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,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 && (
|
||||
|
||||
Reference in New Issue
Block a user