{
 "title": "How to backtest a trading strategy with SQL \u2014 StockQL",
 "description": "Build and evaluate a 200-day moving-average backtest in PostgreSQL with StockQL, including runnable SQL, costs, drawdowns, and live results.",
 "url": "https://stockql.ai/blog/how-to-backtest-with-stockql.html",
 "slug": "blog/how-to-backtest-with-stockql",
 "published": "2026-09-11",
 "site": "StockQL",
 "contact": "hello@stockql.ai",
 "body_text": "Backtesting guide\n\nHow to backtest a trading strategy with StockQL\n\nA complete PostgreSQL walkthrough: define a 200-trading-day trend rule, delay the signal correctly, charge transaction costs, measure drawdown, and export the daily results.\n\nSeptember 11, 2026 \u00b7 12 minute read\n\nIn this guide\n\nDefine the strategy\n\nCheck the price history\n\nBuild the daily backtest\n\nMeasure the result\n\nExport and extend it\n\nStockQL is a market-data database, not a proprietary backtesting language. That is useful: the prices live in PostgreSQL, the strategy is ordinary SQL, and every assumption remains visible.\n\nThis guide builds a deliberately simple end-of-day strategy on SPY.US . It runs entirely inside PostgreSQL and creates only a session-local temporary view. You can inspect every daily return, send the result to Python or R, and replace the rule without moving the underlying history into another platform.\n\nReproducible, not predictive. This is an implementation example, not investment advice or evidence that a moving-average rule will perform similarly in the future.\n\n1. Define the strategy before reading the result\n\nThe test uses a 200-observation simple moving average as a trend filter:\n\nHold SPY when its adjusted close is above its 200-trading-day average.\n\nHold cash otherwise. Cash earns 0% in this example.\n\nUse today\u2019s close to set the next trading day\u2019s position. The one-row delay prevents look-ahead.\n\nCharge 5 basis points, or 0.05%, whenever the position changes.\n\nUse adjusted closes so splits and distributions do not appear as trading returns.\n\nUse no leverage, shorting, tax model, or slippage beyond the fixed transaction cost.\n\nThese assumptions are modest enough to audit and specific enough to reproduce. They are not a claim that this is the best trend rule. They are the contract the query must implement.\n\n2. Connect and check the history\n\nOpen psql with the PostgreSQL connection string from your StockQL installation:\n\npsql \"$DATABASE_URL\"\n\nBefore calculating a return, verify that the requested series exists and that its adjusted closes are usable:\n\nSELECT\nCOUNT(*) AS rows,\nMIN(date) AS first_date,\nMAX(date) AS last_date,\nCOUNT(*) FILTER (\nWHERE adjusted_close IS NULL OR adjusted_close <= 0\n) AS unusable_adjusted_closes\nFROM stock_prices\nWHERE exchange_code = 'US'\nAND symbol = 'SPY';\n\nOn the live StockQL snapshot used for this article, the query returned:\n\nRows First date Last date Unusable closes\n\n2,708 2015-12-02 2026-09-10 0\n\nYour last date will move forward as StockQL ingests new sessions. A missing or non-positive adjusted close is a reason to stop and inspect the series, not something to fill silently inside the backtest.\n\n3. Build the daily backtest\n\nThe following statement ran unchanged against the live database. It creates a temporary view, so it lasts only for the current PostgreSQL session.\n\nCREATE OR REPLACE TEMP VIEW spy_sma_backtest AS\nWITH features AS (\nSELECT\ndate,\nadjusted_close::double precision AS adjusted_close,\nAVG(adjusted_close::double precision) OVER (\nORDER BY date ROWS BETWEEN 199 PRECEDING AND CURRENT ROW\n) AS sma_200,\nCOUNT(*) OVER (\nORDER BY date ROWS BETWEEN 199 PRECEDING AND CURRENT ROW\n) AS observations\nFROM stock_prices\nWHERE exchange_code = 'US'\nAND symbol = 'SPY'\nAND adjusted_close > 0\n),\nsignals AS (\nSELECT\ndate,\nadjusted_close,\nsma_200,\nCASE WHEN adjusted_close > sma_200 THEN 1.0 ELSE 0.0 END AS signal\nFROM features\nWHERE observations = 200\n),\npositions AS (\nSELECT\n*,\nLAG(signal) OVER (ORDER BY date) AS position,\nLAG(adjusted_close) OVER (ORDER BY date) AS prior_close\nFROM signals\n),\nreturns AS (\nSELECT\n*,\nadjusted_close / prior_close - 1.0 AS buy_hold_return,\nABS(position - LAG(position, 1, 0.0) OVER (ORDER BY date)) AS turnover\nFROM positions\nWHERE position IS NOT NULL\n)\nSELECT\ndate,\nadjusted_close,\nsma_200,\nposition,\nbuy_hold_return,\nposition * buy_hold_return - 0.0005 * turnover AS strategy_return,\nturnover\nFROM returns;\n\nEach common table expression has one job:\n\nfeatures Calculates the moving average and counts actual trading observations. It does not confuse 200 rows with 200 calendar days.\nsignals Waits for a complete 200-observation window, then emits a long-or-cash decision.\npositions Shifts that decision by one row. A close can inform the following return; it cannot trade retroactively.\nreturns Calculates adjusted close-to-close returns and detects each entry or exit.\n\nThe final expression subtracts 0.0005 for every unit of turnover. Because the position is either 0 or 1, each entry and each exit costs 5 basis points.\n\n4. Measure return, volatility, and drawdown\n\nA final balance alone is not enough. This query compounds the daily series, annualizes its observed volatility, measures drawdown from the running peak, counts position changes, and keeps buy-and-hold as a control:\n\nWITH curve AS (\nSELECT\n*,\nEXP(SUM(LN(1.0 + strategy_return)) OVER (ORDER BY date)) AS strategy_equity,\nEXP(SUM(LN(1.0 + buy_hold_return)) OVER (ORDER BY date)) AS buy_hold_equity\nFROM spy_sma_backtest\n),\nwith_drawdown AS (\nSELECT\n*,\nstrategy_equity\n/ MAX(GREATEST(strategy_equity, 1.0)) OVER (ORDER BY date) - 1.0\nAS strategy_drawdown\nFROM curve\n),\nstats AS (\nSELECT\nMIN(date) AS start_date,\nMAX(date) AS end_date,\nCOUNT(*) AS trading_days,\nSTDDEV_SAMP(strategy_return) * SQRT(252.0) AS strategy_volatility,\nMIN(strategy_drawdown) AS strategy_max_drawdown,\nCOUNT(*) FILTER (WHERE turnover > 0) AS position_changes\nFROM with_drawdown\n),\nending AS (\nSELECT strategy_equity, buy_hold_equity\nFROM with_drawdown\nORDER BY date DESC\nLIMIT 1\n)\nSELECT\nstart_date,\nend_date,\ntrading_days,\nROUND(((strategy_equity - 1.0) * 100.0)::numeric, 2) AS strategy_total_return_pct,\nROUND(((POWER(strategy_equity, 365.25 / NULLIF(end_date - start_date, 0)) - 1.0) * 100.0)::numeric, 2) AS strategy_cagr_pct,\nROUND((strategy_volatility * 100.0)::numeric, 2) AS strategy_volatility_pct,\nROUND((strategy_max_drawdown * 100.0)::numeric, 2) AS strategy_max_drawdown_pct,\nposition_changes,\nROUND(((buy_hold_equity - 1.0) * 100.0)::numeric, 2) AS buy_hold_total_return_pct\nFROM stats CROSS JOIN ending;\n\nThe live run covered 2,508 test days after the moving-average warmup:\n\nStrategy total return 190.90%\n\nStrategy CAGR 11.30%\n\nAnnualized volatility 11.98%\n\nMaximum drawdown \u221220.68%\n\nPosition changes 53\n\nBuy-and-hold return 311.46%\n\nThe strategy did not beat buy-and-hold on total return. It spent time in cash and reduced observed volatility, but the foregone upside was substantial. That is a useful result: a backtest should reject attractive stories as readily as it supports them.\n\nOn the machine used for verification, the temporary-view statement completed in 15 ms, the summary in 289 ms, and the annual query below in 218 ms. Those timings describe one installation, not a general benchmark.\n\nInspect the result year by year\n\nSELECT\nEXTRACT(YEAR FROM date)::integer AS year,\nROUND(((EXP(SUM(LN(1.0 + strategy_return))) - 1.0) * 100.0)::numeric, 2) AS strategy_return_pct,\nROUND(((EXP(SUM(LN(1.0 + buy_hold_return))) - 1.0) * 100.0)::numeric, 2) AS buy_hold_return_pct,\nCOUNT(*) FILTER (WHERE turnover > 0) AS position_changes\nFROM spy_sma_backtest\nGROUP BY 1\nORDER BY 1;\n\nYear Strategy Buy and hold Position changes\n\n2016* 5.33% 5.38% 1\n\n2017 21.71% 21.71% 0\n\n2018 \u22121.15% \u22124.57% 9\n\n2019 16.33% 31.22% 5\n\n2020 9.56% 18.33% 6\n\n2021 28.73% 28.73% 0\n\n2022 \u221215.52% \u221218.18% 15\n\n2023 11.03% 25.14% 11\n\n2024 24.89% 24.89% 0\n\n2025 11.55% 17.72% 4\n\n2026* 7.08% 11.72% 2\n\n* 2016 begins on September 19 after the 200-observation warmup. 2026 ends on September 10, the snapshot date. The buy-and-hold control is shown without transaction costs.\n\n5. Pull the daily curve into your own tools\n\nThe temporary view remains inspectable for the rest of the psql session. Export it without rewriting the strategy in an application layer:\n\n\\copy (SELECT date, adjusted_close, sma_200, position, buy_hold_return, strategy_return, turnover FROM spy_sma_backtest ORDER BY date) TO 'spy-sma-backtest.csv' CSV HEADER\n\nThe CSV is ready for a notebook, chart, independent calculation, or review. Keeping the daily rows is important: aggregate metrics cannot reveal whether one bad price, one missing interval, or one unrealistic fill produced the result.\n\nChange one assumption at a time\n\nChange SPY and US to test another instrument and exchange.\n\nChange both window frames from 199 PRECEDING to 99 PRECEDING for a 100-observation average.\n\nChange 0.0005 to your own one-way cost estimate.\n\nAdd a cash return series rather than assuming idle capital earns zero.\n\nSplit the sample into development and holdout periods before comparing many rules.\n\nCross-sectional backtests need another guard. StockQL retains delisted histories, but a historical stock screen must still form its universe from facts known on each formation date. Filtering old prices with today\u2019s active status or current classification creates look-ahead. A single-instrument example avoids that membership problem; a multi-stock strategy must address it explicitly.\n\nWhat StockQL contributes\n\nThe SQL in this guide is intentionally ordinary. The hard part is the history beneath it: retained delistings, coherent adjusted prices, per-exchange calendars, repeat ingestion checks, and direct access to the rows when an output looks wrong.\n\nThat separation is the point. StockQL maintains the research database. PostgreSQL, Python, R, or your own agent can own the strategy.\n\nSee how StockQL handles the data \u2192"
}
