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>
402 lines
18 KiB
TypeScript
402 lines
18 KiB
TypeScript
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';
|
|
import { useMarketRegime } from '../../hooks/useMarketRegime';
|
|
import { isCounterTrend } from '../../lib/regime';
|
|
import type { MarketRegime } from '../../lib/types';
|
|
|
|
interface RecommendationPanelProps {
|
|
symbol: string;
|
|
longSetup?: TradeSetup;
|
|
shortSetup?: TradeSetup;
|
|
currentPrice?: number;
|
|
nextEarningsDate?: string | null;
|
|
}
|
|
|
|
/** Whole days from today until an ISO date (negative if past). */
|
|
function daysUntil(iso: string): number | null {
|
|
const t = new Date(iso).getTime();
|
|
if (Number.isNaN(t)) return null;
|
|
return Math.ceil((t - Date.now()) / 86_400_000);
|
|
}
|
|
|
|
/** Earnings within the ~30-day target horizon can gap price through stop/target. */
|
|
const EARNINGS_HORIZON_DAYS = 30;
|
|
|
|
/**
|
|
* How far current price has drifted from the setup's entry. A setup whose
|
|
* entry is far from the live price (price already ran toward target, or fell
|
|
* through the stop) is stale — entering now changes the risk/reward.
|
|
*/
|
|
function entryDrift(setup: TradeSetup, currentPrice?: number) {
|
|
if (currentPrice == null || !setup.entry_price) return null;
|
|
const pct = ((currentPrice - setup.entry_price) / setup.entry_price) * 100;
|
|
const towardTarget = setup.direction === 'long' ? currentPrice >= setup.entry_price : currentPrice <= setup.entry_price;
|
|
// Judge staleness by how much of the entry→target distance is already gone,
|
|
// not the raw % move — an 8%-wide setup is "used up" far faster than a 40% one.
|
|
const span = Math.abs(setup.target - setup.entry_price);
|
|
const moved = Math.abs(currentPrice - setup.entry_price);
|
|
const progressPct = span > 0 ? (moved / span) * 100 : 0;
|
|
const beyondStop = setup.direction === 'long' ? currentPrice <= setup.stop_loss : currentPrice >= setup.stop_loss;
|
|
let status: 'fresh' | 'stale' | 'invalidated' = 'fresh';
|
|
if (beyondStop) status = 'invalidated';
|
|
else if (towardTarget && progressPct > 33) status = 'stale';
|
|
else if (!towardTarget && progressPct > 33) status = 'stale';
|
|
return { pct, progressPct, towardTarget, status };
|
|
}
|
|
|
|
function riskClass(risk: TradeSetup['risk_level']) {
|
|
if (risk === 'Low') return 'text-emerald-400';
|
|
if (risk === 'Medium') return 'text-amber-400';
|
|
if (risk === 'High') return 'text-red-400';
|
|
return 'text-gray-400';
|
|
}
|
|
|
|
function isRecommended(setup: TradeSetup | undefined, action: TradeSetup['recommended_action'] | undefined) {
|
|
if (!setup || !action) return false;
|
|
if (setup.direction === 'long') return action.startsWith('LONG');
|
|
return action.startsWith('SHORT');
|
|
}
|
|
|
|
function TargetTable({ setup }: { setup: TradeSetup }) {
|
|
if (!setup.targets || setup.targets.length === 0) {
|
|
return <p className="text-xs text-gray-500">No target probabilities available.</p>;
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 border-b border-white/[0.06]">
|
|
<th className="py-2 pr-3">Classification</th>
|
|
<th className="py-2 pr-3">Price</th>
|
|
<th className="py-2 pr-3">Distance</th>
|
|
<th className="py-2 pr-3">R:R</th>
|
|
<th className="py-2">Probability</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{setup.targets.map((target) => (
|
|
<tr
|
|
key={`${setup.id}-${target.sr_level_id}-${target.price}`}
|
|
className={`border-b border-white/[0.04] ${target.is_primary ? 'bg-blue-400/10' : ''}`}
|
|
>
|
|
<td className="py-2 pr-3 text-gray-300">
|
|
{target.is_primary && <span className="mr-1 text-blue-300">★</span>}
|
|
{target.classification}
|
|
</td>
|
|
<td className="py-2 pr-3 font-mono text-gray-200">{formatPrice(target.price)}</td>
|
|
<td className="py-2 pr-3 font-mono text-gray-200">{formatPercent((target.distance_from_entry / setup.entry_price) * 100)}</td>
|
|
<td className="py-2 pr-3 font-mono text-gray-200">{target.rr_ratio.toFixed(2)}</td>
|
|
<td className="py-2 font-mono text-gray-200">{target.probability.toFixed(1)}%</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: TradeSetup; action?: TradeSetup['recommended_action']; currentPrice?: number; risk: RiskSettings; regime?: MarketRegime }) {
|
|
if (!setup) {
|
|
return (
|
|
<div className="glass-sm p-4 text-xs text-gray-500">
|
|
Setup unavailable for this direction.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const recommended = isRecommended(setup, action);
|
|
const drift = entryDrift(setup, currentPrice);
|
|
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}
|
|
className={`glass-sm p-4 space-y-3 ${recommended ? 'border border-emerald-500/40' : 'opacity-80'}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<h4 className={`text-sm font-semibold ${setup.direction === 'long' ? 'text-emerald-400' : 'text-red-400'}`}>
|
|
{setup.direction.toUpperCase()}
|
|
</h4>
|
|
<span className="text-xs text-gray-300">{setup.confidence_score?.toFixed(1) ?? '—'}%</span>
|
|
</div>
|
|
|
|
{!recommended && recommendationActionDirection(action ?? null) !== 'neutral' && (
|
|
<p className="text-[11px] text-amber-400">Alternative setup (ticker bias currently favors the opposite direction).</p>
|
|
)}
|
|
|
|
{counterTrend && regime && (
|
|
<p className="text-[11px] text-amber-400">
|
|
⚠ Counter-trend: {setup.direction.toUpperCase()} against a {regime.label} market
|
|
({regime.benchmark ?? 'SPY'}). Lower odds — size down or wait for confirmation.
|
|
</p>
|
|
)}
|
|
|
|
{drift && drift.status === 'invalidated' && (
|
|
<p className="text-[11px] text-red-400">
|
|
⚠ Price ({formatPrice(currentPrice!)}) is past the stop — this setup is invalidated.
|
|
</p>
|
|
)}
|
|
{drift && drift.status === 'stale' && (
|
|
<p className="text-[11px] text-amber-400">
|
|
{drift.towardTarget
|
|
? `⚠ ${drift.progressPct.toFixed(0)}% of the entry→target move is already gone (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}% from entry) — little reward left.`
|
|
: `⚠ Price has moved ${Math.abs(drift.pct).toFixed(1)}% against the setup (toward the stop) — entry may be stale.`}
|
|
</p>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
<div className="text-gray-500">Current</div><div className="font-mono text-gray-200">{currentPrice != null ? formatPrice(currentPrice) : '—'}</div>
|
|
<div className="text-gray-500">Entry</div><div className="font-mono text-gray-200">{formatPrice(setup.entry_price)}{drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''}</div>
|
|
<div className="text-gray-500">Stop</div><div className="font-mono text-gray-200">{formatPrice(setup.stop_loss)}</div>
|
|
<div className="text-gray-500">Primary Target</div><div className="font-mono text-gray-200">{formatPrice(setup.target)}</div>
|
|
<div className="text-gray-500">R:R</div><div className="font-mono text-gray-200">{setup.rr_ratio.toFixed(2)}</div>
|
|
</div>
|
|
|
|
{sizing ? (
|
|
<div className="rounded border border-white/[0.06] bg-white/[0.02] p-2.5 text-xs">
|
|
<p className="mb-1.5 text-[10px] uppercase tracking-wider text-gray-500">
|
|
Position size · {risk.riskPct}% of {formatPrice(risk.accountSize)}
|
|
</p>
|
|
<div className="grid grid-cols-3 gap-2 text-center">
|
|
<div>
|
|
<div className="font-mono text-sm text-gray-100">{sizing.shares}</div>
|
|
<div className="text-[10px] text-gray-500">shares</div>
|
|
</div>
|
|
<div>
|
|
<div className={`font-mono text-sm ${sizing.exceedsAccount ? 'text-amber-400' : 'text-gray-100'}`}>{formatPrice(sizing.positionValue)}</div>
|
|
<div className="text-[10px] text-gray-500">position</div>
|
|
</div>
|
|
<div>
|
|
<div className="font-mono text-sm text-gray-100">{formatPrice(sizing.dollarRisk)}</div>
|
|
<div className="text-[10px] text-gray-500">max loss</div>
|
|
</div>
|
|
</div>
|
|
{sizing.exceedsAccount && (
|
|
<p className="mt-1.5 text-[10px] text-amber-400">Position exceeds account — needs margin.</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<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 && (
|
|
<div className="rounded border border-amber-500/30 bg-amber-500/10 p-2 text-[11px] text-amber-300">
|
|
{setup.conflict_flags.join(' • ')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const RISK_PRESETS = [0.5, 1, 2, 3];
|
|
|
|
/** Compact, set-once sizing controls: a clean account field (no spinners) and a
|
|
* segmented risk-% selector — risk is almost always one of a few values. */
|
|
function RiskControls({ risk, update }: { risk: RiskSettings; update: (p: Partial<RiskSettings>) => void }) {
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
|
<span>Sizing assumes a</span>
|
|
<span className="inline-flex items-center rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-0.5">
|
|
<span className="mr-0.5 text-gray-500">$</span>
|
|
<input
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={risk.accountSize ? String(risk.accountSize) : ''}
|
|
onChange={(e) => update({ accountSize: Number(e.target.value.replace(/[^0-9]/g, '')) || 0 })}
|
|
placeholder="10000"
|
|
aria-label="Account size"
|
|
className="w-20 bg-transparent font-mono text-gray-100 outline-none"
|
|
/>
|
|
</span>
|
|
<span>account, risking</span>
|
|
<span className="inline-flex overflow-hidden rounded-md border border-white/[0.08]">
|
|
{RISK_PRESETS.map((p) => (
|
|
<button
|
|
key={p}
|
|
type="button"
|
|
onClick={() => update({ riskPct: p })}
|
|
className={`px-2 py-0.5 font-mono transition-colors ${
|
|
risk.riskPct === p
|
|
? 'bg-blue-400/15 text-blue-200'
|
|
: 'text-gray-400 hover:bg-white/[0.05] hover:text-gray-200'
|
|
}`}
|
|
>
|
|
{p}%
|
|
</button>
|
|
))}
|
|
</span>
|
|
<span>per trade.</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPrice, nextEarningsDate }: RecommendationPanelProps) {
|
|
const { settings: risk, update: updateRisk } = useRiskSettings();
|
|
const regime = useMarketRegime().data;
|
|
const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary;
|
|
const earningsDays = nextEarningsDate ? daysUntil(nextEarningsDate) : null;
|
|
const action = (summary?.action ?? 'NEUTRAL') as TradeSetup['recommended_action'];
|
|
const preferredDirection = recommendationActionDirection(action);
|
|
|
|
const preferredSetup =
|
|
preferredDirection === 'long'
|
|
? longSetup
|
|
: preferredDirection === 'short'
|
|
? shortSetup
|
|
: undefined;
|
|
|
|
const alternativeSetup =
|
|
preferredDirection === 'long'
|
|
? shortSetup
|
|
: preferredDirection === 'short'
|
|
? longSetup
|
|
: undefined;
|
|
|
|
if (!longSetup && !shortSetup) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<section>
|
|
<h2 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Recommendation</h2>
|
|
<div className="glass p-5 space-y-4">
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<span className="text-sm font-semibold text-blue-300">{recommendationActionLabel(action)}</span>
|
|
<span className={`text-sm font-semibold ${riskClass(summary?.risk_level ?? null)}`}>
|
|
Risk: {summary?.risk_level ?? '—'}
|
|
</span>
|
|
<span className="text-sm text-gray-300">Composite: {summary?.composite_score?.toFixed(1) ?? '—'}</span>
|
|
<span className="text-xs text-gray-500">{symbol.toUpperCase()}</span>
|
|
<div className="ml-auto">
|
|
<RiskControls risk={risk} update={updateRisk} />
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-xs text-gray-500">Recommended Action is the ticker-level bias. The preferred setup is shown first; the opposite side is available under Alternative scenario.</p>
|
|
|
|
{summary?.reasoning && (
|
|
<p className="text-sm text-gray-300">{summary.reasoning}</p>
|
|
)}
|
|
|
|
{earningsDays != null && earningsDays >= 0 && (
|
|
earningsDays <= EARNINGS_HORIZON_DAYS ? (
|
|
<p className="rounded border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-300">
|
|
⚠ Earnings in {earningsDays} day{earningsDays === 1 ? '' : 's'} ({nextEarningsDate}) — inside the ~30-day
|
|
target horizon. A report can gap price through your stop or target; consider waiting or sizing down.
|
|
</p>
|
|
) : (
|
|
<p className="text-[11px] text-gray-500">Next earnings: {nextEarningsDate} ({earningsDays} days).</p>
|
|
)
|
|
)}
|
|
|
|
{preferredDirection !== 'neutral' && preferredSetup ? (
|
|
<div className="space-y-3">
|
|
<SetupCard setup={preferredSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} />
|
|
|
|
{alternativeSetup && (
|
|
<details className="glass-sm p-3">
|
|
<summary className="cursor-pointer text-xs font-medium text-gray-300">
|
|
Alternative scenario ({alternativeSetup.direction.toUpperCase()})
|
|
</summary>
|
|
<div className="mt-3">
|
|
<SetupCard setup={alternativeSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} />
|
|
</div>
|
|
</details>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
<SetupCard setup={longSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} />
|
|
<SetupCard setup={shortSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|