diff --git a/app/api/positions.py b/app/api/positions.py index ff9e5c7..70cf2be 100644 --- a/app/api/positions.py +++ b/app/api/positions.py @@ -316,6 +316,9 @@ async def manual_close( # close_and_finalize handles BOTH paper and live branches internally. from app.services.bot_engine import close_and_finalize leverage = trade.leverage if trade.leverage is not None else sub.leverage + # force=True so an explicit user close still works on a released trade + # (the released_at guard exists to stop AUTOMATED paths from racing + # past a release; a manual click is the user being explicit). await close_and_finalize( trade_id=trade.id, api_key=api_key, @@ -323,6 +326,7 @@ async def manual_close( asset=trade.asset, wallet=wallet, reason="manual", + force=True, ) closed = (await db.execute(select(BotTrade).where(BotTrade.id == trade_id))).scalar_one() diff --git a/app/services/bot_engine.py b/app/services/bot_engine.py index e6c37b0..990a889 100644 --- a/app/services/bot_engine.py +++ b/app/services/bot_engine.py @@ -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)) diff --git a/app/services/recovery.py b/app/services/recovery.py index cb0288c..fdf293b 100644 --- a/app/services/recovery.py +++ b/app/services/recovery.py @@ -90,6 +90,30 @@ async def rehydrate_open_trades() -> None: select(Post).where(Post.id == t.trigger_post_id) ) trigger_post = post_res.scalar_one_or_none() + # System-2 detection. Two paths reach here: + # (a) Auto-opened (legacy): trigger_post.category is set + # to e.g. "btc_bottom_reversal_long" → look up via + # the post's category. + # (b) Adopted (current): trigger_post_id is NULL because + # the user opened on HL manually. The trade IS a + # System-2 trade — sys2_mode is non-NULL on the row — + # but it has no source post to read category from. + # Fall back to the adopted-category constant so the + # ladder still rebuilds correctly on restart. + # + # Bug this guards: before this fix, adopted trades restarted + # with stop_ladder=None / derisk=None / addon=None / + # peak_trail=None — the entire System-2 ladder logic was + # silently lost across any backend restart, downgrading the + # trade to a plain stop_loss + max_hold position. + _cat_for_ladders = ( + (trigger_post.category if trigger_post else None) + or ( + "btc_bottom_reversal_long" + if t.sys2_mode is not None else None + ) + ) + _stop_ladder = _get_stop_ladder(_cat_for_ladders) register_trade( trade_id=t.id, wallet=t.wallet_address, @@ -109,34 +133,21 @@ async def rehydrate_open_trades() -> None: else (trigger_post.invalidation_price if trigger_post else None) ), min_hold_until_ts=t.eff_min_hold_until_ts, - stop_ladder=_get_stop_ladder( - trigger_post.category if trigger_post else None - ), - # Restore staged de-risk: rebuild the ladder from the trade's - # FROZEN leverage and skip steps already executed before the - # restart (derisk_steps_done is persisted on the row). - # Restore staged de-risk/pyramid/peak-trail with the trade's - # FROZEN risk mode + leverage, skipping steps already executed - # before the restart (persisted on the row). + stop_ladder=_stop_ladder, derisk_ladder=( _sys2_derisk_ladder(t.leverage or 1, t.sys2_mode) - if _get_stop_ladder(trigger_post.category if trigger_post else None) - else None + if _stop_ladder else None ), derisk_done=(t.derisk_steps_done or 0), addon_ladder=( _sys2_addon_ladder(t.sys2_mode) - if _get_stop_ladder(trigger_post.category if trigger_post else None) - else None + if _stop_ladder else None ), addon_done=(t.addon_steps_done or 0), - # Restore the monotonic peak so a pyramided / in-profit trade - # doesn't fall back to the underwater de-risk regime on restart. initial_peak=(t.peak_gain_pct or 0.0), peak_trail=( _sys2_peak_trail(t.sys2_mode) - if _get_stop_ladder(trigger_post.category if trigger_post else None) - else None + if _stop_ladder else None ), grow_mode=bool(getattr(t, "grow_mode", False)), )