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
}
/>