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:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user