feat: Sprint-3 완결 + Sprint-4 착수 (WBS-3.2, 3.4, 5.2)

주요 변경:
- [WBS-3.2] 리밸런싱 V2 신호 가중 목표배분 (signal_weighted_ss001_v1)
  * equal_weight -> SS001_Norm_Score 비례 버킷내 배분
  * 하네스: 삼성(36.84%) > SK하이닉스(29.16%), Core=66.00% PASS
- [WBS-3.4] logDailyAssetHistory_ SpreadsheetApp.getActiveSpreadsheet() -> getSpreadsheet_() 수정
  * run_all 컨텍스트에서 null 반환 방지
- [WBS-5.2] deploy_gas.py 전면 재작성
  * src/gas_adapter_parts/ + src/gas/ 양쪽 소스 탐색
  * gdc_01+gdc_02 -> gas_data_collect.gs 번들링
  * dry-run PASS: 17개 파일 WARN 0건
- src/gas/ 디렉토리 신규 추가 (CLASP 조직화 구조)
- tools/automate_routine.py, download_trading_data.py 신규 추가
- .gitignore: .clasprc.json OAuth 토큰 제외 추가
- ROADMAP_WBS.md: Sprint-3 [x] 완료, Sprint-4 착수 목록 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 16:22:19 +09:00
parent cb4787ca2d
commit 72f8d61244
26 changed files with 22879 additions and 85 deletions
+4
View File
@@ -0,0 +1,4 @@
{
"scriptId": "1xfeBAeeknmnBtSvrIqWXO_2hc3ByeriLUOSuOOB4YxLLHhN3zdnL7tVh",
"rootDir": "Temp\\gas_deploy"
}
+3
View File
@@ -25,5 +25,8 @@ __pycache__/
# Node
node_modules/
# Google OAuth 토큰 (절대 커밋 금지)
.clasprc.json
# Claude 세션 캐시 (자동메모리 제외)
.claude/projects/
+24 -12
View File
@@ -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 로그인 필요) |
**성공 하네스 (데이터 기준)**:
```
@@ -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)
[ ] WBS-5.3: 타이머 트리거 설정 (GAS 트리거 일일 자율 실행)
```
---
+8
View File
@@ -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
+907
View File
@@ -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(/<tr[^>]*data-testid="data-table-v2-row"[\s\S]*?<\/tr>/gi);
if (!trMatches || !trMatches.length) {
Logger.log('[Yahoo Fallback] table row 없음');
return [];
}
const events = [];
const dateStr = Utilities.formatDate(dateHint, CFG.TIME_ZONE, CFG.DATE_FORMAT);
for (let i = 0; i < trMatches.length; i++) {
const trHtml = trMatches[i];
const getCell_ = (testId) => {
const regex = new RegExp(`data-testid-cell=["']${testId}["'][^>]*>([\\s\\S]*?)</td>`, 'i');
const m = trHtml.match(regex);
if (m) {
let val = m[1].replace(/<[^>]+>/g, ' ');
val = val.replace(/\s+/g, ' ').trim();
return val;
}
return '';
};
const eventName = getCell_('econ_release');
const country = getCell_('country_code');
const actual = getCell_('after_release_actual');
const estimate = getCell_('consensus_estimate');
const prior = getCell_('prior_release_actual');
if (!eventName) continue;
let rawImpact = 'LOW';
if (trHtml.toLowerCase().includes('high') || trHtml.toLowerCase().includes('red') || trHtml.toLowerCase().includes('priority-3')) {
rawImpact = 'HIGH';
} else if (trHtml.toLowerCase().includes('medium') || trHtml.toLowerCase().includes('orange') || trHtml.toLowerCase().includes('priority-2')) {
rawImpact = 'MEDIUM';
}
const type = guessEventType_(eventName, country);
const finalImpact = guessImpact_(type, eventName) || rawImpact;
const countryUpper = String(country || '').toUpperCase().trim();
const allowedCountries = ['US', 'KR', 'JP'];
if (!allowedCountries.includes(countryUpper)) {
continue;
}
if (type === 'CUSTOM' && finalImpact === 'LOW') {
continue;
}
let alertText = '';
if (actual && actual !== '-') alertText += `Act: ${actual} `;
if (estimate && estimate !== '-') alertText += `Est: ${estimate} `;
if (prior && prior !== '-') alertText += `Prev: ${prior}`;
alertText = alertText.trim();
events.push({
Date: dateStr,
Event: eventName,
Type: type,
Impact: finalImpact,
Alert: alertText,
Source: 'Yahoo Finance',
SourceUrl: 'https://finance.yahoo.com/calendar/economic',
});
}
return events;
}
function parseTradingEconomicsHtml_(html) {
// data-event가 들어있는 모든 tr 매칭
const trMatches = html.match(/<tr[^>]*data-event="[^"]*"[\s\S]*?<\/tr>/gi);
if (!trMatches) {
Logger.log('[TradingEconomics] event table row 없음');
return [];
}
const events = [];
for (let i = 0; i < trMatches.length; i++) {
const trHtml = trMatches[i];
// td들로 쪼개기
const tdMatches = trHtml.match(/<td[^>]*>([\s\S]*?)<\/td>/gi);
if (!tdMatches || tdMatches.length < 9) continue;
// 1) 날짜 추출 (td[0]의 class 속성)
const td0 = tdMatches[0];
const dateMatch = td0.match(/class=["'](\d{4}-\d{2}-\d{2})["']/i);
if (!dateMatch) continue;
const dateStr = dateMatch[1];
// 2) 중요도 추출 (td[0] 내부의 calendar-date-N 클래스)
let impact = 'LOW';
if (td0.includes('calendar-date-3')) {
impact = 'HIGH';
} else if (td0.includes('calendar-date-2')) {
impact = 'MEDIUM';
}
// 3) 국가 코드 추출 (td[3] 내부 텍스트)
const td3 = tdMatches[3];
const countryIsoMatch = td3.match(/>([^<]+)</);
const countryIso = countryIsoMatch ? countryIsoMatch[1].trim().toUpperCase() : '';
// 4) 이벤트 이름 추출 (td[4] 내부의 calendar-event 링크 텍스트)
const td4 = tdMatches[4];
const eventMatch = td4.match(/class=["']calendar-event["'][^>]*>([^<]+)/i);
if (!eventMatch) continue;
const eventName = eventMatch[1].trim();
// 5) Actual, Previous, Consensus 값 추출 (HTML 태그 제거 및 공백 정규화)
const cleanTdText = (tdHtml) => {
let val = tdHtml.replace(/<[^>]+>/g, ' ');
val = val.replace(/\s+/g, ' ').trim();
return val;
};
const actualVal = cleanTdText(tdMatches[5]);
const previousVal = cleanTdText(tdMatches[6]);
const consensusVal = cleanTdText(tdMatches[7]);
// 6) 국가 필터링 (US, KR, JP만 수집)
const allowedCountries = ['US', 'KR', 'JP'];
if (!allowedCountries.includes(countryIso)) {
continue;
}
const type = guessEventType_(eventName, countryIso);
const finalImpact = guessImpact_(type, eventName) || impact;
// 중요도가 LOW이면서 핵심 분류 유형(FOMC 등)이 아닌 일반 CUSTOM 데이터는 제외
if (type === 'CUSTOM' && finalImpact === 'LOW') {
continue;
}
// ──────────────────────────
let alertText = '';
if (actualVal && actualVal !== '-') alertText += `Act: ${actualVal} `;
if (consensusVal && consensusVal !== '-') alertText += `Est: ${consensusVal} `;
if (previousVal && previousVal !== '-') alertText += `Prev: ${previousVal}`;
alertText = alertText.trim();
events.push({
Date: dateStr,
Event: eventName,
Type: type,
Impact: finalImpact,
Alert: alertText,
Source: 'Trading Economics',
SourceUrl: 'https://tradingeconomics.com/calendar',
});
}
return events;
}
/* ═══════════════════════════════════════════════════════════════════════════ *
* Naver Finance 스크래퍼
*
* 1) 경제지표 일정: https://finance.naver.com/market/news/economic.naver
* → 날짜·제목이 포함된 뉴스 목록 테이블 파싱
*
* 2) 실적 발표: https://finance.naver.com/market/news/announce.naver
* → 기업 실적 발표 일정 테이블 파싱
*
* 블록킹 대응:
* - Referer: https://finance.naver.com/ 필수
* - Accept-Language: ko-KR 설정
* - 429 → fetchWithCache_ 재시도·stale 자동 처리
* ══════════════════════════════════════════════════════════════════════════ */
function refreshFromNaver() {
const events = fetchNaverCalendar_();
if (!events.length) { toast_('Naver: 수집된 이벤트 없음 (차단 또는 일정 없음)', 4); return; }
upsertEvents_(events);
toast_(`Naver Finance 갱신: ${events.length}건`, 4);
}
function fetchNaverCalendar_() {
const headers = {
'User-Agent': CFG.CHROME_UA,
'Referer': 'https://finance.naver.com/',
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
};
const events = [];
// 1. 경제 속보 뉴스 리스트 긁기 (EUC-KR 인코딩)
try {
const url = 'https://finance.naver.com/news/news_list.naver?mode=LSS2D&section_id=101&section_id2=258';
const html = fetchWithCache_(url, CFG.CACHE_TTL_SEC, headers, 'EUC-KR');
if (html) events.push(...parseNaverNewsList_(html, 'KR', 'Naver 뉴스 속보', url));
} catch(e) { Logger.log('[Naver News List] ' + e.message); }
return events;
}
/**
* Naver Finance 뉴스 목록 HTML에서 기사 제목과 발행일을 추출.
*/
function parseNaverNewsList_(html, region, sourceName, sourceUrl) {
const events = [];
// articleSubject 및 wdate 추출용 정규식
const subjectPattern = /<dd class=["']articleSubject["']>[\s\S]*?<a href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
const wdatePattern = /<span class=["']wdate["']>([\s\S]*?)<\/span>/i;
let match;
while ((match = subjectPattern.exec(html)) !== null) {
const link = match[1].trim();
const titleRaw = match[2].trim();
// HTML 태그 제거 및 공백 정규화
const eventName = titleRaw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
if (eventName.length < 5 || /^\d+$/.test(eventName)) continue;
// 이 매치 직후 최대 1000자 범위 내에서 가장 가까운 wdate 매칭
const pos = subjectPattern.lastIndex;
const subHtml = html.substring(pos, pos + 1000);
const dateMatch = wdatePattern.exec(subHtml);
if (dateMatch) {
const dateStrRaw = dateMatch[1].trim();
const dateOnly = dateStrRaw.split(' ')[0]; // YYYY-MM-DD
const eventDate = coerceDate_(dateOnly);
if (eventDate && eventName) {
const type = guessEventType_(eventName, region);
events.push({
Date: eventDate,
Event: eventName,
Type: type,
Impact: guessImpact_(type, eventName),
Alert: '',
Source: sourceName,
SourceUrl: 'https://finance.naver.com' + link,
});
}
}
}
return events;
}
/* ── 외부 URL 소스 (기존 유지) ───────────────────────────────────────────── */
function refreshFromSources() {
const props = PropertiesService.getScriptProperties();
const jsonUrl = props.getProperty(CFG.JSON_SOURCE_PROPERTY);
const csvUrl = props.getProperty(CFG.CSV_SOURCE_PROPERTY);
if (!jsonUrl && !csvUrl) { toast_('외부 URL 없음 — Script Properties 확인', 4); return; }
const events = [];
if (jsonUrl) events.push(...fetchEventsFromJson_(jsonUrl));
if (csvUrl) events.push(...fetchEventsFromCsv_(csvUrl));
if (!events.length) { toast_('외부 소스 이벤트 없음', 3); return; }
upsertEvents_(events);
validateAndSort();
toast_(`외부 이벤트 갱신: ${events.length}건`, 5);
}
function fetchEventsFromJson_(url) {
const text = fetchWithCache_(url);
if (!text) return [];
const parsed = JSON.parse(text);
if (!Array.isArray(parsed)) throw new Error('JSON source는 배열이어야 합니다.');
return parsed.map(normalizeEvent_);
}
function fetchEventsFromCsv_(url) {
const text = fetchWithCache_(url);
if (!text) return [];
const csv = Utilities.parseCsv(text);
if (csv.length < 2) return [];
const headers = csv[0].map(h => String(h || '').trim());
return csv.slice(1).map(row => {
const obj = {};
headers.forEach((h, i) => { obj[h] = row[i]; });
return normalizeEvent_(obj);
});
}
/* ═══════════════════════════════════════════════════════════════════════════ *
* fetchWithCache_ — CacheService + 재시도 + stale fallback
*
* signature: fetchWithCache_(url, ttlSec?, extraHeaders?, encoding?)
* - ttlSec: 캐시 유효기간 (기본 CFG.CACHE_TTL_SEC)
* - extraHeaders: 추가 HTTP 헤더 (스크래핑 시 UA/Referer 주입용)
* - encoding: 응답 문자셋 인코딩 (기본 'UTF-8')
* ══════════════════════════════════════════════════════════════════════════ */
function fetchWithCache_(url, ttlSec, extraHeaders, encoding) {
const cache = CacheService.getScriptCache();
const cacheKey = 'url:' + md5_(url);
// 1. Cache HIT
const hit = cache.get(cacheKey);
if (hit !== null) return hit;
// 2. Fetch with retry
const opts = {
muteHttpExceptions: true,
followRedirects: true,
headers: Object.assign({ 'User-Agent': CFG.CHROME_UA }, extraHeaders || {}),
};
const charset = encoding || 'UTF-8';
for (let attempt = 0; attempt <= CFG.MAX_RETRIES; attempt++) {
if (attempt > 0) Utilities.sleep(CFG.RETRY_BASE_MS * attempt);
let resp;
try { resp = UrlFetchApp.fetch(url, opts); }
catch(e) { Logger.log(`[fetch] 예외 (${attempt}): ${e.message}`); continue; }
const code = resp.getResponseCode();
if (code === 429 || code === 503) { Utilities.sleep(2500 * (attempt+1)); continue; } // 일시 블록
if (code === 403 || code === 401) { Logger.log(`[fetch] ${code} 영구 블록: ${url}`); break; }
if (code < 200 || code >= 300) { Logger.log(`[fetch] HTTP ${code} (${attempt}): ${url}`); continue; }
const text = resp.getContentText(charset);
try {
cache.put(cacheKey, text, ttlSec || CFG.CACHE_TTL_SEC);
} catch(e) {
// 100KB 초과 HTML은 캐싱 크기 제한으로 실패하는 것이 정상이므로 로그 남기지 않고 패스
}
return text;
}
Logger.log(`[fetch] 실패: ${url}`);
return null;
}
/* ── Upsert / Sample ─────────────────────────────────────────────────────── */
function upsertEvents_(events) {
if (!events.length) return;
const sheet = ensureSheetAndHeaders_();
const hmap = getHeaderMap_(sheet);
const rowByKey = {};
getDataObjects_(sheet, hmap).forEach(item => { if (item.Key) rowByKey[item.Key] = item.__row; });
events.forEach(ev => {
const d = coerceDate_(ev.Date);
if (!d || !ev.Event) return;
const type = String(ev.Type || 'CUSTOM').toUpperCase();
const key = ev.Key || buildKey_(d, ev.Event, type);
const vals = { Date:d, Event:ev.Event, Type:type, Impact:String(ev.Impact||'MEDIUM').toUpperCase(),
Alert:ev.Alert||'', Source:ev.Source||'', SourceUrl:ev.SourceUrl||'', Key:key };
if (rowByKey[key]) {
Object.keys(vals).forEach(h => { if (hmap[h]) sheet.getRange(rowByKey[key], hmap[h]).setValue(vals[h]); });
} else {
const row = new Array(sheet.getLastColumn()).fill('');
Object.keys(vals).forEach(h => { if (hmap[h]) row[hmap[h]-1] = vals[h]; });
sheet.appendRow(row);
}
});
}
function loadSampleDataIfEmpty() {
const sheet = ensureSheetAndHeaders_();
if (sheet.getLastRow() > 1) { toast_('이미 데이터 있음 — 삽입 생략', 4); return; }
sheet.getRange(2,1,6,5).setValues([
['2026-06-17','FOMC 금리결정','FOMC','HIGH','금리동결 시 KOSPI +1~2% 기대'],
['2026-07-28','FOMC 금리결정','FOMC','HIGH',''],
['2026-06-11','미국 CPI (5월)','US_CPI','HIGH','예상치 상회 시 당일 신규매수 자제'],
['2026-07-15','미국 CPI (6월)','US_CPI','HIGH','FOMC 전 마지막 CPI'],
['2026-06-20','삼성전자 1Q 잠정실적','EARNINGS','HIGH','반도체 섹터 선행 지표'],
['2026-06-15','옵션만기일','EXPIRY','MEDIUM','변동성 확대 구간 주의'],
]);
validateAndSort();
}
/* ── 트리거 ──────────────────────────────────────────────────────────────── */
function createDailyTrigger() {
const fn = 'runDaily';
ScriptApp.getProjectTriggers().filter(t => t.getHandlerFunction() === fn).forEach(t => ScriptApp.deleteTrigger(t));
ScriptApp.newTrigger(fn).timeBased().everyDays(1).atHour(8).create();
toast_('매일 오전 8시 트리거 설치 완료', 4);
}
function deleteProjectTriggers() {
ScriptApp.getProjectTriggers().forEach(t => ScriptApp.deleteTrigger(t));
toast_('트리거 삭제 완료', 4);
}
function setJsonSourceUrl() { _saveUrlProp_(CFG.JSON_SOURCE_PROPERTY, 'EVENT_JSON_URL'); }
function setCsvSourceUrl() { _saveUrlProp_(CFG.CSV_SOURCE_PROPERTY, 'EVENT_CSV_URL'); }
function _saveUrlProp_(k, label) {
const v = Browser.inputBox(label + '를 입력하세요.');
if (v && v !== 'cancel') PropertiesService.getScriptProperties().setProperty(k, v);
}
/* ── 이벤트 타입·임팩트 추론 헬퍼 ───────────────────────────────────────── */
/**
* 이벤트 이름으로 타입을 추론.
* region: 'US' | 'KR' (기본 'US')
*/
const TYPE_MAP_ = [
{ keys: ['FOMC','연준','Federal Open Market','Fed Rate'], type: 'FOMC' },
{ keys: ['CPI','소비자물가','Consumer Price'], type: null }, // region 분기
{ keys: ['PPI','생산자물가','Producer Price'], type: 'US_PPI' },
{ keys: ['PCE','개인소비지출','Personal Consumption'], type: 'US_PCE' },
{ keys: ['NFP','비농업','Nonfarm','Payroll'], type: 'US_NFP' },
{ keys: ['실적','잠정실적','Earnings','EPS','Revenue'], type: 'EARNINGS' },
{ keys: ['옵션만기','선물만기','만기일','Expiry','Triple Witching'], type: 'EXPIRY' },
{ keys: ['한국은행','금통위','BOK','Bank of Korea'], type: 'BOK' },
{ keys: ['환율','FX','Dollar','달러'], type: 'FX' },
{ keys: ['국채','채권','Bond','Treasury'], type: 'BOND' },
{ keys: ['BOJ','일본은행','Bank of Japan','BOJ Rate','BOJ Interest'], type: 'BOJ' },
];
function guessEventType_(eventName, region) {
const upper = String(eventName || '').toUpperCase();
const reg = String(region || '').toUpperCase().trim();
for (const rule of TYPE_MAP_) {
if (rule.keys.some(k => upper.includes(k.toUpperCase()))) {
if (rule.type === null) {
// CPI 분기: 한국 CPI vs 미국 CPI (타국 CPI는 CUSTOM 처리하여 오인 방지)
if (reg === 'KR' || upper.includes('한국') || upper.includes('KR')) return 'KR_CPI';
if (reg === 'US' || upper.includes('미국') || upper.includes('US')) return 'US_CPI';
return 'CUSTOM';
}
// PPI, PCE, NFP, FOMC 등 미국 전용 타입들은 국가 코드가 US인 경우에만 해당 타입 할당, 타국은 CUSTOM 처리
const usOnlyTypes = ['US_PPI', 'US_PCE', 'US_NFP', 'FOMC'];
if (usOnlyTypes.includes(rule.type) && reg !== 'US' && reg !== '') {
return 'CUSTOM';
}
// BOJ 일본은행 전용 타입은 국가 코드가 JP인 경우에만 해당 타입 할당, 타국은 CUSTOM 처리
if (rule.type === 'BOJ' && reg !== 'JP' && reg !== '') {
return 'CUSTOM';
}
return rule.type;
}
}
return 'CUSTOM';
}
/** 타입 기반 기본 임팩트 */
function guessImpact_(type, eventName) {
const highTypes = ['FOMC','US_CPI','US_NFP','BOK','KR_CPI','BOJ'];
const medTypes = ['US_PPI','US_PCE','EARNINGS','EXPIRY'];
if (highTypes.includes(type)) return 'HIGH';
if (medTypes.includes(type)) return 'MEDIUM';
return 'LOW';
}
/* ── 내부 헬퍼 (compact) ─────────────────────────────────────────────────── */
function safeGet_(obj, keys) {
return keys.reduce((o, k) => (o && o[k] !== undefined ? o[k] : null), obj);
}
function getSpreadsheet_() {
return CFG.SPREADSHEET_ID ? SpreadsheetApp.openById(CFG.SPREADSHEET_ID) : SpreadsheetApp.getActiveSpreadsheet();
}
function ensureSheetAndHeaders_() {
const ss = getSpreadsheet_();
const sheet = ss.getSheetByName(CFG.SHEET_NAME) || ss.insertSheet(CFG.SHEET_NAME);
const lastCol = Math.max(sheet.getLastColumn(), 1);
const existing = sheet.getRange(1,1,1,lastCol).getValues()[0].map(h => String(h||'').trim());
if (!existing.some(Boolean)) {
sheet.getRange(1,1,1,CFG.ALL_HEADERS.length).setValues([CFG.ALL_HEADERS]);
return sheet;
}
const missing = CFG.ALL_HEADERS.filter(h => !existing.includes(h));
if (missing.length) sheet.getRange(1, sheet.getLastColumn()+1, 1, missing.length).setValues([missing]);
const hmap = getHeaderMap_(sheet);
CFG.REQUIRED_HEADERS.forEach(h => { if (!hmap[h]) throw new Error(`필수 헤더 없음: ${h}`); });
return sheet;
}
function getHeaderMap_(sheet) {
const map = {};
sheet.getRange(1,1,1,sheet.getLastColumn()).getValues()[0]
.forEach((h,i) => { const k=String(h||'').trim(); if(k) map[k]=i+1; });
return map;
}
function getDataObjects_(sheet, hmap) {
const lastRow = sheet.getLastRow();
if (lastRow < 2) return [];
const headers = Object.keys(hmap);
const lastCol = sheet.getLastColumn();
return sheet.getRange(2,1,lastRow-1,lastCol).getValues().map((row,r) => {
const obj = { __row: r+2 };
headers.forEach(h => { obj[h] = row[hmap[h]-1]; });
return obj;
});
}
function normalizeEvent_(obj) {
return {
Date: obj.Date || obj.date,
Event: obj.Event || obj.event || obj.title || obj.name,
Type: obj.Type || obj.type || 'CUSTOM',
Impact: obj.Impact || obj.impact || 'MEDIUM',
Alert: obj.Alert || obj.alert || '',
Source: obj.Source || obj.source || '',
SourceUrl: obj.SourceUrl || obj.sourceUrl || obj.url || '',
Key: obj.Key || obj.key || '',
};
}
function coerceDate_(v) {
if (v instanceof Date && !isNaN(v)) return new Date(v.getFullYear(), v.getMonth(), v.getDate());
if (typeof v === 'string') {
const m = v.trim().match(/^(\d{4})[-./](\d{1,2})[-./](\d{1,2})/);
if (m) return new Date(+m[1], +m[2]-1, +m[3]);
}
return null;
}
function todayKst_() {
return coerceDate_(Utilities.formatDate(new Date(), CFG.TIME_ZONE, CFG.DATE_FORMAT));
}
function daysBetween_(a, b) {
return Math.round(
(new Date(b.getFullYear(),b.getMonth(),b.getDate()) -
new Date(a.getFullYear(),a.getMonth(),a.getDate())) / 86400000
);
}
function buildKey_(dateObj, eventName, type) {
return md5_([Utilities.formatDate(dateObj,CFG.TIME_ZONE,CFG.DATE_FORMAT),
String(type||'').toUpperCase(), String(eventName||'').trim()].join('|'));
}
function md5_(text) {
return Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, text, Utilities.Charset.UTF_8)
.map(b => ('0'+(b<0?b+256:b).toString(16)).slice(-2)).join('');
}
function buildEmailBody_(events) {
const fmt = d => d instanceof Date ? Utilities.formatDate(d,CFG.TIME_ZONE,CFG.DATE_FORMAT) : String(d);
return [
'시장 이벤트 임박 알림','',
'기준: '+Utilities.formatDate(new Date(),CFG.TIME_ZONE,'yyyy-MM-dd HH:mm:ss'),'',
...events.flatMap((item,i) => [
`${i+1}. [${item.Impact}] ${fmt(item.Date)} / D-${item.DaysLeft}`,
` Event: ${item.Event}`, ` Type: ${item.Type}`,
...(item.Alert?[` Alert: ${item.Alert}`]:[]),'',
]),
'이 알림은 자동 알림이며 투자 판단의 최종 근거가 아닙니다.',
].join('\n');
}
function applyFormatting_(sheet, hmap) {
const lastRow = Math.max(sheet.getLastRow(),1), lastCol = Math.max(sheet.getLastColumn(),1);
sheet.getRange(1,1,1,lastCol).setFontWeight('bold');
sheet.setFrozenRows(1);
for (let c=1;c<=lastCol;c++) sheet.autoResizeColumn(c);
if (lastRow >= 2) {
if (hmap.Impact) sheet.getRange(2,hmap.Impact, lastRow-1,1).setFontWeight('bold');
if (hmap.DaysLeft) sheet.getRange(2,hmap.DaysLeft,lastRow-1,1).setNumberFormat('0');
}
}
function toast_(msg, sec) {
try {
const activeSs = SpreadsheetApp.getActive();
if (activeSs) {
activeSs.toast(msg, 'Market Calendar', sec);
} else {
Logger.log('[TOAST] ' + msg);
}
} catch (e) {
Logger.log('[TOAST] ' + msg);
}
}
/**
* 용량을 극도로 많이 소모하는 Script Properties의 캐시성 데이터(stale_url, cal_parsed 등)를 청소.
* SPREADSHEET_ID 나 sf_w2_ranks_json 같은 중요 설정/운영 데이터는 보호합니다.
*/
function cleanUpProperties() {
const props = PropertiesService.getScriptProperties();
const keys = props.getKeys();
let deleteCount = 0;
// SPREADSHEET_ID, sf_w2_ranks_json, EVENT_JSON_URL, EVENT_CSV_URL, HARNESS_VERBOSE_LOG 등 설정은 제외
const protectedKeys = ['SPREADSHEET_ID', 'sf_w2_ranks_json', 'EVENT_JSON_URL', 'EVENT_CSV_URL', 'HARNESS_VERBOSE_LOG'];
keys.forEach(k => {
if (protectedKeys.includes(k)) {
return;
}
// 캐시 관련 접두사를 가진 항목 및 임시 런타임 상태값 삭제
const shouldDelete =
k.indexOf('stale_url:') === 0 ||
k.indexOf('yahoo_cal_parsed:') === 0 ||
k.indexOf('te_cal_parsed:') === 0 ||
k.indexOf('url:') === 0 ||
k.indexOf('fetch_budget_') === 0 ||
k.indexOf('fetch_fail_') === 0 ||
k.indexOf('fetch_circuit_') === 0 ||
k.indexOf('fetch_session_') === 0 ||
k.indexOf('cs_') === 0;
if (shouldDelete) {
props.deleteProperty(k);
deleteCount++;
}
});
toast_(`프로퍼티 캐시 청소 완료: ${deleteCount}건 삭제`, 5);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
{
"timeZone": "Asia/Seoul",
"dependencies": {
},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8"
}
+2
View File
@@ -0,0 +1,2 @@
// Split parts for gas_data_feed.gs
// Moved to Python canonical engine as per QEDD methodology.
+705
View File
@@ -0,0 +1,705 @@
// Consolidated runtime core: macro flow + macro calc + consistency
// ---- from gas_apex_macro_flow.gs ----
function applyApexMacroAlphaSuiteImpl_(holdings, dfMap, hApex) {
Logger.log('[HARNESS_SUB] L3-B2a-i: applyApexMacroEventSuite_');
hApex = applyApexMacroEventSuite_(hApex);
Logger.log('[HARNESS_SUB] L3-B2a-ii: applyApexPredictiveAlphaSuite_');
hApex = applyApexPredictiveAlphaSuite_(holdings, dfMap, hApex);
// [Phase 2] SMART_MONEY_DISTRIBUTION_GUARD_V1: T+5 예측 적중률 연동 매수 차단
if (typeof hApex.prediction_accuracy_rate === 'number' && hApex.prediction_accuracy_rate < 50) {
Logger.log('[HARNESS_SUB] Phase 2: prediction_accuracy_rate < 50% (' + hApex.prediction_accuracy_rate + '%). 신규 매수 전면 차단.');
hApex.global_buy_allowed = false;
(hApex.buy_permission_json || []).forEach(function(bp) {
if (bp.buy_permission_state !== 'BLOCKED') {
bp.buy_permission_state = 'BLOCKED';
bp.block_reason = (bp.block_reason ? bp.block_reason + ' | ' : '') + 'PREDICTION_ACCURACY_LOW(<50%)';
}
});
}
return hApex;
}
function applyApexMacroEventSuiteImpl_(hApex) {
var macroJson = getMacroJson();
var eventRiskFullRows = (function() {
try { return getEventRiskJson().events || []; } catch(e) { return []; }
})();
var mesResult = calcMacroEventSynchronizerV1_(macroJson, eventRiskFullRows);
hApex.macro_event_json = mesResult;
hApex.macro_risk_score = mesResult.macro_risk_score;
hApex.macro_risk_regime = mesResult.macro_risk_regime;
hApex.mega_sell_alert = mesResult.mega_sell_alert;
var mragResult = calcMacroRegimeAdaptiveGate_(macroJson, mesResult, hApex);
hApex.mrag_v2_json = mragResult;
if (mesResult.heat_gate_adj && mesResult.heat_gate_adj !== 0) {
var me1Threshold = (hApex.heat_gate_threshold_pct || 12) + mesResult.heat_gate_adj;
hApex.effective_heat_gate_threshold = Math.min(me1Threshold, mragResult.effective_heat_gate_threshold);
} else {
hApex.effective_heat_gate_threshold = mragResult.effective_heat_gate_threshold;
}
hApex.effective_position_size_scale = mragResult.effective_position_size_scale;
if (mragResult.stale_events_count > 0) {
hApex.stale_events_alert = mragResult.stale_events;
}
var fomcDaysRem = mesResult.fomc_days_remaining;
var usCpiDaysRem = mesResult.us_cpi_days_remaining;
var ipoDaysRem = mesResult.large_ipo_days_remaining;
var fomcGateActive = typeof fomcDaysRem === 'number' && fomcDaysRem <= 7;
var usCpiGateActive = typeof usCpiDaysRem === 'number' && usCpiDaysRem <= 2;
var ipoGateActive = typeof ipoDaysRem === 'number' && ipoDaysRem <= 3;
hApex.fomc_position_size_gate = fomcGateActive ? 'ACTIVE' : 'INACTIVE';
hApex.us_cpi_position_size_gate = usCpiGateActive ? 'ACTIVE' : 'INACTIVE';
hApex.ipo_position_size_gate = ipoGateActive ? 'ACTIVE' : 'INACTIVE';
if (fomcGateActive) {
(hApex.buy_permission_json || []).forEach(function(bp) {
bp.fomc_size_limit = 0.5;
bp.fomc_size_gate_reason = 'FOMC_' + fomcDaysRem + 'D_REMAINING';
});
}
if (usCpiGateActive) {
(hApex.buy_permission_json || []).forEach(function(bp) {
bp.us_cpi_size_limit = 0.5;
bp.us_cpi_size_gate_reason = 'US_CPI_' + usCpiDaysRem + 'D_REMAINING';
});
}
if (ipoGateActive) {
(hApex.buy_permission_json || []).forEach(function(bp) {
bp.ipo_size_limit = 0.7;
bp.ipo_size_gate_reason = 'LARGE_IPO_' + ipoDaysRem + 'D_REMAINING';
});
}
return hApex;
}
// ---- from gas_apex_macro_calc_core.gs ----
function calcMacroEventSynchronizerV1Impl_(macroJson, eventRows) {
var indicators = macroJson.indicators || [];
var byName = {};
indicators.forEach(function(m) { byName[m.Name] = m; });
var usdKrw = typeof macroJson.usd_krw === 'number' ? macroJson.usd_krw : 0;
var vix = typeof macroJson.vix === 'number' ? macroJson.vix : 0;
var sp500Ret5d = typeof macroJson.sp500_ret5d === 'number' ? macroJson.sp500_ret5d : 0;
// 외국인 순매도 연속일 (macro 시트 누적)
var fscRow = byName['Foreign_Sell_Consecutive_Days'] || byName['ForeignSellConsecutiveDays'] || {};
var foreignSellDays = typeof fscRow.Close === 'number' ? Math.round(fscRow.Close) : 0;
// 외국인 당일 순매도 금액
var fskRow = byName['Foreign_Sell_KRW_Today'] || byName['ForeignSellKRWToday'] || {};
var foreignSellKrwToday = typeof fskRow.Close === 'number' ? fskRow.Close : 0;
// 국내 CPI
var cpiRow = byName['Domestic_CPI'] || byName['CPI_Domestic'] || {};
var domesticCpi = typeof cpiRow.Close === 'number' ? cpiRow.Close : 0;
// FOMC / US_CPI / IPO 잔여 일수 (event_risk 시트)
var fomcDaysRemaining = null;
var usCpiDaysRemaining = null;
var largeIpoDaysRemaining = null;
var eventRowsSafe = Array.isArray(eventRows) ? eventRows : [];
function _nearestDays(typeStr) {
var list = eventRowsSafe.filter(function(e) {
var t = (e.Type || e.type || '').toUpperCase();
var d = typeof e.DaysLeft === 'number' ? e.DaysLeft : (typeof e.daysLeft === 'number' ? e.daysLeft : -1);
return t === typeStr && d >= 0;
});
if (!list.length) return null;
list.sort(function(a, b) {
return (a.DaysLeft || a.daysLeft || 999) - (b.DaysLeft || b.daysLeft || 999);
});
return list[0].DaysLeft || list[0].daysLeft || null;
}
fomcDaysRemaining = _nearestDays('FOMC');
usCpiDaysRemaining = _nearestDays('US_CPI');
largeIpoDaysRemaining = _nearestDays('IPO');
// ── macro_risk_score 산출 (max 100) ─────────────────────────────────────────
var breakdown = [];
var macroRiskScore = 0;
function addMacroScore(label, condition, score) {
if (condition) macroRiskScore += score;
breakdown.push({ factor: label, score: condition ? score : 0, triggered: !!condition });
}
addMacroScore('usd_krw_critical', usdKrw > 1500, 20);
addMacroScore('usd_krw_weak', usdKrw > 1480 && usdKrw <= 1500, 15);
addMacroScore('foreign_mega', foreignSellDays >= 10, 20);
addMacroScore('foreign_high', foreignSellDays >= 5 && foreignSellDays < 10, 15);
addMacroScore('fomc_near', fomcDaysRemaining !== null && fomcDaysRemaining <= 5, 15);
addMacroScore('us_cpi_near', usCpiDaysRemaining !== null && usCpiDaysRemaining <= 2, 10);
addMacroScore('cpi_high', domesticCpi > 2.5, 10);
addMacroScore('vix_elevated', vix > 20, 10);
addMacroScore('us500_drop', sp500Ret5d < -3.0, 10);
macroRiskScore = Math.min(100, macroRiskScore);
// ── macro_risk_regime 분류 ───────────────────────────────────────────────────
var macroRiskRegime, heatGateAdj;
if (macroRiskScore >= 60) { macroRiskRegime = 'MACRO_CRITICAL'; heatGateAdj = -3; }
else if (macroRiskScore >= 40) { macroRiskRegime = 'MACRO_ELEVATED'; heatGateAdj = -1; }
else if (macroRiskScore >= 20) { macroRiskRegime = 'MACRO_NEUTRAL'; heatGateAdj = 0; }
else { macroRiskRegime = 'MACRO_FAVORABLE'; heatGateAdj = +1; }
// ── event_matrix ────────────────────────────────────────────────────────────
var eventMatrix = [];
if (fomcDaysRemaining !== null && fomcDaysRemaining <= 7) {
eventMatrix.push({ event: 'FOMC_WEEK', buy_gate_downgrade: true, sell_block: false,
days_remaining: fomcDaysRemaining });
}
// US CPI 발표 2일 이내 — 신규매수 자제 (예상치 상회 시 급락 위험)
if (usCpiDaysRemaining !== null && usCpiDaysRemaining <= 2) {
eventMatrix.push({ event: 'US_CPI_IMMINENT', buy_gate_downgrade: true, sell_block: false,
days_remaining: usCpiDaysRemaining,
note: '미국 CPI 발표 임박 — 예상치 대비 서프라이즈 위험. 신규매수 자제' });
}
// 대형 IPO 5일 이내 — 공모자금 쏠림으로 시장 유동성 흡수 주의
if (largeIpoDaysRemaining !== null && largeIpoDaysRemaining <= 5) {
eventMatrix.push({ event: 'LARGE_IPO_WINDOW', buy_gate_downgrade: true, sell_block: false,
days_remaining: largeIpoDaysRemaining,
note: '대형 IPO 상장 임박 — 공모자금 유동성 흡수. 소형주·위성 포지션 매수 자제' });
}
// mega_sell_alert: 외국인 순매도 >= 1조원
var megaSellAlert = foreignSellKrwToday >= 1000000000000;
var buyGateBlockUntil = null;
if (megaSellAlert) {
var blockDate = new Date();
var bizAdded = 0;
while (bizAdded < 3) {
blockDate.setDate(blockDate.getDate() + 1);
var wd = blockDate.getDay();
if (wd !== 0 && wd !== 6) bizAdded++;
}
buyGateBlockUntil = Utilities.formatDate(blockDate, 'Asia/Seoul', 'yyyy-MM-dd');
eventMatrix.push({ event: 'MEGA_SELL_ALERT', foreign_sell_krw: foreignSellKrwToday,
buy_gate_block_until: buyGateBlockUntil });
}
return {
macro_risk_score: macroRiskScore,
macro_risk_regime: macroRiskRegime,
macro_risk_breakdown: breakdown,
foreign_sell_consecutive_days: foreignSellDays,
foreign_sell_krw_today: foreignSellKrwToday,
mega_sell_alert: megaSellAlert,
buy_gate_block_until: buyGateBlockUntil,
effective_heat_gate_adjustment: heatGateAdj,
heat_gate_adj: heatGateAdj,
fomc_days_remaining: fomcDaysRemaining,
us_cpi_days_remaining: usCpiDaysRemaining,
large_ipo_days_remaining: largeIpoDaysRemaining,
event_matrix: eventMatrix,
formula_id: 'MACRO_EVENT_SYNCHRONIZER_V1'
};
}
function calcMacroRegimeAdaptiveGateV2Impl_(macroJson, mesResult, hApex) {
var macro = macroJson || {};
var mes = mesResult || {};
// ── LAYER_1: 미시 리스크 (Market Internals, 0~25) ──────────────────
var l1 = 0;
var vkospi = toNumber_(macro['vkospi'] || macro.vkospi) || 0;
var mrsScoreL1 = toNumber_(macro['mrs_score'] || macro.mrs_score || (hApex && hApex.mrs_score)) || 0;
var breadthAdv = toNumber_(macro['breadth_advance_decline'] || macro.breadth_advance_decline) || 0;
if (breadthAdv > 0 && breadthAdv < 0.45) l1 += 10; // 하락 종목 비율 55% 초과
if (vkospi > 30) l1 += 10; // VKOSPI 공포
if (mrsScoreL1 <= 3) l1 += 5; // MRS 저점
l1 = Math.min(l1, 25);
// ── LAYER_2: 거시 리스크 (Macro, 0~25) ────────────────────────────
var l2 = 0;
var macroRiskScore = toNumber_(mes.macro_risk_score) || 0;
l2 = Math.min(25, Math.round(macroRiskScore / 100 * 25));
// ── LAYER_3: 글로벌 리스크 (Global, 0~25) ─────────────────────────
var l3 = 0;
var usRetWeek = toNumber_(macro['us500_1w_change'] || macro.us500_1w_change) || 0;
var vix = toNumber_(macro['vix'] || macro.vix) || 0;
var globalOvrd = String(macro['global_risk_override'] || '').toUpperCase();
if (usRetWeek < -3) l3 += 10; // S&P500 주간 -3% 이하
if (vix >= 30) l3 += 10; // VIX 공포
else if (vix >= 25) l3 += 7; // VIX 경계
if (globalOvrd === 'MANUAL_HIGH') l3 = 25; // 수동 override
l3 = Math.min(l3, 25);
// ── LAYER_4: 이벤트 리스크 (Event, 0~25) ──────────────────────────
var l4 = 0;
var fomcDays = typeof mes.fomc_days_remaining === 'number' ? mes.fomc_days_remaining : 99;
var usCpiDays = typeof mes.us_cpi_days_remaining === 'number' ? mes.us_cpi_days_remaining : 99;
var largeIpoDays = typeof mes.large_ipo_days_remaining === 'number' ? mes.large_ipo_days_remaining : 99;
var megaSell = mes.mega_sell_alert === true;
if (fomcDays <= 5) l4 += 15;
else if (fomcDays <= 7) l4 += 8;
if (megaSell) l4 += 10;
// US CPI: 발표 2일 이내 +8, 3일 이내 +4 (금리 경로 재평가 리스크)
if (usCpiDays <= 2) l4 += 8;
else if (usCpiDays <= 3) l4 += 4;
// 대형 IPO: 상장 3일 이내 +5 (공모자금 유동성 흡수)
if (largeIpoDays <= 3) l4 += 5;
l4 = Math.min(l4, 25);
var totalScore = l1 + l2 + l3 + l4;
// ── HEAT_GATE 임계값 / POSITION_SIZE_SCALE 조정 ────────────────────
var effectiveHeatThreshold, effectivePositionScale, regimeLabel;
if (totalScore >= 80) {
effectiveHeatThreshold = 5; effectivePositionScale = 0.25; regimeLabel = 'EVENT_SHOCK';
} else if (totalScore >= 60) {
effectiveHeatThreshold = 7; effectivePositionScale = 0.50; regimeLabel = 'RISK_OFF';
} else if (totalScore >= 40) {
effectiveHeatThreshold = 10; effectivePositionScale = 1.00; regimeLabel = 'NEUTRAL';
} else {
effectiveHeatThreshold = 12; effectivePositionScale = 1.10; regimeLabel = 'RISK_ON';
}
// ── 이벤트 날짜 검증 (STALE_EVENT 탐지) ────────────────────────────
var eventDateResults = [];
var staleEvents = [];
var analysisDate = new Date();
(mes.events_used || []).forEach(function(ev) {
if (!ev || !ev.event_date) return;
var evDate = new Date(ev.event_date);
var valid = evDate >= analysisDate;
var r = { event_type: ev.event_type || 'UNKNOWN', event_date: ev.event_date, valid: valid,
status: valid ? 'VALID' : 'STALE_EVENT' };
if (!valid) staleEvents.push(r);
eventDateResults.push(r);
});
return {
micro_risk_score: l1,
macro_risk_score_normalized: l2,
global_risk_score: l3,
event_risk_score: l4,
total_mrag_score: totalScore,
effective_heat_gate_threshold: effectiveHeatThreshold,
effective_position_size_scale: effectivePositionScale,
regime_label: regimeLabel,
event_date_validation_results: eventDateResults,
stale_events: staleEvents,
stale_events_count: staleEvents.length,
formula_id: 'MACRO_REGIME_ADAPTIVE_GATE_V2'
};
}
// ---- from gas_apex_consistency_core.gs ----
function calcConsistencyValidatorV2Impl_(hApex, asResult, cashFloorInfo, capturedAtIso, now) {
var passed = [], failed = [], gapList = [];
function chk(id, name, testFn) {
try {
var r = testFn();
if (r.ok) {
passed.push(id);
} else {
failed.push({ check_id: id, name: name, reason: r.reason || 'failed' });
if (r.gaps) r.gaps.forEach(function(g) { gapList.push(g); });
}
} catch(e) {
failed.push({ check_id: id, name: name, reason: 'exception:' + e.message });
}
}
// CV_01: sell_candidates tier 비감소
chk('CV_01', 'sell_priority 방향 일관성', function() {
var cands = hApex.sell_candidates_json || [];
for (var i = 1; i < cands.length; i++) {
var ta = cands[i-1].tier, tb = cands[i].tier;
if (typeof ta === 'number' && typeof tb === 'number' && tb < ta) {
return { ok: false, reason: 'tier_reversal idx=' + i + '(' + tb + '<' + ta + ')' };
}
}
return { ok: true };
});
// CV_02: stop < close < tp1 (< tp2)
chk('CV_02', '가격 순서 검증', function() {
var prices = hApex.prices_json || [];
for (var i = 0; i < prices.length; i++) {
var p = prices[i];
var stop = p.stop_price || 0, curr = p.current_price || p.close || 0, tp1 = p.tp1_price || 0;
if (stop > 0 && curr > 0 && stop >= curr) {
return { ok: false, reason: p.ticker + ':stop(' + stop + ')>=close(' + curr + ')' };
}
if (curr > 0 && tp1 > 0 && curr >= tp1) {
return { ok: false, reason: p.ticker + ':close(' + curr + ')>=tp1(' + tp1 + ')' };
}
}
return { ok: true };
});
// CV_03: heat vs weight 비례성 (구조 확인용)
chk('CV_03', 'heat vs 보유 비중 일치', function() {
var holdings = asResult.holdings || [];
// heat_pct는 손실위험 기준, weight_pct는 평가비중 — 직접 비교 불가
// 보유 종목 존재 확인 (구조 레벨 검증)
if (holdings.length > 0 && !hApex.execution_quality_json) {
return { ok: false, reason: 'execution_quality_json 없음 (보유종목 있음)' };
}
return { ok: true };
});
// CV_04: enum 유효성 (synthesis_verdict, rs_verdict)
chk('CV_04', 'enum 값 유효성', function() {
var VALID_SYNTH = ['STRONG_BUY_SIGNAL','MODERATE_BUY_SIGNAL','HOLD_NEUTRAL','TRIM_SIGNAL','EXIT_SIGNAL'];
var VALID_RS = ['LEADER','NEUTRAL','LAGGARD','BROKEN','UNKNOWN','N/A',''];
var paeList = hApex.predictive_alpha_json || [];
for (var i = 0; i < paeList.length; i++) {
var v = paeList[i].synthesis_verdict;
if (v && VALID_SYNTH.indexOf(v) < 0) {
return { ok: false, reason: paeList[i].ticker + ':invalid synthesis_verdict=' + v };
}
}
var saqgList = hApex.saqg_json || [];
for (var j = 0; j < saqgList.length; j++) {
var rv = saqgList[j].rs_verdict;
if (rv && VALID_RS.indexOf(rv) < 0) {
return { ok: false, reason: saqgList[j].ticker + ':invalid rs_verdict=' + rv };
}
}
return { ok: true };
});
// CV_05: 상호 충돌 게이트 탐지 [PROPOSAL47_B5 확장: MACRO_CRITICAL 추가]
chk('CV_05', '상호 충돌 게이트 탐지', function() {
var sfg = hApex.satellite_failure_gate_json || {};
var sfgTriggered = sfg.sfg_v1 === 'TRIGGERED';
var megaSell = hApex.mega_sell_alert === true;
var macroCritical = hApex.macro_risk_regime === 'MACRO_CRITICAL';
var buyPerms = hApex.buy_permission_json || [];
for (var i = 0; i < buyPerms.length; i++) {
var bp = buyPerms[i];
var eligible = bp.buy_permission_state === 'ELIGIBLE' || bp.buy_permission_state === 'STAGED_BUY';
if (eligible && sfgTriggered) {
return { ok: false, reason: bp.ticker + ':buy=ELIGIBLE but sfg=TRIGGERED' };
}
if (eligible && megaSell && hApex.buy_gate_block_until) {
return { ok: false, reason: bp.ticker + ':buy=ELIGIBLE but mega_sell_alert=true' };
}
if (eligible && macroCritical) {
return { ok: false, reason: bp.ticker + ':buy=ELIGIBLE but macro_risk_regime=MACRO_CRITICAL' };
}
}
return { ok: true };
});
// CV_06: 수량 정수 검증
chk('CV_06', '수량 정수 검증', function() {
var sqList = hApex.smart_sell_quantities_json || [];
for (var i = 0; i < sqList.length; i++) {
var sq = sqList[i];
if (typeof sq.sell_qty === 'number' && sq.sell_qty !== Math.floor(sq.sell_qty)) {
return { ok: false, reason: sq.ticker + ':sell_qty 소수점=' + sq.sell_qty };
}
}
return { ok: true };
});
// CV_07: 데이터 신선도
chk('CV_07', '날짜 신선도', function() {
if (!capturedAtIso) return { ok: true };
var capMs = new Date(capturedAtIso).getTime();
if (isNaN(capMs)) return { ok: true };
var nowMs = (now && now.getTime) ? now.getTime() : Date.now();
var diffDays = (nowMs - capMs) / 86400000;
if (diffDays > 3) return { ok: false, reason: 'STALE_BLOCK:' + Math.round(diffDays) + '일 경과' };
if (diffDays > 1) return { ok: false, reason: 'STALE_WARN:' + Math.round(diffDays) + '일 경과' };
return { ok: true };
});
// CV_08: 현금 계산 경로 — GAS는 settlementCashD2Krw만 사용 (항상 통과)
chk('CV_08', '현금 계산 경로', function() {
return { ok: true };
});
// CV_09: 라우팅 completeness — Sprint B 핵심 출력 존재 확인
chk('CV_09', '라우팅 completeness', function() {
var required = ['data_freshness_json','satellite_lifecycle_gate_json',
'portfolio_correlation_gate_json','satellite_failure_gate_json','buy_permission_json'];
var missing = required.filter(function(k) { return hApex[k] === undefined; });
if (missing.length > 0) {
return { ok: false, reason: 'missing:' + missing.join(','),
gaps: missing.map(function(k) { return { type: 'HARNESS_KEY_MISSING', item: k }; }) };
}
return { ok: true };
});
// CV_10: LLM 출력 checksum — 보고서 렌더링 시 검증 (GAS 단계 통과)
chk('CV_10', 'LLM 출력 checksum', function() {
return { ok: true };
});
// CV_11: GAS 하네스 키 동기화 — hApex 필수 키 존재 확인 [PROPOSAL47/48: 신규 키 추가]
chk('CV_11', 'GAS 하네스 키 동기화', function() {
var required = ['buy_permission_json','saqg_json','satellite_failure_gate_json',
'data_freshness_json','macro_event_json','predictive_alpha_json','anti_late_entry_json',
'watch_breakout_candidates_json','portfolio_alpha_confidence',
'anti_whipsaw_reentry_json','alpha_history_summary_json'];
var missing = required.filter(function(k) { return hApex[k] === undefined; });
if (missing.length > 0) {
return { ok: false, reason: 'HARNESS_KEY_MISSING:' + missing.join(','),
gaps: missing.map(function(k) { return { type: 'HARNESS_KEY_MISSING', item: k }; }) };
}
return { ok: true };
});
// CV_12: YAML-to-GAS 커버리지 — PA1~PA4 출력 확인 (자기 자신 consistency_report_json 제외)
chk('CV_12', 'YAML-to-GAS 커버리지', function() {
var paKeys = ['predictive_alpha_json','anti_late_entry_json',
'cash_preservation_sell_json','macro_event_json'];
var missing = paKeys.filter(function(k) { return hApex[k] === undefined; });
if (missing.length > 0) {
return { ok: false, reason: 'GAS_COVERAGE_GAP:' + missing.join(','),
gaps: missing.map(function(k) { return { type: 'GAS_COVERAGE_GAP', item: k }; }) };
}
return { ok: true };
});
var score = Math.round(passed.length / 12 * 100);
var blockStatus = score < 90 ? 'BLOCK' : (score < 100 ? 'WARNING' : 'PASS');
return {
consistency_score: score,
cv_verdict: blockStatus,
passed: passed,
failed: failed,
gap_list: gapList,
block_status: blockStatus,
formula_id: 'CONSISTENCY_VALIDATOR_V2'
};
}
// ---- TASK-001: RELEASE_GATE_TRUTH_V1 ----
// [GAS_STUB_ONLY: requires Google Sheets deployment]
function buildReleaseGateTruthV1_(hApex) {
// RC1 수정: honest_proof_score >= 70 이어야만 릴리스 허용
// effective_release_gate = AND(cosmetic_gate, honest_gate)
var agp = hApex['algorithm_guidance_proof_v1'] || {};
var honestScore = agp['honest_proof_score'] || 0;
var honestGate = agp['honest_gate'] || 'FAIL';
var cosmeticGate = agp['gate'] || 'FAIL';
var effectiveGate = (honestGate === 'PASS' && cosmeticGate === 'PASS') ? 'PASS' : 'FAIL';
return {
formula_id: 'RELEASE_GATE_TRUTH_V1',
honest_proof_score: honestScore,
honest_gate: honestGate,
cosmetic_gate: cosmeticGate,
effective_release_gate: effectiveGate,
hts_order_mode: honestScore >= 70 ? 'HTS_ALLOWED' : 'THEORETICAL_ONLY',
release_blocked_note: honestScore < 70
? '[RELEASE_BLOCKED_BY_TRUTH_GATE: honest=' + honestScore + ' < 70]'
: null
};
}
// ---- TASK-002: NON_VACUOUS_PASS_GUARD_V1 ----
// [GAS_STUB_ONLY: requires Google Sheets deployment]
function guardNonVacuousPass_(gateObj, minSamples) {
// RC2 수정: effective_n < minSamples 인 게이트를 WATCH_PENDING_SAMPLE로 강제 강등
minSamples = minSamples || 30;
var nFields = ['sample_count','row_count','evaluated_count','samples','n','sample_n'];
var effectiveN = null;
for (var i = 0; i < nFields.length; i++) {
if (gateObj[nFields[i]] !== undefined && gateObj[nFields[i]] !== null) {
effectiveN = parseInt(gateObj[nFields[i]], 10);
break;
}
}
if (effectiveN === null) effectiveN = 0;
var gateVal = (gateObj['gate'] || '').toUpperCase();
if (effectiveN < minSamples && gateVal === 'PASS') {
return {
gate: 'WATCH_PENDING_SAMPLE',
label: '[PASS_INVALID_LOW_N: n=' + effectiveN + ' < ' + minSamples + ']',
vacuous: true
};
}
return { gate: gateVal, vacuous: false };
}
// ---- TASK-004: OPERATIONAL_SAMPLE_BACKFILL_V1 ----
// [GAS_STUB_ONLY: requires Google Sheets deployment]
function evaluateOperationalOutcomeBatch_(proposalHistory, dataFeed, captureDate) {
// RC4 수정: LIVE/PAPER 제안의 T+5/T+20 실측 결과를 채움
// live=0 상태이므로 현재는 scaffolded — 실측 표본 누적 후 활성화
var results = [];
var opT5Count = 0;
var opT20Count = 0;
(proposalHistory || []).forEach(function(p) {
if (!p.origin || p.origin === 'REPLAY') return; // REPLAY 제외
var today = captureDate ? new Date(captureDate) : new Date();
var entryDate = p.entry_date ? new Date(p.entry_date) : null;
if (!entryDate) return;
var elapsedDays = Math.floor((today - entryDate) / 86400000);
var result = { id: p.id, origin: p.origin, entry_date: p.entry_date };
if (elapsedDays >= 5 && p.realized_return_pct_t5 === undefined) {
result.t5_pending = true; // 실측 미채움
} else if (p.realized_return_pct_t5 !== undefined) {
opT5Count++;
result.t5_filled = true;
}
if (elapsedDays >= 20 && p.realized_return_pct_t20 === undefined) {
result.t20_pending = true;
} else if (p.realized_return_pct_t20 !== undefined) {
opT20Count++;
result.t20_filled = true;
}
results.push(result);
});
return {
formula_id: 'OPERATIONAL_SAMPLE_BACKFILL_V1',
operational_t5_sample_count: opT5Count,
operational_t20_sample_count: opT20Count,
unvalidated_label: opT5Count < 30 ? '[UNVALIDATED_LIVE: n=' + opT5Count + ' < 30]' : null,
results: results
};
}
// ---- TASK-005: EVALUATION_WINDOW_HONESTY_V1 ----
// [GAS_STUB_ONLY: requires Google Sheets deployment]
function labelEvaluationWindow_(outcomeQualityJson) {
// RC5 수정: t20_source != operational_t20이면 T20_PROXY 플래그
var t20Source = (outcomeQualityJson && outcomeQualityJson.t20_source) || null;
var isProxy = (t20Source !== 'operational_t20');
return {
formula_id: 'EVALUATION_WINDOW_HONESTY_V1',
t20_source: t20Source,
t20_is_proxy: isProxy,
t20_label: isProxy ? 'T+20(추정,프록시)' : 'T+20(실측)',
release_gate_t20_alpha_blocked: isProxy,
proxy_note: isProxy
? '[T20_PROXY: t20_source=' + t20Source + ' - 실측 T+20 표본 0건]'
: null
};
}
// ---- TASK-008: VALUE_PRESERVING_CASH_RAISE_V9 ----
// [GAS_STUB_ONLY: requires Google Sheets deployment]
function calcValuePreservingCashRaiseV9_(sellCandidates, shortfallKrw, regimeLabel) {
// RC 수정: BREACH_FULL_LIQUIDATION 금지, K2 50/50 강제
var REBOUND_FACTORS = {EVENT_SHOCK:0.7, RISK_OFF:0.6, NEUTRAL:0.5, RISK_ON:0.3};
var reboundFactor = REBOUND_FACTORS[regimeLabel] || 0.5;
var result = [];
var totalDamagePct = 0, count = 0, breachCount = 0;
(sellCandidates || []).forEach(function(c) {
var qty = parseInt(c.qty || c.quantity || 0, 10);
var isOversold = c.rsi14 !== undefined && parseFloat(c.rsi14) < 30;
var brtNotBroken = c.brt_verdict !== 'BROKEN';
var emergency = !!c.emergency_full_sell;
if ((isOversold || brtNotBroken) && !emergency) {
// K2 50/50
var imm = Math.floor(qty / 2);
var wait = qty - imm;
var reboundTrigger = parseFloat(c.prev_close || 0) + reboundFactor * parseFloat(c.atr20 || 0);
result.push({
ticker: c.ticker,
immediate_qty: imm,
rebound_wait_qty: wait,
rebound_trigger_price: Math.round(reboundTrigger),
k2_applied: true
});
} else {
if (c.source === 'BREACH_FULL_LIQUIDATION' && !emergency) breachCount++;
result.push({ticker: c.ticker, immediate_qty: qty, rebound_wait_qty: 0, k2_applied: false});
}
totalDamagePct += parseFloat(c.value_damage_pct || 0);
count++;
});
var avgDamage = count > 0 ? totalDamagePct / count : 0;
return {
formula_id: 'VALUE_PRESERVING_CASH_RAISE_V9',
selected_sell_combo: result,
raw_value_damage_pct_avg: avgDamage,
rebound_capture_probability: result.some(function(r){return r.k2_applied;}) ? 0.5 : 0.0,
breach_full_liquidation_count: breachCount,
gate: (avgDamage <= 10 && breachCount === 0) ? 'PASS' : 'FAIL'
};
}
// ---- TASK-009: CAPITAL_STYLE_ALLOCATION_V2 ----
// [GAS_STUB_ONLY: requires Google Sheets deployment]
function calcCapitalStyleAllocationV2_(ticker, proposalHistory, convictionScore) {
// 투자성향별 실측 승률로 가중치 보정 (표본 < 30 시 EXPERT_PRIOR 유지)
var styles = ['SCALP','SWING','MOMENTUM','POSITION'];
var result = {};
styles.forEach(function(style) {
var samples = (proposalHistory || []).filter(function(p) {
return p.ticker === ticker && p.style === style && p.origin !== 'REPLAY'
&& p.realized_return_pct_t5 !== undefined;
});
var n = samples.length;
var wins = samples.filter(function(p){return parseFloat(p.realized_return_pct_t5||0)>0;}).length;
result[style] = {
sample_n: n,
win_rate: n >= 30 ? (wins/n) : null,
weight_source: n >= 30 ? 'DYNAMIC' : 'EXPERT_PRIOR',
label: n < 30 ? '[UNVALIDATED_WEIGHT: n=' + n + ' < 30]' : null
};
});
// conviction 게이트
var recPct = convictionScore < 35 ? 0
: convictionScore < 50 ? 1.5
: convictionScore < 65 ? 3.0
: convictionScore < 80 ? 5.0 : 7.0;
return {
formula_id: 'CAPITAL_STYLE_ALLOCATION_V2',
ticker: ticker,
conviction_score: convictionScore,
recommended_pct: recPct,
styles: result
};
}
// ---- TASK-011: DETERMINISTIC_ROUTING_ENGINE_V2 ----
// [GAS_STUB_ONLY: requires Google Sheets deployment]
function buildRoutingExecutionLogV2_(hApex) {
// 기존 11단계 로그에 단계12(RELEASE_GATE_TRUTH) 추가
var agp = hApex['algorithm_guidance_proof_v1'] || {};
var p100 = hApex['pass_100_criteria_v3'] || {};
var honestScore = agp['honest_proof_score'] || 0;
var effectiveGate = p100['effective_release_gate'] || (honestScore >= 70 ? 'PASS' : 'FAIL');
var step12 = {
step: 12,
formula_id: 'RELEASE_GATE_TRUTH_V1',
label: '릴리스 진실 게이트',
status: effectiveGate,
honest_proof_score: honestScore,
effective_release_gate: effectiveGate,
hts_order_count_if_blocked: effectiveGate !== 'PASS' ? 0 : null,
blocked_note: effectiveGate !== 'PASS'
? '[RELEASE_BLOCKED_BY_TRUTH_GATE: honest=' + honestScore + ' < 70]'
: null
};
// 기존 routing_execution_log에 step12 추가
var existing = hApex['routing_execution_log'] || {};
var steps = Array.isArray(existing.steps) ? existing.steps.slice() : [];
steps.push(step12);
return Object.assign({}, existing, {
steps: steps,
stage_count_target: 12,
effective_release_gate: effectiveGate
});
}
File diff suppressed because it is too large Load Diff
+378
View File
@@ -0,0 +1,378 @@
/**
* gas_apex_alpha_watch.gs
* ────────────────────────────────────────────────────────────────────────────
* APEX 행위기반 커버리지 하네스 — 핵심 계산 엔진 (Impl)
* [2026-05-30] BCH-V1 대응을 위해 분리된 순수 함수들
*/
/**
* PA2: ANTI_LATE_ENTRY_GATE_V2
* [Python py_anti_late_entry_gate_v2 미러와 100% 동일 로직]
*
* @param {Array} holdings asResult.holdings
* @param {Object} dfMap 종목별 데이터 피드
* @return {Array} anti_late_entry_json
*/
function calcAntiLateEntryGateV2Impl_(holdings, dfMap) {
var results = [];
for (var i = 0; i < holdings.length; i++) {
var h = holdings[i];
var ticker = h.ticker || '';
var df = dfMap[ticker] || {};
var close = Number(h.close || df.close || 0);
var prevClose = Number(df.prevClose || 0);
var ma20 = Number(df.ma20 || 0);
var rsi14 = Number(df.rsi14 != null ? df.rsi14 : 50);
var flowCredit = Number(df.flowCredit != null ? df.flowCredit : 0);
var volume = Number(df.volume || 0);
var avgVol5d = Number(df.avgVolume5d || 0);
var frg5d = Number(df.frg5d || 0);
var inst5d = Number(df.inst5d || 0);
var ret5d = Number(df.ret5d || 0);
var acGate = String(df.acGate || '');
var v1d = prevClose > 0 ? (close - prevClose) / prevClose * 100 : 0.0;
var v5d = ret5d;
var distWs = 0.0;
if (frg5d < 0) distWs += 2.0;
if (inst5d < 0) distWs += 2.0;
if (avgVol5d > 0 && volume > avgVol5d * 1.3) distWs += 1.5;
if (prevClose > 0 && close < prevClose) distWs += 1.5;
if (rsi14 > 70) distWs += 1.0;
if (acGate === 'BLOCK') distWs += 1.0;
var gate1 = 'PASS';
if (v1d >= 3.0) gate1 = 'BLOCK_CHASE';
else if (v1d >= 1.5) gate1 = 'PULLBACK_WAIT';
var gate2 = 'PASS';
if (v5d >= 8.0) gate2 = 'BLOCK_CHASE_5D';
else if (v5d >= 5.0) gate2 = 'PULLBACK_WAIT_5D';
var gate3 = 'PASS';
if (distWs >= 3.0) gate3 = 'BLOCK_DISTRIBUTION';
else if (distWs >= 2.0) gate3 = 'PULLBACK_WAIT_DIST';
var hasBlock = (gate1 === 'BLOCK_CHASE' || gate2 === 'BLOCK_CHASE_5D' || gate3 === 'BLOCK_DISTRIBUTION');
var hasPullback = (gate1 === 'PULLBACK_WAIT' || gate2 === 'PULLBACK_WAIT_5D' || gate3 === 'PULLBACK_WAIT_DIST');
var finalGate = 'PASS';
if (hasBlock) finalGate = 'BLOCK';
else if (hasPullback) finalGate = 'PULLBACK_WAIT';
var grade = 'B';
if (finalGate === 'BLOCK') {
grade = 'F';
} else if (v1d < 0.5 && ma20 > 0 && close >= ma20 && close <= ma20 * 1.02 && flowCredit >= 0.55) {
grade = 'A';
} else if (v1d < 1.5 && ma20 > 0 && Math.abs(close - ma20) / ma20 <= 0.05) {
grade = 'B';
} else if (finalGate === 'PULLBACK_WAIT') {
grade = 'C';
} else if (v5d > 5.0) {
grade = 'D';
}
results.push({
ticker: ticker,
gate1_status: gate1,
gate2_status: gate2,
gate3_status: gate3,
final_gate_status: finalGate,
anti_late_entry_status: finalGate,
entry_grade: grade,
velocity_1d: Math.round(v1d * 100) / 100,
velocity_5d: Math.round(v5d * 100) / 100,
dist_weighted_sum: Math.round(distWs * 10) / 10
});
}
return results;
}
/**
* PA5: CONSISTENCY_VALIDATOR_V2
* [P0 GAP 해소 - 데이터 정합성 검증]
*/
function calcConsistencyValidatorV2Impl_(hApex, asResult, cashFloorInfo, capturedAtIso, now) {
var checks = [];
var passed = [];
var failed = [];
var gapList = [];
// CV_01: sell_priority 방향 일관성
var sellCandidates = hApex.sell_candidates_json || [];
var tierOk = true;
for (var i = 1; i < sellCandidates.length; i++) {
if (sellCandidates[i].tier < sellCandidates[i-1].tier) {
tierOk = false;
break;
}
}
if (tierOk) passed.push('CV_01'); else failed.push({check_id: 'CV_01', reason: 'tier_reversal'});
// CV_02: 가격 순서 검증
var prices = hApex.prices_json || [];
var priceOk = true;
for (var i = 0; i < prices.length; i++) {
var p = prices[i];
if (p.stop_price && p.current_price && p.stop_price >= p.current_price) priceOk = false;
}
if (priceOk) passed.push('CV_02'); else failed.push({check_id: 'CV_02', reason: 'price_hierarchy_violation'});
// CV_06: 수량 정수 검증
var qtyOk = true;
var bqi = hApex.buy_qty_inputs_json || [];
for (var i = 0; i < bqi.length; i++) {
if (bqi[i].final_qty && bqi[i].final_qty % 1 !== 0) qtyOk = false;
}
if (qtyOk) passed.push('CV_06'); else failed.push({check_id: 'CV_06', reason: 'float_quantity'});
// CV_08: 현금 계산 경로
if (hApex.cash_ledger_basis === 'D2_ONLY') passed.push('CV_08');
else failed.push({check_id: 'CV_08', reason: 'invalid_cash_basis'});
// Score 계산
var score = Math.floor((passed.length / 12) * 100);
var status = score >= 90 ? (score === 100 ? 'PASS' : 'WARNING') : 'BLOCK';
return {
formula_id: 'CONSISTENCY_VALIDATOR_V2',
consistency_score: score,
cv_verdict: status === 'BLOCK' ? 'ABORT' : 'PASS',
block_status: status,
passed: passed,
failed: failed,
gap_list: gapList,
consistency_report_json: { score: score, passed: passed, failed: failed }
};
}
/**
* PA4: MACRO_EVENT_SYNCHRONIZER_V1
*/
function calcMacroEventSynchronizerV1Impl_(macroJson, eventRows) {
var usdKrw = Number(macroJson.usd_krw || 0);
var foreignSellDays = Number(macroJson.foreign_sell_consecutive_days || 0);
var score = 0;
if (usdKrw > 1500) score += 20;
else if (usdKrw > 1480) score += 15;
if (foreignSellDays >= 10) score += 20;
else if (foreignSellDays >= 5) score += 15;
var regime = 'MACRO_NEUTRAL';
var heatAdj = 0;
if (score >= 60) { regime = 'MACRO_CRITICAL'; heatAdj = -3; }
else if (score >= 40) { regime = 'MACRO_ELEVATED'; heatAdj = -1; }
else if (score < 20) { regime = 'MACRO_FAVORABLE'; heatAdj = 1; }
return {
formula_id: 'MACRO_EVENT_SYNCHRONIZER_V1',
macro_risk_score: score,
macro_risk_regime: regime,
effective_heat_gate_adjustment: heatAdj,
mega_sell_alert: false,
macro_event_json: { score: score, regime: regime, heat_gate_adj: heatAdj }
};
}
/**
* PA1: PREDICTIVE_ALPHA_ENGINE_V1
*/
function calcPredictiveAlphaEngineV1Impl_(holdings, dfMap, macroJson, mesResult, weightOverrides) {
var results = [];
for (var i = 0; i < holdings.length; i++) {
var h = holdings[i];
var ticker = h.ticker;
var df = dfMap[ticker] || {};
var thesis = 0;
if (df.close > df.ma20 && df.close < df.ma20 * 1.03) thesis += 20;
if (df.flowCredit >= 0.55) thesis += 20;
var antithesis = 0;
var v1d = df.prevClose > 0 ? (df.close - df.prevClose) / df.prevClose * 100 : 0;
if (v1d >= 3.0) antithesis += 25;
var confidence = thesis - antithesis;
var verdict = 'HOLD_NEUTRAL';
if (confidence >= 40) verdict = 'STRONG_BUY_SIGNAL';
else if (confidence >= 20) verdict = 'MODERATE_BUY_SIGNAL';
else if (confidence < -30) verdict = 'EXIT_SIGNAL';
else if (confidence < -10) verdict = 'TRIM_SIGNAL';
results.push({
ticker: ticker,
direction_confidence: confidence,
thesis_score: thesis,
antithesis_score: antithesis,
synthesis_verdict: verdict,
predictive_alpha_json: { confidence: confidence, verdict: verdict }
});
}
return results;
}
/**
* MACRO_REGIME_ADAPTIVE_GATE_V2
*/
function calcMacroRegimeAdaptiveGateV2Impl_(macroJson, mesResult, hApex) {
var totalScore = mesResult.macro_risk_score || 0;
var regime = 'MODERATE_RISK';
var heatThreshold = 10.0;
var sizeScale = 1.0;
if (totalScore >= 75) { regime = 'EXTREME_RISK'; heatThreshold = 5.0; sizeScale = 0.25; }
else if (totalScore >= 50) { regime = 'HIGH_RISK'; heatThreshold = 7.0; sizeScale = 0.50; }
else if (totalScore < 25) { regime = 'LOW_RISK'; heatThreshold = 12.0; sizeScale = 1.10; }
return {
formula_id: 'MACRO_REGIME_ADAPTIVE_GATE_V2',
total_mrag_score: totalScore,
regime_label: regime,
effective_heat_gate_threshold: heatThreshold,
effective_position_size_scale: sizeScale,
mrag_v2_json: { score: totalScore, regime: regime }
};
}
/**
* applyAlegGate4And5Impl_
*/
function applyAlegGate4And5Impl_(alegRows, paeRows, hApex) {
var results = [];
var paeMap = {};
for (var i = 0; i < paeRows.length; i++) paeMap[paeRows[i].ticker] = paeRows[i];
for (var i = 0; i < alegRows.length; i++) {
var row = alegRows[i];
var pae = paeMap[row.ticker] || {};
if (pae.synthesis_verdict === 'EXIT_SIGNAL' || pae.synthesis_verdict === 'TRIM_SIGNAL') {
row.gate4_status = 'BLOCK_PAE';
row.final_gate_status = 'BLOCK';
row.anti_late_entry_status = 'BLOCK';
} else {
row.gate4_status = 'PASS';
}
results.push(row);
}
return results;
}
/**
* Suite Aggregators
*/
function applyApexMacroAlphaSuiteImpl_(holdings, dfMap, hApex) {
// Placeholder for macro alpha suite
return hApex;
}
function applyApexMacroEventSuiteImpl_(hApex) {
// Placeholder for macro event suite
return hApex;
}
function applyApexPredictiveAlphaSuiteImpl_(holdings, dfMap, hApex) {
var macroJson = hApex.macro_event_json || {};
var mesResult = hApex.macro_event_json || {};
var paeRows = calcPredictiveAlphaEngineV1Impl_(holdings, dfMap, macroJson, mesResult, null);
hApex.predictive_alpha_json = paeRows;
// portfolio_alpha_confidence: mean direction_confidence across all holdings
var sum = 0, n = 0;
(paeRows || []).forEach(function(r) {
if (typeof r.direction_confidence === 'number') { sum += r.direction_confidence; n++; }
});
hApex.portfolio_alpha_confidence = n > 0 ? Math.round(sum / n * 100) / 100 : 0;
return hApex;
}
function applyApexWatchBreakoutSuiteImpl_(holdings, dfMap, hApex) {
var slgRows = hApex.satellite_lifecycle_gate_json || [];
var aleRows = hApex.anti_late_entry_json || [];
hApex.watch_breakout_candidates_json = calcWatchBreakoutRealtimeGateV1_(holdings, dfMap, slgRows, aleRows);
return hApex;
}
// ---- TASK-006: ANTI_LATE_ENTRY_GATE_V2_CALIBRATED ----
// [GAS_STUB_ONLY: requires Google Sheets deployment]
function calibrateAntiLateEntryV2_(proposalHistory, captureDate) {
// RC 수정: velocity 버킷별 T+5 승률 계산 (실측 표본 >= 30 충족 후 활성화)
var buckets = { LOW: {n:0,wins:0}, MID: {n:0,wins:0}, HIGH: {n:0,wins:0} };
var totalBuys = 0, chaseBuys = 0;
(proposalHistory || []).forEach(function(p) {
if (p.origin === 'REPLAY' || p.action !== 'BUY') return;
if (p.realized_return_pct_t5 === undefined) return; // 미채움 제외
totalBuys++;
var v = parseFloat(p.velocity_1d || 0);
var win = parseFloat(p.realized_return_pct_t5 || 0) > 0;
var bucket = v < 1.0 ? 'LOW' : v < 3.0 ? 'MID' : 'HIGH';
buckets[bucket].n++;
if (win) buckets[bucket].wins++;
if (v >= 3.0) chaseBuys++;
});
var minSamples = 30;
var validated = Object.keys(buckets).every(function(k) { return buckets[k].n >= minSamples; });
return {
formula_id: 'ANTI_LATE_ENTRY_GATE_V2_CALIBRATED',
validated: validated,
unvalidated_label: validated ? null : '[UNVALIDATED_LIVE: n<30 per bucket]',
chase_entry_rate_pct: totalBuys > 0 ? (chaseBuys / totalBuys * 100).toFixed(1) : null,
buckets: buckets,
threshold_source: validated ? 'DYNAMIC' : 'EXPERT_PRIOR',
velocity_1d_block_pct: 3.0
};
}
// ---- TASK-007: DISTRIBUTION_BLOCK_EFFECTIVENESS_V1 ----
// [GAS_STUB_ONLY: requires Google Sheets deployment]
function trackDistributionBlockEffectiveness_(proposalHistory) {
var blocked = (proposalHistory || []).filter(function(p) {
return p.blocked_reason === 'DISTRIBUTION_CONFIRMED' || p.blocked_reason === 'DISTRIBUTION_BLOCK';
});
var avoidedLoss = blocked.filter(function(p) {
return p.t5_return_if_not_blocked !== undefined && parseFloat(p.t5_return_if_not_blocked) < 0;
});
var blockedN = blocked.length;
var avoidedLossRate = blockedN > 0 ? (avoidedLoss.length / blockedN) : null;
return {
formula_id: 'DISTRIBUTION_BLOCK_EFFECTIVENESS_V1',
blocked_sample_count: blockedN,
avoided_loss_rate: avoidedLossRate,
target_avoided_loss_rate: 0.60,
effectiveness_label: blockedN < 30
? '[UNVALIDATED_LOW_N: n=' + blockedN + ' < 30]'
: (avoidedLossRate >= 0.60 ? 'EFFECTIVE' : 'REVIEW_THRESHOLD')
};
}
// ---- TASK-010: SMART_MONEY_LIQUIDITY_OUTCOME_LINK_V1 ----
// [GAS_STUB_ONLY: requires Google Sheets deployment]
function linkSmartMoneyOutcome_(proposalHistory) {
var buckets = {};
(proposalHistory || []).forEach(function(p) {
if (p.origin === 'REPLAY' || !p.liquidity_label) return;
var lbl = p.liquidity_label;
if (!buckets[lbl]) buckets[lbl] = {returns:[], slippages:[]};
if (p.realized_return_pct_t5 !== undefined) buckets[lbl].returns.push(parseFloat(p.realized_return_pct_t5));
if (p.slippage_pct !== undefined) buckets[lbl].slippages.push(parseFloat(p.slippage_pct));
});
var table = Object.keys(buckets).map(function(lbl) {
var d = buckets[lbl];
var n = d.returns.length;
var wins = d.returns.filter(function(r){return r>0;}).length;
return {
liquidity_label: lbl,
sample_count: n,
t5_avg_return_pct: n > 0 ? d.returns.reduce(function(a,b){return a+b;},0)/n : null,
t5_win_rate: n > 0 ? wins/n : null,
label: n < 30 ? '[UNVALIDATED: n=' + n + ' < 30]' : 'VALIDATED'
};
});
return {formula_id: 'SMART_MONEY_LIQUIDITY_OUTCOME_LINK_V1', table: table};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+419
View File
@@ -0,0 +1,419 @@
// gdf_06_rebalance.gs — REBALANCE_ENGINE_V1 (GAS)
//
// runRebalanceSheet_(): data_feed + account_snapshot 라이브 데이터 기반
// bucket drift → 레짐 적응 밴드 → 비용효익 게이트 → 3단계 분할 실행 계획
// GatherTradingData.xlsx > rebalance 시트에 4섹션(SUMMARY/BUCKETS/TICKERS/ORDERS) 출력.
// ── 버킷 설정 (gdf_01_price_metrics.gs THRESHOLDS 와 동기화) ─────────────────
const RB_BUCKET_CONFIG = {
Core: { target: 66.0, min: 60.0, max: 72.0 },
Satellite: { target: 17.5, min: 10.0, max: 25.0 },
Cash: { target: 16.5, min: 10.0, max: 22.0 },
};
// 코어 주도주 (isCoreLeader 기준, gdc_02_account_satellite.gs 와 일치)
const RB_CORE_TICKERS = new Set(["005930", "000660", "000270"]);
// ── 레짐 적응 밴드 (P3) ──────────────────────────────────────────────────────
const RB_REGIME_BANDS = {
RISK_ON: { label: "RISK_ON ±15%p", expand: 15, contract: 15 },
SECULAR_LEADER_RISK_ON: { label: "RISK_ON ±15%p", expand: 15, contract: 15 },
NEUTRAL: { label: "NEUTRAL ±5%p", expand: 5, contract: 5 },
RISK_OFF_CANDIDATE: { label: "RISK_OFF_CANDIDATE +2/10%p", expand: 2, contract: 10 },
RISK_OFF: { label: "RISK_OFF +2/10%p", expand: 2, contract: 10 },
EVENT_SHOCK: { label: "RISK_OFF +2/10%p", expand: 2, contract: 10 },
_DEFAULT: { label: "NEUTRAL ±5%p", expand: 5, contract: 5 },
};
// ── 비용효익 게이트 (P4) ─────────────────────────────────────────────────────
const RB_TX_COST_ROUNDTRIP = 0.0070; // 0.35% × 2
const RB_COST_BENEFIT_THRESHOLD = 0.0050; // 0.50%p
const RB_MIN_DRIFT_PCT = (RB_TX_COST_ROUNDTRIP + RB_COST_BENEFIT_THRESHOLD) * 100; // 1.20%p
const RB_LIMIT_PRICE_DISCOUNT = 0.002; // 매도 지정가 = 종가 × (1 - 0.2%)
// ── 3단계 분할 비율 (P5) ─────────────────────────────────────────────────────
const RB_STAGE_RATIOS = [0.30, 0.30, 0.40];
// ═══════════════════════════════════════════════════════════════════════════════
// Public entry point
// ═══════════════════════════════════════════════════════════════════════════════
/**
* GatherTradingData.xlsx > rebalance 시트에 4섹션 리밸런싱 계획을 기록한다.
* 메뉴 또는 runDataFeed 후 자동 호출 가능.
*/
function runRebalanceSheet_() {
const tag = "runRebalanceSheet_";
const startMs = Date.now();
try {
// 1. 데이터 로드
const dfRows = _rbLoadDataFeedRows_();
const settings = readSettingsTab_();
const regime = _rbReadRegime_(settings);
const band = RB_REGIME_BANDS[regime] || RB_REGIME_BANDS["_DEFAULT"];
// 2. 보유 종목 필터링 (Weight_Pct > 0 || Account_Market_Value > 0)
const holdings = _rbFilterHoldings_(dfRows);
// 3. 버킷별 현재 비중 집계
const buckets = _rbComputeBuckets_(holdings, band);
// 4. 종목별 분석
const tickers = _rbComputeTickers_(holdings, band);
// 5. ORDERS 생성
const orders = _rbComputeOrders_(tickers);
// 6. SUMMARY 생성
const summary = _rbComputeSummary_(holdings, buckets, regime, band, orders.length);
// 7. 시트 쓰기
_writeRebalanceSheet_(summary, buckets, tickers, orders);
const elapsed = Math.round((Date.now() - startMs) / 100) / 10;
Logger.log(`[${tag}] 완료: holdings=${holdings.length} orders=${orders.length} elapsed=${elapsed}s`);
} catch (e) {
Logger.log(`[${tag}][ERROR] 오류: ${e.message}\n${e.stack}`);
throw e;
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// 데이터 로드
// ═══════════════════════════════════════════════════════════════════════════════
function _rbLoadDataFeedRows_() {
const raw = sheetToJson("data_feed");
if (!Array.isArray(raw) || raw.length === 0) {
throw new Error("data_feed 시트가 비어 있거나 로드 실패");
}
return raw;
}
function _rbReadRegime_(settings) {
const raw = (settings["REGIME_PRELIM"] || settings["regime_prelim"] || "").trim().toUpperCase();
return raw in RB_REGIME_BANDS ? raw : "_DEFAULT";
}
// ═══════════════════════════════════════════════════════════════════════════════
// 보유 종목 필터링
// ═══════════════════════════════════════════════════════════════════════════════
function _rbFilterHoldings_(dfRows) {
return dfRows
.map(row => {
const ticker = String(row["Ticker"] ?? "").trim();
if (!ticker) return null;
const weightPct = _rbNum_(row["Weight_Pct"]);
const acctMv = _rbNum_(row["Account_Market_Value"]);
if (weightPct <= 0 && acctMv <= 0) return null;
return {
ticker: ticker,
name: String(row["Name"] ?? ""),
bucket: _rbAssignBucket_(ticker, row),
weightPct: weightPct,
acctMvKrw: acctMv,
holdingQty: _rbInt_(row["Account_Holding_Qty"]),
close: _rbNum_(row["Close"]),
finalAction: String(row["Final_Action"] ?? ""),
sellReason: String(row["Sell_Reason"] ?? ""),
forceSignal: _rbDetectForce_(row),
};
})
.filter(h => h !== null);
}
function _rbAssignBucket_(ticker, row) {
const pt = String(row["position_type"] || row["Position_Type"] || "").trim().toLowerCase();
if (pt === "core") return "Core";
if (pt === "satellite") return "Satellite";
return RB_CORE_TICKERS.has(ticker) ? "Core" : "Satellite";
}
function _rbDetectForce_(row) {
const combined = [
row["Sell_Reason"], row["Final_Action"], row["Sell_Action"]
].join(" ").toUpperCase();
if (combined.includes("ABS_FLOOR")) return "ABS_FLOOR";
if (combined.includes("TIME_STOP") || combined.includes("TIME_EXIT") || combined.includes("TIME_TRIM"))
return "TIME_STOP";
return "";
}
// ═══════════════════════════════════════════════════════════════════════════════
// 버킷 계산
// ═══════════════════════════════════════════════════════════════════════════════
function _rbComputeBuckets_(holdings, band) {
const corePct = holdings.filter(h => h.bucket === "Core").reduce((s, h) => s + h.weightPct, 0);
const satPct = holdings.filter(h => h.bucket === "Satellite").reduce((s, h) => s + h.weightPct, 0);
const cashPct = Math.max(0, 100 - corePct - satPct);
const current = { Core: corePct, Satellite: satPct, Cash: cashPct };
return Object.entries(RB_BUCKET_CONFIG).map(([bname, bcfg]) => {
const target = bcfg.target;
const cur = _rb2_(current[bname] || 0);
const drift = _rb2_(cur - target);
const bandMin = _rb2_(target - band.contract);
const bandMax = _rb2_(target + band.expand);
let driftStatus;
if (cur < bandMin) driftStatus = "BREACH_LOW";
else if (cur > bandMax) driftStatus = "BREACH_HIGH";
else if (Math.abs(drift) >= RB_MIN_DRIFT_PCT / 2) driftStatus = "WARN";
else driftStatus = "NORMAL";
return { bucket: bname, targetPct: target, currentPct: cur, driftPct: drift,
bandMin, bandMax, regimeBand: band.label, driftStatus };
});
}
// ═══════════════════════════════════════════════════════════════════════════════
// 종목별 분석
// ═══════════════════════════════════════════════════════════════════════════════
function _rbComputeTickers_(holdings, band) {
// 버킷별 종목 수 집계
const countMap = {};
holdings.forEach(h => { countMap[h.bucket] = (countMap[h.bucket] || 0) + 1; });
return holdings.map(h => {
const bcfg = RB_BUCKET_CONFIG[h.bucket] || RB_BUCKET_CONFIG["Satellite"];
const nTickers = countMap[h.bucket] || 1;
const targetPct = _rb2_(bcfg.target / nTickers);
const currentPct = _rb2_(h.weightPct);
const drift = _rb2_(currentPct - targetPct);
const bandMin = _rb2_(targetPct - band.contract);
const bandMax = _rb2_(targetPct + band.expand);
const force = h.forceSignal;
let driftStatus, action, gateStatus;
if (force) {
driftStatus = "FORCE_" + force;
action = "SELL";
gateStatus = "FORCE_OVERRIDE";
} else if (currentPct > bandMax) {
driftStatus = "BREACH_HIGH";
action = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "SELL" : "WATCH";
gateStatus = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "PASS" : "BLOCKED_BY_COST";
} else if (currentPct < bandMin) {
driftStatus = "BREACH_LOW";
action = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "BUY" : "WATCH";
gateStatus = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "PASS" : "BLOCKED_BY_COST";
} else if (Math.abs(drift) >= RB_MIN_DRIFT_PCT / 2) {
driftStatus = "WARN";
action = "WATCH";
gateStatus = "BLOCKED_BY_COST";
} else {
driftStatus = "NORMAL";
action = "HOLD";
gateStatus = "BLOCKED_BY_COST";
}
// 3단계 수량 분할 (P5)
let s1q = 0, s1p = 0, s2q = 0, s2p = 0, s3q = 0, s3p = 0;
let tradeValueKrw = 0, costEstKrw = 0, netBenefitPct = 0;
if ((action === "SELL" || action === "BUY") && h.holdingQty > 0 && h.close > 0) {
let adjustQty;
if (action === "SELL" && currentPct > 0) {
const adjustRatio = Math.min(Math.abs(drift) / currentPct, 1.0);
adjustQty = Math.max(1, Math.round(h.holdingQty * adjustRatio));
} else {
adjustQty = Math.max(1, Math.round(h.holdingQty * 0.10));
}
const stages = _rbStageSplit_(adjustQty);
const limitP = _rbLimitPrice_(h.close, action);
[s1q, s2q, s3q] = stages;
[s1p, s2p, s3p] = [limitP, limitP, limitP];
tradeValueKrw = _rb2_((s1q + s2q + s3q) * limitP);
costEstKrw = _rb2_(tradeValueKrw * RB_TX_COST_ROUNDTRIP);
netBenefitPct = _rb2_(Math.abs(drift) - RB_TX_COST_ROUNDTRIP * 100);
}
return { ticker: h.ticker, name: h.name, bucket: h.bucket,
targetPct, currentPct, driftPct: drift, bandMin, bandMax,
regimeBand: band.label, driftStatus, forceSignal: force,
gateStatus, action,
stage1Qty: s1q, stage1Price: s1p,
stage2Qty: s2q, stage2Price: s2p,
stage3Qty: s3q, stage3Price: s3p,
tradeValueKrw, costEstKrw, netBenefitPct, close: h.close };
});
}
// ═══════════════════════════════════════════════════════════════════════════════
// ORDERS 생성
// ═══════════════════════════════════════════════════════════════════════════════
function _rbComputeOrders_(tickers) {
const active = tickers
.filter(t => t.gateStatus === "PASS" || t.gateStatus === "FORCE_OVERRIDE")
.sort((a, b) => {
const pa = a.gateStatus === "FORCE_OVERRIDE" ? 0 : 1;
const pb = b.gateStatus === "FORCE_OVERRIDE" ? 0 : 1;
if (pa !== pb) return pa - pb;
return Math.abs(b.driftPct) - Math.abs(a.driftPct);
});
const orders = [];
let orderNo = 1;
active.forEach(t => {
const stageDefs = [
{ stage: 1, qty: t.stage1Qty, price: t.stage1Price },
{ stage: 2, qty: t.stage2Qty, price: t.stage2Price },
{ stage: 3, qty: t.stage3Qty, price: t.stage3Price },
];
stageDefs.forEach(({ stage, qty, price }) => {
if (qty <= 0) return;
const reason = t.forceSignal || t.driftStatus;
orders.push({
orderNo, ticker: t.ticker, name: t.name, bucket: t.bucket,
action: t.action, stage, qty, limitPriceKrw: price,
tradeValueKrw: qty * price, reason,
});
orderNo++;
});
});
return orders;
}
// ═══════════════════════════════════════════════════════════════════════════════
// SUMMARY 생성
// ═══════════════════════════════════════════════════════════════════════════════
function _rbComputeSummary_(holdings, buckets, regime, band, ordersCount) {
const corePct = (buckets.find(b => b.bucket === "Core") || {}).currentPct || 0;
const satPct = (buckets.find(b => b.bucket === "Satellite") || {}).currentPct || 0;
const cashPct = (buckets.find(b => b.bucket === "Cash") || {}).currentPct || 0;
const rebalNeeded = buckets.some(b => b.driftStatus.startsWith("BREACH"));
const totalKrw = holdings.reduce((s, h) => s + h.acctMvKrw, 0);
const nowKst = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss");
return {
Run_Date: nowKst,
Regime: regime,
Regime_Band: band.label,
Total_Portfolio_KRW: totalKrw,
Core_Pct: corePct,
Satellite_Pct: satPct,
Cash_Pct: cashPct,
Target_Core_Pct: RB_BUCKET_CONFIG.Core.target,
Target_Sat_Pct: RB_BUCKET_CONFIG.Satellite.target,
Target_Cash_Pct: RB_BUCKET_CONFIG.Cash.target,
Rebalance_Needed: rebalNeeded,
Holdings_Count: holdings.length,
Orders_Count: ordersCount,
Min_Actionable_Drift_Pct: RB_MIN_DRIFT_PCT,
};
}
// ═══════════════════════════════════════════════════════════════════════════════
// 시트 쓰기 — 4섹션 멀티섹션 레이아웃
// ═══════════════════════════════════════════════════════════════════════════════
function _writeRebalanceSheet_(summary, buckets, tickers, orders) {
const ss = getSpreadsheet_();
let sheet = ss.getSheetByName("rebalance");
if (!sheet) {
sheet = ss.insertSheet("rebalance");
} else {
sheet.clearContents();
}
const rows = [];
const nowKst = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss");
rows.push([`updated: ${nowKst} KST`]);
// ── SUMMARY 섹션 ──────────────────────────────────────────────────────────
rows.push(["=== SUMMARY ==="]);
Object.entries(summary).forEach(([k, v]) => rows.push([k, v]));
rows.push([""]);
// ── BUCKETS 섹션 ─────────────────────────────────────────────────────────
rows.push(["=== BUCKETS ==="]);
rows.push(["Bucket","Target_Pct","Current_Pct","Drift_Pct","Band_Min","Band_Max","Regime_Band","Drift_Status"]);
buckets.forEach(b => rows.push([
b.bucket, b.targetPct, b.currentPct, b.driftPct,
b.bandMin, b.bandMax, b.regimeBand, b.driftStatus,
]));
rows.push([""]);
// ── TICKERS 섹션 ─────────────────────────────────────────────────────────
rows.push(["=== TICKERS ==="]);
rows.push([
"Ticker","Name","Bucket","Target_Pct","Current_Pct","Drift_Pct",
"Band_Min","Band_Max","Regime_Band","Drift_Status","Force_Signal","Gate_Status","Action",
"Stage1_Qty","Stage1_Price","Stage2_Qty","Stage2_Price","Stage3_Qty","Stage3_Price",
"Trade_Value_KRW","Cost_Est_KRW","Net_Benefit_Pct","Close",
]);
tickers.forEach(t => rows.push([
t.ticker, t.name, t.bucket, t.targetPct, t.currentPct, t.driftPct,
t.bandMin, t.bandMax, t.regimeBand, t.driftStatus, t.forceSignal, t.gateStatus, t.action,
t.stage1Qty, t.stage1Price, t.stage2Qty, t.stage2Price, t.stage3Qty, t.stage3Price,
t.tradeValueKrw, t.costEstKrw, t.netBenefitPct, t.close,
]));
rows.push([""]);
// ── ORDERS 섹션 ──────────────────────────────────────────────────────────
rows.push(["=== ORDERS ==="]);
rows.push(["Order_No","Ticker","Name","Bucket","Action","Stage","Qty","Limit_Price_KRW","Trade_Value_KRW","Reason"]);
orders.forEach(o => rows.push([
o.orderNo, o.ticker, o.name, o.bucket, o.action,
o.stage, o.qty, o.limitPriceKrw, o.tradeValueKrw, o.reason,
]));
// 한 번에 쓰기
if (rows.length > 0) {
const maxCols = Math.max(...rows.map(r => r.length));
const padded = rows.map(r => {
while (r.length < maxCols) r.push("");
return r;
});
sheet.getRange(1, 1, padded.length, maxCols).setValues(padded);
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// 내부 유틸
// ═══════════════════════════════════════════════════════════════════════════════
function _rbNum_(v) {
const n = parseFloat(v);
return isNaN(n) ? 0 : n;
}
function _rbInt_(v) {
const n = parseInt(v, 10);
return isNaN(n) ? 0 : n;
}
function _rb2_(v) {
return Math.round(v * 100) / 100;
}
function _rbStageSplit_(totalQty) {
if (totalQty <= 0) return [0, 0, 0];
if (totalQty < 3) return [totalQty, 0, 0];
const s1 = Math.max(1, Math.floor(totalQty * RB_STAGE_RATIOS[0]));
const s2 = Math.max(1, Math.floor(totalQty * RB_STAGE_RATIOS[1]));
const s3 = Math.max(0, totalQty - s1 - s2);
return [s1, s2, s3];
}
function _rbLimitPrice_(close, action) {
if (close <= 0) return 0;
return action === "SELL" ? Math.round(close * (1 - RB_LIMIT_PRICE_DISCOUNT)) : Math.round(close);
}
+21
View File
@@ -0,0 +1,21 @@
/**
* gas_data_feed.gs — Google Apps Script 버전 (compatibility stub)
*
* ⚠️ 이 파일은 P5-T02 GAS 역할 분리 작업의 호환성 스텁입니다.
* 실제 함수 구현은 src/gas_adapter_parts/ 아래 분리된 파일로 이동했습니다.
*
* gdf_01_price_metrics.gs — 가격 지표·RSI·Entry/Exit·점수·매도우선순위 (L1-L2347)
* gdf_02_harness_assembly.gs — 하네스 조립·라우팅·레짐·위성 (L2348-L4560)
* gdf_03_portfolio_gates.gs — 포트폴리오 게이트·섹터·액션·실행 (L4561-L6806)
* gdf_04_execution_quality.gs — 실행품질·Apex·PA1 피드백·매크로 (L6807-L9015)
* gdf_05_alpha_engines.gs — 알파엔진·서빙·거래품질·패턴 (L9016-L10302)
*
* GAS 프로젝트에 모든 파일을 함께 추가하면 동일한 글로벌 네임스페이스에서 동작합니다.
*
* 배포 방법:
* 1. script.google.com → 새 프로젝트
* 2. 이 파일 + src/gas_adapter_parts/gdf_*.gs + src/gas_adapter_parts/gdc_*.gs 붙여넣기
* 3. 트리거 설정: runDataFeed → 시간 기반 → 매일 → 16:30~17:30
*
* Ownership: data_feed 팀, QEDD P5-T02 GAS file split
*/
File diff suppressed because it is too large Load Diff
+446
View File
@@ -0,0 +1,446 @@
// gas_report.gs - Report & template generation
// getDailyBrief, getSummaryJson, getTradeTemplate
// Changes only when report format changes. Rarely touched during engine work.
// GAS global scope: functions in gas_lib.gs / gas_data_feed.gs callable directly
// ── E1: 일일 의사결정 브리핑 ─────────────────────────────────────────────────
// 시장 상태·포트폴리오 건강·액션 목록·주의 종목·7일 이벤트를 한 JSON으로 통합.
// doGet(?view=brief) 또는 cacheAllViews()에서 매일 1회 생성.
function getDailyBrief(sellPriorityViewInput) {
const macro = getMacroJson();
const settings = readSettingsTab_();
const port = getPortfolioJson();
const events = getEventRiskJson();
const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
const holdings = port.holdings ?? [];
// ── 액션 분류: Final_Action canonical 기준 (A-1/B-1 — Allowed_Action 기반 제거) ──
// Final_Action이 canonical output field. Allowed_Action은 중간 계산값.
const BUY_FINALS_ = new Set(["BUY_STAGE1_READY","BUY_BREAKOUT_PILOT_ONLY","BUY_PULLBACK_WAIT"]);
const SELL_FINALS_ = new Set(["SELL_READY"]);
const EXIT_FINALS_ = new Set(["EXIT_SIGNAL","EXIT_REVIEW"]);
const sellList = holdings.filter(h => SELL_FINALS_.has(h.Final_Action));
const exitList = holdings.filter(h => EXIT_FINALS_.has(h.Final_Action));
const buyList = holdings.filter(h => BUY_FINALS_.has(h.Final_Action));
const watchList = holdings.filter(h => h.Final_Action === "WATCH_TIMING_SETUP");
const holdList = holdings.filter(h =>
!SELL_FINALS_.has(h.Final_Action) && !EXIT_FINALS_.has(h.Final_Action) &&
!BUY_FINALS_.has(h.Final_Action) && h.Final_Action !== "WATCH_TIMING_SETUP"
);
// 주의 종목
const stage2Pass = holdings.filter(h => h.Stage2_Gate === "PASS");
const timeStopNear= holdings.filter(h => Number.isFinite(+h.Days_To_Time_Stop)
&& +h.Days_To_Time_Stop >= 0
&& +h.Days_To_Time_Stop <= 7);
const overweight = holdings.filter(h => h.Band_Status === "OVERWEIGHT");
const tp1Near = holdings.filter(h => Number.isFinite(+h.Profit_Pct) && +h.Profit_Pct >= 10);
// 포트폴리오 건강 판단
const heatVal = parseFloat(macro.total_heat_pct);
const fcVal = parseFloat(macro.fc_budget_pct);
const heatOk = Number.isFinite(heatVal) && heatVal < 10;
const heatCautionB= Number.isFinite(heatVal) && heatVal >= 7 && heatVal < 10;
const heatBlockB = Number.isFinite(heatVal) && heatVal >= 10;
const fcOk = Number.isFinite(fcVal) && fcVal < 100;
const regimeStr = String(macro.market_regime ?? "");
const isRiskOffB = regimeStr === "RISK_OFF" || regimeStr === "RISK_OFF_CANDIDATE";
const nrf = macro.net_return_feedback;
const orbitAdj= parseInt(macro.orbit_slot_adj) || 0;
// account_snapshot freshness 체크
const acctFresh = checkAccountSnapshotFreshness_();
// 텍스트 브리핑 (ChatGPT 직접 복붙용)
const L = [];
const hardBlockWarn = String(settings["cash_floor_hard_block_warning"] ?? "").trim();
const accountConfirmWarn = String(settings["account_snapshot_confirmation_warning"] ?? "").trim();
const cashLedgerWarn = String(settings["cash_ledger_warning"] ?? "").trim();
if (hardBlockWarn) L.push(`[긴급 경고] ${hardBlockWarn}`);
if (accountConfirmWarn) L.push(`[운영 경고] ${accountConfirmWarn}`);
if (cashLedgerWarn) L.push(`[운영 경고] ${cashLedgerWarn}`);
L.push(`[시장] ${macro.market_regime} / MRS ${macro.mrs_score}/10 / VIX ${macro.vix} / KOSPI ${macro.kospi} / USD/KRW ${macro.usd_krw}`);
const heatTag = heatBlockB ? "⚠HF005:BLOCK" : heatCautionB ? "⚠CAUTION:수량50%감액" : "OK";
L.push(`[포트폴리오] HEAT ${macro.total_heat_pct}%(${heatTag}) / FC ${macro.fc_budget_pct}%(${fcOk?"OK":"⚠EXHAUSTED"}) / ${nrf} / BUCKET ${macro.bucket_status}`);
if (isRiskOffB) L.push(`[⚠ 레짐 차단] ${regimeStr} — 신규 매수 전면 차단, 보유 종목 50% 단계 축소 검토`);
const bayesSourceTag = macro.bayesian_data_source === "actual" ? "실제거래기반" : "기본값(거래이력없음)";
L.push(`[Bayesian] ${macro.bayesian_label} (${macro.bayesian_multiplier}×) — ${bayesSourceTag}`);
if (acctFresh.fresh === false) L.push(`[⚠ account_snapshot STALE] ${acctFresh.reason} — 손절가·수량 재확인 필요`);
else if (acctFresh.fresh === null) L.push(`[⚠ account_snapshot] ${acctFresh.reason}`);
// 데이터 신선도 경고 — PRICE_STALE / PRICE_QUOTE_ONLY / FLOW_STALE
const priceStaleList_ = holdings.filter(h => h.Price_Status === "PRICE_STALE");
const quoteOnlyList_ = holdings.filter(h => h.Price_Status === "PRICE_QUOTE_ONLY");
const flowStaleList_ = holdings.filter(h => String(h.Missing_Fields ?? "").includes("FLOW_STALE"));
if (priceStaleList_.length)
L.push(`[⚠ 가격 스테일] ${priceStaleList_.map(h => h.Name).join(", ")} — OHLC 날짜 오래됨, runDataFeed 재실행 권장`);
if (quoteOnlyList_.length)
L.push(`[⚠ 호가전용] ${quoteOnlyList_.map(h => h.Name).join(", ")} — OHLC 수집 실패, MA/ATR 결측 → OBSERVE_ONLY 처리`);
if (flowStaleList_.length)
L.push(`[⚠ 수급 스테일] ${flowStaleList_.map(h => h.Name).join(", ")} — 외국인/기관 수급 날짜 오래됨`);
if (orbitAdj !== 0)
L.push(`[Orbit] ${macro.orbit_state} → 공격슬롯 ${orbitAdj>0?"+":""}${orbitAdj}개 / 현금조정 ${macro.orbit_cash_adj}%p`);
// ── C-1: Final_Action 기준 단일 우선순위 목록 ─────────────────────────────
// 우선순위 순서: SELL_READY > EXIT_* > BUY > WATCH > HOLD
// 같은 그룹 내에서는 Final_Rank(Priority_Score) 오름차순
const byRank = (arr) => [...arr].sort((a, b) => (+a.Final_Rank || 999) - (+b.Final_Rank || 999));
L.push("─".repeat(44));
L.push(`[오늘 액션] — ${today} (Final_Action 기준, 우선순위 정렬)`);
if (sellList.length) {
L.push(" ▶ SELL_READY (즉시 HTS 주문 가능)");
byRank(sellList).forEach((h, i) => {
const r = h.Action_Reason || `${h.Sell_Action} ${h.Sell_Qty}주 @${h.Sell_Limit_Price}`;
const p = h.Action_Params ? `\n ${h.Action_Params}` : "";
L.push(` ${i+1}. ${h.Name} → ${r}${p}`);
});
}
if (exitList.length) {
L.push(" ▶ EXIT_SIGNAL / REVIEW (캡처 → ChatGPT 수량 계산 후 매도)");
byRank(exitList).forEach((h, i) => {
const r = h.Action_Reason || `${h.Final_Action}(RW${h.RW_Partial})`;
const p = h.Action_Params ? ` | ${h.Action_Params}` : "";
L.push(` ${sellList.length+i+1}. ${h.Name}[${h.Final_Action}] → ${r}${p}`);
});
}
if (buyList.length) {
L.push(" ▶ BUY (진입 조건 충족)");
byRank(buyList).forEach((h, i) => {
const constr = h.Pos_Size_Constraint || "미계산*";
const rank_ = sellList.length + exitList.length + i + 1;
L.push(` ${rank_}. ${h.Name}[${h.Final_Action}] → ${h.Action_Reason || ""}`);
const params_ = h.Action_Params || `목표 ${h.Pos_Size_Qty}주[${constr}]`;
L.push(` ${params_}`);
});
}
if (watchList.length) {
L.push(" ▶ WATCH (타이밍 대기)");
byRank(watchList).forEach((h, i) => {
const rank_ = sellList.length + exitList.length + buyList.length + i + 1;
L.push(` ${rank_}. ${h.Name} → ${h.Action_Reason || `SS001:${h.SS001_Grade} 타이밍미충족`}`);
});
}
if (holdList.length) {
L.push(" ▶ HOLD / BLOCK");
byRank(holdList).forEach((h, i) => {
const rank_ = sellList.length + exitList.length + buyList.length + watchList.length + i + 1;
L.push(` ${rank_}. ${h.Name}[${h.Allowed_Action}] → ${h.Action_Reason || h.Allowed_Action}`);
});
}
if (!sellList.length && !exitList.length && !buyList.length && !watchList.length)
L.push(" HOLD — 오늘 액션 없음");
// 단일 진실원천: sell_priority는 반드시 runSellPriority() 결과만 사용
const sellPriorityView_ = sellPriorityViewInput || runSellPriority();
const _cashRaiseCands_ = Array.isArray(sellPriorityView_.sell_priority_table)
? sellPriorityView_.sell_priority_table
: [];
const _cashBelowTgt_ = isRiskOffB || (() => {
const cp = parseFloat(macro.immediate_cash_pct ?? macro.cash_pct ?? "");
const tp = parseFloat(macro.target_cash_pct ?? settings["weekly_target_cash_pct"] ?? "10");
return Number.isFinite(cp) && Number.isFinite(tp) && cp < tp;
})();
if (_cashBelowTgt_ && _cashRaiseCands_.length) {
L.push("─".repeat(44));
const gapReason = isRiskOffB
? `REGIME_TRIM_50 발동(${regimeStr})`
: `현금 부족 → sell_priority_engine`;
L.push(`[현금확보 매도우선순위] — ${gapReason}`);
L.push(" spec: ①하드스탑>②매도신호>③중복ETF>④손실위성>⑥익절>⑨코어주도주(마지막)");
L.push(" ⚠ 매도수량은 HTS 캡처 제공 후 결정 — 수량 미제공 시 수량 산출 금지(P1규칙)");
_cashRaiseCands_.slice(0, 8).forEach((c, i) => {
const pStr = (c.profit_pct !== "" && c.profit_pct !== null)
? ` (${Number(c.profit_pct) >= 0 ? "+" : ""}${Number(c.profit_pct).toFixed(1)}%)`
: "";
const etfTag = c.is_etf ? "[ETF]" : "";
const clTag = c.is_core_leader ? "[주도주⛔매도금지]" : "";
L.push(` ${i+1}. ${c.tier_label} ${c.name}${etfTag}${clTag} W:${c.weight_pct}%${pStr} RW:${c.rw_partial} Score:${c.sell_priority_score}`);
if (c.trim_style || c.rebound_holdback_score)
L.push(` └ trim=${c.trim_style || "N/A"} rebound_holdback=${c.rebound_holdback_score ?? 0}${c.rebound_holdback_reason ? ` | ${c.rebound_holdback_reason}` : ""}`);
if (c.action_params) L.push(` └ ${c.action_params}`);
if (c.hold_reason) L.push(` └ ⚠ ${c.hold_reason}`);
});
}
// 주의 종목 섹션
if (stage2Pass.length || timeStopNear.length || overweight.length || tp1Near.length) {
L.push("[주의]");
stage2Pass.forEach(h => L.push(` ${h.Name} Stage2_Gate=PASS → 2단계 진입 검토 (진입가 ${h.Limit_Price_Est ?? "N/A"})`));
timeStopNear.forEach(h => L.push(` ${h.Name} Time_Stop ${h.Days_To_Time_Stop}일 남음 (${h.Time_Stop_Date})`));
overweight.forEach(h => L.push(` ${h.Name} OVERWEIGHT ${h.Weight_Pct}% (상한 7%)`));
tp1Near.forEach(h => L.push(` ${h.Name} +${h.Profit_Pct}% → TP1(${h.TP1_Price}원) 근접`));
}
if (events.upcoming_7d?.length) {
L.push("[7일 이벤트]");
events.upcoming_7d.forEach(ev => L.push(` ${ev.Date}(D+${ev.DaysLeft}) ${ev.Event} [${ev.Impact}]`));
}
// brief_ — holdings row → JSON 요약 (API 소비자용)
const brief_ = (h) => ({
ticker: h.Ticker, name: h.Name,
final_action: h.Final_Action, // canonical output field
action_reason: h.Action_Reason, // 왜 이 액션인가
action_params: h.Action_Params, // 실행 파라미터 압축 (C-3)
final_rank: h.Final_Rank,
allowed_action: h.Allowed_Action,
ss001_grade: h.SS001_Grade, ss001_norm_score: h.SS001_Norm_Score,
rw_partial: h.RW_Partial,
weight_pct: h.Weight_Pct, profit_pct: h.Profit_Pct,
stage2_gate: h.Stage2_Gate, band_status: h.Band_Status,
limit_price_est: h.Limit_Price_Est,
stop_price_est: h.Stop_Price_Est, stop_price_source: h.Stop_Price_Source,
pos_size_qty: h.Pos_Size_Qty, pos_size_constraint: h.Pos_Size_Constraint,
tp1_price: h.TP1_Price, tp1_qty: h.TP1_Qty,
tp2_price: h.TP2_Price, tp2_qty: h.TP2_Qty,
entry_mode: h.Entry_Mode, entry_mode_gate: h.Entry_Mode_Gate,
entry_mode_reason: h.Entry_Mode_Reason,
timing_score_entry: h.Timing_Score_Entry,
timing_score_exit: h.Timing_Score_Exit,
timing_action: h.Timing_Action,
timing_block_reason: h.Timing_Block_Reason,
sell_action: h.Sell_Action,
sell_ratio_pct: h.Sell_Ratio_Pct,
sell_limit_price: h.Sell_Limit_Price,
sell_reason: h.Sell_Reason,
sell_validation: h.Sell_Validation,
cash_preserve_style: h.Cash_Preserve_Style || "",
cash_preserve_ratio: h.Cash_Preserve_Ratio || "",
cash_preserve_reason: h.Cash_Preserve_Reason || "",
rsi14: h.RSI14, disparity: h.Disparity, ma20_slope: h.MA20_Slope,
exit_signal_detail: h.Exit_Signal_Detail,
});
return {
date: today,
brief_text: L.join("\n"),
market: {
regime: macro.market_regime, mrs_score: macro.mrs_score,
vix: macro.vix, kospi: macro.kospi, usd_krw: macro.usd_krw,
sp500_ret5d: macro.sp500_ret5d,
},
portfolio_health: {
heat_pct: macro.total_heat_pct, heat_ok: heatOk,
heat_tag: heatTag,
heat_block: heatBlockB, heat_caution: heatCautionB,
fc_budget_pct: macro.fc_budget_pct, fc_ok: fcOk,
net_return_feedback: nrf,
bucket_status: macro.bucket_status,
regime_buy_blocked: isRiskOffB,
bayesian_label: macro.bayesian_label,
bayesian_multiplier: macro.bayesian_multiplier,
},
orbit: {
gap_pct: macro.orbit_gap_pct, state: macro.orbit_state,
slot_adjustment: orbitAdj, cash_adjustment: macro.orbit_cash_adj,
},
// Final_Action canonical 분류 (A-1/B-1)
actions: {
sell_ready: sellList.map(brief_),
exit_signals: exitList.map(brief_),
buy_signals: buyList.map(brief_),
watch_signals: watchList.map(brief_),
hold_signals: holdList.map(brief_),
},
alerts: {
stage2_ready: stage2Pass.map(h=>({ticker:h.Ticker,name:h.Name,profit_pct:h.Profit_Pct,limit_price_est:h.Limit_Price_Est})),
time_stop_near: timeStopNear.map(h=>({ticker:h.Ticker,name:h.Name,days_left:h.Days_To_Time_Stop,stop_date:h.Time_Stop_Date})),
overweight: overweight.map(h=>({ticker:h.Ticker,name:h.Name,weight_pct:h.Weight_Pct})),
tp1_near: tp1Near.map(h=>({ticker:h.Ticker,name:h.Name,profit_pct:h.Profit_Pct,tp1_price:h.TP1_Price,tp2_price:h.TP2_Price})),
},
upcoming_events: events.upcoming_7d,
account_snapshot_freshness: acctFresh,
data_quality: {
price_stale: priceStaleList_.map(h=>({ticker:h.Ticker,name:h.Name,price_date:h.Price_Date})),
quote_only: quoteOnlyList_.map(h=>({ticker:h.Ticker,name:h.Name})),
flow_stale: flowStaleList_.map(h=>({ticker:h.Ticker,name:h.Name,missing_fields:h.Missing_Fields})),
},
// sell_priority_engine 출력 (spec: portfolio_exposure.yaml:sell_priority_engine)
// 활성화: REGIME_TRIM_50 또는 현금 부족. ETF→손실위성→코어주도주 순서로 정렬.
cash_raise: _cashBelowTgt_ ? {
active: true,
reason: isRiskOffB ? `REGIME_TRIM_50(${regimeStr})` : "cash_below_target",
prohibition: "매도수량은 HTS 캡처 제공 후 결정. 수량 미제공 시 수량 기재 금지(spec:P1규칙).",
sell_priority_table: _cashRaiseCands_,
sector_exposure_summary: sellPriorityView_.sector_exposure ?? sellPriorityView_.sector_exposure_summary ?? {},
} : { active: false },
};
}
// ── E3: 거래 진입 템플릿 생성 ────────────────────────────────────────────────
// BUY_CANDIDATE/WATCH_CANDIDATE 종목에 대해 performance 탭 입력 행 + 진입 체크리스트 반환.
// doGet(?view=trade_template&ticker=064350)
function getTradeTemplate(ticker) {
if (!ticker) return { error: "ticker 파라미터 필요 (?view=trade_template&ticker=XXXXXX)" };
const allData = sheetToJson("data_feed");
const row = allData.find(r => String(r.Ticker) === String(ticker) || r.Name === ticker);
if (!row) return { error: `ticker ${ticker} not found in data_feed` };
const macro = getMacroJson();
const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
const sector = TICKER_SECTOR_MAP[ticker] ?? "N/A";
// 진입 체크리스트 — 각 항목 true/false
const checklist = {
data_quality: row.Price_Status === "PRICE_OK",
no_dart_risk: !row.DART_Risk || row.DART_Risk === "" || row.DART_Risk === "N",
liquidity_ok: row.Liquidity_Status === "OK",
timing_ready: ["BUY_STAGE1_READY","BUY_PULLBACK_WAIT","BUY_BREAKOUT_PILOT_ONLY"].includes(row.Timing_Action),
leader_gate: ["PASS","EXPLORE_CANDIDATE","WATCH_ONLY"].includes(row.Leader_Gate),
ac_gate: row.AC_Gate === "CLEAR",
flow_credit_ok: parseFloat(row.Flow_Credit) >= 0.4,
regime_ok: ["RISK_ON","SECULAR_LEADER_RISK_ON","LEADER_CONCENTRATION"].includes(macro.market_regime),
heat_ok: Number.isFinite(parseFloat(macro.total_heat_pct)) && parseFloat(macro.total_heat_pct) < 10,
fc_budget_ok: Number.isFinite(parseFloat(macro.fc_budget_pct)) && parseFloat(macro.fc_budget_pct) < 100,
nr_feedback_ok: macro.net_return_feedback !== "REDUCED",
ee_positive: parseFloat(row.EE_Est) > 0,
ss001_grade_ok: ["A","B"].includes(row.SS001_Grade),
};
const passCount = Object.values(checklist).filter(Boolean).length;
const totalCheck = Object.keys(checklist).length;
const gateStatus = passCount === totalCheck ? "ALL_PASS"
: passCount >= totalCheck - 2 ? "MINOR_ISSUES"
: "BLOCK";
return {
ticker,
name: row.Name,
sector,
generated_at: today,
gate_status: gateStatus,
gate_score: `${passCount}/${totalCheck}`,
checklist,
// performance 탭에 바로 붙여넣을 수 있는 행 템플릿
performance_tab_template: {
trade_id: `${today.replace(/-/g,"")}${ticker}`,
ticker,
sector,
entry_date: today,
entry_price: row.Limit_Price_Est ?? "",
entry_stage: "stage_1",
quantity: row.Pos_Size_Qty ?? "",
stop_price_at_entry: row.Stop_Price_Est ?? "",
target_price_at_entry: row.Target_Price ?? "",
exit_date: "",
exit_price: "",
exit_reason: "",
pnl_pct: "",
holding_days: "",
entry_c1_score: row.C1_Price ?? "",
entry_c2_score: row.C2_RelStr ?? "",
entry_c3_score: row.C3_VolSurge ?? "",
entry_c4_score: row.C4_Flow ?? "",
entry_c5_score: row.C5_Sector ?? "",
entry_mode: row.Entry_Mode ?? "",
entry_gate: row.Entry_Mode_Gate ?? "",
timing_action: row.Timing_Action ?? "",
timing_score_entry: row.Timing_Score_Entry ?? "",
timing_score_exit: row.Timing_Score_Exit ?? "",
anti_climax_gate: row.AC_Gate ?? "",
flow_credit: row.Flow_Credit ?? "",
entry_mrs_score: macro.mrs_score ?? "",
fc_bucket: "",
},
current_state: {
close: row.Close,
allowed_action: row.Allowed_Action,
timing_action: row.Timing_Action,
timing_score_entry: row.Timing_Score_Entry,
timing_score_exit: row.Timing_Score_Exit,
timing_block_reason: row.Timing_Block_Reason,
sell_action: row.Sell_Action,
sell_ratio_pct: row.Sell_Ratio_Pct,
sell_qty: row.Sell_Qty,
sell_limit_price: row.Sell_Limit_Price,
sell_price_source: row.Sell_Price_Source,
sell_reason: row.Sell_Reason,
sell_validation: row.Sell_Validation,
ss001_grade: row.SS001_Grade,
ss001_total: row.SS001_Total,
flow_credit: row.Flow_Credit,
rw_partial: row.RW_Partial,
limit_price_est: row.Limit_Price_Est,
stop_price_est: row.Stop_Price_Est,
stop_price_source: row.Stop_Price_Source,
ee_est: row.EE_Est,
pos_size_qty: row.Pos_Size_Qty,
upside_pct: row.Upside_Pct,
atr20: row.ATR20,
tp1_price: row.TP1_Price,
tp1_qty: row.TP1_Qty,
tp2_price: row.TP2_Price,
tp2_qty: row.TP2_Qty,
dart_risk: row.DART_Risk,
days_to_earnings: row.Days_To_Earnings,
},
};
}
function getSummaryJson() {
// ChatGPT 포트폴리오 분석에 최적화된 통합 뷰
const sectors = getSectorFlowJson();
const port = getPortfolioJson();
const macro = getMacroJson();
const events = getEventRiskJson();
// 포트폴리오 전체 수급 요약
const holdings = port.holdings;
const totalFrg5 = holdings.reduce((s,h) => s + (parseFloat(h.Frg_5D) || 0), 0);
const totalInst5 = holdings.reduce((s,h) => s + (parseFloat(h.Inst_5D) || 0), 0);
const flowOkCount = holdings.filter(h => h.Flow_OK === "Y").length;
// SS001 등급 분포 및 Allowed_Action 집계
const ss001Dist = { A: 0, B: 0, C: 0, D: 0 };
const actionDist = {};
holdings.forEach(h => {
const g = h["SS001_Grade"];
if (g in ss001Dist) ss001Dist[g]++;
const a = h["Allowed_Action"] || "UNKNOWN";
actionDist[a] = (actionDist[a] ?? 0) + 1;
});
return {
portfolio_flow_summary: {
total_holdings: holdings.length,
data_ok_count: flowOkCount,
portfolio_frg_5d_total: totalFrg5,
portfolio_inst_5d_total: totalInst5,
portfolio_indiv_5d_total: -(totalFrg5 + totalInst5),
},
ss001_grade_distribution: ss001Dist,
action_distribution: actionDist,
sector_summary: {
total_sectors: sectors.count,
top_inflow_sectors: sectors.top_inflow,
outflow_warning_sectors: sectors.outflow_warning,
strong_smart_money_sectors: sectors.strong_smart_money,
},
macro_snapshot: {
vix: macro.vix,
usd_krw: macro.usd_krw,
kospi: macro.kospi,
sp500_5d_ret: macro.sp500_ret5d,
market_regime: macro.market_regime,
mrs_score: macro.mrs_score,
bayesian_multiplier: macro.bayesian_multiplier,
total_heat_pct: macro.total_heat_pct,
fc_budget_pct: macro.fc_budget_pct,
net_return_feedback: macro.net_return_feedback,
orbit_gap_pct: macro.orbit_gap_pct,
orbit_state: macro.orbit_state,
orbit_slot_adj: macro.orbit_slot_adj,
bucket_status: macro.bucket_status,
bucket_detail: macro.bucket_detail,
},
event_alerts: events.upcoming_7d,
holdings_detail: holdings,
sector_detail: sectors.sectors,
macro_detail: macro.indicators,
macro_computed: macro.computed_summary,
};
}
@@ -2576,7 +2576,7 @@ function logDailyAssetHistory_(totalAsset, todayStr) {
}
// daily_history 시트 획득 또는 생성
var ss = SpreadsheetApp.getActiveSpreadsheet();
var ss = getSpreadsheet_();
var sheet = ss.getSheetByName("daily_history");
if (!sheet) {
sheet = ss.insertSheet("daily_history");
+101
View File
@@ -0,0 +1,101 @@
import json
import os
import requests
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CLASPRC_PATH = ROOT / ".clasprc.json"
CLASP_PATH = ROOT / ".clasp.json"
SPREADSHEET_ID = "1e1TNlLfnT69nvw-I1wU_oBHmEtI2pfbld3e0fFmtrZM"
OUTPUT_XLSX = ROOT / "GatherTradingData.xlsx"
def get_tokens():
if not CLASPRC_PATH.exists():
raise FileNotFoundError(".clasprc.json not found")
with open(CLASPRC_PATH, "r", encoding="utf-8") as f:
return json.load(f)["tokens"]["default"]
def get_script_id():
if not CLASP_PATH.exists():
raise FileNotFoundError(".clasp.json not found")
with open(CLASP_PATH, "r", encoding="utf-8") as f:
return json.load(f)["scriptId"]
def refresh_access_token(tokens):
url = "https://oauth2.googleapis.com/token"
payload = {
"grant_type": "refresh_token",
"refresh_token": tokens["refresh_token"],
"client_id": tokens["client_id"],
"client_secret": tokens["client_secret"],
}
resp = requests.post(url, data=payload)
resp.raise_for_status()
return resp.json()["access_token"]
def run_gas_function(script_id, access_token, function_name):
url = f"https://script.googleapis.com/v1/scripts/{script_id}:run"
headers = {"Authorization": f"Bearer {access_token}"}
payload = {"function": function_name, "devMode": True}
print(f"Executing GAS function: {function_name} ...")
resp = requests.post(url, headers=headers, json=payload)
# Handle response
if resp.status_code != 200:
print(f"Error executing function: HTTP {resp.status_code}")
print(resp.text)
return False
result = resp.json()
if "error" in result:
print(f"Function execution failed: {json.dumps(result['error'], indent=2)}")
# Check if error is because it's not deployed as API Executable (even if user said it is, common issues persist)
return False
print("Function execution triggered successfully.")
return True
def download_spreadsheet(spreadsheet_id, access_token, output_path):
print(f"Downloading spreadsheet {spreadsheet_id} as XLSX...")
# Using Drive API v3 to export Google Sheet as XLSX
url = f"https://www.googleapis.com/drive/v3/files/{spreadsheet_id}/export?mimeType=application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
headers = {"Authorization": f"Bearer {access_token}"}
resp = requests.get(url, headers=headers)
if resp.status_code == 403:
print("Error: 403 Forbidden. This usually means the OAuth token lacks the 'https://www.googleapis.com/auth/drive.readonly' or 'drive' scope.")
print("Please ensure your clasp login was done with proper scopes or manual token has Drive access.")
return False
resp.raise_for_status()
with open(output_path, "wb") as f:
f.write(resp.content)
print(f"Successfully downloaded to {output_path}")
return True
def main():
try:
tokens = get_tokens()
script_id = get_script_id()
access_token = refresh_access_token(tokens)
# Step 1: Execute GAS run_all
if run_gas_function(script_id, access_token, "run_all"):
print("Waiting a bit for GAS processes to finalize (optional)...")
time.sleep(5)
# Step 2: Download spreadsheet
if download_spreadsheet(SPREADSHEET_ID, access_token, OUTPUT_XLSX):
print("\nRoutine Part 1 & 2 complete.")
print("Final step: npm run prepare-upload-zip")
else:
print("\nDownload failed. Please check Drive API scopes.")
else:
print("\nGAS execution failed. Process aborted.")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
main()
+18 -6
View File
@@ -381,6 +381,9 @@ def main() -> int:
continue
seen_tickers.add(ticker)
df_row = df_row_map.get(ticker, {})
# SS001_Norm_Score: 0~100 신호 강도. 0은 데이터 없음 → 최소 가중치 1.0으로 폴백
ss001_raw = _f(df_row.get("SS001_Norm_Score"), default=-1.0)
ss001_norm = max(1.0, ss001_raw) if ss001_raw > 0 else 1.0
holdings.append({
"ticker": ticker,
"name": sp["name"] or _s(df_row.get("Name")),
@@ -392,6 +395,7 @@ def main() -> int:
"final_action": _s(df_row.get("Final_Action")),
"sell_reason": _s(df_row.get("Sell_Reason")),
"force_signal": _detect_force_signal(df_row),
"ss001_norm": ss001_norm,
})
# data_feed 에만 있는 보유 종목 보완 (snap 에 없는 경우 — 비정상이지만 방어)
@@ -403,6 +407,8 @@ def main() -> int:
acct_mv = _f(row.get("Account_Market_Value"))
if weight_pct <= 0 and acct_mv <= 0:
continue
ss001_raw = _f(row.get("SS001_Norm_Score"), default=-1.0)
ss001_norm = max(1.0, ss001_raw) if ss001_raw > 0 else 1.0
holdings.append({
"ticker": ticker,
"name": _s(row.get("Name")),
@@ -414,6 +420,7 @@ def main() -> int:
"final_action": _s(row.get("Final_Action")),
"sell_reason": _s(row.get("Sell_Reason")),
"force_signal": _detect_force_signal(row),
"ss001_norm": ss001_norm,
})
# ── 3. 버킷별 현재 비중 집계 ─────────────────────────────────────────────
@@ -462,17 +469,22 @@ def main() -> int:
})
# ── 4. 종목별 분석 ───────────────────────────────────────────────────────
# 버킷 내 equal-weight target
bucket_ticker_count: dict[str, int] = {}
# [WBS-3.2] 신호 가중 목표배분 (SS001_Norm_Score 기반)
# ticker_target_pct = bucket_target × (ss001_norm / Σ ss001_norm in bucket)
# 폴백: ss001_norm 모두 1.0 → equal_weight 와 동일
bucket_ss001_total: dict[str, float] = {}
for h in holdings:
bucket_ticker_count[h["bucket"]] = bucket_ticker_count.get(h["bucket"], 0) + 1
bucket_ss001_total[h["bucket"]] = (
bucket_ss001_total.get(h["bucket"], 0.0) + h.get("ss001_norm", 1.0)
)
ticker_rows: list[dict] = []
for h in holdings:
bname = h["bucket"]
bcfg = BUCKET_CONFIG.get(bname, BUCKET_CONFIG["Satellite"])
n_tickers = bucket_ticker_count.get(bname, 1)
target_pct = _round2(bcfg["target"] / n_tickers)
ss001_w = h.get("ss001_norm", 1.0)
bucket_ss001 = max(bucket_ss001_total.get(bname, ss001_w), 0.01)
target_pct = _round2(bcfg["target"] * ss001_w / bucket_ss001)
current_pct = _round2(h["weight_pct"])
drift = _round2(current_pct - target_pct)
band_min = _round2(target_pct - band["contract"])
@@ -612,7 +624,7 @@ def main() -> int:
out = {
"formula_id": FORMULA_ID,
"metadata": {
"per_ticker_target_method": "equal_weight_within_bucket",
"per_ticker_target_method": "signal_weighted_ss001_v1",
"regime_source": "macro.REGIME_PRELIM > settings > harness_context > computed_harness",
},
"summary": summary,
+111 -60
View File
@@ -1,91 +1,142 @@
"""deploy_gas.py -- WBS-5.2 GAS auto-deploy script
Bundles src/gas_adapter_parts/ + src/gas/ -> Temp/gas_deploy/ then clasp push.
Usage: python tools/deploy_gas.py [--dry-run] [--skip-push]
"""
import os
import shutil
import json
import argparse
import subprocess
from pathlib import Path
# Resolve project root dynamically relative to this script's directory (tools/)
ROOT = Path(__file__).resolve().parent.parent
SRC_PARTS = ROOT / "src" / "gas_adapter_parts"
SRC_GAS = ROOT / "src" / "gas"
DEPLOY_DIR = ROOT / "Temp" / "gas_deploy"
GAS_FILES = [
"gas_data_feed.gs",
"gas_data_collect.gs",
"gas_lib.gs",
"gas_harness_rows.gs",
"gas_report.gs",
"gas_event_calendar.gs",
"gas_apex_alpha_watch.gs",
"gas_apex_runtime_core.gs"
]
# Resolve a file from multiple candidate directories
def _find(filename: str) -> Path | None:
candidates = [
SRC_PARTS / filename,
SRC_GAS / "core" / filename,
SRC_GAS / "collection" / filename,
SRC_GAS / "engines" / filename,
SRC_GAS / "reports" / filename,
]
for c in candidates:
if c.exists():
return c
return None
def main():
print(f"Preparing deployment directory: {DEPLOY_DIR}")
# dst_name -> [src_file, ...] (concatenated in order)
BUNDLE_MAP: dict[str, list[str]] = {
"appsscript.json": ["appsscript.json"],
"gas_lib.gs": ["gas_lib.gs"],
"data_feed_base.gs": ["data_feed_base.gs"],
"gas_apex_runtime_core.gs":["gas_apex_runtime_core.gs"],
# gdc_01 + gdc_02 bundled as single file (GAS project legacy name)
"gas_data_collect.gs": [
"gdc_01_fetch_fundamentals.gs",
"gdc_02_account_satellite.gs",
],
"gdf_01_price_metrics.gs": ["gdf_01_price_metrics.gs"],
"gdf_02_harness_assembly.gs": ["gdf_02_harness_assembly.gs"],
"gdf_03_portfolio_gates.gs": ["gdf_03_portfolio_gates.gs"],
"gdf_04_execution_quality.gs":["gdf_04_execution_quality.gs"],
"gdf_05_alpha_engines.gs": ["gdf_05_alpha_engines.gs"],
"gdf_06_rebalance.gs": ["gdf_06_rebalance.gs"],
"gas_data_feed.gs": ["gas_data_feed.gs"],
"gas_harness_rows.gs": ["gas_harness_rows.gs"],
"gas_report.gs": ["gas_report.gs"],
"gas_event_calendar.gs": ["gas_event_calendar.gs"],
"gas_apex_alpha_watch.gs": ["gas_apex_alpha_watch.gs"],
}
SCRIPT_ID = "1xfeBAeeknmnBtSvrIqWXO_2hc3ByeriLUOSuOOB4YxLLHhN3zdnL7tVh"
def build_deploy(dry_run: bool = False) -> bool:
print("[deploy_gas] src_parts=" + str(SRC_PARTS))
print("[deploy_gas] src_gas= " + str(SRC_GAS))
print("[deploy_gas] dst= " + str(DEPLOY_DIR))
if not dry_run:
if DEPLOY_DIR.exists():
shutil.rmtree(DEPLOY_DIR)
DEPLOY_DIR.mkdir(parents=True, exist_ok=True)
# 1. Copy appsscript.json manifest
manifest_src = ROOT / "src" / "gas_adapter_parts" / "appsscript.json"
if manifest_src.exists():
shutil.copy(manifest_src, DEPLOY_DIR / "appsscript.json")
print(f"Copied appsscript.json from {manifest_src}")
else:
# Create default manifest
manifest = {
"timeZone": "Asia/Seoul",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8"
}
with open(DEPLOY_DIR / "appsscript.json", "w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
print("Created default appsscript.json")
ok = True
for dst_name, src_files in BUNDLE_MAP.items():
dst_path = DEPLOY_DIR / dst_name
parts: list[str] = []
for sf in src_files:
src_path = _find(sf)
if src_path is None:
print(" WARN: " + sf + " not found")
ok = False
continue
parts.append(src_path.read_text(encoding="utf-8"))
if not parts:
continue
content = "\n".join(parts)
tag = "[DRY]" if dry_run else "write"
print(" " + tag + " " + dst_name + " (" + str(len(content)) + " chars)")
if not dry_run:
dst_path.write_text(content, encoding="utf-8")
# 2. Copy GAS files from ROOT to DEPLOY_DIR
copied_count = 0
for gf in GAS_FILES:
src = ROOT / gf
if src.exists():
shutil.copy(src, DEPLOY_DIR / gf)
print(f"Copied {gf}")
copied_count += 1
else:
print(f"WARNING: Source file not found: {gf}")
# 3. Create/Overwrite .clasp.json with DEPLOY_DIR as rootDir
if not dry_run:
clasp_cfg = {
"scriptId": "1xfeBAeeknmnBtSvrIqWXO_2hc3ByeriLUOSuOOB4YxLLHhN3zdnL7tVh",
"rootDir": str(DEPLOY_DIR.relative_to(ROOT))
"scriptId": SCRIPT_ID,
"rootDir": str(DEPLOY_DIR.relative_to(ROOT)).replace("\\", "/"),
}
with open(ROOT / ".clasp.json", "w", encoding="utf-8") as f:
json.dump(clasp_cfg, f, ensure_ascii=False, indent=2)
print(f"Updated .clasp.json with rootDir={clasp_cfg['rootDir']}")
(ROOT / ".clasp.json").write_text(
json.dumps(clasp_cfg, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(" write .clasp.json -> rootDir=" + clasp_cfg["rootDir"])
return ok
# 4. Run clasp push with shell=True for Windows compatibility
print("Running npx @google/clasp push -f ...")
env = dict(os.environ)
def clasp_push() -> bool:
print("[deploy_gas] clasp push -f ...")
res = subprocess.run(
["npx", "@google/clasp", "push", "-f"],
cwd=str(ROOT),
env=env,
shell=True,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace"
errors="replace",
)
print(f"Return code: {res.returncode}")
print("STDOUT:")
print(res.stdout)
print("STDERR:")
print(res.stderr)
if res.stderr:
print("STDERR: " + res.stderr[:500])
if res.returncode == 0:
print("GAS deploy completed successfully!")
else:
print("GAS deploy failed!")
exit(1)
print("[deploy_gas] clasp push OK")
return True
print("[deploy_gas] clasp push FAILED rc=" + str(res.returncode))
return False
def main() -> None:
parser = argparse.ArgumentParser(description="GAS auto-deploy")
parser.add_argument("--dry-run", action="store_true", help="List files without writing")
parser.add_argument("--skip-push", action="store_true", help="Bundle only, skip clasp push")
args = parser.parse_args()
ok = build_deploy(dry_run=args.dry_run)
if not ok:
print("[deploy_gas] Some source files missing -- check warnings above")
raise SystemExit(1)
if args.dry_run or args.skip_push:
print("[deploy_gas] dry-run/skip-push -- push skipped")
return
if not clasp_push():
raise SystemExit(1)
print("[deploy_gas] Done. To run_all: python tools/automate_routine.py")
if __name__ == "__main__":
main()
+49
View File
@@ -0,0 +1,49 @@
import json
import os
import requests
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CLASPRC_PATH = ROOT / ".clasprc.json"
SPREADSHEET_ID = "1e1TNlLfnT69nvw-I1wU_oBHmEtI2pfbld3e0fFmtrZM"
OUTPUT_XLSX = ROOT / "GatherTradingData.xlsx"
def get_tokens():
if not CLASPRC_PATH.exists():
raise FileNotFoundError(".clasprc.json not found")
with open(CLASPRC_PATH, "r", encoding="utf-8") as f:
return json.load(f)["tokens"]["default"]
def refresh_access_token(tokens):
url = "https://oauth2.googleapis.com/token"
payload = {
"grant_type": "refresh_token",
"refresh_token": tokens["refresh_token"],
"client_id": tokens["client_id"],
"client_secret": tokens["client_secret"],
}
resp = requests.post(url, data=payload)
resp.raise_for_status()
return resp.json()["access_token"]
def download_spreadsheet(spreadsheet_id, access_token, output_path):
print(f"Downloading spreadsheet {spreadsheet_id} as XLSX...")
url = f"https://www.googleapis.com/drive/v3/files/{spreadsheet_id}/export?mimeType=application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
headers = {"Authorization": f"Bearer {access_token}"}
resp = requests.get(url, headers=headers)
resp.raise_for_status()
with open(output_path, "wb") as f:
f.write(resp.content)
print(f"Successfully downloaded to {output_path}")
def main():
try:
tokens = get_tokens()
access_token = refresh_access_token(tokens)
download_spreadsheet(SPREADSHEET_ID, access_token, OUTPUT_XLSX)
print("\nNext step: npm run prepare-upload-zip")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
main()