diff --git a/app/schemas/paper_trade.py b/app/schemas/paper_trade.py index ae71568..f0ea23d 100644 --- a/app/schemas/paper_trade.py +++ b/app/schemas/paper_trade.py @@ -53,3 +53,7 @@ class PaperTradeResponse(BaseModel): # when the trailing exit policy is active. trailing_stop: float | None = None trailing_distance_pct: float | None = None + # Trading sessions represented by post-entry OHLCV bars. These are populated + # only while the active exit policy has a max-hold rule. + sessions_held: int | None = None + sessions_remaining: int | None = None diff --git a/app/services/paper_trade_service.py b/app/services/paper_trade_service.py index e2de89d..ed5c030 100644 --- a/app/services/paper_trade_service.py +++ b/app/services/paper_trade_service.py @@ -352,6 +352,7 @@ def _to_dict( current_price: float | None, benchmark_closes: dict[date, float] | None = None, trailing: tuple[float, float | None] | None = None, + holding_sessions: tuple[int, int] | None = None, ) -> dict: # For open trades, mark to market; for closed, the realized exit price. ref = current_price if trade.status == "open" else trade.close_price @@ -395,6 +396,8 @@ def _to_dict( "fill_mode": trade.fill_mode, "trailing_stop": trailing[0] if trailing else None, "trailing_distance_pct": trailing[1] if trailing else None, + "sessions_held": holding_sessions[0] if holding_sessions else None, + "sessions_remaining": holding_sessions[1] if holding_sessions else None, } @@ -435,6 +438,26 @@ async def list_trades( # Current trailing-stop level + distance for open trades (when a trailing # policy is active). policy = await get_exit_policy(db) + holding_sessions: dict[int, tuple[int, int]] = {} + if policy["mode"] in ("time", "atr_trailing"): + hold_days = int(policy["hold_days"]) + for t, _ in rows: + if t.status != "open": + continue + held = int( + ( + await db.execute( + select(func.count()) + .select_from(OHLCVRecord) + .where( + OHLCVRecord.ticker_id == t.ticker_id, + OHLCVRecord.date > t.opened_at.date(), + ) + ) + ).scalar_one() + ) + holding_sessions[t.id] = (held, max(0, hold_days - held)) + trailing_info: dict[int, tuple[float, float | None]] = {} if policy["mode"] == "trailing": trail_frac = policy["trailing_pct"] / 100.0 @@ -483,7 +506,14 @@ async def list_trades( trailing_info[t.id] = (level, dist) return [ - _to_dict(t, sym, prices.get(t.ticker_id), benchmark_closes, trailing_info.get(t.id)) + _to_dict( + t, + sym, + prices.get(t.ticker_id), + benchmark_closes, + trailing_info.get(t.id), + holding_sessions.get(t.id), + ) for t, sym in rows ] diff --git a/frontend/src/components/dashboard/OpenTradesPanel.tsx b/frontend/src/components/dashboard/OpenTradesPanel.tsx index 8ade57b..4862eba 100644 --- a/frontend/src/components/dashboard/OpenTradesPanel.tsx +++ b/frontend/src/components/dashboard/OpenTradesPanel.tsx @@ -22,6 +22,21 @@ function pnlColor(v: number): string { return 'text-gray-300'; } +function maxHoldText(trade: PaperTrade, compact = false): string | null { + const remaining = trade.sessions_remaining; + if (remaining == null) return null; + if (remaining <= 0) return 'exits today'; + if (compact) return `${remaining} ${remaining === 1 ? 'session' : 'sessions'} left`; + const held = trade.sessions_held ?? 0; + return `${held} held · ${remaining} remaining`; +} + +function maxHoldColor(trade: PaperTrade): string { + return trade.sessions_remaining != null && trade.sessions_remaining <= 5 + ? 'text-amber-300' + : 'text-gray-400'; +} + function DirTag({ direction }: { direction: string }) { const isLong = direction === 'long'; return ( @@ -116,6 +131,13 @@ function TradeDetail({ trade, exitLabel, exitMode, atrMultiplier, trailingPct, o } /> + {maxHoldText(trade) && ( + + )}