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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
40
dashboard/src/components/AppBreadcrumb.tsx
Normal file
40
dashboard/src/components/AppBreadcrumb.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
dashboard/src/pages/AddIngestionPage.tsx
Normal file
137
dashboard/src/pages/AddIngestionPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
481
dashboard/src/pages/AddPaperRunPage.tsx
Normal file
481
dashboard/src/pages/AddPaperRunPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
dashboard/src/pages/AddSubscriptionPage.tsx
Normal file
163
dashboard/src/pages/AddSubscriptionPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
|
||||
@@ -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'}>
|
||||
|
||||
Reference in New Issue
Block a user