feat(trading): 实现做市商吃单/挂单模式互斥机制

## 后端 - trading-service

### MarketMakerService
- 新增 MarketMakerMode 类型:'idle' | 'taker' | 'maker'
- 新增 getCurrentMode() 和 getRunningStatus() 方法获取当前运行状态
- start() (吃单模式): 启动前自动停止挂单模式
- startMaker() (挂单模式): 启动前自动停止吃单模式
- 两种模式互斥,同一时间只能运行一种

### MarketMakerController
- getConfig 接口返回 runningStatus 运行状态
- 新增 GET /status 接口获取做市商运行状态

## 前端 - mining-admin-web

### 做市商管理页面
- 新增运行模式状态卡片,显示当前模式(空闲/吃单/挂单)
- 吃单模式和挂单模式使用 runningStatus 判断状态
- 添加互斥提示:启动一个模式会自动停止另一个
- 挂单模式添加警告提示:卖单被吃会触发销毁导致价格上涨

### API 更新
- 新增 RunningStatus 接口类型
- getConfig 返回类型增加 runningStatus
- 新增 getRunningStatus API

## 设计说明
- 吃单模式(推荐):做市商只作为买方,不触发额外销毁
- 挂单模式(谨慎使用):做市商挂卖单会触发销毁机制

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-17 21:41:39 -08:00
parent 3b6bd29283
commit 8c78f26e6d
4 changed files with 169 additions and 15 deletions

View File

@ -97,11 +97,14 @@ export class MarketMakerController {
@ApiResponse({ status: 200, description: '返回做市商配置' })
async getConfig(@Param('name') name: string) {
const config = await this.marketMakerService.getConfig(name);
const runningStatus = this.marketMakerService.getRunningStatus();
if (!config) {
return {
success: false,
message: `做市商 ${name} 不存在`,
config: null,
runningStatus,
};
}
@ -124,6 +127,24 @@ export class MarketMakerController {
discountRate: config.discountRate.toString(),
isActive: config.isActive,
},
runningStatus,
};
}
@Get('status')
@Public()
@ApiOperation({ summary: '获取做市商运行状态' })
@ApiResponse({ status: 200, description: '返回运行状态' })
getRunningStatus() {
const status = this.marketMakerService.getRunningStatus();
return {
success: true,
...status,
modeDescription: {
idle: '空闲(未运行)',
taker: '吃单模式(自动买入用户卖单)',
maker: '挂单模式(双边深度做市)',
}[status.mode],
};
}

View File

@ -52,6 +52,8 @@ export enum AssetType {
SHARE = 'SHARE',
}
export type MarketMakerMode = 'idle' | 'taker' | 'maker';
@Injectable()
export class MarketMakerService {
private readonly logger = new Logger(MarketMakerService.name);
@ -103,6 +105,33 @@ export class MarketMakerService {
};
}
/**
*
* idle: 空闲
* taker: 吃单模式运行中
* maker: 挂单模式运行中
*/
getCurrentMode(): MarketMakerMode {
if (this.isMakerRunning) return 'maker';
if (this.isRunning) return 'taker';
return 'idle';
}
/**
*
*/
getRunningStatus(): {
mode: MarketMakerMode;
takerRunning: boolean;
makerRunning: boolean;
} {
return {
mode: this.getCurrentMode(),
takerRunning: this.isRunning,
makerRunning: this.isMakerRunning,
};
}
/**
*
*/
@ -333,7 +362,8 @@ export class MarketMakerService {
}
/**
*
*
*
*/
async start(name: string = 'MAIN_MARKET_MAKER'): Promise<void> {
const config = await this.getConfig(name);
@ -342,17 +372,23 @@ export class MarketMakerService {
}
if (this.isRunning) {
this.logger.warn('Market maker is already running');
this.logger.warn('Market maker taker mode is already running');
return;
}
// 互斥:如果挂单模式正在运行,先停止它
if (this.isMakerRunning) {
this.logger.log('Stopping maker mode before starting taker mode (mutual exclusion)');
await this.stopMaker(name);
}
await this.prisma.marketMakerConfig.update({
where: { id: config.id },
data: { isActive: true },
data: { isActive: true, makerEnabled: false },
});
this.isRunning = true;
this.logger.log(`Market maker ${name} started`);
this.logger.log(`Market maker ${name} taker mode started`);
// 开始吃单循环
this.scheduleNextRun(config);
@ -723,6 +759,7 @@ export class MarketMakerService {
/**
*
*
*/
async startMaker(name: string = 'MAIN_MARKET_MAKER'): Promise<void> {
const configRecord = await this.prisma.marketMakerConfig.findUnique({
@ -737,9 +774,15 @@ export class MarketMakerService {
return;
}
// 互斥:如果吃单模式正在运行,先停止它
if (this.isRunning) {
this.logger.log('Stopping taker mode before starting maker mode (mutual exclusion)');
await this.stop(name);
}
await this.prisma.marketMakerConfig.update({
where: { id: configRecord.id },
data: { makerEnabled: true },
data: { makerEnabled: true, isActive: false },
});
this.isMakerRunning = true;

View File

@ -58,6 +58,8 @@ export default function MarketMakerPage() {
const { data: depthData, isLoading: depthLoading } = useDepth(10);
const { data: depthEnabled, isLoading: depthEnabledLoading } = useDepthEnabled();
const runningStatus = configData?.runningStatus;
const initializeMutation = useInitializeMarketMaker();
const depositCashMutation = useDepositCash();
const withdrawCashMutation = useWithdrawCash();
@ -341,6 +343,49 @@ export default function MarketMakerPage() {
</Card>
</div>
{/* 运行模式状态 */}
<Card className="border-2 border-primary/20">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg"></CardTitle>
{runningStatus?.mode === 'idle' && (
<Badge variant="secondary" className="text-base px-3 py-1">
<Pause className="h-4 w-4 mr-1" />
</Badge>
)}
{runningStatus?.mode === 'taker' && (
<Badge variant="default" className="bg-green-500 text-base px-3 py-1">
<Zap className="h-4 w-4 mr-1" />
</Badge>
)}
{runningStatus?.mode === 'maker' && (
<Badge variant="default" className="bg-blue-500 text-base px-3 py-1">
<BarChart3 className="h-4 w-4 mr-1" />
</Badge>
)}
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-6 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${runningStatus?.takerRunning ? 'bg-green-500' : 'bg-gray-300'}`} />
<span>: {runningStatus?.takerRunning ? '运行中' : '未运行'}</span>
</div>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${runningStatus?.makerRunning ? 'bg-blue-500' : 'bg-gray-300'}`} />
<span>: {runningStatus?.makerRunning ? '运行中' : '未运行'}</span>
</div>
<div className="ml-auto text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
<AlertCircle className="h-3 w-3 inline mr-1" />
</div>
</div>
</CardContent>
</Card>
{/* 运行模式控制 */}
<Tabs defaultValue="taker">
<TabsList>
@ -359,9 +404,11 @@ export default function MarketMakerPage() {
<Zap className="h-5 w-5" />
</CardTitle>
<CardDescription>Taker </CardDescription>
<CardDescription>
Taker
</CardDescription>
</div>
{config.isActive ? (
{runningStatus?.takerRunning ? (
<Badge variant="default" className="bg-green-500">
<CheckCircle2 className="h-3 w-3 mr-1" />
@ -396,7 +443,7 @@ export default function MarketMakerPage() {
</div>
<div className="flex gap-2 pt-4 border-t">
{config.isActive ? (
{runningStatus?.takerRunning ? (
<Button
variant="destructive"
onClick={() => stopTakerMutation.mutate()}
@ -423,6 +470,13 @@ export default function MarketMakerPage() {
{takeOrderMutation.isPending ? '执行中...' : '手动吃单'}
</Button>
</div>
{runningStatus?.makerRunning && (
<div className="text-sm text-yellow-600 bg-yellow-50 p-3 rounded">
<AlertCircle className="h-4 w-4 inline mr-1" />
</div>
)}
</div>
</CardContent>
</Card>
@ -438,10 +492,12 @@ export default function MarketMakerPage() {
<BarChart3 className="h-5 w-5" />
</CardTitle>
<CardDescription>Maker </CardDescription>
<CardDescription>
Maker
</CardDescription>
</div>
{config.makerEnabled ? (
<Badge variant="default" className="bg-green-500">
{runningStatus?.makerRunning ? (
<Badge variant="default" className="bg-blue-500">
<CheckCircle2 className="h-3 w-3 mr-1" />
</Badge>
@ -455,6 +511,13 @@ export default function MarketMakerPage() {
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* 警告提示 */}
<div className="text-sm text-orange-600 bg-orange-50 p-3 rounded border border-orange-200">
<AlertCircle className="h-4 w-4 inline mr-1" />
<strong></strong>
使
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div>
<p className="text-sm text-muted-foreground"></p>
@ -483,7 +546,7 @@ export default function MarketMakerPage() {
</div>
<div className="flex gap-2 pt-4 border-t">
{config.makerEnabled ? (
{runningStatus?.makerRunning ? (
<Button
variant="destructive"
onClick={() => stopMakerMutation.mutate()}
@ -494,6 +557,7 @@ export default function MarketMakerPage() {
</Button>
) : (
<Button
variant="outline"
onClick={() => startMakerMutation.mutate()}
disabled={startMakerMutation.isPending}
>
@ -504,7 +568,7 @@ export default function MarketMakerPage() {
<Button
variant="outline"
onClick={() => refreshOrdersMutation.mutate()}
disabled={refreshOrdersMutation.isPending}
disabled={refreshOrdersMutation.isPending || !runningStatus?.makerRunning}
>
<RefreshCw className="h-4 w-4 mr-2" />
{refreshOrdersMutation.isPending ? '刷新中...' : '刷新挂单'}
@ -517,6 +581,13 @@ export default function MarketMakerPage() {
{cancelAllOrdersMutation.isPending ? '取消中...' : '取消所有挂单'}
</Button>
</div>
{runningStatus?.takerRunning && (
<div className="text-sm text-yellow-600 bg-yellow-50 p-3 rounded">
<AlertCircle className="h-4 w-4 inline mr-1" />
</div>
)}
</div>
</CardContent>
</Card>

View File

@ -34,6 +34,15 @@ tradingClient.interceptors.response.use(
}
);
export type MarketMakerMode = 'idle' | 'taker' | 'maker';
export interface RunningStatus {
mode: MarketMakerMode;
takerRunning: boolean;
makerRunning: boolean;
modeDescription?: string;
}
export interface MarketMakerConfig {
id: string;
name: string;
@ -90,12 +99,22 @@ export interface DepthData {
}
export const marketMakerApi = {
// 获取做市商配置
getConfig: async (name: string = 'MAIN_MARKET_MAKER'): Promise<{ success: boolean; config: MarketMakerConfig | null }> => {
// 获取做市商配置(包含运行状态)
getConfig: async (name: string = 'MAIN_MARKET_MAKER'): Promise<{
success: boolean;
config: MarketMakerConfig | null;
runningStatus: RunningStatus;
}> => {
const response = await tradingClient.get(`/admin/market-maker/${name}/config`);
return response.data;
},
// 获取运行状态
getRunningStatus: async (): Promise<{ success: boolean } & RunningStatus> => {
const response = await tradingClient.get('/admin/market-maker/status');
return response.data;
},
// 初始化做市商
initialize: async (data: {
name?: string;