"""Build the three published studies offline: python scripts/build_studies.py.

Python 3.10+, standard library only. Never fetches or overwrites the frozen input.
All dollar outcomes are hypothetical. See research/2026-09/manifest.json.
"""
import csv
import hashlib
import io
import json
import math
from pathlib import Path
from statistics import median

ROOT = Path(__file__).resolve().parents[1]
DATA = ROOT / 'research' / '2026-09'


def write_csv(name, rows):
    stream = io.StringIO(newline='')
    writer = csv.DictWriter(stream, fieldnames=list(rows[0]), lineterminator='\n')
    writer.writeheader()
    writer.writerows(rows)
    (DATA / name).write_text(stream.getvalue(), encoding='utf-8', newline='\n')


def load():
    raw = (DATA / 'prices.json').read_bytes()
    manifest = json.loads((DATA / 'manifest.json').read_text())
    assert hashlib.sha256(raw).hexdigest() == manifest['sha256'], 'Snapshot checksum mismatch'
    data = json.loads(raw)['symbols']
    for symbol, rows in data.items():
        assert rows and all(math.isfinite(r['close']) and r['close'] > 0 for r in rows)
        months = [int(r['date'][:4]) * 12 + int(r['date'][5:7]) for r in rows]
        assert all(b == a + 1 for a, b in zip(months, months[1:])), symbol
    return data, manifest


def drawdown(rows):
    peak = rows[0]
    worst = {'drawdown': 0, 'peak': peak['date'], 'trough': peak['date']}
    for row in rows:
        if row['close'] > peak['close']:
            peak = row
        loss = row['close'] / peak['close'] - 1
        if loss < worst['drawdown']:
            worst = {'drawdown': loss, 'peak': peak['date'], 'trough': row['date']}
    peak_price = next(r['close'] for r in rows if r['date'] == worst['peak'])
    worst['recovery'] = next((r['date'] for r in rows if r['date'] > worst['trough'] and r['close'] >= peak_price), 'Not recovered in window')
    return worst


def table(headers, rows):
    return '<div class="table-scroll" tabindex="0" role="region" aria-label="Study results; scroll horizontally on small screens"><table class="article-table"><thead><tr>' + ''.join('<th scope="col">' + x + '</th>' for x in headers) + '</tr></thead><tbody>' + ''.join('<tr>' + ''.join('<td>' + str(x) + '</td>' for x in row) + '</tr>' for row in rows) + '</tbody></table></div>'


def pct(x):
    return f'{x * 100:.2f}%'


def page(stem, title, description, body, output, manifest):
    shared = f'''<h2>Data, assumptions, and reproducibility</h2>
<p>This is an original descriptive calculation by Sun Insight Lab using a frozen response from the public API that powers our <a href="/simulator">Simulator</a>. Retrieved {manifest['retrieved_at']}; coverage is January 2010 through December 2025. The last observation is December 1, 2025, not the last trading day of that year. The endpoint selects the first available trading-day observation in each month; the IPO month can start later. No interpolation, missing-month fill, or future data is used.</p>
<p>The S&amp;P 500 series is the Yahoo Finance ^GSPC price index: it omits dividend reinvestment and cannot itself be purchased. Stock series come from the pipeline's adjusted-close field and can incorporate dividend and split adjustments. We do not describe the mixed series as a uniform total-return comparison. No separate dividend payments are added. Taxes, fees, trading spreads, inflation, and cash interest are excluded. Fractional units and frictionless transactions are assumed.</p>
<p><a href="/research/2026-09/prices.json" download>Frozen API data (JSON)</a> · <a href="/research/2026-09/prices.csv" download>Prices (CSV)</a> · <a href="/research/2026-09/{output}" download>All observations for this study (CSV)</a> · <a href="/research/2026-09/results.json" download>Summary results (JSON)</a> · <a href="/research/2026-09/manifest.json">Provenance and SHA-256 checksum</a> · <a href="/research/2026-09/reproduce.py" download>Calculation and page-generation script</a></p>
<p>Download <code>prices.json</code> and <code>manifest.json</code> into <code>research/2026-09/</code>, and save <code>reproduce.py</code> as <code>scripts/build_studies.py</code> beside that directory. Run <code>python scripts/build_studies.py</code> with Python 3.10 or newer. No packages, API keys, or network access are needed. The script checks the snapshot hash and complete monthly coverage, then regenerates the CSVs, summaries, and all three articles. Rounded displayed figures come from the unrounded calculations.</p>
<p>The snapshot preserves what the simulator served on retrieval; it is not an independent audit of every vendor price. Later corrections to the live feed may change an interactive replay. Dates and symbol selection are part of the result, not evidence of predictive power. See <a href="/data-methodology">data methodology</a> and <a href="/editorial-policy">editorial policy</a>.</p>
<h2>Sources</h2><ul>
<li><a href="https://suninsightlab.com/api/prices?symbols=sp500,nvda,tsla,pltr&amp;from=2010-01-01&amp;to=2025-12-31">Simulator public price API</a> (the frozen response above is the numerical source).</li>
<li><a href="https://www.spglobal.com/spdji/en/documents/additional-material/faq-sp-500-dividend-points-index.pdf">S&amp;P Dow Jones Indices: price and dividend return distinction</a>.</li>
<li><a href="https://github.com/ranaroussi/yfinance/blob/main/yfinance/utils.py">yfinance adjustment implementation</a> (upstream methodology context, not a certification of this snapshot).</li></ul>
<p>Educational analysis, not a forecast or a recommendation. <a href="/findings">Browse all three studies</a> · <a href="/contact">Report a correction</a>.</p>'''
    html = f'''<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title} | Sun Insight Lab</title><meta name="description" content="{description}">
<meta name="robots" content="index,follow,max-image-preview:large"><meta name="author" content="Sun Insight Lab">
<meta name="google-adsense-account" content="ca-pub-5817993938289413">
<link rel="canonical" href="https://suninsightlab.com/{stem}"><link rel="stylesheet" href="/style.css?v=20260907a"><link rel="icon" href="/favicon.svg">
</head><body class="content-page"><main class="content-main"><article class="content-article">
<h1>{title}</h1><p class="content-meta">Published September 7, 2026 · Sun Insight Lab · Original simulator study</p>
{body}{shared}</article></main><script defer src="/site-nav.js?v=20260907a"></script></body></html>\n'''
    (ROOT / (stem + '.html')).write_text(html, encoding='utf-8', newline='\n')


def main():
    data, manifest = load()
    write_csv('prices.csv', [dict(symbol=s, **r) for s, rows in data.items() for r in rows])
    sp = data['sp500']
    rolling = []
    horizons = []
    for months in (12, 36, 60, 120):
        group = []
        for i in range(len(sp) - months):
            a, b = sp[i], sp[i + months]
            ret = b['close'] / a['close'] - 1
            row = dict(months=months, start=a['date'], end=b['date'], start_price=a['close'], end_price=b['close'], return_fraction=ret, annualized=(1 + ret) ** (12 / months) - 1)
            group.append(row)
        rolling.extend(group)
        low, high = min(group, key=lambda r: r['annualized']), max(group, key=lambda r: r['annualized'])
        horizons.append(dict(months=months, windows=len(group), losses=sum(r['return_fraction'] < 0 for r in group), minimum=low['annualized'], median=median(r['annualized'] for r in group), maximum=high['annualized'], worst_start=low['start'], worst_end=low['end']))
    write_csv('rolling-horizons.csv', rolling)
    body = '''<p>Does a longer holding period eliminate losses? We test every available 1-, 3-, 5-, and 10-year S&amp;P 500 window in the simulator snapshot, rather than choosing one attractive start date. The question concerns endpoint price changes; it does not measure what an investor experienced between the endpoints.</p>'''
    body += table(['Horizon', 'Windows', 'Loss windows', 'Worst annualized', 'Median annualized', 'Best annualized'], [[f"{r['months']//12} years", r['windows'], r['losses'], pct(r['minimum']), pct(r['median']), pct(r['maximum'])] for r in horizons])
    body += '<h2>What changes when you move the start date?</h2><p>' + '; '.join(f"the {r['months']//12}-year windows range from {pct(r['minimum'])} to {pct(r['maximum'])} annualized" for r in horizons) + '.</p>'
    body += f'''<p>The weakest one-year interval began {horizons[0]['worst_start']} and ended {horizons[0]['worst_end']}. That is a useful counterexample to judging this dataset only by its full-period rise. Longer windows combine more market regimes, but they also leave fewer observations available for comparison.</p>
<h2>Method</h2><p>For each starting observation i and horizon h months, we use P[i+h]/P[i] − 1. Annualized growth is (P[i+h]/P[i])^(12/h) − 1. A 12-month interval requires 13 observations, not 12. We count a loss only when the endpoint is below the start. Medians use all eligible windows, including those ending in a decline. For a spreadsheet replay, sort the CSV's sp500 rows by date and divide each close by the close h rows earlier.</p>
<h2>What the result cannot establish</h2><p>These are overlapping windows: adjacent ten-year observations share almost all their history. They are not independent trials, and their loss frequency is not the probability of a future loss. This sample begins after the 2008 financial crisis and omits many earlier market regimes. Even if a column has zero losses, it is not a guarantee. Inflation could turn a positive nominal outcome into a real loss, and a severe interim drawdown can occur inside a positive endpoint window.</p>
<h2>Replay one interval</h2><p>In Single mode, choose S&amp;P 500, set initial investment to $10,000 and monthly contribution to $0, and select the months in any CSV row. Divide ending value by $10,000 and subtract one to recover the unannualized return. Repeat with a neighboring start month while keeping the horizon constant. Use the downloaded snapshot for an exact replay if live prices have changed.</p>'''
    page('study-rolling-horizons', 'How holding periods change S&amp;P 500 outcomes', 'Every available 1-, 3-, 5-, and 10-year window in our frozen 2010–2025 simulator data, with downloadable results and Python code.', body, 'rolling-horizons.csv', manifest)

    dca = []
    for i in range(len(sp) - 11):
        rows = sp[i:i + 12]
        lump = 12000 * rows[-1]['close'] / rows[0]['close']
        staged = sum(1000 / r['close'] for r in rows) * rows[-1]['close']
        dca.append(dict(start=rows[0]['date'], end=rows[-1]['date'], lump_sum=lump, staged=staged, lump_minus_staged=lump - staged))
    write_csv('cash-deployment.csv', dca)
    deployment = dict(windows=len(dca), lump_wins=sum(r['lump_minus_staged'] > 1e-8 for r in dca), staged_wins=sum(r['lump_minus_staged'] < -1e-8 for r in dca), median_gap=median(r['lump_minus_staged'] for r in dca), worst_lump=min(r['lump_sum'] for r in dca), worst_staged=min(r['staged'] for r in dca))
    best_lump, best_staged = max(dca, key=lambda r:r['lump_minus_staged']), min(dca, key=lambda r:r['lump_minus_staged'])
    body = f'''<p>If $12,000 is already available, how did investing it immediately compare with twelve $1,000 purchases in this snapshot? We run both schedules from every eligible starting month. Each comparison commits the same $12,000 and ends on the same observation. This is a test of delayed deployment of existing cash, not a test of saving from future paychecks.</p>
<p><strong>Lump sum finished ahead in {deployment['lump_wins']} of {deployment['windows']} windows ({pct(deployment['lump_wins']/deployment['windows'])}); staged purchases finished ahead in {deployment['staged_wins']}.</strong> The median lump-sum advantage was ${deployment['median_gap']:,.2f}. These are sample counts, not forecast odds.</p>'''
    body += table(['Scenario', 'Start', 'End', 'Lump sum', 'Twelve purchases', 'Lump minus staged'], [[label,r['start'],r['end'],f"${r['lump_sum']:,.2f}",f"${r['staged']:,.2f}",f"${r['lump_minus_staged']:,.2f}"] for label,r in [('Largest lump advantage',best_lump),('Largest staged advantage',best_staged)]])
    body += f'''<h2>Why the cash schedule matters</h2><p>When the index rises after entry, investing earlier exposes more of the capital to that rise. When prices fall early, the staged schedule can buy more units later. The same mechanism can help or hurt; a lower average purchase price is not guaranteed and is not itself a measure of profit. The worst ending wealth across this sample was ${deployment['worst_lump']:,.2f} for lump sum and ${deployment['worst_staged']:,.2f} for staged purchases. Those minima may come from different windows.</p>
<h2>Method and fair comparison</h2><p>At the first observation, lump sum buys $12,000/P[0] units. The staged schedule buys $1,000/P[t] at each of twelve observations t=0 through 11, including the final valuation date. Ending values are units multiplied by P[11]. There are eleven monthly intervals between the twelve purchases: this is not a twelve-month holding-period return. Idle cash earns zero and remains part of the investor's wealth until spent; all of it is invested by the final observation.</p>
<p>We use every consecutive twelve-observation block, including overlapping blocks, and report the full CSV rather than only the winning scenarios. No CAGR is calculated on staged contributions: cash enters at different times. A spreadsheet can sum 1000/close across a block and multiply by its last close.</p>
<h2>Replay in the simulator</h2><p>Choose S&amp;P 500 and one start/end month pair above. Run Single mode first with $12,000 initial and $0 monthly, then with $0 initial and $1,000 monthly. The tool makes a contribution on the first and final observations, so confirm that twelve contributions total $12,000. Compare ending dollars, not the two displayed percentages as if capital had identical time in the market.</p>
<h2>Limits and external context</h2><p>This result excludes dividends, interest on waiting cash, tax, inflation, and fund costs. Paying interest on idle cash would improve the staged result. A different staging duration or market history may change both the win count and size of the gap. Adjacent windows are dependent. Payroll contributions are a different decision because money not yet earned cannot be invested upfront.</p>
<p><a href="https://www.investor.gov/introduction-investing/investing-basics/glossary/dollar-cost-averaging">Investor.gov defines dollar-cost averaging</a> as regular equal-dollar investing. <a href="https://corporate.vanguard.com/content/dam/corp/research/pdf/cost_averaging_invest_now_or_temporarily_hold_your_cash.pdf">Vanguard's February 2023 research</a> reports a 68% lump-sum win rate for its global-market comparison with three-month staging and a one-year evaluation. Its assets, period, and schedule differ from ours; it is context, not validation of our numerical result.</p>'''
    page('study-cash-deployment', 'Invest now or spread twelve purchases?', 'An equal-capital comparison of $12,000 upfront versus twelve $1,000 purchases across every eligible window in our simulator snapshot.', body, 'cash-deployment.csv', manifest)

    risk = []
    for symbol in ('sp500', 'nvda', 'tsla', 'pltr'):
        rows = [r for r in data[symbol] if '2021-01' <= r['date'][:7] <= '2025-12']
        assert len(rows) == 60
        multiple = rows[-1]['close'] / rows[0]['close']
        risk.append(dict(symbol=symbol, start=rows[0]['date'], end=rows[-1]['date'], start_price=rows[0]['close'], end_price=rows[-1]['close'], multiple=multiple, annualized=multiple ** (12/59) - 1, **drawdown(rows)))
    write_csv('growth-and-drawdown.csv', risk)
    body = '''<p>A large ending multiple says little about the hardest part of holding an investment. We compare growth with the largest observed peak-to-trough decline for S&amp;P 500, NVIDIA, Tesla, and Palantir over the same January 2021–December 2025 monthly sample. The start was chosen to give all four series complete calendar-year coverage after Palantir's listing; it is not an optimized entry date.</p>'''
    body += table(['Series', 'Ending multiple', 'Annualized growth', 'Maximum sampled drawdown', 'Peak', 'Trough', 'Recovery'], [[r['symbol'].upper(),f"{r['multiple']:.2f}×",pct(r['annualized']),pct(r['drawdown']),r['peak'],r['trough'],r['recovery']] for r in risk])
    worst = min(risk, key=lambda r:r['drawdown'])
    body += f'''<h2>Growth and the path to it are different questions</h2><p>The deepest sampled decline among these four series was {pct(worst['drawdown'])} for {worst['symbol'].upper()}, from {worst['peak']} to {worst['trough']}. A hypothetical $10,000 position bought at that sampled peak would have been worth ${10000*(1+worst['drawdown']):,.2f} at the trough before costs. This peak-entry illustration is separate from buying at the beginning of the study.</p>
<p>Recovery means reaching the previous sampled peak again, not recovering every investor's purchase price or compensating for inflation. A row can finish above its study starting price while still being below a later peak. Read the growth and drawdown columns together rather than sorting only by the winner.</p>
<h2>Method</h2><p>Each series has 60 observations and 59 monthly intervals. Ending multiple is last close / first close; annualized growth is multiple^(12/59) − 1. At each observation we maintain the highest close seen so far, calculate close / running peak − 1, and retain the most negative value. Recovery is the first later observation at or above that peak; a missing recovery means none was observed before the sample ended. There are no contributions or rebalancing in these calculations.</p>
<h2>Monthly sampling hides risk</h2><p>These are first-trading-day samples, not daily closes. A crash and recovery between two samples will be missed. The worst daily or intraday drawdown can therefore be deeper than this table. Nor does a historical recovery show that a future decline must recover. The S&amp;P series excludes dividends while adjusted stock prices may include them, so relative growth here is not a comparison of identically defined total returns.</p>
<h2>Selection bias</h2><p>These are four familiar choices already offered in the simulator, not a random portfolio or the historical investable universe. Three are individual technology-related companies. Selecting recognizable survivors after observing their success can make stock selection look easier than it was. This study describes their paths and does not estimate the performance of a strategy that could have selected them in advance.</p>
<h2>Replay and extend</h2><p>Use Single mode with $10,000 initial, $0 monthly, January 2021 through December 2025. Divide the ending value by $10,000 to reproduce the multiple. For drawdown, download the prices CSV and calculate a running maximum in a spreadsheet; deposits would otherwise obscure losses in an account-value chart. Then move the start month and repeat. The code provides the exact peak, trough, and recovery rule rather than relying on visual estimates from a chart.</p>'''
    page('study-growth-drawdowns', 'Strong growth can coexist with deep drawdowns', 'A common-window study of S&P 500, NVIDIA, Tesla, and Palantir growth, sampled drawdowns, and recovery dates from 2021 through 2025.', body, 'growth-and-drawdown.csv', manifest)
    (DATA / 'results.json').write_text(json.dumps(dict(rolling_horizons=horizons, cash_deployment=deployment, growth_drawdowns=risk), indent=2) + '\n', encoding='utf-8', newline='\n')
    (DATA / 'reproduce.py').write_bytes(Path(__file__).read_bytes())
    print(json.dumps(dict(horizons=horizons, deployment=deployment, risk=risk), indent=2))


if __name__ == '__main__':
    main()
