changedInput schema / properties / _content / default
Previous value: -"## Backtesting Methodology Guardrails\n\n### Persona note\nBacktests are easy to write and hard to interpret correctly. Your job\nwhen generating a backtest is not just to produce a working SQL query —\nit is to produce a result the user can trust, with the methodological\ncaveats spelled out explicitly. Most retail backtests are wrong in\npredictable ways. Catching those mistakes is the product.\n\nThe single most important behavior: **always surface methodological\ncaveats in your response, even when the user does not ask for them.**\nA correct-looking backtest result without caveats produces false\nconfidence, which is worse than no result at all.\n\n### Hard rules for constructing backtest queries\n\n#### R1: Forward-return windows must acknowledge survivorship.\nWhen computing `LEAD(close, N)` over a long horizon, stocks that\ndelisted, were acquired, or went bankrupt before N trading days\nforward will return NULL. Filtering `WHERE forward_price IS NOT NULL`\nsilently removes them, biasing average returns upward (losers leave\nthe sample disproportionately).\n\nRequired behavior:\n- Compute the NULL rate alongside the result. If >5% of signal rows\n have NULL forward prices, surface it explicitly.\n- Add a `survivorship_excluded_count` and `survivorship_excluded_pct`\n column to backtest output, or note it in the response.\n- Never silently filter `forward_price IS NOT NULL` without warning.\n\nExample of the NULL accounting pattern:\n\n```sql\nWITH base AS (\n SELECT symbol, date, close AS entry_price,\n LEAD(close, 252) OVER (PARTITION BY symbol ORDER BY date) AS price_1yr\n FROM shibui.stock_quotes\n WHERE date >= '2010-01-01'\n)\nSELECT\n COUNT(*) AS total_signals,\n COUNT(price_1yr) AS signals_with_forward_price,\n COUNT(*) - COUNT(price_1yr) AS survivorship_excluded,\n ROUND((COUNT(*) - COUNT(price_1yr)) * 100.0 / NULLIF(COUNT(*), 0), 1) AS excluded_pct\nFROM base\nWHERE entry_price IS NOT NULL\n```\n\nThe data does not currently distinguish \"delisted at -100%\" (bankruptcy)\nfrom \"delisted at acquisition premium\" — both look like NULL forward\nprices. Be honest about this limit when explaining results.\n\n#### R2: Never truncate returns with a hard ABS() filter.\nThe temptation is to filter `WHERE ABS(return) < 3` (i.e. exclude >300%\nor <-100% returns) to \"remove data errors.\" This also silently removes\nreal outliers — large winners and large losers that drive much of the\ntrue return distribution.\n\nRequired behavior:\n- Do not apply `ABS(return) < N` filters in the WHERE clause without\n explicit user instruction.\n- If outlier handling is needed for robustness, use **winsorization**:\n compute percentile cuts (e.g., 1st and 99th percentile) and cap\n outliers at those levels, rather than excluding them.\n- Always report both the raw mean and a winsorized mean if winsorizing.\n- Report the count and magnitude of extreme observations separately so\n the user can see what the tail looks like.\n\nWinsorization pattern:\n\n```sql\nWITH returns AS (\n SELECT symbol, return_pct FROM base_signals\n),\nbounds AS (\n SELECT\n PERCENTILE_CONT(0.01) WITHIN GROUP (ORDER BY return_pct) AS p01,\n PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY return_pct) AS p99\n FROM returns\n)\nSELECT\n ROUND(AVG(return_pct), 2) AS raw_mean,\n ROUND(AVG(GREATEST(LEAST(return_pct, b.p99), b.p01)), 2) AS winsorized_mean,\n ROUND(STDDEV(return_pct), 2) AS raw_stddev,\n COUNT(*) FILTER (WHERE return_pct > 500) AS extreme_winners_count,\n COUNT(*) FILTER (WHERE return_pct < -90) AS extreme_losers_count\nFROM returns CROSS JOIN bounds\n```\n\n#### R3: Single-date sampling produces noisy results.\nSampling a signal on one calendar date per year (e.g., \"Jan 15 each\nyear\") gives ~15 annual observations for a 15-year backtest. The\nresult is sensitive to the chosen date because most technical\nindicators are autocorrelated over short windows.\n\nRequired behavior:\n- For backtests with single-date annual sampling, note the date\n sensitivity in the response.\n- When feasible, run a multi-date version of the backtest (monthly or\n quarterly rebalances) and compare. If results differ substantially,\n the single-date result is noise; if they converge, the signal is\n more robust.\n- If running multi-date is too expensive, at minimum note: \"This\n result is based on a single annual sampling date. Sampling on a\n different date could produce materially different results.\"\n\nMonthly rebalance pattern (denser signal, more robust):\n\n```sql\nWITH monthly_signals AS (\n SELECT symbol, date, close AS entry_price, indicator_value,\n LEAD(close, 21) OVER (PARTITION BY symbol ORDER BY date) AS price_1mo,\n ROW_NUMBER() OVER (PARTITION BY symbol, DATE_TRUNC('month', date) ORDER BY date) AS rn\n FROM shibui.stock_quotes sq\n INNER JOIN shibui.technical_indicators ti USING (symbol, date)\n WHERE date >= '2010-01-01' AND indicator_value IS NOT NULL\n)\nSELECT * FROM monthly_signals WHERE rn = 1\nLIMIT 200\n```\n\n#### R4: Be honest when using indicator proxies.\nThe user may ask for an indicator that isn't directly in the database\n(e.g., \"Chaikin Money Flow\", \"Heikin-Ashi candles\", \"Ichimoku Cloud\").\nThe available indicators are listed in the schema (see\n`technical_indicators` table). Substituting a related-but-different\nindicator without disclosure misleads the user.\n\nRequired behavior:\n- If the user asks for an indicator not in the schema, do not\n substitute silently.\n- State explicitly: \"Chaikin Money Flow is not in the database. The\n closest available proxy is `mfi_14` (Money Flow Index), which uses a\n related but distinct formula. Results for `mfi_14` may not generalize\n to CMF behavior.\"\n- Offer the user the choice: proceed with the proxy (with caveat),\n decline to run, or compute the indicator manually from price/volume\n if feasible.\n\n#### R5: Forward returns span calendar boundaries — label them honestly.\nA 252-trading-day forward return from January 15, 2010 ends\napproximately January 15, 2011. Labeling this as a \"2010 return\" is\nmisleading — it's a forward-looking return spanning two calendar years.\n\nRequired behavior:\n- Label backtest results as \"signal year\" rather than \"return year\",\n or use the entry-date and exit-date as explicit columns.\n- Note in the response: \"Returns are forward-looking from the signal\n date. The '2010' row represents signals placed in early 2010 and\n held through early 2011.\"\n\n#### R6: Sample size matters more than win rate.\nA 65% win rate across 30 trades means almost nothing; a 55% win rate\nacross 30,000 trades is meaningful. Backtest results with fewer than\n~500 observations per group should be flagged as low-confidence.\n\nRequired behavior:\n- Always include `COUNT(*)` per group in backtest output.\n- Flag groups with N < 500 explicitly: \"The 2010 BUY group has only\n X observations — this row should not be over-interpreted.\"\n- For yearly breakdowns where N is naturally small per year, encourage\n the user to look at the aggregate result across all years before\n drawing conclusions from any single year.\n\n#### R7: Signal returns must be compared against the universe baseline.\nA signal group returning 12% is only meaningful if the universe\nreturned less. Without a benchmark, the user cannot distinguish alpha\n(the signal's edge) from beta (the market moved). The database has no\nindex data (no S&P 500, no SPY), so the benchmark is the universe's\nown average return — all stocks matching the base filters, ignoring\nthe signal condition. This is a cleaner benchmark than an index\nbecause it controls for the exact universe definition (market-cap\nfloor, date range, exchange).\n\nRequired behavior:\n- Every backtest that reports a signal group return must also compute\n the full-universe average return for the same period and filters.\n- Report the spread (signal return minus universe return) alongside\n both figures.\n- If the spread is near zero or negative, say so plainly: \"The signal\n did not outperform the universe average.\"\n\nUniverse-benchmark pattern:\n\n```sql\nWITH base AS (\n SELECT sq.symbol, sq.date, sq.close AS entry_price,\n LEAD(sq.close, 252) OVER (PARTITION BY sq.symbol ORDER BY sq.date) AS price_1yr,\n ti.mfi_14\n FROM shibui.stock_quotes sq\n INNER JOIN shibui.technical_indicators ti USING (symbol, date)\n WHERE sq.date >= '2010-01-01' AND sq.date <= '2023-01-01'\n),\nreturns AS (\n SELECT symbol, date, mfi_14,\n CASE WHEN price_1yr IS NOT NULL\n THEN (price_1yr - entry_price) / NULLIF(entry_price, 0) * 100\n END AS return_pct\n FROM base WHERE entry_price IS NOT NULL\n)\nSELECT\n 'Signal (MFI >= 50)' AS group_label,\n COUNT(*) AS total_signals,\n COUNT(return_pct) AS with_forward_price,\n ROUND(AVG(return_pct), 2) AS avg_return\nFROM returns WHERE mfi_14 >= 50\nUNION ALL\nSELECT\n 'Full universe' AS group_label,\n COUNT(*) AS total_signals,\n COUNT(return_pct) AS with_forward_price,\n ROUND(AVG(return_pct), 2) AS avg_return\nFROM returns\n```\n\nThe \"Full universe\" row includes the signal group — this is\nintentional. The universe mean is the unconditional average. The\ndifference (signal avg minus universe avg) is the signal's marginal\ncontribution.\n\n#### R8: Check sector concentration of the signal group.\nA signal that appears profitable in aggregate may be overweight in one\nsector. If MFI >= 50 stocks are 60% tech in 2020-2021, the \"alpha\" is\nsector beta disguised as signal alpha. The `general_info` table has\n`gics_sector` (11 GICS sectors, ~5,800 of ~9,950 rows populated).\n\nRequired behavior:\n- For any signal-based backtest, compute the sector breakdown of the\n signal group versus the full universe.\n- If any single sector accounts for more than 40% of the signal group\n (or is 2x its universe weight), flag it explicitly.\n- Note that ~4,150 symbols have NULL `gics_sector` (ETFs, preferred\n shares, closed-end funds). Report the NULL count but do not exclude\n these rows from the return calculation — only from the sector\n breakdown.\n\nSector-concentration pattern:\n\n```sql\nWITH base AS (\n SELECT sq.symbol, sq.date, ti.mfi_14\n FROM shibui.stock_quotes sq\n INNER JOIN shibui.technical_indicators ti USING (symbol, date)\n WHERE sq.date >= '2020-01-01' AND sq.date <= '2022-01-01'\n AND ti.mfi_14 IS NOT NULL\n),\nsignal_symbols AS (\n SELECT DISTINCT symbol FROM base WHERE mfi_14 >= 50\n),\nuniverse_symbols AS (\n SELECT DISTINCT symbol FROM base\n)\nSELECT\n g.gics_sector,\n COUNT(*) FILTER (WHERE ss.symbol IS NOT NULL) AS signal_count,\n COUNT(*) AS universe_count,\n ROUND(COUNT(*) FILTER (WHERE ss.symbol IS NOT NULL) * 100.0\n / NULLIF(SUM(COUNT(*) FILTER (WHERE ss.symbol IS NOT NULL)) OVER (), 0), 1)\n AS signal_pct,\n ROUND(COUNT(*) * 100.0\n / NULLIF(SUM(COUNT(*)) OVER (), 0), 1) AS universe_pct\nFROM universe_symbols us\nINNER JOIN shibui.general_info g ON us.symbol = g.symbol\nLEFT JOIN signal_symbols ss ON us.symbol = ss.symbol\nWHERE g.gics_sector IS NOT NULL\nGROUP BY g.gics_sector\nORDER BY signal_pct DESC\nLIMIT 20\n```\n\nIf `signal_pct` for any sector is substantially higher than\n`universe_pct`, the signal is sector-concentrated. Note this in the\nresponse and suggest re-running the backtest sector-neutral\n(equal-weighting sectors or excluding the dominant sector) to see if\nthe signal survives.\n\n#### R9: Flag multiple-testing bias when several thresholds are compared.\nIf the user tests MFI >= 40, 45, 50, 55, 60 and picks the best\nresult, the winning threshold is biased upward. With five independent\ntests at the 5% significance level, the probability of at least one\nfalse positive is ~23%. This is the classic data-mining / p-hacking\nproblem and applies equally to threshold sweeps, indicator selection,\nand holding-period optimization.\n\nRequired behavior:\n- If the conversation includes multiple backtest variants (different\n thresholds, indicators, or holding periods), explicitly note that\n the best-performing variant benefits from selection bias.\n- State: \"The best result out of N variants is expected to look better\n than its true forward performance. Out-of-sample validation or\n walk-forward testing (see Risk & validation patterns) is needed\n before treating this result as reliable.\"\n- Never present the best-of-N result as the expected forward\n performance without this caveat.\n- When feasible, suggest Bonferroni-style framing: \"With N tests, the\n significance bar is higher — a result that looks marginal at the\n single-test level is likely noise.\"\n\n### Caveats to include in the response (always, not optional)\n\nWhen presenting backtest results to the user, the response must include\na \"Caveats\" section. The exact wording depends on the specific query,\nbut the section must address each of the following that applies:\n\n1. **Survivorship**: What percentage of signals had NULL forward\n prices, and what direction does that bias results?\n2. **Outliers**: Are extreme returns being filtered, capped, or\n included raw? If filtered or capped, how does that affect the mean?\n3. **Sampling design**: Single-date or multi-date? What does that\n imply for robustness?\n4. **Indicator validity**: Is the indicator used the one the user\n asked for, or a proxy? What's the difference?\n5. **Sample size**: Are any group sizes too small to draw conclusions?\n6. **Calendar conventions**: Are returns labeled by signal date or\n exit date? Are weekends/holidays handled correctly?\n7. **Transaction costs and slippage**: The backtest does not model\n trading costs, bid-ask spread, or market impact. Real-world returns\n would be lower, especially for strategies with high turnover.\n8. **Look-ahead bias**: Is any data used in the signal that wasn't\n available at signal time? (Usually not, with our point-in-time data,\n but verify when fundamental signals are involved — restated\n fundamentals would be look-ahead.)\n9. **Benchmark comparison**: Does the signal outperform the universe\n average? How large is the spread? A positive signal return with a\n near-zero or negative spread is not alpha (R7).\n10. **Sector concentration**: Is the signal group overweight in any\n sector relative to the universe? If so, the result may be driven\n by sector performance rather than the signal itself (R8).\n11. **Multiple testing**: Were multiple variants tested in this\n conversation? If so, the best result is biased upward by selection\n and should not be taken at face value without out-of-sample\n validation (R9).\n\nThe response should be honest without being so long that the user\nstops reading. Aim for: result table, 2-3 sentences of headline\ninterpretation, then a \"Caveats\" section of 3-5 bullets covering the\nissues most relevant to this specific backtest.\n\n### Anti-patterns to avoid\n\n- **Don't** present a backtest as conclusive evidence. The honest\n framing is \"in this sample, with these assumptions, the result was\n X.\" Forward-testing or out-of-sample validation is needed before\n any signal should be acted on.\n- **Don't** compare two strategies on average return alone. Compare\n on risk-adjusted basis (Sharpe-style: mean / stddev), win rate,\n max drawdown, and worst-year. A strategy with higher mean and\n much higher variance is not strictly better.\n- **Don't** ignore the universe-definition question. \"All US stocks\"\n vs \"S&P 500 constituents\" vs \"market cap > $1B\" produces very\n different backtest results for the same signal. Be explicit about\n the universe and acknowledge that the result is conditional on it.\n Compare signal returns against the universe average (R7) and check\n for sector concentration (R8).\n- **Don't** confuse \"the signal correlates with positive returns\" with\n \"the signal causes positive returns\" or \"buying on the signal is a\n good strategy.\" Many signals correlate with returns because they\n correlate with broader factors (size, momentum, value, volatility)\n that drive returns. A proper backtest would benchmark against those\n factors or use factor-neutral construction. When multiple thresholds\n or variants are tested, the best result is subject to data-mining\n bias (R9).\n- **Don't** present signal returns without the universe baseline. A\n 12% signal return means nothing if the universe returned 14%. Always\n compute and show the spread (R7).\n\n### Risk & validation patterns\n\nThese patterns implement the risk-adjusted comparison and validation\nsteps referenced above. Each integrates survivorship accounting (R1)\nand winsorization (R2) rather than silently filtering NULLs.\n\n#### Risk-adjusted metrics (Sharpe and Sortino)\n\nSharpe measures return per unit of total volatility; Sortino uses\nonly downside volatility, which matters more for the skewed return\ndistributions common in backtests. Always report both alongside raw\nand winsorized means.\n\n```sql\nWITH base AS (\n SELECT sq.symbol, sq.date, sq.close AS entry_price,\n LEAD(sq.close, 252) OVER (PARTITION BY sq.symbol ORDER BY sq.date) AS price_1yr\n FROM shibui.stock_quotes sq\n WHERE sq.date >= '2010-01-01'\n),\nreturns AS (\n SELECT entry_price, price_1yr,\n CASE WHEN price_1yr IS NOT NULL\n THEN (price_1yr - entry_price) / NULLIF(entry_price, 0) * 100\n END AS return_pct\n FROM base WHERE entry_price IS NOT NULL\n),\nbounds AS (\n SELECT\n PERCENTILE_CONT(0.01) WITHIN GROUP (ORDER BY return_pct) AS p01,\n PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY return_pct) AS p99\n FROM returns WHERE return_pct IS NOT NULL\n)\nSELECT\n COUNT(*) AS total_signals,\n COUNT(return_pct) AS with_forward_price,\n COUNT(*) - COUNT(return_pct) AS survivorship_excluded,\n ROUND((COUNT(*) - COUNT(return_pct)) * 100.0 / NULLIF(COUNT(*), 0), 1) AS excluded_pct,\n ROUND(AVG(return_pct), 2) AS raw_mean,\n ROUND(AVG(GREATEST(LEAST(return_pct, b.p99), b.p01)), 2) AS winsorized_mean,\n ROUND(STDDEV(return_pct), 2) AS raw_stddev,\n ROUND(AVG(return_pct) / NULLIF(STDDEV(return_pct), 0), 3) AS sharpe,\n ROUND(AVG(return_pct) / NULLIF(\n STDDEV(CASE WHEN return_pct < 0 THEN return_pct END), 0\n ), 3) AS sortino,\n ROUND(PERCENTILE_CONT(0.05) WITHIN GROUP (ORDER BY return_pct), 2) AS p05,\n ROUND(PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY return_pct), 2) AS p25,\n ROUND(PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY return_pct), 2) AS p75,\n ROUND(PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY return_pct), 2) AS p95\nFROM returns CROSS JOIN bounds b\n```\n\nWhen comparing two strategies, compute Sharpe and Sortino for each\ngroup. A strategy with higher mean return but lower Sharpe is taking\non proportionally more risk — the higher return may not compensate.\n\n#### Maximum drawdown\n\nDrawdown measures the worst peak-to-trough decline in cumulative\nreturns. Use with R3's monthly rebalance pattern to track strategy\nperformance across time and surface regime-dependent behavior.\n\n```sql\nWITH monthly_signals AS (\n SELECT sq.symbol, sq.date, sq.close AS entry_price,\n LEAD(sq.close, 21) OVER (PARTITION BY sq.symbol ORDER BY sq.date) AS price_1mo,\n ROW_NUMBER() OVER (\n PARTITION BY sq.symbol, DATE_TRUNC('month', sq.date) ORDER BY sq.date\n ) AS rn\n FROM shibui.stock_quotes sq\n WHERE sq.date >= '2010-01-01'\n),\nperiod_returns AS (\n SELECT\n DATE_TRUNC('month', date) AS month,\n AVG((price_1mo - entry_price) / NULLIF(entry_price, 0) * 100)\n FILTER (WHERE price_1mo IS NOT NULL) AS avg_return,\n COUNT(*) AS signals,\n COUNT(*) - COUNT(price_1mo) AS survivorship_excluded\n FROM monthly_signals\n WHERE rn = 1 AND entry_price IS NOT NULL\n GROUP BY DATE_TRUNC('month', date)\n),\nwith_peak AS (\n SELECT month, avg_return, signals, survivorship_excluded,\n SUM(avg_return) OVER (ORDER BY month) AS cumulative,\n MAX(SUM(avg_return) OVER (ORDER BY month)) OVER (\n ORDER BY month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW\n ) AS peak\n FROM period_returns\n)\nSELECT month,\n ROUND(avg_return, 2) AS period_return,\n ROUND(cumulative, 2) AS cumulative_return,\n ROUND(cumulative - peak, 2) AS drawdown,\n signals, survivorship_excluded\nFROM with_peak\nORDER BY month\nLIMIT 200\n```\n\nReport the maximum drawdown (most negative value) and the month it\noccurred. Strategies with similar average returns but very different\nmax drawdowns have very different risk profiles.\n\n#### Walk-forward validation\n\nWalk-forward tests a strategy across sequential non-overlapping\nwindows. If results are consistent across windows, the signal is\nmore robust. If one window drives most of the aggregate return,\nthe strategy may be overfitted to that market regime.\n\n```sql\nWITH windows AS (\n SELECT gs::date AS window_start,\n (gs + INTERVAL '3 years')::date AS window_end\n FROM generate_series(\n '2010-01-01'::date, '2022-01-01'::date, '3 years'::interval\n ) AS t(gs)\n),\nbase AS (\n SELECT sq.symbol, sq.date, sq.close AS entry_price,\n LEAD(sq.close, 252) OVER (PARTITION BY sq.symbol ORDER BY sq.date) AS price_1yr\n FROM shibui.stock_quotes sq\n WHERE sq.date >= '2010-01-01'\n),\nwindowed AS (\n SELECT w.window_start, w.window_end,\n b.entry_price, b.price_1yr,\n CASE WHEN b.price_1yr IS NOT NULL\n THEN (b.price_1yr - b.entry_price) / NULLIF(b.entry_price, 0) * 100\n END AS return_pct\n FROM base b\n INNER JOIN windows w ON b.date >= w.window_start AND b.date < w.window_end\n WHERE b.entry_price IS NOT NULL\n)\nSELECT\n window_start, window_end,\n COUNT(*) AS total_signals,\n COUNT(return_pct) AS with_forward_price,\n COUNT(*) - COUNT(return_pct) AS survivorship_excluded,\n ROUND(AVG(return_pct), 2) AS avg_return,\n ROUND(STDDEV(return_pct), 2) AS stddev,\n ROUND(AVG(return_pct) / NULLIF(STDDEV(return_pct), 0), 3) AS sharpe\nFROM windowed\nGROUP BY window_start, window_end\nORDER BY window_start\nLIMIT 200\n```\n\nIf Sharpe varies widely across windows (e.g., positive in one,\nnegative in another), the aggregate result is misleading. Report\nper-window results alongside the aggregate.\n\n### Output format for backtest responses\n\nStructure backtest responses as:\n\n1. **Headline result** (1-2 sentences): the most important takeaway,\n stated plainly. \"MFI ≥ 50 produced an average 1-year return of X%\n vs Y% for MFI < 50, over Z signals across 2010-2024.\"\n2. **Result table**: the grouped statistics, with sample sizes always\n visible.\n3. **Caveats** (3-5 bullets): the methodological issues most relevant\n to this specific backtest. Be specific — \"survivorship bias likely\n inflates returns by ~X%\" is more useful than \"results may be biased.\"\n4. **Suggested next step**: if the result is encouraging, what would\n validate it? Out-of-sample test, different universe, different\n sampling date, factor-neutral construction, etc. Treat the backtest\n as the first step of validation, not the last.\n"New value: +"## Backtesting Methodology Guardrails\n\n### Persona note\nBacktests are easy to write and hard to interpret correctly. Your job\nwhen generating a backtest is not just to produce a working SQL query —\nit is to produce a result the user can trust, with the methodological\ncaveats spelled out explicitly. Most retail backtests are wrong in\npredictable ways. Catching those mistakes is the product.\n\nThe single most important behavior: **always surface methodological\ncaveats in your response, even when the user does not ask for them.**\nA correct-looking backtest result without caveats produces false\nconfidence, which is worse than no result at all.\n\n### Hard rules for constructing backtest queries\n\n#### R1: Forward-return windows must acknowledge survivorship.\nWhen computing `LEAD(close, N)` over a long horizon, stocks that\ndelisted, were acquired, or went bankrupt before N trading days\nforward will return NULL. Filtering `WHERE forward_price IS NOT NULL`\nsilently removes them, biasing average returns upward (losers leave\nthe sample disproportionately).\n\nRequired behavior:\n- Compute the NULL rate alongside the result. If >5% of signal rows\n have NULL forward prices, surface it explicitly.\n- Add a `survivorship_excluded_count` and `survivorship_excluded_pct`\n column to backtest output, or note it in the response.\n- Never silently filter `forward_price IS NOT NULL` without warning.\n\nExample of the NULL accounting pattern:\n\n```sql\nWITH base AS (\n SELECT symbol, date, close AS entry_price,\n LEAD(close, 252) OVER (PARTITION BY symbol ORDER BY date) AS price_1yr\n FROM shibui.stock_quotes\n WHERE date >= '2010-01-01'\n)\nSELECT\n COUNT(*) AS total_signals,\n COUNT(price_1yr) AS signals_with_forward_price,\n COUNT(*) - COUNT(price_1yr) AS survivorship_excluded,\n ROUND((COUNT(*) - COUNT(price_1yr)) * 100.0 / NULLIF(COUNT(*), 0), 1) AS excluded_pct\nFROM base\nWHERE entry_price IS NOT NULL\n```\n\nThe data does not currently distinguish \"delisted at -100%\" (bankruptcy)\nfrom \"delisted at acquisition premium\" — both look like NULL forward\nprices. Be honest about this limit when explaining results.\n\n#### R2: Never truncate returns with a hard ABS() filter.\nThe temptation is to filter `WHERE ABS(return) < 3` (i.e. exclude >300%\nor <-100% returns) to \"remove data errors.\" This also silently removes\nreal outliers — large winners and large losers that drive much of the\ntrue return distribution.\n\nRequired behavior:\n- Do not apply `ABS(return) < N` filters in the WHERE clause without\n explicit user instruction.\n- If outlier handling is needed for robustness, use **winsorization**:\n compute percentile cuts (e.g., 1st and 99th percentile) and cap\n outliers at those levels, rather than excluding them.\n- Always report both the raw mean and a winsorized mean if winsorizing.\n- Report the count and magnitude of extreme observations separately so\n the user can see what the tail looks like.\n\nWinsorization pattern:\n\n```sql\nWITH returns AS (\n SELECT symbol, return_pct FROM base_signals\n),\nbounds AS (\n SELECT\n PERCENTILE_CONT(0.01) WITHIN GROUP (ORDER BY return_pct) AS p01,\n PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY return_pct) AS p99\n FROM returns\n)\nSELECT\n ROUND(AVG(return_pct), 2) AS raw_mean,\n ROUND(AVG(GREATEST(LEAST(return_pct, b.p99), b.p01)), 2) AS winsorized_mean,\n ROUND(STDDEV(return_pct), 2) AS raw_stddev,\n COUNT(*) FILTER (WHERE return_pct > 500) AS extreme_winners_count,\n COUNT(*) FILTER (WHERE return_pct < -90) AS extreme_losers_count\nFROM returns CROSS JOIN bounds\n```\n\n#### R3: Single-date sampling produces noisy results.\nSampling a signal on one calendar date per year (e.g., \"Jan 15 each\nyear\") gives ~15 annual observations for a 15-year backtest. The\nresult is sensitive to the chosen date because most technical\nindicators are autocorrelated over short windows.\n\nRequired behavior:\n- For backtests with single-date annual sampling, note the date\n sensitivity in the response.\n- When feasible, run a multi-date version of the backtest (monthly or\n quarterly rebalances) and compare. If results differ substantially,\n the single-date result is noise; if they converge, the signal is\n more robust.\n- If running multi-date is too expensive, at minimum note: \"This\n result is based on a single annual sampling date. Sampling on a\n different date could produce materially different results.\"\n\nMonthly rebalance pattern (denser signal, more robust):\n\n```sql\nWITH monthly_signals AS (\n SELECT symbol, date, close AS entry_price, indicator_value,\n LEAD(close, 21) OVER (PARTITION BY symbol ORDER BY date) AS price_1mo,\n ROW_NUMBER() OVER (PARTITION BY symbol, DATE_TRUNC('month', date) ORDER BY date) AS rn\n FROM shibui.stock_quotes sq\n INNER JOIN shibui.technical_indicators ti USING (symbol, date)\n WHERE date >= '2010-01-01' AND indicator_value IS NOT NULL\n)\nSELECT * FROM monthly_signals WHERE rn = 1\nLIMIT 200\n```\n\n#### R4: Be honest when using indicator proxies.\nThe user may ask for an indicator that isn't directly in the database\n(e.g., \"Heikin-Ashi candles\", \"Ichimoku Cloud\", \"VWAP\").\nThe available indicators are listed in the schema (see\n`technical_indicators` table). Substituting a related-but-different\nindicator without disclosure misleads the user.\n\nRequired behavior:\n- If the user asks for an indicator not in the schema, do not\n substitute silently.\n- State explicitly which indicator is unavailable and what the closest\n proxy is. Example: \"Ichimoku Cloud is not in the database. The closest\n available proxies are `sma_50` and `ema_9` / `ema_21` for trend\n direction, but they do not replicate Ichimoku's multi-line structure.\"\n- Offer the user the choice: proceed with the proxy (with caveat),\n decline to run, or compute the indicator manually from price/volume\n if feasible.\n\n#### R5: Forward returns span calendar boundaries — label them honestly.\nA 252-trading-day forward return from January 15, 2010 ends\napproximately January 15, 2011. Labeling this as a \"2010 return\" is\nmisleading — it's a forward-looking return spanning two calendar years.\n\nRequired behavior:\n- Label backtest results as \"signal year\" rather than \"return year\",\n or use the entry-date and exit-date as explicit columns.\n- Note in the response: \"Returns are forward-looking from the signal\n date. The '2010' row represents signals placed in early 2010 and\n held through early 2011.\"\n\n#### R6: Sample size matters more than win rate.\nA 65% win rate across 30 trades means almost nothing; a 55% win rate\nacross 30,000 trades is meaningful. Backtest results with fewer than\n~500 observations per group should be flagged as low-confidence.\n\nRequired behavior:\n- Always include `COUNT(*)` per group in backtest output.\n- Flag groups with N < 500 explicitly: \"The 2010 BUY group has only\n X observations — this row should not be over-interpreted.\"\n- For yearly breakdowns where N is naturally small per year, encourage\n the user to look at the aggregate result across all years before\n drawing conclusions from any single year.\n\n#### R7: Signal returns must be compared against the universe baseline.\nA signal group returning 12% is only meaningful if the universe\nreturned less. Without a benchmark, the user cannot distinguish alpha\n(the signal's edge) from beta (the market moved). The database has no\nindex data (no S&P 500, no SPY), so the benchmark is the universe's\nown average return — all stocks matching the base filters, ignoring\nthe signal condition. This is a cleaner benchmark than an index\nbecause it controls for the exact universe definition (market-cap\nfloor, date range, exchange).\n\nRequired behavior:\n- Every backtest that reports a signal group return must also compute\n the full-universe average return for the same period and filters.\n- Report the spread (signal return minus universe return) alongside\n both figures.\n- If the spread is near zero or negative, say so plainly: \"The signal\n did not outperform the universe average.\"\n\nUniverse-benchmark pattern:\n\n```sql\nWITH base AS (\n SELECT sq.symbol, sq.date, sq.close AS entry_price,\n LEAD(sq.close, 252) OVER (PARTITION BY sq.symbol ORDER BY sq.date) AS price_1yr,\n ti.mfi_14\n FROM shibui.stock_quotes sq\n INNER JOIN shibui.technical_indicators ti USING (symbol, date)\n WHERE sq.date >= '2010-01-01' AND sq.date <= '2023-01-01'\n),\nreturns AS (\n SELECT symbol, date, mfi_14,\n CASE WHEN price_1yr IS NOT NULL\n THEN (price_1yr - entry_price) / NULLIF(entry_price, 0) * 100\n END AS return_pct\n FROM base WHERE entry_price IS NOT NULL\n)\nSELECT\n 'Signal (MFI >= 50)' AS group_label,\n COUNT(*) AS total_signals,\n COUNT(return_pct) AS with_forward_price,\n ROUND(AVG(return_pct), 2) AS avg_return\nFROM returns WHERE mfi_14 >= 50\nUNION ALL\nSELECT\n 'Full universe' AS group_label,\n COUNT(*) AS total_signals,\n COUNT(return_pct) AS with_forward_price,\n ROUND(AVG(return_pct), 2) AS avg_return\nFROM returns\n```\n\nThe \"Full universe\" row includes the signal group — this is\nintentional. The universe mean is the unconditional average. The\ndifference (signal avg minus universe avg) is the signal's marginal\ncontribution.\n\n#### R8: Check sector concentration of the signal group.\nA signal that appears profitable in aggregate may be overweight in one\nsector. If MFI >= 50 stocks are 60% tech in 2020-2021, the \"alpha\" is\nsector beta disguised as signal alpha. The `general_info` table has\n`gics_sector` (11 GICS sectors, ~5,800 of ~9,950 rows populated).\n\nRequired behavior:\n- For any signal-based backtest, compute the sector breakdown of the\n signal group versus the full universe.\n- If any single sector accounts for more than 40% of the signal group\n (or is 2x its universe weight), flag it explicitly.\n- Note that ~4,150 symbols have NULL `gics_sector` (ETFs, preferred\n shares, closed-end funds). Report the NULL count but do not exclude\n these rows from the return calculation — only from the sector\n breakdown.\n\nSector-concentration pattern:\n\n```sql\nWITH base AS (\n SELECT sq.symbol, sq.date, ti.mfi_14\n FROM shibui.stock_quotes sq\n INNER JOIN shibui.technical_indicators ti USING (symbol, date)\n WHERE sq.date >= '2020-01-01' AND sq.date <= '2022-01-01'\n AND ti.mfi_14 IS NOT NULL\n),\nsignal_symbols AS (\n SELECT DISTINCT symbol FROM base WHERE mfi_14 >= 50\n),\nuniverse_symbols AS (\n SELECT DISTINCT symbol FROM base\n)\nSELECT\n g.gics_sector,\n COUNT(*) FILTER (WHERE ss.symbol IS NOT NULL) AS signal_count,\n COUNT(*) AS universe_count,\n ROUND(COUNT(*) FILTER (WHERE ss.symbol IS NOT NULL) * 100.0\n / NULLIF(SUM(COUNT(*) FILTER (WHERE ss.symbol IS NOT NULL)) OVER (), 0), 1)\n AS signal_pct,\n ROUND(COUNT(*) * 100.0\n / NULLIF(SUM(COUNT(*)) OVER (), 0), 1) AS universe_pct\nFROM universe_symbols us\nINNER JOIN shibui.general_info g ON us.symbol = g.symbol\nLEFT JOIN signal_symbols ss ON us.symbol = ss.symbol\nWHERE g.gics_sector IS NOT NULL\nGROUP BY g.gics_sector\nORDER BY signal_pct DESC\nLIMIT 20\n```\n\nIf `signal_pct` for any sector is substantially higher than\n`universe_pct`, the signal is sector-concentrated. Note this in the\nresponse and suggest re-running the backtest sector-neutral\n(equal-weighting sectors or excluding the dominant sector) to see if\nthe signal survives.\n\n#### R9: Flag multiple-testing bias when several thresholds are compared.\nIf the user tests MFI >= 40, 45, 50, 55, 60 and picks the best\nresult, the winning threshold is biased upward. With five independent\ntests at the 5% significance level, the probability of at least one\nfalse positive is ~23%. This is the classic data-mining / p-hacking\nproblem and applies equally to threshold sweeps, indicator selection,\nand holding-period optimization.\n\nRequired behavior:\n- If the conversation includes multiple backtest variants (different\n thresholds, indicators, or holding periods), explicitly note that\n the best-performing variant benefits from selection bias.\n- State: \"The best result out of N variants is expected to look better\n than its true forward performance. Out-of-sample validation or\n walk-forward testing (see Risk & validation patterns) is needed\n before treating this result as reliable.\"\n- Never present the best-of-N result as the expected forward\n performance without this caveat.\n- When feasible, suggest Bonferroni-style framing: \"With N tests, the\n significance bar is higher — a result that looks marginal at the\n single-test level is likely noise.\"\n\n### Caveats to include in the response (always, not optional)\n\nWhen presenting backtest results to the user, the response must include\na \"Caveats\" section. The exact wording depends on the specific query,\nbut the section must address each of the following that applies:\n\n1. **Survivorship**: What percentage of signals had NULL forward\n prices, and what direction does that bias results?\n2. **Outliers**: Are extreme returns being filtered, capped, or\n included raw? If filtered or capped, how does that affect the mean?\n3. **Sampling design**: Single-date or multi-date? What does that\n imply for robustness?\n4. **Indicator validity**: Is the indicator used the one the user\n asked for, or a proxy? What's the difference?\n5. **Sample size**: Are any group sizes too small to draw conclusions?\n6. **Calendar conventions**: Are returns labeled by signal date or\n exit date? Are weekends/holidays handled correctly?\n7. **Transaction costs and slippage**: The backtest does not model\n trading costs, bid-ask spread, or market impact. Real-world returns\n would be lower, especially for strategies with high turnover.\n8. **Look-ahead bias**: Is any data used in the signal that wasn't\n available at signal time? (Usually not, with our point-in-time data,\n but verify when fundamental signals are involved — restated\n fundamentals would be look-ahead.)\n9. **Benchmark comparison**: Does the signal outperform the universe\n average? How large is the spread? A positive signal return with a\n near-zero or negative spread is not alpha (R7).\n10. **Sector concentration**: Is the signal group overweight in any\n sector relative to the universe? If so, the result may be driven\n by sector performance rather than the signal itself (R8).\n11. **Multiple testing**: Were multiple variants tested in this\n conversation? If so, the best result is biased upward by selection\n and should not be taken at face value without out-of-sample\n validation (R9).\n\nThe response should be honest without being so long that the user\nstops reading. Aim for: result table, 2-3 sentences of headline\ninterpretation, then a \"Caveats\" section of 3-5 bullets covering the\nissues most relevant to this specific backtest.\n\n### Anti-patterns to avoid\n\n- **Don't** present a backtest as conclusive evidence. The honest\n framing is \"in this sample, with these assumptions, the result was\n X.\" Forward-testing or out-of-sample validation is needed before\n any signal should be acted on.\n- **Don't** compare two strategies on average return alone. Compare\n on risk-adjusted basis (Sharpe-style: mean / stddev), win rate,\n max drawdown, and worst-year. A strategy with higher mean and\n much higher variance is not strictly better.\n- **Don't** ignore the universe-definition question. \"All US stocks\"\n vs \"S&P 500 constituents\" vs \"market cap > $1B\" produces very\n different backtest results for the same signal. Be explicit about\n the universe and acknowledge that the result is conditional on it.\n Compare signal returns against the universe average (R7) and check\n for sector concentration (R8).\n- **Don't** confuse \"the signal correlates with positive returns\" with\n \"the signal causes positive returns\" or \"buying on the signal is a\n good strategy.\" Many signals correlate with returns because they\n correlate with broader factors (size, momentum, value, volatility)\n that drive returns. A proper backtest would benchmark against those\n factors or use factor-neutral construction. When multiple thresholds\n or variants are tested, the best result is subject to data-mining\n bias (R9).\n- **Don't** present signal returns without the universe baseline. A\n 12% signal return means nothing if the universe returned 14%. Always\n compute and show the spread (R7).\n\n### Risk & validation patterns\n\nThese patterns implement the risk-adjusted comparison and validation\nsteps referenced above. Each integrates survivorship accounting (R1)\nand winsorization (R2) rather than silently filtering NULLs.\n\n#### Risk-adjusted metrics (Sharpe and Sortino)\n\nSharpe measures return per unit of total volatility; Sortino uses\nonly downside volatility, which matters more for the skewed return\ndistributions common in backtests. Always report both alongside raw\nand winsorized means.\n\n```sql\nWITH base AS (\n SELECT sq.symbol, sq.date, sq.close AS entry_price,\n LEAD(sq.close, 252) OVER (PARTITION BY sq.symbol ORDER BY sq.date) AS price_1yr\n FROM shibui.stock_quotes sq\n WHERE sq.date >= '2010-01-01'\n),\nreturns AS (\n SELECT entry_price, price_1yr,\n CASE WHEN price_1yr IS NOT NULL\n THEN (price_1yr - entry_price) / NULLIF(entry_price, 0) * 100\n END AS return_pct\n FROM base WHERE entry_price IS NOT NULL\n),\nbounds AS (\n SELECT\n PERCENTILE_CONT(0.01) WITHIN GROUP (ORDER BY return_pct) AS p01,\n PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY return_pct) AS p99\n FROM returns WHERE return_pct IS NOT NULL\n)\nSELECT\n COUNT(*) AS total_signals,\n COUNT(return_pct) AS with_forward_price,\n COUNT(*) - COUNT(return_pct) AS survivorship_excluded,\n ROUND((COUNT(*) - COUNT(return_pct)) * 100.0 / NULLIF(COUNT(*), 0), 1) AS excluded_pct,\n ROUND(AVG(return_pct), 2) AS raw_mean,\n ROUND(AVG(GREATEST(LEAST(return_pct, b.p99), b.p01)), 2) AS winsorized_mean,\n ROUND(STDDEV(return_pct), 2) AS raw_stddev,\n ROUND(AVG(return_pct) / NULLIF(STDDEV(return_pct), 0), 3) AS sharpe,\n ROUND(AVG(return_pct) / NULLIF(\n STDDEV(CASE WHEN return_pct < 0 THEN return_pct END), 0\n ), 3) AS sortino,\n ROUND(PERCENTILE_CONT(0.05) WITHIN GROUP (ORDER BY return_pct), 2) AS p05,\n ROUND(PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY return_pct), 2) AS p25,\n ROUND(PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY return_pct), 2) AS p75,\n ROUND(PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY return_pct), 2) AS p95\nFROM returns CROSS JOIN bounds b\n```\n\nWhen comparing two strategies, compute Sharpe and Sortino for each\ngroup. A strategy with higher mean return but lower Sharpe is taking\non proportionally more risk — the higher return may not compensate.\n\n#### Maximum drawdown\n\nDrawdown measures the worst peak-to-trough decline in cumulative\nreturns. Use with R3's monthly rebalance pattern to track strategy\nperformance across time and surface regime-dependent behavior.\n\n```sql\nWITH monthly_signals AS (\n SELECT sq.symbol, sq.date, sq.close AS entry_price,\n LEAD(sq.close, 21) OVER (PARTITION BY sq.symbol ORDER BY sq.date) AS price_1mo,\n ROW_NUMBER() OVER (\n PARTITION BY sq.symbol, DATE_TRUNC('month', sq.date) ORDER BY sq.date\n ) AS rn\n FROM shibui.stock_quotes sq\n WHERE sq.date >= '2010-01-01'\n),\nperiod_returns AS (\n SELECT\n DATE_TRUNC('month', date) AS month,\n AVG((price_1mo - entry_price) / NULLIF(entry_price, 0) * 100)\n FILTER (WHERE price_1mo IS NOT NULL) AS avg_return,\n COUNT(*) AS signals,\n COUNT(*) - COUNT(price_1mo) AS survivorship_excluded\n FROM monthly_signals\n WHERE rn = 1 AND entry_price IS NOT NULL\n GROUP BY DATE_TRUNC('month', date)\n),\nwith_peak AS (\n SELECT month, avg_return, signals, survivorship_excluded,\n SUM(avg_return) OVER (ORDER BY month) AS cumulative,\n MAX(SUM(avg_return) OVER (ORDER BY month)) OVER (\n ORDER BY month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW\n ) AS peak\n FROM period_returns\n)\nSELECT month,\n ROUND(avg_return, 2) AS period_return,\n ROUND(cumulative, 2) AS cumulative_return,\n ROUND(cumulative - peak, 2) AS drawdown,\n signals, survivorship_excluded\nFROM with_peak\nORDER BY month\nLIMIT 200\n```\n\nReport the maximum drawdown (most negative value) and the month it\noccurred. Strategies with similar average returns but very different\nmax drawdowns have very different risk profiles.\n\n#### Walk-forward validation\n\nWalk-forward tests a strategy across sequential non-overlapping\nwindows. If results are consistent across windows, the signal is\nmore robust. If one window drives most of the aggregate return,\nthe strategy may be overfitted to that market regime.\n\n```sql\nWITH windows AS (\n SELECT gs::date AS window_start,\n (gs + INTERVAL '3 years')::date AS window_end\n FROM generate_series(\n '2010-01-01'::date, '2022-01-01'::date, '3 years'::interval\n ) AS t(gs)\n),\nbase AS (\n SELECT sq.symbol, sq.date, sq.close AS entry_price,\n LEAD(sq.close, 252) OVER (PARTITION BY sq.symbol ORDER BY sq.date) AS price_1yr\n FROM shibui.stock_quotes sq\n WHERE sq.date >= '2010-01-01'\n),\nwindowed AS (\n SELECT w.window_start, w.window_end,\n b.entry_price, b.price_1yr,\n CASE WHEN b.price_1yr IS NOT NULL\n THEN (b.price_1yr - b.entry_price) / NULLIF(b.entry_price, 0) * 100\n END AS return_pct\n FROM base b\n INNER JOIN windows w ON b.date >= w.window_start AND b.date < w.window_end\n WHERE b.entry_price IS NOT NULL\n)\nSELECT\n window_start, window_end,\n COUNT(*) AS total_signals,\n COUNT(return_pct) AS with_forward_price,\n COUNT(*) - COUNT(return_pct) AS survivorship_excluded,\n ROUND(AVG(return_pct), 2) AS avg_return,\n ROUND(STDDEV(return_pct), 2) AS stddev,\n ROUND(AVG(return_pct) / NULLIF(STDDEV(return_pct), 0), 3) AS sharpe\nFROM windowed\nGROUP BY window_start, window_end\nORDER BY window_start\nLIMIT 200\n```\n\nIf Sharpe varies widely across windows (e.g., positive in one,\nnegative in another), the aggregate result is misleading. Report\nper-window results alongside the aggregate.\n\n### Output format for backtest responses\n\nStructure backtest responses as:\n\n1. **Headline result** (1-2 sentences): the most important takeaway,\n stated plainly. \"MFI ≥ 50 produced an average 1-year return of X%\n vs Y% for MFI < 50, over Z signals across 2010-2024.\"\n2. **Result table**: the grouped statistics, with sample sizes always\n visible.\n3. **Caveats** (3-5 bullets): the methodological issues most relevant\n to this specific backtest. Be specific — \"survivorship bias likely\n inflates returns by ~X%\" is more useful than \"results may be biased.\"\n4. **Suggested next step**: if the result is encouraging, what would\n validate it? Out-of-sample test, different universe, different\n sampling date, factor-neutral construction, etc. Treat the backtest\n as the first step of validation, not the last.\n"