diff --git a/app/services/paper_trade_service.py b/app/services/paper_trade_service.py index ed5c030..35133e3 100644 --- a/app/services/paper_trade_service.py +++ b/app/services/paper_trade_service.py @@ -441,22 +441,31 @@ async def list_trades( 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(), - ) + open_trades = [trade for trade, _ in rows if trade.status == "open"] + if open_trades: + ticker_ids = {trade.ticker_id for trade in open_trades} + earliest_opened = min(trade.opened_at.date() for trade in open_trades) + session_rows = ( + await db.execute( + select(OHLCVRecord.ticker_id, OHLCVRecord.date) + .where( + OHLCVRecord.ticker_id.in_(ticker_ids), + OHLCVRecord.date > earliest_opened, ) - ).scalar_one() - ) - holding_sessions[t.id] = (held, max(0, hold_days - held)) + .order_by(OHLCVRecord.ticker_id, OHLCVRecord.date) + ) + ).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() + ) + # 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]] = {} if policy["mode"] == "trailing": diff --git a/frontend/src/components/dashboard/OpenTradesPanel.tsx b/frontend/src/components/dashboard/OpenTradesPanel.tsx index 4862eba..0a70eea 100644 --- a/frontend/src/components/dashboard/OpenTradesPanel.tsx +++ b/frontend/src/components/dashboard/OpenTradesPanel.tsx @@ -25,16 +25,19 @@ function pnlColor(v: number): string { 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; + 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`; } function maxHoldColor(trade: PaperTrade): string { - return trade.sessions_remaining != null && trade.sessions_remaining <= 5 - ? 'text-amber-300' - : 'text-gray-400'; + const remaining = trade.sessions_remaining; + if (remaining == null) return '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 }) { diff --git a/tests/unit/test_paper_trade_service.py b/tests/unit/test_paper_trade_service.py index 9d6bc26..4a63a8e 100644 --- a/tests/unit/test_paper_trade_service.py +++ b/tests/unit/test_paper_trade_service.py @@ -105,6 +105,50 @@ async def test_list_open_counts_post_entry_sessions_for_max_hold(session): 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): await svc.set_exit_policy(session, mode="trailing") await _seed(session, "NOHOLD", close=110.0)