ash001's picture
Deploy from GitHub Actions to nse-bot-backend (part 4)
a45d45a verified
Raw
History Blame Contribute Delete
15.5 kB
"""Deterministic base-frame bar replay with the spec's scaled-exit management.
Fill & exit rules (spec §Entry, §Stop, §Targets & Position Management):
* A resting limit at the M5 OB fills no earlier than the bar in which its plan is
known (``created_time``); cancelled on expiry, session end, or if the OB distal
edge is crossed before filling (``invalidate_before_fill``).
* On fill the position is split: **50% books at the fixed 5R first target**; the
remaining **runner** targets the structural secondary level.
* **Break-even** for the runner follows ``breakeven_mode``: ``never`` keeps the
original stop; ``after_first_target`` moves to BE once the first tranche books;
``after_further_bos`` (the faithful rule) moves to BE only once a *further* BOS in
the trade direction prints — "no reason for price to return".
* Same-bar stop/target collisions resolve stop-first (``same_bar_policy``); MAE/MFE
track the whole trade. Consumed OB zones are not re-used (``first_touch_only``).
One event-log row per plan (filled / unfilled / invalidated / rejected) — nothing is
silently dropped. Option execution is mapped afterward on a size-weighted exit spot.
"""
from __future__ import annotations
from math import floor
from typing import Dict, List, Optional
import numpy as np
import pandas as pd
from .config import Config
from .confirm import Plan
class _Trade:
__slots__ = ("plan", "start", "expiry_pos", "entry_idx", "entry_price",
"units_first", "units_runner", "first_done", "runner_stop",
"be_moved", "mae", "mfe", "realized_pnl", "realized_units",
"exit_spot_num", "first_exit", "runner_exit", "reasons")
def __init__(self, plan: Plan, start: int, expiry_pos: int):
self.plan = plan
self.start = start
self.expiry_pos = expiry_pos
self.entry_idx: Optional[int] = None
self.entry_price = float("nan")
self.units_first = 0
self.units_runner = 0
self.first_done = False
self.runner_stop = plan.stop_price
self.be_moved = False
self.mae = 0.0
self.mfe = 0.0
self.realized_pnl = 0.0
self.realized_units = 0
self.exit_spot_num = 0.0 # sum(units*exit_price) for weighted exit
self.first_exit: Optional[tuple] = None # (idx, price, reason)
self.runner_exit: Optional[tuple] = None
self.reasons: List[str] = []
def _pos(index: pd.DatetimeIndex, ts) -> int:
return int(index.searchsorted(ts, side="left"))
def run(plans: List[Plan], base_df: pd.DataFrame, cfg: Config,
option_map=None) -> List[dict]:
index = base_df.index
high = base_df["high"].to_numpy(dtype=float)
low = base_df["low"].to_numpy(dtype=float)
close = base_df["close"].to_numpy(dtype=float)
times = index.time
n = len(base_df)
planned = [p for p in plans if p.planned]
rejected = [p for p in plans if not p.planned]
working: List[_Trade] = []
by_start: Dict[int, List[_Trade]] = {}
for p in sorted(planned, key=lambda x: (x.created_time, x.entry_price)):
start = _pos(index, p.created_time)
if start >= n:
rejected.append(_as_unfilled(p, "no_base_bar"))
continue
expiry_pos = max(min(_pos(index, p.expiry_time), n - 1), start)
by_start.setdefault(start, []).append(_Trade(p, start, expiry_pos))
consumed: set = set()
open_trades: List[_Trade] = []
rows: List[dict] = []
for t in range(n):
# ---- 1) manage open trades ----
still: List[_Trade] = []
for tr in open_trades:
_excursion(tr, high[t], low[t])
_maybe_breakeven(tr, index[t], cfg)
done = _manage_exit(tr, t, high[t], low[t], close[t], times[t], cfg)
if done:
rows.append(_row(tr, base_df, cfg, option_map))
else:
still.append(tr)
open_trades = still
# ---- 2) activate newly-tradeable orders ----
for tr in by_start.get(t, []):
working.append(tr)
# ---- 3) work resting limits ----
nxt: List[_Trade] = []
for tr in working:
p = tr.plan
if t > tr.expiry_pos:
rows.append(_unfilled(tr, t, "unfilled_expired", base_df)); continue
if cfg.first_touch_only and p.zone_key in consumed:
rows.append(_unfilled(tr, t, "zone_consumed", base_df)); continue
if cfg.intraday_only and times[t] >= cfg.intraday_exit_time:
rows.append(_unfilled(tr, t, "session_end", base_df)); continue
# first target reached before entry -> stale, cancel
if _tgt_pre_entry(p, high[t], low[t]):
rows.append(_unfilled(tr, t, "target_pre_entry", base_df)); continue
# fill?
if low[t] <= p.entry_price <= high[t]:
if len(open_trades) >= cfg.max_concurrent:
rows.append(_unfilled(tr, t, "skipped_concurrency", base_df)); continue
_fill(tr, t, cfg)
consumed.add(p.zone_key)
_excursion(tr, high[t], low[t])
if _manage_exit(tr, t, high[t], low[t], close[t], times[t], cfg):
rows.append(_row(tr, base_df, cfg, option_map))
else:
open_trades.append(tr)
continue
if cfg.invalidate_before_fill and _invalidated(p, high[t], low[t]):
rows.append(_unfilled(tr, t, "invalidated", base_df)); continue
nxt.append(tr)
working = nxt
# flush leftovers at series end
for tr in open_trades:
_force_close(tr, n - 1, close[-1], "eod_series")
rows.append(_row(tr, base_df, cfg, option_map))
for tr in working:
rows.append(_unfilled(tr, min(tr.expiry_pos, n - 1), "unfilled_expired", base_df))
for p in rejected:
rows.append(_reject_row(p))
return rows
# ---- position lifecycle ----------------------------------------------------
def _fill(tr: _Trade, t: int, cfg: Config):
p = tr.plan
tr.entry_idx = t
tr.entry_price = p.entry_price
total = _size(p, cfg)
tr.units_first = int(round(total * p.first_scale_fraction))
tr.units_runner = total - tr.units_first
tr.runner_stop = p.stop_price
def _size(p: Plan, cfg: Config) -> int:
if p.risk <= 0:
return 0
units = floor(cfg.risk_pct * cfg.start_equity / p.risk)
if cfg.position_cap_pct is not None and p.entry_price > 0:
cap = floor(cfg.position_cap_pct * cfg.start_equity / p.entry_price)
units = min(units, cap)
return max(units, 2)
def _sign(p: Plan) -> float:
return 1.0 if p.direction == "long" else -1.0
def _excursion(tr: _Trade, hi, lo):
if tr.entry_idx is None:
return
if tr.plan.direction == "long":
tr.mfe = max(tr.mfe, hi - tr.entry_price)
tr.mae = min(tr.mae, lo - tr.entry_price)
else:
tr.mfe = max(tr.mfe, tr.entry_price - lo)
tr.mae = min(tr.mae, tr.entry_price - hi)
def _maybe_breakeven(tr: _Trade, ts, cfg: Config):
if tr.be_moved or cfg.breakeven_mode == "never":
return
p = tr.plan
move = False
if cfg.breakeven_mode == "after_first_target":
move = tr.first_done
elif cfg.breakeven_mode == "after_further_bos":
move = any(ts >= bt for bt in p.further_bos_times)
if move:
sign = _sign(p)
tr.runner_stop = tr.entry_price + sign * p.breakeven_buffer
tr.be_moved = True
def _book(tr: _Trade, units: int, price: float, idx: int, reason: str):
p = tr.plan
sign = _sign(p)
tr.realized_pnl += sign * (price - tr.entry_price) * units
tr.realized_units += units
tr.exit_spot_num += units * price
tr.reasons.append(reason)
def _manage_exit(tr: _Trade, t, hi, lo, cl, tm, cfg: Config) -> bool:
"""Advance exits on bar ``t``; return True when the whole position is closed."""
p = tr.plan
long = p.direction == "long"
# ---- phase A: first tranche + runner share the original stop ----
if not tr.first_done and tr.units_first > 0:
hit_stop = (lo <= p.stop_price) if long else (hi >= p.stop_price)
hit_first = (hi >= p.first_target_price) if long else (lo <= p.first_target_price)
if hit_stop and hit_first:
if cfg.same_bar_policy == "stop_first":
_book(tr, tr.units_first + tr.units_runner, p.stop_price, t, "stop")
tr.first_exit = (t, p.stop_price, "stop")
tr.runner_exit = (t, p.stop_price, "stop")
_force_close(tr, t, p.stop_price, "stop", already=True)
return True
# target-first
_book(tr, tr.units_first, p.first_target_price, t, "first_target_5R")
tr.first_exit = (t, p.first_target_price, "first_target_5R")
tr.first_done = True
_apply_be_on_first(tr, cfg)
elif hit_stop:
_book(tr, tr.units_first + tr.units_runner, p.stop_price, t, "stop")
tr.first_exit = (t, p.stop_price, "stop")
tr.runner_exit = (t, p.stop_price, "stop")
return True
elif hit_first:
_book(tr, tr.units_first, p.first_target_price, t, "first_target_5R")
tr.first_exit = (t, p.first_target_price, "first_target_5R")
tr.first_done = True
_apply_be_on_first(tr, cfg)
# ---- phase B: runner only ----
if tr.first_done and tr.units_runner > 0 and tr.runner_exit is None:
rstop = tr.runner_stop
hit_stop = (lo <= rstop) if long else (hi >= rstop)
hit_sec = (hi >= p.secondary_target_price) if long else (lo <= p.secondary_target_price)
if hit_stop and hit_sec:
px = rstop if cfg.same_bar_policy == "stop_first" else p.secondary_target_price
reason = "runner_stop" if cfg.same_bar_policy == "stop_first" else "secondary_target"
_book(tr, tr.units_runner, px, t, reason)
tr.runner_exit = (t, px, reason)
return True
if hit_stop:
_book(tr, tr.units_runner, rstop, t, "runner_stop")
tr.runner_exit = (t, rstop, "runner_stop")
return True
if hit_sec:
_book(tr, tr.units_runner, p.secondary_target_price, t, "secondary_target")
tr.runner_exit = (t, p.secondary_target_price, "secondary_target")
return True
# ---- intraday / session force-close of anything still open ----
if cfg.intraday_only and tm >= cfg.intraday_exit_time:
_force_close(tr, t, cl, "intraday_exit")
return True
# fully closed?
if tr.realized_units >= tr.units_first + tr.units_runner and tr.units_first + tr.units_runner > 0:
return True
return False
def _apply_be_on_first(tr: _Trade, cfg: Config):
if cfg.breakeven_mode == "after_first_target" and not tr.be_moved:
sign = _sign(tr.plan)
tr.runner_stop = tr.entry_price + sign * tr.plan.breakeven_buffer
tr.be_moved = True
def _force_close(tr: _Trade, t, price, reason, already: bool = False):
if already:
return
if not tr.first_done and tr.units_first > 0 and tr.first_exit is None:
_book(tr, tr.units_first, price, t, reason)
tr.first_exit = (t, price, reason)
tr.first_done = True
if tr.units_runner > 0 and tr.runner_exit is None:
_book(tr, tr.units_runner, price, t, reason)
tr.runner_exit = (t, price, reason)
def _tgt_pre_entry(p: Plan, hi, lo) -> bool:
return (hi >= p.first_target_price) if p.direction == "long" else (lo <= p.first_target_price)
def _invalidated(p: Plan, hi, lo) -> bool:
return (lo < p.invalidation_price) if p.direction == "long" else (hi > p.invalidation_price)
# ---- event-log rows --------------------------------------------------------
def _base_row(p: Plan) -> dict:
return {
"symbol": p.symbol, "direction": p.direction, "trend": p.trend,
"htf_break_kind": p.htf_break_kind, "qualified_by": p.qualified_by,
"swept": p.swept, "sweep_level_type": p.sweep_level_type,
"sweep_level_price": p.sweep_level_price, "sweep_time": p.sweep_time,
"htf_known_time": p.htf_known_time, "htf_ob_low": p.htf_ob_low,
"htf_ob_high": p.htf_ob_high, "htf_ob_time": p.htf_ob_time,
"htf_touch_time": p.htf_touch_time, "m1_shift_time": p.m1_shift_time,
"m1_shift_kind": p.m1_shift_kind, "m5_ob_low": p.m5_ob_low,
"m5_ob_high": p.m5_ob_high, "m5_ob_time": p.m5_ob_time,
"entry_price": p.entry_price, "stop_price": p.stop_price,
"first_target_price": p.first_target_price,
"secondary_target_price": p.secondary_target_price,
"secondary_target_mode": p.secondary_target_mode,
"planned_first_R": p.planned_first_R, "planned_secondary_R": p.planned_secondary_R,
"created_time": p.created_time,
"entry_timestamp": None, "first_exit_time": None, "first_exit_price": np.nan,
"runner_exit_time": None, "runner_exit_price": np.nan, "exit_reason": None,
"units_total": np.nan, "gross_pnl": np.nan, "costs": np.nan, "net_pnl": np.nan,
"realized_R": np.nan, "mae": np.nan, "mfe": np.nan, "be_moved": False,
"rejection_reason": p.rejection_reason,
"option_symbol": None, "option_entry_price": np.nan, "option_exit_price": np.nan,
"option_gross_pnl": np.nan, "option_costs": np.nan, "option_net_pnl": np.nan,
}
def _row(tr: _Trade, base_df: pd.DataFrame, cfg: Config, option_map) -> dict:
p = tr.plan
r = _base_row(p)
idx = base_df.index
units = tr.units_first + tr.units_runner
gross = tr.realized_pnl
wexit = tr.exit_spot_num / tr.realized_units if tr.realized_units else np.nan
notional = (tr.entry_price + wexit) * units
costs = notional * (cfg.commission_pct + cfg.slippage_pct)
risk = p.risk
exit_time = None
if tr.runner_exit is not None:
exit_time = idx[tr.runner_exit[0]]
elif tr.first_exit is not None:
exit_time = idx[tr.first_exit[0]]
r.update(
entry_timestamp=idx[tr.entry_idx], units_total=units,
first_exit_time=(idx[tr.first_exit[0]] if tr.first_exit else None),
first_exit_price=(tr.first_exit[1] if tr.first_exit else np.nan),
runner_exit_time=(idx[tr.runner_exit[0]] if tr.runner_exit else None),
runner_exit_price=(tr.runner_exit[1] if tr.runner_exit else np.nan),
exit_reason="+".join(dict.fromkeys(tr.reasons)) or "open",
gross_pnl=gross, costs=costs, net_pnl=gross - costs,
realized_R=(gross / (risk * units)) if (risk > 0 and units) else np.nan,
mae=tr.mae, mfe=tr.mfe, be_moved=tr.be_moved,
)
if option_map is not None and cfg.options_mode and not np.isnan(wexit):
r.update(option_map(p, idx[tr.entry_idx], exit_time, tr.entry_price, wexit))
return r
def _unfilled(tr: _Trade, at_idx: int, reason: str, base_df: pd.DataFrame) -> dict:
r = _base_row(tr.plan)
r.update(exit_reason=reason, first_exit_time=base_df.index[at_idx])
return r
def _as_unfilled(p: Plan, reason: str) -> Plan:
p.meta["_unfilled_reason"] = reason
return p
def _reject_row(p: Plan) -> dict:
r = _base_row(p)
r.update(exit_reason=(p.rejection_reason or p.meta.get("_unfilled_reason", "unfilled")))
return r