Files
us-stock-index-drawdown-ana…/build_workbook.mjs
T

219 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Workbook, SpreadsheetFile } from '@oai/artifact-tool';
const root = path.dirname(fileURLToPath(import.meta.url));
const out = path.join(root, 'outputs', '20261019');
const analysis = JSON.parse(await fs.readFile(path.join(root, 'analysis.json'), 'utf8'));
const wb = Workbook.create();
const summary = wb.worksheets.add('回撤概览');
const detail = wb.worksheets.add('回撤明细');
const daily = wb.worksheets.add('日线与逐日回本');
const navy = '#193A56', ink = '#172B3A', muted = '#607383', light = '#EDF2F6';
const indices = Object.values(analysis.indices);
const excelDate = s => s ? new Date(s+'T00:00:00Z') : null;
const eventRanges = {}, dailyOffsets = {}, eventRows = new Map();
function put(sheet, cell, value) { sheet.getRange(cell).values = [[value]]; }
function formula(sheet, cell, value) { sheet.getRange(cell).formulas = [[value]]; }
function base(sheet, range) {
sheet.showGridLines = false;
sheet.getRange(range).format.font = {name:'Arial',size:10,color:ink};
sheet.getRange(range).format.rowHeight = 21;
sheet.getRange(range).format.verticalAlignment = 'center';
}
function header(sheet, range) {
sheet.getRange(range).format = {fill:navy,font:{name:'Arial',size:10,bold:true,color:'#FFFFFF'},rowHeight:31,horizontalAlignment:'center',verticalAlignment:'center'};
}
function title(sheet, cell, text) {
put(sheet,cell,text);sheet.getRange(cell).format.font={name:'Arial',size:15,bold:true,color:navy};
}
function number(sheet, range, format='#,##0') { sheet.getRange(range).setNumberFormat(format); }
// One source and daily-results table; event detection and next-breakeven are script snapshots.
const dHeaders=['指数','日期','收盘点位','数据来源','历史最高收盘','距历史最高回撤','首次回本日期','等待自然日','等待交易日','期间曾浮亏','回本状态','已观察自然日','已观察交易日','交易日序号'];
let drow=10;
for(const ix of indices){dailyOffsets[ix.key]=drow; drow+=ix.daily.length;}
base(daily,`A1:N${drow-1}`);
title(daily,'A2','日线数据与逐日买入回本时间');
put(daily,'A4','有效交易观测;日收盘价格指数,不含分红、汇率、税费或通胀。');
put(daily,'A5','回本日期:买入日之后,首个收盘不低于买入价的日期;次日即盈利也会列出,是否浮亏见 J 列。');
put(daily,'A6','无回本日的末尾记录表示后续样本不足;最后观测日单列,不能解释为已发生亏损。');
put(daily,'A7','交易日按有效日线观测序号相减;早期标普日线未逐日对照独立交易所日历,可能不含当年周六交易。');
daily.getRange('A9:N9').values=[dHeaders]; header(daily,'A9:N9');
for(const ix of indices){
const start=dailyOffsets[ix.key];
const rows=ix.daily.map(r=>[r[0],excelDate(r[1]),r[2],r[3],r[4],r[5],excelDate(r[6]),...r.slice(7)]);
for(let offset=0;offset<rows.length;offset+=3000){const block=rows.slice(offset,offset+3000);daily.getRangeByIndexes(start-1+offset,0,block.length,14).values=block;}
}
const dend=drow-1;
daily.getRange(`A9:N${dend}`).format.columnWidth=17;
daily.getRange(`A9:A${dend}`).format.columnWidth=20;
daily.getRange(`J9:K${dend}`).format.columnWidth=18;
number(daily,`B10:B${dend}`,'yyyy-mm-dd');number(daily,`G10:G${dend}`,'yyyy-mm-dd');
number(daily,`C10:C${dend}`,'#,##0.00');number(daily,`E10:E${dend}`,'#,##0.00');number(daily,`F10:F${dend}`,'0.00%');
number(daily,`H10:I${dend}`);number(daily,`L10:N${dend}`);
daily.tables.add(`A9:N${dend}`,true,'DailyPricesAndBreakeven');
daily.freezePanes.freezeRows(9);daily.freezePanes.freezeColumns(2);
// Every non-overlapping underwater spell, no minimum depth threshold.
let erow=9;
for(const ix of indices){eventRanges[ix.key]={start:erow,end:erow+ix.events.length-1};erow+=ix.events.length;}
base(detail,`A1:T${erow-1}`);
title(detail,'A2','每一次历史新高回撤与解套');
put(detail,'A4','前高为下跌前最后一个最高收盘日;收盘首次回到或超过前高即解套;同一长回撤内的反弹不另起事件。');
put(detail,'A5','自然日为日期相减,交易日为有效观测序号相减;不含起点、包含终点。谷底重复时取首次。');
put(detail,'A6','未解套事件的总解套及恢复时间留空;已观察天数仅截至本次样本末日。');
put(detail,'A7','事件边界及逐日回本日期是计算快照。更改日线价格后,须重跑分析脚本重新识别事件。');
detail.getRange('A8:T8').values=[['指数','序号','前高日','前高点位','谷底日','谷底点位','解套日','解套收盘','最大回撤','下跌自然日','谷底后恢复自然日','总解套自然日','下跌交易日','谷底后恢复交易日','总解套交易日','状态','观测截止日','已观察自然日','已观察交易日','截止距前高回撤']];header(detail,'A8:T8');
for(const ix of indices){
const start=eventRanges[ix.key].start;
for(let k=0;k<ix.events.length;k++){
const e=ix.events[k],r=start+k;
eventRows.set(`${ix.key}:${e.peak_date}`,r);
const p=dailyOffsets[ix.key]+e.peak_i,t=dailyOffsets[ix.key]+e.trough_i,rec=e.recovery_i===null?null:dailyOffsets[ix.key]+e.recovery_i;
const source=col=>`='日线与逐日回本'!${col}`;
detail.getRange(`A${r}:T${r}`).values=[[ix.name,k+1,null,null,null,null,null,null,null,null,null,null,e.decline_trading_days,e.rebound_trading_days,e.total_trading_days,e.status,excelDate(e.end_date),null,e.observed_trading_days,null]];
detail.getRange(`C${r}:L${r}`).formulas=[[
source(`B${p}`),source(`C${p}`),source(`B${t}`),source(`C${t}`),rec?source(`B${rec}`):null,rec?source(`C${rec}`):null,
`=F${r}/D${r}-1`,`=E${r}-C${r}`,`=IF(G${r}="","",G${r}-E${r})`,`=IF(G${r}="","",G${r}-C${r})`
]];
formula(detail,`R${r}`,`=Q${r}-C${r}`);
// Q is recovery day for completed spells, sample end for open spells.
const end=dailyOffsets[ix.key]+(e.recovery_i===null?ix.daily.length-1:e.recovery_i);
formula(detail,`T${r}`,`='日线与逐日回本'!C${end}/D${r}-1`);
}
}
const eend=erow-1;
detail.getRange(`A8:T${eend}`).format.columnWidth=17;
detail.getRange(`A8:A${eend}`).format.columnWidth=20;detail.getRange(`B8:B${eend}`).format.columnWidth=8;
detail.getRange('K8').format.columnWidth=23;detail.getRange('N8').format.columnWidth=23;detail.getRange('T8').format.columnWidth=23;
for(const col of ['C','E','G','Q'])number(detail,`${col}9:${col}${eend}`,'yyyy-mm-dd');
for(const col of ['D','F','H'])number(detail,`${col}9:${col}${eend}`,'#,##0.00');
for(const col of ['I','T'])number(detail,`${col}9:${col}${eend}`,'0.00%');
for(const col of ['J','K','L','M','N','O','R','S'])number(detail,`${col}9:${col}${eend}`);
detail.getRange(`I9:I${eend}`).conditionalFormats.add('colorScale',{colors:['#D88484','#FFFFFF'],thresholds:['min','max']});
detail.getRange(`P9:P${eend}`).conditionalFormats.add('containsText',{text:'未解套',format:{fill:'#FFF0D4',font:{bold:true,color:'#9C6400'}}});
detail.tables.add(`A8:T${eend}`,true,'DrawdownEpisodes');
detail.freezePanes.freezeRows(8);detail.freezePanes.freezeColumns(2);
// Main output: compact overview, long-duration spells, depth thresholds and method.
base(summary,'A1:H90');summary.tabColor=navy;
summary.getRange('A1:H90').format.columnWidth=18;summary.getRange('A1:A90').format.columnWidth=27;
summary.getRange('B1:C90').format.columnWidth=21;
title(summary,'A2','纳指与标普500历史回撤解套时间');
put(summary,'A4','数据截止 2026-10-16;抓取日期 2026-10-19(北京时间)。');
put(summary,'A5','NASDAQ = 纳斯达克综合指数 ^IXIC;S&P 500 = ^GSPC。');
summary.getRange('A7:C7').values=[['指标',indices[0].name,indices[1].name]];header(summary,'A7:C7');
const labels=['首个观测日','最后观测日','有效日线条数','全部回撤段数','已解套段数','未解套段数','历史最大回撤','最长解套自然日','最长解套年数','解套自然日中位数','最长回撤前高日','该次解套日'];
summary.getRange('A8:A19').values=labels.map(x=>[x]);
for(let k=0;k<indices.length;k++){
const ix=indices[k],c=k?'C':'B',rr=eventRanges[ix.key],lr=eventRows.get(`${ix.key}:${ix.longest.peak_date}`),dr=eventRows.get(`${ix.key}:${ix.deepest.peak_date}`);
put(summary,`${c}8`,excelDate(ix.start));put(summary,`${c}9`,excelDate(ix.end));put(summary,`${c}10`,ix.observations);
formula(summary,`${c}11`,`=COUNTA('回撤明细'!B${rr.start}:B${rr.end})`);
formula(summary,`${c}12`,`=COUNTIFS('回撤明细'!P${rr.start}:P${rr.end},"已解套")`);
formula(summary,`${c}13`,`=COUNTIFS('回撤明细'!P${rr.start}:P${rr.end},"未解套")`);
formula(summary,`${c}14`,`='回撤明细'!I${dr}`);formula(summary,`${c}15`,`='回撤明细'!L${lr}`);
formula(summary,`${c}16`,`=${c}15/365.2425`);formula(summary,`${c}17`,`=MEDIAN('回撤明细'!L${rr.start}:L${rr.end})`);
formula(summary,`${c}18`,`='回撤明细'!C${lr}`);formula(summary,`${c}19`,`='回撤明细'!G${lr}`);
}
for(const r of [8,9,18,19])number(summary,`B${r}:C${r}`,'yyyy-mm-dd');
number(summary,'B10:C13');number(summary,'B14:C14','0.00%');number(summary,'B15:C15');number(summary,'B16:C16','0.00');number(summary,'B17:C17','0.0');
put(summary,'E8','统计范围各自从最早观测日起。');put(summary,'E9','不同起点的均值与中位数不能直接比较。');
put(summary,'E11','全部回撤包括只有一天的微小回撤。');put(summary,'E12','中位数仅含已解套事件。');
put(summary,'E14','标普1957-03-04之前为前身/回溯序列。');put(summary,'E15','早期日期为数据商提供的历史日线口径。');
put(summary,'E17','价格回本不含分红、税费、汇率和通胀。');
summary.getRange('E8:H19').format.font={name:'Arial',size:10,color:muted};
title(summary,'A22','各指数解套最慢的五次回撤');
summary.getRange('A24:H24').values=[['指数','前高日','谷底日','解套日','最大回撤','总自然日','总交易日','约合年数']];header(summary,'A24:H24');
let sr=25;
for(const ix of indices){
const top=ix.events.filter(e=>e.recovery_i!==null).sort((a,b)=>b.total_calendar_days-a.total_calendar_days).slice(0,5);
for(const e of top){const r=eventRows.get(`${ix.key}:${e.peak_date}`);summary.getRange(`A${sr}:H${sr}`).formulas=[[`='回撤明细'!A${r}`,`='回撤明细'!C${r}`,`='回撤明细'!E${r}`,`='回撤明细'!G${r}`,`='回撤明细'!I${r}`,`='回撤明细'!L${r}`,`='回撤明细'!O${r}`,`=F${sr}/365.2425`]];sr++;}
}
number(summary,'B25:D34','yyyy-mm-dd');number(summary,'E25:E34','0.00%');number(summary,'F25:G34');number(summary,'H25:H34','0.00');
title(summary,'A37','按最大跌幅筛选回撤');
put(summary,'A38','各行是累计阈值,不能相加;时长中位数和最长值只含已解套事件。');
summary.getRange('A40:G40').values=[['指数','最大跌幅至少','全部段数','已解套','未解套','中位自然日','最长自然日']];header(summary,'A40:G40');
sr=41;
for(const ix of indices){
const rr=eventRanges[ix.key];
for(const b of ix.buckets){
put(summary,`A${sr}`,ix.name);put(summary,`B${sr}`,b.cutoff);
formula(summary,`C${sr}`,`=COUNTIFS('回撤明细'!I${rr.start}:I${rr.end},"<="&-B${sr})`);
formula(summary,`D${sr}`,`=COUNTIFS('回撤明细'!I${rr.start}:I${rr.end},"<="&-B${sr},'回撤明细'!P${rr.start}:P${rr.end},"已解套")`);
formula(summary,`E${sr}`,`=C${sr}-D${sr}`);
const ids=ix.events.filter(e=>-e.max_drawdown>=b.cutoff&&e.recovery_i!==null).map(e=>eventRows.get(`${ix.key}:${e.peak_date}`));
// Coalesce adjacent rows to stay below Excel's 255-function-argument limit.
const spans=[];
for(const id of ids){const tail=spans.at(-1);if(tail&&id===tail[1]+1)tail[1]=id;else spans.push([id,id]);}
const included=spans.map(([a,z])=>`'回撤明细'!L${a}${z===a?'':`:L${z}`}`);
if(included.length){formula(summary,`F${sr}`,`=MEDIAN(${included.join(',')})`);formula(summary,`G${sr}`,`=MAX(${included.join(',')})`);}
sr++;
}
}
number(summary,'B41:B54','0%');number(summary,'C41:E54');number(summary,'F41:F54','0.0');number(summary,'G41:G54');
title(summary,'A57','截至样本末仍未解套');
summary.getRange('A59:G59').values=[['指数','前高日','截止日','最大回撤','已观察自然日','已观察交易日','截止距前高回撤']];header(summary,'A59:G59');
sr=60;
for(const ix of indices)for(const e of ix.open_events){const r=eventRows.get(`${ix.key}:${e.peak_date}`);summary.getRange(`A${sr}:G${sr}`).formulas=[[`='回撤明细'!A${r}`,`='回撤明细'!C${r}`,`='回撤明细'!Q${r}`,`='回撤明细'!I${r}`,`='回撤明细'!R${r}`,`='回撤明细'!S${r}`,`='回撤明细'!T${r}`]];sr++;}
number(summary,'B60:C61','yyyy-mm-dd');number(summary,'D60:D61','0.00%');number(summary,'G60:G61','0.00%');number(summary,'E60:F61');
title(summary,'A64','数据来源与计算口径');
const notes=[
'来源优先级:FRED转发的指数公司收盘优先;FRED缺失区间以Yahoo Finance同指数收盘补足。',
`纳指:${indices[0].fred_rows.toLocaleString()}条FRED;${indices[0].yahoo_rows}条Yahoo(2026-10-16)。`,
`标普:${indices[1].fred_rows.toLocaleString()}条FRED;${indices[1].yahoo_rows.toLocaleString()}条Yahoo。`,
`两源重叠区按0.01点比较,纳指${indices[0].source_mismatches}条、标普${indices[1].source_mismatches}条不一致;最终均选FRED。`,
'日期以Yahoo对应指数的有效交易观测为基准;排除FRED纳指2019-04-19休市日异常观测。',
'为去除Yahoo浮点表示误差,比较价格统一到0.01点;未填充周末或休市日,未插值。',
'回撤率 = 当日收盘 / 截至当日最高收盘 - 1;每次回撤只在重新收复历史高点后结束。',
'解套天数 = 解套日 - 前高日;谷底后恢复天数 = 解套日 - 谷底日。',
'最近未解套事件属于右删失,真实总解套时长未知,不能把已观察天数当作最终结果。',
'逐日买入后若次日直接不低于买入价,也有首次回本日期;是否实际浮亏见日线表。',
'事件识别和逐日首次回本日期为脚本快照,更新行情或修改价格须重跑分析。',
'FRED NASDAQCOM:https://fred.stlouisfed.org/series/NASDAQCOM',
'FRED SP500:https://fred.stlouisfed.org/series/SP500 (仅提供最近10年)',
'Yahoo ^IXIC:https://finance.yahoo.com/quote/%5EIXIC/history/',
'Yahoo ^GSPC:https://finance.yahoo.com/quote/%5EGSPC/history/',
'标普指数定义与推出日期:https://www.spglobal.com/spdji/en/indices/equity/sp-500/',
'2019休市日:https://ir.theice.com/press/news-details/2018/NYSE-Group-Announces-2019-2020-and-2021-Holiday-and-Early-Closings-Calendar/default.aspx',
];
notes.forEach((text,i)=>put(summary,`A${66+i}`,text));
summary.getRange('A66:H83').format.font={name:'Arial',size:10,color:muted};
// A reversible source-price change verifies that linked arithmetic recalculates.
{
const ix=indices[0],e=ix.longest;
const sourceCell=daily.getRange(`C${dailyOffsets[ix.key]+e.trough_i}`);
const resultCell=detail.getRange(`I${eventRows.get(`${ix.key}:${e.peak_date}`)}`);
const original=sourceCell.values[0][0];
sourceCell.values=[[original+1]];
const expected=(original+1)/e.peak_close-1;
if(Math.abs(resultCell.values[0][0]-expected)>1e-12)throw Error('Linked-price recalculation failed');
sourceCell.values=[[original]];
}
// Long notes are placed on otherwise empty rows, so they can extend horizontally.
wb.recalculate();
console.log((await wb.inspect({kind:'table',range:'回撤概览!A7:C19',include:'values,formulas',tableMaxRows:13,tableMaxCols:3,maxChars:4500})).ndjson);
console.log((await wb.inspect({kind:'match',searchTerm:'#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A|#NUM!|#NULL!|#SPILL!|#CALC!',options:{useRegex:true,maxResults:30},maxChars:1500})).ndjson);
// Verify representative formula outputs against independently calculated Python results.
for(let k=0;k<indices.length;k++){
const c=k?'C':'B',ix=indices[k];
if(summary.getRange(`${c}11`).values[0][0]!==ix.events.length)throw Error('Event count mismatch');
if(summary.getRange(`${c}15`).values[0][0]!==ix.longest.total_calendar_days)throw Error('Longest duration mismatch');
}
let checkRow=41;
for(const ix of indices)for(const bucket of ix.buckets){
const values=summary.getRange(`C${checkRow}:G${checkRow}`).values[0];
const expected=[bucket.all_events,bucket.recovered,bucket.open,bucket.median_calendar_days,bucket.max_calendar_days];
if(values.some((v,i)=>Math.abs(v-expected[i])>1e-10))throw Error(`Threshold result mismatch ${ix.key} row ${checkRow}`);
checkRow++;
}
for(const [sheet,range,name] of [['回撤概览','A7:H19','overview'],['回撤概览','A24:H34','longest'],['回撤明细','A8:L15','episodes'],['日线与逐日回本','A9:N16','daily']]){
try{await fs.access(path.join(out,`${name}_preview.png`));continue;}catch{}
const preview=await wb.render({sheetName:sheet,range,scale:1.3,format:'png'});
await fs.writeFile(path.join(out,`${name}_preview.png`),new Uint8Array(await preview.arrayBuffer()));
}
await (await SpreadsheetFile.exportXlsx(wb)).save(path.join(out,'nasdaq_sp500_drawdown_recovery.xlsx'));
console.log(JSON.stringify({output:path.join(out,'nasdaq_sp500_drawdown_recovery.xlsx'),dailyRows:dend-9,eventRows:eend-8,sheets:3}));