Add copy trading functionality with StartCopyTrading endpoint and related models. Implemented position copying from master bot and subscription to copy trading stream in LiveTradingBotGrain. Updated TradingBotConfig to support copy trading parameters.

This commit is contained in:
2025-11-16 14:54:17 +07:00
parent 428e36d744
commit 1e15d5f23b
10 changed files with 711 additions and 173 deletions

View File

@@ -1479,6 +1479,45 @@ export class BotClient extends AuthorizedApiBase {
return Promise.resolve<string>(null as any);
}
bot_StartCopyTrading(request: StartCopyTradingRequest): Promise<string> {
let url_ = this.baseUrl + "/Bot/StartCopyTrading";
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(request);
let options_: RequestInit = {
body: content_,
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
}
};
return this.transformOptions(options_).then(transformedOptions_ => {
return this.http.fetch(url_, transformedOptions_);
}).then((_response: Response) => {
return this.processBot_StartCopyTrading(_response);
});
}
protected processBot_StartCopyTrading(response: Response): Promise<string> {
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) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as string;
return result200;
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<string>(null as any);
}
bot_Save(request: SaveBotRequest): Promise<string> {
let url_ = this.baseUrl + "/Bot/Save";
url_ = url_.replace(/[?&]$/, "");
@@ -4772,6 +4811,8 @@ export interface TradingBotConfig {
useForPositionSizing?: boolean;
useForSignalFiltering?: boolean;
useForDynamicStopLoss?: boolean;
isForCopyTrading?: boolean;
masterBotIdentifier?: string | null;
}
export interface LightMoneyManagement {
@@ -5334,6 +5375,11 @@ export interface StartBotRequest {
config?: TradingBotConfigRequest | null;
}
export interface StartCopyTradingRequest {
masterBotIdentifier?: string;
botTradingBalance?: number;
}
export interface SaveBotRequest extends StartBotRequest {
}

View File

@@ -334,6 +334,8 @@ export interface TradingBotConfig {
useForPositionSizing?: boolean;
useForSignalFiltering?: boolean;
useForDynamicStopLoss?: boolean;
isForCopyTrading?: boolean;
masterBotIdentifier?: string | null;
}
export interface LightMoneyManagement {
@@ -896,6 +898,11 @@ export interface StartBotRequest {
config?: TradingBotConfigRequest | null;
}
export interface StartCopyTradingRequest {
masterBotIdentifier?: string;
botTradingBalance?: number;
}
export interface SaveBotRequest extends StartBotRequest {
}

View File

@@ -1,17 +1,29 @@
import {ChartBarIcon, CogIcon, EyeIcon, PlayIcon, PlusCircleIcon, StopIcon, TrashIcon} from '@heroicons/react/solid'
import {
ChartBarIcon,
CogIcon,
DocumentDuplicateIcon,
EyeIcon,
PlayIcon,
PlusCircleIcon,
StopIcon,
TrashIcon
} from '@heroicons/react/solid'
import React, {useState} from 'react'
import {useQuery} from '@tanstack/react-query'
import useApiUrlStore from '../../app/store/apiStore'
import {useCurrentUser} from '../../app/store/userStore'
import {CardPosition, CardSignal, CardText, Toast,} from '../../components/mollecules'
import {Toast,} from '../../components/mollecules'
import ManualPositionModal from '../../components/mollecules/ManualPositionModal'
import TradesModal from '../../components/mollecules/TradesModal/TradesModal'
import {TradeChart, UnifiedTradingModal} from '../../components/organism'
import Table from '../../components/mollecules/Table/Table'
import {UnifiedTradingModal} from '../../components/organism'
import {
BotClient,
BotSortableColumn,
BotStatus,
MoneyManagement,
Position,
StartCopyTradingRequest,
TradingBotConfig,
TradingBotResponse
} from '../../generated/ManagingApi'
@@ -51,15 +63,29 @@ function cardClasses(botStatus: BotStatus) {
const BotList: React.FC<IBotList> = ({ list }) => {
const { apiUrl } = useApiUrlStore()
const client = new BotClient({}, apiUrl)
// Call the hook at the top level
const { user } = useCurrentUser()
// Filter and pagination states
const [pageNumber, setPageNumber] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [statusFilter, setStatusFilter] = useState<BotStatus | undefined>(undefined)
const [agentFilter, setAgentFilter] = useState<string | undefined>(undefined)
const [sortBy, setSortBy] = useState<BotSortableColumn>(BotSortableColumn.Roi)
const [sortDirection, setSortDirection] = useState<'Asc' | 'Desc'>('Desc')
// Use the user data for bot ownership checking
const checkIsBotOwner = (botAgentName: string) => {
return user?.agentName === botAgentName
}
// Fetch paginated bot data
const { data: paginatedBots, isLoading } = useQuery({
queryFn: () => client.bot_GetBotsPaginated(pageNumber, pageSize, statusFilter, undefined, undefined, agentFilter, sortBy, sortDirection),
queryKey: ['bots', pageNumber, pageSize, statusFilter, agentFilter, sortBy, sortDirection],
})
const [showMoneyManagementModal, setShowMoneyManagementModal] =
useState(false)
const [selectedMoneyManagement, setSelectedMoneyManagement] =
@@ -73,6 +99,9 @@ const BotList: React.FC<IBotList> = ({ list }) => {
identifier: string
config: TradingBotConfig
} | null>(null)
const [showCopyTradingModal, setShowCopyTradingModal] = useState(false)
const [selectedBotForCopy, setSelectedBotForCopy] = useState<{ name: string; identifier: string } | null>(null)
const [copyTradingBalance, setCopyTradingBalance] = useState<number>(1000)
function getDeleteBadge(identifier: string) {
const classes = baseBadgeClass() + 'bg-error'
@@ -145,6 +174,17 @@ const BotList: React.FC<IBotList> = ({ list }) => {
)
}
function getCopyTradingBadge(name: string, botIdentifier: string) {
const classes = baseBadgeClass() + ' bg-purple-500'
return (
<button className={classes} onClick={() => openCopyTradingModal(name, botIdentifier)}>
<p className="text-primary-content flex">
<DocumentDuplicateIcon width={15}></DocumentDuplicateIcon>
</p>
</button>
)
}
function openManualPositionModal(botIdentifier: string) {
setSelectedBotForManualPosition(botIdentifier)
setShowManualPositionModal(true)
@@ -155,6 +195,11 @@ const BotList: React.FC<IBotList> = ({ list }) => {
setShowTradesModal(true)
}
function openCopyTradingModal(name: string, botIdentifier: string) {
setSelectedBotForCopy({ name: name, identifier: botIdentifier })
setShowCopyTradingModal(true)
}
function toggleBotStatus(status: BotStatus, identifier: string) {
const isUp = status == BotStatus.Running
const t = new Toast(isUp ? 'Stoping bot' : 'Restarting bot')
@@ -193,6 +238,29 @@ const BotList: React.FC<IBotList> = ({ list }) => {
})
}
function copyTrading() {
if (!selectedBotForCopy) return
const t = new Toast('Starting copy trading bot')
const request: StartCopyTradingRequest = {
masterBotIdentifier: selectedBotForCopy.identifier,
botTradingBalance: copyTradingBalance
}
client
.bot_StartCopyTrading(request)
.then((result) => {
t.update('success', result || 'Copy trading bot started successfully', { autoClose: 3000 })
setShowCopyTradingModal(false)
setSelectedBotForCopy(null)
setCopyTradingBalance(1000)
})
.catch((err) => {
t.update('error', err.message || 'Failed to start copy trading bot', { autoClose: 5000 })
})
}
function getUpdateBotBadge(bot: TradingBotResponse) {
const classes = baseBadgeClass() + ' bg-orange-500'
return (
@@ -206,7 +274,7 @@ const BotList: React.FC<IBotList> = ({ list }) => {
async function openUpdateBotModal(bot: TradingBotResponse) {
const t = new Toast('Loading bot configuration...')
try {
const config = await client.bot_GetBotConfig(bot.identifier)
setSelectedBotForUpdate({
@@ -221,94 +289,158 @@ const BotList: React.FC<IBotList> = ({ list }) => {
}
}
return (
<div className="flex flex-wrap m-4 -mx-4">
{list.map((bot: TradingBotResponse, index: number) => (
<div
key={index.toString()}
className="sm:w-1 md:w-1/2 xl:w-1/2 w-full p-2"
>
<div className={cardClasses(bot.status as BotStatus)}>
<figure className="w-full">
{bot.candles && bot.candles.length > 0 ? (
<TradeChart
candles={bot.candles}
positions={Object.values(bot.positions)}
signals={Object.values(bot.signals)}
></TradeChart>
) : null}
</figure>
<div className="card-body">
<div className="mb-4">
{/* Bot Name - Always on its own line */}
<h2 className="card-title text-sm mb-3">
{bot.name}
</h2>
{/* Badge Container - Responsive */}
<div className="flex flex-wrap gap-1 sm:gap-2">
{/* Action Badges - Only show for bot owners */}
{ (
<div className="flex flex-wrap gap-1">
{getToggleBotStatusBadge(bot.status as BotStatus, bot.identifier)}
{getUpdateBotBadge(bot)}
{getManualPositionBadge(bot.identifier)}
{getDeleteBadge(bot.identifier)}
</div>
)}
</div>
</div>
<div className="columns-2">
<div>
<div>
<CardText
title="Ticker"
content={bot.ticker}
></CardText>
</div>
</div>
</div>
<div className="columns-2">
<CardText
title="Agent"
content={bot.agentName}
></CardText>
<CardSignal signals={Object.values(bot.signals ?? {})}></CardSignal>
</div>
<div className="columns-2">
<CardPosition
positivePosition={true}
positions={Object.values(bot.positions ?? {}).filter((p: Position) => {
const realized = p.ProfitAndLoss?.realized ?? 0
return realized > 0 ? p : null
})}
></CardPosition>
<CardPosition
positivePosition={false}
positions={Object.values(bot.positions ?? {}).filter((p: Position) => {
const realized = p.ProfitAndLoss?.realized ?? 0
return realized <= 0 ? p : null
})}
></CardPosition>
</div>
<div className="text-sm">
<div className="card-actions justify-center pt-2">
<div className={baseBadgeClass(true)}>
WR {bot.winRate?.toFixed(2).toString()} %
</div>
<div className={baseBadgeClass(true)}>
PNL {bot.profitAndLoss.toFixed(2).toString()} $
</div>
{getTradesBadge(bot.name, bot.agentName, bot.identifier)}
</div>
</div>
// Table columns definition
const columns = React.useMemo(
() => [
{
Header: 'Name',
accessor: 'name',
Cell: ({ row }: any) => (
<div className="font-medium">{row.original.name}</div>
),
},
{
Header: 'Status',
accessor: 'status',
Cell: ({ row }: any) => {
const status = row.original.status as BotStatus
let statusClass = 'badge '
switch (status) {
case BotStatus.Running:
statusClass += 'badge-success'
break
case BotStatus.Stopped:
statusClass += 'badge-warning'
break
case BotStatus.Saved:
statusClass += 'badge-info'
break
default:
statusClass += 'badge-neutral'
}
return <div className={statusClass}>{BotStatus[status]}</div>
},
},
{
Header: 'Ticker',
accessor: 'ticker',
},
{
Header: 'Agent',
accessor: 'agentName',
},
{
Header: 'Win Rate %',
accessor: 'winRate',
Cell: ({ value }: any) => value?.toFixed(2) || '0.00',
},
{
Header: 'PNL $',
accessor: 'profitAndLoss',
Cell: ({ value }: any) => value?.toFixed(2) || '0.00',
},
{
Header: 'Actions',
accessor: 'actions',
disableSortBy: true,
Cell: ({ row }: any) => {
const bot = row.original
return (
<div className="flex gap-1">
{getToggleBotStatusBadge(bot.status, bot.identifier)}
{getUpdateBotBadge(bot)}
{getManualPositionBadge(bot.identifier)}
{getCopyTradingBadge(bot.name, bot.identifier)}
{getTradesBadge(bot.name, bot.agentName, bot.identifier)}
{getDeleteBadge(bot.identifier)}
</div>
</div>
</div>
))}
)
},
},
],
[]
)
return (
<div className="p-4">
{/* Filters */}
<div className="mb-4 flex flex-wrap gap-4 items-center">
<div className="form-control">
<label className="label">
<span className="label-text">Status</span>
</label>
<select
className="select select-bordered select-sm"
value={statusFilter || ''}
onChange={(e) => setStatusFilter(e.target.value ? (Number(e.target.value) as unknown as BotStatus) : undefined)}
>
<option value="">All Status</option>
<option value={BotStatus.Running}>Running</option>
<option value={BotStatus.Stopped}>Stopped</option>
<option value={BotStatus.Saved}>Saved</option>
</select>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Agent</span>
</label>
<input
type="text"
placeholder="Filter by agent name"
className="input input-bordered input-sm"
value={agentFilter || ''}
onChange={(e) => setAgentFilter(e.target.value || undefined)}
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Sort By</span>
</label>
<select
className="select select-bordered select-sm"
value={sortBy}
onChange={(e) => setSortBy(Number(e.target.value) as unknown as BotSortableColumn)}
>
<option value={BotSortableColumn.Roi}>ROI</option>
<option value={BotSortableColumn.Pnl}>Profit & Loss</option>
<option value={BotSortableColumn.WinRate}>Win Rate</option>
<option value={BotSortableColumn.Name}>Name</option>
<option value={BotSortableColumn.Status}>Status</option>
<option value={BotSortableColumn.CreateDate}>Created At</option>
</select>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Sort Direction</span>
</label>
<select
className="select select-bordered select-sm"
value={sortDirection}
onChange={(e) => setSortDirection(e.target.value as 'Asc' | 'Desc')}
>
<option value="Desc">Descending</option>
<option value="Asc">Ascending</option>
</select>
</div>
</div>
{/* Table */}
{isLoading ? (
<div className="flex justify-center py-8">
<div className="loading loading-spinner loading-lg"></div>
</div>
) : (
<Table
columns={columns}
data={paginatedBots?.items || []}
showPagination={true}
/>
)}
{/* Modals */}
<MoneyManagementModal
showModal={showMoneyManagementModal}
moneyManagement={selectedMoneyManagement}
@@ -345,6 +477,51 @@ const BotList: React.FC<IBotList> = ({ list }) => {
config: selectedBotForUpdate.config
} : undefined}
/>
{/* Copy Trading Modal */}
{showCopyTradingModal && selectedBotForCopy && (
<div className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg">Start Copy Trading</h3>
<p className="py-4">
Copy trades from <strong>{selectedBotForCopy.name}</strong>
</p>
<div className="form-control">
<label className="label">
<span className="label-text">Trading Balance (USDC)</span>
</label>
<input
type="number"
placeholder="Enter trading balance"
className="input input-bordered"
value={copyTradingBalance}
onChange={(e) => setCopyTradingBalance(Number(e.target.value))}
min="1"
step="0.01"
/>
</div>
<div className="modal-action">
<button
className="btn"
onClick={() => {
setShowCopyTradingModal(false)
setSelectedBotForCopy(null)
setCopyTradingBalance(1000)
}}
>
Cancel
</button>
<button
className="btn btn-primary"
onClick={copyTrading}
disabled={copyTradingBalance <= 0}
>
Start Copy Trading
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,92 +1,16 @@
import {ViewGridAddIcon} from '@heroicons/react/solid'
import React, {useState} from 'react'
import 'react-toastify/dist/ReactToastify.css'
import useApiUrlStore from '../../app/store/apiStore'
import {useCurrentAgentName} from '../../app/store/userStore'
import {UnifiedTradingModal} from '../../components/organism'
import {BotClient, BotSortableColumn, BotStatus} from '../../generated/ManagingApi'
import {useQuery} from '@tanstack/react-query'
import BotList from './botList'
const Bots: React.FC = () => {
const [activeTab, setActiveTab] = useState(0)
const [showBotConfigModal, setShowBotConfigModal] = useState(false)
const [pageNumber, setPageNumber] = useState(1)
const [pageSize] = useState(50) // Fixed page size for now
// Reset page number when tab changes
const handleTabChange = (newTab: number) => {
setActiveTab(newTab)
setPageNumber(1) // Reset to first page when changing tabs
}
const { apiUrl } = useApiUrlStore()
const botClient = new BotClient({}, apiUrl)
// Use the new user store hook to get current agent name
const currentAgentName = useCurrentAgentName()
// Query for paginated bots using the new endpoint
const { data: paginatedBots } = useQuery({
queryFn: () => {
switch (activeTab) {
case 0: // All Active Bots
return botClient.bot_GetBotsPaginated(pageNumber, pageSize, BotStatus.Running, undefined, undefined, undefined, BotSortableColumn.Roi, 'Desc')
case 1: // My Active Bots
return botClient.bot_GetBotsPaginated(pageNumber, pageSize, BotStatus.Running, undefined, undefined, currentAgentName, BotSortableColumn.Roi, 'Desc')
case 2: // My Down Bots
return botClient.bot_GetBotsPaginated(pageNumber, pageSize, BotStatus.Stopped, undefined, undefined, currentAgentName, BotSortableColumn.Roi, 'Desc')
case 3: // Saved Bots
return botClient.bot_GetBotsPaginated(pageNumber, pageSize, BotStatus.Saved, undefined, undefined, currentAgentName, BotSortableColumn.Roi, 'Desc')
default:
return botClient.bot_GetBotsPaginated(pageNumber, pageSize, undefined, undefined, undefined, undefined, BotSortableColumn.Roi, 'Desc')
}
},
queryKey: ['paginatedBots', activeTab, pageNumber, pageSize, currentAgentName],
enabled: !!currentAgentName,
})
const filteredBots = paginatedBots?.items || []
function openCreateBotModal() {
setShowBotConfigModal(true)
}
const tabs = [
{ label: 'All Active Bots', index: 0 },
{ label: 'My Active Bots', index: 1 },
{ label: 'My Down Bots', index: 2 },
{ label: 'Saved Bots', index: 3 },
]
return (
<div>
<div className="container mx-auto">
{/* Action Buttons */}
<div className="flex gap-2 mb-4">
<div className="tooltip" data-tip="Create new bot">
<button className="btn btn-primary m-1 text-xs" onClick={openCreateBotModal}>
<ViewGridAddIcon width="20"></ViewGridAddIcon>
</button>
</div>
</div>
{/* Tabs */}
<div className="tabs tabs-boxed mb-4">
{tabs.map((tab) => (
<button
key={tab.index}
className={`tab ${activeTab === tab.index ? 'tab-active' : ''}`}
onClick={() => handleTabChange(tab.index)}
>
{tab.label}
</button>
))}
</div>
{/* Bot List */}
<BotList list={filteredBots} />
<BotList list={[]} />
{/* Unified Trading Modal */}
<UnifiedTradingModal