Add signalr
This commit is contained in:
@@ -871,6 +871,90 @@ export class BacktestClient extends AuthorizedApiBase {
|
||||
return Promise.resolve<FileResponse>(null as any);
|
||||
}
|
||||
|
||||
backtest_SubscribeToBundle(requestId: string | null | undefined): Promise<FileResponse> {
|
||||
let url_ = this.baseUrl + "/Backtest/Bundle/Subscribe?";
|
||||
if (requestId !== undefined && requestId !== null)
|
||||
url_ += "requestId=" + encodeURIComponent("" + requestId) + "&";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
let options_: RequestInit = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/octet-stream"
|
||||
}
|
||||
};
|
||||
|
||||
return this.transformOptions(options_).then(transformedOptions_ => {
|
||||
return this.http.fetch(url_, transformedOptions_);
|
||||
}).then((_response: Response) => {
|
||||
return this.processBacktest_SubscribeToBundle(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processBacktest_SubscribeToBundle(response: Response): Promise<FileResponse> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200 || status === 206) {
|
||||
const contentDisposition = response.headers ? response.headers.get("content-disposition") : undefined;
|
||||
let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined;
|
||||
let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined;
|
||||
if (fileName) {
|
||||
fileName = decodeURIComponent(fileName);
|
||||
} else {
|
||||
fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined;
|
||||
fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined;
|
||||
}
|
||||
return response.blob().then(blob => { return { fileName: fileName, data: blob, status: status, headers: _headers }; });
|
||||
} else if (status !== 200 && status !== 204) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<FileResponse>(null as any);
|
||||
}
|
||||
|
||||
backtest_UnsubscribeFromBundle(requestId: string | null | undefined): Promise<FileResponse> {
|
||||
let url_ = this.baseUrl + "/Backtest/Bundle/Unsubscribe?";
|
||||
if (requestId !== undefined && requestId !== null)
|
||||
url_ += "requestId=" + encodeURIComponent("" + requestId) + "&";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
let options_: RequestInit = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/octet-stream"
|
||||
}
|
||||
};
|
||||
|
||||
return this.transformOptions(options_).then(transformedOptions_ => {
|
||||
return this.http.fetch(url_, transformedOptions_);
|
||||
}).then((_response: Response) => {
|
||||
return this.processBacktest_UnsubscribeFromBundle(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processBacktest_UnsubscribeFromBundle(response: Response): Promise<FileResponse> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200 || status === 206) {
|
||||
const contentDisposition = response.headers ? response.headers.get("content-disposition") : undefined;
|
||||
let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined;
|
||||
let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined;
|
||||
if (fileName) {
|
||||
fileName = decodeURIComponent(fileName);
|
||||
} else {
|
||||
fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined;
|
||||
fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined;
|
||||
}
|
||||
return response.blob().then(blob => { return { fileName: fileName, data: blob, status: status, headers: _headers }; });
|
||||
} else if (status !== 200 && status !== 204) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<FileResponse>(null as any);
|
||||
}
|
||||
|
||||
backtest_RunGenetic(request: RunGeneticRequest): Promise<GeneticRequest> {
|
||||
let url_ = this.baseUrl + "/Backtest/Genetic";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {BundleBacktestRequest, LightBacktestResponse} from '../../generated/ManagingApiTypes';
|
||||
import {BacktestClient} from '../../generated/ManagingApi';
|
||||
import useApiUrlStore from '../../app/store/apiStore';
|
||||
import Toast from '../../components/mollecules/Toast/Toast';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import * as signalR from '@microsoft/signalr';
|
||||
import AuthorizedApiBase from '../../generated/AuthorizedApiBase';
|
||||
|
||||
interface BundleRequestModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
bundle: BundleBacktestRequest | null;
|
||||
}
|
||||
|
||||
const BundleRequestModal: React.FC<BundleRequestModalProps> = ({ open, onClose, bundle }) => {
|
||||
const { apiUrl } = useApiUrlStore();
|
||||
const [backtests, setBacktests] = useState<LightBacktestResponse[]>([]);
|
||||
const signalRRef = useRef<any>(null);
|
||||
|
||||
const {
|
||||
data: queryBacktests,
|
||||
isLoading,
|
||||
error: queryError,
|
||||
refetch
|
||||
} = useQuery({
|
||||
queryKey: ['bundle-backtests', bundle?.requestId],
|
||||
queryFn: async () => {
|
||||
if (!open || !bundle) return [];
|
||||
const client = new BacktestClient({} as any, apiUrl);
|
||||
const res = await client.backtest_GetBacktestsByRequestId(bundle.requestId);
|
||||
if (!res) return [];
|
||||
return res.map((b: any) => ({
|
||||
id: b.id,
|
||||
config: b.config,
|
||||
finalPnl: b.finalPnl,
|
||||
winRate: b.winRate,
|
||||
growthPercentage: b.growthPercentage,
|
||||
hodlPercentage: b.hodlPercentage,
|
||||
startDate: b.startDate,
|
||||
endDate: b.endDate,
|
||||
maxDrawdown: b.maxDrawdown ?? null,
|
||||
fees: b.fees,
|
||||
sharpeRatio: b.sharpeRatio ?? null,
|
||||
score: b.score ?? 0,
|
||||
scoreMessage: b.scoreMessage ?? '',
|
||||
}));
|
||||
},
|
||||
enabled: !!open && !!bundle,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (queryBacktests) setBacktests(queryBacktests);
|
||||
}, [queryBacktests]);
|
||||
|
||||
// SignalR live updates
|
||||
useEffect(() => {
|
||||
if (!open || !bundle) return;
|
||||
if (bundle.status !== 'Pending' && bundle.status !== 'Running') return;
|
||||
let connection: any = null;
|
||||
let connectionId: string = '';
|
||||
let unsubscribed = false;
|
||||
(async () => {
|
||||
try {
|
||||
connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(`${apiUrl.replace(/\/$/, '')}/backtestHub`)
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
await connection.start();
|
||||
connectionId = connection.connectionId;
|
||||
// Subscribe to bundle updates
|
||||
const authBase = new AuthorizedApiBase({} as any);
|
||||
let fetchOptions: any = {
|
||||
method: 'POST',
|
||||
headers: { 'X-SignalR-ConnectionId': connectionId },
|
||||
};
|
||||
fetchOptions = await authBase.transformOptions(fetchOptions);
|
||||
await fetch(`${apiUrl}/backtest/Bundle/Subscribe?requestId=${bundle.requestId}`, fetchOptions);
|
||||
connection.on('BundleBacktestUpdate', (result: LightBacktestResponse) => {
|
||||
setBacktests((prev) => {
|
||||
if (prev.some((b) => b.id === result.id)) return prev;
|
||||
return [...prev, result];
|
||||
});
|
||||
});
|
||||
signalRRef.current = connection;
|
||||
} catch (e: any) {
|
||||
new Toast('Failed to subscribe to live updates', false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
unsubscribed = true;
|
||||
if (connection && connectionId) {
|
||||
(async () => {
|
||||
const authBase = new AuthorizedApiBase({} as any);
|
||||
let fetchOptions: any = {
|
||||
method: 'POST',
|
||||
headers: { 'X-SignalR-ConnectionId': connectionId },
|
||||
};
|
||||
fetchOptions = await authBase.transformOptions(fetchOptions);
|
||||
await fetch(`${apiUrl}/backtest/Bundle/Unsubscribe?requestId=${bundle.requestId}`, fetchOptions);
|
||||
})();
|
||||
}
|
||||
if (signalRRef.current) {
|
||||
signalRRef.current.stop();
|
||||
signalRRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [open, bundle, apiUrl]);
|
||||
|
||||
if (!open || !bundle) return null;
|
||||
|
||||
return (
|
||||
<div className="modal modal-open">
|
||||
<div className="modal-box max-w-4xl">
|
||||
<h3 className="font-bold text-lg mb-2">Bundle: {bundle.name}</h3>
|
||||
<div className="mb-2 text-sm">
|
||||
<div><b>Request ID:</b> <span className="font-mono text-xs">{bundle.requestId}</span></div>
|
||||
<div><b>Status:</b> <span className={`badge badge-sm ml-1`}>{bundle.status}</span></div>
|
||||
<div><b>Created:</b> {bundle.createdAt ? new Date(bundle.createdAt).toLocaleString() : '-'}</div>
|
||||
<div><b>Completed:</b> {bundle.completedAt ? new Date(bundle.completedAt).toLocaleString() : '-'}</div>
|
||||
</div>
|
||||
<div className="divider">Backtest Results</div>
|
||||
{isLoading ? (
|
||||
<div>Loading backtests...</div>
|
||||
) : queryError ? (
|
||||
<div className="text-error">{(queryError as any)?.message || 'Failed to fetch backtests'}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto max-h-96">
|
||||
<table className="table table-zebra w-full text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Final PnL</th>
|
||||
<th>Win Rate</th>
|
||||
<th>Growth %</th>
|
||||
<th>Start</th>
|
||||
<th>End</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{backtests.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td className="font-mono">{b.id}</td>
|
||||
<td>{b.finalPnl}</td>
|
||||
<td>{b.winRate}</td>
|
||||
<td>{b.growthPercentage}</td>
|
||||
<td>{b.startDate ? new Date(b.startDate).toLocaleString() : '-'}</td>
|
||||
<td>{b.endDate ? new Date(b.endDate).toLocaleString() : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div className="modal-action">
|
||||
<button className="btn" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BundleRequestModal;
|
||||
@@ -4,6 +4,7 @@ import useApiUrlStore from '../../app/store/apiStore';
|
||||
import Table from '../../components/mollecules/Table/Table';
|
||||
import {BundleBacktestRequest} from '../../generated/ManagingApiTypes';
|
||||
import Toast from '../../components/mollecules/Toast/Toast';
|
||||
import BundleRequestModal from './BundleRequestModal';
|
||||
|
||||
const BundleRequestsTable = () => {
|
||||
const { apiUrl } = useApiUrlStore();
|
||||
@@ -11,6 +12,8 @@ const BundleRequestsTable = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedBundle, setSelectedBundle] = useState<BundleBacktestRequest | null>(null);
|
||||
|
||||
const fetchData = () => {
|
||||
setLoading(true);
|
||||
@@ -119,7 +122,15 @@ const BundleRequestsTable = () => {
|
||||
disableSortBy: true,
|
||||
Cell: ({ row }: any) => (
|
||||
<div className="flex gap-2">
|
||||
<button className="btn btn-xs btn-outline" onClick={() => new Toast(`RequestId: ${row.original.requestId}`, false)}>View</button>
|
||||
<button
|
||||
className="btn btn-xs btn-outline"
|
||||
onClick={() => {
|
||||
setSelectedBundle(row.original);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-xs btn-error"
|
||||
onClick={() => handleDelete(row.original.requestId)}
|
||||
@@ -139,6 +150,11 @@ const BundleRequestsTable = () => {
|
||||
<div className="w-full">
|
||||
<h2 className="text-lg font-bold mb-2">Bundle Backtest Requests</h2>
|
||||
<Table columns={columns} data={data} showPagination={true} />
|
||||
<BundleRequestModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
bundle={selectedBundle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user