Skip to main content
Glama

Backtesting Analysis Workflow

load_backtesting_workflow
Read-onlyIdempotent

Backtesting and simulation guardrails: survivorship, drawdown, Sharpe, day-of-week. REQUIRES get_database_schema then get_query_patterns to be called first (in that order). Call BEFORE writing SQL when the user asks to backtest, simulate, validate a strategy, test "what happens after X", compare forward returns, measure win rates or hit rates, compute Sharpe, drawdown, profit factor, rotation strategies, basket returns, or any hypothetical return over past data. Contains hard rules for survivorship bias, outlier handling, sampling design, day-of-week filters, and risk-adjusted metrics (Sharpe, Sortino, drawdown). Can be combined with other workflow tools.

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault

No arguments

Output Schema

TableJSON Schema
NameRequiredDescriptionDefault
resultYes

Schema Changelog

Changes observed during successful MCP inspections. Dates show when Glama detected each change.

  1. Changed1 schema field changed
    • removedInput schema / properties / _content
      Removed value: -{
      -  "default": "## 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",
      -  "type": "string"
      -}
  2. Changed1 schema field changed
    • 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"
  3. Changed1 schema field changed
    • 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### 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.)\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- **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.\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., \"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"
  4. Added

TDQS

A4.4/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Beyond the read-only/idempotent annotations, the description reveals internal guardrails (survivorship bias, outlier handling, sampling design, day-of-week filters, risk-adjusted metrics) and the ordering prerequisite, which is useful behavioral context. No contradiction with annotations is present.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is compact and front-loaded, opening with a summary, then a requirement, then a list of triggering user requests. It is somewhat lengthy but every sentence carries meaningful information without fluff.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given that the tool has no input parameters and an output schema exists, the description fully covers purpose, triggers, prerequisites, and internal rules. It also notes combinability with other workflow tools, leaving no significant gaps.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

The tool has zero parameters, and schema coverage is 100% vacuously. With no parameters to document, the baseline score of 4 applies; the description does not need to add parameter details.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states that this tool loads a backtesting workflow with guardrails, and enumerates specific use cases (backtest, simulate, validate strategy, compute Sharpe/drawdown, etc.), distinguishing it from sibling workflow tools. The verb 'load' and resource 'backtesting workflow' are unambiguous.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description gives explicit when-to-use guidance: call before writing SQL for a long list of backtesting/simulation requests. It also states the prerequisite ordering (get_database_schema then get_query_patterns). However, it does not explicitly name alternative tools or provide when-not-to-use conditions, so it falls short of full marks.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Try in Browser

Glama MCP Gateway

Add one secure layer between your agents and this server.

TDQS

A4.4/5.0
Disambiguation4/5

Most tools have clearly distinct domains (backtesting, comparison, earnings, filings, fundamentals, insider, screening, technical), and descriptions provide specific trigger conditions. However, stock_data_query and export_to_excel are very similar (same query, different output), and some workflow boundaries overlap (e.g., earnings vs. fundamental both mention revenue trends; filing vs. insider both involve SEC documents).

Naming Consistency3/5

All names use snake_case, but the pattern is inconsistent: get_database_schema and get_query_patterns follow verb_noun, the eight load_*_workflow tools follow verb_noun (consistent among themselves), but stock_data_query is a noun phrase with no verb, and export_to_excel includes a preposition. The mixed conventions are still readable but not uniform.

Tool Count4/5

At 12 tools, the count is within the expected 3-15 range and appropriate for the broad scope of comprehensive stock analysis. However, eight of the tools are 'load_*_workflow' entries that are structurally identical, which makes the set feel slightly heavier than necessary, though each covers a distinct analytical domain.

Completeness5/5

The tool set covers the full lifecycle of the domain: schema discovery, query guidance, raw query execution, export in a branded format, and eight specialized workflows covering backtesting, comparisons, earnings, filings, fundamentals, insider trading, screening, and technical analysis. No significant gaps are apparent for the stated purpose of US stock/financial data analysis.

Resources