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]] = {}
|
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 }) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user