add paper trading: mark a setup as taken, track open P&L, sell
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:
@@ -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 && (
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user