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:
@@ -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