feat: add paper trading capital controls — API endpoint + UI with presets, fix status/metrics to read real state, fix migration duplicates
This commit is contained in:
@@ -397,6 +397,15 @@ export function useSetTradingMode() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSetTradingCapital() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (initial_capital: number) =>
|
||||||
|
apiPut<{ initial_capital: number; active_pool: number; reserve_pool: number }>('trading', '/api/trading/capital', { initial_capital }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['trading-config'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function usePendingApprovals() {
|
export function usePendingApprovals() {
|
||||||
return useGet<Approval[]>(['pending-approvals'], 'query', '/api/admin/trading/approvals');
|
return useGet<Approval[]>(['pending-approvals'], 'query', '/api/admin/trading/approvals');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
useTradingConfig,
|
useTradingConfig,
|
||||||
useSetTradingMode,
|
useSetTradingMode,
|
||||||
|
useSetTradingCapital,
|
||||||
usePendingApprovals,
|
usePendingApprovals,
|
||||||
useReviewApproval,
|
useReviewApproval,
|
||||||
useActiveLockouts,
|
useActiveLockouts,
|
||||||
@@ -19,6 +20,7 @@ export function TradingPage() {
|
|||||||
const { data: macroStatus } = useMacroStatus();
|
const { data: macroStatus } = useMacroStatus();
|
||||||
const { data: competitiveStatus } = useCompetitiveStatus();
|
const { data: competitiveStatus } = useCompetitiveStatus();
|
||||||
const setMode = useSetTradingMode();
|
const setMode = useSetTradingMode();
|
||||||
|
const setCapital = useSetTradingCapital();
|
||||||
const reviewApproval = useReviewApproval();
|
const reviewApproval = useReviewApproval();
|
||||||
const toggleMacro = useToggleMacro();
|
const toggleMacro = useToggleMacro();
|
||||||
const toggleCompetitive = useToggleCompetitive();
|
const toggleCompetitive = useToggleCompetitive();
|
||||||
@@ -87,6 +89,9 @@ export function TradingPage() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Paper Trading Capital */}
|
||||||
|
<CapitalCard onSetCapital={(amount) => setCapital.mutate(amount)} isPending={setCapital.isPending} />
|
||||||
|
|
||||||
{/* Macro Signal Layer Toggle */}
|
{/* Macro Signal Layer Toggle */}
|
||||||
<Card>
|
<Card>
|
||||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Macro Signal Layer</h2>
|
<h2 className="mb-3 text-sm font-medium text-gray-400">Macro Signal Layer</h2>
|
||||||
@@ -292,3 +297,77 @@ function ApprovalRow({ approval, onReview }: {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function CapitalCard({ onSetCapital, isPending }: { onSetCapital: (amount: number) => void; isPending: boolean }) {
|
||||||
|
const [amount, setAmount] = useState('100000');
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
|
const presets = [10000, 50000, 100000, 500000];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<h2 className="mb-3 text-sm font-medium text-gray-400">Paper Trading Capital</h2>
|
||||||
|
<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>
|
||||||
|
<input
|
||||||
|
id="capital-input"
|
||||||
|
type="number"
|
||||||
|
min="100"
|
||||||
|
step="1000"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
className="w-40 rounded-md border border-surface-700 bg-surface-900 px-3 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{presets.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setAmount(String(p))}
|
||||||
|
className={`rounded-md border px-2 py-1.5 text-xs font-medium ${
|
||||||
|
amount === String(p)
|
||||||
|
? 'border-brand-500 bg-brand-600/20 text-brand-300'
|
||||||
|
: 'border-surface-700 bg-surface-900 text-gray-400 hover:bg-surface-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
${(p / 1000).toFixed(0)}k
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm(true)}
|
||||||
|
disabled={isPending || !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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showConfirm && (
|
||||||
|
<div className="mt-4 rounded-lg border border-orange-700/50 bg-orange-900/20 p-4">
|
||||||
|
<p className="text-sm text-orange-300">
|
||||||
|
This will reset the paper trading pools to <span className="font-semibold">${Number(amount).toLocaleString()}</span>.
|
||||||
|
Any existing pool balances will be overwritten.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { onSetCapital(Number(amount)); setShowConfirm(false); }}
|
||||||
|
disabled={isPending}
|
||||||
|
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'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm(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>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ CREATE INDEX idx_notifications_event ON notifications(event_type, created_at DES
|
|||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
-- Insert default trading engine configuration (moderate tier defaults)
|
-- Insert default trading engine configuration (moderate tier defaults)
|
||||||
|
-- Use a singleton pattern: only insert if no rows exist
|
||||||
INSERT INTO trading_engine_config (
|
INSERT INTO trading_engine_config (
|
||||||
enabled, paused, risk_tier, reserve_siphon_pct,
|
enabled, paused, risk_tier, reserve_siphon_pct,
|
||||||
polling_interval_seconds, gradual_entry_tranches,
|
polling_interval_seconds, gradual_entry_tranches,
|
||||||
@@ -339,7 +340,8 @@ INSERT INTO trading_engine_config (
|
|||||||
notification_sms_enabled, notification_email_enabled,
|
notification_sms_enabled, notification_email_enabled,
|
||||||
notification_rate_limit_sms_per_hour, notification_rate_limit_email_per_hour,
|
notification_rate_limit_sms_per_hour, notification_rate_limit_email_per_hour,
|
||||||
notification_daily_summary_time
|
notification_daily_summary_time
|
||||||
) VALUES (
|
)
|
||||||
|
SELECT
|
||||||
FALSE, FALSE, 'moderate', 0.20,
|
FALSE, FALSE, 'moderate', 0.20,
|
||||||
60, 3,
|
60, 3,
|
||||||
30.0, 15,
|
30.0, 15,
|
||||||
@@ -358,8 +360,9 @@ INSERT INTO trading_engine_config (
|
|||||||
FALSE, FALSE,
|
FALSE, FALSE,
|
||||||
10, 20,
|
10, 20,
|
||||||
'16:30'
|
'16:30'
|
||||||
);
|
WHERE NOT EXISTS (SELECT 1 FROM trading_engine_config);
|
||||||
|
|
||||||
-- Insert initial reserve pool ledger entry with zero balance
|
-- Insert initial reserve pool ledger entry with zero balance (only if empty)
|
||||||
INSERT INTO reserve_pool_ledger (amount, balance_after, trigger_type, notes)
|
INSERT INTO reserve_pool_ledger (amount, balance_after, trigger_type, notes)
|
||||||
VALUES (0.0, 0.0, 'initial', 'Initial reserve pool entry');
|
SELECT 0.0, 0.0, 'initial', 'Initial reserve pool entry'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM reserve_pool_ledger);
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ class ConfigUpdateRequest(BaseModel):
|
|||||||
absolute_position_cap: Optional[float] = None
|
absolute_position_cap: Optional[float] = None
|
||||||
active_pool_minimum: Optional[float] = None
|
active_pool_minimum: Optional[float] = None
|
||||||
micro_trading_enabled: Optional[bool] = None
|
micro_trading_enabled: Optional[bool] = None
|
||||||
|
max_open_positions: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CapitalRequest(BaseModel):
|
||||||
|
"""Body for PUT /api/trading/capital."""
|
||||||
|
|
||||||
|
initial_capital: float
|
||||||
|
|
||||||
|
|
||||||
class BacktestRequest(BaseModel):
|
class BacktestRequest(BaseModel):
|
||||||
@@ -255,6 +262,60 @@ async def resume_engine() -> dict[str, bool]:
|
|||||||
return {"paused": False}
|
return {"paused": False}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/trading/capital")
|
||||||
|
async def set_capital(body: CapitalRequest) -> dict[str, Any]:
|
||||||
|
"""Set or reset the paper trading capital.
|
||||||
|
|
||||||
|
Allocates the given amount as initial capital, splitting between
|
||||||
|
active pool and reserve pool based on the current reserve_siphon_pct.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
ps = engine.portfolio_state
|
||||||
|
previous = {
|
||||||
|
"total_value": ps.total_value if ps else 0.0,
|
||||||
|
"active_pool": ps.active_pool if ps else 0.0,
|
||||||
|
"reserve_pool": ps.reserve_pool if ps else 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
from services.trading.models import PortfolioState
|
||||||
|
engine.portfolio_state = PortfolioState(
|
||||||
|
total_value=capital,
|
||||||
|
cash=capital,
|
||||||
|
active_pool=active,
|
||||||
|
reserve_pool=reserve,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Record in reserve pool ledger
|
||||||
|
if engine.pool:
|
||||||
|
try:
|
||||||
|
await engine.pool.execute(
|
||||||
|
"INSERT INTO reserve_pool_ledger (amount, balance_after, trigger_type, notes) "
|
||||||
|
"VALUES ($1, $2, 'capital_reset', $3)",
|
||||||
|
reserve, reserve,
|
||||||
|
f"Capital set to ${capital:,.2f} (active=${active:,.2f}, reserve=${reserve:,.2f})",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # Non-critical
|
||||||
|
|
||||||
|
return {
|
||||||
|
"initial_capital": capital,
|
||||||
|
"active_pool": active,
|
||||||
|
"reserve_pool": reserve,
|
||||||
|
"reserve_siphon_pct": reserve_pct,
|
||||||
|
"previous": previous,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Decision Audit Trail
|
# Decision Audit Trail
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user