Overall Statistics
Total Orders
200
Average Win
0.05%
Average Loss
-0.05%
Compounding Annual Return
0.638%
Drawdown
0.600%
Expectancy
0.044
Start Equity
100000
End Equity
100261.37
Net Profit
0.261%
Sharpe Ratio
-4.426
Sortino Ratio
-5.346
Probabilistic Sharpe Ratio
32.072%
Loss Rate
48%
Win Rate
52%
Profit-Loss Ratio
1.02
Alpha
-0.051
Beta
-0.003
Annual Standard Deviation
0.011
Annual Variance
0
Information Ratio
-0.159
Tracking Error
0.23
Treynor Ratio
18.323
Total Fees
$0.00
Estimated Strategy Capacity
$4600000.00
Lowest Capacity Asset
USDJPY 8G
Portfolio Turnover
98.89%
Drawdown Recovery
91
from AlgorithmImports import *
from collections import deque
from datetime import timedelta


class UsdjpyAsiaLondonBreakoutMt5V13Qc(QCAlgorithm):
    """
    QuantConnect Python mirror of the uploaded MT5 live engine
    `live_usdjpy_asia_london_breakout_MT5_v1_3.py`.

    Important scope note
    --------------------
    This mirrors the STRATEGY LOGIC on QC-native Forex data.
    It is not yet a venue-faithful MT5/IBKR replay.

    Why this is still useful
    ------------------------
    - The pair is explicitly USDJPY.
    - The session logic is preserved.
    - ATR gate / TP1 / runner trailing logic is preserved.
    - The backtest generates real QC trades and statistics.
    - It is suitable to accompany the article as the canonical strategy backtest.
    """

    def initialize(self):
        self.set_start_date(2025, 1, 1)
        self.set_end_date(2025, 5, 31)
        self.set_cash(100000)
        self.set_time_zone(TimeZones.UTC)

        # Explicit pair selection
        self.symbol = self.add_forex("USDJPY", Resolution.MINUTE, Market.OANDA).symbol

        # ----- Strategy parameters taken from the uploaded live engine -----
        self.asia_start_hour = 0
        self.asia_end_hour = 5

        self.entry_start_hour = 8
        self.entry_end_hour = 11

        self.time_stop_hour = 11

        self.atr_len = 14
        self.atr_rising_gate = True

        self.entry_buffer_pips = 0.0
        self.max_spread_pips_at_entry = 3.0

        self.sl_atr_mult = 1.0
        self.tp1_atr_mult = 1.0
        self.tp1_frac = 0.5
        self.trail_atr_mult = 1.5
        self.move_to_be_after_tp1 = True
        self.one_trade_per_day = True

        # FX sizing
        self.base_units = 100000
        self.tp1_units = int(round(self.base_units * self.tp1_frac))
        self.runner_units = self.base_units - self.tp1_units

        self.pip = 0.01

        # ----- Day/session state -----
        self.current_day = None
        self.asia_high = None
        self.asia_low = None
        self.traded_today = False

        # ----- Position/model state -----
        self._reset_trade_state()

        # ----- 15-minute ATR state (built on BID quote bars) -----
        self.last_completed_m15_bid_close = None
        self.tr_values = deque(maxlen=self.atr_len)
        self.atr_current = None
        self.atr_prev = None
        self.atr_gate_ok = False

        # Consolidate minute QuoteBars into 15-minute QuoteBars
        self.consolidate(self.symbol, timedelta(minutes=15), self._on_fifteen_minute_quote_bar)

        # ----- Article/debug metrics -----
        self.model_realized_pips = 0.0
        self.trade_count = 0
        self.win_count = 0
        self.loss_count = 0

        self.debug("Initialized USDJPY Asia-London breakout QC mirror")

    # ---------------------------------------------------------
    # Consolidated 15-minute quote bars -> ATR on BID
    # ---------------------------------------------------------
    def _on_fifteen_minute_quote_bar(self, bar: QuoteBar):
        if bar is None or bar.bid is None:
            return

        high_ = float(bar.bid.high)
        low_ = float(bar.bid.low)
        close_ = float(bar.bid.close)

        if self.last_completed_m15_bid_close is None:
            tr = high_ - low_
        else:
            tr = max(
                high_ - low_,
                abs(high_ - self.last_completed_m15_bid_close),
                abs(low_ - self.last_completed_m15_bid_close)
            )

        self.tr_values.append(float(tr))
        self.last_completed_m15_bid_close = close_

        prev_atr = self.atr_current
        if len(self.tr_values) == self.atr_len:
            self.atr_current = float(sum(self.tr_values) / len(self.tr_values))
        else:
            self.atr_current = None

        self.atr_prev = prev_atr

        if self.atr_rising_gate:
            self.atr_gate_ok = (
                self.atr_current is not None
                and self.atr_prev is not None
                and self.atr_current > self.atr_prev
            )
        else:
            self.atr_gate_ok = self.atr_current is not None

        if self.atr_current is not None:
            self.plot("ATR", "ATR_M15_pips", self.atr_current / self.pip)
        if self.atr_prev is not None:
            self.plot("ATR", "ATR_M15_prev_pips", self.atr_prev / self.pip)

    # ---------------------------------------------------------
    # Main minute loop
    # ---------------------------------------------------------
    def on_data(self, data: Slice):
        if self.symbol not in data.quote_bars:
            return

        qb = data.quote_bars[self.symbol]
        if qb is None or qb.bid is None or qb.ask is None:
            return

        now = self.time

        bid_high = float(qb.bid.high)
        bid_low = float(qb.bid.low)
        bid_close = float(qb.bid.close)

        ask_high = float(qb.ask.high)
        ask_low = float(qb.ask.low)
        ask_close = float(qb.ask.close)

        spread_pips = (ask_close - bid_close) / self.pip
        self.last_spread_pips = spread_pips

        self._ensure_day_reset(now)

        # Build Asia range from BID minute bars
        if self._in_asia_window(now):
            if self.asia_high is None:
                self.asia_high = bid_high
                self.asia_low = bid_low
            else:
                self.asia_high = max(self.asia_high, bid_high)
                self.asia_low = min(self.asia_low, bid_low)

        # Plot levels when available
        if self.asia_high is not None:
            self.plot("Levels", "AsiaHigh", self.asia_high)
            self.plot("Levels", "AsiaLow", self.asia_low)

        self.plot("Levels", "BidClose", bid_close)
        self.plot("Levels", "AskClose", ask_close)

        # 1) Manage open position first
        if self.is_open:
            self._manage_open_trade(now, bid_high, bid_low, bid_close, ask_high, ask_low, ask_close, spread_pips)

        # 2) Only scan for entry if flat
        if (not self.is_open) and self._can_scan_for_entry(now):
            self._try_enter(now, bid_high, bid_low, bid_close, ask_close, spread_pips)

    # ---------------------------------------------------------
    # Day/window helpers
    # ---------------------------------------------------------
    def _ensure_day_reset(self, now):
        day_key = now.date()
        if self.current_day != day_key:
            self.current_day = day_key
            self.asia_high = None
            self.asia_low = None
            self.traded_today = False
            self._reset_trade_state()
            self.debug(f"NEW DAY {self.current_day}")

    def _in_asia_window(self, now):
        return self.asia_start_hour <= now.hour < self.asia_end_hour

    def _in_entry_window(self, now):
        return self.entry_start_hour <= now.hour < self.entry_end_hour

    def _time_stop_reached(self, now):
        return now.hour >= self.time_stop_hour

    def _can_scan_for_entry(self, now):
        if self.asia_high is None or self.asia_low is None:
            return False
        if self.one_trade_per_day and self.traded_today:
            return False
        if not self._in_entry_window(now):
            return False
        if self.atr_current is None:
            return False
        if self.atr_rising_gate and not self.atr_gate_ok:
            return False
        return True

    # ---------------------------------------------------------
    # Entry logic
    # ---------------------------------------------------------
    def _try_enter(self, now, bid_high, bid_low, bid_close, ask_close, spread_pips):
        if spread_pips > self.max_spread_pips_at_entry:
            return

        buffer_px = self.entry_buffer_pips * self.pip
        long_trigger = self.asia_high + buffer_px
        short_trigger = self.asia_low - buffer_px

        hit_long = bid_high >= long_trigger
        hit_short = bid_low <= short_trigger

        # Ambiguous minute: both breakouts within same minute bar.
        # Skip to keep traceability clean for the article.
        if hit_long and hit_short:
            self.log(
                f"AMBIGUOUS_ENTRY {now} bid_high={bid_high:.3f} bid_low={bid_low:.3f} "
                f"long_trigger={long_trigger:.3f} short_trigger={short_trigger:.3f}"
            )
            return

        if not hit_long and not hit_short:
            return

        direction = "LONG" if hit_long else "SHORT"
        entry_bid_level = long_trigger if hit_long else short_trigger
        atr_at_entry = self.atr_current

        if direction == "LONG":
            sl_bid = entry_bid_level - self.sl_atr_mult * atr_at_entry
            tp1_bid = entry_bid_level + self.tp1_atr_mult * atr_at_entry
            qc_qty = +self.base_units
            fill_price_model = ask_close  # Long market buy at ask
            best_extreme = bid_high
        else:
            sl_bid = entry_bid_level + self.sl_atr_mult * atr_at_entry
            tp1_bid = entry_bid_level - self.tp1_atr_mult * atr_at_entry
            qc_qty = -self.base_units
            fill_price_model = bid_close  # Short market sell at bid
            best_extreme = bid_low

        # Real QC order so the backtest generates actual trades
        self.market_order(self.symbol, qc_qty, tag=f"ENTRY_{direction}")

        self.is_open = True
        self.direction = direction
        self.entry_time = now
        self.entry_bid_level = entry_bid_level
        self.entry_fill_price = fill_price_model
        self.atr_at_entry = atr_at_entry
        self.sl_bid = sl_bid
        self.tp1_bid = tp1_bid
        self.be_bid = entry_bid_level
        self.tp1_done = (self.tp1_units <= 0)
        self.best_extreme_bid = best_extreme
        self.trail_bid = sl_bid
        self.open_units = qc_qty
        self.remaining_fraction = 1.0
        self.last_status = "OPEN"
        self.traded_today = True

        self.trade_count += 1

        self.log(
            f"ENTRY {now} dir={direction} bid_close={bid_close:.3f} ask_close={ask_close:.3f} "
            f"spread_pips={spread_pips:.2f} asia_high={self.asia_high:.3f} asia_low={self.asia_low:.3f} "
            f"entry_bid={entry_bid_level:.3f} atr={atr_at_entry/self.pip:.2f}p "
            f"sl_bid={sl_bid:.3f} tp1_bid={tp1_bid:.3f}"
        )

    # ---------------------------------------------------------
    # Position management
    # ---------------------------------------------------------
    def _manage_open_trade(self, now, bid_high, bid_low, bid_close, ask_high, ask_low, ask_close, spread_pips):
        atr_eff = self.atr_current if self.atr_current is not None else self.atr_at_entry

        if self.direction == "LONG":
            self.best_extreme_bid = max(self.best_extreme_bid, bid_high)
            trail_candidate = self.best_extreme_bid - self.trail_atr_mult * atr_eff
            self.trail_bid = max(self.trail_bid, trail_candidate)

            if self.tp1_done and self.move_to_be_after_tp1:
                self.trail_bid = max(self.trail_bid, self.be_bid)

            stop_bid = self.trail_bid
            sl_hit = bid_low <= stop_bid
            tp1_hit = (not self.tp1_done) and (bid_high >= self.tp1_bid)

            # Conservative same-bar priority: stop first
            if sl_hit and tp1_hit:
                tp1_hit = False

            if tp1_hit:
                self._execute_tp1_long(now)

            if sl_hit:
                self._close_all(now, stop_bid, exit_reason="STOP_OR_TRAIL", spread_pips=spread_pips)
                return

            if self._time_stop_reached(now):
                self._close_all(now, bid_close, exit_reason="TIME_STOP", spread_pips=spread_pips)
                return

        else:  # SHORT
            self.best_extreme_bid = min(self.best_extreme_bid, bid_low)
            trail_candidate = self.best_extreme_bid + self.trail_atr_mult * atr_eff
            self.trail_bid = min(self.trail_bid, trail_candidate)

            if self.tp1_done and self.move_to_be_after_tp1:
                self.trail_bid = min(self.trail_bid, self.be_bid)

            stop_bid = self.trail_bid
            sl_hit = bid_high >= stop_bid
            tp1_hit = (not self.tp1_done) and (bid_low <= self.tp1_bid)

            # Conservative same-bar priority: stop first
            if sl_hit and tp1_hit:
                tp1_hit = False

            if tp1_hit:
                self._execute_tp1_short(now, spread_pips)

            if sl_hit:
                # Short exit buys at ASK, approximated as stop_bid + current spread
                exit_fill = stop_bid + spread_pips * self.pip
                self._close_all(now, exit_fill, exit_reason="STOP_OR_TRAIL", spread_pips=spread_pips)
                return

            if self._time_stop_reached(now):
                self._close_all(now, ask_close, exit_reason="TIME_STOP", spread_pips=spread_pips)
                return

    def _execute_tp1_long(self, now):
        if self.open_units <= 0:
            return

        close_units = min(abs(self.open_units), self.tp1_units)
        if close_units <= 0:
            self.tp1_done = True
            return

        self.market_order(self.symbol, -close_units, tag="TP1_LONG")

        pnl_pips_full = (self.tp1_bid - self.entry_fill_price) / self.pip
        weighted_pips = pnl_pips_full * self.tp1_frac

        self.model_realized_pips += weighted_pips
        self.remaining_fraction -= self.tp1_frac
        self.open_units -= close_units
        self.tp1_done = True

        self.log(
            f"TP1 {now} dir=LONG tp1_bid={self.tp1_bid:.3f} "
            f"entry_fill={self.entry_fill_price:.3f} pnl_pips_full={pnl_pips_full:.1f} "
            f"weighted_pips={weighted_pips:.1f} remaining_fraction={self.remaining_fraction:.2f}"
        )

    def _execute_tp1_short(self, now, spread_pips):
        if self.open_units >= 0:
            return

        close_units = min(abs(self.open_units), self.tp1_units)
        if close_units <= 0:
            self.tp1_done = True
            return

        self.market_order(self.symbol, +close_units, tag="TP1_SHORT")

        exit_fill = self.tp1_bid + spread_pips * self.pip  # Short exits buy at ask
        pnl_pips_full = (self.entry_fill_price - exit_fill) / self.pip
        weighted_pips = pnl_pips_full * self.tp1_frac

        self.model_realized_pips += weighted_pips
        self.remaining_fraction -= self.tp1_frac
        self.open_units += close_units
        self.tp1_done = True

        self.log(
            f"TP1 {now} dir=SHORT tp1_bid={self.tp1_bid:.3f} exit_fill={exit_fill:.3f} "
            f"entry_fill={self.entry_fill_price:.3f} pnl_pips_full={pnl_pips_full:.1f} "
            f"weighted_pips={weighted_pips:.1f} remaining_fraction={self.remaining_fraction:.2f}"
        )

    def _close_all(self, now, exit_fill_price, exit_reason, spread_pips):
        if not self.is_open or self.open_units == 0:
            return

        qty = -self.open_units
        self.market_order(self.symbol, qty, tag=exit_reason)

        if self.direction == "LONG":
            pnl_pips_full = (exit_fill_price - self.entry_fill_price) / self.pip
        else:
            pnl_pips_full = (self.entry_fill_price - exit_fill_price) / self.pip

        weighted_pips = pnl_pips_full * self.remaining_fraction
        self.model_realized_pips += weighted_pips

        if weighted_pips >= 0:
            self.win_count += 1
        else:
            self.loss_count += 1

        self.log(
            f"EXIT {now} dir={self.direction} reason={exit_reason} exit_fill={exit_fill_price:.3f} "
            f"entry_fill={self.entry_fill_price:.3f} pnl_pips_full={pnl_pips_full:.1f} "
            f"weighted_pips={weighted_pips:.1f} spread_pips={spread_pips:.2f}"
        )

        self.plot("ModelPips", "CumModelPips", self.model_realized_pips)

        self._reset_trade_state()
        self.last_status = "CLOSED"

    # ---------------------------------------------------------
    # State reset / summary
    # ---------------------------------------------------------
    def _reset_trade_state(self):
        self.is_open = False
        self.direction = None
        self.entry_time = None
        self.entry_bid_level = None
        self.entry_fill_price = None
        self.atr_at_entry = None
        self.sl_bid = None
        self.tp1_bid = None
        self.be_bid = None
        self.tp1_done = False
        self.best_extreme_bid = None
        self.trail_bid = None
        self.open_units = 0
        self.remaining_fraction = 0.0
        self.last_spread_pips = None
        self.last_status = "FLAT"

    def on_end_of_algorithm(self):
        win_rate = 0.0 if self.trade_count == 0 else 100.0 * self.win_count / max(1, self.trade_count)

        self.log("==== SUMMARY ====")
        self.log(f"Symbol: {self.symbol.value}")
        self.log(f"Trades: {self.trade_count}")
        self.log(f"Wins: {self.win_count}")
        self.log(f"Losses: {self.loss_count}")
        self.log(f"Win rate: {win_rate:.1f}%")
        self.log(f"Model realized pips: {self.model_realized_pips:.1f}")