//@version=6 strategy( "QTraders.com SPY PD12-18", overlay = true, initial_capital = 100000, currency = currency.USD, pyramiding = 1, commission_type = strategy.commission.percent, commission_value = 0.015, slippage = 0, process_orders_on_close = true, calc_on_order_fills = false, calc_on_every_tick = false, margin_long = 100, margin_short = 100) // ============================================================================= // Inputs // ============================================================================= // Every default here mirrors config.yaml from the Python case // (spy_payday_effect/config.yaml): payday_window [12,18], selected_volatility_window 5, // target_daily_volatility 0.01, maximum_weight 1.0, volatility_warmup 63, // round_turn_cost 0.0003 (=> 1.5 bps per side). string groupRules = "Strategy rules" int startDay = input.int(12, "First calendar day", minval = 1, maxval = 31, group = groupRules, display=display.none) int endDay = input.int(18, "Last calendar day", minval = 1, maxval = 31, group = groupRules, display=display.none) int volLength = input.int(5, "Volatility lookback (selected window)", minval = 2, group = groupRules, display=display.none) float targetDailyVolPct = input.float(1.0, "Daily volatility target (%)", minval = 0.01, step = 0.05, group = groupRules, display=display.none) float maxWeightPct = input.float(100.0, "Maximum weight (%)", minval = 0.0, maxval = 100.0, step = 1.0, group = groupRules, display=display.none) int warmupLength = input.int(63, "Common warm-up length (bars)", minval = 1, group = groupRules, tooltip = "Matches config.yaml's volatility_warmup. The Python report only starts the tradeable sample once a 63-bar volatility estimate exists, so fixed vs. Vol(5)/21/63 are compared on the identical date range. Set to 1 to disable and trade as soon as Vol(volLength) itself is available.", display=display.none) string groupTest = "Backtest" int startDate = input.time(timestamp("01 Jan 2000 00:00 -0500"), "Start date", group = groupTest, display=display.none) int endDate = input.time(timestamp("31 Dec 2099 23:59 -0500"), "End date", group = groupTest, display=display.none) string groupBenchmark = "Benchmark" float adjustedBenchmarkExposurePct = input.float(23.33, "Exposure-adjusted B&H weight (%)", minval = 0.0, maxval = 100.0, step = 0.01, group = groupBenchmark, tooltip = "Constant daily underlying weight used by the source material to compare the Payday window with an exposure-adjusted buy-and-hold path. Recompute per symbol as the actual share of trading days inside the calendar window over your sample.", display=display.none) string groupDisplay = "Display" bool showWindow = input.bool(true, "Highlight return days", group = groupDisplay, display=display.none) bool showTable = input.bool(true, "Show comparison table", group = groupDisplay, display=display.none) // ============================================================================= // Helpers and chart validation // ============================================================================= formatPercent(float value) => na(value) ? "n/a" : str.tostring(value * 100.0, "#.##") + "%" string chartTicker = str.upper(syminfo.ticker) string sizingMode = "Vol(" + str.tostring(volLength) + ")" if barstate.isfirst and timeframe.period != "1D" runtime.error("Use this strategy on a 1D chart. The rules use daily close-to-close returns.") if barstate.isfirst and startDay > endDay runtime.error("The first calendar day must not be after the last calendar day.") // ============================================================================= // Bias-safe signal and per-symbol volatility sizing // ============================================================================= // TradingView's dividend-adjusted chart setting must be enabled because the // source CSVs and the Python backtest use adjusted close-to-close returns // (config.yaml: "Report-Return: Adj Close[t] / Adj Close[t-1] - 1"). float dailyReturn = close / close[1] - 1.0 // Passing false selects sample standard deviation (ddof=1). The latest completed // N returns size the next close-to-close interval, matching pandas // market_return.shift(1).rolling(N, min_periods=N).std(ddof=1) with a one-row lag // and preventing look-ahead bias. This runs on the CURRENT chart's own returns — // build_report.py's cross_asset_analysis() computes Vol(N) on each asset's own // return series too (dynamic_asset_path), not on SPY's volatility for every market. float realizedVol = ta.stdev(dailyReturn, volLength, false) float targetDailyVol = targetDailyVolPct / 100.0 float maxWeight = maxWeightPct / 100.0 // Common warm-up gate, matching build_report.py's common_sample(): the fixed and // dynamic variants only start once a 63-bar (config: volatility_warmup) volatility // estimate exists, so the Python report compares every candidate on the identical // date range. na(warmupVol) forces cash before that point. float warmupVol = ta.stdev(dailyReturn, warmupLength, false) bool warmupReady = not na(warmupVol) // Orders fill on the current close and determine exposure for the next bar. // The negative bars_back lookup obtains the next expected session timestamp; // it never accesses a future price or return. The exchange's unscheduled // Sep. 11-14, 2001 closure is the sole exception in the 2000+ sample: the // next actual daily bar after Sep. 10 was Sep. 17, whose return belongs to the // 12-18 window. Encoding that known closure keeps Pine's historical trade // dates aligned with the supplied daily CSV/Python backtest. int nextBarTime = time(timeframe.period, bars_back = -1) bool beforeSeptember2001Closure = year == 2001 and month == 9 and dayofmonth == 10 int nextReturnBarTime = beforeSeptember2001Closure ? timestamp(syminfo.timezone, 2001, 9, 17, 0, 0) : nextBarTime int nextCalendarDay = dayofmonth(nextReturnBarTime) bool nextDayInWindow = nextCalendarDay >= startDay and nextCalendarDay <= endDay bool nextDayInRange = time >= startDate and nextReturnBarTime >= startDate and nextReturnBarTime <= endDate float volatilityWeight = na(realizedVol) ? 0.0 : realizedVol > 0.0 ? math.min(maxWeight, targetDailyVol / realizedVol) : maxWeight float targetWeight = nextDayInWindow and nextDayInRange and warmupReady ? volatilityWeight : 0.0 float transactionCostRate = 1.5 / 10000.0 // ============================================================================= // Position management // ============================================================================= float contractValue = close * syminfo.pointvalue float positiveEquity = math.max(strategy.equity, 0.0) float currentPositionValue = math.max(strategy.position_size, 0.0) * contractValue float unadjustedTargetValue = positiveEquity * targetWeight bool increasingPosition = unadjustedTargetValue >= currentPositionValue float targetPositionValue = increasingPosition ? targetWeight * (positiveEquity + transactionCostRate * currentPositionValue) / (1.0 + transactionCostRate * targetWeight) : targetWeight * (positiveEquity - transactionCostRate * currentPositionValue) / (1.0 - transactionCostRate * targetWeight) float rawTargetQuantity = contractValue > 0.0 ? math.max(targetPositionValue, 0.0) / contractValue : 0.0 // TradingView can still round a full-allocation ETF order upward internally. // Leaving one share of cash at a 100% target avoids artificial one-share // margin calls; the resulting exposure remains effectively 100%. float targetQuantity = targetWeight >= 0.999999 and rawTargetQuantity >= 1.0 ? rawTargetQuantity - 1.0 : rawTargetQuantity float quantityDelta = targetQuantity - strategy.position_size float quantityTolerance = math.max(syminfo.mincontract * 0.0001, 0.0000001) // The target-value equations reserve cash for the commission. This prevents // TradingView margin calls at a 100% target while keeping the post-cost position // at the intended weight. Only genuine target-weight changes create orders. float previousTargetWeight = nz(targetWeight[1], 0.0) bool targetChanged = math.abs(targetWeight - previousTargetWeight) > 0.0000001 if barstate.isconfirmed and targetChanged and math.abs(quantityDelta) > quantityTolerance if quantityDelta > 0.0 strategy.order( "Rebalance up", strategy.long, qty = quantityDelta, comment = "Volatility weight up", alert_message = "Payday: increase volatility-sized long position") else strategy.order( "Rebalance down", strategy.short, qty = math.abs(quantityDelta), comment = targetWeight == 0.0 ? "Outside day 12-18" : "Volatility weight down", alert_message = targetWeight == 0.0 ? "Payday: close long position" : "Payday: reduce volatility-sized long position") // targetWeight[1] is the intended exposure for the current bar's close-to-close // return. Highlighting it makes days 12-18 easy to audit visually. float heldWeight = targetWeight[1] bool currentReturnDay = heldWeight > 0.0 bgcolor(showWindow and currentReturnDay ? color.new(color.aqua, 88) : na, title = "Payday return window") plot(heldWeight * 100.0, "Held weight (%)", color = color.aqua, display = display.data_window) plot(realizedVol * 100.0, "Realized Vol(N) (%)", color = color.orange, display = display.data_window) // ============================================================================= // Strategy vs. underlying benchmarks // ============================================================================= // The full B&H column holds 100% underlying continuously. The exposure-adjusted // B&H column holds the source material's constant weight (default 23.33%, SPY's // share of trading days in 12-18; recompute per symbol/sample if you change chart). // Both benchmark paths use the same adjusted close-to-close series. A one-time // entry cost of 1.5 bps times capital weight is included for cost consistency. float adjustedBenchmarkExposure = adjustedBenchmarkExposurePct / 100.0 bool metricBar = time >= startDate and time <= endDate and warmupReady bool firstMetricBar = metricBar and (bar_index == 0 or not metricBar[1]) var float buyHoldEquity = na var float adjustedBuyHoldEquity = na var float buyHoldPeak = na var float adjustedBuyHoldPeak = na var float buyHoldMaxDrawdown = 0.0 var float adjustedBuyHoldMaxDrawdown = 0.0 var float strategyPeak = strategy.initial_capital var float strategyMaxDrawdown = 0.0 var float sampledStrategyEquity = na var int metricBars = 0 var int marketBars = 0 var int metricStartTime = na var int metricEndTime = na if metricBar float safeDailyReturn = nz(dailyReturn, 0.0) if firstMetricBar buyHoldEquity := strategy.initial_capital * (1.0 + safeDailyReturn - transactionCostRate) adjustedBuyHoldEquity := strategy.initial_capital * (1.0 + adjustedBenchmarkExposure * safeDailyReturn - adjustedBenchmarkExposure * transactionCostRate) buyHoldPeak := strategy.initial_capital adjustedBuyHoldPeak := strategy.initial_capital strategyPeak := strategy.initial_capital metricStartTime := time else buyHoldEquity *= 1.0 + safeDailyReturn adjustedBuyHoldEquity *= 1.0 + adjustedBenchmarkExposure * safeDailyReturn buyHoldPeak := math.max(buyHoldPeak, buyHoldEquity) adjustedBuyHoldPeak := math.max(adjustedBuyHoldPeak, adjustedBuyHoldEquity) strategyPeak := math.max(strategyPeak, strategy.equity) buyHoldMaxDrawdown := math.min(buyHoldMaxDrawdown, buyHoldEquity / buyHoldPeak - 1.0) adjustedBuyHoldMaxDrawdown := math.min(adjustedBuyHoldMaxDrawdown, adjustedBuyHoldEquity / adjustedBuyHoldPeak - 1.0) strategyMaxDrawdown := math.min(strategyMaxDrawdown, strategy.equity / strategyPeak - 1.0) sampledStrategyEquity := strategy.equity metricBars += 1 marketBars += currentReturnDay ? 1 : 0 metricEndTime := time float millisecondsPerYear = 365.2425 * 24.0 * 60.0 * 60.0 * 1000.0 float elapsedYears = not na(metricStartTime) and not na(metricEndTime) ? (metricEndTime - metricStartTime) / millisecondsPerYear : na float strategyCagr = elapsedYears > 0.0 and sampledStrategyEquity > 0.0 ? math.pow(sampledStrategyEquity / strategy.initial_capital, 1.0 / elapsedYears) - 1.0 : na float buyHoldCagr = elapsedYears > 0.0 and buyHoldEquity > 0.0 ? math.pow(buyHoldEquity / strategy.initial_capital, 1.0 / elapsedYears) - 1.0 : na float adjustedBuyHoldCagr = elapsedYears > 0.0 and adjustedBuyHoldEquity > 0.0 ? math.pow(adjustedBuyHoldEquity / strategy.initial_capital, 1.0 / elapsedYears) - 1.0 : na float strategyTimeInMarket = metricBars > 0 ? float(marketBars) / metricBars : na // ============================================================================= // Comparison table // ============================================================================= color headerColor = color.white color strategyColor = color.white color benchmarkColor = color.white color adjustedColor = color.white color labelColor = color.white var table comparison = table.new(position.top_right, 4, 6, bgcolor = color.white, border_color = color.silver, frame_color = color.silver, border_width = 1, frame_width = 1) if barstate.isfirst table.merge_cells(comparison, 0, 0, 3, 0) if barstate.islast and showTable table.cell(comparison, 0, 0, "Comparison to Underlying · " + chartTicker, bgcolor = headerColor, text_color = color.black, text_size = size.tiny) table.cell(comparison, 0, 1, "Metric", bgcolor = headerColor, text_color = color.black, text_size = size.tiny) table.cell(comparison, 1, 1, "Strategy", bgcolor = strategyColor, text_color = color.black, text_size = size.tiny) table.cell(comparison, 2, 1, "Buy & Hold", bgcolor = benchmarkColor, text_color = color.black, text_size = size.tiny) table.cell(comparison, 3, 1, "Exp.-adj. B&H", bgcolor = adjustedColor, text_color = color.black, text_size = size.tiny) table.cell(comparison, 0, 2, "Sizing", bgcolor = labelColor, text_color = color.black, text_size = size.tiny) table.cell(comparison, 1, 2, sizingMode, bgcolor = color.white, text_color = color.black, text_size = size.tiny) table.cell(comparison, 2, 2, "100% always", bgcolor = color.white, text_color = color.black, text_size = size.tiny) table.cell(comparison, 3, 2, str.tostring(adjustedBenchmarkExposurePct, "#.##") + "% always", bgcolor = color.white, text_color = color.black, text_size = size.tiny) table.cell(comparison, 0, 3, "CAGR", bgcolor = labelColor, text_color = color.black, text_size = size.tiny) table.cell(comparison, 1, 3, formatPercent(strategyCagr), bgcolor = color.white, text_color = color.black, text_size = size.tiny) table.cell(comparison, 2, 3, formatPercent(buyHoldCagr), bgcolor = color.white, text_color = color.black, text_size = size.tiny) table.cell(comparison, 3, 3, formatPercent(adjustedBuyHoldCagr), bgcolor = color.white, text_color = color.black, text_size = size.tiny) table.cell(comparison, 0, 4, "Time in market", bgcolor = labelColor, text_color = color.black, text_size = size.tiny) table.cell(comparison, 1, 4, formatPercent(strategyTimeInMarket), bgcolor = color.white, text_color = color.black, text_size = size.tiny) table.cell(comparison, 2, 4, "100%", bgcolor = color.white, text_color = color.black, text_size = size.tiny) table.cell(comparison, 3, 4, "100%", bgcolor = color.white, text_color = color.black, text_size = size.tiny) table.cell(comparison, 0, 5, "Max drawdown", bgcolor = labelColor, text_color = color.black, text_size = size.tiny) table.cell(comparison, 1, 5, formatPercent(strategyMaxDrawdown), bgcolor = color.white, text_color = color.black, text_size = size.tiny) table.cell(comparison, 2, 5, formatPercent(buyHoldMaxDrawdown), bgcolor = color.white, text_color = color.black, text_size = size.tiny) table.cell(comparison, 3, 5, formatPercent(adjustedBuyHoldMaxDrawdown), bgcolor = color.white, text_color = color.black, text_size = size.tiny) else if barstate.islast and not showTable table.clear(comparison, 0, 0, 3, 5) // ============================================================================= // Regel-Bauplan: optional video explanation, independent of the comparison table. // Presentation only: no signals, position sizing or order settings are changed. // ============================================================================= string groupRuleBlueprint = "Regel-Bauplan (Video)" bool showRuleBlueprint = input.bool(true, "Regel-Bauplan anzeigen", group = groupRuleBlueprint, tooltip = "Zeigt Markt, Bedingung, Einstieg, Ausstieg, Positionsgröße und Kosten rechts mittig. Unabhängig von der Vergleichstabelle ein- und ausschaltbar.", display = display.none) string ruleBlueprintSizeInput = input.string("Normal", "Schriftgröße", options = ["Klein", "Normal", "Groß"], group = groupRuleBlueprint, display = display.none) string ruleBlueprintTextSize = ruleBlueprintSizeInput == "Klein" ? size.small : ruleBlueprintSizeInput == "Groß" ? size.large : size.normal blueprintPercent(float percentValue) => str.replace_all(str.tostring(percentValue, "#.###"), ".", ",") + " %" var table ruleBlueprint = table.new(position.middle_right, 2, 7, bgcolor = color.white, frame_color = color.rgb(45, 77, 109), frame_width = 2, border_color = color.rgb(210, 219, 228), border_width = 1) if barstate.isfirst table.merge_cells(ruleBlueprint, 0, 0, 1, 0) // Also populate while the current realtime bar is still open, using the last // confirmed historical bar. Subsequent updates run on confirmed realtime bars. if barstate.islastconfirmedhistory or (barstate.isrealtime and barstate.isconfirmed) if showRuleBlueprint table.set_frame_width(ruleBlueprint, 2) table.set_border_width(ruleBlueprint, 1) table.set_bgcolor(ruleBlueprint, color.white) string marketName = chartTicker == "SPY" ? "SPY · ETF auf den S&P 500" : chartTicker == "QQQ" ? "QQQ · ETF auf den Nasdaq-100" : chartTicker string marketText = marketName + "\nTageschart · dividendenbereinigte Kurse" string conditionText = "Nur Long: Renditetage " + str.tostring(startDay) + "–" + str.tostring(endDay) + " jedes Monats.\nAußerhalb dieses Fensters: Cash." string entryText = "Zum Schlusskurs des Handelstags\nvor dem ersten Renditetag im Fenster." string exitText = "Zum Schlusskurs des letzten Handelstags\nim Fenster; danach Cash." string sizeText = "Ziel: " + blueprintPercent(targetDailyVolPct) + " tägliche Volatilität.\nGewicht = min(" + blueprintPercent(maxWeightPct) + ", " + blueprintPercent(targetDailyVolPct) + " / Vol(" + str.tostring(volLength) + ")).\nBei Zieländerung am Schluss anpassen." // Pine does not expose overrides from the Properties tab. Explicitly // identify these figures as code defaults rather than live broker fees. string costText = "Code-Standard: " + blueprintPercent(transactionCostRate * 100.0) + " je Kauf/Verkauf,\nauch bei Positionsanpassungen.\nZusätzliche Slippage: 0 Ticks." array labels = array.from("1 Markt", "2 Bedingung", "3 Einstieg", "4 Ausstieg", "5 Positionsgröße", "6 Kosten") array answers = array.from(marketText, conditionText, entryText, exitText, sizeText, costText) table.cell(ruleBlueprint, 0, 0, "DER REGEL-BAUPLAN · " + chartTicker, bgcolor = color.rgb(45, 77, 109), text_color = color.white, text_size = ruleBlueprintTextSize, text_formatting = text.format_bold, text_halign = text.align_left) for item = 0 to 5 table.cell(ruleBlueprint, 0, item + 1, array.get(labels, item), bgcolor = color.rgb(239, 244, 248), text_color = color.rgb(45, 77, 109), text_size = ruleBlueprintTextSize, text_formatting = text.format_bold, text_halign = text.align_left, text_valign = text.align_center) table.cell(ruleBlueprint, 1, item + 1, array.get(answers, item), bgcolor = color.white, text_color = color.rgb(26, 36, 46), text_size = ruleBlueprintTextSize, text_halign = text.align_left, text_valign = text.align_center) else table.clear(ruleBlueprint, 0, 0, 1, 6) table.set_frame_width(ruleBlueprint, 0) table.set_border_width(ruleBlueprint, 0) table.set_bgcolor(ruleBlueprint, color.new(color.white, 100))