feat: show max-hold session countdown
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user