Overall Statistics
Total Orders
5716
Average Win
0.35%
Average Loss
-0.40%
Compounding Annual Return
11.578%
Drawdown
9.100%
Expectancy
0.253
Start Equity
100000
End Equity
196000.01
Net Profit
96.000%
Sharpe Ratio
0.667
Sortino Ratio
0.507
Probabilistic Sharpe Ratio
56.186%
Loss Rate
33%
Win Rate
67%
Profit-Loss Ratio
0.88
Alpha
0.04
Beta
0.112
Annual Standard Deviation
0.074
Annual Variance
0.006
Information Ratio
-0.202
Tracking Error
0.168
Treynor Ratio
0.442
Total Fees
$2200.64
Estimated Strategy Capacity
$66000000.00
Lowest Capacity Asset
VIAC XA367FHRKACL
Portfolio Turnover
6.26%
Drawdown Recovery
622
from AlgorithmImports import *
from collections import defaultdict
import pandas as pd


class Nasdaq100MarketCapIbs(QCAlgorithm):
    def initialize(self) -> None:
        self.set_start_date(2023, 1, 1)
        self.set_cash(100000)
        self.universe_settings.resolution = Resolution.DAILY

        self._universe = self.add_universe(self.universe.etf("QQQ", self._etf_filter))
        self._entry_dates = {}
        self._market_caps = {}
        self._pending_orders = {}

        # Warm-up 210 dní pro momentum 200D
        self.set_warmup(210, Resolution.DAILY)

        self.schedule.on(
            self.date_rules.every_day("QQQ"),
            self.time_rules.after_market_close("QQQ", 2),
            self._evaluate_signals
        )
        self.schedule.on(
            self.date_rules.every(DayOfWeek.MONDAY),
            self.time_rules.after_market_open("QQQ", 30),
            self._refresh_market_caps
        )

    def _etf_filter(self, constituents: List[ETFConstituentUniverse]) -> List[Symbol]:
        return [c.symbol for c in constituents]

    def _refresh_market_caps(self) -> None:
        if self.is_warming_up:
            return
        selected = list(self._universe.selected)
        if not selected:
            return
        for symbol in selected:
            try:
                cap = self.securities[symbol].fundamentals.market_cap
                if cap and cap > 0:
                    self._market_caps[symbol] = cap
            except Exception:
                pass
        self.log(f"Market caps refreshed: {len(self._market_caps)} symbols")

    def on_securities_changed(self, changes: SecurityChanges) -> None:
        for added in changes.added_securities:
            try:
                cap = added.fundamentals.market_cap
                if cap and cap > 0:
                    self._market_caps[added.symbol] = cap
            except Exception:
                pass

        for removed in changes.removed_securities:
            if removed.symbol in self._entry_dates:
                del self._entry_dates[removed.symbol]
            if removed.symbol in self._market_caps:
                del self._market_caps[removed.symbol]
            if removed.symbol in self._pending_orders:
                ticket = self._pending_orders.pop(removed.symbol)
                if ticket.status not in [OrderStatus.FILLED, OrderStatus.CANCELED]:
                    ticket.cancel()

    def _get_history(self, symbol: Symbol, periods: int) -> Optional[pd.DataFrame]:
        """Načte daily historii pro symbol."""
        try:
            hist = self.history(symbol, periods, Resolution.DAILY)
            if hist.empty or len(hist) < periods:
                return None
            return hist
        except Exception:
            return None

    def _compute_ibs(self, symbol: Symbol) -> Optional[float]:
        """IBS z posledního daily baru."""
        hist = self._get_history(symbol, 2)
        if hist is None:
            return None
        last = hist.iloc[-1]
        high, low, close = float(last["high"]), float(last["low"]), float(last["close"])
        if high == low:
            return 0.0
        return (close - low) / (high - low)

    def _compute_momentum_200(self, symbol: Symbol) -> Optional[float]:
        """200denní momentum."""
        hist = self._get_history(symbol, 201)
        if hist is None:
            return None
        past    = float(hist.iloc[0]["close"])
        current = float(hist.iloc[-1]["close"])
        if past <= 0:
            return None
        return current / past - 1.0

    def _compute_atr(self, symbol: Symbol, period: int = 10) -> Optional[float]:
        """Wilder ATR z daily barů."""
        hist = self._get_history(symbol, period * 3)
        if hist is None or len(hist) < period + 2:
            return None

        df = hist.copy().reset_index(drop=True)
        prev_close = df["close"].shift(1)
        tr = pd.concat([
            df["high"] - df["low"],
            (df["high"] - prev_close).abs(),
            (df["low"]  - prev_close).abs()
        ], axis=1).max(axis=1)

        atr = tr.ewm(alpha=1.0 / period, adjust=False).mean()
        val = atr.iloc[-1]
        return None if pd.isna(val) else float(val)

    def _compute_adx(self, symbol: Symbol, period: int = 10) -> Optional[float]:
        """Wilder ADX z daily barů."""
        hist = self._get_history(symbol, period * 3)
        if hist is None or len(hist) < period * 3:
            return None

        df = hist.copy().reset_index(drop=True)
        high       = df["high"]
        low        = df["low"]
        close      = df["close"]
        prev_close = close.shift(1)

        tr = pd.concat([
            high - low,
            (high - prev_close).abs(),
            (low  - prev_close).abs()
        ], axis=1).max(axis=1)

        dm_plus  = (high - high.shift(1)).clip(lower=0)
        dm_minus = (low.shift(1) - low).clip(lower=0)
        mask     = dm_plus >= dm_minus
        dm_plus  = dm_plus.where(mask, 0)
        dm_minus = dm_minus.where(~mask, 0)

        alpha    = 1.0 / period
        atr      = tr.ewm(alpha=alpha, adjust=False).mean()
        di_plus  = 100 * dm_plus.ewm(alpha=alpha,  adjust=False).mean() / atr.replace(0, float("nan"))
        di_minus = 100 * dm_minus.ewm(alpha=alpha, adjust=False).mean() / atr.replace(0, float("nan"))

        dx  = 100 * (di_plus - di_minus).abs() / (di_plus + di_minus).replace(0, float("nan"))
        adx = dx.ewm(alpha=alpha, adjust=False).mean()

        val = adx.iloc[-1]
        return None if pd.isna(val) else float(val)

    def _evaluate_signals(self) -> None:
        if self.is_warming_up:
            return

        selected = list(self._universe.selected)
        if not selected:
            return

        today = self.time.date()

        # Zrušit nevyplněné pending ordery z předchozího dne
        for symbol in list(self._pending_orders.keys()):
            ticket = self._pending_orders[symbol]
            if ticket.status not in [OrderStatus.FILLED, OrderStatus.CANCELED, OrderStatus.INVALID]:
                ticket.cancel()
                self.log(f"Cancelled pending limit order for {symbol}")
                if symbol in self._entry_dates:
                    del self._entry_dates[symbol]
            del self._pending_orders[symbol]

        # Sort dle market cap desc
        sorted_symbols = sorted(
            selected,
            key=lambda s: self._market_caps.get(s, 0),
            reverse=True
        )

        exit_targets = []
        current_positions = sum(1 for s in selected if self.portfolio[s].invested)
        new_order_count = 0
        max_positions    = 10
        position_size    = 0.10
        max_new_positions = max(0, max_positions - current_positions - len(self._pending_orders))

        for symbol in sorted_symbols:
            # Exit existující pozice
            if self.portfolio[symbol].invested:
                ibs      = self._compute_ibs(symbol)
                momentum = self._compute_momentum_200(symbol)
                days_held = (today - self._entry_dates[symbol]).days if symbol in self._entry_dates else 999

                if (
                    (ibs is not None and ibs > 0.8) or
                    days_held >= 10 or
                    (momentum is not None and momentum <= 0)
                ):
                    exit_targets.append(PortfolioTarget(symbol, 0))
                    if symbol in self._entry_dates:
                        del self._entry_dates[symbol]
                    ibs_str      = f"{ibs:.2f}"      if ibs is not None      else "N/A"
                    momentum_str = f"{momentum:.2%}" if momentum is not None else "N/A"
                    self.log(f"Exit {symbol}: ibs={ibs_str}, days={days_held}, mom200={momentum_str}")
                continue

            if symbol in self._pending_orders:
                continue

            if new_order_count >= max_new_positions:
                break

            # Filter: pozitivní 200denní momentum
            momentum = self._compute_momentum_200(symbol)
            if momentum is None or momentum <= 0:
                continue

            # Entry filtr: IBS < 0.1
            ibs = self._compute_ibs(symbol)
            if ibs is None or ibs >= 0.1:
                continue

            # Entry filtr: ADX(10) > 20
            adx = self._compute_adx(symbol, period=10)
            if adx is None or adx <= 20:
                continue

            # Limit price: dnešní low - 0.25 * ATR(10)
            hist = self._get_history(symbol, 2)
            if hist is None:
                continue

            atr = self._compute_atr(symbol, period=10)
            atr_offset  = 0.40 * atr if atr is not None else 0.0
            daily_low   = float(hist.iloc[-1]["low"])
            limit_price = round(daily_low - atr_offset, 2)

            quantity = int((self.portfolio.total_portfolio_value * position_size) / limit_price)
            if quantity <= 0:
                continue

            ticket = self.limit_order(
                symbol, quantity, limit_price,
                tag=f"IBS entry limit @ {limit_price:.2f} (low={daily_low:.2f}, atr_offset={atr_offset:.2f}, mom200={momentum:.2%})"
            )
            self._pending_orders[symbol] = ticket
            self._entry_dates[symbol]    = today
            new_order_count += 1
            self.log(f"Limit order placed: {symbol} qty={quantity} @ {limit_price:.2f} mom200={momentum:.2%}")

        if exit_targets:
            self.set_holdings(exit_targets, liquidate_existing_holdings=False)
from AlgorithmImports import *


class DipBuyMeanReversion(QCAlgorithm):
    def initialize(self) -> None:
        self.set_start_date(2020, 1, 1)
        self.set_cash(100000)
        self.universe_settings.resolution = Resolution.DAILY

        # --- Tunable parameters ---
        self.MAX_POSITIONS      = 10
        self.POSITION_SIZE      = 0.10
        self.MIN_DAY_RETURN     = -0.03
        self.IBS_EXIT           = 0.8
        self.SMA_WINDOW         = 200
        self.ATR_WINDOW         = 5
        self.ATR_MULTIPLIER     = 0.9
        self.MAX_GROSS_EXPOSURE = 1.0
        self.MAX_HOLD_DAYS      = 10

        self._universe = self.add_universe(self.universe.etf("SPY", self._etf_filter))
        self._pending_orders: dict[Symbol, OrderTicket] = {}
        self._sma: dict[Symbol, SimpleMovingAverage] = {}
        self._atr: dict[Symbol, AverageTrueRange] = {}
        self._entry_date: dict[Symbol, datetime] = {}

        # SPY regime filter (replaces QQQ)
        self._spy = self.add_equity("SPY", Resolution.DAILY).symbol
        self._spy_sma = self.sma("SPY", self.SMA_WINDOW, Resolution.DAILY)

        self.schedule.on(
            self.date_rules.every_day("SPY"),
            self.time_rules.before_market_close("SPY", 1),
            self._evaluate_signals
        )
        self.schedule.on(
            self.date_rules.every_day("SPY"),
            self.time_rules.before_market_close("SPY", 0),
            self._cancel_pending_orders
        )

        self.set_warm_up(self.SMA_WINDOW, Resolution.DAILY)

    # ------------------------------------------------------------------ #
    #  Universe
    # ------------------------------------------------------------------ #

    def _etf_filter(self, constituents: List[ETFConstituentUniverse]) -> List[Symbol]:
        return [c.symbol for c in constituents]

    def on_securities_changed(self, changes: SecurityChanges) -> None:
        for added in changes.added_securities:
            sym = added.symbol
            if sym not in self._sma:
                self._sma[sym] = self.sma(sym, self.SMA_WINDOW, Resolution.DAILY)
            if sym not in self._atr:
                self._atr[sym] = self.atr(sym, self.ATR_WINDOW,
                                           MovingAverageType.WILDERS, Resolution.DAILY)

        for removed in changes.removed_securities:
            sym = removed.symbol
            self._entry_date.pop(sym, None)
            self._cancel_order_for(sym)
            self._sma.pop(sym, None)
            self._atr.pop(sym, None)

    # ------------------------------------------------------------------ #
    #  Regime
    # ------------------------------------------------------------------ #

    def _is_spy_bull(self) -> bool:
        if not self._spy_sma.is_ready:
            return False
        return self.securities[self._spy].price > self._spy_sma.current.value

    def _is_stock_uptrend(self, symbol: Symbol) -> bool:
        ind = self._sma.get(symbol)
        if ind is None or not ind.is_ready:
            return False
        return self.securities[symbol].price > ind.current.value

    # ------------------------------------------------------------------ #
    #  Signal helpers
    # ------------------------------------------------------------------ #

    def _compute_day_return(self, symbol: Symbol) -> Optional[float]:
        hist = self.history(symbol, 2, Resolution.DAILY)
        if hist is None or hist.empty or len(hist) < 2:
            return None
        prev = float(hist.iloc[-2]["close"])
        curr = float(hist.iloc[-1]["close"])
        return (curr - prev) / prev if prev != 0 else None

    def _compute_ibs(self, symbol: Symbol) -> Optional[float]:
        hist = self.history(symbol, 1, Resolution.DAILY)
        if hist is None or hist.empty:
            return None
        last = hist.iloc[-1]
        high, low, close = float(last["high"]), float(last["low"]), float(last["close"])
        if high == low:
            return 0.0
        return (close - low) / (high - low)

    def _get_close_price(self, symbol: Symbol) -> Optional[float]:
        hist = self.history(symbol, 1, Resolution.DAILY)
        if hist is None or hist.empty:
            return None
        return float(hist.iloc[-1]["close"])

    def _close_above_prev_high(self, symbol: Symbol) -> bool:
        hist = self.history(symbol, 2, Resolution.DAILY)
        if hist is None or hist.empty or len(hist) < 2:
            return False
        prev_high   = float(hist.iloc[-2]["high"])
        today_close = float(hist.iloc[-1]["close"])
        return today_close > prev_high

    def _atr_ratio(self, symbol: Symbol) -> float:
        atr_ind = self._atr.get(symbol)
        price = self.securities[symbol].price
        if atr_ind is None or not atr_ind.is_ready or price == 0:
            return 0.0
        return atr_ind.current.value / price

    def _holding_days(self, symbol: Symbol) -> int:
        entry = self._entry_date.get(symbol)
        if entry is None:
            return 0
        return (self.time - entry).days

    def _current_gross_exposure(self) -> float:
        total = self.portfolio.total_portfolio_value
        if total <= 0:
            return 0.0
        return sum(
            abs(self.portfolio[s].holdings_value)
            for s in self._universe.selected
            if self.portfolio[s].invested
        ) / total

    # ------------------------------------------------------------------ #
    #  Order management
    # ------------------------------------------------------------------ #

    def _cancel_order_for(self, symbol: Symbol) -> None:
        ticket = self._pending_orders.pop(symbol, None)
        if ticket is not None:
            try:
                ticket.cancel("Stale limit order cancelled")
            except Exception as e:
                self.debug(f"Cancel failed for {symbol.value}: {e}")

    def _cancel_pending_orders(self) -> None:
        for symbol in list(self._pending_orders.keys()):
            self._cancel_order_for(symbol)

    def _liquidate_all_market(self, reason: str) -> None:
        for symbol in list(self._universe.selected):
            if self.portfolio[symbol].invested:
                self.market_order(symbol, -self.portfolio[symbol].quantity,
                                  tag=f"Regime exit: {reason}")
                self._entry_date.pop(symbol, None)
        self._cancel_pending_orders()

    def _exit_limit(self, symbol: Symbol, close_price: float, tag: str) -> None:
        quantity = self.portfolio[symbol].quantity
        ticket = self.limit_order(symbol, -quantity, close_price, tag=tag)
        self._pending_orders[symbol] = ticket

    # ------------------------------------------------------------------ #
    #  Core logic
    # ------------------------------------------------------------------ #

    def _evaluate_signals(self) -> None:
        if self.is_warming_up:
            return

        selected = list(self._universe.selected)
        if not selected:
            return

        # --- Index regime gate ---
        if not self._is_spy_bull():
            if any(self.portfolio[s].invested for s in selected):
                self._liquidate_all_market("SPY below 200 SMA")
            return

        invested     = [s for s in selected if self.portfolio[s].invested]
        not_invested = [s for s in selected if not self.portfolio[s].invested]
        current      = len(invested)

        # --- EXIT (three conditions, priority order) ---
        for symbol in invested:
            if symbol in self._pending_orders:
                continue

            close_price = self._get_close_price(symbol)
            if close_price is None:
                continue

            # Priority 1: time stop → market order immediately
            if self._holding_days(symbol) >= self.MAX_HOLD_DAYS:
                self.market_order(symbol, -self.portfolio[symbol].quantity,
                                  tag=f"Time stop: {self._holding_days(symbol)}d held")
                self._entry_date.pop(symbol, None)
                current -= 1
                continue

            # Priority 2: close > yesterday's high → mean reversion complete
            if self._close_above_prev_high(symbol):
                self._exit_limit(symbol, close_price, tag="Exit: close > prev high")
                current -= 1
                continue

            # Priority 3: IBS > 0.8 → closed near top of today's range
            ibs = self._compute_ibs(symbol)
            if ibs is not None and ibs > self.IBS_EXIT:
                self._exit_limit(symbol, close_price, tag="Exit: IBS > 0.8")
                current -= 1

        # --- ENTRY: stock above own 200 SMA + down ≥ 3% + ATR limit ---
        # Sort by ATR/Close descending — prefer highest relative volatility
        not_invested.sort(key=self._atr_ratio, reverse=True)
        slots = self.MAX_POSITIONS - current

        for symbol in not_invested:
            if slots <= 0:
                break
            if symbol in self._pending_orders:
                continue

            # Hard exposure cap
            pending_exposure = len(self._pending_orders) * self.POSITION_SIZE
            if self._current_gross_exposure() + pending_exposure >= self.MAX_GROSS_EXPOSURE:
                break

            # Rule 1: stock above its own 200-day SMA
            if not self._is_stock_uptrend(symbol):
                continue

            # Rule 2: today's close ≤ -3% vs yesterday
            day_ret = self._compute_day_return(symbol)
            if day_ret is None or day_ret > self.MIN_DAY_RETURN:
                continue

            close_price = self._get_close_price(symbol)
            if close_price is None:
                continue

            # Rule 3: limit price = close - 0.9 × ATR(5)
            atr_ind = self._atr.get(symbol)
            if atr_ind is None or not atr_ind.is_ready:
                continue
            limit_price = round(close_price - self.ATR_MULTIPLIER * atr_ind.current.value, 2)

            quantity = int((self.portfolio.total_portfolio_value * self.POSITION_SIZE) / limit_price)
            if quantity <= 0:
                continue

            ticket = self.limit_order(symbol, quantity, limit_price,
                                      tag=f"Dip entry ATR limit | ret={day_ret:.2%} lim={limit_price:.2f}")
            self._pending_orders[symbol] = ticket
            slots -= 1

    def on_order_event(self, order_event: OrderEvent) -> None:
        if order_event.status == OrderStatus.FILLED:
            symbol = order_event.symbol
            self._pending_orders.pop(symbol, None)
            if order_event.fill_quantity > 0:
                self._entry_date[symbol] = self.time
            else:
                self._entry_date.pop(symbol, None)
        elif order_event.status in (OrderStatus.CANCELED, OrderStatus.INVALID):
            self._pending_orders.pop(order_event.symbol, None)
from AlgorithmImports import *
from collections import defaultdict
import json
import pandas as pd


class Nasdaq100MarketCapIbs(QCAlgorithm):
    def initialize(self) -> None:
        self.set_start_date(2023, 1, 1)
        self.set_cash(100000)
        self.universe_settings.resolution = Resolution.MINUTE
        self.universe_settings.extended_market_hours = True

        self._universe = self.add_universe(self.universe.etf("QQQ", self._etf_filter))
        self._unfinished_bars = {}
        self._databank_key = "nasdaq100_daily_bars"
        self._databank = defaultdict(list)
        self._entry_dates = {}
        self._market_caps = {}
        self._pending_orders = {}
        self._warmup_done = False

        # Warm-up 210 dní aby momentum 200D mělo dostatek dat od prvního dne
        self.set_warmup(210, Resolution.DAILY)

        if self.object_store.contains_key(self._databank_key):
            raw = self.object_store.read_string(self._databank_key)
            loaded = json.loads(raw)
            for k, v in loaded.items():
                self._databank[k] = v

        self.schedule.on(
            self.date_rules.every_day("QQQ"),
            self.time_rules.before_market_close("QQQ", 5),
            self._evaluate_signals
        )
        self.schedule.on(
            self.date_rules.every_day("QQQ"),
            self.time_rules.after_market_close("QQQ", 0),
            self._save_eod_bars
        )
        self.schedule.on(
            self.date_rules.every(DayOfWeek.MONDAY),
            self.time_rules.after_market_open("QQQ", 30),
            self._refresh_market_caps
        )

    def _etf_filter(self, constituents: List[ETFConstituentUniverse]) -> List[Symbol]:
        return [c.symbol for c in constituents]

    def on_warmup_finished(self) -> None:
        """Po skončení warm-up naplníme databank z history callu."""
        selected = list(self._universe.selected)
        if not selected:
            self._warmup_done = True
            return

        self.log(f"Warm-up finished, loading history for {len(selected)} symbols...")

        history = self.history(selected, 210, Resolution.DAILY)

        if history.empty:
            self._warmup_done = True
            return

        for symbol in selected:
            symbol_str = str(symbol)
            # Přeskočit pokud databank už má data (z object store)
            if len(self._databank.get(symbol_str, [])) >= 200:
                continue

            try:
                sym_hist = history.loc[symbol]
            except KeyError:
                continue

            bars = []
            for date, row in sym_hist.iterrows():
                bars.append({
                    "open":   float(row["open"]),
                    "high":   float(row["high"]),
                    "low":    float(row["low"]),
                    "close":  float(row["close"]),
                    "volume": float(row["volume"]),
                    "date":   str(date.date())
                })

            if bars:
                self._databank[symbol_str] = bars[-300:]

        self._warmup_done = True
        self.log(f"Databank loaded: {len(self._databank)} symbols")

    def _refresh_market_caps(self) -> None:
        selected = list(self._universe.selected)
        if not selected:
            return
        for symbol in selected:
            try:
                cap = self.securities[symbol].fundamentals.market_cap
                if cap and cap > 0:
                    self._market_caps[symbol] = cap
            except Exception:
                pass
        self.log(f"Market caps refreshed: {len(self._market_caps)} symbols")

    def on_securities_changed(self, changes: SecurityChanges) -> None:
        for added in changes.added_securities:
            self._unfinished_bars[added.symbol] = None
            added.set_data_normalization_mode(DataNormalizationMode.RAW)
            added.set_fill_model(ImmediateFillModel())
            try:
                cap = added.fundamentals.market_cap
                if cap and cap > 0:
                    self._market_caps[added.symbol] = cap
            except Exception:
                pass

        for removed in changes.removed_securities:
            if removed.symbol in self._unfinished_bars:
                del self._unfinished_bars[removed.symbol]
            if removed.symbol in self._entry_dates:
                del self._entry_dates[removed.symbol]
            if removed.symbol in self._market_caps:
                del self._market_caps[removed.symbol]
            if removed.symbol in self._pending_orders:
                ticket = self._pending_orders.pop(removed.symbol)
                if ticket.status not in [OrderStatus.FILLED, OrderStatus.CANCELED]:
                    ticket.cancel()
            key = str(removed.symbol)
            if key in self._databank:
                del self._databank[key]

    def on_data(self, data: Slice) -> None:
        # Během warm-up ignorujeme — databank plníme v on_warmup_finished
        if self.is_warming_up:
            return

        for symbol in self._universe.selected:
            if not data.contains_key(symbol):
                continue
            bar = data[symbol]
            if bar is None:
                continue

            t = self.time
            if t.hour < 9 or (t.hour == 9 and t.minute < 30) or t.hour >= 16:
                continue

            if self._unfinished_bars.get(symbol) is None:
                self._unfinished_bars[symbol] = {
                    "open":   float(bar.open),
                    "high":   float(bar.high),
                    "low":    float(bar.low),
                    "close":  float(bar.close),
                    "volume": float(bar.volume),
                    "date":   str(self.time.date())
                }
            else:
                u = self._unfinished_bars[symbol]
                u["high"]   = max(u["high"], float(bar.high))
                u["low"]    = min(u["low"],  float(bar.low))
                u["close"]  = float(bar.close)
                u["volume"] += float(bar.volume)

    def _compute_momentum_200(self, symbol: Symbol) -> Optional[float]:
        bars = self._databank.get(str(symbol), [])
        if len(bars) < 200:
            return None
        past = bars[-200]["close"]
        current = bars[-1]["close"]
        if past <= 0:
            return None
        return current / past - 1.0

    def _evaluate_signals(self) -> None:
        if self.is_warming_up or not self._warmup_done:
            return

        selected = list(self._universe.selected)
        if not selected:
            return

        today = self.time.date()

        for symbol in list(self._pending_orders.keys()):
            ticket = self._pending_orders[symbol]
            if ticket.status not in [OrderStatus.FILLED, OrderStatus.CANCELED, OrderStatus.INVALID]:
                ticket.cancel()
                self.log(f"Cancelled pending limit order for {symbol}")
                if symbol in self._entry_dates:
                    del self._entry_dates[symbol]
            del self._pending_orders[symbol]

        sorted_symbols = sorted(
            selected,
            key=lambda s: self._market_caps.get(s, 0),
            reverse=True
        )

        exit_targets = []
        current_positions = sum(1 for s in selected if self.portfolio[s].invested)
        new_order_count = 0
        max_positions = 10
        position_size = 0.10
        max_new_positions = max(0, max_positions - current_positions - len(self._pending_orders))

        for symbol in sorted_symbols:
            if self.portfolio[symbol].invested:
                ibs = self._compute_ibs(symbol)
                days_held = (today - self._entry_dates[symbol]).days if symbol in self._entry_dates else 999
                momentum = self._compute_momentum_200(symbol)

                if (
                    (ibs is not None and ibs > 0.8) or
                    days_held >= 10 or
                    (momentum is not None and momentum <= 0)
                ):
                    exit_targets.append(PortfolioTarget(symbol, 0))
                    if symbol in self._entry_dates:
                        del self._entry_dates[symbol]
                    ibs_str = f"{ibs:.2f}" if ibs is not None else "N/A"
                    momentum_str = f"{momentum:.2%}" if momentum is not None else "N/A"
                    self.log(f"Exit {symbol}: ibs={ibs_str}, days={days_held}, mom200={momentum_str}")
                continue

            if symbol in self._pending_orders:
                continue

            if new_order_count >= max_new_positions:
                break

            momentum = self._compute_momentum_200(symbol)
            if momentum is None or momentum <= 0:
                continue

            ibs = self._compute_ibs(symbol)
            if ibs is None or ibs >= 0.2:
                continue

            adx = self._compute_adx(symbol, period=10)
            if adx is None or adx <= 20:
                continue

            u = self._unfinished_bars.get(symbol)
            if u is None:
                continue

            atr = self._compute_atr(symbol, period=10)
            atr_offset = 0.50 * atr if atr is not None else 0.0
            limit_price = round(u["low"] - atr_offset, 2)

            quantity = int((self.portfolio.total_portfolio_value * position_size) / limit_price)
            if quantity <= 0:
                continue

            ticket = self.limit_order(
                symbol, quantity, limit_price,
                tag=f"IBS entry limit @ {limit_price:.2f} (low={u['low']:.2f}, atr_offset={atr_offset:.2f}, mom200={momentum:.2%})"
            )
            self._pending_orders[symbol] = ticket
            self._entry_dates[symbol] = today
            new_order_count += 1
            self.log(f"Limit order placed: {symbol} qty={quantity} @ {limit_price:.2f} mom200={momentum:.2%}")

        if exit_targets:
            self.set_holdings(exit_targets, liquidate_existing_holdings=False)

    def _compute_ibs(self, symbol: Symbol) -> Optional[float]:
        u = self._unfinished_bars.get(symbol)
        if u is None:
            return None
        high, low, close = u["high"], u["low"], u["close"]
        if high == low:
            return 0.0
        return (close - low) / (high - low)

    def _compute_atr(self, symbol: Symbol, period: int = 10) -> Optional[float]:
        symbol_str = str(symbol)
        bars = list(self._databank.get(symbol_str, []))
        u = self._unfinished_bars.get(symbol)
        if u is not None:
            bars.append(u)

        if len(bars) < period + 2:
            return None

        df = pd.DataFrame(bars)
        df["date"] = pd.to_datetime(df["date"])
        df = df.sort_values("date").tail(period * 3).reset_index(drop=True)

        prev_close = df["close"].shift(1)
        tr = pd.concat([
            df["high"] - df["low"],
            (df["high"] - prev_close).abs(),
            (df["low"]  - prev_close).abs()
        ], axis=1).max(axis=1)

        atr = tr.ewm(alpha=1.0 / period, adjust=False).mean()
        val = atr.iloc[-1]
        return None if pd.isna(val) else float(val)

    def _compute_adx(self, symbol: Symbol, period: int = 10) -> Optional[float]:
        symbol_str = str(symbol)
        bars = list(self._databank.get(symbol_str, []))
        u = self._unfinished_bars.get(symbol)
        if u is not None:
            bars.append(u)

        if len(bars) < period * 3:
            return None

        df = pd.DataFrame(bars)
        df["date"] = pd.to_datetime(df["date"])
        df = df.sort_values("date").tail(period * 3).reset_index(drop=True)

        high       = df["high"]
        low        = df["low"]
        close      = df["close"]
        prev_close = close.shift(1)

        tr = pd.concat([
            high - low,
            (high - prev_close).abs(),
            (low  - prev_close).abs()
        ], axis=1).max(axis=1)

        dm_plus  = (high - high.shift(1)).clip(lower=0)
        dm_minus = (low.shift(1) - low).clip(lower=0)
        mask     = dm_plus >= dm_minus
        dm_plus  = dm_plus.where(mask, 0)
        dm_minus = dm_minus.where(~mask, 0)

        alpha    = 1.0 / period
        atr      = tr.ewm(alpha=alpha, adjust=False).mean()
        di_plus  = 100 * dm_plus.ewm(alpha=alpha,  adjust=False).mean() / atr.replace(0, float("nan"))
        di_minus = 100 * dm_minus.ewm(alpha=alpha, adjust=False).mean() / atr.replace(0, float("nan"))

        dx  = 100 * (di_plus - di_minus).abs() / (di_plus + di_minus).replace(0, float("nan"))
        adx = dx.ewm(alpha=alpha, adjust=False).mean()

        val = adx.iloc[-1]
        return None if pd.isna(val) else float(val)

    def _save_eod_bars(self) -> None:
        if self.is_warming_up:
            return

        for symbol, u in self._unfinished_bars.items():
            if u is None:
                continue
            symbol_str = str(symbol)
            self._databank[symbol_str].append(u)
            if len(self._databank[symbol_str]) > 300:
                self._databank[symbol_str] = self._databank[symbol_str][-300:]

        self.object_store.save_string(self._databank_key, json.dumps(dict(self._databank)))

        for symbol in self._unfinished_bars:
            self._unfinished_bars[symbol] = None