Overall Statistics
Total Orders
63
Average Win
3.54%
Average Loss
-2.55%
Compounding Annual Return
-2.855%
Drawdown
33.400%
Expectancy
-0.170
Start Equity
100000
End Equity
81626.8
Net Profit
-18.373%
Sharpe Ratio
-0.369
Sortino Ratio
-0.402
Probabilistic Sharpe Ratio
0.016%
Loss Rate
65%
Win Rate
35%
Profit-Loss Ratio
1.39
Alpha
-0.037
Beta
-0.045
Annual Standard Deviation
0.11
Annual Variance
0.012
Information Ratio
-0.601
Tracking Error
0.201
Treynor Ratio
0.9
Total Fees
$0.00
Estimated Strategy Capacity
$33000.00
Lowest Capacity Asset
SPX500USD 8I
Portfolio Turnover
2.97%
Drawdown Recovery
3
# ==================================================================================
# Carver Starter System — Chapter 5 of "Leveraged Trading"
# S&P 500 CFD with MAC 16,64 crossover
# ==================================================================================
#
# EXACT BOOK FORMULAS:
#
#   Instrument risk (Appendix C):
#     daily_returns[i] = (price[i] - price[i-1]) / price[i-1]
#     instrument_risk = stdev(last 25 daily_returns) × 16
#
#   Opening rule (Formula 12-13):
#     MA16 = average of last 16 closing prices
#     MA64 = average of last 64 closing prices
#     MA16 > MA64 → Go Long
#     MA16 < MA64 → Go Short
#
#   Position sizing (Formula 14):
#     notional_exposure = (risk_target × capital) / instrument_risk
#     risk_target = 12% (Starter System, p.99)
#
#   Stop loss (Formulas 22-24):
#     price_volatility = instrument_risk × current_price
#     stop_gap = price_volatility × 0.5  (fraction for MAC 16,64, Table 12)
#     Long:  exit if close ≤ highest_close_since_entry - stop_gap
#     Short: exit if close ≥ lowest_close_since_entry + stop_gap
#
#   Expected: ~5.4 trades/year, SR ≈ 0.24, return ≈ 4.9% at 12% risk
# ==================================================================================

from AlgorithmImports import *
import numpy as np


class CarverStarterSystem(QCAlgorithm):

    def initialize(self) -> None:
        self.set_start_date(2018, 1, 1)
        self.set_end_date(2024, 12, 31)
        self.set_cash(100000)

        # S&P 500 CFD
        self.cfd = self.add_cfd("SPX500USD", Resolution.DAILY)
        self.sym = self.cfd.symbol

        # Book parameters
        self.RISK_TARGET = 0.12     # 12% annual (Ch. 5 p.99)
        self.STOP_FRACTION = 0.5    # for MAC 16,64 (Table 12)
        self.MA_FAST = 16
        self.MA_SLOW = 64
        self.VOL_LOOKBACK = 25      # Appendix C: 25 trading days

        # State
        self.direction = 0          # +1=long, -1=short, 0=flat
        self.high_wm = None         # trailing stop: highest close (long)
        self.low_wm = None          # trailing stop: lowest close (short)
        self.trade_count = 0
        self.prices = []            # all closing prices
        self.ready = False
        self.last_date = None

    def on_data(self, data: Slice) -> None:
        # --- Once per day ---
        today = self.time.date()
        if self.last_date == today:
            return
        self.last_date = today

        if not data.contains_key(self.sym):
            return
        bar = data[self.sym]
        if bar is None:
            return
        price = float(bar.close)
        if price <= 0:
            return

        # --- Pre-fill history on first bar ---
        if not self.ready:
            self._load_history()
            self.ready = True

        # --- Append today's close ---
        self.prices.append(price)

        # Need at least 64 prices for slow MA + 25 for vol
        if len(self.prices) < self.MA_SLOW + 1:
            return

        # =============================================
        # STEP 1: Instrument risk (Appendix C)
        # =============================================
        # daily % returns over last 25 days
        p = np.array(self.prices)
        recent = p[-(self.VOL_LOOKBACK + 1):]
        daily_returns = np.diff(recent) / recent[:-1]
        instrument_risk = float(np.std(daily_returns, ddof=1) * 16)

        if instrument_risk < 0.01:
            return

        # =============================================
        # STEP 2: Check trailing stop loss (F.22-24)
        # =============================================
        if self._check_stop(price, instrument_risk):
            self._close_position("Stop loss hit")
            return

        # =============================================
        # STEP 3: Opening rule — MAC 16,64 (F.12-13)
        # =============================================
        ma16 = float(np.mean(p[-self.MA_FAST:]))
        ma64 = float(np.mean(p[-self.MA_SLOW:]))

        if ma16 > ma64:
            new_dir = 1    # Long
        else:
            new_dir = -1   # Short

        # =============================================
        # STEP 4: Trade only on direction change
        # =============================================
        if new_dir == self.direction:
            # No change — just update watermark
            self._update_watermark(price)
            return

        # =============================================
        # STEP 5: Position sizing (Formula 14)
        # =============================================
        capital = self.portfolio.total_portfolio_value
        notional = (self.RISK_TARGET * capital) / instrument_risk
        target_pct = new_dir * notional / capital

        # Place order
        self.set_holdings(self.sym, target_pct)

        # Update state
        self.direction = new_dir
        self.trade_count += 1

        if new_dir == 1:
            self.high_wm = price
            self.low_wm = None
        else:
            self.low_wm = price
            self.high_wm = None

        self.log(
            f"TRADE #{self.trade_count} | {'LONG' if new_dir == 1 else 'SHORT'} | "
            f"Price={price:.2f} | MA16={ma16:.2f} | MA64={ma64:.2f} | "
            f"Vol={instrument_risk:.3f} | Alloc={target_pct:.2%}"
        )

    # ==================================================================
    # TRAILING STOP LOSS (Formulas 22-24)
    # ==================================================================

    def _check_stop(self, price: float, instrument_risk: float) -> bool:
        """
        Formula 22: price_vol = instrument_risk × price
        Formula 23: stop_gap = price_vol × stop_fraction
        Formula 24: stop_level = watermark ∓ stop_gap
        """
        if self.direction == 0:
            return False

        price_vol = instrument_risk * price         # Formula 22
        stop_gap = price_vol * self.STOP_FRACTION   # Formula 23

        if self.direction == 1 and self.high_wm is not None:
            # Update watermark first
            self.high_wm = max(self.high_wm, price)
            stop_level = self.high_wm - stop_gap    # Formula 24
            if price <= stop_level:
                self.log(
                    f"STOP (Long) | Price={price:.2f} | "
                    f"HWM={self.high_wm:.2f} | Stop={stop_level:.2f}"
                )
                return True

        elif self.direction == -1 and self.low_wm is not None:
            self.low_wm = min(self.low_wm, price)
            stop_level = self.low_wm + stop_gap     # Formula 24
            if price >= stop_level:
                self.log(
                    f"STOP (Short) | Price={price:.2f} | "
                    f"LWM={self.low_wm:.2f} | Stop={stop_level:.2f}"
                )
                return True

        return False

    def _close_position(self, reason: str) -> None:
        """Flatten position and reset state."""
        self.liquidate(self.sym, reason)
        self.direction = 0
        self.high_wm = None
        self.low_wm = None
        self.trade_count += 1

    def _update_watermark(self, price: float) -> None:
        if self.direction == 1 and self.high_wm is not None:
            self.high_wm = max(self.high_wm, price)
        elif self.direction == -1 and self.low_wm is not None:
            self.low_wm = min(self.low_wm, price)

    # ==================================================================
    # HISTORY PRE-LOAD
    # ==================================================================

    def _load_history(self) -> None:
        """
        Pre-fill price buffer with 200 days of history.
        (Need 64 for MA64 + 25 for vol + buffer)
        """
        history = self.history([self.sym], 200, Resolution.DAILY)
        if history.empty:
            return

        closes = history.close.unstack(level=0)
        for col in closes.columns:
            vals = closes[col].dropna().values
            for v in vals:
                self.prices.append(float(v))

    # ==================================================================
    # LIFECYCLE
    # ==================================================================

    def on_order_event(self, event: OrderEvent) -> None:
        if event.status == OrderStatus.FILLED:
            self.debug(
                f"FILLED | Qty={event.fill_quantity} | "
                f"Price={event.fill_price:.2f}"
            )

    def on_end_of_algorithm(self) -> None:
        yrs = (self.end_date - self.start_date).days / 365.25
        self.log(f"=== STARTER SYSTEM RESULTS ===")
        self.log(f"Period: {self.start_date.date()} to {self.end_date.date()}")
        self.log(f"Total trades: {self.trade_count}")
        self.log(f"Trades/year: {self.trade_count / yrs:.1f}")
        self.log(f"Final portfolio: ${self.portfolio.total_portfolio_value:,.2f}")
        self.log(f"Risk target: {self.RISK_TARGET:.0%}")
        self.log(f"Stop fraction: {self.STOP_FRACTION}")
        self.log(f"Rule: MAC {self.MA_FAST},{self.MA_SLOW}")