Import project source and Git ignore rules

This commit is contained in:
2026-11-05 00:20:18 +08:00
commit 7ec9aa51ad
5 changed files with 526 additions and 0 deletions
+13
View File
@@ -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
+180
View File
@@ -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()
+218
View File
@@ -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<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}));
+46
View File
@@ -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))
+69
View File
@@ -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)