"""Anish Singh Thakur's intraday stock strategy: VWAP, Fibonacci pivots and Stochastic RSI on 5-minute candles.

Source: Booming Bulls, "Secrets of Intraday Strategy No One will tell you - VWAP PIVOTS STOCHASTIC RSI" (video
8OurAiVyhE4, 15 Aug 2020, 4.4M views). His words (the channel's own English subtitles, checked against the Hindi
audio) sit next to the rule they define, with the second they are said at. Where he left something open, our
reading is marked "ours". Nothing is tuned on the results: every reading was written down before the first run
(projects/boomingbulls-audit/bb_rules.json, dated copy in prereg/).

His claim, after walking Bajaj Finance day by day: "Which strategy gives 2 stop loss ... gives a target of 8
days, almost 0.8%? If there's one, please share. I will also use it." (1716) -- 8 target days out of 10.

"Pivot enters, VWAP filters, & Stochastic supports." (548)
"""

from __future__ import annotations

from dataclasses import dataclass, replace
from typing import Literal

import numpy as np

from backtest.data import Session, SessionBars, session_bars
from backtest.indicators import fib_pivots, session_vwap, stoch_rsi

LONG, SHORT = 1, -1
Reason = Literal["stop", "target", "close"]


def minutes_of(clock: str) -> int:
    hours, minutes = clock.split(":")
    return int(hours) * 60 + int(minutes)


@dataclass(frozen=True)
class Rules:
    # "Choose the 5 minutes. Because it is the best time frame for 'intra-day' trading." (128)
    chart_minutes: int = 5

    # "The 1st rule is to wake up late in the morning ... not to start trading at 9:15. Start at 10:45." (487)
    # Ours: the first candle that may give a signal is the one opening at 10:45; no signal from a candle closing
    # after 15:00; anything still open is closed at the 15:20 minute's close.
    first_signal_open: int = minutes_of("10:45")
    last_signal_close: int = minutes_of("15:00")
    flat_at: int = minutes_of("15:20")

    # "If the price is below VWAP, then we'll plan to 'sell' it. But, if the price is above VWAP, then we'll plan
    # to 'buy' it. ... whenever the price crosses or breaks it, it will become the entry trigger." (292)
    # "Entry always triggers on any important Pivot break" (518), "the high or low of a day is also a very
    # important part" (654). Pivots: "Pivots Standard ... Fibonacci ... Daily" (148).
    # "Whenever the blue line intercepts the red line from down to up, it gives us 'buy' signal" (353): a buy
    # needs Stoch RSI's K above its D at the signal candle's close. Variant: "Even if the Stochastic doesn't
    # indicate anything, that's not an issue ... the most important signal is VWAP break" (1163).
    stochastic_support: bool = True

    # Entry: "close" = at the signal candle's close (ours, the version a chart shows). "level" = "You must put
    # your limit order instantly" (680): a stop order at the nearest trigger level, armed with what was known at
    # the previous candle's close, filled in the minute price trades through it.
    entry: Literal["close", "level"] = "close"

    # "Take a small stop loss say of 15 rupees, and target of 30 rupees. The ratio is 1:2." (1800), on a stock
    # near Rs 3,000; "always try to set the target as one percent" (567). So: stop 0.5%, target 1%.
    # Variant "vwap": "I will put my stop loss somewhere below the VWAP ... leaving some space of Rupees 1 or
    # 2" (693) -- ours: 0.05% of room, never closer than 0.25% ("stop loss should always be normal, not very
    # tight", 820), and no trade if it is further than the target ("big gap in VWAP above the target ... forgo",
    # 1534). Variant "next pivot": the target is the next pivot level beyond the entry.
    stop: Literal["percent", "vwap"] = "percent"
    stop_pct: float = 0.5
    target: Literal["percent", "next_pivot"] = "percent"
    target_pct: float = 1.0
    vwap_room_pct: float = 0.05
    min_stop_pct: float = 0.25

    # "The rule is to make two trades a day" (1229); "just make a profit of 30 rupees, and stop trading for that
    # particular day" (1112). One position at a time.
    max_trades_per_day: int = 2
    stop_after_target: bool = True

    # His claim: 8 target days in 10.
    claimed_target_days_pct: float = 80.0


EXACT = Rules()
NO_STOCHASTIC = replace(EXACT, stochastic_support=False)
STOP_BELOW_VWAP = replace(EXACT, stop="vwap")
TARGET_NEXT_PIVOT = replace(EXACT, target="next_pivot")
STOP_ORDER_AT_LEVEL = replace(EXACT, entry="level")
MOST_GENEROUS = replace(EXACT, stochastic_support=False, target_pct=0.5)


@dataclass(frozen=True)
class EquityCosts:
    """What an intraday (MIS) stock trade costs. Charges are shares of the traded value: STT on the sell leg,
    stamp duty on the buy leg, the exchange charge and SEBI fee (with GST) on both. Market and stop fills lose
    `slippage` of the price; targets are limit orders. The flat brokerage (two orders of Rs 20 plus GST) is a fixed
    share of the rupee risk: `brokerage_r`."""

    stt_sell: float
    exchange: tuple[tuple[str, float], ...]
    stamp_buy: float
    sebi_fee: float
    gst: float
    slippage: float
    brokerage_r: float
    optimistic: bool = False  # sensitivity check only: same-minute doubts go our way

    def points(self, day: str, side: int, entry: float, exit_price: float) -> float:
        buy, sell = (entry, exit_price) if side == LONG else (exit_price, entry)
        traded = buy + sell
        exchange = self.exchange[0][1]
        for since, rate in self.exchange:
            if day >= since:
                exchange = rate
        return (
            self.stt_sell * sell
            + exchange * traded * (1 + self.gst)
            + self.stamp_buy * buy
            + self.sebi_fee * traded * (1 + self.gst)
        )


NO_COSTS = EquityCosts(0.0, (("2000-01-01", 0.0),), 0.0, 0.0, 0.0, 0.0, 0.0)


@dataclass(frozen=True)
class Indicators:
    vwap: np.ndarray
    k: np.ndarray  # Stoch RSI's blue line
    d: np.ndarray  # its red line


def indicators(bars: SessionBars) -> Indicators:
    k, d = stoch_rsi(bars.candles.close)
    return Indicators(session_vwap(bars.candles, bars.volume, bars.session), k, d)


@dataclass(frozen=True)
class Trade:
    day: str
    side: int
    signal_minute: int  # minutes after midnight when the signal candle opened
    level: float  # the level it broke
    entry_minute: int  # close entry: the signal candle's last minute (fill at its close); level entry: the fill
    entry_price: float
    stop: float
    target: float
    exit_minute: int
    exit_price: float
    reason: Reason

    @property
    def risk(self) -> float:
        return abs(self.entry_price - self.stop)


def supported(ind: Indicators, k: int, side: int) -> bool:
    """Stoch RSI's K above D for a buy, below for a sell (a missing value supports nothing)."""
    return bool(ind.k[k] > ind.d[k]) if side == LONG else bool(ind.k[k] < ind.d[k])


def broken_levels(bars: SessionBars, ind: Indicators, levels, first: int, k: int, side: int) -> list[float]:
    """The levels candle k closed through in the trade's direction: pivots, VWAP and the day's high or low."""
    c = bars.candles
    before, now = c.close[k - 1], c.close[k]
    hit = [level for level in levels if (before <= level < now if side == LONG else before >= level > now)]
    if side == LONG:
        vwap_cross = before <= ind.vwap[k - 1] and now > ind.vwap[k]
    else:
        vwap_cross = before >= ind.vwap[k - 1] and now < ind.vwap[k]
    if vwap_cross:
        hit.append(float(ind.vwap[k]))
    if side == LONG and now > c.high[first:k].max():
        hit.append(float(c.high[first:k].max()))
    if side == SHORT and now < c.low[first:k].min():
        hit.append(float(c.low[first:k].min()))
    return hit


def close_signal(bars: SessionBars, ind: Indicators, levels, first: int, k: int, rules: Rules):
    """(side, level) if candle k's close is an entry, else None."""
    if np.isnan(ind.vwap[k]):
        return None
    now = bars.candles.close[k]
    side = LONG if now > ind.vwap[k] else SHORT if now < ind.vwap[k] else 0
    if side == 0 or (rules.stochastic_support and not supported(ind, k, side)):
        return None
    hit = broken_levels(bars, ind, levels, first, k, side)
    if not hit:
        return None
    return side, (max(hit) if side == LONG else min(hit))


def stop_order_fill(s: Session, bars: SessionBars, ind: Indicators, levels, first: int, k: int, rules: Rules):
    """(side, level, minute, price) if a stop order armed at candle k - 1's close fills during candle k.

    A buy stop rests at the nearest trigger level above that close (only above VWAP, unless the level is VWAP
    itself), a sell stop at the nearest below. It fills at the level, or at the minute's open if price jumped past
    it. Both filling in one minute: the order of events is unknowable, so no trade.
    """
    before = bars.candles.close[k - 1]
    vwap = ind.vwap[k - 1]
    if np.isnan(vwap):
        return None
    day_high, day_low = bars.candles.high[first:k].max(), bars.candles.low[first:k].min()
    orders = []
    for side in (LONG, SHORT):
        if rules.stochastic_support and not supported(ind, k - 1, side):
            continue
        candidates = list(levels) + [float(vwap)] + [day_high if side == LONG else day_low]
        if side == LONG:
            allowed = before > vwap
            above = [level for level in candidates if level > before and (allowed or level == float(vwap))]
            if above:
                orders.append((LONG, min(above)))
        else:
            allowed = before < vwap
            below = [level for level in candidates if level < before and (allowed or level == float(vwap))]
            if below:
                orders.append((SHORT, max(below)))
    if not orders:
        return None
    bid = s.bid
    for m in range(int(bars.first[k]), int(bars.last[k]) + 1):
        filled = [(side, level) for side, level in orders if traded_through(bid, m, side, level)]
        if len(filled) > 1:
            return None
        if filled:
            side, level = filled[0]
            price = max(level, bid.open[m]) if side == LONG else min(level, bid.open[m])
            return side, level, m, price
    return None


def traded_through(bid, m: int, side: int, level: float) -> bool:
    return bool(bid.high[m] > level) if side == LONG else bool(bid.low[m] < level)


def stop_and_target(entry: float, side: int, vwap: float, levels, rules: Rules):
    """(stop, target), or (None, None) when the VWAP stop would be further away than the target."""
    if rules.stop == "percent":
        stop = entry * (1 - side * rules.stop_pct / 100)
    else:
        stop = vwap * (1 - side * rules.vwap_room_pct / 100)
        if (entry - stop) * side < entry * rules.min_stop_pct / 100:
            stop = entry * (1 - side * rules.min_stop_pct / 100)
        if (entry - stop) * side > entry * rules.target_pct / 100:
            return None, None
    if rules.target == "percent":
        return stop, entry * (1 + side * rules.target_pct / 100)
    beyond = [level for level in levels if (level > entry if side == LONG else level < entry)]
    if not beyond:
        return stop, entry * (1 + side * rules.target_pct / 100)
    return stop, (min(beyond) if side == LONG else max(beyond))


def follow(
    s: Session,
    side: int,
    start: int,
    stop: float,
    target: float,
    last: int,
    *,
    in_fill_minute: bool,
    slippage: float,
    optimistic: bool,
) -> tuple[int, float, Reason]:
    """Walk minute by minute to the target, the stop or `last`.

    in_fill_minute: `start` is the minute the order filled in, so a stop touch there counts and a target only if
    the minute closed beyond it. Stop first when both are inside one minute. A gap through the stop fills at the
    minute's open. Stops and the end-of-day exit lose `slippage`; the target fills exactly.
    """
    bid = s.bid
    for m in range(start, last + 1):
        stopped = bid.low[m] <= stop if side == LONG else bid.high[m] >= stop
        fill_minute = in_fill_minute and m == start
        if fill_minute:
            reached = bid.close[m] >= target if side == LONG else bid.close[m] <= target
            if optimistic:
                stopped = bid.close[m] <= stop if side == LONG else bid.close[m] >= stop
                reached = bid.high[m] >= target if side == LONG else bid.low[m] <= target
        else:
            reached = bid.high[m] >= target if side == LONG else bid.low[m] <= target
        if stopped and (not reached or not optimistic):
            if fill_minute:
                fill = stop
            else:
                fill = min(stop, bid.open[m]) if side == LONG else max(stop, bid.open[m])
            return m, fill - side * slippage * fill, "stop"
        if reached:
            return m, target, "target"
    close = bid.close[last]
    return last, close - side * slippage * close, "close"


def trade_day(
    s: Session, day_bars: range, bars: SessionBars, ind: Indicators, levels, rules: Rules, costs: EquityCosts
) -> list[tuple[Trade, float]]:
    """Every trade the rules take in one session, each with its result in R after costs."""
    flat = int(np.searchsorted(s.clock // 100 * 60 + s.clock % 100, rules.flat_at, side="right")) - 1
    day = str(s.day.date())
    first = day_bars.start
    trades: list[tuple[Trade, float]] = []
    free_from = 0
    for k in range(first + 1, day_bars.stop):
        if len(trades) >= rules.max_trades_per_day:
            break
        if trades and rules.stop_after_target and trades[-1][0].reason == "target":
            break
        if bars.opens_at[k] < rules.first_signal_open or bars.closes_at[k] > rules.last_signal_close:
            continue
        if bars.first[k] < free_from:
            continue
        if rules.entry == "close":
            signal = close_signal(bars, ind, levels, first, k, rules)
            if signal is None:
                continue
            side, level = signal
            entry_minute = int(bars.last[k])
            entry = bars.candles.close[k] * (1 + side * costs.slippage)
            start, vwap = entry_minute + 1, ind.vwap[k]
        else:
            filled = stop_order_fill(s, bars, ind, levels, first, k, rules)
            if filled is None:
                continue
            side, level, entry_minute, price = filled
            entry = price * (1 + side * costs.slippage)
            start, vwap = entry_minute, ind.vwap[k - 1]
        if start > flat:
            break
        stop, target = stop_and_target(entry, side, vwap, levels, rules)
        if stop is None:
            continue
        exit_minute, exit_price, reason = follow(
            s,
            side,
            start,
            stop,
            target,
            flat,
            in_fill_minute=rules.entry == "level",
            slippage=costs.slippage,
            optimistic=costs.optimistic,
        )
        risk = abs(entry - stop)
        result = ((exit_price - entry) * side - costs.points(day, side, entry, exit_price)) / risk - costs.brokerage_r
        trade = Trade(
            day,
            side,
            int(bars.opens_at[k]),
            float(level),
            entry_minute,
            float(entry),
            float(stop),
            float(target),
            exit_minute,
            float(exit_price),
            reason,
        )
        trades.append((trade, result))
        free_from = exit_minute + 1
    return trades


def backtest(sessions: list[Session], rules: Rules, costs: EquityCosts) -> list[tuple[Trade, float]]:
    """Run the rules over the whole history, session by session."""
    bars = session_bars(sessions, rules.chart_minutes)
    ind = indicators(bars)
    levels = fib_pivots(sessions)
    edges = np.concatenate([[0], np.flatnonzero(np.diff(bars.session)) + 1, [len(bars)]])
    results: list[tuple[Trade, float]] = []
    for a, b in zip(edges[:-1], edges[1:], strict=True):
        q = int(bars.session[a])
        if levels[q] is None:
            continue
        results += trade_day(sessions[q], range(int(a), int(b)), bars, ind, levels[q], rules, costs)
    return results
