feat: split add/list routes and add breadcrumb navigation to all pages

- Extract AddSubscriptionPage, AddIngestionPage, AddPaperRunPage as dedicated routes
- Add AppBreadcrumb component using useMatches + handle.crumb from data router
- Rewrite App.tsx to use createBrowserRouter with nested routes and crumb handles
- Add AppBreadcrumb to all pages (Subscriptions, Ingestion, Metrics, Paper Runs, Run Detail)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 14:19:43 +02:00
parent 502959ac96
commit c95436f5dd
10 changed files with 910 additions and 825 deletions

View File

@@ -1,28 +1,71 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { Outlet, RouterProvider, createBrowserRouter, Navigate } from 'react-router-dom';
import AppNavbar from './components/AppNavbar';
import SubscriptionsPage from './pages/SubscriptionsPage';
import AddSubscriptionPage from './pages/AddSubscriptionPage';
import MetricsPage from './pages/MetricsPage';
import IngestionPage from './pages/IngestionPage';
import AddIngestionPage from './pages/AddIngestionPage';
import PaperRunsPage from './pages/PaperRunsPage';
import AddPaperRunPage from './pages/AddPaperRunPage';
import PaperRunDetailPage from './pages/PaperRunDetailPage';
const queryClient = new QueryClient();
function RootLayout() {
return (
<>
<AppNavbar />
<Outlet />
</>
);
}
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
children: [
{ index: true, element: <Navigate to="/subscriptions" replace /> },
{
path: 'subscriptions',
handle: { crumb: 'Subscriptions' },
children: [
{ index: true, element: <SubscriptionsPage /> },
{ path: 'new', handle: { crumb: 'Add Subscription' }, element: <AddSubscriptionPage /> },
],
},
{
path: 'metrics',
handle: { crumb: 'Metrics' },
element: <MetricsPage />,
},
{
path: 'ingestion',
handle: { crumb: 'Ingestion' },
children: [
{ index: true, element: <IngestionPage /> },
{ path: 'new', handle: { crumb: 'Add Config' }, element: <AddIngestionPage /> },
],
},
{
path: 'paper-runs',
handle: { crumb: 'Paper Runs' },
children: [
{ index: true, element: <PaperRunsPage /> },
{ path: 'new', handle: { crumb: 'New Run' }, element: <AddPaperRunPage /> },
{ path: ':id', handle: { crumb: 'Run Detail' }, element: <PaperRunDetailPage /> },
],
},
{ path: '*', element: <Navigate to="/subscriptions" replace /> },
],
},
]);
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppNavbar />
<Routes>
<Route path="/subscriptions" element={<SubscriptionsPage />} />
<Route path="/metrics" element={<MetricsPage />} />
<Route path="/ingestion" element={<IngestionPage />} />
<Route path="/paper-runs" element={<PaperRunsPage />} />
<Route path="/paper-runs/:id" element={<PaperRunDetailPage />} />
<Route path="*" element={<Navigate to="/subscriptions" replace />} />
</Routes>
</BrowserRouter>
<RouterProvider router={router} />
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,40 @@
import { Breadcrumb } from 'react-bootstrap';
import { Link, useMatches } from 'react-router-dom';
export interface CrumbHandle {
crumb: string;
}
/**
* Reads `handle.crumb` from each matched route and renders a Bootstrap
* breadcrumb. Routes that don't define a handle are skipped.
*/
export default function AppBreadcrumb() {
const matches = useMatches();
const crumbs = matches
.filter((m) => m.handle && (m.handle as CrumbHandle).crumb)
.map((m) => ({
label: (m.handle as CrumbHandle).crumb,
path: m.pathname,
}));
if (crumbs.length <= 1) return null;
return (
<Breadcrumb className="mb-3">
{crumbs.map((c, i) => {
const isLast = i === crumbs.length - 1;
return isLast ? (
<Breadcrumb.Item key={c.path} active>
{c.label}
</Breadcrumb.Item>
) : (
<Breadcrumb.Item key={c.path} linkAs={Link} linkProps={{ to: c.path }}>
{c.label}
</Breadcrumb.Item>
);
})}
</Breadcrumb>
);
}

View File

@@ -0,0 +1,137 @@
import { useState, useMemo, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Button,
Card,
Col,
Container,
Form,
Row,
Spinner,
} from 'react-bootstrap';
import { useCreateIngestConfig, useExchangeInstruments } from '../api/hooks';
import AppBreadcrumb from '../components/AppBreadcrumb';
const SUPPORTED_INGEST_EXCHANGES = ['binance_spot'];
export default function AddIngestionPage() {
const navigate = useNavigate();
const [exchange, setExchange] = useState('');
const [pair, setPair] = useState('');
const [retentionDays, setRetentionDays] = useState(365);
const createMutation = useCreateIngestConfig();
const supportsLookup = exchange === 'binance_spot';
const instruments = useExchangeInstruments(supportsLookup ? exchange : null);
const sortedInstruments = useMemo(() => {
if (!instruments.data) return [];
return [...instruments.data].sort((a, b) =>
`${a.base}-${a.quote}`.localeCompare(`${b.base}-${b.quote}`),
);
}, [instruments.data]);
function handleExchangeChange(name: string) {
setExchange(name);
setPair('');
}
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!exchange || !pair) return;
createMutation.mutate(
{ exchange, pair, retention_days: retentionDays },
{ onSuccess: () => navigate('/ingestion') },
);
}
return (
<Container>
<AppBreadcrumb />
<Row>
<Col lg={6}>
<Card>
<Card.Header>Add Ingestion Config</Card.Header>
<Card.Body>
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>Exchange</Form.Label>
<Form.Select
value={exchange}
onChange={(e) => handleExchangeChange(e.target.value)}
>
<option value="">Select an exchange...</option>
{SUPPORTED_INGEST_EXCHANGES.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</Form.Select>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>
Pair (BASE-QUOTE)
{supportsLookup && instruments.isLoading && (
<Spinner size="sm" animation="border" className="ms-2" />
)}
</Form.Label>
{supportsLookup ? (
<>
<Form.Control
value={pair}
onChange={(e) => setPair(e.target.value)}
placeholder="Type to search instruments..."
list="ingest-instrument-pairs"
/>
<datalist id="ingest-instrument-pairs">
{sortedInstruments.map((inst) => {
const value = `${inst.base}-${inst.quote}`;
return <option key={value} value={value} />;
})}
</datalist>
</>
) : (
<Form.Control
value={pair}
onChange={(e) => setPair(e.target.value)}
placeholder="e.g. BTC-USDC"
/>
)}
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Retention (days)</Form.Label>
<Form.Control
type="number"
min={1}
value={retentionDays}
onChange={(e) => setRetentionDays(parseInt(e.target.value) || 365)}
/>
</Form.Group>
<div className="d-flex gap-2">
<Button
type="submit"
disabled={createMutation.isPending || !exchange || !pair}
>
{createMutation.isPending ? <Spinner size="sm" animation="border" /> : 'Create'}
</Button>
<Button variant="outline-secondary" onClick={() => navigate('/ingestion')}>
Cancel
</Button>
</div>
{createMutation.isError && (
<Alert variant="danger" className="mt-3">
{(createMutation.error as Error).message}
</Alert>
)}
</Form>
</Card.Body>
</Card>
</Col>
</Row>
</Container>
);
}

View File

@@ -0,0 +1,481 @@
import { useState, useMemo, useEffect, type FormEvent } from 'react';
import {
Alert,
Button,
Card,
Col,
Container,
Form,
Row,
Spinner,
} from 'react-bootstrap';
import { useNavigate } from 'react-router-dom';
import {
useCreatePaperRun,
useExchanges,
useExchangeInstruments,
useInstrumentDataRange,
useSubscriptions,
useIngestConfigs,
} from '../api/hooks';
import AppBreadcrumb from '../components/AppBreadcrumb';
export default function AddPaperRunPage() {
const navigate = useNavigate();
const createMutation = useCreatePaperRun();
// Instrument selection
const [exchange, setExchange] = useState('');
const [pair, setPair] = useState('');
const exchanges = useExchanges();
const subscriptions = useSubscriptions();
const ingestConfigs = useIngestConfigs();
const { availableExchanges, availablePairsByExchange } = useMemo(() => {
const exSet = new Set<string>();
const pairMap = new Map<string, Set<string>>();
const addEntry = (exchangeName: string, base: string, quote: string) => {
const ex = exchangeName.toLowerCase();
exSet.add(ex);
if (!pairMap.has(ex)) pairMap.set(ex, new Set());
pairMap.get(ex)!.add(`${base.toUpperCase()}-${quote.toUpperCase()}`);
};
for (const sub of subscriptions.data ?? []) {
addEntry(sub.exchange, sub.base_asset, sub.quote_asset);
}
for (const cfg of ingestConfigs.data ?? []) {
addEntry(cfg.exchange, cfg.base_asset, cfg.quote_asset);
}
return { availableExchanges: exSet, availablePairsByExchange: pairMap };
}, [subscriptions.data, ingestConfigs.data]);
const dataLoaded = !subscriptions.isLoading && !ingestConfigs.isLoading;
const filteredExchanges = useMemo(() => {
if (!exchanges.data || !dataLoaded) return exchanges.data ?? [];
return exchanges.data.filter((ex) => availableExchanges.has(ex.name.toLowerCase()));
}, [exchanges.data, availableExchanges, dataLoaded]);
const selectedExchange = useMemo(
() => exchanges.data?.find((ex) => ex.name === exchange) ?? null,
[exchange, exchanges.data],
);
const supportsLookup = selectedExchange?.supports_instrument_lookup ?? false;
const localPairs = useMemo(() => {
const pairs = availablePairsByExchange.get(exchange.toLowerCase());
if (!pairs) return [];
return [...pairs].sort().map((p) => {
const [base, quote] = p.split('-');
return { base, quote };
});
}, [exchange, availablePairsByExchange]);
const useLocalPairs = localPairs.length > 0;
const instruments = useExchangeInstruments(
supportsLookup && !useLocalPairs ? exchange : null,
);
const sortedInstruments = useMemo(() => {
if (useLocalPairs) return localPairs;
if (!instruments.data) return [];
return [...instruments.data].sort((a, b) =>
`${a.base}-${a.quote}`.localeCompare(`${b.base}-${b.quote}`),
);
}, [useLocalPairs, localPairs, instruments.data]);
const [parsedBase, parsedQuote] = useMemo(() => {
const parts = pair.split('-');
if (parts.length === 2 && parts[0] && parts[1]) return [parts[0], parts[1]];
return ['', ''];
}, [pair]);
// Execution parameters
const [latencyMs, setLatencyMs] = useState(100);
const [feesPercent, setFeesPercent] = useState(0.1);
const [quoteBalance, setQuoteBalance] = useState('10000');
const [baseBalance, setBaseBalance] = useState('0');
// Run parameters
function toLocalDatetimeString(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function defaultStartsAt(): string {
const d = new Date();
d.setSeconds(0, 0);
return toLocalDatetimeString(d);
}
function defaultFinishesAt(): string {
const d = new Date(Date.now() + 60_000);
d.setSeconds(0, 0);
return toLocalDatetimeString(d);
}
const [mode, setMode] = useState<'live' | 'backtest'>('live');
const [startsAt, setStartsAt] = useState(defaultStartsAt);
const [finishesAt, setFinishesAt] = useState(defaultFinishesAt);
const nameExchange = parsedBase && parsedQuote ? parsedBase + parsedQuote : null;
const dataRange = useInstrumentDataRange(
mode === 'backtest' && exchange ? exchange : null,
mode === 'backtest' ? nameExchange : null,
);
useEffect(() => {
if (mode === 'backtest' && dataRange.data) {
const start = new Date(dataRange.data.start);
start.setSeconds(0, 0);
start.setMinutes(start.getMinutes() + 1);
const end = new Date(dataRange.data.end);
end.setSeconds(0, 0);
setStartsAt(toLocalDatetimeString(start));
setFinishesAt(toLocalDatetimeString(end));
}
}, [dataRange.data, mode]);
const [riskFreeReturn, setRiskFreeReturn] = useState(0.05);
// Strategy
const [strategyType, setStrategyType] = useState<'default' | 'simple_spread'>('default');
const [spreadBps, setSpreadBps] = useState(10);
const [orderQuantity, setOrderQuantity] = useState('0.001');
const [intervalSecs, setIntervalSecs] = useState(5);
const [maxPositionQuantity, setMaxPositionQuantity] = useState('0.01');
function handleExchangeChange(name: string) {
setExchange(name);
setPair('');
}
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!exchange || !parsedBase || !parsedQuote) return;
const base = parsedBase;
const quote = parsedQuote;
const nameEx = base + quote;
const now = new Date().toISOString();
const strategy =
strategyType === 'simple_spread'
? {
type: 'simple_spread',
spread_bps: spreadBps,
order_quantity: orderQuantity,
interval_secs: intervalSecs,
max_position_quantity: maxPositionQuantity,
}
: { type: 'default' };
const config = {
instrument: {
exchange,
name_exchange: nameEx,
underlying: { base: base.toLowerCase(), quote: quote.toLowerCase() },
quote: 'underlying_quote',
kind: 'spot',
},
execution: {
mocked_exchange: exchange,
latency_ms: latencyMs,
fees_percent: feesPercent / 100,
initial_state: {
exchange,
balances: [
{
asset: quote.toLowerCase(),
balance: { total: Number(quoteBalance), free: Number(quoteBalance) },
time_exchange: now,
},
{
asset: base.toLowerCase(),
balance: { total: Number(baseBalance), free: Number(baseBalance) },
time_exchange: now,
},
],
instrument: { instrument_name: nameEx, orders: [] },
},
},
strategy,
};
createMutation.mutate(
{
mode,
config,
starts_at: new Date(startsAt).toISOString(),
finishes_at: new Date(finishesAt).toISOString(),
risk_free_return: riskFreeReturn,
},
{
onSuccess: () => navigate('/paper-runs'),
},
);
}
const pairValid = !!parsedBase && !!parsedQuote;
const formValid = !!exchange && pairValid;
return (
<Container>
<AppBreadcrumb />
<Card>
<Card.Header>Create Paper Run</Card.Header>
<Card.Body>
<Form onSubmit={handleSubmit}>
<Row>
<Col lg={4}>
<h6>Instrument</h6>
<Form.Group className="mb-3">
<Form.Label>Exchange</Form.Label>
<Form.Select
value={exchange}
onChange={(e) => handleExchangeChange(e.target.value)}
disabled={!dataLoaded}
>
<option value="">
{dataLoaded ? 'Select an exchange...' : 'Loading...'}
</option>
{filteredExchanges.map((ex) => (
<option key={ex.name} value={ex.name}>
{ex.name}
</option>
))}
</Form.Select>
{dataLoaded && filteredExchanges.length === 0 && (
<Form.Text className="text-warning">
No exchanges with available data. Add a subscription or ingest config first.
</Form.Text>
)}
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>
Pair (BASE-QUOTE)
{!useLocalPairs && supportsLookup && instruments.isLoading && (
<Spinner size="sm" animation="border" className="ms-2" />
)}
</Form.Label>
{useLocalPairs || supportsLookup ? (
<>
<Form.Control
value={pair}
onChange={(e) => setPair(e.target.value)}
placeholder="Type to search instruments..."
list="paper-run-instrument-pairs"
disabled={!exchange}
/>
<datalist id="paper-run-instrument-pairs">
{sortedInstruments.map((inst) => {
const value = `${inst.base}-${inst.quote}`;
return <option key={value} value={value} />;
})}
</datalist>
</>
) : (
<Form.Control
value={pair}
onChange={(e) => setPair(e.target.value)}
placeholder="e.g. BTC-USDT"
disabled={!exchange}
/>
)}
{pair && !pairValid && (
<Form.Text className="text-danger">
Enter pair as BASE-QUOTE (e.g. BTC-USDC)
</Form.Text>
)}
</Form.Group>
<h6>Execution</h6>
<Form.Group className="mb-3">
<Form.Label>Latency (ms)</Form.Label>
<Form.Control
type="number"
min={0}
value={latencyMs}
onChange={(e) => setLatencyMs(Number(e.target.value))}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Fees (%, e.g. 0.1 = 0.1%)</Form.Label>
<Form.Control
type="number"
step={0.01}
min={0}
value={feesPercent}
onChange={(e) => setFeesPercent(Number(e.target.value))}
/>
</Form.Group>
<h6>Starting Balances</h6>
<Form.Group className="mb-3">
<Form.Label>
{parsedQuote ? `${parsedQuote} balance` : 'Quote balance'}
</Form.Label>
<Form.Control
type="number"
min={0}
step="any"
value={quoteBalance}
onChange={(e) => setQuoteBalance(e.target.value)}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>
{parsedBase ? `${parsedBase} balance` : 'Base balance'}
</Form.Label>
<Form.Control
type="number"
min={0}
step="any"
value={baseBalance}
onChange={(e) => setBaseBalance(e.target.value)}
/>
</Form.Group>
</Col>
<Col lg={4}>
<h6>Run Parameters</h6>
<Form.Group className="mb-3">
<Form.Label>Mode</Form.Label>
<Form.Select
value={mode}
onChange={(e) => setMode(e.target.value as 'live' | 'backtest')}
>
<option value="live">Live</option>
<option value="backtest">Backtest</option>
</Form.Select>
{mode === 'backtest' && (
<Form.Text className="text-muted">
Replays historical trade data runs immediately, no wall-clock wait.
</Form.Text>
)}
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{mode === 'backtest' ? 'Data Start' : 'Starts At'}</Form.Label>
<Form.Control
type="datetime-local"
value={startsAt}
onChange={(e) => setStartsAt(e.target.value)}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{mode === 'backtest' ? 'Data End' : 'Finishes At'}</Form.Label>
<Form.Control
type="datetime-local"
value={finishesAt}
onChange={(e) => setFinishesAt(e.target.value)}
/>
{mode === 'backtest' && nameExchange && (
<Form.Text className="text-muted">
{dataRange.isLoading && 'Checking available data…'}
{dataRange.data && (
<>
Available:{' '}
{new Date(dataRange.data.start).toLocaleString()} {' '}
{new Date(dataRange.data.end).toLocaleString()}
</>
)}
{!dataRange.isLoading && dataRange.data === null && (
'No trade data available for this instrument'
)}
</Form.Text>
)}
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Risk-free return</Form.Label>
<Form.Control
type="number"
step={0.01}
value={riskFreeReturn}
onChange={(e) => setRiskFreeReturn(Number(e.target.value))}
/>
</Form.Group>
</Col>
<Col lg={4}>
<h6>Strategy</h6>
<Form.Group className="mb-3">
<Form.Label>Type</Form.Label>
<Form.Select
value={strategyType}
onChange={(e) =>
setStrategyType(e.target.value as 'default' | 'simple_spread')
}
>
<option value="default">Default</option>
<option value="simple_spread">Simple Spread</option>
</Form.Select>
</Form.Group>
{strategyType === 'simple_spread' && (
<>
<Form.Group className="mb-3">
<Form.Label>Spread (bps)</Form.Label>
<Form.Control
type="number"
min={1}
value={spreadBps}
onChange={(e) => setSpreadBps(Number(e.target.value))}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Order quantity</Form.Label>
<Form.Control
value={orderQuantity}
onChange={(e) => setOrderQuantity(e.target.value)}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Interval (seconds)</Form.Label>
<Form.Control
type="number"
min={1}
value={intervalSecs}
onChange={(e) => setIntervalSecs(Number(e.target.value))}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Max position quantity</Form.Label>
<Form.Control
value={maxPositionQuantity}
onChange={(e) => setMaxPositionQuantity(e.target.value)}
/>
</Form.Group>
</>
)}
</Col>
</Row>
<div className="mt-3 d-flex gap-2">
<Button type="submit" disabled={createMutation.isPending || !formValid}>
{createMutation.isPending ? (
<Spinner size="sm" animation="border" />
) : (
'Create Run'
)}
</Button>
<Button
variant="outline-secondary"
onClick={() => navigate('/paper-runs')}
disabled={createMutation.isPending}
>
Cancel
</Button>
</div>
{createMutation.isError && (
<Alert variant="danger" className="mt-3">
{(createMutation.error as Error).message}
</Alert>
)}
</Form>
</Card.Body>
</Card>
</Container>
);
}

View File

@@ -0,0 +1,163 @@
import { useState, useMemo, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Button,
Card,
Col,
Container,
Form,
Row,
Spinner,
} from 'react-bootstrap';
import {
useCreateSubscription,
useExchanges,
useExchangeInstruments,
} from '../api/hooks';
import AppBreadcrumb from '../components/AppBreadcrumb';
export default function AddSubscriptionPage() {
const navigate = useNavigate();
const [exchange, setExchange] = useState('');
const [pair, setPair] = useState('');
const [selectedKinds, setSelectedKinds] = useState<string[]>(['PublicTrades']);
const exchanges = useExchanges();
const createMutation = useCreateSubscription();
const selectedExchange = useMemo(
() => exchanges.data?.find((ex) => ex.name === exchange) ?? null,
[exchange, exchanges.data],
);
const supportsLookup = selectedExchange?.supports_instrument_lookup ?? false;
const instruments = useExchangeInstruments(supportsLookup ? exchange : null);
const sortedInstruments = useMemo(() => {
if (!instruments.data) return [];
return [...instruments.data].sort((a, b) =>
`${a.base}-${a.quote}`.localeCompare(`${b.base}-${b.quote}`),
);
}, [instruments.data]);
const availableKinds = selectedExchange?.supported_sub_kinds ?? [];
function handleExchangeChange(name: string) {
setExchange(name);
setPair('');
setSelectedKinds(['PublicTrades']);
}
function toggleKind(kind: string) {
setSelectedKinds((prev) =>
prev.includes(kind) ? prev.filter((k) => k !== kind) : [...prev, kind],
);
}
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!exchange || !pair || selectedKinds.length === 0) return;
createMutation.mutate(
{ exchange, pair, sub_kinds: selectedKinds },
{ onSuccess: () => navigate('/subscriptions') },
);
}
return (
<Container>
<AppBreadcrumb />
<Row>
<Col lg={6}>
<Card>
<Card.Header>Add Subscription</Card.Header>
<Card.Body>
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>Exchange</Form.Label>
<Form.Select
value={exchange}
onChange={(e) => handleExchangeChange(e.target.value)}
>
<option value="">Select an exchange...</option>
{exchanges.data?.map((ex) => (
<option key={ex.name} value={ex.name}>{ex.name}</option>
))}
</Form.Select>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>
Pair (BASE-QUOTE)
{supportsLookup && instruments.isLoading && (
<Spinner size="sm" animation="border" className="ms-2" />
)}
</Form.Label>
{supportsLookup ? (
<>
<Form.Control
value={pair}
onChange={(e) => setPair(e.target.value)}
placeholder="Type to search instruments..."
list="instrument-pairs"
/>
<datalist id="instrument-pairs">
{sortedInstruments.map((inst) => {
const value = `${inst.base}-${inst.quote}`;
return <option key={value} value={value} />;
})}
</datalist>
</>
) : (
<Form.Control
value={pair}
onChange={(e) => setPair(e.target.value)}
placeholder="e.g. BTC-USDT"
/>
)}
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Subscription Types</Form.Label>
{availableKinds.length === 0 ? (
<Form.Text className="text-muted d-block">
Select an exchange to see available types
</Form.Text>
) : (
availableKinds.map((kind) => (
<Form.Check
key={kind}
type="checkbox"
label={kind}
checked={selectedKinds.includes(kind)}
onChange={() => toggleKind(kind)}
/>
))
)}
</Form.Group>
<div className="d-flex gap-2">
<Button
type="submit"
disabled={createMutation.isPending || !exchange || !pair || selectedKinds.length === 0}
>
{createMutation.isPending ? <Spinner size="sm" animation="border" /> : 'Create'}
</Button>
<Button variant="outline-secondary" onClick={() => navigate('/subscriptions')}>
Cancel
</Button>
</div>
{createMutation.isError && (
<Alert variant="danger" className="mt-3">
{(createMutation.error as Error).message}
</Alert>
)}
</Form>
</Card.Body>
</Card>
</Col>
</Row>
</Container>
);
}

View File

@@ -1,24 +1,15 @@
import { useState, useMemo, type FormEvent } from 'react';
import {
Alert,
Badge,
Button,
Card,
Col,
Container,
Form,
Row,
Spinner,
Table,
} from 'react-bootstrap';
import {
useIngestConfigs,
useCreateIngestConfig,
useUpdateIngestConfig,
useExchangeInstruments,
} from '../api/hooks';
const SUPPORTED_INGEST_EXCHANGES = ['binance_spot'];
import { Link } from 'react-router-dom';
import { useIngestConfigs, useUpdateIngestConfig } from '../api/hooks';
import AppBreadcrumb from '../components/AppBreadcrumb';
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
@@ -31,140 +22,18 @@ function relativeTime(iso: string): string {
}
export default function IngestionPage() {
const [exchange, setExchange] = useState('');
const [pair, setPair] = useState('');
const [retentionDays, setRetentionDays] = useState(365);
const ingestConfigs = useIngestConfigs();
const createMutation = useCreateIngestConfig();
const updateMutation = useUpdateIngestConfig();
// binance_spot supports instrument lookup
const supportsLookup = exchange === 'binance_spot';
const instruments = useExchangeInstruments(supportsLookup ? exchange : null);
const sortedInstruments = useMemo(() => {
if (!instruments.data) return [];
return [...instruments.data].sort((a, b) => {
const pairA = `${a.base}-${a.quote}`;
const pairB = `${b.base}-${b.quote}`;
return pairA.localeCompare(pairB);
});
}, [instruments.data]);
function handleExchangeChange(name: string) {
setExchange(name);
setPair('');
}
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!exchange || !pair) return;
createMutation.mutate(
{ exchange, pair, retention_days: retentionDays },
{
onSuccess: () => {
setPair('');
setRetentionDays(365);
},
},
);
}
const datalistId = 'ingest-instrument-pairs';
return (
<Container>
<Row className="mb-4">
<Col lg={6}>
<Card>
<Card.Header>Add Ingestion Config</Card.Header>
<Card.Body>
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>Exchange</Form.Label>
<Form.Select
value={exchange}
onChange={(e) => handleExchangeChange(e.target.value)}
>
<option value="">Select an exchange...</option>
{SUPPORTED_INGEST_EXCHANGES.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</Form.Select>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>
Pair (BASE-QUOTE)
{supportsLookup && instruments.isLoading && (
<Spinner size="sm" animation="border" className="ms-2" />
)}
</Form.Label>
{supportsLookup ? (
<>
<Form.Control
value={pair}
onChange={(e) => setPair(e.target.value)}
placeholder="Type to search instruments..."
list={datalistId}
/>
<datalist id={datalistId}>
{sortedInstruments.map((inst) => {
const value = `${inst.base}-${inst.quote}`;
return <option key={value} value={value} />;
})}
</datalist>
</>
) : (
<Form.Control
value={pair}
onChange={(e) => setPair(e.target.value)}
placeholder="e.g. BTC-USDC"
/>
)}
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Retention (days)</Form.Label>
<Form.Control
type="number"
min={1}
value={retentionDays}
onChange={(e) => setRetentionDays(parseInt(e.target.value) || 365)}
/>
</Form.Group>
<Button
type="submit"
disabled={createMutation.isPending || !exchange || !pair}
>
{createMutation.isPending ? (
<Spinner size="sm" animation="border" />
) : (
'Create'
)}
</Button>
{createMutation.isError && (
<Alert variant="danger" className="mt-3">
{(createMutation.error as Error).message}
</Alert>
)}
{createMutation.isSuccess && (
<Alert variant="success" className="mt-3">
Ingestion config created.
</Alert>
)}
</Form>
</Card.Body>
</Card>
</Col>
</Row>
<h5>Ingestion Configs</h5>
<AppBreadcrumb />
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">Ingestion Configs</h5>
<Link to="/ingestion/new" className="btn btn-primary btn-sm">
Add Config
</Link>
</div>
{ingestConfigs.isLoading && <Spinner animation="border" />}
{ingestConfigs.isError && (
@@ -189,9 +58,7 @@ export default function IngestionPage() {
{ingestConfigs.data.map((cfg) => (
<tr key={cfg.id}>
<td>{cfg.exchange}</td>
<td>
{cfg.base_asset}-{cfg.quote_asset}
</td>
<td>{cfg.base_asset}-{cfg.quote_asset}</td>
<td>{cfg.source}</td>
<td>
<Form.Control

View File

@@ -1,5 +1,6 @@
import { Alert, Badge, Container, Spinner, Table } from 'react-bootstrap';
import { useIngestionStats } from '../api/hooks';
import AppBreadcrumb from '../components/AppBreadcrumb';
function relativeTime(iso: string | null): string {
if (!iso) return '--';
@@ -35,6 +36,7 @@ export default function MetricsPage() {
return (
<Container>
<AppBreadcrumb />
<h5 className="mb-3">Ingestion Metrics</h5>
{isLoading && <Spinner animation="border" />}

View File

@@ -14,6 +14,7 @@ import { usePaperRun, useCancelPaperRun, usePaperRunPositions } from '../api/hoo
import type { PaperRun } from '../types/api';
import EquityCurveChart from '../components/EquityCurveChart';
import MetricLabel from '../components/MetricLabel';
import AppBreadcrumb from '../components/AppBreadcrumb';
import mdPnl from '../content/metrics/pnl.md?raw';
import mdReturn from '../content/metrics/return.md?raw';
import mdWinRate from '../content/metrics/win_rate.md?raw';
@@ -163,6 +164,7 @@ export default function PaperRunDetailPage() {
return (
<Container>
<AppBreadcrumb />
{/* Header card */}
<Card className="mb-3">
<Card.Body>

View File

@@ -1,28 +1,17 @@
import { useState, useMemo, useEffect, type FormEvent } from 'react';
import {
Alert,
Badge,
Button,
Card,
Col,
Container,
Form,
Row,
Spinner,
Table,
} from 'react-bootstrap';
import { useNavigate } from 'react-router-dom';
import {
usePaperRuns,
useCancelPaperRun,
useCreatePaperRun,
useExchanges,
useExchangeInstruments,
useInstrumentDataRange,
useSubscriptions,
useIngestConfigs,
} from '../api/hooks';
import { Link, useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { usePaperRuns, useCancelPaperRun } from '../api/hooks';
import type { PaperRun } from '../types/api';
import AppBreadcrumb from '../components/AppBreadcrumb';
const STATUS_OPTIONS = ['all', 'queued', 'running', 'complete', 'failed', 'cancelled'] as const;
@@ -58,251 +47,21 @@ function formatTimestamp(iso: string | null): string {
export default function PaperRunsPage() {
const [statusFilter, setStatusFilter] = useState<string>('all');
const [showForm, setShowForm] = useState(false);
const navigate = useNavigate();
const cancelMutation = useCancelPaperRun();
const createMutation = useCreatePaperRun();
// Instrument selection
const [exchange, setExchange] = useState('');
const [pair, setPair] = useState('');
const exchanges = useExchanges();
const subscriptions = useSubscriptions();
const ingestConfigs = useIngestConfigs();
// Build sets of exchanges and pairs that have data available (subscriptions OR ingest configs).
// Key format for pairs: "BASE-QUOTE" (uppercase).
const { availableExchanges, availablePairsByExchange } = useMemo(() => {
const exSet = new Set<string>();
const pairMap = new Map<string, Set<string>>();
const addEntry = (exchangeName: string, base: string, quote: string) => {
const ex = exchangeName.toLowerCase();
exSet.add(ex);
if (!pairMap.has(ex)) pairMap.set(ex, new Set());
pairMap.get(ex)!.add(`${base.toUpperCase()}-${quote.toUpperCase()}`);
};
for (const sub of subscriptions.data ?? []) {
addEntry(sub.exchange, sub.base_asset, sub.quote_asset);
}
for (const cfg of ingestConfigs.data ?? []) {
addEntry(cfg.exchange, cfg.base_asset, cfg.quote_asset);
}
return { availableExchanges: exSet, availablePairsByExchange: pairMap };
}, [subscriptions.data, ingestConfigs.data]);
const dataLoaded = !subscriptions.isLoading && !ingestConfigs.isLoading;
// Filtered exchange list: only exchanges that have at least one data source.
const filteredExchanges = useMemo(() => {
if (!exchanges.data || !dataLoaded) return exchanges.data ?? [];
return exchanges.data.filter((ex) => availableExchanges.has(ex.name.toLowerCase()));
}, [exchanges.data, availableExchanges, dataLoaded]);
const selectedExchange = useMemo(
() => exchanges.data?.find((ex) => ex.name === exchange) ?? null,
[exchange, exchanges.data],
);
const supportsLookup = selectedExchange?.supports_instrument_lookup ?? false;
// Available pairs for the selected exchange from our data sources.
const localPairs = useMemo(() => {
const pairs = availablePairsByExchange.get(exchange.toLowerCase());
if (!pairs) return [];
return [...pairs].sort().map((p) => {
const [base, quote] = p.split('-');
return { base, quote };
});
}, [exchange, availablePairsByExchange]);
// Use local pairs when we have them; fall back to live exchange lookup otherwise.
const useLocalPairs = localPairs.length > 0;
const instruments = useExchangeInstruments(
supportsLookup && !useLocalPairs ? exchange : null,
);
const sortedInstruments = useMemo(() => {
if (useLocalPairs) return localPairs;
if (!instruments.data) return [];
return [...instruments.data].sort((a, b) =>
`${a.base}-${a.quote}`.localeCompare(`${b.base}-${b.quote}`),
);
}, [useLocalPairs, localPairs, instruments.data]);
// Derive base/quote from selected pair (format: "BASE-QUOTE")
const [parsedBase, parsedQuote] = useMemo(() => {
const parts = pair.split('-');
if (parts.length === 2 && parts[0] && parts[1]) return [parts[0], parts[1]];
return ['', ''];
}, [pair]);
// Execution parameters
const [latencyMs, setLatencyMs] = useState(100);
const [feesPercent, setFeesPercent] = useState(0.1); // display in %, sent as decimal (/100)
const [quoteBalance, setQuoteBalance] = useState('10000');
const [baseBalance, setBaseBalance] = useState('0');
// Run parameters
function toLocalDatetimeString(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function defaultStartsAt(): string {
const d = new Date();
d.setSeconds(0, 0);
return toLocalDatetimeString(d);
}
function defaultFinishesAt(): string {
const d = new Date(Date.now() + 60_000);
d.setSeconds(0, 0);
return toLocalDatetimeString(d);
}
const [mode, setMode] = useState<'live' | 'backtest'>('live');
const [startsAt, setStartsAt] = useState(defaultStartsAt);
const [finishesAt, setFinishesAt] = useState(defaultFinishesAt);
// Data range for backtest date hints
const nameExchange = parsedBase && parsedQuote ? parsedBase + parsedQuote : null;
const dataRange = useInstrumentDataRange(
mode === 'backtest' && exchange ? exchange : null,
mode === 'backtest' ? nameExchange : null,
);
// Auto-fill backtest dates when data range loads.
// Round start up to next minute and end down to previous minute so that
// the truncated datetime-local values stay within the actual data range.
useEffect(() => {
if (mode === 'backtest' && dataRange.data) {
const start = new Date(dataRange.data.start);
start.setSeconds(0, 0);
start.setMinutes(start.getMinutes() + 1);
const end = new Date(dataRange.data.end);
end.setSeconds(0, 0);
setStartsAt(toLocalDatetimeString(start));
setFinishesAt(toLocalDatetimeString(end));
}
}, [dataRange.data, mode]);
const [riskFreeReturn, setRiskFreeReturn] = useState(0.05);
// Strategy
const [strategyType, setStrategyType] = useState<'default' | 'simple_spread'>('default');
const [spreadBps, setSpreadBps] = useState(10);
const [orderQuantity, setOrderQuantity] = useState('0.001');
const [intervalSecs, setIntervalSecs] = useState(5);
const [maxPositionQuantity, setMaxPositionQuantity] = useState('0.01');
const queryStatus = statusFilter === 'all' ? undefined : statusFilter;
const { data, isLoading, isError, error } = usePaperRuns(queryStatus);
function handleExchangeChange(name: string) {
setExchange(name);
setPair('');
}
function resetForm() {
setExchange('');
setPair('');
setLatencyMs(100);
setFeesPercent(0.1);
setQuoteBalance('10000');
setBaseBalance('0');
setMode('live');
setStartsAt(defaultStartsAt());
setFinishesAt(defaultFinishesAt());
setRiskFreeReturn(0.05);
setStrategyType('default');
setSpreadBps(10);
setOrderQuantity('0.001');
setIntervalSecs(5);
setMaxPositionQuantity('0.01');
}
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!exchange || !parsedBase || !parsedQuote) return;
const base = parsedBase;
const quote = parsedQuote;
const nameExchange = base + quote;
const now = new Date().toISOString();
const strategy =
strategyType === 'simple_spread'
? {
type: 'simple_spread',
spread_bps: spreadBps,
order_quantity: orderQuantity,
interval_secs: intervalSecs,
max_position_quantity: maxPositionQuantity,
}
: { type: 'default' };
const config = {
instrument: {
exchange,
name_exchange: nameExchange,
underlying: { base: base.toLowerCase(), quote: quote.toLowerCase() },
quote: 'underlying_quote',
kind: 'spot',
},
execution: {
mocked_exchange: exchange,
latency_ms: latencyMs,
fees_percent: feesPercent / 100,
initial_state: {
exchange,
balances: [
{
asset: quote.toLowerCase(),
balance: { total: Number(quoteBalance), free: Number(quoteBalance) },
time_exchange: now,
},
{
asset: base.toLowerCase(),
balance: { total: Number(baseBalance), free: Number(baseBalance) },
time_exchange: now,
},
],
instrument: { instrument_name: nameExchange, orders: [] },
},
},
strategy,
};
createMutation.mutate(
{
mode,
config,
starts_at: new Date(startsAt).toISOString(),
finishes_at: new Date(finishesAt).toISOString(),
risk_free_return: riskFreeReturn,
},
{
onSuccess: () => {
setShowForm(false);
resetForm();
},
},
);
}
const pairValid = !!parsedBase && !!parsedQuote;
const formValid = !!exchange && pairValid;
return (
<Container>
<AppBreadcrumb />
<div className="d-flex align-items-center justify-content-between mb-3">
<h5 className="mb-0">Paper Runs</h5>
<div className="d-flex gap-2">
<Button
variant={showForm ? 'outline-secondary' : 'primary'}
size="sm"
onClick={() => setShowForm(!showForm)}
>
{showForm ? 'Cancel' : 'New Run'}
</Button>
<Link to="/paper-runs/new" className="btn btn-primary btn-sm">
New Run
</Link>
<Form.Select
style={{ width: 'auto' }}
value={statusFilter}
@@ -317,256 +76,6 @@ export default function PaperRunsPage() {
</div>
</div>
{showForm && (
<Card className="mb-4">
<Card.Header>Create Paper Run</Card.Header>
<Card.Body>
<Form onSubmit={handleSubmit}>
<Row>
<Col lg={4}>
<h6>Instrument</h6>
<Form.Group className="mb-3">
<Form.Label>Exchange</Form.Label>
<Form.Select
value={exchange}
onChange={(e) => handleExchangeChange(e.target.value)}
disabled={!dataLoaded}
>
<option value="">
{dataLoaded ? 'Select an exchange...' : 'Loading...'}
</option>
{filteredExchanges.map((ex) => (
<option key={ex.name} value={ex.name}>
{ex.name}
</option>
))}
</Form.Select>
{dataLoaded && filteredExchanges.length === 0 && (
<Form.Text className="text-warning">
No exchanges with available data. Add a subscription or ingest config first.
</Form.Text>
)}
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>
Pair (BASE-QUOTE)
{!useLocalPairs && supportsLookup && instruments.isLoading && (
<Spinner size="sm" animation="border" className="ms-2" />
)}
</Form.Label>
{useLocalPairs || supportsLookup ? (
<>
<Form.Control
value={pair}
onChange={(e) => setPair(e.target.value)}
placeholder="Type to search instruments..."
list="paper-run-instrument-pairs"
disabled={!exchange}
/>
<datalist id="paper-run-instrument-pairs">
{sortedInstruments.map((inst) => {
const value = `${inst.base}-${inst.quote}`;
return <option key={value} value={value} />;
})}
</datalist>
</>
) : (
<Form.Control
value={pair}
onChange={(e) => setPair(e.target.value)}
placeholder="e.g. BTC-USDT"
disabled={!exchange}
/>
)}
{pair && !pairValid && (
<Form.Text className="text-danger">
Enter pair as BASE-QUOTE (e.g. BTC-USDC)
</Form.Text>
)}
</Form.Group>
<h6>Execution</h6>
<Form.Group className="mb-3">
<Form.Label>Latency (ms)</Form.Label>
<Form.Control
type="number"
min={0}
value={latencyMs}
onChange={(e) => setLatencyMs(Number(e.target.value))}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Fees (%, e.g. 0.1 = 0.1%)</Form.Label>
<Form.Control
type="number"
step={0.01}
min={0}
value={feesPercent}
onChange={(e) => setFeesPercent(Number(e.target.value))}
/>
</Form.Group>
<h6>Starting Balances</h6>
<Form.Group className="mb-3">
<Form.Label>
{parsedQuote ? `${parsedQuote} balance` : 'Quote balance'}
</Form.Label>
<Form.Control
type="number"
min={0}
step="any"
value={quoteBalance}
onChange={(e) => setQuoteBalance(e.target.value)}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>
{parsedBase ? `${parsedBase} balance` : 'Base balance'}
</Form.Label>
<Form.Control
type="number"
min={0}
step="any"
value={baseBalance}
onChange={(e) => setBaseBalance(e.target.value)}
/>
</Form.Group>
</Col>
<Col lg={4}>
<h6>Run Parameters</h6>
<Form.Group className="mb-3">
<Form.Label>Mode</Form.Label>
<Form.Select
value={mode}
onChange={(e) => setMode(e.target.value as 'live' | 'backtest')}
>
<option value="live">Live</option>
<option value="backtest">Backtest</option>
</Form.Select>
{mode === 'backtest' && (
<Form.Text className="text-muted">
Replays historical trade data runs immediately, no wall-clock wait.
</Form.Text>
)}
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{mode === 'backtest' ? 'Data Start' : 'Starts At'}</Form.Label>
<Form.Control
type="datetime-local"
value={startsAt}
onChange={(e) => setStartsAt(e.target.value)}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{mode === 'backtest' ? 'Data End' : 'Finishes At'}</Form.Label>
<Form.Control
type="datetime-local"
value={finishesAt}
onChange={(e) => setFinishesAt(e.target.value)}
/>
{mode === 'backtest' && nameExchange && (
<Form.Text className="text-muted">
{dataRange.isLoading && 'Checking available data…'}
{dataRange.data && (
<>
Available:{' '}
{new Date(dataRange.data.start).toLocaleString()} {' '}
{new Date(dataRange.data.end).toLocaleString()}
</>
)}
{!dataRange.isLoading && dataRange.data === null && (
'No trade data available for this instrument'
)}
</Form.Text>
)}
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Risk-free return</Form.Label>
<Form.Control
type="number"
step={0.01}
value={riskFreeReturn}
onChange={(e) => setRiskFreeReturn(Number(e.target.value))}
/>
</Form.Group>
</Col>
<Col lg={4}>
<h6>Strategy</h6>
<Form.Group className="mb-3">
<Form.Label>Type</Form.Label>
<Form.Select
value={strategyType}
onChange={(e) =>
setStrategyType(e.target.value as 'default' | 'simple_spread')
}
>
<option value="default">Default</option>
<option value="simple_spread">Simple Spread</option>
</Form.Select>
</Form.Group>
{strategyType === 'simple_spread' && (
<>
<Form.Group className="mb-3">
<Form.Label>Spread (bps)</Form.Label>
<Form.Control
type="number"
min={1}
value={spreadBps}
onChange={(e) => setSpreadBps(Number(e.target.value))}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Order quantity</Form.Label>
<Form.Control
value={orderQuantity}
onChange={(e) => setOrderQuantity(e.target.value)}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Interval (seconds)</Form.Label>
<Form.Control
type="number"
min={1}
value={intervalSecs}
onChange={(e) => setIntervalSecs(Number(e.target.value))}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Max position quantity</Form.Label>
<Form.Control
value={maxPositionQuantity}
onChange={(e) => setMaxPositionQuantity(e.target.value)}
/>
</Form.Group>
</>
)}
</Col>
</Row>
<div className="mt-3">
<Button type="submit" disabled={createMutation.isPending || !formValid}>
{createMutation.isPending ? (
<Spinner size="sm" animation="border" />
) : (
'Create Run'
)}
</Button>
</div>
{createMutation.isError && (
<Alert variant="danger" className="mt-3">
{(createMutation.error as Error).message}
</Alert>
)}
</Form>
</Card.Body>
</Card>
)}
{isLoading && <Spinner animation="border" />}
{isError && <Alert variant="danger">{(error as Error).message}</Alert>}

View File

@@ -1,185 +1,28 @@
import { useState, useMemo, type FormEvent } from 'react';
import {
Alert,
Badge,
Button,
Card,
Col,
Container,
Form,
Row,
Spinner,
Table,
} from 'react-bootstrap';
import {
useSubscriptions,
useCreateSubscription,
useDeactivateSubscription,
useExchanges,
useExchangeInstruments,
} from '../api/hooks';
import { Link } from 'react-router-dom';
import { useSubscriptions, useDeactivateSubscription } from '../api/hooks';
import AppBreadcrumb from '../components/AppBreadcrumb';
export default function SubscriptionsPage() {
const [exchange, setExchange] = useState('');
const [pair, setPair] = useState('');
const [selectedKinds, setSelectedKinds] = useState<string[]>(['PublicTrades']);
const subscriptions = useSubscriptions(false);
const exchanges = useExchanges();
const createMutation = useCreateSubscription();
const deactivateMutation = useDeactivateSubscription();
const selectedExchange = useMemo(
() => exchanges.data?.find((ex) => ex.name === exchange) ?? null,
[exchange, exchanges.data],
);
const supportsLookup = selectedExchange?.supports_instrument_lookup ?? false;
const instruments = useExchangeInstruments(supportsLookup ? exchange : null);
const sortedInstruments = useMemo(() => {
if (!instruments.data) return [];
return [...instruments.data].sort((a, b) => {
const pairA = `${a.base}-${a.quote}`;
const pairB = `${b.base}-${b.quote}`;
return pairA.localeCompare(pairB);
});
}, [instruments.data]);
const availableKinds = useMemo(() => {
return selectedExchange?.supported_sub_kinds ?? [];
}, [selectedExchange]);
function handleExchangeChange(name: string) {
setExchange(name);
setPair('');
setSelectedKinds(['PublicTrades']);
}
function toggleKind(kind: string) {
setSelectedKinds((prev) =>
prev.includes(kind) ? prev.filter((k) => k !== kind) : [...prev, kind],
);
}
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!exchange || !pair || selectedKinds.length === 0) return;
createMutation.mutate(
{ exchange, pair, sub_kinds: selectedKinds },
{
onSuccess: () => {
setPair('');
setSelectedKinds(['PublicTrades']);
},
},
);
}
const datalistId = 'instrument-pairs';
return (
<Container>
<Row className="mb-4">
<Col lg={6}>
<Card>
<Card.Header>Add Subscription</Card.Header>
<Card.Body>
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>Exchange</Form.Label>
<Form.Select
value={exchange}
onChange={(e) => handleExchangeChange(e.target.value)}
>
<option value="">Select an exchange...</option>
{exchanges.data?.map((ex) => (
<option key={ex.name} value={ex.name}>
{ex.name}
</option>
))}
</Form.Select>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>
Pair (BASE-QUOTE)
{supportsLookup && instruments.isLoading && (
<Spinner size="sm" animation="border" className="ms-2" />
)}
</Form.Label>
{supportsLookup ? (
<>
<Form.Control
value={pair}
onChange={(e) => setPair(e.target.value)}
placeholder="Type to search instruments..."
list={datalistId}
/>
<datalist id={datalistId}>
{sortedInstruments.map((inst) => {
const value = `${inst.base}-${inst.quote}`;
return <option key={value} value={value} />;
})}
</datalist>
</>
) : (
<Form.Control
value={pair}
onChange={(e) => setPair(e.target.value)}
placeholder="e.g. BTC-USDT"
/>
)}
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Subscription Types</Form.Label>
{availableKinds.length === 0 ? (
<Form.Text className="text-muted d-block">
Select an exchange to see available types
</Form.Text>
) : (
availableKinds.map((kind) => (
<Form.Check
key={kind}
type="checkbox"
label={kind}
checked={selectedKinds.includes(kind)}
onChange={() => toggleKind(kind)}
/>
))
)}
</Form.Group>
<Button
type="submit"
disabled={createMutation.isPending || !exchange || !pair || selectedKinds.length === 0}
>
{createMutation.isPending ? (
<Spinner size="sm" animation="border" />
) : (
'Create'
)}
</Button>
{createMutation.isError && (
<Alert variant="danger" className="mt-3">
{(createMutation.error as Error).message}
</Alert>
)}
{createMutation.isSuccess && (
<Alert variant="success" className="mt-3">
Subscription created.
</Alert>
)}
</Form>
</Card.Body>
</Card>
</Col>
</Row>
<h5>Subscriptions</h5>
<AppBreadcrumb />
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">Subscriptions</h5>
<Link to="/subscriptions/new" className="btn btn-primary btn-sm">
Add Subscription
</Link>
</div>
{subscriptions.isLoading && <Spinner animation="border" />}
{subscriptions.isError && (
@@ -204,9 +47,7 @@ export default function SubscriptionsPage() {
<tr key={sub.id}>
<td>{sub.id}</td>
<td>{sub.exchange}</td>
<td>
{sub.base_asset}-{sub.quote_asset}
</td>
<td>{sub.base_asset}-{sub.quote_asset}</td>
<td>{sub.sub_kind}</td>
<td>
<Badge bg={sub.active ? 'success' : 'secondary'}>