Add signalr

This commit is contained in:
2025-07-21 19:54:04 +07:00
parent a32e9c33a8
commit 83ed78a1fa
11 changed files with 441 additions and 10 deletions

View File

@@ -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(/[?&]$/, "");

View File

@@ -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;

View File

@@ -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>
);
};