Files

181 lines
10 KiB
Python

"""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()