add paper trading: mark a setup as taken, track open P&L, sell
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 24s

New paper_trades table (migration 007) + service/router. "Mark as taken" on each
setup card (shares prefilled from position sizing, entry from current price, both
editable) records a simulated trade. Overview gains an Open Trades table that
marks each position to the latest close — P&L in $, %, and R-multiples — with a
total unrealized P&L footer and a Sell button to close at the current price.
Closed trades are retained for future realized-P&L reporting.

Deploy: alembic upgrade (new paper_trades table).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 06:33:56 +02:00
parent 050abc6f71
commit a69557f5d8
16 changed files with 736 additions and 1 deletions
+29
View File
@@ -0,0 +1,29 @@
import apiClient from './client';
import type { PaperTrade } from '../lib/types';
export function listPaperTrades(status?: 'open' | 'closed') {
return apiClient
.get<PaperTrade[]>('paper-trades', { params: status ? { status } : {} })
.then((r) => r.data);
}
export interface CreatePaperTradeBody {
symbol: string;
direction: 'long' | 'short';
entry_price: number;
shares: number;
stop_loss: number;
target: number;
}
export function createPaperTrade(body: CreatePaperTradeBody) {
return apiClient.post<PaperTrade>('paper-trades', body).then((r) => r.data);
}
export function closePaperTrade(id: number, closePrice?: number) {
return apiClient
.post<{ id: number; status: string }>(`paper-trades/${id}/close`, {
close_price: closePrice ?? null,
})
.then((r) => r.data);
}
@@ -0,0 +1,126 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { usePaperTrades, useClosePaperTrade } from '../../hooks/usePaperTrades';
import { tradePnl } from '../../lib/paperTrade';
import { formatPrice } from '../../lib/format';
import { Section } from '../ui/Section';
import { Callout } from '../ui/Callout';
function money(v: number): string {
const sign = v >= 0 ? '+' : '';
return `${sign}$${Math.abs(v).toFixed(2)}`;
}
function pnlColor(v: number): string {
if (v > 0) return 'text-emerald-400';
if (v < 0) return 'text-red-400';
return 'text-gray-300';
}
export function OpenTradesPanel() {
const { data: trades, isLoading } = usePaperTrades('open');
const close = useClosePaperTrade();
const totals = useMemo(() => {
let pnl = 0, winners = 0, losers = 0, priced = 0;
for (const t of trades ?? []) {
const p = tradePnl(t);
if (!p) continue;
priced += 1;
pnl += p.pnl;
if (p.pnl > 0) winners += 1;
else if (p.pnl < 0) losers += 1;
}
return { pnl, winners, losers, priced };
}, [trades]);
if (isLoading) return null;
const rows = trades ?? [];
return (
<Section
title="Open Trades"
hint={rows.length > 0 ? `${rows.length} open · ${totals.winners}${totals.losers}` : 'paper trading'}
>
{rows.length === 0 ? (
<Callout variant="empty">
No open paper trades. Open a ticker and tap Mark as taken on a setup to start.
</Callout>
) : (
<div className="glass overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-3">Ticker</th>
<th className="px-4 py-3">Dir</th>
<th className="px-4 py-3 text-right">Shares</th>
<th className="px-4 py-3 text-right">Entry</th>
<th className="px-4 py-3 text-right">Now</th>
<th className="px-4 py-3 text-right">P&L</th>
<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"></th>
</tr>
</thead>
<tbody>
{rows.map((t) => {
const p = tradePnl(t);
return (
<tr key={t.id} className="border-b border-white/[0.04] hover:bg-white/[0.03]">
<td className="px-4 py-3">
<Link to={`/ticker/${t.symbol}`} className="font-medium text-blue-300 hover:text-blue-200">
{t.symbol}
</Link>
</td>
<td className="px-4 py-3">
<span className={`num text-[10px] font-semibold uppercase ${t.direction === 'long' ? 'text-emerald-400' : 'text-red-400'}`}>
{t.direction}
</span>
</td>
<td className="num px-4 py-3 text-right text-gray-300">{t.shares}</td>
<td className="num px-4 py-3 text-right text-gray-300">{formatPrice(t.entry_price)}</td>
<td className="num px-4 py-3 text-right text-gray-200">
{t.current_price != null ? formatPrice(t.current_price) : '—'}
</td>
<td className={`num px-4 py-3 text-right font-semibold ${p ? pnlColor(p.pnl) : 'text-gray-500'}`}>
{p ? money(p.pnl) : '—'}
</td>
<td className={`num px-4 py-3 text-right ${p ? pnlColor(p.pct) : 'text-gray-500'}`}>
{p ? `${p.pct >= 0 ? '+' : ''}${p.pct.toFixed(1)}%` : '—'}
</td>
<td className={`num px-4 py-3 text-right ${p?.r != null ? pnlColor(p.r) : 'text-gray-500'}`}>
{p?.r != null ? `${p.r >= 0 ? '+' : ''}${p.r.toFixed(2)}R` : '—'}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => {
if (window.confirm(`Close ${t.shares} ${t.symbol} at the current price?`)) {
close.mutate({ id: t.id });
}
}}
disabled={close.isPending}
className="rounded-md border border-white/[0.1] px-2.5 py-1 text-xs text-gray-300 transition-colors hover:bg-white/[0.06] hover:text-white disabled:opacity-50"
>
Sell
</button>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-white/[0.08]">
<td className="px-4 py-2.5 text-xs text-gray-500" colSpan={5}>
Total unrealized P&L
</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${pnlColor(totals.pnl)}`}>
{money(totals.pnl)}
</td>
<td colSpan={3} />
</tr>
</tfoot>
</table>
</div>
)}
</Section>
);
}
@@ -1,5 +1,7 @@
import { useState } from 'react';
import type { TradeSetup } from '../../lib/types';
import { formatPrice, formatPercent } from '../../lib/format';
import { useCreatePaperTrade } from '../../hooks/usePaperTrades';
import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation';
import { useRiskSettings, type RiskSettings } from '../../hooks/useRiskSettings';
import { positionSize } from '../../lib/position';
@@ -113,6 +115,25 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
const sizing = positionSize(risk.accountSize, risk.riskPct, setup.entry_price, setup.stop_loss);
const counterTrend = regime ? isCounterTrend(setup.direction, regime.label) : false;
const createTrade = useCreatePaperTrade();
const [taking, setTaking] = useState(false);
const [takeShares, setTakeShares] = useState<number>(sizing?.shares ?? 0);
const [takeEntry, setTakeEntry] = useState<number>(currentPrice ?? setup.entry_price);
const confirmTake = () => {
createTrade.mutate(
{
symbol: setup.symbol,
direction: setup.direction as 'long' | 'short',
entry_price: takeEntry,
shares: takeShares,
stop_loss: setup.stop_loss,
target: setup.target,
},
{ onSuccess: () => setTaking(false) },
);
};
return (
<div
data-direction={setup.direction}
@@ -184,6 +205,63 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
<p className="text-[11px] text-gray-600">Set account size below to size this trade.</p>
)}
{!taking ? (
<button
onClick={() => {
setTakeShares(sizing?.shares ?? 0);
setTakeEntry(currentPrice ?? setup.entry_price);
setTaking(true);
}}
className="w-full rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-300 transition-colors hover:bg-emerald-500/20"
>
+ Mark as taken (paper trade)
</button>
) : (
<div className="rounded-md border border-white/[0.08] bg-white/[0.02] p-2.5 space-y-2">
<div className="grid grid-cols-2 gap-2">
<label className="block space-y-1">
<span className="text-[10px] uppercase tracking-wider text-gray-500">Shares</span>
<input
type="number"
min={0}
value={takeShares}
onChange={(e) => setTakeShares(Number(e.target.value))}
className="w-full input-glass px-2 py-1 text-sm num"
/>
</label>
<label className="block space-y-1">
<span className="text-[10px] uppercase tracking-wider text-gray-500">Entry</span>
<input
type="number"
min={0}
step="0.01"
value={takeEntry}
onChange={(e) => setTakeEntry(Number(e.target.value))}
className="w-full input-glass px-2 py-1 text-sm num"
/>
</label>
</div>
<p className="text-[10px] text-gray-500">
Stop {formatPrice(setup.stop_loss)} · Target {formatPrice(setup.target)} · {setup.direction.toUpperCase()}
</p>
<div className="flex gap-2">
<button
onClick={confirmTake}
disabled={createTrade.isPending || !(takeShares > 0) || !(takeEntry > 0)}
className="flex-1 rounded-md bg-emerald-500/20 px-3 py-1.5 text-xs font-medium text-emerald-300 hover:bg-emerald-500/30 disabled:opacity-50"
>
{createTrade.isPending ? 'Taking…' : 'Confirm'}
</button>
<button
onClick={() => setTaking(false)}
className="rounded-md border border-white/[0.08] px-3 py-1.5 text-xs text-gray-400 hover:text-gray-200"
>
Cancel
</button>
</div>
</div>
)}
<TargetTable setup={setup} />
{setup.conflict_flags.length > 0 && (
+38
View File
@@ -0,0 +1,38 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as api from '../api/paperTrades';
import { useToast } from '../components/ui/Toast';
export function usePaperTrades(status?: 'open' | 'closed') {
return useQuery({
queryKey: ['paper-trades', status ?? 'all'],
queryFn: () => api.listPaperTrades(status),
refetchInterval: 60_000,
});
}
export function useCreatePaperTrade() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (body: api.CreatePaperTradeBody) => api.createPaperTrade(body),
onSuccess: (t) => {
qc.invalidateQueries({ queryKey: ['paper-trades'] });
addToast('success', `Taken: ${t.shares} ${t.symbol} ${t.direction.toUpperCase()} @ ${t.entry_price}`);
},
onError: (e: Error) => addToast('error', e.message || 'Failed to take trade'),
});
}
export function useClosePaperTrade() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: ({ id, closePrice }: { id: number; closePrice?: number }) =>
api.closePaperTrade(id, closePrice),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['paper-trades'] });
addToast('success', 'Trade closed.');
},
onError: (e: Error) => addToast('error', e.message || 'Failed to close trade'),
});
}
+25
View File
@@ -0,0 +1,25 @@
import type { PaperTrade } from './types';
export interface TradePnl {
/** Reference price: live close for open trades, exit price for closed. */
ref: number;
perShare: number;
pnl: number;
pct: number;
/** Profit in R-multiples (relative to the per-share risk), null if no risk. */
r: number | null;
}
export function tradePnl(t: PaperTrade): TradePnl | null {
const ref = t.current_price;
if (ref == null || !t.entry_price) return null;
const perShare = t.direction === 'long' ? ref - t.entry_price : t.entry_price - ref;
const risk = Math.abs(t.entry_price - t.stop_loss);
return {
ref,
perShare,
pnl: perShare * t.shares,
pct: (perShare / t.entry_price) * 100,
r: risk > 0 ? perShare / risk : null,
};
}
+15
View File
@@ -179,6 +179,21 @@ export interface SentimentProviderConfig {
custom_base_url_providers: string[];
}
export interface PaperTrade {
id: number;
symbol: string;
direction: 'long' | 'short';
entry_price: number;
shares: number;
stop_loss: number;
target: number;
status: 'open' | 'closed';
opened_at: string;
close_price: number | null;
closed_at: string | null;
current_price: number | null;
}
export interface BacktestBucket {
total: number;
wins: number;
+4
View File
@@ -8,6 +8,7 @@ import { useMarketRegime } from '../hooks/useMarketRegime';
import { regimeColor, regimeDot, regimeHeadline } from '../lib/regime';
import { Callout } from '../components/ui/Callout';
import { Section } from '../components/ui/Section';
import { OpenTradesPanel } from '../components/dashboard/OpenTradesPanel';
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
import { formatPrice } from '../lib/format';
import { recommendationActionLabel } from '../lib/recommendation';
@@ -150,6 +151,9 @@ export default function DashboardPage() {
</div>
)}
{/* Open paper trades */}
<OpenTradesPanel />
<div className="grid gap-8 xl:grid-cols-5">
{/* Top setups */}
<div className="xl:col-span-3">