monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x11cd7f230> def test_lock_on_flat_account_arms_day_lock(monkeypatch): """Bug fbb62441d227 (operator 2026-07-30 'i locked, and many position were sold without vetoed'): the LOCK half on a flat account is NO LONGER a no-op — it arms the account-scoped day-lock so the NEXT entry on this account is protected from the moment it lands (the entire 13:43Z lock-no-leg gap the operator complained about). No PositionLocks row is written (no symbol to key on), but the day-lock IS — that's the durable veto. UNLOCK still clears both.""" import rtrader.database as database monkeypatch.setattr(database, "lock_position", lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not lock flat"))) monkeypatch.setattr(mse, "_broker_for", lambda a: None) # Track the day-lock writes/clears through a stub DB + collection (matches the production # wiring: set_providers(db_factory=lambda: db); db[name] returns the collection). class _Coll: def __init__(self): self.docs = {} def update_one(self, q, update, upsert=False): key = (q["account_nick"], q["trading_day"]) doc = self.docs.get(key, {}) doc.update(update["$set"]) self.docs[key] = doc class _R: upserted_id = None return _R() def find_one(self, q): return self.docs.get((q["account_nick"], q["trading_day"])) def delete_one(self, q): self.docs.pop((q["account_nick"], q["trading_day"]), None) class _R: deleted_count = 1 return _R() class _FakeDB: def __init__(self): self.colls = {} def __getitem__(self, name): return self.colls.setdefault(name, _Coll()) db = _FakeDB() mse.set_providers(broker_provider=lambda a: None, db_factory=lambda: db) monkeypatch.setattr(mse, "_record_decision", lambda d: None) monkeypatch.setattr(mse, "_vehicle", lambda: ("TNA", "TZA")) monkeypatch.setattr(mse, "_held_leg_3way", lambda broker, bull, bear, retry_unknown=True: (None, None, 0, "flat")) > dec = mse.lock_sleeve_leg("miaoyong", DAY, NOW, lock=True, hard=True) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ tests/services/test_permanent_lock.py:292: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ account_nick = 'miaoyong', day = datetime.date(2026, 7, 24) now_et = datetime.datetime(2026, 7, 24, 15, 56, tzinfo=zoneinfo.ZoneInfo(key='America/New_York')) def lock_sleeve_leg(account_nick: str, day: _date, now_et: datetime, *, lock: bool, hard: bool = False, origin: str = MANUAL_ORIGIN) -> Dict[str, Any]: """OPERATOR LOCK/UNLOCK of ONE account's CURRENTLY-HELD sleeve leg (the 🔒 LOCK ALL button). Writes TWO lock artefacts so the veto survives an engine-driven PositionLocks rewrite (bug fbb62441d227 — operator 2026-07-30 "i locked, and many position were sold without vetoed"): 1. ``ManualHoldLocksDay`` — the ACCOUNT-SCOPED, DAY-PERSISTENT veto (``manual_hold_active``'s tier-1 source of truth). Keyed by ``(account_nick, trading_day)``; ARMABLE WHILE FLAT; survives boost's post-buy ``_lock_position`` overwrite, the sleeve's own entry bookkeeping, and the EOD force-unlock. 2. ``PositionLocks{reason:"manual"}`` — the per-symbol row the /positions padlock still writes (legacy tier-2 fallback + dashboard visibility). The day-lock tier is what matters; this row is a courtesy. A lock whose reason is not the sleeve's own 'market_state_etf' suppresses the crest profit-take and the rollover-flip flatten. There is no second lock mechanism and no new veto — this is only a control for the existing one, reachable from the panel the operator actually runs the sleeve from (bug c55ff0c207). Scope is the SLEEVE LEG ONLY (operator 2026-07-16): the held TNA/TZA position, never the account's other holdings. The DAY-LOCK armable-while-flat path is the explicit exception (bug fbb62441d227 spec: locked means the engine may not sell this account today regardless of position changes): when the account is FLAT and the operator clicks LOCK, the day-lock is STILL written so the next entry is protected from the moment it lands. Deliberately does NOT take ``execute``: a lock places no broker order. It is a DB veto flag, so it must work even when MARKET_STATE_ETF_EXECUTE_BROKER is off (DRY-RUN) — an operator locking a position in a log-only window still means "do not sell this". Unlocking is the exact inverse and hands the leg back to normal sleeve management. LEG DETECTION IS 3-WAY (bug e0c4a45ec60c) — the bulk positions() read this used to do laundered a degraded/None read into "flat" via ``or []``, so a blipped read answered an operator's 🔒 LOCK with 'no_leg' ("nothing to protect") on a leg that WAS held — the sleeve then stayed free to sell the very position the operator had just moved to protect. That is strictly worse than the sibling BOOST defect it shares a button row with (bug 597b46e6c0cd): a dropped boost costs an unplaced order, a dropped LOCK silently withdraws protection while reporting success-shaped no-op. UNKNOWN CROSS-CHECKS THE LEDGER (as boost does, and unlike trim_account): a lock places NO order, so acting on a ledger-only leg is cheap and one-directional — worst case we write a veto row for a symbol that turns out to be flat, which vetoes nothing. Weigh that against dropping a real lock and the fallback is obviously right. A definitive 404 'flat' still does NOT get the second opinion. DAY-LOCK ARMED ON FLAT (bug fbb62441d227 spec): when ``lock=True`` and the account is confirmed FLAT, the function STILL writes the ManualHoldLocksDay row (action='armed_day_lock') so the next entry on this account is protected. The legacy PositionLocks row is intentionally not written — there is no symbol to key it on, and a sym-less row would be a bug surface. UNLOCK on a flat account STILL clears the day-lock and any leftover PositionLocks rows (live 2026-07-24 PERM UNLOCK incident: an operator closed three legs on the website and three PERM UNLOCK clicks each returned no_leg leaving stale hard rows behind — clearing foreign rows for BOTH vehicles regardless of held state). """ from rtrader.database import lock_position bull, bear = _vehicle() dbf = _db_factory() > broker = _broker_for(account_nick, book=exit_book_for(account_nick)) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ E TypeError: test_lock_on_flat_account_arms_day_lock..() got an unexpected keyword argument 'book' rtrader/services/market_state_etf.py:4592: TypeError