feat: paper trading capital controls — add, withdraw, and full reset

Three distinct capital operations on the Trading Controls page:

- Set Capital: overwrites pool balances to a new amount (existing)
- Add/Withdraw: adjusts active pool by a delta without touching
  positions, orders, or history. Validates sufficient balance for
  withdrawals. Logged to reserve_pool_ledger as manual_adjustment.
- Reset Everything: nuclear option — deletes all positions, orders,
  trading decisions, stop levels, snapshots, backtests, notifications,
  and circuit breaker events, then resets capital fresh. Red button
  with double-confirmation dialog.

Backend: POST /api/trading/capital/adjust and POST /api/trading/reset
Frontend: CapitalCard rebuilt with three sections and confirmation UIs
This commit is contained in:
Celes Renata
2026-04-17 02:23:26 +00:00
parent 45752b9a29
commit 90614dd7bb
3 changed files with 245 additions and 4 deletions
+32
View File
@@ -309,6 +309,38 @@ export function useBacktestLaunch() {
});
}
/** Full paper trading reset: clears all positions, orders, decisions, and resets capital. */
export function useResetPaperTrading() {
const qc = useQueryClient();
return useMutation({
mutationFn: (initial_capital: number) =>
apiPost<{ reset: boolean; initial_capital: number; active_pool: number; reserve_pool: number }>(
'trading', '/api/trading/reset', { initial_capital },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['trading-status'] });
qc.invalidateQueries({ queryKey: ['trading-decisions'] });
qc.invalidateQueries({ queryKey: ['trading-metrics'] });
qc.invalidateQueries({ queryKey: ['trading-metrics-history'] });
},
});
}
/** Add or subtract capital from the active pool without resetting anything else. */
export function useAdjustCapital() {
const qc = useQueryClient();
return useMutation({
mutationFn: (amount: number) =>
apiPost<{ adjusted: number; active_pool: number; reserve_pool: number; total_value: number }>(
'trading', '/api/trading/capital/adjust', { amount },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['trading-status'] });
qc.invalidateQueries({ queryKey: ['trading-metrics'] });
},
});
}
/** Update notification preferences. */
export function useUpdateNotificationConfig() {
const qc = useQueryClient();
+100 -4
View File
@@ -11,6 +11,7 @@ import {
useCompetitiveStatus,
useToggleCompetitive,
} from '../api/hooks';
import { useResetPaperTrading, useAdjustCapital } from '../api/tradingHooks';
import { StatusBadge, LoadingSpinner, Card } from '../components/ui';
export function TradingPage() {
@@ -21,6 +22,8 @@ export function TradingPage() {
const { data: competitiveStatus } = useCompetitiveStatus();
const setMode = useSetTradingMode();
const setCapital = useSetTradingCapital();
const resetTrading = useResetPaperTrading();
const adjustCapital = useAdjustCapital();
const reviewApproval = useReviewApproval();
const toggleMacro = useToggleMacro();
const toggleCompetitive = useToggleCompetitive();
@@ -90,7 +93,14 @@ export function TradingPage() {
</Card>
{/* Paper Trading Capital */}
<CapitalCard onSetCapital={(amount) => setCapital.mutate(amount)} isPending={setCapital.isPending} />
<CapitalCard
onSetCapital={(amount) => setCapital.mutate(amount)}
onReset={(amount) => resetTrading.mutate(amount)}
onAdjust={(amount) => adjustCapital.mutate(amount)}
isPending={setCapital.isPending}
isResetting={resetTrading.isPending}
isAdjusting={adjustCapital.isPending}
/>
{/* Macro Signal Layer Toggle */}
<Card>
@@ -299,15 +309,27 @@ function ApprovalRow({ approval, onReview }: {
}
function CapitalCard({ onSetCapital, isPending }: { onSetCapital: (amount: number) => void; isPending: boolean }) {
function CapitalCard({ onSetCapital, onReset, onAdjust, isPending, isResetting, isAdjusting }: {
onSetCapital: (amount: number) => void;
onReset: (amount: number) => void;
onAdjust: (amount: number) => void;
isPending: boolean;
isResetting: boolean;
isAdjusting: boolean;
}) {
const [amount, setAmount] = useState('100000');
const [adjustAmount, setAdjustAmount] = useState('');
const [showConfirm, setShowConfirm] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const presets = [10000, 50000, 100000, 500000];
const busy = isPending || isResetting || isAdjusting;
return (
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Paper Trading Capital</h2>
{/* Set Capital */}
<div className="flex flex-wrap items-end gap-3">
<div>
<label htmlFor="capital-input" className="mb-1 block text-xs text-gray-500">Initial Capital ($)</label>
@@ -338,7 +360,7 @@ function CapitalCard({ onSetCapital, isPending }: { onSetCapital: (amount: numbe
</div>
<button
onClick={() => setShowConfirm(true)}
disabled={isPending || !amount || Number(amount) <= 0}
disabled={busy || !amount || Number(amount) <= 0}
className="rounded-md bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
Set Capital
@@ -354,7 +376,7 @@ function CapitalCard({ onSetCapital, isPending }: { onSetCapital: (amount: numbe
<div className="mt-3 flex gap-2">
<button
onClick={() => { onSetCapital(Number(amount)); setShowConfirm(false); }}
disabled={isPending}
disabled={busy}
className="rounded-md bg-orange-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-orange-700 disabled:opacity-50"
>
{isPending ? 'Setting…' : 'Confirm'}
@@ -368,6 +390,80 @@ function CapitalCard({ onSetCapital, isPending }: { onSetCapital: (amount: numbe
</div>
</div>
)}
{/* Add / Subtract Capital */}
<div className="mt-4 border-t border-surface-700 pt-4">
<h3 className="mb-2 text-xs font-medium text-gray-500">Adjust Capital</h3>
<div className="flex items-end gap-2">
<div>
<label htmlFor="adjust-input" className="mb-1 block text-xs text-gray-500">Amount ($)</label>
<input
id="adjust-input"
type="number"
step="100"
value={adjustAmount}
onChange={(e) => setAdjustAmount(e.target.value)}
placeholder="5000"
className="w-36 rounded-md border border-surface-700 bg-surface-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-600 focus:border-brand-500 focus:outline-none"
/>
</div>
<button
onClick={() => { onAdjust(Math.abs(Number(adjustAmount))); setAdjustAmount(''); }}
disabled={busy || !adjustAmount || Number(adjustAmount) <= 0}
className="rounded-md bg-green-700 px-3 py-1.5 text-sm font-medium text-white hover:bg-green-600 disabled:opacity-50"
>
+ Add
</button>
<button
onClick={() => { onAdjust(-Math.abs(Number(adjustAmount))); setAdjustAmount(''); }}
disabled={busy || !adjustAmount || Number(adjustAmount) <= 0}
className="rounded-md bg-red-700 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-600 disabled:opacity-50"
>
Withdraw
</button>
</div>
{isAdjusting && <p className="mt-1 text-xs text-gray-500">Adjusting</p>}
</div>
{/* Full Reset */}
<div className="mt-4 border-t border-surface-700 pt-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xs font-medium text-gray-500">Full Reset</h3>
<p className="text-[10px] text-gray-600">Wipes all positions, orders, decisions, and history. Resets capital to the amount above.</p>
</div>
<button
onClick={() => setShowResetConfirm(true)}
disabled={busy || !amount || Number(amount) <= 0}
className="rounded-md border border-red-700/50 bg-red-900/20 px-3 py-1.5 text-sm font-medium text-red-400 hover:bg-red-900/40 disabled:opacity-50"
>
Reset Everything
</button>
</div>
{showResetConfirm && (
<div className="mt-3 rounded-lg border border-red-700/50 bg-red-900/20 p-4">
<p className="text-sm text-red-300">
This will <span className="font-semibold">permanently delete</span> all positions, orders, trading decisions, stop levels, portfolio snapshots, and backtest data.
Capital will be reset to <span className="font-semibold">${Number(amount).toLocaleString()}</span>.
</p>
<div className="mt-3 flex gap-2">
<button
onClick={() => { onReset(Number(amount)); setShowResetConfirm(false); }}
disabled={busy}
className="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{isResetting ? 'Resetting…' : 'Yes, Reset Everything'}
</button>
<button
onClick={() => setShowResetConfirm(false)}
className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800"
>
Cancel
</button>
</div>
</div>
)}
</div>
</Card>
);
}
+113
View File
@@ -354,6 +354,119 @@ async def set_capital(body: CapitalRequest) -> dict[str, Any]:
}
@app.post("/api/trading/capital/adjust")
async def adjust_capital(body: dict[str, Any]) -> dict[str, Any]:
"""Add or subtract capital from the active pool without resetting anything.
Body: { "amount": 5000 } to add, { "amount": -2000 } to subtract.
Positions, orders, decisions, and history are untouched.
"""
if engine is None:
raise HTTPException(503, "Engine not initialised")
amount = body.get("amount", 0)
if not isinstance(amount, (int, float)) or amount == 0:
raise HTTPException(400, "amount must be a non-zero number")
ps = engine.portfolio_state
if ps is None:
raise HTTPException(400, "No portfolio state — set capital first")
new_active = ps.active_pool + amount
if new_active < 0:
raise HTTPException(400, f"Cannot subtract ${abs(amount):,.2f} — only ${ps.active_pool:,.2f} available in active pool")
previous_active = ps.active_pool
ps.active_pool = new_active
ps.cash = ps.cash + amount
ps.total_value = ps.total_value + amount
# Record in reserve pool ledger for audit
if engine.pool:
try:
trigger = "manual_adjustment"
note = f"{'Added' if amount > 0 else 'Withdrew'} ${abs(amount):,.2f} (active pool: ${previous_active:,.2f} → ${new_active:,.2f})"
await engine.pool.execute(
"INSERT INTO reserve_pool_ledger (amount, balance_after, trigger_type, notes) "
"VALUES ($1, $2, $3, $4)",
amount, new_active, trigger, note,
)
except Exception:
pass
return {
"adjusted": amount,
"active_pool": ps.active_pool,
"reserve_pool": ps.reserve_pool,
"total_value": ps.total_value,
}
@app.post("/api/trading/reset")
async def reset_paper_trading(body: CapitalRequest) -> dict[str, Any]:
"""Full paper trading reset: clear all positions, orders, decisions,
stop levels, and snapshots, then reset capital to the specified amount.
This is a destructive operation — all trading history is wiped.
"""
if engine is None:
raise HTTPException(503, "Engine not initialised")
capital = body.initial_capital
if capital <= 0:
raise HTTPException(400, "initial_capital must be positive")
reserve_pct = engine.config.reserve_siphon_pct
reserve = capital * reserve_pct
active = capital - reserve
# Clear trading state in the database
if engine.pool:
try:
async with engine.pool.acquire() as conn:
async with conn.transaction():
# Order matters due to FK constraints
await conn.execute("DELETE FROM backtest_trades")
await conn.execute("DELETE FROM backtest_runs")
await conn.execute("DELETE FROM position_stop_levels")
await conn.execute("DELETE FROM trading_decisions")
await conn.execute("DELETE FROM portfolio_snapshots")
await conn.execute("DELETE FROM reserve_pool_ledger")
await conn.execute("DELETE FROM risk_tier_history")
await conn.execute("DELETE FROM circuit_breaker_events")
await conn.execute("DELETE FROM notifications")
# Clear orders and their events
await conn.execute("DELETE FROM order_events")
await conn.execute("DELETE FROM orders")
# Re-seed reserve pool ledger
await conn.execute(
"INSERT INTO reserve_pool_ledger (amount, balance_after, trigger_type, notes) "
"VALUES ($1, $2, 'initial', $3)",
reserve, reserve,
f"Paper trading reset to ${capital:,.2f}",
)
except Exception:
logger.exception("Failed to clear trading tables during reset")
raise HTTPException(500, "Database reset failed")
# Reset in-memory engine state
from services.trading.models import CircuitBreakerState, PortfolioState
engine.portfolio_state = PortfolioState(
total_value=capital,
cash=capital,
active_pool=active,
reserve_pool=reserve,
)
engine.circuit_breaker_state = CircuitBreakerState()
return {
"reset": True,
"initial_capital": capital,
"active_pool": active,
"reserve_pool": reserve,
}
# ---------------------------------------------------------------------------
# Decision Audit Trail
# ---------------------------------------------------------------------------