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