CoolFace
Apppublic

sridattapradeep/India_Equity_Scanner

sourceHugging Faceupdated 2mo agoView on Hugging Face
0likes
paper_trading.py515 linesDownload Raw Back to root
1"""2paper_trading.py — fully automatic paper trading across every screener3(Feature 3, extended). Positions open/close entirely driven by the scan4engine (see main.py's _execute_full_scan) — no manual trade-entry UI, no5auth-gating. Tables live on auth.AuthBase / auth_engine (the persistent Neon6Postgres instance already used for accounts + fundamentals), independent of7the scan DB's own DATABASE_URL.8 9Each screener gets the exit logic that ALREADY EXISTS for it, rather than an10invented one:11  - swing: stop/target/timeout bracket — mirrors backtest.py's12    simulate_swing_trades exactly (same constants, same stop-first same-bar13    conservatism).14  - smc:   the same bracket mechanics as swing (there's no existing SMC15    backtest to mirror — "SMC+VCP not replayed" per project history — so this16    reuses the one bracket convention already validated for swing), triggered17    when confluence_score newly crosses into the A+ grade (>=75).18  - momentum: membership-based — mirrors backtest.py's run_backtest momentum19    methodology exactly (hold while Minervini-passing, exit when it drops out20    of the template). No stop/target; those columns are null for this21    strategy on purpose, not a gap.22  - reversal: fixed-horizon — mirrors backtest.py's reversal event study,23    which measures forward returns at fixed horizons (5/10/20/60 sessions)24    from the qualifying day rather than a stop/target trade. Uses the 20-day25    ("1mo") horizon as the single close point for a real position.26"""27import logging28import math29from datetime import datetime, timezone30from typing import Optional31 32from sqlalchemy import Column, Integer, Float, String, DateTime, Index, text33from sqlalchemy.orm import Session34from fastapi import APIRouter35 36from auth import AuthBase, AuthSession, auth_engine37from backtest import SWING_MAX_HOLD38 39logger = logging.getLogger(__name__)40 41# Same Rs10,000-risk-per-trade convention already hardcoded in main.py's42# _get_stock_detail_inner (position_size_shares=int(10_000/risk_per_share)) —43# kept in sync deliberately so the site's "if you took this trade" number and44# the paper book's actual sizing agree. Only meaningful for bracket45# strategies (swing/smc), which have a real stop-loss to size risk against.46RISK_PER_TRADE_RS = 10_00047 48# Membership/horizon strategies (momentum/reversal) have no stop-loss to size49# risk against, so they use a flat notional allocation instead — same50# "portfolio size" ballpark as the position-size calculator's own default.51NOTIONAL_PER_TRADE_RS = 100_00052 53REVERSAL_HOLD_DAYS = 20   # the "1mo" horizon in backtest.py's HORIZONS tuple54SMC_ENTRY_GRADE_MIN = 75  # A+ grade threshold (see StockDetail.jsx's smcGrade)55 56STRATEGIES = ("swing", "smc", "momentum", "reversal")57 58 59class PaperPosition(AuthBase):60    __tablename__ = "paper_positions"61 62    id          = Column(Integer, primary_key=True, autoincrement=True)63    symbol      = Column(String(30), nullable=False)64    strategy    = Column(String(10), nullable=False, default="swing")65    entry_date  = Column(DateTime, nullable=False)66    entry_price = Column(Float, nullable=False)67    # Null for membership/horizon strategies (momentum/reversal) — there is68    # no stop-loss/target concept for those, not a missing value.69    stop_loss   = Column(Float, nullable=True)70    target_1    = Column(Float, nullable=True)71    target_2    = Column(Float, nullable=True)72    shares      = Column(Integer, nullable=False)73    status      = Column(String(10), nullable=False)  # "open" / "closed"74    exit_date   = Column(DateTime, nullable=True)75    exit_price  = Column(Float, nullable=True)76    # "target_1"/"target_2"/"stop"/"timeout" (swing/smc) or77    # "template_exit" (momentum) or "horizon_20d" (reversal)78    exit_reason = Column(String(20), nullable=True)79    pnl_pct     = Column(Float, nullable=True)80    r_multiple  = Column(Float, nullable=True)  # null for momentum/reversal (no risk basis)81 82    __table_args__ = (83        Index("ix_paper_pos_symbol_status", "symbol", "status"),84    )85 86    def __repr__(self) -> str:87        return f"<PaperPosition {self.symbol} strategy={self.strategy} status={self.status}>"88 89 90def init_paper_trading_db() -> None:91    AuthBase.metadata.create_all(bind=auth_engine)92    # create_all only creates missing TABLES — it never ALTERs an existing93    # one. `strategy` was added after this table may already have been94    # created live, so back-fill the column defensively; the try/except95    # (rather than dialect-specific "IF NOT EXISTS") keeps this portable96    # across the sqlite (local dev) / Postgres (prod) split every other97    # module in this codebase already has to handle.98    try:99        with auth_engine.begin() as conn:100            conn.execute(text(101                "ALTER TABLE paper_positions ADD COLUMN strategy VARCHAR(10) NOT NULL DEFAULT 'swing'"102            ))103        logger.info("[paper-trading] migrated: added strategy column")104    except Exception:105        pass  # column already exists — expected on every boot after the first106 107    # Same story for nullability: stop_loss/target_1/target_2 were108    # NOT NULL in the original (swing-only) schema. Changing the ORM109    # model to nullable=True for momentum/reversal (which have no110    # bracket) does NOT relax the constraint on a table that already111    # exists in Postgres — confirmed live 2026-08-07: every reversal AND112    # momentum insert (both send stop_loss=None) was failing with a real113    # NotNullViolation the whole time, silently caught by the non-fatal114    # scan-hook try/except, which is exactly what made this look like a115    # batching/parameter-limit problem before the real error was read116    # directly off HF's container logs. SQLite doesn't support ALTER117    # COLUMN DROP NOT NULL at all -- harmless here since a fresh local118    # sqlite db is already created from the current (nullable=True) model119    # and never needs this migration in the first place.120    for col in ("stop_loss", "target_1", "target_2"):121        try:122            with auth_engine.begin() as conn:123                conn.execute(text(f"ALTER TABLE paper_positions ALTER COLUMN {col} DROP NOT NULL"))124            logger.info("[paper-trading] migrated: %s now nullable", col)125        except Exception:126            pass  # already nullable, or dialect doesn't support ALTER COLUMN (sqlite)127 128 129def _size_by_risk(entry_price: float, stop_loss: float) -> int:130    if not (math.isfinite(entry_price) and math.isfinite(stop_loss)):131        return 0132    risk_per_share = entry_price - stop_loss133    if risk_per_share <= 0:134        return 0135    return int(RISK_PER_TRADE_RS / risk_per_share)136 137 138def _size_by_notional(entry_price: float) -> int:139    # isfinite excludes both NaN and inf -- a NaN close price compares False140    # against `<= 0` (NaN comparisons are always False in Python), so it141    # would otherwise slip past a plain `<= 0` guard and blow up `int()`142    # downstream instead of being skipped cleanly here.143    if not math.isfinite(entry_price) or entry_price <= 0:144        return 0145    return int(NOTIONAL_PER_TRADE_RS / entry_price)146 147 148def _open_symbols(db: Session, strategy: Optional[str] = None) -> set:149    q = db.query(PaperPosition.symbol).filter(PaperPosition.status == "open")150    if strategy:151        q = q.filter(PaperPosition.strategy == strategy)152    return {row.symbol for row in q.all()}153 154 155# ── swing + smc: bracket strategies (stop/target/timeout) ──────────────────156 157def open_new_positions(db: Session, swing_setups: list, strategy: str = "swing") -> int:158    """159    For each setup-like row where bars_since_signal == 0 (a brand-new160    qualifying signal, not a stock that's been qualifying for a while) AND no161    existing status="open" PaperPosition for that symbol+strategy, open one162    using that row's entry/stop/target1/target2 directly — no recomputation.163 164    `swing_setups` is a list of objects with .symbol/.bars_since_signal/165    .entry_price/.stop_loss/.target_1/.target_2 — either the real SwingSetup166    ORM rows (strategy="swing") or synthetic SimpleNamespace rows built for167    newly-A+ SMC signals (strategy="smc", see main.py's _smc_paper_candidates).168    """169    open_symbols = _open_symbols(db, strategy)170 171    opened = 0172    now = datetime.now(timezone.utc)173    for setup in swing_setups:174        if setup.bars_since_signal != 0:175            continue176        if setup.symbol in open_symbols:177            continue178        shares = _size_by_risk(setup.entry_price, setup.stop_loss)179        if shares <= 0:180            continue181        db.add(PaperPosition(182            symbol=setup.symbol, strategy=strategy,183            entry_date=now,184            entry_price=setup.entry_price,185            stop_loss=setup.stop_loss,186            target_1=setup.target_1,187            target_2=setup.target_2,188            shares=shares,189            status="open",190        ))191        open_symbols.add(setup.symbol)192        opened += 1193 194    if opened:195        db.commit()196        logger.info("[paper-trading:%s] opened %d new position(s)", strategy, opened)197    return opened198 199 200def check_and_close_positions(db: Session, universe_dfs: dict) -> int:201    """202    Checks every OPEN bracket-strategy position (swing or smc — anything with203    a real stop_loss) against that day's OHLC bar. Mirrors backtest.py's204    simulate_swing_trades exit semantics — same-bar stop-first conservatism205    (if a bar touches both stop and target, the stop is assumed to fill206    first), target check, SWING_MAX_HOLD-bar timeout — but as an incremental207    per-position check against live data, not a full historical replay.208 209    Positions with stop_loss=None (momentum/reversal — no bracket) are210    skipped here; see close_momentum_positions/close_reversal_positions.211 212    `universe_dfs` is the same {symbol_with_.NS: DataFrame} dict already213    fetched once per scan for the whole universe — no extra fetch.214    """215    open_positions = db.query(PaperPosition).filter(PaperPosition.status == "open").all()216    closed = 0217    now = datetime.now(timezone.utc)218 219    for pos in open_positions:220        if pos.stop_loss is None:221            continue  # membership/horizon strategy — handled elsewhere222 223        sym_ns = pos.symbol if pos.symbol.endswith(".NS") else f"{pos.symbol}.NS"224        df = universe_dfs.get(sym_ns)225        if df is None:226            df = universe_dfs.get(pos.symbol)227        if df is None or df.empty:228            continue229 230        entry_naive = pos.entry_date.replace(tzinfo=None) if pos.entry_date.tzinfo else pos.entry_date231        index_naive = df.index.tz_localize(None) if getattr(df.index, "tz", None) is not None else df.index232        bars_after_entry = df[index_naive > entry_naive]233        if bars_after_entry.empty:234            continue235 236        last_bar = bars_after_entry.iloc[-1]237        low = float(last_bar["low"])238        high = float(last_bar["high"])239        close = float(last_bar["close"])240        risk_per_share = pos.entry_price - pos.stop_loss241 242        exit_price = None243        exit_reason = None244 245        if low <= pos.stop_loss:246            exit_price, exit_reason = pos.stop_loss, "stop"247        elif pos.target_2 is not None and high >= pos.target_2:248            exit_price, exit_reason = pos.target_2, "target_2"249        elif pos.target_1 is not None and high >= pos.target_1:250            exit_price, exit_reason = pos.target_1, "target_1"251        elif len(bars_after_entry) >= SWING_MAX_HOLD:252            exit_price, exit_reason = close, "timeout"253 254        if exit_price is None:255            continue256 257        pos.status = "closed"258        pos.exit_date = now259        pos.exit_price = exit_price260        pos.exit_reason = exit_reason261        pos.pnl_pct = (exit_price - pos.entry_price) / pos.entry_price * 100262        pos.r_multiple = (263            (exit_price - pos.entry_price) / risk_per_share if risk_per_share > 0 else 0.0264        )265        closed += 1266 267    if closed:268        db.commit()269        logger.info("[paper-trading] closed %d bracket position(s)", closed)270    return closed271 272 273# ── momentum: membership-based (hold while Minervini-passing) ──────────────274 275def open_momentum_positions(db: Session, momentum_results: list, prior_passing_symbols: set) -> int:276    """277    `momentum_results` = this scan's raw MomentumData objects (scanner.py278    dataclass, already filtered to Minervini-passing only — this is the279    `momentum_results` list from scan_momentum's `(passing, rs_by_symbol,280    all_momentum_data)` return, NOT the MomentumResult ORM class; `.close`281    is the price field here, not `.close_price` — that name only exists on282    the ORM row main.py separately builds from this same data).283    Opens on a symbol's FIRST scan as a passer (not in prior_passing_symbols)284    — mirrors run_backtest's `entries` event (template turned True this bar).285    """286    open_symbols = _open_symbols(db, "momentum")287    opened = 0288    now = datetime.now(timezone.utc)289    for r in momentum_results:290        if r.symbol in prior_passing_symbols:291            continue  # already passing last scan — not a new entry event292        if r.symbol in open_symbols:293            continue294        shares = _size_by_notional(r.close)295        if shares <= 0:296            continue297        db.add(PaperPosition(298            symbol=r.symbol, strategy="momentum",299            entry_date=now, entry_price=r.close,300            shares=shares, status="open",301        ))302        open_symbols.add(r.symbol)303        opened += 1304    if opened:305        db.commit()306        logger.info("[paper-trading:momentum] opened %d new position(s)", opened)307    return opened308 309 310def close_momentum_positions(db: Session, current_passing_symbols: set, price_by_symbol: dict) -> int:311    """312    Closes the moment a symbol drops out of the Minervini-passing list —313    mirrors run_backtest's membership-lag exit. `price_by_symbol` is this314    scan's {symbol: close} for the WHOLE universe (not just passers) — a315    symbol that just dropped out is by definition no longer in316    current_passing_symbols, so its exit price has to come from the broader317    map, not the passing set.318    """319    open_positions = (320        db.query(PaperPosition)321        .filter(PaperPosition.status == "open", PaperPosition.strategy == "momentum")322        .all()323    )324    closed = 0325    now = datetime.now(timezone.utc)326    for pos in open_positions:327        if pos.symbol in current_passing_symbols:328            continue329        exit_price = price_by_symbol.get(pos.symbol, pos.entry_price)330        pos.status = "closed"331        pos.exit_date = now332        pos.exit_price = exit_price333        pos.exit_reason = "template_exit"334        pos.pnl_pct = (exit_price - pos.entry_price) / pos.entry_price * 100335        closed += 1336    if closed:337        db.commit()338        logger.info("[paper-trading:momentum] closed %d position(s)", closed)339    return closed340 341 342# ── reversal: fixed-horizon (20 trading days from the qualifying day) ──────343 344# The reversal watchlist can carry 400+ symbols; committing all of them as345# one bulk INSERT (confirmed live: a single 430-row / ~6000-parameter insert346# consistently failed against the Neon/PgBouncer pooler, then poisoned the347# session for every later query on it -- "session is in 'inactive' state"348# cascading to code that ran afterward on the same connection) is too much349# in one statement. Commit in small batches instead, so a single bad batch350# can't take out the whole run, and roll back on failure so the session351# stays usable for whatever runs next.352_REVERSAL_BATCH_SIZE = 25353 354 355def open_reversal_positions(db: Session, reversal_candidates: list) -> int:356    """357    Opens on a symbol's first appearance on the reversal watchlist this run.358    `reversal_candidates` rows come from scanner.scan_reversal_watchlist —359    objects with .symbol/.close/.days_above_200.360    """361    open_symbols = _open_symbols(db, "reversal")362    now = datetime.now(timezone.utc)363 364    candidates = []365    for rc in reversal_candidates:366        if rc.symbol in open_symbols:367            continue368        shares = _size_by_notional(rc.close)369        if shares <= 0:370            continue371        candidates.append(rc)372        open_symbols.add(rc.symbol)373 374    opened = 0375    for i in range(0, len(candidates), _REVERSAL_BATCH_SIZE):376        batch = candidates[i:i + _REVERSAL_BATCH_SIZE]377        for rc in batch:378            db.add(PaperPosition(379                symbol=rc.symbol, strategy="reversal",380                entry_date=now, entry_price=rc.close,381                shares=_size_by_notional(rc.close), status="open",382            ))383        try:384            db.commit()385            opened += len(batch)386        except Exception as exc:387            db.rollback()388            logger.warning(389                "[paper-trading:reversal] batch of %d failed, skipped (non-fatal): %s",390                len(batch), exc,391            )392 393    if opened:394        logger.info("[paper-trading:reversal] opened %d new position(s)", opened)395    return opened396 397 398def close_reversal_positions(db: Session, universe_dfs: dict, hold_days: int = REVERSAL_HOLD_DAYS) -> int:399    """Closes at the close price on the hold_days-th trading bar after entry —400    the fixed-horizon exit backtest.py's reversal event study measures401    against (the 20-session / "1mo" horizon), not a stop/target trade."""402    open_positions = (403        db.query(PaperPosition)404        .filter(PaperPosition.status == "open", PaperPosition.strategy == "reversal")405        .all()406    )407    closed = 0408    now = datetime.now(timezone.utc)409    for pos in open_positions:410        sym_ns = pos.symbol if pos.symbol.endswith(".NS") else f"{pos.symbol}.NS"411        df = universe_dfs.get(sym_ns)412        if df is None:413            df = universe_dfs.get(pos.symbol)414        if df is None or df.empty:415            continue416 417        entry_naive = pos.entry_date.replace(tzinfo=None) if pos.entry_date.tzinfo else pos.entry_date418        index_naive = df.index.tz_localize(None) if getattr(df.index, "tz", None) is not None else df.index419        bars_after_entry = df[index_naive > entry_naive]420        if len(bars_after_entry) < hold_days:421            continue422 423        exit_price = float(bars_after_entry.iloc[hold_days - 1]["close"])424        pos.status = "closed"425        pos.exit_date = now426        pos.exit_price = exit_price427        pos.exit_reason = f"horizon_{hold_days}d"428        pos.pnl_pct = (exit_price - pos.entry_price) / pos.entry_price * 100429        closed += 1430 431    if closed:432        db.commit()433        logger.info("[paper-trading:reversal] closed %d position(s)", closed)434    return closed435 436 437router = APIRouter(prefix="/api/paper-trading", tags=["paper-trading"])438 439 440@router.get("")441def get_paper_trading_summary(strategy: Optional[str] = None):442    db = AuthSession()443    try:444        q = db.query(PaperPosition).order_by(PaperPosition.entry_date)445        if strategy:446            q = q.filter(PaperPosition.strategy == strategy)447        positions = q.all()448        open_pos = [p for p in positions if p.status == "open"]449        closed_pos = [p for p in positions if p.status == "closed"]450 451        def _stats(rows: list) -> dict:452            total = len(rows)453            # r_multiple is None for momentum/reversal — fall back to pnl_pct454            # sign for win/loss so those strategies still get a real win rate455            # instead of silently reading as all-losses.456            def _is_win(p):457                return (p.r_multiple if p.r_multiple is not None else p.pnl_pct or 0) > 0458            wins = [p for p in rows if _is_win(p)]459            win_rate = (len(wins) / total * 100) if total else 0.0460            has_r = [p for p in rows if p.r_multiple is not None]461            gross_win = sum(p.r_multiple for p in has_r if p.r_multiple > 0)462            gross_loss = abs(sum(p.r_multiple for p in has_r if p.r_multiple < 0))463            profit_factor = (gross_win / gross_loss) if gross_loss > 0 else (999.0 if gross_win > 0 else 0.0)464            avg_r = (sum(p.r_multiple for p in has_r) / len(has_r)) if has_r else None465            return {466                "win_rate": round(win_rate, 1),467                "profit_factor": round(profit_factor, 2) if has_r else None,468                "total_trades": total,469                "avg_r": round(avg_r, 2) if avg_r is not None else None,470            }471 472        def to_dict(p: PaperPosition) -> dict:473            return {474                "symbol": p.symbol, "strategy": p.strategy,475                "entry_date": p.entry_date.isoformat() if p.entry_date else None,476                "entry_price": p.entry_price, "stop_loss": p.stop_loss,477                "target_1": p.target_1, "target_2": p.target_2, "shares": p.shares,478                "status": p.status,479                "exit_date": p.exit_date.isoformat() if p.exit_date else None,480                "exit_price": p.exit_price, "exit_reason": p.exit_reason,481                "pnl_pct": p.pnl_pct, "r_multiple": p.r_multiple,482            }483 484        # Equity curve: cumulative realized R-multiple walk over closed485        # bracket trades (momentum/reversal have no R basis, excluded from486        # the curve itself but still counted in stats/tables). Benchmark487        # left null — wiring a real NIFTY buy-hold series is a follow-up.488        equity_curve = []489        running = 100.0490        for p in closed_pos:491            if p.exit_date is None or p.r_multiple is None:492                continue493            running += p.r_multiple494            equity_curve.append({495                "date": p.exit_date.date().isoformat(),496                "strategy": round(running, 2),497                "benchmark": None,498            })499 500        by_strategy = {501            s: _stats([p for p in closed_pos if p.strategy == s])502            for s in STRATEGIES503            if any(p.strategy == s for p in positions)504        }505 506        return {507            "open": [to_dict(p) for p in open_pos],508            "closed": [to_dict(p) for p in closed_pos],509            "stats": _stats(closed_pos),510            "by_strategy": by_strategy,511            "equity_curve": equity_curve,512        }513    finally:514        db.close()515