fix: tighten max-hold session countdown

This commit is contained in:
2026-08-04 08:07:10 +02:00
parent 4c0c0579f5
commit e0f3d43efb
3 changed files with 76 additions and 20 deletions
+20 -11
View File
@@ -441,22 +441,31 @@ async def list_trades(
holding_sessions: dict[int, tuple[int, int]] = {} holding_sessions: dict[int, tuple[int, int]] = {}
if policy["mode"] in ("time", "atr_trailing"): if policy["mode"] in ("time", "atr_trailing"):
hold_days = int(policy["hold_days"]) hold_days = int(policy["hold_days"])
for t, _ in rows: open_trades = [trade for trade, _ in rows if trade.status == "open"]
if t.status != "open": if open_trades:
continue ticker_ids = {trade.ticker_id for trade in open_trades}
held = int( earliest_opened = min(trade.opened_at.date() for trade in open_trades)
( session_rows = (
await db.execute( await db.execute(
select(func.count()) select(OHLCVRecord.ticker_id, OHLCVRecord.date)
.select_from(OHLCVRecord)
.where( .where(
OHLCVRecord.ticker_id == t.ticker_id, OHLCVRecord.ticker_id.in_(ticker_ids),
OHLCVRecord.date > t.opened_at.date(), OHLCVRecord.date > earliest_opened,
) )
.order_by(OHLCVRecord.ticker_id, OHLCVRecord.date)
) )
).scalar_one() ).all()
dates_by_ticker: dict[int, list[date]] = {}
for ticker_id, session_date in session_rows:
dates_by_ticker.setdefault(int(ticker_id), []).append(session_date)
for trade in open_trades:
dates = dates_by_ticker.get(trade.ticker_id, [])
held = len(dates) - bisect.bisect_right(
dates, trade.opened_at.date()
) )
holding_sessions[t.id] = (held, max(0, hold_days - held)) # Do not clamp: a policy shortened below the current holding
# period must remain visible as overdue until the exit pass runs.
holding_sessions[trade.id] = (held, 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":
@@ -25,16 +25,19 @@ function pnlColor(v: number): string {
function maxHoldText(trade: PaperTrade, compact = false): string | null { function maxHoldText(trade: PaperTrade, compact = false): string | null {
const remaining = trade.sessions_remaining; const remaining = trade.sessions_remaining;
if (remaining == null) return null; 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; const held = trade.sessions_held ?? 0;
if (remaining < 0) return compact ? 'past max hold' : `${held} held · past max hold`;
if (remaining === 0) return compact ? 'max hold reached' : `${held} held · max hold reached`;
if (compact) return `${remaining} ${remaining === 1 ? 'session' : 'sessions'} left`;
return `${held} held · ${remaining} remaining`; return `${held} held · ${remaining} remaining`;
} }
function maxHoldColor(trade: PaperTrade): string { function maxHoldColor(trade: PaperTrade): string {
return trade.sessions_remaining != null && trade.sessions_remaining <= 5 const remaining = trade.sessions_remaining;
? 'text-amber-300' if (remaining == null) return 'text-gray-400';
: 'text-gray-400'; const holdDays = Math.max(1, (trade.sessions_held ?? 0) + remaining);
const warningAt = Math.max(1, Math.ceil(holdDays * 0.2));
return remaining <= warningAt ? 'text-amber-300' : 'text-gray-400';
} }
function DirTag({ direction }: { direction: string }) { function DirTag({ direction }: { direction: string }) {
+44
View File
@@ -105,6 +105,50 @@ async def test_list_open_counts_post_entry_sessions_for_max_hold(session):
assert row["sessions_remaining"] == 2 assert row["sessions_remaining"] == 2
async def test_list_open_exposes_past_max_hold_after_policy_is_shortened(session):
await svc.set_exit_policy(session, mode="time", hold_days=2)
ticker_id = await _seed(session, "OVERDUE", close=110.0)
trade = await svc.create_trade(
session,
1,
symbol="OVERDUE",
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]
assert row["sessions_held"] == 3
assert row["sessions_remaining"] == -1
async def test_list_open_omits_countdown_without_max_hold_policy(session): async def test_list_open_omits_countdown_without_max_hold_policy(session):
await svc.set_exit_policy(session, mode="trailing") await svc.set_exit_policy(session, mode="trailing")
await _seed(session, "NOHOLD", close=110.0) await _seed(session, "NOHOLD", close=110.0)