feat: trailing-stop auto-exit for paper trades + close/digest alerts
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 54s
Deploy / deploy (push) Successful in 33s

Applies the backtest-validated trailing stop to live paper trading, and surfaces
it transparently.

Exit (A):
- New paper-trade exit policy (paper_exit_mode=trailing, paper_trailing_pct=12),
  tunable in Admin → Paper-Trade Exit. resolve_open_trades runs a trailing stop
  (initial stop as floor, ratchets up from the peak; target ignored — the
  validated rule) and records close_reason (trailing|stop|target|manual; +migration
  013).
- list_trades enriches open trades with the live trailing-stop level + distance %.
  Open Trades panel shows the active tactic and a Trail Stop column.

Alerts (B):
- Daily digest now lists open trades with unrealized gain, trailing stop, and how
  far away it is.
- New "trade closed" alert: one summary per auto-close (trailing/target/stop, not
  manual) — direction, reason, days held, P&L abs+%/R — covering wins AND
  stop-loss losses. Deduped by trade id; toggle in Admin alerts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 18:48:05 +02:00
parent ab9ce18809
commit 1566b84379
17 changed files with 558 additions and 25 deletions
+2
View File
@@ -134,6 +134,8 @@ export function updateAlertSettings(payload: {
sr_proximity_enabled?: boolean;
score_drop_enabled?: boolean;
digest_enabled?: boolean;
regime_quadrant_enabled?: boolean;
trade_closed_enabled?: boolean;
}) {
return apiClient
.put<AlertConfig>('admin/settings/alerts', payload)
+9 -1
View File
@@ -1,5 +1,5 @@
import apiClient from './client';
import type { PaperTrade } from '../lib/types';
import type { ExitPolicy, PaperTrade } from '../lib/types';
export function listPaperTrades(status?: 'open' | 'closed') {
return apiClient
@@ -7,6 +7,14 @@ export function listPaperTrades(status?: 'open' | 'closed') {
.then((r) => r.data);
}
export function getExitPolicy() {
return apiClient.get<ExitPolicy>('paper-trades/exit-policy').then((r) => r.data);
}
export function updateExitPolicy(payload: Partial<ExitPolicy>) {
return apiClient.put<ExitPolicy>('paper-trades/exit-policy', payload).then((r) => r.data);
}
export interface CreatePaperTradeBody {
symbol: string;
direction: 'long' | 'short';
@@ -13,14 +13,16 @@ type TriggerKey =
| 'sr_proximity_enabled'
| 'score_drop_enabled'
| 'digest_enabled'
| 'regime_quadrant_enabled';
| 'regime_quadrant_enabled'
| 'trade_closed_enabled';
const TRIGGERS: { key: TriggerKey; label: string; hint: string }[] = [
{ key: 'qualified_enabled', label: 'Qualified setups', hint: 'a setup newly clears the activation gate' },
{ key: 'sr_proximity_enabled', label: 'Watchlist S/R proximity', hint: 'a watched ticker nears a strong support/resistance' },
{ key: 'score_drop_enabled', label: 'Score deterioration', hint: 'a watched tickers composite drops sharply' },
{ key: 'digest_enabled', label: 'Daily digest', hint: 'one end-of-day summary of qualified setups' },
{ key: 'digest_enabled', label: 'Daily digest', hint: 'end-of-day summary incl. open trades + trailing stops' },
{ key: 'regime_quadrant_enabled', label: 'Regime quadrant change', hint: 'the regime monitor shifts quadrant (hysteresis + cooldown)' },
{ key: 'trade_closed_enabled', label: 'Trade closed', hint: 'a paper trade auto-closes (trailing/target/stop) — incl. losses' },
];
function Toggle({ checked, onChange, label, hint }: {
@@ -59,6 +61,7 @@ export function AlertSettings() {
score_drop_enabled: true,
digest_enabled: true,
regime_quadrant_enabled: true,
trade_closed_enabled: true,
});
useEffect(() => {
@@ -71,6 +74,7 @@ export function AlertSettings() {
score_drop_enabled: data.score_drop_enabled,
digest_enabled: data.digest_enabled,
regime_quadrant_enabled: data.regime_quadrant_enabled,
trade_closed_enabled: data.trade_closed_enabled,
});
}
}, [data]);
@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react';
import type { ExitPolicy } from '../../lib/types';
import { useExitPolicy, useUpdateExitPolicy } from '../../hooks/usePaperTrades';
import { SkeletonCard } from '../ui/Skeleton';
export function ExitPolicySettings() {
const { data, isLoading } = useExitPolicy();
const update = useUpdateExitPolicy();
const [mode, setMode] = useState<ExitPolicy['mode']>('trailing');
const [pct, setPct] = useState(12);
useEffect(() => {
if (data) {
setMode(data.mode);
setPct(data.trailing_pct);
}
}, [data]);
if (isLoading) return <SkeletonCard />;
return (
<div className="glass p-5 space-y-4">
<div>
<h3 className="text-sm font-semibold text-gray-200">Paper-Trade Exit</h3>
<p className="mt-1 text-xs text-gray-500">
How open paper trades auto-close (in the nightly/intraday outcome job).{' '}
<span className="text-gray-300">Trailing</span> rides a trailing stop the backtest's best exit,
it lets winners run; <span className="text-gray-300">Target / stop</span> closes at the setup's
target or stop. The setup's initial stop is always the floor.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-gray-400">Exit mode</span>
<select
value={mode}
onChange={(e) => setMode(e.target.value as ExitPolicy['mode'])}
className="w-full input-glass px-3 py-2 text-sm"
>
<option value="trailing">Trailing stop</option>
<option value="target">Target / stop</option>
</select>
</label>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Trailing width (%)</span>
<input
type="number"
min={0.5}
max={90}
step={0.5}
value={pct}
onChange={(e) => setPct(Number(e.target.value))}
disabled={mode !== 'trailing'}
className="w-full input-glass px-3 py-2 text-sm disabled:opacity-50"
/>
<span className="text-[11px] text-gray-600">Give-back from the peak. Backtest sweet spot ~1215%.</span>
</label>
</div>
<button
className="btn-primary px-4 py-2 text-sm disabled:opacity-50"
disabled={update.isPending}
onClick={() => update.mutate({ mode, trailing_pct: pct })}
>
{update.isPending ? 'Saving' : 'Save Exit Policy'}
</button>
</div>
);
}
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { usePaperTrades, useClosePaperTrade } from '../../hooks/usePaperTrades';
import { usePaperTrades, useClosePaperTrade, useExitPolicy } from '../../hooks/usePaperTrades';
import { tradePnl } from '../../lib/paperTrade';
import { formatPrice } from '../../lib/format';
import { Section } from '../ui/Section';
@@ -18,8 +18,15 @@ function pnlColor(v: number): string {
export function OpenTradesPanel() {
const { data: trades, isLoading } = usePaperTrades('open');
const { data: policy } = useExitPolicy();
const close = useClosePaperTrade();
const exitLabel = policy
? policy.mode === 'trailing'
? `auto-exit: trailing ${Math.round(policy.trailing_pct)}%`
: 'auto-exit: target/stop'
: null;
const totals = useMemo(() => {
let pnl = 0, winners = 0, losers = 0, priced = 0, alphaUsd = 0, alphaPriced = 0;
for (const t of trades ?? []) {
@@ -44,7 +51,7 @@ export function OpenTradesPanel() {
return (
<Section
title="Open Trades"
hint={rows.length > 0 ? `${rows.length} open · ${totals.winners}${totals.losers}` : 'paper trading'}
hint={rows.length > 0 ? `${rows.length} open · ${totals.winners}${totals.losers}${exitLabel ? ` · ${exitLabel}` : ''}` : 'paper trading'}
>
{rows.length === 0 ? (
<Callout variant="empty">
@@ -64,6 +71,7 @@ export function OpenTradesPanel() {
<th className="px-4 py-3 text-right">%</th>
<th className="px-4 py-3 text-right">R</th>
<th className="px-4 py-3 text-right">Alpha</th>
<th className="px-4 py-3 text-right">Trail Stop</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
@@ -99,6 +107,20 @@ export function OpenTradesPanel() {
<td className={`num px-4 py-3 text-right ${t.alpha_pct != null ? pnlColor(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">
{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}
</td>
<td className="num px-4 py-3 text-right text-gray-300" title="Current trailing-stop level · how far below the price">
{t.trailing_stop != null ? (
<>
{formatPrice(t.trailing_stop)}
{t.trailing_distance_pct != null && (
<span className="ml-1 text-[10px] text-gray-500">
{Math.abs(t.trailing_distance_pct).toFixed(1)}%
</span>
)}
</>
) : (
<span className="text-gray-500"></span>
)}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => {
@@ -128,7 +150,7 @@ export function OpenTradesPanel() {
<td className={`num px-4 py-2.5 text-right font-semibold ${totals.alphaPriced > 0 ? pnlColor(totals.alphaUsd) : 'text-gray-500'}`}>
{totals.alphaPriced > 0 ? money(totals.alphaUsd) : '—'}
</td>
<td />
<td colSpan={2} />
</tr>
</tfoot>
</table>
+22
View File
@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as api from '../api/paperTrades';
import type { ExitPolicy } from '../lib/types';
import { useToast } from '../components/ui/Toast';
export function usePaperTrades(status?: 'open' | 'closed') {
@@ -10,6 +11,27 @@ export function usePaperTrades(status?: 'open' | 'closed') {
});
}
export function useExitPolicy() {
return useQuery({
queryKey: ['paper-trades', 'exit-policy'],
queryFn: () => api.getExitPolicy(),
staleTime: 5 * 60 * 1000,
});
}
export function useUpdateExitPolicy() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (body: Partial<ExitPolicy>) => api.updateExitPolicy(body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['paper-trades'] });
addToast('success', 'Exit policy updated.');
},
onError: (e: Error) => addToast('error', e.message || 'Failed to update exit policy'),
});
}
export function useCreatePaperTrade() {
const qc = useQueryClient();
const { addToast } = useToast();
+9
View File
@@ -207,6 +207,14 @@ export interface PaperTrade {
benchmark_return_pct: number | null;
alpha_pct: number | null;
alpha_usd: number | null;
close_reason: 'trailing' | 'stop' | 'target' | 'manual' | null;
trailing_stop: number | null;
trailing_distance_pct: number | null;
}
export interface ExitPolicy {
mode: 'trailing' | 'target';
trailing_pct: number;
}
export interface BacktestBucket {
@@ -407,6 +415,7 @@ export interface AlertConfig {
score_drop_enabled: boolean;
digest_enabled: boolean;
regime_quadrant_enabled: boolean;
trade_closed_enabled: boolean;
}
export interface AlertTestResult {
+2
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { ActivationSettings } from '../components/admin/ActivationSettings';
import { ExitPolicySettings } from '../components/admin/ExitPolicySettings';
import { AlertSettings } from '../components/admin/AlertSettings';
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
import { DataCleanup } from '../components/admin/DataCleanup';
@@ -33,6 +34,7 @@ export default function AdminPage() {
{activeTab === 'Settings' && (
<div className="space-y-4">
<ActivationSettings />
<ExitPolicySettings />
<AlertSettings />
<SentimentProviderSettings />
<TickerUniverseBootstrap />