fix(trading): close 3 race / lifecycle bugs in adopt-then-release path

1. recovery.py rehydrated adopted trades with no System-2 ladder
   ─────────────────────────────────────────────────────────────────
   On backend restart, recovery resolved stop_ladder / derisk_ladder /
   addon_ladder / peak_trail from trigger_post.category. Adopted trades
   have trigger_post_id=NULL (the user opened on HL manually, there's no
   source post) so all four resolved to None and the trade restarted as
   a vanilla "stop_loss + max_hold" position — the entire System-2 ladder
   logic silently disappeared across any restart.

   Fix: detect System-2 from the row itself (sys2_mode IS NOT NULL falls
   back to the adopted-category constant), then rebuild the full ladder
   set from frozen leverage + mode. derisk_steps_done / addon_steps_done /
   peak_gain_pct on the row let us pick up exactly where we left off.

2. close_and_finalize raced with release_management
   ─────────────────────────────────────────────────────────────────
   on_price_tick snapshots _watched, evaluates per trade, appends to a
   triggered list, then spawns _fire_close as an async task. If
   release_management runs between the snapshot and _fire_close firing,
   close_and_finalize would still close the HL position the user just
   took back manual control of.

   Fix: atomic claim now requires released_at IS NULL by default. New
   `force=True` parameter on close_and_finalize bypasses the guard for
   the explicit user-close API (manual_close passes it) so a deliberate
   user click still works on a released trade.

3. partial_derisk + pyramid_add raced with release_management
   ─────────────────────────────────────────────────────────────────
   Same race shape: a price tick captured before release could still
   trigger a partial reduce-only close, or pyramid INTO a position the
   user is now driving themselves.

   Fix: both functions return idempotent-success if released_at IS NOT
   NULL — match the closed_at handling we already had.

Tests still 64/64 + preflight + smoke all green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-26 13:18:48 +08:00
parent 3754d2caf8
commit 7599d4952d
3 changed files with 61 additions and 21 deletions
+29 -4
View File
@@ -668,6 +668,11 @@ async def partial_derisk(
trade = row.scalar_one_or_none()
if trade is None or trade.closed_at is not None:
return True # gone / already fully closed — nothing to do
if trade.released_at is not None:
# User took back manual control between tick capture and
# execution — do NOT partially reduce a position they're
# now driving themselves.
return True
if (trade.derisk_steps_done or 0) > step_idx:
return True # this step already executed (idempotent)
if (trade.derisk_steps_done or 0) != step_idx:
@@ -757,6 +762,10 @@ async def pyramid_add(
trade = row.scalar_one_or_none()
if trade is None or trade.closed_at is not None:
return (True, None, None)
if trade.released_at is not None:
# User took back manual control — never pyramid into a
# position they're now managing themselves.
return (True, None, None)
if (trade.derisk_steps_done or 0) > 0:
return (True, None, None) # went underwater — no pyramiding
if (trade.addon_steps_done or 0) > step_idx:
@@ -840,28 +849,44 @@ async def close_and_finalize(
asset: str,
wallet: str,
reason: str = "max_hold",
force: bool = False,
) -> None:
"""
Close a live HL position and write exit_price/pnl to BotTrade.
Race-safe: a conditional UPDATE `closed_at = now WHERE closed_at IS NULL`
lets exactly one caller win; losers return silently. Also wraps in a
per-trade asyncio.Lock to prevent us even *attempting* the HL API call twice.
`force=True` overrides the released_at guard — only the explicit user
close path (manual_close API) should pass it. Automated paths (tp/sl
monitor, max-hold, time-stop, reconciler) must respect release.
"""
async with _lock_for(trade_id):
async with AsyncSessionLocal() as db:
try:
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
# Atomic claim: only one caller sets closed_at.
claim = await db.execute(
# Atomic claim: only one caller sets closed_at. For automated
# callers, ALSO require released_at IS NULL — a price tick
# captured BEFORE release_management ran would otherwise be
# able to close an HL position the user just took back
# control of. The manual user-close endpoint sets force=True
# because the user explicitly wants the position gone
# regardless of release state.
claim_stmt = (
update(BotTrade)
.where(BotTrade.id == trade_id)
.where(BotTrade.closed_at.is_(None))
.values(closed_at=now_naive)
)
if not force:
claim_stmt = claim_stmt.where(BotTrade.released_at.is_(None))
claim = await db.execute(claim_stmt.values(closed_at=now_naive))
await db.commit()
if claim.rowcount == 0:
return # someone else closed it
# Either someone else closed it, or (automated path) the
# user released it between the price-tick capture and
# this close attempt. Either way: bail without touching HL.
return
# Reload the row we just claimed
result = await db.execute(select(BotTrade).where(BotTrade.id == trade_id))