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`='日线与逐日回本'!${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;ke.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;kMath.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}));