| 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.<locals>.<lambda>() got an unexpected keyword argument 'book'
|
|
|
| rtrader/services/market_state_etf.py:4592: TypeError
|