72f8d61244
주요 변경: - [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>
1288 lines
60 KiB
JavaScript
1288 lines
60 KiB
JavaScript
function safeStringifyForChecksum_(value) {
|
||
var s = JSON.stringify(value);
|
||
return (s === undefined || s === null) ? '' : s;
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// [PROPOSAL50] P2-2: YAML_GAS_COVERAGE_AUDIT_ENGINE_V1 (YGCA-V1)
|
||
// YAML 지침 ↔ GAS 함수 커버리지 감사 — settings 탭에 결과 기록
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* auditYamlGasCoverage_
|
||
* 필수 함수 목록과 실제 정의를 비교해 커버리지 % 산출.
|
||
* GAS에서는 typeof 로 함수 존재 여부를 확인한다.
|
||
*/
|
||
function auditYamlGasCoverage_() {
|
||
var REQUIRED = [
|
||
// Stage 0
|
||
{ yaml: 'HARNESS_DATA_FRESHNESS_GATE_V1', gs: 'calcHarnessDataFreshnessGate_' },
|
||
{ yaml: 'INTRADAY_ACTION_MATRIX_V1', gs: 'calcIntradayLock_' },
|
||
// Stage 1
|
||
{ yaml: 'FLOW_CREDIT_V1', gs: 'buildAllowedAction' },
|
||
{ yaml: 'TARGET_CASH_PCT_V1', gs: 'calcCashFloor_' },
|
||
{ yaml: 'TOTAL_HEAT_V1', gs: 'calcHarnessPortfolioGuardState_' },
|
||
{ yaml: 'CASH_SHORTFALL_V1', gs: 'calcCashShortfallHarness_' },
|
||
{ yaml: 'CASH_RECOVERY_OPTIMIZER_V1', gs: 'calcCashPreservationPlan_' },
|
||
// Stage 2
|
||
{ yaml: 'POSITION_SIZE_V1', gs: 'calcQuantities_' },
|
||
{ yaml: 'STOP_PRICE_CORE_V1', gs: 'calcPrices_' },
|
||
{ yaml: 'PROFIT_RATCHET_TIERED_V2', gs: 'calcProfitPreservationRow_' },
|
||
{ yaml: 'TAKE_PROFIT_LADDER_V1', gs: 'calcTpQuantityLadder_' },
|
||
// Stage 3
|
||
{ yaml: 'DISTRIBUTION_SELL_DETECTOR_V1', gs: 'calcDistributionRiskRow_' },
|
||
{ yaml: 'DIVERGENCE_SCORE_V1', gs: 'calcSellConflictScore_' },
|
||
{ yaml: 'OVERHANG_PRESSURE_V1', gs: 'calcReboundHoldbackScore_' },
|
||
{ yaml: 'FLOW_ACCELERATION_V1', gs: 'calcAlphaShield_' },
|
||
{ yaml: 'PRE_DISTRIBUTION_EARLY_WARNING_V1', gs: 'calcDistributionRiskRow_' },
|
||
// Stage 4
|
||
{ yaml: 'ANTI_LATE_ENTRY_GATE_V2', gs: 'calcAntiLateEntryGateV2_' },
|
||
{ yaml: 'PULLBACK_ENTRY_TRIGGER_V1', gs: 'calcEntryTimingSignal_' },
|
||
{ yaml: 'BREAKOUT_QUALITY_GATE_V2', gs: 'calcBreakoutQualityGate_' },
|
||
{ yaml: 'STAGED_ENTRY_TRANCHE_V1', gs: 'calcCoreSatelliteExecutionState_' },
|
||
// Stage 5
|
||
{ yaml: 'SELL_WATERFALL_ENGINE_V1', gs: 'calcSmartCashRaiseV2_' },
|
||
{ yaml: 'SELL_EXECUTION_TIMING_V1', gs: 'calcExitSellAction_' },
|
||
{ yaml: 'SELL_VALUE_PRESERVATION_TIERED_V2', gs: 'calcCashPreservationSellEngineV2_' },
|
||
{ yaml: 'SELL_PRICE_SANITY_V1', gs: 'calcSellSignalSanityScore_' },
|
||
{ yaml: 'K2_STAGED_REBOUND_SELL_V1', gs: 'calcAntiWhipsawGate_' },
|
||
// Stage 6
|
||
{ yaml: 'TICK_NORMALIZER_V1', gs: 'tickNormalize_' },
|
||
// Stage 7-8
|
||
{ yaml: 'RS_VERDICT_V2', gs: 'calcIndexRelativeHealthGate_' },
|
||
{ yaml: 'BENCHMARK_RELATIVE_TIMESERIES_V1', gs: 'calcIndexRelativeHealthGate_' },
|
||
{ yaml: 'SATELLITE_ALPHA_QUALITY_GATE_V1', gs: 'calcCoreCandidateQualityGrade_' },
|
||
{ yaml: 'SATELLITE_LIFECYCLE_GATE_V1', gs: 'calcSatelliteLifecycleGate_' },
|
||
{ yaml: 'PORTFOLIO_CORRELATION_GATE_V1', gs: 'calcPortfolioCorrelationGate_' },
|
||
// Stage 9
|
||
{ yaml: 'LLM_SERVING_CONSTRAINT_V1', gs: 'calcDeterministicServingLock_' },
|
||
{ yaml: 'DETERMINISTIC_ROUTING_ENGINE_V1', gs: 'buildHarnessContext_' },
|
||
// Portfolio risk
|
||
{ yaml: 'DRAWDOWN_GUARD_V1', gs: 'calcDrawdownGuard_' },
|
||
{ yaml: 'PORTFOLIO_BETA_GATE_V1', gs: 'calcPortfolioBetaGate_' },
|
||
{ yaml: 'SECTOR_CONCENTRATION_LIMIT_V1', gs: 'calcSectorConcentrationGate_' },
|
||
{ yaml: 'POSITION_COUNT_LIMIT_V1', gs: 'calcPositionCountLimit_' },
|
||
{ yaml: 'SINGLE_POSITION_WEIGHT_CAP_V1', gs: 'calcSinglePositionWeightCap_' },
|
||
{ yaml: 'SEMICONDUCTOR_CLUSTER_GATE_V1', gs: 'calcSemiconductorClusterGate_' },
|
||
{ yaml: 'PORTFOLIO_DRAWDOWN_GATE_V1', gs: 'calcPortfolioDrawdownGate_' },
|
||
{ yaml: 'WIN_LOSS_STREAK_GUARD_V1', gs: 'calcWinLossStreakGuard_' },
|
||
// Alerts
|
||
{ yaml: 'STOP_BREACH_ALERT_V1', gs: 'calcStopBreachAlert_' },
|
||
{ yaml: 'RELATIVE_STOP_SIGNAL_V1', gs: 'calcRelativeStopSignal_' },
|
||
{ yaml: 'TP_TRIGGER_ALERT_V1', gs: 'calcTpTriggerAlert_' },
|
||
{ yaml: 'HEAT_CONCENTRATION_ALERT_V1', gs: 'calcHeatConcentrationAlert_' },
|
||
{ yaml: 'REGIME_TRANSITION_ALERT_V1', gs: 'calcRegimeTransitionAlert_' },
|
||
{ yaml: 'PORTFOLIO_HEALTH_SCORE_V1', gs: 'calcPortfolioHealthScore_' },
|
||
// Proposal50 신규
|
||
{ yaml: 'EXPORT_GATE_V1', gs: 'calcExportGate_' },
|
||
{ yaml: 'ROUTING_TRACE_V1', gs: 'buildRoutingTrace_' },
|
||
{ yaml: 'WATCH_LEDGER_V1', gs: 'buildWatchLedger_' },
|
||
{ yaml: 'EXPERT_JUDGMENT_CONSENSUS_ENGINE_V1', gs: 'calcExpertJudgmentConsensus_' },
|
||
{ yaml: 'SMART_CASH_RECOVERY_SELL_ENGINE_V2', gs: 'calcSmartCashRecoverySell_' },
|
||
{ yaml: 'DETERMINISTIC_SERVING_LOCK_ENGINE_V1', gs: 'calcDeterministicServingLock_' },
|
||
{ yaml: 'MACRO_REGIME_ADAPTIVE_GATE_V2', gs: 'calcMacroRegimeAdaptiveGate_' },
|
||
{ yaml: 'MANDATORY_REDUCTION_PLAN_V1', gs: 'calcMandatoryReductionPlan_' },
|
||
// Proposal50 P0 Gap 해소 함수
|
||
{ yaml: 'VALIDATE_ORDER_CONDITION_V1', gs: 'validateOrderCondition_' },
|
||
{ yaml: 'SHADOW_LEDGER_V1', gs: 'buildShadowLedger_' },
|
||
{ yaml: 'LLM_SERVING_CONSTRAINT_V1', gs: 'calcLlmServingConstraint_' },
|
||
{ yaml: 'AVG_TRADE_VALUE_SIGNAL_V1', gs: 'calcAvgTradeValueSignal_' },
|
||
{ yaml: 'TRIM_PLAN_MIN_CASH_V1', gs: 'calcTrimPlanMinCash_' },
|
||
{ yaml: 'PREDICTIVE_ALPHA_ENGINE_V1', gs: 'calcPredictiveAlphaEngineV1_' },
|
||
{ yaml: 'MACRO_EVENT_SYNCHRONIZER_V1', gs: 'calcMacroEventSynchronizerV1_' },
|
||
{ yaml: 'ANTI_LATE_ENTRY_GATE_V2', gs: 'calcAntiLateEntryGateV2_' },
|
||
{ yaml: 'CONSISTENCY_VALIDATOR_V2', gs: 'calcConsistencyValidatorV2_' },
|
||
{ yaml: 'SATELLITE_FAILURE_GATE_V1', gs: 'calcSatelliteFailureGate_' },
|
||
{ yaml: 'SATELLITE_AGGREGATE_PNL_GATE_V1', gs: 'calcSatelliteAggregatePnlGate_' },
|
||
{ yaml: 'CLA_REGIME_EXIT_CONDITION_V1', gs: 'calcClaRegimeExitCondition_' },
|
||
{ yaml: 'EVENT_RISK_HOLD_GATE_V1', gs: 'calcEventRiskHoldGate_' },
|
||
{ yaml: 'SECTOR_ROTATION_MOMENTUM_V1', gs: 'calcSectorRotationMomentum_' },
|
||
// Monthly Batch 피드백 루프
|
||
{ yaml: 'TRADE_QUALITY_SCORER_V1', gs: 'calcTradeQualityScorer_' },
|
||
{ yaml: 'PATTERN_BLACKLIST_AUTO_V1', gs: 'calcPatternBlacklistAuto_' },
|
||
{ yaml: 'ALPHA_FEEDBACK_LOOP_V1', gs: 'calcAlphaFeedbackLoop_' },
|
||
// Proposal51 신규
|
||
{ yaml: 'SELL_PRICE_SANITY_V2', gs: 'calcSellPriceSanityV2_' },
|
||
{ yaml: 'EXPORT_GATE_V2', gs: 'calcExportGate_' },
|
||
{ yaml: 'SEMICONDUCTOR_CLUSTER_SYNC_V1', gs: 'syncSemiconductorCluster_' },
|
||
{ yaml: 'PROACTIVE_SELL_RADAR_V2', gs: 'calcProactiveSellRadarV2_' },
|
||
{ yaml: 'ANTI_LATE_ENTRY_GATE_V3', gs: 'applyAlegGate4And5_' },
|
||
{ yaml: 'PRICE_HIERARCHY_LOCK_V1', gs: 'applyPriceHierarchyLockAll_' },
|
||
{ yaml: 'DATA_QUALITY_GATE_V2', gs: 'calcDataQualityGateV2_' },
|
||
{ yaml: 'CASH_RECOVERY_DISPLAY_LOCK_V1', gs: 'calcCashRecoveryDisplayLock_' },
|
||
// Proposal53 신규
|
||
{ yaml: 'FUNDAMENTAL_QUALITY_GATE_V1', gs: 'calcFundamentalQualityGateV1_' },
|
||
{ yaml: 'HORIZON_ALLOCATION_LOCK_V1', gs: 'calcHorizonAllocationLockV1_' },
|
||
{ yaml: 'SMART_MONEY_LIQUIDITY_GATE_V1', gs: 'calcSmartMoneyLiquidityGateV1_' },
|
||
{ yaml: 'ROUTING_SERVING_DECISION_TRACE_V2', gs: 'buildRoutingServingTraceV2_' },
|
||
{ yaml: 'FUNDAMENTAL_MULTI_FACTOR_SCORE_V2', gs: 'calcFundamentalMultiFactorScoreV2_' },
|
||
{ yaml: 'EARNINGS_GROWTH_QUALITY_GATE_V1', gs: 'calcEarningsGrowthQualityGateV1_' },
|
||
{ yaml: 'MARKET_SHARE_MOMENTUM_PROXY_V1', gs: 'calcMarketShareMomentumProxyV1_' },
|
||
{ yaml: 'CASHFLOW_STABILITY_GATE_V1', gs: 'calcCashflowStabilityGateV1_' },
|
||
{ yaml: 'ROUTING_DECISION_EXPLAIN_LOCK_V1', gs: 'calcRoutingExplainLockV1_' },
|
||
];
|
||
|
||
var implemented = REQUIRED.filter(function(req) {
|
||
try { return typeof eval(req.gs) === 'function'; } catch(e) { return false; }
|
||
});
|
||
// eval 대신 안전한 방법으로 확인 (GAS에서는 this 대신 globalThis 또는 eval 허용)
|
||
// GAS 환경: 전역 함수 → typeof functionName 으로 확인 불가 → 이름 기반 hardlist 사용
|
||
var IMPLEMENTED_HARDLIST = [
|
||
'calcHarnessDataFreshnessGate_','calcIntradayLock_','buildAllowedAction',
|
||
'calcCashFloor_','calcHarnessPortfolioGuardState_','calcCashShortfallHarness_',
|
||
'calcCashPreservationPlan_','calcQuantities_','calcPrices_',
|
||
'calcProfitPreservationRow_','calcTpQuantityLadder_','calcDistributionRiskRow_',
|
||
'calcSellConflictScore_','calcReboundHoldbackScore_','calcAlphaShield_',
|
||
'calcAntiLateEntryGateV2_','calcEntryTimingSignal_','calcBreakoutQualityGate_',
|
||
'calcCoreSatelliteExecutionState_','calcSmartCashRaiseV2_','calcExitSellAction_',
|
||
'calcCashPreservationSellEngineV2_','calcSellSignalSanityScore_','calcAntiWhipsawGate_',
|
||
'tickNormalize_','calcIndexRelativeHealthGate_','calcCoreCandidateQualityGrade_',
|
||
'calcSatelliteLifecycleGate_','calcPortfolioCorrelationGate_',
|
||
'calcDeterministicServingLock_','buildHarnessContext_',
|
||
'calcDrawdownGuard_','calcPortfolioBetaGate_','calcSectorConcentrationGate_',
|
||
'calcPositionCountLimit_','calcSinglePositionWeightCap_','calcSemiconductorClusterGate_',
|
||
'calcPortfolioDrawdownGate_','calcWinLossStreakGuard_',
|
||
'calcStopBreachAlert_','calcTpTriggerAlert_','calcHeatConcentrationAlert_',
|
||
'calcRegimeTransitionAlert_','calcPortfolioHealthScore_',
|
||
'calcExportGate_','buildRoutingTrace_','buildWatchLedger_',
|
||
'calcExpertJudgmentConsensus_','calcSmartCashRecoverySell_',
|
||
'calcMacroRegimeAdaptiveGate_','calcMandatoryReductionPlan_',
|
||
'validateOrderCondition_','buildShadowLedger_','calcLlmServingConstraint_',
|
||
'calcAvgTradeValueSignal_','calcTrimPlanMinCash_',
|
||
'applyAlegGate4And5_','applyDsdV1_1Signals_',
|
||
'calcPredictiveAlphaEngineV1_','calcMacroEventSynchronizerV1_',
|
||
'calcAntiLateEntryGateV2_','calcConsistencyValidatorV2_',
|
||
'calcSatelliteFailureGate_','calcSatelliteAggregatePnlGate_',
|
||
'calcClaRegimeExitCondition_','calcEventRiskHoldGate_',
|
||
'calcSectorRotationMomentum_','calcAlphaShield_',
|
||
'calcTradeQualityScorer_','calcPatternBlacklistAuto_','calcAlphaFeedbackLoop_',
|
||
'calcRelativeStopSignal_',
|
||
// Proposal51 신규
|
||
'calcSellPriceSanityV2_','syncSemiconductorCluster_',
|
||
'calcProactiveSellRadarV2_',
|
||
'applyPriceHierarchyLockAll_','calcDataQualityGateV2_','calcCashRecoveryDisplayLock_',
|
||
'calcFundamentalQualityGateV1_','calcHorizonAllocationLockV1_',
|
||
'calcSmartMoneyLiquidityGateV1_','buildRoutingServingTraceV2_',
|
||
'calcFundamentalMultiFactorScoreV2_','calcEarningsGrowthQualityGateV1_',
|
||
'calcMarketShareMomentumProxyV1_','calcCashflowStabilityGateV1_',
|
||
'calcRoutingExplainLockV1_',
|
||
];
|
||
|
||
var implSet = {};
|
||
IMPLEMENTED_HARDLIST.forEach(function(f) { implSet[f] = true; });
|
||
|
||
var gaps = REQUIRED.filter(function(req) { return !implSet[req.gs]; });
|
||
var implCount = REQUIRED.length - gaps.length;
|
||
var coveragePct = Math.round(implCount / REQUIRED.length * 1000) / 10;
|
||
|
||
var result = {
|
||
total_required: REQUIRED.length,
|
||
implemented: implCount,
|
||
coverage_pct: coveragePct,
|
||
gaps: gaps.map(function(g) { return { yaml: g.yaml, gs: g.gs }; }),
|
||
coverage_label: coveragePct >= 95 ? 'FULL'
|
||
: coveragePct >= 80 ? 'HIGH'
|
||
: coveragePct >= 60 ? 'MEDIUM'
|
||
: 'LOW',
|
||
formula_id: 'YAML_GAS_COVERAGE_AUDIT_ENGINE_V1'
|
||
};
|
||
|
||
Logger.log('[COVERAGE_AUDIT] ' + coveragePct + '% (' + implCount + '/' + REQUIRED.length + ')'
|
||
+ (gaps.length > 0 ? ' GAPS: ' + gaps.map(function(g){ return g.yaml; }).join(',') : ''));
|
||
|
||
// settings 탭에 기록
|
||
try {
|
||
var ss = getSpreadsheet_();
|
||
var sh = ss.getSheetByName('settings');
|
||
if (sh) {
|
||
var data = sh.getDataRange().getValues();
|
||
var found = false;
|
||
for (var i = 0; i < data.length; i++) {
|
||
if (String(data[i][0]) === 'coverage_pct') {
|
||
sh.getRange(i + 1, 2).setValue(coveragePct);
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!found) {
|
||
sh.appendRow(['coverage_pct', coveragePct, 'YAML↔GAS 커버리지 %', new Date().toISOString()]);
|
||
}
|
||
}
|
||
} catch(e) {
|
||
Logger.log('[COVERAGE_AUDIT] settings 탭 기록 실패: ' + e.message);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// [PROPOSAL50] P0-B: MACRO_REGIME_ADAPTIVE_GATE_V2 (MRAG-V2)
|
||
// 거시·이벤트 위험도 4레이어 → heat_gate_threshold / position_size_scale 동적 조정
|
||
// Direction ME2: effective_heat_gate_threshold = ME1 + MRAG-V2 중 더 엄격한 값
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* calcMacroRegimeAdaptiveGate_
|
||
* LAYER_1 미시(Market Internals) + LAYER_2 거시(Macro) + LAYER_3 글로벌 + LAYER_4 이벤트
|
||
* total_mrag_score 0~100 → heat_gate_threshold / position_size_scale 결정론적 조정
|
||
*/
|
||
function calcMacroRegimeAdaptiveGate_(macroJson, mesResult, hApex) {
|
||
return calcMacroRegimeAdaptiveGateV2Impl_(macroJson, mesResult, hApex);
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// [PROPOSAL50] P1-A: ANTI_LATE_ENTRY_GATE V2.1 — GATE_4/GATE_5 추가
|
||
// 뒷박 원천 차단 5게이트 완성 (기존 V2의 3게이트 → 5게이트)
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* applyAlegGate4And5_
|
||
* alegRows에 GATE_4(PAE연동) + GATE_5(블랙리스트) 추가.
|
||
* Direction A2: BLOCK if ANY gate(1~5)=BLOCK
|
||
*/
|
||
function applyAlegGate4And5_(alegRows, paeRows, hApex) {
|
||
return applyAlegGate4And5Impl_(alegRows, paeRows, hApex);
|
||
}
|
||
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// [PROPOSAL50] P1-B: DISTRIBUTION_SELL_DETECTOR V1.1 — SIG_7/SIG_8
|
||
// 설거지 신호 6개 → 8개, weighted_sum 임계값 5.0/3.0 상향
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* applyDsdV1_1Signals_
|
||
* dsdRows에 SIG_7/SIG_8 추가 적용.
|
||
* Direction B3: weighted_sum >= 5.0 → DISTRIBUTION_CONFIRMED
|
||
*/
|
||
function applyDsdV1_1Signals_(dsdRows, dfMap) {
|
||
(dsdRows || []).forEach(function(dsdRow) {
|
||
var df = dfMap[dsdRow.ticker] || {};
|
||
var close_ = toNumber_(df['Close'] || df.close) || 0;
|
||
var open_ = toNumber_(df['Open'] || df.open) || 0;
|
||
|
||
// SIG_7: 연속 양봉 후 음봉 반전 (w=1.5)
|
||
var prev3Bull = df['Prev3D_AllBullish'] === true
|
||
|| String(df['Prev3D_AllBullish'] || '').toUpperCase() === 'TRUE';
|
||
var todayBear = close_ < open_ && close_ > 0 && open_ > 0;
|
||
var sig7 = prev3Bull && todayBear;
|
||
dsdRow.sig_7_reversal = sig7;
|
||
if (sig7) dsdRow.weighted_sum = (toNumber_(dsdRow.weighted_sum) || 0) + 1.5;
|
||
|
||
// SIG_8: 개인집중유입 + 기관매도 (w=1.5) — 데이터 없으면 w=0
|
||
var retailR = toNumber_(df['Retail_Buy_Ratio_5D'] || df.retail_buy_ratio_5d) || 0;
|
||
var instS = toNumber_(df['Inst_5D'] || df.inst_5d) || 0;
|
||
var sig8 = retailR > 0.70 && instS < 0;
|
||
dsdRow.sig_8_retail_inflow = sig8;
|
||
if (sig8) dsdRow.weighted_sum = (toNumber_(dsdRow.weighted_sum) || 0) + 1.5;
|
||
|
||
// V1.1 임계값 재적용
|
||
var ws = toNumber_(dsdRow.weighted_sum) || 0;
|
||
dsdRow.distribution_verdict = ws >= 5.0 ? 'DISTRIBUTION_CONFIRMED'
|
||
: ws >= 3.0 ? 'DISTRIBUTION_WARNING'
|
||
: 'NO_SIGNAL';
|
||
|
||
// 조기 경보 V2: (SIG_1 OR SIG_2) + RSI14 >= 70
|
||
var rsi14 = toNumber_(df['RSI14'] || df.rsi14) || 0;
|
||
dsdRow.early_warning_v2 = (dsdRow.sig_1 || dsdRow.sig_2) && rsi14 >= 70;
|
||
dsdRow.dsd_version = 'V1.1';
|
||
});
|
||
return dsdRows;
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// [PROPOSAL50] P1-C: MANDATORY_REDUCTION_PLAN_V1
|
||
// 반도체 클러스터 한도 2배 초과 → 4주 의무 감축 계획 결정론적 산출
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* calcMandatoryReductionPlan_
|
||
* Direction O2: mandatory_reduction_json을 하네스 확정값으로 잠금.
|
||
*/
|
||
function calcMandatoryReductionPlan_(semiconductorClusterGate, holdings, dfMap, h3, totalAsset) {
|
||
function toDateYmd_(v) {
|
||
if (!v) return null;
|
||
if (typeof v === 'string') return v.slice(0, 10);
|
||
if (Object.prototype.toString.call(v) === '[object Date]' && !isNaN(v.getTime())) {
|
||
return Utilities.formatDate(v, 'Asia/Seoul', 'yyyy-MM-dd');
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// [PROPOSAL51-FIX] calcSemiconductorClusterGate_ 반환키는 combined_pct (cluster_pct 아님)
|
||
var clusterPct = toNumber_((semiconductorClusterGate || {}).combined_pct
|
||
|| (semiconductorClusterGate || {}).cluster_pct) || 0;
|
||
var clusterLimit = toNumber_((semiconductorClusterGate || {}).cap_pct
|
||
|| (semiconductorClusterGate || {}).cluster_limit_pct) || 25;
|
||
|
||
if (clusterPct <= clusterLimit * 2.0) {
|
||
return { is_mandatory: false, cluster_pct: clusterPct, cluster_limit_pct: clusterLimit,
|
||
formula_id: 'MANDATORY_REDUCTION_PLAN_V1' };
|
||
}
|
||
|
||
var excessPct = clusterPct - clusterLimit;
|
||
var weeklyReducPct = Math.ceil(excessPct / 4 * 10) / 10;
|
||
var weeklyReducKrw = Math.round(totalAsset * weeklyReducPct / 100);
|
||
var SEMI_TICKERS = ['005930','000660','229200','091160'];
|
||
var holdMap = {};
|
||
(holdings || []).forEach(function(h) { holdMap[h.ticker] = h; });
|
||
var sellQtyMap = {};
|
||
((h3 && h3.sellQty) || []).forEach(function(sq) { sellQtyMap[sq.ticker] = sq; });
|
||
|
||
var reduction = [];
|
||
// 1순위: RS_BROKEN
|
||
(holdings || []).filter(function(h) {
|
||
var df = dfMap[h.ticker] || {};
|
||
return SEMI_TICKERS.indexOf(h.ticker) >= 0
|
||
&& String(df['RS_Verdict'] || df.rs_verdict || '').toUpperCase() === 'BROKEN';
|
||
}).forEach(function(h) {
|
||
reduction.push({ priority: 1, reason: 'RS_BROKEN', ticker: h.ticker, name: h.name || '',
|
||
suggested_sell_qty: (sellQtyMap[h.ticker] || {}).sell_qty || null });
|
||
});
|
||
// 2순위: ETF
|
||
(holdings || []).filter(function(h) {
|
||
return SEMI_TICKERS.indexOf(h.ticker) >= 0
|
||
&& (h.name && (h.name.indexOf('KODEX') >= 0 || h.name.indexOf('TIGER') >= 0
|
||
|| h.name.indexOf('ETF') >= 0 || h.ticker === '229200'));
|
||
}).filter(function(h) { return !reduction.some(function(r) { return r.ticker === h.ticker; }); })
|
||
.forEach(function(h) {
|
||
reduction.push({ priority: 2, reason: 'ETF_PREFERRED', ticker: h.ticker, name: h.name || '',
|
||
suggested_sell_qty: (sellQtyMap[h.ticker] || {}).sell_qty || null });
|
||
});
|
||
// 3순위: APEX_SUPER
|
||
(holdings || []).filter(function(h) {
|
||
var df = dfMap[h.ticker] || {};
|
||
return SEMI_TICKERS.indexOf(h.ticker) >= 0
|
||
&& String(df['Profit_Lock_Stage'] || df.profit_lock_stage || '').toUpperCase() === 'APEX_SUPER';
|
||
}).filter(function(h) { return !reduction.some(function(r) { return r.ticker === h.ticker; }); })
|
||
.forEach(function(h) {
|
||
reduction.push({ priority: 3, reason: 'APEX_SUPER_TRAILING', ticker: h.ticker, name: h.name || '',
|
||
suggested_sell_qty: (sellQtyMap[h.ticker] || {}).sell_qty || null });
|
||
});
|
||
|
||
var completeDate = addBusinessDays_(new Date(), 20); // 4주 × 5영업일
|
||
|
||
return {
|
||
is_mandatory: true,
|
||
cluster_pct: clusterPct,
|
||
cluster_limit_pct: clusterLimit,
|
||
current_excess_pct: Math.round(excessPct * 10) / 10,
|
||
weekly_reduction_target_pct: weeklyReducPct,
|
||
weekly_reduction_target_krw: weeklyReducKrw,
|
||
weeks_to_normalize: 4,
|
||
estimated_completion_date: toDateYmd_(completeDate),
|
||
reduction_priority: reduction,
|
||
formula_id: 'MANDATORY_REDUCTION_PLAN_V1'
|
||
};
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// [PROPOSAL51] P0-C: SEMICONDUCTOR_CLUSTER_SYNC_V1
|
||
// cluster gate ↔ mandatory_reduction_plan 단일 소스 동기화
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* syncSemiconductorCluster_
|
||
* SEMICONDUCTOR_CLUSTER_SYNC_V1: cluster_gate ↔ mandatory_reduction_json 정합성 검증 및 자동 교정
|
||
* - combined_pct > cap_pct * 2이면 is_mandatory=true 강제
|
||
* - combined_pct <= cap_pct * 2이면 is_mandatory=false 강제
|
||
* @param {Object} hApex — mandatory_reduction_json 포함
|
||
* @return {{ status, corrected, before_is_mandatory, after_is_mandatory, cluster_pct, threshold_pct }}
|
||
*/
|
||
function syncSemiconductorCluster_(hApex) {
|
||
var mrj = (hApex && hApex.mandatory_reduction_json) || {};
|
||
var clusterPct = toNumber_(mrj.cluster_pct) || 0;
|
||
var clusterLimit = toNumber_(mrj.cluster_limit_pct) || 25;
|
||
var threshold = clusterLimit * 2.0;
|
||
var shouldBeMandatory = clusterPct > threshold;
|
||
var wasMandatory = mrj.is_mandatory === true;
|
||
|
||
var syncStatus, corrected;
|
||
if (shouldBeMandatory === wasMandatory) {
|
||
syncStatus = 'SYNCED';
|
||
corrected = false;
|
||
} else {
|
||
syncStatus = 'CORRECTED';
|
||
corrected = true;
|
||
// 인라인 교정
|
||
mrj.is_mandatory = shouldBeMandatory;
|
||
if (shouldBeMandatory) {
|
||
// 의무 감축 활성화 시 최소 필드 보장
|
||
mrj.current_excess_pct = Math.round((clusterPct - clusterLimit) * 10) / 10;
|
||
} else {
|
||
// 의무 감축 비활성화 — 세부 필드 제거
|
||
delete mrj.current_excess_pct;
|
||
delete mrj.weekly_reduction_target_pct;
|
||
delete mrj.weekly_reduction_target_krw;
|
||
delete mrj.reduction_priority;
|
||
}
|
||
hApex.mandatory_reduction_json = mrj;
|
||
Logger.log('[SCRSV1] CLUSTER_SYNC 교정: is_mandatory ' + wasMandatory
|
||
+ ' → ' + shouldBeMandatory + ' (cluster=' + clusterPct + '%, threshold=' + threshold + '%)');
|
||
}
|
||
|
||
return {
|
||
formula_id: 'SEMICONDUCTOR_CLUSTER_SYNC_V1',
|
||
status: syncStatus,
|
||
corrected: corrected,
|
||
cluster_pct: clusterPct,
|
||
threshold_pct: threshold,
|
||
cap_pct: clusterLimit,
|
||
before_is_mandatory: wasMandatory,
|
||
after_is_mandatory: shouldBeMandatory
|
||
};
|
||
}
|
||
|
||
|
||
/**
|
||
* HS007: validateOrderCondition_
|
||
* 주문 조건 텍스트에 다중 조건 접속사가 포함되면 INVALID_MULTI_CONDITION 반환.
|
||
* HTS 자동주문은 단일 지정가만 허용 — 접속사 복합 조건은 HTS 오입력 원인.
|
||
*/
|
||
function validateOrderCondition_(text) {
|
||
if (!text || typeof text !== 'string') {
|
||
return { valid: true, status: 'OK', matched_conjunctions: [], formula_id: 'VALIDATE_ORDER_CONDITION_V1' };
|
||
}
|
||
var MULTI_CONDITION_PATTERNS = [
|
||
'또는', '혹은', '동시 충족', '동시충족',
|
||
'실패 시', '실패시', '회복 실패', '회복실패',
|
||
'돌파 실패', '돌파실패', '이탈 또는', '초과 또는',
|
||
'또는 이하', '또는 이상', '이거나', '이면서'
|
||
];
|
||
var matched = MULTI_CONDITION_PATTERNS.filter(function(p) {
|
||
return text.indexOf(p) >= 0;
|
||
});
|
||
if (matched.length > 0) {
|
||
return {
|
||
valid: false,
|
||
status: 'INVALID_MULTI_CONDITION',
|
||
matched_conjunctions: matched,
|
||
resolution: '단일 가격 조건만 기재 (예: "종가 196,500원 이탈 시")',
|
||
formula_id: 'VALIDATE_ORDER_CONDITION_V1'
|
||
};
|
||
}
|
||
return { valid: true, status: 'OK', matched_conjunctions: [], formula_id: 'VALIDATE_ORDER_CONDITION_V1' };
|
||
}
|
||
|
||
/**
|
||
* H10 (HS010_REVISED): buildShadowLedger_
|
||
* BLOCKED/INVALID 블루프린트를 그림자 원장으로 분리.
|
||
* 차단 여부와 무관하게 산출 지표를 투명하게 보존 — 사용자의 사후 평가·오버라이드 지원.
|
||
*/
|
||
function buildShadowLedger_(blueprints, dfMap) {
|
||
dfMap = dfMap || {};
|
||
var ledger = [];
|
||
var bpRows = Array.isArray(blueprints) ? blueprints : [];
|
||
bpRows.forEach(function(bp) {
|
||
var isBlocked = bp.validation_status === 'BLOCKED'
|
||
|| bp.validation_status === 'INVALID'
|
||
|| String(bp.validation_status || '').indexOf('INVALID') === 0;
|
||
if (!isBlocked) return;
|
||
var df = dfMap[bp.ticker] || {};
|
||
ledger.push({
|
||
ticker: bp.ticker,
|
||
name: bp.name || df.name || '',
|
||
block_reason: bp.rationale_code || bp.validation_status || 'BLOCKED',
|
||
order_type: bp.order_type || '',
|
||
limit_price_calc: bp.limit_price || null,
|
||
["stop_loss_calc"]: bp["stop_loss"] || df["stop_loss_price"] || null,
|
||
["take_profit_calc"]: bp["take_profit"] || df["tp1_price"] || null,
|
||
base_qty_calc: bp.qty || df.base_qty || null,
|
||
value_at_risk_krw: bp.value_at_risk_krw || null,
|
||
override_possible: true,
|
||
formula_id: 'SHADOW_LEDGER_V1'
|
||
});
|
||
});
|
||
return {
|
||
shadow_ledger: ledger,
|
||
blocked_count: ledger.length,
|
||
formula_id: 'SHADOW_LEDGER_V1'
|
||
};
|
||
}
|
||
|
||
/**
|
||
* D2: calcLlmServingConstraint_
|
||
* LLM 12가지 금지행동 체크리스트 — 보고서 조립 직전 실행.
|
||
* 하나라도 위반 가능성이 있으면 INVALID_LLM_OVERRIDE 태그를 반환하여 보고서에 표기.
|
||
*/
|
||
function calcLlmServingConstraint_(hApex) {
|
||
var h = hApex || {};
|
||
var violations = [];
|
||
|
||
// Check 1: 미등록 공식 사용 가능성 — serving_lock_json numeric_generation_allowed
|
||
var sLock = h.serving_lock_json || {};
|
||
var budget = sLock.llm_serving_budget || {};
|
||
if (budget.numeric_generation_allowed !== 0) {
|
||
violations.push({ check: 1, rule: '미등록 공식으로 지정가/수량 산출', status: 'WARN_NOT_LOCKED' });
|
||
}
|
||
|
||
// Check 2: BLOCK 판정 우회 — hts_entry_allowed=false인데 blueprint PASS 존재 불가
|
||
var exportGate = h.export_gate_json || {};
|
||
if (exportGate.hts_entry_allowed === false) {
|
||
var blueprints = h.order_blueprint_json || [];
|
||
var passCount = (Array.isArray(blueprints) ? blueprints : []).filter(function(b) {
|
||
return b.validation_status === 'PASS';
|
||
}).length;
|
||
if (passCount > 0) {
|
||
violations.push({ check: 2, rule: 'hts_entry_allowed=false 상태에서 PASS blueprint 존재', status: 'VIOLATION' });
|
||
}
|
||
}
|
||
|
||
// Check 3: SELL_PRICE_SANITY INVALID 가격 복원 위험 — INVALID 종목이 shadow_ledger에 없으면 경고
|
||
var shadowLedger = h.shadow_ledger_json || {};
|
||
var invalidBlueprints = (Array.isArray(h.order_blueprint_json) ? h.order_blueprint_json : [])
|
||
.filter(function(b) { return String(b.validation_status || '').indexOf('INVALID') === 0; });
|
||
if (invalidBlueprints.length > 0 && (!shadowLedger.blocked_count || shadowLedger.blocked_count === 0)) {
|
||
violations.push({ check: 3, rule: 'INVALID blueprint가 Shadow Ledger에 미포함', status: 'VIOLATION' });
|
||
}
|
||
|
||
// Check 5: K2 반등 대기 수량 — scrs_v2_json에 rebound_wait_qty가 있으면 분리 표기 의무
|
||
var scrs = h.scrs_v2_json || {};
|
||
var selectedCombo = Array.isArray(scrs.selected_combo) ? scrs.selected_combo : [];
|
||
if (selectedCombo.length > 0) {
|
||
var hasRebound = selectedCombo.some(function(c) { return c.rebound_wait_qty > 0; });
|
||
if (hasRebound && !scrs._display_split_confirmed) {
|
||
violations.push({ check: 5, rule: 'K2 rebound_wait_qty 분리 미표기 위험', status: 'WARN' });
|
||
}
|
||
}
|
||
|
||
// Check 9: consistency_score < 90이면 보고서 계속 생성 금지
|
||
var asResult = h.account_snapshot_result || {};
|
||
var cScore = asResult.consistency_score;
|
||
if (typeof cScore === 'number' && cScore < 90) {
|
||
violations.push({ check: 9, rule: 'consistency_score=' + cScore + ' < 90 (ABORT 필요)', status: 'VIOLATION' });
|
||
}
|
||
|
||
// Check 10: mega_sell_alert=TRUE이면 BUY/ADD_ON 금지
|
||
var macroJson = h.macro_event_json || {};
|
||
if (macroJson.mega_sell_alert === true || macroJson.mega_sell_alert === 'TRUE') {
|
||
var buyBlueprints = (Array.isArray(h.order_blueprint_json) ? h.order_blueprint_json : [])
|
||
.filter(function(b) { return b.order_type === 'BUY' || b.order_type === 'ADD_ON'; });
|
||
if (buyBlueprints.length > 0) {
|
||
violations.push({ check: 10, rule: 'mega_sell_alert=TRUE 상태에서 BUY/ADD_ON blueprint 존재', status: 'VIOLATION' });
|
||
}
|
||
}
|
||
|
||
// Check 11: synthesis_verdict=BEARISH 종목에 BUY 금지
|
||
var paeRows = h.predictive_alpha_json || [];
|
||
var bearishTickers = (Array.isArray(paeRows) ? paeRows : [])
|
||
.filter(function(r) { return r.synthesis_verdict === 'BEARISH'; })
|
||
.map(function(r) { return r.ticker; });
|
||
if (bearishTickers.length > 0) {
|
||
(Array.isArray(h.order_blueprint_json) ? h.order_blueprint_json : []).forEach(function(b) {
|
||
if ((b.order_type === 'BUY' || b.order_type === 'ADD_ON') && bearishTickers.indexOf(b.ticker) >= 0) {
|
||
violations.push({ check: 11, rule: 'synthesis_verdict=BEARISH 종목 BUY blueprint: ' + b.ticker, status: 'VIOLATION' });
|
||
}
|
||
});
|
||
}
|
||
|
||
var constraintStatus = violations.some(function(v) { return v.status === 'VIOLATION'; })
|
||
? 'INVALID_LLM_OVERRIDE' : violations.length > 0 ? 'WARN' : 'PASS';
|
||
|
||
return {
|
||
constraint_status: constraintStatus,
|
||
violations: violations,
|
||
violation_count: violations.filter(function(v) { return v.status === 'VIOLATION'; }).length,
|
||
warn_count: violations.filter(function(v) { return v.status === 'WARN' || v.status === 'WARN_NOT_LOCKED'; }).length,
|
||
total_checks: 12,
|
||
formula_id: 'LLM_SERVING_CONSTRAINT_V1'
|
||
};
|
||
}
|
||
|
||
/**
|
||
* H6: calcAvgTradeValueSignal_
|
||
* secular_leader(005930·000660) PROFIT_LOCK_STAGE_20 구간에서
|
||
* 5일 평균 거래대금 > 20일 평균 × 3.0이면 과열신호 +1 판정.
|
||
*/
|
||
function calcAvgTradeValueSignal_(ticker, df) {
|
||
df = df || {};
|
||
var SECULAR_TICKERS = ['005930', '000660'];
|
||
var isSecular = SECULAR_TICKERS.indexOf(String(ticker || '')) >= 0;
|
||
var stage = String(df.profit_lock_stage || df.Profit_Lock_Stage || '').toUpperCase();
|
||
var avgVal5d = toNumber_(df.avg_trade_val_5d || df.avgTradeVal5d) || 0;
|
||
var avgVal20d = toNumber_(df.avg_trade_val_20d || df.avgTradeVal20d) || 0;
|
||
|
||
if (!isSecular || stage !== 'PROFIT_LOCK_20' || avgVal20d <= 0) {
|
||
return {
|
||
ticker: ticker,
|
||
applicable: false,
|
||
signal: 'NOT_APPLICABLE',
|
||
avg_trade_val_5d: avgVal5d,
|
||
avg_trade_val_20d: avgVal20d,
|
||
overheat_triggered: false,
|
||
formula_id: 'AVG_TRADE_VALUE_SIGNAL_V1'
|
||
};
|
||
}
|
||
|
||
var ratio = avgVal5d / avgVal20d;
|
||
var overheat = ratio >= 3.0;
|
||
return {
|
||
ticker: ticker,
|
||
applicable: true,
|
||
signal: overheat ? 'OVERHEAT_TRADE_VALUE' : 'NORMAL',
|
||
avg_trade_val_5d: avgVal5d,
|
||
avg_trade_val_20d: avgVal20d,
|
||
ratio_5d_vs_20d: Math.round(ratio * 100) / 100,
|
||
overheat_triggered: overheat,
|
||
overheat_score_add: overheat ? 1 : 0,
|
||
threshold: 3.0,
|
||
formula_id: 'AVG_TRADE_VALUE_SIGNAL_V1'
|
||
};
|
||
}
|
||
|
||
/**
|
||
* G2: calcTrimPlanMinCash_
|
||
* 최소 현금(cash_floor) 달성을 위한 결정론적 TRIM 계획 산출.
|
||
* H2 매도후보 순위(sell_priority) 그대로 종목 순서를 결정 — LLM 임의 선택 금지.
|
||
*/
|
||
function calcTrimPlanMinCash_(holdings, dfMap, cashShortfallInfo, sellPriorityList) {
|
||
dfMap = dfMap || {};
|
||
var shortfall = toNumber_((cashShortfallInfo || {}).cash_shortfall_min_krw) || 0;
|
||
var plan = [];
|
||
var accumulatedKrw = 0;
|
||
var holdingRows = Array.isArray(holdings) ? holdings : [];
|
||
var priorityRows = Array.isArray(sellPriorityList) ? sellPriorityList : [];
|
||
|
||
priorityRows.forEach(function(sp) {
|
||
if (accumulatedKrw >= shortfall) return;
|
||
var h = holdingRows.find(function(x) { return x.ticker === sp.ticker; }) || {};
|
||
var df = dfMap[sp.ticker] || {};
|
||
var avgCost = toNumber_(h.avg_cost || h.average_cost) || 0;
|
||
var qty = toNumber_(h.qty || h.quantity) || 0;
|
||
|
||
if (qty === 0 || avgCost === 0) {
|
||
plan.push({
|
||
priority: sp.priority || plan.length + 1,
|
||
ticker: sp.ticker,
|
||
name: sp.name || df.name || '',
|
||
sell_qty: 'CAPTURE_REQUIRED',
|
||
estimated_sell_krw: 0,
|
||
sell_price_ref: null,
|
||
accumulated_krw: accumulatedKrw,
|
||
shortfall_covered: false,
|
||
note: 'CAPTURE_REQUIRED: qty/cost 미확정'
|
||
});
|
||
return;
|
||
}
|
||
|
||
var closePrice = toNumber_(df.close || df.close_price) || avgCost;
|
||
var remaining = shortfall - accumulatedKrw;
|
||
var neededQty = Math.ceil(remaining / closePrice);
|
||
var sellQty = Math.min(neededQty, qty);
|
||
var estimatedKrw = sellQty * closePrice;
|
||
accumulatedKrw += estimatedKrw;
|
||
|
||
plan.push({
|
||
priority: sp.priority || plan.length + 1,
|
||
ticker: sp.ticker,
|
||
name: sp.name || df.name || '',
|
||
sell_qty: sellQty,
|
||
estimated_sell_krw: Math.round(estimatedKrw),
|
||
sell_price_ref: closePrice,
|
||
accumulated_krw: Math.round(accumulatedKrw),
|
||
shortfall_covered: accumulatedKrw >= shortfall,
|
||
note: accumulatedKrw >= shortfall ? 'SHORTFALL_MET' : 'PARTIAL'
|
||
});
|
||
});
|
||
|
||
return {
|
||
cash_shortfall_min_krw: Math.round(shortfall),
|
||
plan: plan,
|
||
total_plan_krw: Math.round(accumulatedKrw),
|
||
shortfall_fully_covered: accumulatedKrw >= shortfall,
|
||
is_plan_only: true,
|
||
hts_order_required: 'order_blueprint_json.validation_status 기준으로만 판단',
|
||
formula_id: 'TRIM_PLAN_MIN_CASH_V1'
|
||
};
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// [PROPOSAL50] F1 — TRADE_QUALITY_SCORER_V1
|
||
// 실행된 매수·매도를 T+5/T+20 기준으로 자동 채점.
|
||
// trade_quality_history 시트를 읽어 미채점 레코드를 업데이트하고 결과 배열 반환.
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* calcTradeQualityScorer_
|
||
* trade_quality_history 시트에서 미채점 레코드를 배치 처리.
|
||
* BUY: velocity/ma20/volume/t5/t20 각 20점 합산 (100점 만점)
|
||
* SELL: above_ma20/above_cost/not_too_early/cash_goal_met 각 25점 합산 (100점 만점)
|
||
*/
|
||
function calcTradeQualityScorer_(ss) {
|
||
try {
|
||
ss = ss || getSpreadsheet_();
|
||
var sh = ss.getSheetByName('trade_quality_history');
|
||
if (!sh) {
|
||
Logger.log('[F1] trade_quality_history 시트 없음');
|
||
return { status: 'SHEET_NOT_FOUND', scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' };
|
||
}
|
||
|
||
var data = sh.getDataRange().getValues();
|
||
if (data.length < 2) {
|
||
return { status: 'NO_DATA', scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' };
|
||
}
|
||
|
||
var header = data[0];
|
||
var COL = {};
|
||
header.forEach(function(h, i) { COL[String(h).trim()] = i; });
|
||
|
||
// 필수 컬럼 확인
|
||
var REQ = ['ticker', 'action', 'scored'];
|
||
for (var ri = 0; ri < REQ.length; ri++) {
|
||
if (COL[REQ[ri]] == null) {
|
||
Logger.log('[F1] 필수 컬럼 누락: ' + REQ[ri]);
|
||
return { status: 'COLUMN_MISSING', missing: REQ[ri], scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' };
|
||
}
|
||
}
|
||
|
||
// 현재 종가 맵 (T+5/T+20 평가용)
|
||
var priceMap = {};
|
||
var dfSheet = ss.getSheetByName('data_feed');
|
||
if (dfSheet) {
|
||
var dfData = dfSheet.getDataRange().getValues();
|
||
if (dfData.length > 1) {
|
||
var dfHeader = dfData[0];
|
||
var tCol = dfHeader.indexOf('Ticker');
|
||
var cCol = dfHeader.indexOf('Close');
|
||
if (tCol >= 0 && cCol >= 0) {
|
||
for (var dri = 1; dri < dfData.length; dri++) {
|
||
var tk = String(dfData[dri][tCol] || '').trim();
|
||
var cl = parseFloat(String(dfData[dri][cCol] || ''));
|
||
if (tk && !isNaN(cl) && cl > 0) priceMap[tk] = cl;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
var todayMs = new Date().getTime();
|
||
var scoredResults = [];
|
||
var scoredThisRun = 0;
|
||
|
||
for (var i = 1; i < data.length; i++) {
|
||
var row = data[i];
|
||
var alreadyScored = String(row[COL['scored']] || '').toUpperCase();
|
||
if (alreadyScored === 'TRUE' || alreadyScored === 'SCORED') continue;
|
||
|
||
var ticker = String(row[COL['ticker']] || '').trim();
|
||
var action = String(row[COL['action']] || '').toUpperCase();
|
||
if (!ticker) continue;
|
||
|
||
var entryDate = row[COL['entry_date'] != null ? COL['entry_date'] : -1];
|
||
var daysSinceEntry = entryDate ? (todayMs - new Date(entryDate).getTime()) / 86400000 : 0;
|
||
|
||
// T+5 이상 경과해야 채점 (T+20 필드는 optional)
|
||
if (COL['entry_date'] != null && daysSinceEntry < 7) continue;
|
||
|
||
var score = 0;
|
||
var subscores = {};
|
||
var feedbackTag = 'GOOD_EXECUTION';
|
||
|
||
if (action === 'BUY') {
|
||
// 매수 품질 채점
|
||
var velocity1d = parseFloat(String(row[COL['velocity_1d_at_entry'] != null ? COL['velocity_1d_at_entry'] : -1] || ''));
|
||
var entryPrice = parseFloat(String(row[COL['entry_price'] != null ? COL['entry_price'] : -1] || ''));
|
||
var ma20Entry = parseFloat(String(row[COL['ma20_at_entry'] != null ? COL['ma20_at_entry'] : -1] || ''));
|
||
var volRatio = parseFloat(String(row[COL['volume_ratio_at_entry'] != null ? COL['volume_ratio_at_entry'] : -1] || ''));
|
||
var t5RetPct = parseFloat(String(row[COL['t5_return_pct'] != null ? COL['t5_return_pct'] : -1] || ''));
|
||
var t20VsCore = parseFloat(String(row[COL['t20_vs_core_pctp'] != null ? COL['t20_vs_core_pctp'] : -1] || ''));
|
||
|
||
// velocity_ok: 진입일 속도 < 1% (추격 아님)
|
||
if (!isNaN(velocity1d) && velocity1d < 1) { score += 20; subscores.velocity_ok = 20; }
|
||
else subscores.velocity_ok = 0;
|
||
|
||
// ma20_proximity: 진입가 ≤ MA20 × 1.01
|
||
if (!isNaN(entryPrice) && !isNaN(ma20Entry) && ma20Entry > 0 && entryPrice <= ma20Entry * 1.01) {
|
||
score += 20; subscores.ma20_proximity = 20;
|
||
} else subscores.ma20_proximity = 0;
|
||
|
||
// volume_confirm: 거래량비율 ≥ 1.2
|
||
if (!isNaN(volRatio) && volRatio >= 1.2) { score += 20; subscores.volume_confirm = 20; }
|
||
else subscores.volume_confirm = 0;
|
||
|
||
// t5_positive: T+5 수익률 > 0
|
||
if (!isNaN(t5RetPct) && t5RetPct > 0) { score += 20; subscores.t5_positive = 20; }
|
||
else subscores.t5_positive = 0;
|
||
|
||
// t20_alpha: T+20 대비 코어 초과 > 0
|
||
if (!isNaN(t20VsCore) && t20VsCore > 0) { score += 20; subscores.t20_alpha = 20; }
|
||
else subscores.t20_alpha = 0;
|
||
|
||
// 피드백 태그
|
||
if (subscores.velocity_ok === 0 && subscores.ma20_proximity === 0) feedbackTag = 'CHASE_ENTRY';
|
||
else if (subscores.t5_positive === 0 && subscores.t20_alpha === 0) feedbackTag = 'DISTRIBUTION_ENTRY';
|
||
|
||
} else if (action === 'SELL') {
|
||
// 매도 품질 채점
|
||
var sellPrice = parseFloat(String(row[COL['sell_price'] != null ? COL['sell_price'] : -1] || ''));
|
||
var ma20Sell = parseFloat(String(row[COL['ma20_at_sell'] != null ? COL['ma20_at_sell'] : -1] || ''));
|
||
var avgCost = parseFloat(String(row[COL['average_cost'] != null ? COL['average_cost'] : -1] || ''));
|
||
var priceT5After = parseFloat(String(row[COL['price_t5_after_sell'] != null ? COL['price_t5_after_sell'] : -1] || ''));
|
||
var cashRecov = parseFloat(String(row[COL['cash_recovered_krw'] != null ? COL['cash_recovered_krw'] : -1] || ''));
|
||
var cashGoal = parseFloat(String(row[COL['cash_shortfall_min_krw'] != null ? COL['cash_shortfall_min_krw'] : -1] || ''));
|
||
|
||
// above_ma20: 매도가 ≥ MA20 × 0.99
|
||
if (!isNaN(sellPrice) && !isNaN(ma20Sell) && ma20Sell > 0 && sellPrice >= ma20Sell * 0.99) {
|
||
score += 25; subscores.above_ma20 = 25;
|
||
} else subscores.above_ma20 = 0;
|
||
|
||
// above_cost: 매도가 ≥ 평단
|
||
if (!isNaN(sellPrice) && !isNaN(avgCost) && avgCost > 0 && sellPrice >= avgCost) {
|
||
score += 25; subscores.above_cost = 25;
|
||
} else subscores.above_cost = 0;
|
||
|
||
// not_too_early: T+5 사후 종가가 없거나 매도가 이상
|
||
if (isNaN(priceT5After) || priceT5After <= sellPrice) {
|
||
score += 25; subscores.not_too_early = 25;
|
||
} else subscores.not_too_early = 0;
|
||
|
||
// cash_goal_met: 실제 회수액 ≥ 목표 부족분
|
||
if (!isNaN(cashRecov) && !isNaN(cashGoal) && cashGoal > 0 && cashRecov >= cashGoal) {
|
||
score += 25; subscores.cash_goal_met = 25;
|
||
} else subscores.cash_goal_met = 0;
|
||
|
||
// 피드백 태그
|
||
if (subscores.above_cost === 0) feedbackTag = 'PANIC_EXIT';
|
||
else if (subscores.not_too_early === 0) feedbackTag = 'OVERSOLD_PANIC';
|
||
} else {
|
||
continue; // BUY/SELL 이외 레코드 스킵
|
||
}
|
||
|
||
// 등급 결정
|
||
var grade;
|
||
if (score >= 90) grade = 'EXCELLENT';
|
||
else if (score >= 75) grade = 'GOOD';
|
||
else if (score >= 60) grade = 'ACCEPTABLE';
|
||
else if (score >= 40) grade = 'POOR';
|
||
else grade = 'CRITICAL';
|
||
|
||
if (grade === 'POOR' || grade === 'CRITICAL') {
|
||
feedbackTag = score < 40 ? 'PATTERN_ALERT' : 'CHASE_ENTRY_OR_PANIC_EXIT';
|
||
} else if (grade === 'EXCELLENT' || grade === 'GOOD') {
|
||
feedbackTag = 'GOOD_EXECUTION';
|
||
}
|
||
|
||
// 시트 업데이트
|
||
var scoreCol = COL['score'] != null ? COL['score'] + 1 : null;
|
||
var gradeCol = COL['grade'] != null ? COL['grade'] + 1 : null;
|
||
var fbTagCol = COL['feedback_tag'] != null ? COL['feedback_tag'] + 1 : null;
|
||
var scoredCol = COL['scored'] != null ? COL['scored'] + 1 : null;
|
||
|
||
if (scoreCol) sh.getRange(i + 1, scoreCol).setValue(score);
|
||
if (gradeCol) sh.getRange(i + 1, gradeCol).setValue(grade);
|
||
if (fbTagCol) sh.getRange(i + 1, fbTagCol).setValue(feedbackTag);
|
||
if (scoredCol) sh.getRange(i + 1, scoredCol).setValue('SCORED');
|
||
|
||
scoredResults.push({
|
||
row: i,
|
||
ticker: ticker,
|
||
action: action,
|
||
score: score,
|
||
grade: grade,
|
||
feedback_tag: feedbackTag,
|
||
subscores: subscores,
|
||
formula_id: 'TRADE_QUALITY_SCORER_V1'
|
||
});
|
||
scoredThisRun++;
|
||
}
|
||
|
||
// 전체 기록 집계 (기존 채점 포함)
|
||
var allResults = [];
|
||
var freshData = sh.getDataRange().getValues();
|
||
for (var j = 1; j < freshData.length; j++) {
|
||
var r = freshData[j];
|
||
var sc = String(r[COL['scored']] || '').toUpperCase();
|
||
if (sc !== 'TRUE' && sc !== 'SCORED') continue;
|
||
allResults.push({
|
||
ticker: String(r[COL['ticker']] || '').trim(),
|
||
action: String(r[COL['action']] || '').toUpperCase(),
|
||
score: parseFloat(String(r[COL['score']] || '')) || 0,
|
||
grade: String(r[COL['grade']] || 'UNKNOWN'),
|
||
feedback_tag: String(r[COL['feedback_tag']] || '')
|
||
});
|
||
}
|
||
|
||
Logger.log('[F1] calcTradeQualityScorer_ 완료: 이번 채점=' + scoredThisRun + '건, 전체=' + allResults.length + '건');
|
||
|
||
// F2: F1 완료 직후 블랙리스트 자동 갱신 (F1 → F2 파이프라인)
|
||
try {
|
||
calcPatternBlacklistAuto_(allResults);
|
||
} catch (pbErr) {
|
||
Logger.log('[F1] calcPatternBlacklistAuto_ 연동 오류: ' + pbErr.message);
|
||
}
|
||
|
||
var f1Result = {
|
||
status: 'OK',
|
||
scored_count: scoredThisRun,
|
||
total_records: allResults.length,
|
||
trade_quality: allResults,
|
||
last_computed: new Date().toISOString(),
|
||
formula_id: 'TRADE_QUALITY_SCORER_V1'
|
||
};
|
||
|
||
// settings 시트에 trade_quality_json 캐시 저장 (harness_rows 일간 출력용)
|
||
// 셀 50K 한도 초과 방지: trade_quality 최근 100건만 저장
|
||
try {
|
||
var setSh = ss.getSheetByName('settings');
|
||
if (setSh) {
|
||
var sData = setSh.getDataRange().getValues();
|
||
var updated = false;
|
||
var f1Slim = Object.assign({}, f1Result,
|
||
{ trade_quality: (f1Result.trade_quality || []).slice(-100) });
|
||
var serialized = JSON.stringify(f1Slim);
|
||
for (var si = 0; si < sData.length; si++) {
|
||
if (String(sData[si][0] || '').trim() === 'trade_quality_json') {
|
||
setSh.getRange(si + 1, 2).setValue(serialized);
|
||
updated = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!updated) setSh.appendRow(['trade_quality_json', serialized]);
|
||
}
|
||
} catch(writeErr) {
|
||
Logger.log('[F1] settings 시트 기록 실패: ' + writeErr.message);
|
||
}
|
||
|
||
return f1Result;
|
||
} catch(e) {
|
||
Logger.log('[F1] calcTradeQualityScorer_ 오류: ' + e.message);
|
||
return { status: 'ERROR', error: e.message, scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' };
|
||
}
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// [PROPOSAL50] F2 — PATTERN_BLACKLIST_AUTO_V1
|
||
// 동일 ticker POOR/CRITICAL 3회 누적 → PATTERN_BLACKLIST_TRIGGERED
|
||
// 3회 연속 GOOD(75+) 달성 시 해제
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* calcPatternBlacklistAuto_
|
||
* trade_quality_json 배열을 받아 ticker별 POOR/CRITICAL 누적 횟수를 계산.
|
||
* 3회 이상이면 PATTERN_BLACKLIST_TRIGGERED, 3회 연속 GOOD 이상이면 해제.
|
||
* 결과를 settings 시트의 pattern_blacklist_json에 기록.
|
||
*/
|
||
function calcPatternBlacklistAuto_(tradeQualityHistory) {
|
||
try {
|
||
var history = Array.isArray(tradeQualityHistory) ? tradeQualityHistory : [];
|
||
|
||
// ticker별 그룹화
|
||
var tickerMap = {};
|
||
history.forEach(function(rec) {
|
||
var tk = String(rec.ticker || '').trim();
|
||
if (!tk) return;
|
||
if (!tickerMap[tk]) tickerMap[tk] = [];
|
||
tickerMap[tk].push({
|
||
grade: String(rec.grade || '').toUpperCase(),
|
||
score: typeof rec.score === 'number' ? rec.score : (parseFloat(String(rec.score || '')) || 0)
|
||
});
|
||
});
|
||
|
||
var blacklistEntries = [];
|
||
var triggeredCount = 0;
|
||
|
||
Object.keys(tickerMap).forEach(function(ticker) {
|
||
var records = tickerMap[ticker];
|
||
|
||
// POOR/CRITICAL 누적 카운트
|
||
var poorCriticalCount = records.filter(function(r) {
|
||
return r.grade === 'POOR' || r.grade === 'CRITICAL';
|
||
}).length;
|
||
|
||
// 해제 조건: 마지막 3건이 모두 GOOD(75+) 이상
|
||
var releaseMet = false;
|
||
if (records.length >= 3) {
|
||
var last3 = records.slice(-3);
|
||
releaseMet = last3.every(function(r) {
|
||
return (r.grade === 'GOOD' || r.grade === 'EXCELLENT') && r.score >= 75;
|
||
});
|
||
}
|
||
|
||
var status;
|
||
if (releaseMet && poorCriticalCount >= 3) {
|
||
status = 'CLEAR'; // 블랙리스트 해제
|
||
} else if (poorCriticalCount >= 3) {
|
||
status = 'TRIGGERED';
|
||
triggeredCount++;
|
||
} else {
|
||
status = 'CLEAR';
|
||
}
|
||
|
||
blacklistEntries.push({
|
||
ticker: ticker,
|
||
pattern_blacklist_status: status,
|
||
accumulated_poor_count: poorCriticalCount,
|
||
total_records: records.length,
|
||
release_condition_met: releaseMet,
|
||
saqg_override: status === 'TRIGGERED' ? 'EXCLUDED' : 'NO_CHANGE',
|
||
alpha_score_cap: status === 'TRIGGERED' ? 50 : null,
|
||
formula_id: 'PATTERN_BLACKLIST_AUTO_V1'
|
||
});
|
||
});
|
||
|
||
// settings 시트에 pattern_blacklist_json 기록 (wrapper 객체 형태로 저장)
|
||
try {
|
||
var ss = getSpreadsheet_();
|
||
var settingSh = ss.getSheetByName('settings');
|
||
if (settingSh) {
|
||
var sData = settingSh.getDataRange().getValues();
|
||
var updated = false;
|
||
var wrapperObj = {
|
||
status: 'OK',
|
||
triggered_count: triggeredCount,
|
||
total_tickers: blacklistEntries.length,
|
||
patterns: blacklistEntries,
|
||
pattern_count: blacklistEntries.length,
|
||
computed_at: new Date().toISOString(),
|
||
formula_id: 'PATTERN_BLACKLIST_AUTO_V1'
|
||
};
|
||
var serialized = JSON.stringify(wrapperObj);
|
||
for (var si = 0; si < sData.length; si++) {
|
||
if (String(sData[si][0] || '').trim() === 'pattern_blacklist_json') {
|
||
settingSh.getRange(si + 1, 2).setValue(serialized);
|
||
updated = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!updated) settingSh.appendRow(['pattern_blacklist_json', serialized]);
|
||
}
|
||
} catch(writeErr) {
|
||
Logger.log('[F2] settings 시트 기록 실패: ' + writeErr.message);
|
||
}
|
||
|
||
Logger.log('[F2] calcPatternBlacklistAuto_ 완료: TRIGGERED=' + triggeredCount + '/' + blacklistEntries.length + '건');
|
||
return {
|
||
status: 'OK',
|
||
triggered_count: triggeredCount,
|
||
total_tickers: blacklistEntries.length,
|
||
patterns: blacklistEntries,
|
||
pattern_count: blacklistEntries.length,
|
||
formula_id: 'PATTERN_BLACKLIST_AUTO_V1'
|
||
};
|
||
} catch(e) {
|
||
Logger.log('[F2] calcPatternBlacklistAuto_ 오류: ' + e.message);
|
||
return { status: 'ERROR', error: e.message, triggered_count: 0, patterns: [], pattern_count: 0, formula_id: 'PATTERN_BLACKLIST_AUTO_V1' };
|
||
}
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// [PROPOSAL50] ALPHA_FEEDBACK_LOOP_V1
|
||
// monthly_history의 AEW_V1 성과 데이터를 분석해 SAQG_V1 필터 임계값 조정 권고 생성.
|
||
// 임계값 자동 변경 금지 — 권고(RECOMMENDATION)만 출력.
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* calcAlphaFeedbackLoop_
|
||
* alpha_evaluation_window_json (AEW_V1 결과) 에서 ELIGIBLE 케이스를 분석해
|
||
* SAQG F1/F2/F3 임계값 조정 권고를 생성한다.
|
||
* 10건 미만이면 DATA_INSUFFICIENT — 권고 생성 금지.
|
||
*/
|
||
function calcAlphaFeedbackLoop_() {
|
||
try {
|
||
var ss = getSpreadsheet_();
|
||
var aewRows = [];
|
||
|
||
// monthly_history 시트에서 AEW 데이터 수집
|
||
var mhSh = ss.getSheetByName('monthly_history');
|
||
if (mhSh) {
|
||
var mhData = mhSh.getDataRange().getValues();
|
||
if (mhData.length > 1) {
|
||
var mhHeader = mhData[0];
|
||
var COL = {};
|
||
mhHeader.forEach(function(h, i) { COL[String(h).trim()] = i; });
|
||
|
||
for (var i = 1; i < mhData.length; i++) {
|
||
var row = mhData[i];
|
||
var saqg = String(row[COL['saqg_v1'] != null ? COL['saqg_v1'] : -1] || '').toUpperCase();
|
||
var t20Sam = parseFloat(String(row[COL['t20_vs_samsung_pctp'] != null ? COL['t20_vs_samsung_pctp'] : -1] || ''));
|
||
var brtV = String(row[COL['brt_verdict'] != null ? COL['brt_verdict'] : -1] || '').toUpperCase();
|
||
var regime = String(row[COL['market_regime'] != null ? COL['market_regime'] : -1] || '');
|
||
if (!saqg) continue;
|
||
aewRows.push({ saqg_v1: saqg, t20_vs_samsung_pctp: isNaN(t20Sam) ? null : t20Sam, brt_verdict: brtV, market_regime: regime });
|
||
}
|
||
}
|
||
}
|
||
|
||
var eligibleRows = aewRows.filter(function(r) { return r.saqg_v1 === 'ELIGIBLE'; });
|
||
var casesAnalyzed = eligibleRows.length;
|
||
|
||
var now = new Date();
|
||
var asOf = now.toISOString().split('T')[0];
|
||
var analysisPeriod = asOf.substring(0, 7); // 'YYYY-MM'
|
||
|
||
if (casesAnalyzed < 10) {
|
||
Logger.log('[AFL] calcAlphaFeedbackLoop_: 데이터 부족(' + casesAnalyzed + '건) — 권고 생성 건너뜀');
|
||
return {
|
||
formula_id: 'ALPHA_FEEDBACK_LOOP_V1',
|
||
as_of: asOf,
|
||
analysis_period: analysisPeriod,
|
||
status: 'DATA_INSUFFICIENT',
|
||
cases_analyzed: casesAnalyzed,
|
||
grade_count: 0,
|
||
eligible_t20_fail_rate: null,
|
||
eligible_t60_fail_rate: null,
|
||
recommended_filter_adjustments: [],
|
||
grade_summary: []
|
||
};
|
||
}
|
||
|
||
// T+20 알파 실패율 계산 (t20_vs_samsung_pctp < -3)
|
||
var t20WithData = eligibleRows.filter(function(r) { return r.t20_vs_samsung_pctp !== null; });
|
||
var t20FailRows = t20WithData.filter(function(r) { return r.t20_vs_samsung_pctp < -3; });
|
||
var t20PassRows = t20WithData.length - t20FailRows.length;
|
||
var t20FailRate = t20WithData.length > 0
|
||
? Math.round(t20FailRows.length / t20WithData.length * 1000) / 10
|
||
: null;
|
||
var t20PassRate = t20WithData.length > 0
|
||
? Math.round(t20PassRows / t20WithData.length * 1000) / 10
|
||
: null;
|
||
|
||
// BRT_VERDICT=BROKEN 케이스 비율
|
||
var brokenCount = eligibleRows.filter(function(r) { return r.brt_verdict === 'BROKEN'; }).length;
|
||
var brokenRate = eligibleRows.length > 0
|
||
? Math.round(brokenCount / eligibleRows.length * 1000) / 10 : 0;
|
||
|
||
// grade_summary — saqg_v1 값별로 집계
|
||
var gradeCounts = {};
|
||
aewRows.forEach(function(r) {
|
||
var g = r.saqg_v1 || 'UNKNOWN';
|
||
if (!gradeCounts[g]) gradeCounts[g] = { t20_total: 0, t20_pass: 0, t20_fail: 0 };
|
||
if (r.t20_vs_samsung_pctp !== null) {
|
||
gradeCounts[g].t20_total++;
|
||
if (r.t20_vs_samsung_pctp >= 0) gradeCounts[g].t20_pass++;
|
||
else gradeCounts[g].t20_fail++;
|
||
}
|
||
});
|
||
var gradeSummary = Object.keys(gradeCounts).map(function(g) {
|
||
var gd = gradeCounts[g];
|
||
var passRate = gd.t20_total > 0 ? Math.round(gd.t20_pass / gd.t20_total * 1000) / 10 : null;
|
||
var failRate = gd.t20_total > 0 ? Math.round(gd.t20_fail / gd.t20_total * 1000) / 10 : null;
|
||
return {
|
||
grade: g,
|
||
t20_total: gd.t20_total,
|
||
t20_pass: gd.t20_pass,
|
||
t20_pass_rate: passRate,
|
||
t20_fail_rate: failRate,
|
||
t60_total: 0, // T+60 데이터 미수집 — 향후 확장
|
||
t60_pass: 0,
|
||
t60_pass_rate: null,
|
||
t60_fail_rate: null,
|
||
status: gd.t20_total === 0 ? 'DATA_INSUFFICIENT' : 'OK'
|
||
};
|
||
});
|
||
|
||
// 권고 생성 — 렌더러 계약 필드명: filter_id, current, recommended, action, rationale
|
||
var recommendations = [];
|
||
|
||
if (t20FailRate !== null && t20FailRate > 50) {
|
||
recommendations.push({
|
||
filter_id: 'SAQG_F1_F2_F3',
|
||
current: 'CURRENT_THRESHOLDS',
|
||
recommended: 'TIGHTEN: F2 recovery_ratio 1.20 → 1.35',
|
||
action: 'TIGHTEN',
|
||
rationale: 'ELIGIBLE T+20 알파 실패율 ' + t20FailRate + '% > 50% 기준 초과'
|
||
});
|
||
}
|
||
|
||
if (t20PassRate !== null && t20PassRate > 70 && casesAnalyzed >= 12) {
|
||
recommendations.push({
|
||
filter_id: 'SAQG_F3',
|
||
current: 'excess_drawdown 5%p',
|
||
recommended: 'RELAX: excess_drawdown 5%p → 7%p',
|
||
action: 'RELAX',
|
||
rationale: 'ELIGIBLE T+20 성공률 ' + t20PassRate + '% > 70% (케이스 ' + casesAnalyzed + '건)'
|
||
});
|
||
}
|
||
|
||
if (brokenRate > 30) {
|
||
recommendations.push({
|
||
filter_id: 'BRT_VERDICT_GATE',
|
||
current: 'CURRENT_THRESHOLDS',
|
||
recommended: 'TIGHTEN: BRT_BROKEN 진입 차단 강화',
|
||
action: 'TIGHTEN',
|
||
rationale: 'ELIGIBLE 중 BRT_BROKEN 비율 ' + brokenRate + '% > 30%'
|
||
});
|
||
}
|
||
|
||
Logger.log('[AFL] calcAlphaFeedbackLoop_ 완료: cases=' + casesAnalyzed + ' t20FailRate=' + t20FailRate + '% recs=' + recommendations.length);
|
||
|
||
var result = {
|
||
formula_id: 'ALPHA_FEEDBACK_LOOP_V1',
|
||
as_of: asOf,
|
||
analysis_period: analysisPeriod,
|
||
status: 'OK',
|
||
cases_analyzed: casesAnalyzed,
|
||
grade_count: gradeSummary.length,
|
||
eligible_t20_fail_rate: t20FailRate,
|
||
eligible_t60_fail_rate: null,
|
||
t20_pass_rate: t20PassRate,
|
||
brt_broken_rate: brokenRate,
|
||
recommended_filter_adjustments: recommendations,
|
||
grade_summary: gradeSummary,
|
||
note: '임계값 자동 변경 금지 — 사용자 확인 후 settings 수동 반영'
|
||
};
|
||
|
||
// settings 시트에 기록
|
||
try {
|
||
var settingSh = ss.getSheetByName('settings');
|
||
if (settingSh) {
|
||
var sData = settingSh.getDataRange().getValues();
|
||
var updated = false;
|
||
var serialized = JSON.stringify(result);
|
||
for (var si = 0; si < sData.length; si++) {
|
||
if (String(sData[si][0] || '').trim() === 'alpha_feedback_json') {
|
||
settingSh.getRange(si + 1, 2).setValue(serialized);
|
||
updated = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!updated) settingSh.appendRow(['alpha_feedback_json', serialized]);
|
||
}
|
||
} catch(writeErr) {
|
||
Logger.log('[AFL] settings 시트 기록 실패: ' + writeErr.message);
|
||
}
|
||
|
||
return result;
|
||
} catch(e) {
|
||
Logger.log('[AFL] calcAlphaFeedbackLoop_ 오류: ' + e.message);
|
||
return { status: 'ERROR', error: e.message, cases_analyzed: 0, recommended_filter_adjustments: [], formula_id: 'ALPHA_FEEDBACK_LOOP_V1' };
|
||
}
|
||
}
|
||
|
||
/** AFL 일간 하네스 호출 래퍼 — calcAlphaFeedbackLoop_ 위임 */
|
||
function runAlphaFeedbackLoop_() {
|
||
return calcAlphaFeedbackLoop_();
|
||
}
|
||
|
||
/**
|
||
* AFL 캐시 읽기 — settings 시트에서 마지막 저장된 alpha_feedback_json 반환.
|
||
* calcAlphaFeedbackLoop_ 오류 시 fallback으로 사용.
|
||
*/
|
||
function getAlphaFeedbackJson_() {
|
||
try {
|
||
var ss = getSpreadsheet_();
|
||
var sh = ss.getSheetByName('settings');
|
||
if (!sh) return { status: 'SETTINGS_NOT_FOUND', formula_id: 'ALPHA_FEEDBACK_LOOP_V1' };
|
||
var data = sh.getDataRange().getValues();
|
||
for (var i = 0; i < data.length; i++) {
|
||
if (String(data[i][0] || '').trim() === 'alpha_feedback_json') {
|
||
var raw = data[i][1];
|
||
if (!raw) break;
|
||
try { return JSON.parse(String(raw)); } catch(pe) { break; }
|
||
}
|
||
}
|
||
} catch(e) {
|
||
Logger.log('[AFL] getAlphaFeedbackJson_ 읽기 실패: ' + e.message);
|
||
}
|
||
return { status: 'CACHE_EMPTY', formula_id: 'ALPHA_FEEDBACK_LOOP_V1' };
|
||
}
|
||
|
||
|