Add TopstepX platform support, drain withdrawal stages, and perfect-path calendar projection

- TopstepXClient (ProjectX Gateway): API-key auth, SignalR user hub,
  Trade/search daily P&L, emulated OCO exits, books-invariant
  withdrawal detection for $0-start accounts
- Platform-aware client pool, credentials (api_key column), and
  credential form; $0-size account types match by prefix alone
- Drain stages: min-day ($5-$15 randomized) MNQ legs for the
  configured days, then wait for the 50% withdrawal; stage pills
  show 'Stage N – Drain'
- Account calendar projects the perfect path: future days book their
  planned targets, simulated 50% withdrawals, payout marker

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-12 16:25:52 -05:00
co-authored by Claude Fable 5
parent 1bca441247
commit 50a5eb4713
21 changed files with 1854 additions and 142 deletions
+49 -16
View File
@@ -186,26 +186,35 @@ function AccountTypeCell({ account, firm, onAssigned }: {
);
}
/** Inline credential entry, shown to the right of a firm that has none. */
/** Inline credential entry, shown to the right of a firm that has none.
* Tradovate wants username + password; TopstepX wants the API key (username
* optional for now, though login needs it eventually — password unused). */
function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
const isTopstepX = firm.platform === 'topstepx';
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [apiKey, setApiKey] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const canSave = isTopstepX
? apiKey.trim().length > 0
: username.trim().length > 0 && password.trim().length > 0;
async function save() {
if (!username.trim() || !password.trim()) return;
if (!canSave) return;
setSaving(true);
setError('');
try {
const res = await fetch(`/api/firms/${firm.id}/credentials`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
body: JSON.stringify(isTopstepX ? { username, apiKey } : { username, password }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Failed');
setUsername('');
setPassword('');
setApiKey('');
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed');
@@ -220,21 +229,32 @@ function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => vo
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
<input
className={cls}
placeholder="Tradovate username"
placeholder={isTopstepX ? 'Username (optional)' : 'Tradovate username'}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
className={cls}
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void save(); }}
/>
{isTopstepX ? (
<input
className={`${cls} w-52`}
type="password"
placeholder="TopstepX API key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void save(); }}
/>
) : (
<input
className={cls}
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void save(); }}
/>
)}
<button
onClick={save}
disabled={saving || !username.trim() || !password.trim()}
disabled={saving || !canSave}
className="px-2.5 py-1 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-xs font-semibold rounded-md transition-colors"
>
{saving ? '…' : 'Save'}
@@ -346,7 +366,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
<div className="flex items-center gap-1.5">
<span>${(account.effectiveProfitTarget ?? cfg?.profitTarget ?? 0).toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${stagePillClasses(account.stage)}`}>
Stage {account.stage}
Stage {account.stage}{account.stageDrain ? ' – Drain' : ''}
</span>
</div>
{account.dailyTarget && (
@@ -449,6 +469,19 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
{tradeSymbol} BANNED
</span>
)}
{firm.platform === 'topstepx' && (
<span className="text-xs bg-indigo-100 text-indigo-700 px-1.5 py-0.5 rounded font-semibold">
TopstepX
</span>
)}
{firm.platform === 'topstepx' && hasCredentials && !firm.username && (
<span
className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-semibold"
title="TopstepX login needs the platform username the API key was generated for — remove and re-save the credentials with it"
>
Username missing
</span>
)}
{hasCredentials && !state.connected && (
<span className="text-xs bg-slate-200 text-slate-600 px-1.5 py-0.5 rounded font-semibold">
Connecting…
@@ -469,7 +502,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
<td colSpan={5} className="px-4 py-2.5">
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<span className="text-xs text-slate-500">
Remove the saved Tradovate login for {firm.firm}? Its accounts disappear until you enter one again.
Remove the saved {firm.platform === 'topstepx' ? 'TopstepX API key' : 'Tradovate login'} for {firm.firm}? Its accounts disappear until you enter one again.
</span>
<button
onClick={removeCredentials}
@@ -495,7 +528,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
<button
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
className="text-slate-400 hover:text-red-500 hover:bg-red-50 p-1 rounded-md transition-colors inline-flex"
title="Remove saved Tradovate login"
title={firm.platform === 'topstepx' ? 'Remove saved TopstepX API key' : 'Remove saved Tradovate login'}
>
<KeyOffIcon />
</button>