diff --git a/app/scheduler.py b/app/scheduler.py index 35526fc..aed1a6f 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -800,11 +800,14 @@ async def evaluate_outcomes() -> None: summary = await evaluate_pending_setups( db, max_bars=settings.outcome_evaluation_max_bars ) + from app.services import paper_trade_service + closed_trades = await paper_trade_service.resolve_open_trades(db) _runtime_progress(job_name, processed=1, total=1) _runtime_finish( job_name, "completed", processed=1, total=1, - message=f"Evaluated {summary['evaluated']}, pending {summary['still_pending']}", + message=f"Evaluated {summary['evaluated']}, pending {summary['still_pending']}, " + f"{closed_trades} paper trade(s) closed", ) logger.info(json.dumps({ "event": "job_complete", diff --git a/app/services/paper_trade_service.py b/app/services/paper_trade_service.py index 1c17621..1f0b49a 100644 --- a/app/services/paper_trade_service.py +++ b/app/services/paper_trade_service.py @@ -11,6 +11,13 @@ from app.exceptions import NotFoundError, ValidationError from app.models.ohlcv import OHLCVRecord from app.models.paper_trade import PaperTrade from app.models.ticker import Ticker +from app.services.outcome_service import ( + OUTCOME_AMBIGUOUS, + OUTCOME_STOP_HIT, + OUTCOME_TARGET_HIT, + Bar, + evaluate_setup_against_bars, +) async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker: @@ -146,3 +153,52 @@ async def close_trade( await db.commit() await db.refresh(trade) return trade + + +async def resolve_open_trades(db: AsyncSession) -> int: + """Auto-close open trades whose stop or target was hit in the daily bars. + + Walks the bars after each trade's open (same logic as the outcome evaluator). + Target hit → close at the target; stop (or an ambiguous same-bar touch) → + close at the stop. Trades that have hit neither stay open. Returns the count + closed. + """ + result = await db.execute(select(PaperTrade).where(PaperTrade.status == "open")) + open_trades = list(result.scalars().all()) + if not open_trades: + return 0 + + closed = 0 + for trade in open_trades: + bars_result = await db.execute( + select(OHLCVRecord.date, OHLCVRecord.high, OHLCVRecord.low) + .where( + OHLCVRecord.ticker_id == trade.ticker_id, + OHLCVRecord.date > trade.opened_at.date(), + ) + .order_by(OHLCVRecord.date.asc()) + ) + bars = [Bar(date=d, high=h, low=lo) for d, h, lo in bars_result.all()] + if not bars: + continue + + # max_bars beyond the data so a still-open trade returns undecided (not "expired"). + outcome, outcome_date = evaluate_setup_against_bars( + trade.direction, trade.stop_loss, trade.target, bars, max_bars=len(bars) + 1 + ) + if outcome == OUTCOME_TARGET_HIT: + trade.close_price = trade.target + elif outcome in (OUTCOME_STOP_HIT, OUTCOME_AMBIGUOUS): + trade.close_price = trade.stop_loss + else: + continue + + trade.status = "closed" + trade.closed_at = datetime.combine( + outcome_date, datetime.min.time(), tzinfo=timezone.utc + ) + closed += 1 + + if closed: + await db.commit() + return closed diff --git a/frontend/src/components/signals/MyTradesPanel.tsx b/frontend/src/components/signals/MyTradesPanel.tsx new file mode 100644 index 0000000..4403b03 --- /dev/null +++ b/frontend/src/components/signals/MyTradesPanel.tsx @@ -0,0 +1,110 @@ +import { useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { usePaperTrades } 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 { + return `${v >= 0 ? '+' : '−'}$${Math.abs(v).toFixed(2)}`; +} +function fmtR(v: number | null): string { + return v === null ? '—' : `${v > 0 ? '+' : ''}${v.toFixed(2)}R`; +} +function color(v: number | null): string { + if (v === null) return 'text-gray-400'; + if (v > 0) return 'text-emerald-400'; + if (v < 0) return 'text-red-400'; + return 'text-gray-300'; +} + +function Stat({ label, value, valueClass = 'text-gray-100', sub }: { + label: string; value: string; valueClass?: string; sub?: string; +}) { + return ( +
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ ); +} + +export function MyTradesPanel() { + const { data: closed, isLoading } = usePaperTrades('closed'); + + const stats = useMemo(() => { + const rows = (closed ?? []).map((t) => ({ t, p: tradePnl(t) })); + const rs = rows.map((r) => r.p?.r).filter((r): r is number => r != null); + const pnls = rows.map((r) => r.p?.pnl ?? 0); + const wins = pnls.filter((p) => p > 0).length; + const losses = pnls.filter((p) => p < 0).length; + const decided = wins + losses; + return { + total: rows.length, + wins, + losses, + hitRate: decided ? (wins / decided) * 100 : null, + avgR: rs.length ? rs.reduce((a, b) => a + b, 0) / rs.length : null, + totalR: rs.length ? rs.reduce((a, b) => a + b, 0) : null, + totalPnl: pnls.reduce((a, b) => a + b, 0), + rows, + }; + }, [closed]); + + if (isLoading) return null; + + return ( +
+ {stats.total === 0 ? ( + + No closed trades yet. Take setups as paper trades and they’ll resolve here when price hits + the stop or target (or when you sell). + + ) : ( +
+
+ + + + +
+ +
+ + + + + + + + + + + + + + {stats.rows.map(({ t, p }) => ( + + + + + + + + + + ))} + +
TickerDirEntryExitP&LRClosed
+ {t.symbol} + + {t.direction} + {formatPrice(t.entry_price)}{t.close_price != null ? formatPrice(t.close_price) : '—'}{p ? money(p.pnl) : '—'}{p?.r != null ? fmtR(p.r) : '—'}{t.closed_at ? new Date(t.closed_at).toLocaleDateString() : '—'}
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/signals/TrackRecordPanel.tsx b/frontend/src/components/signals/TrackRecordPanel.tsx index 50cdffe..a536cc8 100644 --- a/frontend/src/components/signals/TrackRecordPanel.tsx +++ b/frontend/src/components/signals/TrackRecordPanel.tsx @@ -12,6 +12,7 @@ import { SkeletonCard } from '../ui/Skeleton'; import { useToast } from '../ui/Toast'; import { RECOMMENDATION_ACTION_LABELS } from '../../lib/recommendation'; import { BacktestPanel } from './BacktestPanel'; +import { MyTradesPanel } from './MyTradesPanel'; import type { OutcomeBucketStats } from '../../lib/types'; function fmtR(value: number | null): string { @@ -138,6 +139,10 @@ export function TrackRecordPanel() { return (
+ {/* Your real, realized results come first; the signal/theoretical record follows. */} + +
+