Add demo trading page, trader snapshot service and trading tables
Add trading_symbols, trading_analyses, trading_orders and trading_events. The new trader service publishes account, position and open-order snapshots of the Binance demo account to Redis every 3s. The /trading page shows engine status, start/stop, balances, positions, the symbol list with angle selection, open orders and the event timeline.
This commit is contained in:
1 parent
154fe2b687
commit
005943a54c
12 files changed
+1289
-3
No files matched your search
@@ -0,0 +1,138 @@
|
||||
import type { Effort } from "@/apis/settings"
|
||||
import { http } from "@/http"
|
||||
|
||||
export type Angle = "short" | "long"
|
||||
|
||||
export const ANGLE_LABELS: Record<Angle, string> = { short: "短线(24h 内平仓)", long: "长线(12–48h 平仓)" }
|
||||
|
||||
/** 以下数值字段都是币安返回的十进制字符串,避免浮点误差 */
|
||||
export interface SnapshotPosition {
|
||||
symbol: string
|
||||
side: "LONG" | "SHORT"
|
||||
amount: string
|
||||
entry_price: string
|
||||
break_even_price: string
|
||||
mark_price: string
|
||||
unrealized_pnl: string
|
||||
liquidation_price: string
|
||||
notional: string
|
||||
margin: string
|
||||
leverage: number
|
||||
margin_type: string
|
||||
}
|
||||
|
||||
export interface SnapshotOrder {
|
||||
symbol: string
|
||||
client_id: string
|
||||
side: "BUY" | "SELL"
|
||||
type: string
|
||||
price: string
|
||||
quantity: string
|
||||
filled: string
|
||||
reduce_only: boolean
|
||||
time: number
|
||||
}
|
||||
|
||||
export interface SnapshotAlgoOrder {
|
||||
symbol: string
|
||||
client_id: string
|
||||
side: "BUY" | "SELL"
|
||||
type: string
|
||||
trigger_price: string
|
||||
quantity: string
|
||||
close_position: boolean
|
||||
reduce_only: boolean
|
||||
time: number
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
updated_at: string
|
||||
/** 最近一次成功同步币安的时间 */
|
||||
synced_at: string | null
|
||||
error: string | null
|
||||
account: { wallet_balance: string, margin_balance: string, available_balance: string, unrealized_pnl: string } | null
|
||||
positions: SnapshotPosition[]
|
||||
orders: SnapshotOrder[]
|
||||
algo_orders: SnapshotAlgoOrder[]
|
||||
}
|
||||
|
||||
export interface TradingStatus {
|
||||
configured: boolean
|
||||
running: boolean
|
||||
running_changed_at: string | null
|
||||
provider_id: number | null
|
||||
reasoning_effort: Effort | null
|
||||
/** trader 进程离线时为空 */
|
||||
snapshot: Snapshot | null
|
||||
today: { analyses: number, limit: number, input_tokens: number, output_tokens: number }
|
||||
}
|
||||
|
||||
export interface TradingSymbol {
|
||||
symbol: string
|
||||
angle: Angle
|
||||
added_at: string
|
||||
analysis_requested_at: string | null
|
||||
analyzing: boolean
|
||||
plan_status: "pending" | "open" | null
|
||||
}
|
||||
|
||||
export interface SymbolOption {
|
||||
symbol: string
|
||||
base: string
|
||||
}
|
||||
|
||||
export interface TradingEvent {
|
||||
id: number
|
||||
created_at: string
|
||||
symbol: string | null
|
||||
analysis_id: number | null
|
||||
kind: string
|
||||
level: "info" | "warn" | "error"
|
||||
message: string
|
||||
data: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
/** 本程序下的单;其他是用户手动下的 */
|
||||
export function isOwnOrder(clientId: string) {
|
||||
return /^ap\d+$/.test(clientId)
|
||||
}
|
||||
|
||||
export function getStatus(): Promise<TradingStatus> {
|
||||
return http.get("/trading/status")
|
||||
}
|
||||
|
||||
export function startTrading(): Promise<TradingStatus> {
|
||||
return http.post("/trading/start")
|
||||
}
|
||||
|
||||
export function stopTrading(): Promise<TradingStatus> {
|
||||
return http.post("/trading/stop")
|
||||
}
|
||||
|
||||
export function patchConfig(body: { provider_id?: number | null, reasoning_effort?: Effort | null }): Promise<TradingStatus> {
|
||||
return http.patch("/trading/config", body)
|
||||
}
|
||||
|
||||
export function listSymbolOptions(): Promise<SymbolOption[]> {
|
||||
return http.get("/trading/symbol-options")
|
||||
}
|
||||
|
||||
export function listSymbols(): Promise<TradingSymbol[]> {
|
||||
return http.get("/trading/symbols")
|
||||
}
|
||||
|
||||
export function addSymbol(symbol: string, angle: Angle): Promise<void> {
|
||||
return http.post("/trading/symbols", { symbol, angle })
|
||||
}
|
||||
|
||||
export function patchSymbol(symbol: string, angle: Angle): Promise<void> {
|
||||
return http.patch(`/trading/symbols/${symbol}`, { angle })
|
||||
}
|
||||
|
||||
export function deleteSymbol(symbol: string): Promise<void> {
|
||||
return http.delete(`/trading/symbols/${symbol}`)
|
||||
}
|
||||
|
||||
export function listEvents(params: { symbol?: string, before?: number, limit?: number }): Promise<TradingEvent[]> {
|
||||
return http.get("/trading/events", { params })
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Angle, SymbolOption } from "@/apis/trading"
|
||||
import { addSymbol, ANGLE_LABELS, listSymbolOptions } from "@/apis/trading"
|
||||
|
||||
const emit = defineEmits<{ added: [symbol: string] }>()
|
||||
const visible = defineModel<boolean>({ required: true })
|
||||
|
||||
const symbol = ref("")
|
||||
const angle = ref<Angle>("long")
|
||||
const symbolOptions = ref<SymbolOption[]>([])
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const options = computed(() => symbolOptions.value.map(s => ({ value: s.symbol, label: `${s.base}/USDT 永续` })))
|
||||
|
||||
watch(visible, async (open) => {
|
||||
if (!open)
|
||||
return
|
||||
symbol.value = ""
|
||||
loading.value = true
|
||||
try {
|
||||
symbolOptions.value = await listSymbolOptions()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function submit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await addSymbol(symbol.value, angle.value)
|
||||
ElMessage.success(`已加入 ${symbol.value}`)
|
||||
emit("added", symbol.value)
|
||||
visible.value = false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="新增交易币种" width="480px">
|
||||
<el-form label-width="64px">
|
||||
<el-form-item label="币种">
|
||||
<el-select-v2
|
||||
v-model="symbol" :options="options" :loading="loading" filterable placeholder="搜索,如 BTCUSDT"
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
只列出模拟盘和实盘都在交易的加密货币 USDT 永续合约
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="角度">
|
||||
<el-radio-group v-model="angle">
|
||||
<el-radio v-for="(label, value) in ANGLE_LABELS" :key="value" :value="value">
|
||||
{{ label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :disabled="!symbol" :loading="submitting" @click="submit">
|
||||
加入
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,409 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Effort, Provider } from "@/apis/settings"
|
||||
import type { Angle, SnapshotPosition, TradingEvent, TradingStatus, TradingSymbol } from "@/apis/trading"
|
||||
import { EFFORTS, listProviders } from "@/apis/settings"
|
||||
import { ANGLE_LABELS, deleteSymbol, getStatus, isOwnOrder, listEvents, listSymbols, patchConfig, patchSymbol, startTrading, stopTrading } from "@/apis/trading"
|
||||
import { usePersisted } from "@/utils/storage"
|
||||
import { formatTime } from "@/utils/text"
|
||||
import AddSymbolDialog from "./components/AddSymbolDialog.vue"
|
||||
|
||||
const STATUS_REFRESH_MS = 3000
|
||||
const EVENTS_PAGE = 50
|
||||
const SHORT_ANGLE_LABELS: Record<Angle, string> = { short: "短线", long: "长线" }
|
||||
const LEVEL_TYPES = { info: "primary", warn: "warning", error: "danger" } as const
|
||||
|
||||
const status = ref<TradingStatus>()
|
||||
const symbols = ref<TradingSymbol[]>([])
|
||||
const providers = ref<Provider[]>([])
|
||||
const selected = usePersisted("trading.symbol", "")
|
||||
const events = ref<TradingEvent[]>([])
|
||||
const eventsExhausted = ref(false)
|
||||
const addVisible = ref(false)
|
||||
const toggling = ref(false)
|
||||
|
||||
const snapshot = computed(() => status.value?.snapshot ?? null)
|
||||
const account = computed(() => snapshot.value?.account ?? null)
|
||||
const positions = computed(() => snapshot.value?.positions ?? [])
|
||||
const current = computed(() => symbols.value.find(s => s.symbol === selected.value))
|
||||
const symbolOrders = computed(() => {
|
||||
const s = snapshot.value
|
||||
if (!s)
|
||||
return []
|
||||
return [
|
||||
...s.orders.filter(o => o.symbol === selected.value).map(o => ({
|
||||
...o,
|
||||
conditional: false,
|
||||
priceText: num(o.price)
|
||||
})),
|
||||
...s.algo_orders.filter(o => o.symbol === selected.value).map(o => ({
|
||||
...o,
|
||||
conditional: true,
|
||||
priceText: `触发 ${num(o.trigger_price)}`,
|
||||
filled: "0",
|
||||
quantity: o.close_position ? "全部持仓" : o.quantity
|
||||
}))
|
||||
].sort((a, b) => b.time - a.time)
|
||||
})
|
||||
|
||||
function num(value: string) {
|
||||
return String(Number(value))
|
||||
}
|
||||
|
||||
function money(value: string | undefined) {
|
||||
return value === undefined ? "-" : Number(value).toFixed(2)
|
||||
}
|
||||
|
||||
function pnlClass(value: string) {
|
||||
const n = Number(value)
|
||||
return n > 0 ? "text-[var(--el-color-success)]" : n < 0 ? "text-[var(--el-color-danger)]" : ""
|
||||
}
|
||||
|
||||
/** 持仓当前生效的止损、止盈触发价:取交易所上平仓方向的挂单 */
|
||||
function exitPrices(p: SnapshotPosition) {
|
||||
const s = snapshot.value!
|
||||
const closeSide = p.side === "LONG" ? "SELL" : "BUY"
|
||||
const stops = s.algo_orders.filter(o => o.symbol === p.symbol && o.side === closeSide && o.type.startsWith("STOP")).map(o => Number(o.trigger_price))
|
||||
const targets = [
|
||||
...s.orders.filter(o => o.symbol === p.symbol && o.side === closeSide && o.reduce_only && o.type === "LIMIT").map(o => Number(o.price)),
|
||||
...s.algo_orders.filter(o => o.symbol === p.symbol && o.side === closeSide && o.type.startsWith("TAKE_PROFIT")).map(o => Number(o.trigger_price))
|
||||
]
|
||||
const mark = Number(p.mark_price)
|
||||
const nearest = (prices: number[]) => prices.length ? String(prices.reduce((a, b) => Math.abs(a - mark) < Math.abs(b - mark) ? a : b)) : "无"
|
||||
return { stop: nearest(stops), target: nearest(targets) }
|
||||
}
|
||||
|
||||
function symbolState(s: TradingSymbol) {
|
||||
if (s.analyzing)
|
||||
return { label: "分析中", type: "warning" } as const
|
||||
if (positions.value.some(p => p.symbol === s.symbol))
|
||||
return { label: "持仓中", type: "success" } as const
|
||||
if (s.plan_status === "pending")
|
||||
return { label: "挂单中", type: "primary" } as const
|
||||
return { label: "空仓", type: "info" } as const
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
[status.value, symbols.value] = await Promise.all([getStatus(), listSymbols()])
|
||||
if (!current.value && symbols.value.length)
|
||||
selected.value = symbols.value[0].symbol
|
||||
}
|
||||
|
||||
/** 拉取最新一页,与已加载的合并(保留已翻页加载的旧事件) */
|
||||
async function refreshEvents() {
|
||||
const symbol = selected.value
|
||||
const latest = await listEvents({ symbol: symbol || undefined, limit: EVENTS_PAGE })
|
||||
// 请求期间切换了币种:丢弃旧币种的结果
|
||||
if (symbol !== selected.value)
|
||||
return
|
||||
const newestId = events.value[0]?.id ?? 0
|
||||
const fresh = latest.filter(e => e.id > newestId)
|
||||
if (!events.value.length)
|
||||
eventsExhausted.value = latest.length < EVENTS_PAGE
|
||||
events.value = [...fresh, ...events.value]
|
||||
}
|
||||
|
||||
async function loadMoreEvents() {
|
||||
const symbol = selected.value
|
||||
const older = await listEvents({ symbol: symbol || undefined, before: events.value.at(-1)?.id, limit: EVENTS_PAGE })
|
||||
if (symbol !== selected.value)
|
||||
return
|
||||
eventsExhausted.value = older.length < EVENTS_PAGE
|
||||
events.value = [...events.value, ...older]
|
||||
}
|
||||
|
||||
watch(selected, () => {
|
||||
events.value = []
|
||||
refreshEvents()
|
||||
})
|
||||
|
||||
async function toggleRunning() {
|
||||
if (status.value!.running) {
|
||||
await ElMessageBox.confirm(
|
||||
"停止后不再做新的分析,并撤销未成交的入场单。已有持仓保留,止损、止盈、到期平仓、短线失效平仓等程序规则继续执行。",
|
||||
"停止行情交易",
|
||||
{ type: "warning", confirmButtonText: "停止" }
|
||||
)
|
||||
} else {
|
||||
await ElMessageBox.confirm(
|
||||
"开始后,引擎会按触发条件和例行节奏自动分析交易列表里的币,并在币安模拟盘下单。",
|
||||
"开始行情交易",
|
||||
{ type: "info", confirmButtonText: "开始" }
|
||||
)
|
||||
}
|
||||
toggling.value = true
|
||||
try {
|
||||
status.value = status.value!.running ? await stopTrading() : await startTrading()
|
||||
await refreshEvents()
|
||||
} finally {
|
||||
toggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function changeProvider(providerId: number | null) {
|
||||
status.value = await patchConfig({ provider_id: providerId })
|
||||
}
|
||||
|
||||
async function changeEffort(effort: Effort | null) {
|
||||
status.value = await patchConfig({ reasoning_effort: effort })
|
||||
}
|
||||
|
||||
async function switchAngle(s: TradingSymbol) {
|
||||
const angle: Angle = s.angle === "short" ? "long" : "short"
|
||||
await ElMessageBox.confirm(`把 ${s.symbol} 从${SHORT_ANGLE_LABELS[s.angle]}改为${SHORT_ANGLE_LABELS[angle]}?`, "切换角度")
|
||||
await patchSymbol(s.symbol, angle)
|
||||
await Promise.all([refresh(), refreshEvents()])
|
||||
}
|
||||
|
||||
async function remove(s: TradingSymbol) {
|
||||
await ElMessageBox.confirm(`把 ${s.symbol} 移出交易列表?历史分析和时间线会保留。`, "删除确认", { type: "warning" })
|
||||
await deleteSymbol(s.symbol)
|
||||
ElMessage.success("已移出")
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function onAdded(symbol: string) {
|
||||
await refresh()
|
||||
selected.value = symbol
|
||||
}
|
||||
|
||||
refresh()
|
||||
refreshEvents()
|
||||
listProviders().then(list => providers.value = list)
|
||||
const timer = setInterval(() => {
|
||||
refresh()
|
||||
refreshEvents()
|
||||
}, STATUS_REFRESH_MS)
|
||||
onBeforeUnmount(() => clearInterval(timer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="status" class="h-full flex flex-col gap-3">
|
||||
<!-- 顶部:引擎状态、启停、账户 -->
|
||||
<div class="flex flex-wrap items-center gap-x-6 gap-y-2 border border-[var(--el-border-color)] rounded border-solid px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<el-tag v-if="snapshot" type="success" effect="dark">
|
||||
引擎在线
|
||||
</el-tag>
|
||||
<el-tag v-else type="info" effect="dark">
|
||||
引擎离线
|
||||
</el-tag>
|
||||
<el-tag :type="status.running ? 'success' : 'info'">
|
||||
{{ status.running ? "交易中" : "已停止" }}
|
||||
</el-tag>
|
||||
<el-button
|
||||
:type="status.running ? 'danger' : 'primary'" :loading="toggling" :disabled="!status.configured"
|
||||
@click="toggleRunning"
|
||||
>
|
||||
{{ status.running ? "停止" : "开始行情交易" }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>权益</span>{{ money(account?.margin_balance) }} USDT
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>可用</span>{{ money(account?.available_balance) }} USDT
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>未实现盈亏</span>
|
||||
<b :class="pnlClass(account?.unrealized_pnl ?? '0')">{{ money(account?.unrealized_pnl) }}</b>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>今日分析</span>{{ status.today.analyses }} / {{ status.today.limit }}
|
||||
<span class="ml-1">token</span>{{ status.today.input_tokens.toLocaleString() }} / {{ status.today.output_tokens.toLocaleString() }}
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<el-select
|
||||
:model-value="status.provider_id" class="w-56" clearable :value-on-clear="null" placeholder="模型:跟随默认配置"
|
||||
@update:model-value="changeProvider"
|
||||
>
|
||||
<el-option v-for="p in providers" :key="p.id" :value="p.id" :label="`${p.name}(${p.model})`" />
|
||||
</el-select>
|
||||
<el-select
|
||||
:model-value="status.reasoning_effort" class="w-40" clearable :value-on-clear="null" placeholder="思考强度:跟随配置"
|
||||
@update:model-value="changeEffort"
|
||||
>
|
||||
<el-option v-for="e in EFFORTS" :key="e" :value="e" :label="e" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="!status.configured" type="warning" :closable="false" show-icon>
|
||||
还没有填写币安模拟盘 key,请先到「环境变量」页填写。
|
||||
</el-alert>
|
||||
<el-alert v-else-if="snapshot?.error" type="error" :closable="false" show-icon>
|
||||
同步模拟盘失败:{{ snapshot.error }}{{ snapshot.synced_at ? `(以下为 ${formatTime(snapshot.synced_at, "HH:mm:ss")} 的数据)` : "" }}
|
||||
</el-alert>
|
||||
|
||||
<!-- 持仓 -->
|
||||
<el-table :data="positions" border size="small" empty-text="没有持仓">
|
||||
<el-table-column prop="symbol" label="币种" min-width="110" />
|
||||
<el-table-column label="方向" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.side === 'LONG' ? 'success' : 'danger'" size="small">
|
||||
{{ row.side === "LONG" ? "多" : "空" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" min-width="90">
|
||||
<template #default="{ row }">
|
||||
{{ num(row.amount) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开仓价" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ num(row.entry_price) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="保本价" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ Number(row.break_even_price).toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="标记价" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ Number(row.mark_price).toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="未实现盈亏" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<span :class="pnlClass(row.unrealized_pnl)">{{ money(row.unrealized_pnl) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="强平价" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ Number(row.liquidation_price) ? Number(row.liquidation_price).toFixed(2) : "-" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="杠杆" width="110">
|
||||
<template #default="{ row }">
|
||||
{{ row.leverage }}x {{ row.margin_type === "ISOLATED" ? "逐仓" : "全仓" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="止损 / 止盈" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ exitPrices(row as SnapshotPosition).stop }} / {{ exitPrices(row as SnapshotPosition).target }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="min-h-0 flex flex-1 gap-3">
|
||||
<!-- 侧边栏:交易币种 -->
|
||||
<div class="w-60 flex flex-col border border-[var(--el-border-color)] rounded border-solid">
|
||||
<div class="flex items-center justify-between border-b border-b-[var(--el-border-color)] border-b-solid px-3 py-2">
|
||||
<span class="font-bold">交易币种</span>
|
||||
<el-button size="small" type="primary" @click="addVisible = true">
|
||||
新增
|
||||
</el-button>
|
||||
</div>
|
||||
<el-scrollbar class="flex-1">
|
||||
<div
|
||||
v-for="s in symbols" :key="s.symbol"
|
||||
class="cursor-pointer border-b border-b-[var(--el-border-color-lighter)] border-b-solid px-3 py-2"
|
||||
:class="s.symbol === selected ? 'bg-[var(--el-color-primary-light-9)]' : ''"
|
||||
@click="selected = s.symbol"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-bold">{{ s.symbol }}</span>
|
||||
<el-tag :type="symbolState(s).type" size="small">
|
||||
{{ symbolState(s).label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center justify-between text-xs text-[var(--el-text-color-secondary)]">
|
||||
<span>{{ ANGLE_LABELS[s.angle] }}</span>
|
||||
<span>
|
||||
<el-link type="primary" underline="never" class="text-xs!" @click.stop="switchAngle(s)">切换角度</el-link>
|
||||
<el-link type="danger" underline="never" class="ml-2 text-xs!" @click.stop="remove(s)">删除</el-link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!symbols.length" description="还没有交易币种" :image-size="60" />
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
|
||||
<!-- 主区域:当前计划、挂单、时间线 -->
|
||||
<el-scrollbar class="flex-1">
|
||||
<div class="flex flex-col gap-3 pr-2">
|
||||
<div v-if="current" class="border border-[var(--el-border-color)] rounded border-solid p-3">
|
||||
<div class="mb-2 font-bold">
|
||||
{{ current.symbol }} 当前计划
|
||||
<span class="ml-2 text-xs text-[var(--el-text-color-secondary)] font-normal">{{ ANGLE_LABELS[current.angle] }}</span>
|
||||
</div>
|
||||
<div class="text-sm text-[var(--el-text-color-secondary)]">
|
||||
暂无计划
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="current">
|
||||
<div class="mb-2 font-bold">
|
||||
当前挂单
|
||||
</div>
|
||||
<el-table :data="symbolOrders" border size="small" empty-text="没有挂单">
|
||||
<el-table-column label="类型" min-width="150">
|
||||
<template #default="{ row }">
|
||||
{{ row.type }}
|
||||
<el-tag v-if="row.conditional" size="small" class="ml-1">
|
||||
条件单
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="方向" width="110">
|
||||
<template #default="{ row }">
|
||||
{{ row.side === "BUY" ? "买" : "卖" }}{{ row.reduce_only ? "(只减仓)" : "" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="priceText" label="价格" min-width="130" />
|
||||
<el-table-column label="数量" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.quantity === "全部持仓" ? row.quantity : num(row.quantity) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ isOwnOrder(row.client_id) ? "本程序" : "手动" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" width="130">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(new Date(row.time).toISOString(), "MM-DD HH:mm:ss") }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-2 font-bold">
|
||||
时间线
|
||||
</div>
|
||||
<el-timeline v-if="events.length" class="pl-1">
|
||||
<el-timeline-item
|
||||
v-for="e in events" :key="e.id" :timestamp="formatTime(e.created_at)" :type="LEVEL_TYPES[e.level]"
|
||||
placement="top"
|
||||
>
|
||||
<el-tag size="small" type="info" class="mr-2">
|
||||
{{ e.symbol ?? "账户" }}
|
||||
</el-tag>
|
||||
<span :class="e.level === 'error' ? 'text-[var(--el-color-danger)]' : ''">{{ e.message }}</span>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-else description="还没有事件" :image-size="60" />
|
||||
<el-button v-if="events.length && !eventsExhausted" link type="primary" @click="loadMoreEvents">
|
||||
加载更早的事件
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
|
||||
<AddSymbolDialog v-model="addVisible" @added="onAdded" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat {
|
||||
font-size: 14px;
|
||||
}
|
||||
.stat span {
|
||||
margin-right: 6px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,7 @@ export const menuRoutes = [
|
||||
{ path: "/news", name: "news", component: () => import("@/pages/news/index.vue"), meta: { title: "新闻流" } },
|
||||
{ path: "/crawler", name: "crawler", component: () => import("@/pages/crawler/index.vue"), meta: { title: "采集控制台" } },
|
||||
{ path: "/market", name: "market", component: () => import("@/pages/market/index.vue"), meta: { title: "行情" } },
|
||||
{ path: "/trading", name: "trading", component: () => import("@/pages/trading/index.vue"), meta: { title: "模拟盘" } },
|
||||
{ path: "/chat", name: "chat", component: () => import("@/pages/chat/index.vue"), meta: { title: "Chat" } },
|
||||
{ path: "/skills", name: "skills", component: () => import("@/pages/skills/index.vue"), meta: { title: "Skills" } },
|
||||
{ path: "/settings", name: "settings", component: () => import("@/pages/settings/index.vue"), meta: { title: "环境变量" } }
|
||||
|
||||
Reference in new issue
Block a user