sridattapradeep/India_Equity_Scanner
0
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 