feat: trailing-stop auto-exit for paper trades + close/digest alerts
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:
@@ -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)
|
||||
|
||||
@@ -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 ticker’s 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 ~12–15%.</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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user