Overall Statistics
Total Orders
409
Average Win
0.22%
Average Loss
-0.17%
Compounding Annual Return
-77.425%
Drawdown
22.100%
Expectancy
-0.710
Start Equity
100000
End Equity
78724.41
Net Profit
-21.276%
Sharpe Ratio
-6.809
Sortino Ratio
-6.685
Probabilistic Sharpe Ratio
0%
Loss Rate
87%
Win Rate
13%
Profit-Loss Ratio
1.28
Alpha
-0.582
Beta
0.27
Annual Standard Deviation
0.098
Annual Variance
0.01
Information Ratio
-2.397
Tracking Error
0.147
Treynor Ratio
-2.47
Total Fees
$612.56
Estimated Strategy Capacity
$23000000.00
Lowest Capacity Asset
SPY R735QTJ8XC9X
Portfolio Turnover
691.98%
Drawdown Recovery
0
# region imports
from AlgorithmImports import *
from random import Random
# endregion


class NoiseFilter:
    """An ISecurityDataFilter that perturbs prices inside the subscription pipeline.

    SubscriptionFilterEnumerator.MoveNext calls this on every data point as it
    leaves the subscription, BEFORE TimeSliceFactory assembles the slice. The
    factory hands the SAME BaseData instance to the security cache, the
    consolidator update list and the Slice, so mutating it here reaches all
    three -- including consolidators, which never read the security cache.
    """

    def __init__(self, seed: int, sigma: float):
        self._seed = seed
        self._sigma = sigma

    def filter(self, vehicle: Security, data: BaseData) -> bool:
        if self._sigma <= 0:
            return True

        # Seeded on (symbol, end_time): the trade and quote subscriptions are
        # separate enumerators with no guaranteed relative order, but both
        # derive the identical shock for the same bar.
        rng = Random(f"{self._seed}:{data.symbol.id}:{data.end_time}")
        shock = 1 + rng.gauss(0, self._sigma)

        if isinstance(data, TradeBar):
            data.open *= shock
            data.high *= shock
            data.low *= shock
            data.close *= shock          # Close's setter also assigns Value
        elif isinstance(data, QuoteBar):
            for side in (data.bid, data.ask):
                if side is not None:
                    side.open *= shock
                    side.high *= shock
                    side.low *= shock
                    side.close *= shock
            data.value *= shock          # not derived from Bid/Ask, set it too
        else:
            data.value *= shock

        # One shock for the whole bar means high >= max(o, c) and ask > bid are
        # preserved for free -- no invariant repair needed. Independent per-field
        # draws would bring both problems back.
        return True                      # returning False DROPS the data point


class RandomizedPricesAlgorithm(QCAlgorithm):

    def initialize(self):
        self.set_start_date(2020, 1, 1)
        self.set_end_date(2020, 3, 1)
        self.set_cash(100_000)

        self._seed = self.get_parameter("seed", 0)
        self._sigma = self.get_parameter("sigma", 0.0005)

        equity = self.add_equity("SPY", Resolution.MINUTE)
        self._spy = equity.symbol
        equity.set_data_filter(NoiseFilter(self._seed, self._sigma))

        # Registered the NORMAL way. These are driven by the consolidator path
        # (AlgorithmManager.cs:443), which is fed timeSlice.ConsolidatorUpdateData
        # directly and never touches the security cache -- so if these move with
        # the seed, the filter really did reach the whole pipeline.
        self._fast = self.ema(self._spy, 20, Resolution.MINUTE)
        self._slow = self.ema(self._spy, 60, Resolution.MINUTE)

        self._ten_min_close = None
        self.consolidate(self._spy, timedelta(minutes=10), self._on_ten_minute)

        self._audited = 0
        self._audit_after = datetime(2020, 1, 15, 10, 0)

    def _on_ten_minute(self, bar: TradeBar):
        self._ten_min_close = bar.close

    def on_data(self, data: Slice):
        if not self._slow.is_ready:
            return

        self._audit()

        invested = self.portfolio[self._spy].invested
        if self._fast.current.value > self._slow.current.value and not invested:
            self.set_holdings(self._spy, 1)
        elif self._fast.current.value < self._slow.current.value and invested:
            self.liquidate(self._spy)

    def _audit(self):
        """Snapshot the pipeline at a fixed instant so runs can be diffed.

        Nothing here can compare against the original data -- by the time the
        algorithm sees anything it is already perturbed. The comparison is
        across runs: baseline (sigma=0) vs each seed.
        """
        if self._audited >= 2 or self.time < self._audit_after:
            return
        self._audited += 1
        n = self._audited
        s = self.securities[self._spy]
        self.set_runtime_statistic(f"a{n} time", str(self.time))
        self.set_runtime_statistic(f"a{n} ema20", f"{self._fast.current.value:.6f}")
        self.set_runtime_statistic(f"a{n} ema60", f"{self._slow.current.value:.6f}")
        self.set_runtime_statistic(f"a{n} 10min close", f"{self._ten_min_close}")
        self.set_runtime_statistic(f"a{n} cache close", f"{s.close}")
        self.set_runtime_statistic(f"a{n} cache price", f"{s.price}")
        self.set_runtime_statistic(f"a{n} cache bid", f"{s.bid_price}")
        self.set_runtime_statistic(f"a{n} cache ask", f"{s.ask_price}")