commit 7ec9aa51adabf8cd000b714cd8afc346ac2c64d8 Author: Codex Bot Date: Thu Nov 5 00:20:18 2026 +0800 Import project source and Git ignore rules diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45afcf0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.DS_Store +.env +.env.* +__pycache__/ +*.py[cod] +/node_modules +/node_modules_link_check/ +/raw/ +/outputs/ +/analysis.json +/analysis_log.json +/build_log.txt +/source_manifest.json diff --git a/analyze.py b/analyze.py new file mode 100644 index 0000000..3a9e642 --- /dev/null +++ b/analyze.py @@ -0,0 +1,180 @@ +"""Daily closing-price drawdowns and next-observation breakeven, no third-party deps.""" +import csv +import datetime as dt +import json +import math +import statistics +from pathlib import Path +from zoneinfo import ZoneInfo + +ROOT = Path(__file__).resolve().parent +OUT = ROOT / 'outputs' / '20261019' +OUT.mkdir(parents=True, exist_ok=True) +ASOF = dt.date(2026, 10, 19) +NAMES = {'nasdaq': '纳斯达克综合', 'sp500': '标普500'} + +def read_yahoo(key): + x = json.loads((ROOT / 'raw' / f'yahoo_{key}.json').read_text())['chart']['result'][0] + result = {} + for t, p in zip(x['timestamp'], x['indicators']['quote'][0]['close']): + d = dt.datetime.fromtimestamp(t, ZoneInfo('America/New_York')).date() + if p is not None and d <= ASOF: + assert math.isfinite(p) and p > 0 + assert d not in result + result[d] = int(round(p * 100)) + return result + +def read_fred(key): + result = {} + with (ROOT / 'raw' / f'fred_{key}.csv').open() as f: + for row in csv.DictReader(f): + d = dt.date.fromisoformat(row['observation_date']) + v = next(v for k, v in row.items() if k != 'observation_date') + if v not in ('', '.') and d <= ASOF: + result[d] = int(round(float(v) * 100)) + return result + +def episodes(dates, prices): + """Every strictly-below-running-max spell, including one-observation spells.""" + peak, active = 0, False + result = [] + for i in range(1, len(prices)): + if prices[i] >= prices[peak]: + if active: + result.append(make_event(dates, prices, peak, trough, i)) + active = False + peak = i # last equal high before the next decline + elif not active: + active, trough = True, i + elif prices[i] < prices[trough]: + trough = i # first occurrence if the trough ties + if active: + result.append(make_event(dates, prices, peak, trough, None)) + return result + +def make_event(dates, prices, p, t, r): + end = r if r is not None else len(prices) - 1 + return { + 'peak_i': p, 'trough_i': t, 'recovery_i': r, + 'peak_date': dates[p].isoformat(), 'peak_close': prices[p] / 100, + 'first_underwater_date': dates[p + 1].isoformat(), + 'trough_date': dates[t].isoformat(), 'trough_close': prices[t] / 100, + 'recovery_date': dates[r].isoformat() if r is not None else None, + 'recovery_close': prices[r] / 100 if r is not None else None, + 'max_drawdown': prices[t] / prices[p] - 1, + 'decline_calendar_days': (dates[t] - dates[p]).days, + 'decline_trading_days': t - p, + 'rebound_calendar_days': (dates[r] - dates[t]).days if r is not None else None, + 'rebound_trading_days': r - t if r is not None else None, + 'total_calendar_days': (dates[r] - dates[p]).days if r is not None else None, + 'total_trading_days': r - p if r is not None else None, + 'observed_calendar_days': (dates[end] - dates[p]).days, + 'observed_trading_days': end - p, + 'end_date': dates[end].isoformat(), + 'end_drawdown': prices[end] / prices[p] - 1, + 'status': '已解套' if r is not None else '未解套', + } + +def next_breakeven(prices): + """First strictly later observation with price >= purchase price. O(n).""" + result = [None] * len(prices) + stack = [] + for i, p in enumerate(prices): + while stack and p >= prices[stack[-1]]: + result[stack.pop()] = i + stack.append(i) + return result + +def verify(dates, prices, events, next_ge): + assert len(dates) == len(set(dates)) and dates == sorted(dates) + # Independent cumulative maximum spell counting. + running, underwater, starts = 0, False, 0 + for v in prices: + running = max(running, v) + flag = v < running + starts += flag and not underwater + underwater = flag + assert starts == len(events) + for k, e in enumerate(events): + p, t, r = e['peak_i'], e['trough_i'], e['recovery_i'] + end = r if r is not None else len(prices) + assert prices[p] == max(prices[:p + 1]) + assert prices[t] == min(prices[p + 1:end]) + assert all(x < prices[p] for x in prices[p + 1:end]) + if r is not None: + assert prices[r] >= prices[p] + assert e['total_calendar_days'] == e['decline_calendar_days'] + e['rebound_calendar_days'] + if k: + assert events[k - 1]['recovery_i'] <= p + # Brute-force a deterministic sample plus every event peak and trough. + sample = set(range(0, len(prices), 97)) | {len(prices) - 1} + sample |= {e[q] for e in events for q in ('peak_i', 'trough_i')} + for i in sample: + brute = next((j for j in range(i + 1, len(prices)) if prices[j] >= prices[i]), None) + assert brute == next_ge[i], (i, brute, next_ge[i]) + return {'status': '通过', 'event_invariants_checked': len(events), 'breakeven_bruteforce_samples': len(sample)} + +def synthetic_tests(): + for prices, expected in [([100,100,99,100], [(1,2,3)]), ([100,90,95,80,100],[(0,3,4)]), ([100,90,90],[(0,1,None)]), ([100,101,102],[])]: + dates = [dt.date(2000,1,1) + dt.timedelta(days=i) for i in range(len(prices))] + ev = episodes(dates,prices) + assert [(x['peak_i'],x['trough_i'],x['recovery_i']) for x in ev] == expected + verify(dates,prices,ev,next_breakeven(prices)) + +def csv_write(name, rows): + with (OUT / name).open('w', encoding='utf-8-sig', newline='') as f: + writer = csv.writer(f) + writer.writerows(rows) + +def main(): + synthetic_tests() + analysis = {'asof_date': ASOF.isoformat(), 'indices': {}, 'source_priority': 'FRED有效收盘优先;FRED无观测时使用Yahoo同指数日收盘;保留逐行来源。不填充休市日。', 'checks': {}} + diffs = [['指数', '日期', 'Yahoo收盘', 'FRED收盘', '差值Yahoo减FRED', '最终使用']] + for key in NAMES: + yahoo, fred = read_yahoo(key), read_fred(key) + # FRED has an anomalous quote on Good Friday 2019-04-19. + # Use the matching Yahoo index's observed-session dates as the date spine. + # This preserves real exchange closures instead of inventing a session. + excluded = [{'date':d.isoformat(),'fred_close':fred[d]/100,'reason':'FRED有值但Yahoo无交易观测;2019-04-19为Good Friday休市'} for d in sorted(fred.keys()-yahoo.keys())] + assert all(x['date'] == '2019-04-19' for x in excluded), excluded + canonical = {d:fred.get(d,p) for d,p in yahoo.items()} + dates = sorted(canonical) + prices = [canonical[d] for d in dates] + sources = ['FRED' if d in fred else 'Yahoo Finance' for d in dates] + ev, nge = episodes(dates, prices), next_breakeven(prices) + mismatches = [(d, yahoo[d], fred[d]) for d in sorted(yahoo.keys() & fred.keys()) if yahoo[d] != fred[d]] + diffs += [[NAMES[key], d.isoformat(), a/100, b/100, (a-b)/100, 'FRED'] for d,a,b in mismatches] + check = verify(dates, prices, ev, nge) + complete = [e for e in ev if e['recovery_i'] is not None] + buckets = [] + for cutoff in [0, .01, .05, .10, .20, .30, .50]: + subset = [e for e in ev if -e['max_drawdown'] >= cutoff] + done = [e for e in subset if e['recovery_i'] is not None] + times = [e['total_calendar_days'] for e in done] + buckets.append({'cutoff':cutoff,'all_events':len(subset),'recovered':len(done),'open':len(subset)-len(done),'median_calendar_days':statistics.median(times) if times else None,'mean_calendar_days':statistics.mean(times) if times else None,'max_calendar_days':max(times) if times else None}) + daily = [] + ath = 0 + for i,(d,p,s) in enumerate(zip(dates,prices,sources)): + ath = max(ath,p) + j = nge[i] + last = j if j is not None else len(dates)-1 + # A price below purchase before first >= means an actual loss spell. + loss = i + 1 < len(prices) and prices[i+1] < p + status = '最后观测日' if i == len(prices)-1 else ('已回本' if j is not None else '尚未回本') + daily.append([NAMES[key],d.isoformat(),p/100,s,ath/100,p/ath-1,dates[j].isoformat() if j is not None else None,(dates[j]-d).days if j is not None else None,j-i if j is not None else None,'是' if loss else '否',status,(dates[last]-d).days,last-i,i]) + longest = max(complete,key=lambda x:x['total_calendar_days']) + deepest = min(ev,key=lambda x:x['max_drawdown']) + item = {'name':NAMES[key],'key':key,'start':dates[0].isoformat(),'end':dates[-1].isoformat(),'observations':len(dates),'fred_rows':sources.count('FRED'),'yahoo_rows':sources.count('Yahoo Finance'),'source_overlap':len(yahoo.keys()&fred.keys()),'source_mismatches':len(mismatches),'excluded_observations':excluded,'events':ev,'buckets':buckets,'daily':daily,'longest':longest,'deepest':deepest,'open_events':[e for e in ev if e['recovery_i'] is None]} + analysis['indices'][key] = item + analysis['checks'][key] = check + csv_write(f'{key}_daily.csv', [['指数','日期','收盘点位','来源','历史最高收盘','距历史最高回撤','买入后首次不低于买价日期','等待自然日','等待交易日','等待期间发生浮亏','状态','截至回本或样本末已观察自然日','已观察交易日','交易日序号从0起']] + daily) + fields = ['peak_date','first_underwater_date','peak_close','trough_date','trough_close','recovery_date','recovery_close','max_drawdown','decline_calendar_days','decline_trading_days','rebound_calendar_days','rebound_trading_days','total_calendar_days','total_trading_days','status','end_date','observed_calendar_days','observed_trading_days','end_drawdown'] + header = ['前高日','首次跌破日','前高点位','谷底日','谷底点位','解套日','解套收盘','最大回撤','下跌自然日','下跌交易日','谷底后恢复自然日','谷底后恢复交易日','总解套自然日','总解套交易日','状态','观测截止日','已观察自然日','已观察交易日','截止日距前高回撤'] + csv_write(f'{key}_drawdowns.csv', [header] + [[e[f] for f in fields] for e in ev]) + print(json.dumps({k:v for k,v in item.items() if k not in ('daily','events')},ensure_ascii=False,indent=2)) + csv_write('source_discrepancies.csv',diffs) + (ROOT/'analysis.json').write_text(json.dumps(analysis,ensure_ascii=False,separators=(',',':'))) + (OUT/'validation.json').write_text(json.dumps(analysis['checks'],ensure_ascii=False,indent=2)) + +if __name__ == '__main__': + main() diff --git a/build_workbook.mjs b/build_workbook.mjs new file mode 100644 index 0000000..cbf8fb5 --- /dev/null +++ b/build_workbook.mjs @@ -0,0 +1,218 @@ +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})); diff --git a/fetch_data.py b/fetch_data.py new file mode 100644 index 0000000..fbe3a93 --- /dev/null +++ b/fetch_data.py @@ -0,0 +1,46 @@ +"""Download public daily index observations; retain responses and provenance.""" +import concurrent.futures +import datetime as dt +import hashlib +import json +from pathlib import Path +import urllib.request + +ROOT = Path(__file__).resolve().parent +RAW = ROOT / 'raw' +RAW.mkdir(parents=True, exist_ok=True) +END = int(dt.datetime(2026, 10, 20, tzinfo=dt.timezone.utc).timestamp()) +SOURCES = { + 'yahoo_nasdaq.json': f'https://query1.finance.yahoo.com/v8/finance/chart/%5EIXIC?period1=-2208988800&period2={END}&interval=1d', + 'yahoo_sp500.json': f'https://query1.finance.yahoo.com/v8/finance/chart/%5EGSPC?period1=-2208988800&period2={END}&interval=1d', + 'fred_nasdaq.csv': 'https://fred.stlouisfed.org/graph/fredgraph.csv?id=NASDAQCOM&cosd=1971-02-05', + 'fred_sp500.csv': 'https://fred.stlouisfed.org/graph/fredgraph.csv?id=SP500', + 'stooq_sp500.csv': 'https://stooq.com/q/d/l/?s=%5Espx&i=d', +} + +def fetch(item): + name, url = item + try: + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + with urllib.request.urlopen(req, timeout=60) as response: + data = response.read() + if name.endswith('.json'): + doc = json.loads(data) + if not doc.get('chart', {}).get('result'): + raise ValueError('No Yahoo chart result') + elif not data.startswith((b'observation_date,', b'DATE,', b'Date,')): + raise ValueError(f'Unexpected CSV response: {data[:60]!r}') + (RAW / name).write_bytes(data) + result = {'file': name, 'url': url, 'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest(), 'retrieved_utc': dt.datetime.now(dt.timezone.utc).isoformat(), 'status': 'ok'} + if name.endswith('.json'): + x = doc['chart']['result'][0] + result.update(rows=len(x['timestamp']), first_timestamp=x['timestamp'][0], last_timestamp=x['timestamp'][-1]) + except Exception as exc: + result = {'file': name, 'url': url, 'status': 'failed', 'error': str(exc)} + print(json.dumps(result), flush=True) + return result + +if __name__ == '__main__': + with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: + results = list(executor.map(fetch, SOURCES.items())) + (ROOT / 'source_manifest.json').write_text(json.dumps(results, ensure_ascii=False, indent=2)) diff --git a/package_results.py b/package_results.py new file mode 100644 index 0000000..cdd4354 --- /dev/null +++ b/package_results.py @@ -0,0 +1,69 @@ +import json +import shutil +import zipfile +from pathlib import Path + +ROOT=Path(__file__).resolve().parent +OUT=ROOT/'outputs'/'20261019' +a=json.loads((ROOT/'analysis.json').read_text()) +lines=['# 纳斯达克综合与标普500:日线回撤及解套时间','', + '抓取日期:2026-10-19(北京时间)。行情截至美国交易日 **2026-10-16**。NASDAQ 在本报告指纳斯达克综合指数 ^IXIC,不是纳斯达克100。','', + '## 数据范围','', + '| 指数 | 起点 | 终点 | 有效日线 | 回撤段数 | 已解套 | 未解套 |', + '|---|---|---|---:|---:|---:|---:|'] +for x in a['indices'].values(): + lines.append(f"| {x['name']} | {x['start']} | {x['end']} | {x['observations']:,} | {len(x['events'])} | {len(x['events'])-len(x['open_events'])} | {len(x['open_events'])} |") +lines += ['', '标普500于1957-03-04正式推出;更早数据是数据商提供的前身/回溯序列,不代表当时已有现行500只股票指数。早期日线没有逐日与独立交易所日历核对,可能未含当年的周六交易。因此“交易日”严格指本数据集的有效日线间隔数;自然日数直接由日期相减。','', + '## 计算口径','', + '- 价格:名义指数日收盘价,不含分红再投资、税费、交易成本、汇率或通胀。价格比较统一到0.01点,消除Yahoo浮点表示噪声。', + '- 回撤:日收盘低于截至此前的历史最高收盘后开始,首次收盘大于或等于该前高时结束。所有严格低于前高的事件均保留,没有跌幅或时长门槛。', + '- 前高日:下跌前最后一个达到该历史最高收盘的日期。谷底日:该事件最低收盘首次出现的日期。', + '- 总解套自然日 = 解套日 − 前高日。下跌自然日 = 谷底日 − 前高日。谷底后恢复自然日 = 解套日 − 谷底日。', + '- 交易日:对应日期在有效日线中的序号相减,不含起点、包含终点。不填充休市日,不插值。', + '- 未解套:解套日期、最终总时长和恢复时长留空;“已观察天数”只累计至2026-10-16。它是右删失的未完成事件,不参加已解套时长均值/中位数。', + '- 一段长回撤包含期间所有反弹,不重复拆分。例如纳指2000—2015年算一段,内部2007年的局部高点不另起历史新高回撤。', + '- 为补充任意日期买入的情况,日线表另列每个买入日之后,首个收盘不低于该日买入价的日期。若次日即盈利,也会列出该日期,但“期间曾浮亏”为否。这列不是历史新高回撤时长。最后一个观测日标为“最后观测日”,不能直接视为亏损。', + '- 事件边界和首次回本日期由脚本识别,是抓取时点快照。Excel可重算简单比率与日期差,但修改价格或新增行情后,须重跑分析脚本以重新识别边界。','', + '## 最大跌幅至少10%的全部回撤','', + '下面仍按“历史新高到收复该前高”的不重叠口径。完整微小回撤也已保存在Excel和CSV。','', + '| 指数 | 前高日 | 谷底日 | 解套日 | 最大回撤 | 下跌自然日 | 谷底后恢复自然日 | 总解套自然日 | 总解套交易日 |', + '|---|---|---|---|---:|---:|---:|---:|---:|'] +for x in a['indices'].values(): + for e in x['events']: + if e['max_drawdown'] <= -.10: + f=lambda v:'未解套' if v is None else str(v) + lines.append(f"| {x['name']} | {e['peak_date']} | {e['trough_date']} | {f(e['recovery_date'])} | {e['max_drawdown']:.2%} | {e['decline_calendar_days']} | {f(e['rebound_calendar_days'])} | {f(e['total_calendar_days'])} | {f(e['total_trading_days'])} |") +lines += ['', '## 目前尚未解套','', '| 指数 | 前高日 | 截止日 | 期间最大回撤 | 已观察自然日 | 已观察交易日 |','|---|---|---|---:|---:|---:|'] +for x in a['indices'].values(): + for e in x['open_events']: + lines.append(f"| {x['name']} | {e['peak_date']} | {e['end_date']} | {e['max_drawdown']:.2%} | {e['observed_calendar_days']} | {e['observed_trading_days']} |") +lines += ['', '## 数据来源与交叉核验','', + '两项指数均以Yahoo同指数日线的有效观测日期为日期基准。在这些日期上,FRED有有效收盘价时优先采用FRED(其上游为指数公司),FRED没有时使用Yahoo。不是总回报指数,不拼接ETF,不把纳指100混入综合指数。',''] +for x in a['indices'].values(): + lines.append(f"- {x['name']}:{x['fred_rows']:,}条FRED,{x['yahoo_rows']:,}条Yahoo。两源重叠{x['source_overlap']:,}条,按0.01点比较有{x['source_mismatches']:,}条不同,全部保留在source_discrepancies.csv,最终选FRED。") +lines += ['','FRED的纳指序列另含2019-04-19一条有效数值,但该日为Good Friday休市且Yahoo无观测。本报告排除此异常行,避免多计交易日。原始响应原样保留。', '', + 'FRED的SP500依协议仅提供最近10年数据,因此更早标普日线依赖Yahoo,未获得独立全历史权威源逐日交叉核验。纳指末日2026-10-16仅Yahoo有观测。数据未来可能修订。','', + '- [FRED NASDAQCOM:来源、日收盘定义](https://fred.stlouisfed.org/series/NASDAQCOM)', + '- [FRED SP500:来源、仅10年限制、价格指数不含分红](https://fred.stlouisfed.org/series/SP500)', + '- [Yahoo纳指历史页](https://finance.yahoo.com/quote/%5EIXIC/history/)', + '- [Yahoo标普历史页](https://finance.yahoo.com/quote/%5EGSPC/history/)', + '- [标普500官方说明、推出日期](https://www.spglobal.com/spdji/en/indices/equity/sp-500/)', + '- [NYSE官方2019年休市日历](https://ir.theice.com/press/news-details/2018/NYSE-Group-Announces-2019-2020-and-2021-Holiday-and-Early-Closings-Calendar/default.aspx)', + '', '## 检查与文件','', + '检查了日期递增、重复日期、正数收盘、事件数与独立累计最高值算法一致、每段最低点、收复日首次达标、不重叠、日期差可加和。逐日回本使用O(n)单调栈,并对抽样日期及所有事件前高/谷底用独立逐日查找核对。另检查同价前高、重复谷底、未解套与没有回撤等人工边界样本。检查结果见validation.json。','', + '- nasdaq_sp500_drawdown_recovery.xlsx:概览、全部回撤、日线与逐日回本三个工作表。', + '- nasdaq_drawdowns.csv / sp500_drawdowns.csv:每次回撤的完整明细。', + '- nasdaq_daily.csv / sp500_daily.csv:逐条来源、每日回撤及买入回本日期。', + '- source_discrepancies.csv:两来源不一致的观测。', + '- source_manifest.json:下载URL、时间戳、字节数和SHA-256校验。raw/为未经改写的响应。', + '- analyze.py:可用Python标准库复算。先运行fetch_data.py更新固定截止日期的行情,再运行analyze.py;Excel用build_workbook.mjs重建。',''] +(OUT/'分析说明.md').write_text('\n'.join(lines)) +shutil.copy2(ROOT/'source_manifest.json',OUT/'source_manifest.json') +archive=OUT/'nasdaq_sp500_data_and_analysis.zip' +with zipfile.ZipFile(archive,'w',zipfile.ZIP_DEFLATED) as z: + for p in OUT.iterdir(): + if p.suffix in ('.csv','.json','.md','.xlsx'): + z.write(p,p.name) + for p in (ROOT/'raw').iterdir():z.write(p,'raw/'+p.name) + for name in ['fetch_data.py','analyze.py','build_workbook.mjs','package_results.py']:z.write(ROOT/name,'scripts/'+name) +print(archive)