Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions backend/api/llm_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package api

import (
"backend/utils"
"net/http"
"strconv"

"github.com/gin-gonic/gin"
)

// AnalyzeOpportunity
// @Summary 使用 LLM 分析套利机会
// @Description 调用 LLM 对指定 ID 的套利机会进行风险分析
// @Tags Opportunities
// @Accept json
// @Produce json
// @Param id path int true "Opportunity ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} api.ErrorResponse
// @Failure 500 {object} api.ErrorResponse
// @Router /opportunities/{id}/analyze [post]
func (h *Handler) AnalyzeOpportunity(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
utils.Fail(c, http.StatusBadRequest, "Invalid ID format")
return
}

result, err := h.service.AnalyzeOpportunityWithLLM(uint(id))
if err != nil {
utils.Fail(c, http.StatusInternalServerError, err.Error())
return
}

utils.Success(c, result)
}
1 change: 1 addition & 0 deletions backend/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func SetupRouter(taskHandler *TaskHandler, templateHandler *TemplateHandler, bat
{
// 3. 将路由绑定到 Handler 的方法上
apiV1.GET("/opportunities", h.GetOpportunities)
apiV1.POST("/opportunities/:id/analyze", h.AnalyzeOpportunity) // 新增路由
apiV1.GET("/price-comparison", h.GetPriceComparisonData)

if taskHandler != nil {
Expand Down
5 changes: 5 additions & 0 deletions backend/config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,8 @@ grpc:
port: ":50060"
worker:
address: "127.0.0.1:50052"

llm:
api_key: "---"
model: "qwen-turbo"
base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1"
1 change: 1 addition & 0 deletions backend/models/opportunity.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type ArbitrageOpportunity struct {
ProfitUSDT float64 `json:"profit_usdt" gorm:"not null"`
DetailsJSON datatypes.JSONMap `json:"details,omitempty" gorm:"type:jsonb" swaggertype:"object"`
RiskMetricsJSON datatypes.JSONMap `json:"risk_metrics,omitempty" gorm:"type:jsonb" swaggertype:"object"`
LLMAnalysisJSON datatypes.JSONMap `json:"llm_analysis,omitempty" gorm:"type:jsonb" swaggertype:"object"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
Expand Down
184 changes: 184 additions & 0 deletions backend/service/llm_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package service

import (
"backend/models"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"

"github.com/spf13/viper"
"gorm.io/datatypes"
)

type LLMRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
}

type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}

type LLMResponse struct {
Choices []Choice `json:"choices"`
}

type Choice struct {
Message Message `json:"message"`
}

func (s *Service) AnalyzeOpportunityWithLLM(opportunityID uint) (datatypes.JSONMap, error) {
// 1. 获取机会数据
var opp models.ArbitrageOpportunity
if err := s.db.First(&opp, opportunityID).Error; err != nil {
return nil, err
}

// 2. 如果已有分析结果,直接返回 (可选:增加 force 参数强制刷新)
if opp.LLMAnalysisJSON != nil {
return opp.LLMAnalysisJSON, nil
}

// 3. 准备 LLM 请求配置
apiKey := viper.GetString("llm.api_key")
baseURL := viper.GetString("llm.base_url")
model := viper.GetString("llm.model")

if apiKey == "" {
return nil, errors.New("LLM API Key is not configured")
}

// 4. 获取交易时间窗口及该窗口内的市场信息
// 假设我们在 DetailsJSON 中存储了 block_time,如果没有则使用 CreatedAt
// 窗口大小:前后 1 小时 (3600秒) 以获取足够的上下文
tradeTime := opp.CreatedAt
if val, ok := opp.DetailsJSON["block_time"]; ok {
if tStr, ok := val.(string); ok {
if t, err := time.Parse(time.RFC3339, tStr); err == nil {
tradeTime = t
}
}
}
windowSeconds := int64(3600)
startTime := tradeTime.UnixMilli() - windowSeconds*1000
endTime := tradeTime.UnixMilli() + windowSeconds*1000

marketData, _ := s.GetPriceComparisonData(startTime, endTime)

// 5. 构造 Prompt
contextData := map[string]interface{}{
"buy_platform": opp.BuyPlatform,
"sell_platform": opp.SellPlatform,
"buy_price": opp.BuyPrice,
"sell_price": opp.SellPrice,
"profit_usdt": opp.ProfitUSDT,
"risk_metrics": opp.RiskMetricsJSON,
"risk_metrics_definitions": map[string]string{
"volatility": "Price volatility in the current time window",
"market_volume_eth": "Total market volume (ETH)",
"investment_usdt": "Initial investment principal (USDT)",
"estimated_slippage_pct": "Estimated slippage percentage based on Square Root Law",
"risk_score": "Profit quality score (0-100), higher is better. Score = (Net Profit / Gross Profit) * 100",
},
"market_context": map[string]interface{}{
"window_start": time.UnixMilli(startTime).Format(time.RFC3339),
"window_end": time.UnixMilli(endTime).Format(time.RFC3339),
"price_data": marketData, // 包含 Uniswap 和 Binance 在该窗口内的价格序列
},
"timestamp": opp.CreatedAt.Format("2006-01-02 15:04:05"),
}
contextBytes, _ := json.Marshal(contextData)

systemPrompt := `You are a senior crypto quantitative risk analyst. Your task is to evaluate a **Non-Atomic Arbitrage Opportunity** between Binance (CEX) and Uniswap (DEX).

**Input Data Structure:**
1. **Opportunity Details**: Core trade parameters including buy/sell platforms, prices, and projected profit (USDT).
2. **Risk Metrics**: Quantitative indicators pre-calculated by the system (e.g., Volatility, Slippage, Risk Score).
3. **Market Context**: Historical price data (+/- 1 hour) for both exchanges.

**Analysis Requirements:**
Assess the risk of this non-atomic arbitrage trade based on the provided data. Focus on price stability, execution risks (due to transfer delays), and the robustness of the net profit.

**Output Format (Strict JSON):**
You MUST return a valid JSON object. Do not wrap it in markdown code blocks.
{
"risk_level": "High" | "Medium" | "Low",
"summary": "Concise analysis (max 50 words) focusing on WHY this risk level was chosen.",
"suggestions": [
"Actionable advice 1 (e.g., Check liquidity depth on Uniswap)",
"Actionable advice 2 (e.g., Wait for lower gas fees)",
"Actionable advice 3"
],
"warning": "Critical warning if applicable (e.g., 'Detected 5% price crash in 10s'), otherwise null or empty string."
}`

userPrompt := fmt.Sprintf("Analyze this opportunity: %s", string(contextBytes))

requestBody := LLMRequest{
Model: model,
Messages: []Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
},
}
jsonBody, _ := json.Marshal(requestBody)

// 5. 发送请求
req, err := http.NewRequest("POST", baseURL+"/chat/completions", bytes.NewBuffer(jsonBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != 200 {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("LLM API failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}

// 6. 解析响应
var llmResp LLMResponse
if err := json.NewDecoder(resp.Body).Decode(&llmResp); err != nil {
return nil, err
}

if len(llmResp.Choices) == 0 {
return nil, errors.New("LLM returned no choices")
}

content := llmResp.Choices[0].Message.Content

// 尝试提取 JSON (LLM 可能返回 Markdown 代码块)
// 这里做一个简单的处理,假设 LLM 听话返回了纯 JSON
// 如果包含 ```json ... ``` 需要 strip
// 简单处理:直接尝试解析,失败则封装为文本
var resultJSON datatypes.JSONMap
if err := json.Unmarshal([]byte(content), &resultJSON); err != nil {
// 尝试清理 markdown 标记
// 这是一个简单的 fallback
resultJSON = datatypes.JSONMap{
"raw_analysis": content,
"risk_level": "Unknown",
}
}

// 7. 保存结果到数据库
opp.LLMAnalysisJSON = resultJSON
if err := s.db.Save(&opp).Error; err != nil {
return nil, err
}

return resultJSON, nil
}
1 change: 1 addition & 0 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export interface ExperimentPayload {
const api = {
getOpportunities: (params?: OpportunitiesParams) =>
instance.get('/opportunities', { params }),
analyzeOpportunity: (id: number) => instance.post(`/opportunities/${id}/analyze`),
getPriceComparisonData: (params: PriceComparisonParams) =>
instance.get('/price-comparison', { params }),
getTasks: (params?: TaskListParams) => instance.get('/tasks', { params }),
Expand Down
94 changes: 94 additions & 0 deletions frontend/src/views/OpportunitiesView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,61 @@
<a-card size="small" title="价格上下文(mini)" :bordered="false">
<div ref="miniPriceRef" style="height: 260px;"></div>
</a-card>

<a-card size="small" :bordered="false" class="mt-4 bg-gray-50">
<template #title>
<div class="flex items-center gap-2">
<RobotOutlined class="text-blue-500" />
<span>AI 风险顾问</span>
</div>
</template>
<template #extra>
<a-button
type="primary"
ghost
size="small"
:loading="analyzing"
@click="analyzeRisk"
class="flex items-center gap-1"
>
<template #icon v-if="!analyzing"><RobotOutlined /></template>
{{ selectedOpp?.llm_analysis ? '重新分析' : '开始分析' }}
</a-button>
</template>

<div v-if="selectedOpp?.llm_analysis" class="py-2">
<div class="flex items-center gap-3 mb-3">
<span class="text-gray-500 text-sm">风险等级:</span>
<a-tag :color="getRiskLevelColor(selectedOpp.llm_analysis.risk_level)" class="px-3 py-0.5 text-sm font-medium rounded-full">
{{ selectedOpp.llm_analysis.risk_level }}
</a-tag>
</div>

<div class="mb-4 text-gray-700 bg-white p-4 rounded-lg border border-gray-100 shadow-sm leading-relaxed">
{{ selectedOpp.llm_analysis.summary }}
</div>

<div v-if="selectedOpp.llm_analysis.warning" class="mb-4 bg-red-50 p-3 rounded-lg border border-red-100 flex items-start gap-2 text-red-700">
<AlertOutlined class="mt-1" />
<span class="text-sm font-medium">{{ selectedOpp.llm_analysis.warning }}</span>
</div>

<div v-if="selectedOpp.llm_analysis.suggestions && selectedOpp.llm_analysis.suggestions.length">
<div class="text-sm font-semibold text-gray-800 mb-2">操作建议:</div>
<ul class="list-none space-y-1.5 pl-0">
<li v-for="(s, i) in selectedOpp.llm_analysis.suggestions" :key="i" class="flex items-start gap-2 text-sm text-gray-600">
<span class="text-blue-400 mt-1">•</span>
<span>{{ s }}</span>
</li>
</ul>
</div>
</div>

<div v-else class="flex flex-col items-center justify-center py-10 text-gray-400">
<RobotOutlined class="text-4xl mb-3 text-gray-300" />
<div class="text-sm">点击右上角按钮获取 AI 深度风险评估</div>
</div>
</a-card>
</div>
</a-modal>
</div>
Expand All @@ -488,6 +543,8 @@ import {
RiseOutlined,
DatabaseOutlined,
TrophyOutlined,
AlertOutlined,
RobotOutlined,
} from '@ant-design/icons-vue';

interface RiskMetrics {
Expand All @@ -506,6 +563,12 @@ interface Opportunity {
profit_usdt: number;
batch_id?: number;
risk_metrics?: RiskMetrics;
llm_analysis?: {
risk_level: string;
summary: string;
suggestions: string[];
warning?: string;
};
details?: {
block_time?: string;
experiment_id?: string | number;
Expand Down Expand Up @@ -727,6 +790,37 @@ const selectedOpp = ref<Opportunity | null>(null);
const riskGaugeRef = ref<HTMLDivElement | null>(null);
const riskRadarRef = ref<HTMLDivElement | null>(null);
const miniPriceRef = ref<HTMLDivElement | null>(null);
const analyzing = ref(false);

const analyzeRisk = async () => {
if (!selectedOpp.value) return;
analyzing.value = true;
try {
const res = await api.analyzeOpportunity(selectedOpp.value.id);
if (res.data.code === 200) {
// 后端返回的 data 字段即为 JSONMap
selectedOpp.value.llm_analysis = res.data.data;
message.success('AI 分析完成');
} else {
message.error(res.data.message || '分析失败');
}
} catch (e) {
console.error(e);
message.error('请求 AI 分析失败');
} finally {
analyzing.value = false;
}
};

const getRiskLevelColor = (level?: string) => {
switch (level?.toLowerCase()) {
case 'low': return 'success';
case 'medium': return 'warning';
case 'high': return 'error';
default: return 'default';
}
};

let riskGaugeChart: echarts.ECharts | null = null;
let riskRadarChart: echarts.ECharts | null = null;
let miniPriceChart: echarts.ECharts | null = null;
Expand Down
Loading