Team Ai
Apppublic

Sumitx369/Paper_Trading_Backtesting

sourceHugging Faceupdated 4mo agoView on Hugging Face
2likes
test_quant_engine.py257 linesDownload Raw Back to tests
1"""Tests for the Quant-Core decision engine (S1..S9 -> Bayes -> gate -> sim)."""2from __future__ import annotations3 4from datetime import datetime, time, timedelta5 6import pandas as pd7import pytest8 9from app.services.engine import bayes, decision, ledger, learning, portfolio, simulator10from app.services.engine.contracts import (DecisionRecord, MarketRegime,11                                            ProbabilityBreakdown, SetupTrigger)12from app.services.engine.indicators import IntradayIndicators13from app.services.engine.setups import BarCtx, SetupConfig, s114 15 16def _frame(rows, day=2):17    idx = [datetime(2026, 6, day, 9, 15) + timedelta(minutes=5 * i) for i in range(len(rows))]18    return pd.DataFrame(rows, index=idx, columns=["open", "high", "low", "close", "volume"])19 20 21# ----------------------------- S1 golden file ------------------------------22def test_s1_fires_per_spec():23    # Rising green bars so EMAs sit below price by the decision candle (index 2).24    rows = [[100, 100.5, 99.8, 100.4, 1000],25            [100.4, 101.0, 100.3, 100.9, 1000],26            [101.0, 101.8, 100.95, 101.7, 1500]]  # decision candle: green, new high, tight stop27    df = _frame(rows)28    ind = IntradayIndicators.compute(df)29    cfg = SetupConfig(first_candle_index=2)30    ctx = BarCtx(bars=df, ind=ind, pdh=100.6, pdl=99.0, prev_close=100.0, i=2, cfg=cfg)31    t = s1(ctx, "TEST.NS")32    assert t.fired and t.side == "LONG" and t.entry_type == "BUY_STOP"33    assert t.entry_price == pytest.approx(101.8 + 0.05, abs=1e-6)   # day-high + tick34    assert t.stop_price == pytest.approx(100.95 - 0.05, abs=1e-6)   # signal low - tick35    assert t.valid_until == time(10, 0)36    assert all(t.conditions.values())37 38 39def test_s1_sl_too_wide_invalidates():40    # Huge candle range -> stop >1% from entry -> SL_TOO_WIDE, not fired.41    rows = [[100, 100.5, 99.8, 100.4, 1000],42            [100.4, 101, 100.3, 100.9, 1000],43            [100.9, 105.0, 98.0, 104.0, 1500]]44    df = _frame(rows)45    ind = IntradayIndicators.compute(df)46    cfg = SetupConfig(first_candle_index=2)47    ctx = BarCtx(bars=df, ind=ind, pdh=100.6, pdl=99, prev_close=100, i=2, cfg=cfg)48    t = s1(ctx, "TEST.NS")49    assert not t.fired and t.invalidation == "SL_TOO_WIDE"50 51 52# ----------------------------- Bayesian math -------------------------------53def test_bayes_odds_math():54    model = bayes.BayesModel()  # fresh seed, prior S1 = 55/4555    conds = {"above_pdh": True, "above_both_emas": True, "green": True,56             "rel_volume_gt_1_2": False}57    out = bayes.score("S1", conds, model=model)58    # manual odds: prior 56/102... compute from model59    prior = model.prior_winrate("S1")60    odds = prior / (1 - prior)61    for c in ("above_pdh", "above_both_emas", "green"):62        odds *= model.lr("S1", c)63    expect = odds / (1 + odds)64    assert out["p_bayes"] == pytest.approx(expect, abs=1e-4)65    assert out["p_bayes"] > prior  # bullish conditions lift the probability66 67 68# ----------------------------- sizing --------------------------------------69def test_sizing_under_risk_cap():70    sd = portfolio.size(100_000, 10_000, entry=100.0, stop=98.0, size_band="FULL")71    assert sd.qty > 0 and sd.risk_rupees <= 10_000 + 1e-672    half = portfolio.size(100_000, 10_000, 100.0, 98.0, "HALF")73    assert half.qty <= sd.qty74 75 76# ----------------------------- simulator -----------------------------------77def test_simulator_long_fill_and_squareoff():78    trig = SetupTrigger(symbol="X", setup_id="S1", fired=True, side="LONG",79                        entry_type="BUY_STOP", entry_price=101.0, stop_price=99.0,80                        valid_until=time(15, 0))81    bars = _frame([[100, 100.5, 99.5, 100.2, 1000],   # trigger bar context (after)82                   [100.3, 101.5, 100.2, 101.2, 1000],  # high>=101 -> fill83                   [101.2, 102.0, 101.0, 101.8, 1000]])  # square-off at close 101.884    pos = simulator.simulate(trig, qty=100, bars_after=bars, fee_bps=0, slippage_bps=0)85    assert pos.exit_reason == "square_off"86    assert pos.entry_price == pytest.approx(101.0)87    assert pos.pnl == pytest.approx((101.8 - 101.0) * 100, abs=1e-6)88 89 90def test_simulator_no_fill_when_untriggered():91    trig = SetupTrigger(symbol="X", setup_id="S1", fired=True, side="LONG",92                        entry_type="BUY_STOP", entry_price=200.0, stop_price=198.0,93                        valid_until=time(15, 0))94    bars = _frame([[100, 101, 99, 100.5, 1000], [100.5, 101, 100, 100.8, 1000]])95    pos = simulator.simulate(trig, 100, bars)96    assert pos.exit_reason == "no_fill" and pos.qty == 100 and pos.pnl == 0.097 98 99# ------------------- risk floors: min-SL widen + trailing profit-lock -------100def _long_trig(entry=100.0, stop=99.5):101    return SetupTrigger(symbol="X", setup_id="S1", fired=True, side="LONG",102                        entry_type="BUY_STOP", entry_price=entry, stop_price=stop,103                        valid_until=time(15, 0))104 105 106def test_min_sl_floor_widens_a_tight_stop():107    # 0.5% setup stop, but the user/agent floor is 2% -> stop widens to 98.108    bars = _frame([[99.8, 100.2, 99.7, 100.0, 1000],109                   [100.0, 101.0, 99.9, 100.5, 1000],110                   [100.5, 101.0, 100.4, 100.8, 1000]])111    pos = simulator.simulate(_long_trig(), 100, bars, fee_bps=0, slippage_bps=0,112                             min_sl_pct=2.0, min_profit_pct=3.0, trail=True)113    assert pos.stop_price == pytest.approx(98.0, abs=1e-6)114 115 116def test_trailing_stop_banks_at_least_min_profit():117    # Peak 106 then pulls back: trailing locks >= min_profit_pct (3%).118    bars = _frame([[99.8, 100.2, 99.7, 100.0, 1000],119                   [100.0, 103.0, 100.0, 102.8, 1000],   # below activation (105)120                   [102.8, 106.0, 102.5, 105.5, 1000],   # peak 106 -> stop trails to 104121                   [105.5, 105.6, 103.0, 103.2, 1000]])  # low 103 <= 104 -> trailing exit122    pos = simulator.simulate(_long_trig(), 100, bars, fee_bps=0, slippage_bps=0,123                             min_sl_pct=2.0, min_profit_pct=3.0, trail=True)124    assert pos.exit_reason == "trailing_stop"125    assert pos.return_pct >= 3.0          # the profit floor is honoured126    assert pos.pnl == pytest.approx(400.0, abs=1e-6)127    assert pos.stop_price > 98.0          # the RATCHETED stop is recorded, not the floor128 129 130def test_profit_floor_holds_net_of_fees_and_slippage():131    # With real fees+slippage, a trailed exit must STILL net >= min_profit_pct132    # (the lock target is padded by the round-trip cost).133    bars = _frame([[99.8, 100.2, 99.7, 100.0, 1000],134                   [100.0, 103.0, 100.0, 102.8, 1000],135                   [102.8, 107.0, 102.5, 106.5, 1000],136                   [106.5, 106.6, 103.0, 103.2, 1000]])137    pos = simulator.simulate(_long_trig(), 100, bars, fee_bps=5, slippage_bps=5,138                             min_sl_pct=2.0, min_profit_pct=2.0, trail=True)139    assert pos.exit_reason == "trailing_stop"140    assert pos.return_pct >= 2.0          # floor holds AFTER costs, not just gross141 142 143def test_min_sl_floor_keeps_an_already_wider_stop():144    # A stop already 5% from entry must NOT be tightened to the 2% floor145    # (the floor is a minimum distance, never a cap).146    pos = simulator.simulate(_long_trig(entry=100.0, stop=95.0), 100,147                             _frame([[99.8, 100.2, 99.7, 100.0, 1000],148                                     [100.0, 101.0, 99.9, 100.5, 1000],149                                     [100.5, 101.0, 100.4, 100.8, 1000]]),150                             fee_bps=0, slippage_bps=0, min_sl_pct=2.0,151                             min_profit_pct=3.0, trail=True)152    assert pos.stop_price == pytest.approx(95.0)153 154 155def test_below_activation_keeps_floored_stop_and_squares_off():156    # Never clears activation (105) -> stop stays at the 2% floor, EOD square-off.157    bars = _frame([[99.8, 100.2, 99.7, 100.0, 1000],158                   [100.0, 104.0, 100.0, 103.8, 1000],159                   [103.8, 104.2, 103.5, 104.0, 1000]])160    pos = simulator.simulate(_long_trig(), 100, bars, fee_bps=0, slippage_bps=0,161                             min_sl_pct=2.0, min_profit_pct=3.0, trail=True)162    assert pos.exit_reason == "square_off" and pos.stop_price == pytest.approx(98.0)163 164 165def test_risk_floors_default_off_is_legacy():166    # No risk params -> identical to before (static setup stop, square-off).167    bars = _frame([[99.8, 100.2, 99.7, 100.0, 1000],168                   [100.0, 101.0, 99.9, 100.5, 1000],169                   [100.5, 101.0, 100.4, 100.8, 1000]])170    pos = simulator.simulate(_long_trig(), 100, bars, fee_bps=0, slippage_bps=0)171    assert pos.exit_reason == "square_off" and pos.stop_price == pytest.approx(99.5)172 173 174# ----------------------------- decision gate -------------------------------175def _regime(active=("S1",)):176    return MarketRegime(state="TRENDING", active_setups=list(active),177                        threshold_delta={s: 0.0 for s in active}, risk_scalar=1.0)178 179 180def _trigger():181    return SetupTrigger(symbol="X", setup_id="S1", fired=True, side="LONG",182                        entry_type="BUY_STOP", entry_price=101.0, stop_price=100.0)183 184 185def test_gate_buys_when_prob_clears_threshold():186    prob = ProbabilityBreakdown(p_final=0.80, p_raw=0.80, p_bayes=0.80)187    d = decision.decide(_trigger(), prob, _regime())188    assert d.action == "BUY" and d.size_band in ("FULL", "HALF")189 190 191def test_gate_skips_when_prob_below_threshold():192    prob = ProbabilityBreakdown(p_final=0.40, p_raw=0.40, p_bayes=0.40)193    d = decision.decide(_trigger(), prob, _regime())194    assert d.action == "SKIP" and "BELOW_THRESHOLD" in d.reason_codes195 196 197def test_regime_no_longer_hard_blocks_setup():198    # Regime no longer disables setups — every fired signal is judged on its199    # probability; a high-probability signal trades regardless of active_setups.200    prob = ProbabilityBreakdown(p_final=0.90, p_raw=0.9, p_bayes=0.9)201    d = decision.decide(_trigger(), prob, _regime(active=("S2",)))202    assert d.action == "BUY"203 204 205# ----------------------------- ledger + learning ---------------------------206def test_ledger_hash_chain_and_tamper():207    from app.database import SessionLocal208    from app.models import DecisionLog209    key = "test_ledger_user"210    ledger.clear(key)211    for i in range(3):212        ledger.append(key, DecisionRecord(record_id=0, ts=datetime.now(), symbol="X",213                                          setup_id="S1"))214    assert ledger.verify(key)["ok"] is True215    # tamper: edit a committed row's payload directly in the DB216    with SessionLocal() as db:217        row = db.query(DecisionLog).filter_by(user_key=key, seq=2).one()218        p = dict(row.payload); p["symbol"] = "HACKED"; row.payload = p219        db.commit()220    assert ledger.verify(key)["ok"] is False221    ledger.clear(key)222 223 224def test_verify_ok_after_head_purge():225    """A retention purge that removes the GENESIS row must NOT look like tampering."""226    from app.database import SessionLocal227    from app.models import DecisionLog228    key = "test_purge_user"229    ledger.clear(key)230    for _ in range(4):231        ledger.append(key, DecisionRecord(record_id=0, ts=datetime.now(), symbol="X", setup_id="S1"))232    # delete the oldest row (seq=1) as a retention purge would233    with SessionLocal() as db:234        db.query(DecisionLog).filter_by(user_key=key, seq=1).delete()235        db.commit()236    assert ledger.verify(key)["ok"] is True   # chain still valid from the new head237    ledger.clear(key)238 239 240def test_eod_training_updates_priors():241    key = "test_train_user"242    ledger.clear(key)243    bayes.save_model(bayes.BayesModel())  # reset to seed for an isolated assertion244    # two resolved wins on S3245    for _ in range(2):246        rec = DecisionRecord(record_id=0, ts=datetime.now(), symbol="X", setup_id="S3",247                             probability={"conditions": {"above_pdh": True, "green": True}},248                             fill={"is_open": False, "exit_reason": "square_off", "pnl": 500})249        ledger.append(key, rec)250    before = bayes.load_model().prior_winrate("S3")251    report = learning.train(key)252    after = bayes.load_model().prior_winrate("S3")253    assert report["samples"] == 2 and report["wins"] == 2254    assert after > before  # wins pushed the posterior up (idempotent: seed + counts)255    ledger.clear(key)256    bayes.save_model(bayes.BayesModel())  # leave the model at seed257