feat: show max-hold session countdown

This commit is contained in:
2026-08-03 23:55:30 +02:00
parent 3a6900d45a
commit d431ee283d
5 changed files with 142 additions and 4 deletions
+4
View File
@@ -53,3 +53,7 @@ class PaperTradeResponse(BaseModel):
# when the trailing exit policy is active. # when the trailing exit policy is active.
trailing_stop: float | None = None trailing_stop: float | None = None
trailing_distance_pct: 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
+31 -1
View File
@@ -352,6 +352,7 @@ def _to_dict(
current_price: float | None, current_price: float | None,
benchmark_closes: dict[date, float] | None = None, benchmark_closes: dict[date, float] | None = None,
trailing: tuple[float, float | None] | None = None, trailing: tuple[float, float | None] | None = None,
holding_sessions: tuple[int, int] | None = None,
) -> dict: ) -> dict:
# For open trades, mark to market; for closed, the realized exit price. # For open trades, mark to market; for closed, the realized exit price.
ref = current_price if trade.status == "open" else trade.close_price ref = current_price if trade.status == "open" else trade.close_price
@@ -395,6 +396,8 @@ def _to_dict(
"fill_mode": trade.fill_mode, "fill_mode": trade.fill_mode,
"trailing_stop": trailing[0] if trailing else None, "trailing_stop": trailing[0] if trailing else None,
"trailing_distance_pct": trailing[1] 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 # Current trailing-stop level + distance for open trades (when a trailing
# policy is active). # policy is active).
policy = await get_exit_policy(db) 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]] = {} trailing_info: dict[int, tuple[float, float | None]] = {}
if policy["mode"] == "trailing": if policy["mode"] == "trailing":
trail_frac = policy["trailing_pct"] / 100.0 trail_frac = policy["trailing_pct"] / 100.0
@@ -483,7 +506,14 @@ async def list_trades(
trailing_info[t.id] = (level, dist) trailing_info[t.id] = (level, dist)
return [ 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 for t, sym in rows
] ]
@@ -22,6 +22,21 @@ function pnlColor(v: number): string {
return 'text-gray-300'; 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 }) { function DirTag({ direction }: { direction: string }) {
const isLong = direction === 'long'; const isLong = direction === 'long';
return ( return (
@@ -116,6 +131,13 @@ function TradeDetail({ trade, exitLabel, exitMode, atrMultiplier, trailingPct, o
} }
/> />
<Detail label="exit rule" value={exitLabel ?? 'target/stop'} /> <Detail label="exit rule" value={exitLabel ?? 'target/stop'} />
{maxHoldText(trade) && (
<Detail
label="max hold"
value={maxHoldText(trade)}
valueClass={maxHoldColor(trade)}
/>
)}
<div className="flex items-end"> <div className="flex items-end">
<button <button
onClick={onClose} onClick={onClose}
@@ -173,13 +195,14 @@ export function OpenTradesPanel() {
const trailingPct = policy?.trailing_pct ?? 12; const trailingPct = policy?.trailing_pct ?? 12;
const exitLabel = policy const exitLabel = policy
? policy.mode === 'atr_trailing' ? policy.mode === 'atr_trailing'
? `${atrMultiplier.toFixed(1)}x ATR trailing stop / ${policy.hold_days}d max` ? `${atrMultiplier.toFixed(1)}x ATR trailing stop / ${policy.hold_days} sessions max`
: policy.mode === 'trailing' : policy.mode === 'trailing'
? `trailing ${Math.round(trailingPct)}%` ? `trailing ${Math.round(trailingPct)}%`
: policy.mode === 'time' : policy.mode === 'time'
? `${policy.hold_days}d hold` ? `${policy.hold_days}-session hold`
: 'target/stop' : 'target/stop'
: null; : null;
const hasMaxHold = exitMode === 'atr_trailing' || exitMode === 'time';
const rows = trades ?? []; const rows = trades ?? [];
@@ -217,6 +240,7 @@ export function OpenTradesPanel() {
{rows.map((t) => { {rows.map((t) => {
const p = tradePnl(t); const p = tradePnl(t);
const open = expandedId === t.id; const open = expandedId === t.id;
const holdText = maxHoldText(t, true);
return ( return (
<li key={t.id}> <li key={t.id}>
<div <div
@@ -230,7 +254,11 @@ export function OpenTradesPanel() {
} }
}} }}
aria-expanded={open} aria-expanded={open}
className="grid w-full cursor-pointer grid-cols-[110px_1fr_60px_16px] items-center gap-3 rounded-lg px-2 py-2.5 text-left transition-colors hover:bg-white/[0.03] sm:grid-cols-[130px_150px_1fr_70px_16px]" className={`grid w-full cursor-pointer grid-cols-[110px_1fr_60px_16px] items-center gap-3 rounded-lg px-2 py-2.5 text-left transition-colors hover:bg-white/[0.03] ${
hasMaxHold
? 'sm:grid-cols-[130px_150px_1fr_70px_16px] lg:grid-cols-[130px_150px_1fr_110px_70px_16px]'
: 'sm:grid-cols-[130px_150px_1fr_70px_16px]'
}`}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Link <Link
@@ -246,6 +274,14 @@ export function OpenTradesPanel() {
{formatPrice(t.entry_price)} {t.current_price != null ? formatPrice(t.current_price) : '—'} {formatPrice(t.entry_price)} {t.current_price != null ? formatPrice(t.current_price) : '—'}
</span> </span>
<RBar r={p?.r ?? null} max={rMax} /> <RBar r={p?.r ?? null} max={rMax} />
{hasMaxHold && (
<span
className={`num hidden text-right text-[11px] lg:block ${maxHoldColor(t)}`}
title="Maximum holding period; the stop may close this trade sooner."
>
{holdText ?? '—'}
</span>
)}
<span className={`num text-right text-[13px] font-semibold ${p?.r != null ? pnlColor(p.r) : 'text-gray-500'}`}> <span className={`num text-right text-[13px] font-semibold ${p?.r != null ? pnlColor(p.r) : 'text-gray-500'}`}>
{p?.r != null ? `${p.r >= 0 ? '+' : ''}${p.r.toFixed(2)}R` : '—'} {p?.r != null ? `${p.r >= 0 ? '+' : ''}${p.r.toFixed(2)}R` : '—'}
</span> </span>
+2
View File
@@ -237,6 +237,8 @@ export interface PaperTrade {
close_reason: 'time' | 'trailing' | 'stop' | 'target' | 'manual' | null; close_reason: 'time' | 'trailing' | 'stop' | 'target' | 'manual' | null;
trailing_stop: number | null; trailing_stop: number | null;
trailing_distance_pct: number | null; trailing_distance_pct: number | null;
sessions_held: number | null;
sessions_remaining: number | null;
} }
export interface ExitPolicy { export interface ExitPolicy {
+66
View File
@@ -56,6 +56,72 @@ async def test_create_and_list_open(session):
assert row["symbol"] == "AAA" assert row["symbol"] == "AAA"
assert row["status"] == "open" assert row["status"] == "open"
assert row["current_price"] == 110.0 # marked to the latest close assert row["current_price"] == 110.0 # marked to the latest close
assert row["sessions_held"] == 0
assert row["sessions_remaining"] == 30
async def test_list_open_counts_post_entry_sessions_for_max_hold(session):
await svc.set_exit_policy(session, mode="atr_trailing", hold_days=5)
ticker_id = await _seed(session, "COUNT", close=110.0)
trade = await svc.create_trade(
session,
1,
symbol="COUNT",
direction="long",
entry_price=100.0,
shares=10,
stop_loss=95.0,
target=120.0,
)
today = _today()
trade.opened_at = datetime.combine(
today - timedelta(days=5), datetime.min.time(), tzinfo=timezone.utc
)
session.add_all([
OHLCVRecord(
ticker_id=ticker_id,
date=today - timedelta(days=4),
open=101,
high=102,
low=100,
close=101,
volume=1,
),
OHLCVRecord(
ticker_id=ticker_id,
date=today - timedelta(days=2),
open=102,
high=103,
low=101,
close=102,
volume=1,
),
])
await session.commit()
row = (await svc.list_trades(session, 1, status="open"))[0]
# Two added bars plus today's seeded bar; skipped calendar dates do not count.
assert row["sessions_held"] == 3
assert row["sessions_remaining"] == 2
async def test_list_open_omits_countdown_without_max_hold_policy(session):
await svc.set_exit_policy(session, mode="trailing")
await _seed(session, "NOHOLD", close=110.0)
await svc.create_trade(
session,
1,
symbol="NOHOLD",
direction="long",
entry_price=100.0,
shares=10,
stop_loss=95.0,
target=120.0,
)
row = (await svc.list_trades(session, 1, status="open"))[0]
assert row["sessions_held"] is None
assert row["sessions_remaining"] is None
async def test_create_trade_enforces_post_stop_gate_reset_at_service_boundary(session): async def test_create_trade_enforces_post_stop_gate_reset_at_service_boundary(session):