Support selectable demo exchanges and configurable analysis limits
Persist manual requests until exchange confirmation, preserve stop protection, and reconcile fills before settling plans. Tighten model stream, crawler, skill, research accounting, and frontend race handling, with regression coverage. Keep queued analyses pending until a fresh account snapshot is available.
This commit is contained in:
1 parent
fcaedfb358
commit
285b3952d7
33 files changed
+1890
-325
No files matched your search
@@ -45,6 +45,7 @@ let chart: Chart | null = null
|
||||
let barCallback: ((bar: KLineData) => void) | undefined
|
||||
// 切换交易对或周期后,丢弃旧请求的返回
|
||||
let loadVersion = 0
|
||||
let tickerVersion = 0
|
||||
|
||||
function toBar(r: KlineRow): KLineData {
|
||||
return { timestamp: r[0], open: r[1], high: r[2], low: r[3], close: r[4], volume: r[5], turnover: r[6] }
|
||||
@@ -85,8 +86,11 @@ const { connected, subscribe } = useMarketSocket(
|
||||
)
|
||||
|
||||
async function loadTicker() {
|
||||
const version = ++tickerVersion
|
||||
const { market, symbol } = selection.value
|
||||
const t = await getTicker(market, symbol)
|
||||
if (version !== tickerVersion)
|
||||
return
|
||||
Object.assign(ticker, {
|
||||
last: +t.lastPrice,
|
||||
high: +t.highPrice,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Exchange, Provider, ProviderForm } from "@/apis/settings"
|
||||
import type { Exchange, ExchangeName, Provider, ProviderForm } from "@/apis/settings"
|
||||
import { Plus } from "@element-plus/icons-vue"
|
||||
import { createProvider, deleteExchange, deleteProvider, EFFORTS, getExchange, listProviders, patchProvider, putExchange, testExchange, testProvider } from "@/apis/settings"
|
||||
import { formatTime } from "@/utils/text"
|
||||
@@ -78,7 +78,7 @@ async function test(p: Provider) {
|
||||
|
||||
load()
|
||||
|
||||
// ---------- 币安模拟盘 ----------
|
||||
// ---------- 模拟盘 ----------
|
||||
|
||||
const exchange = ref<Exchange>()
|
||||
const exchangeDialogVisible = ref(false)
|
||||
@@ -90,6 +90,16 @@ async function loadExchange() {
|
||||
exchange.value = await getExchange()
|
||||
}
|
||||
|
||||
async function changeExchange(value: ExchangeName) {
|
||||
exchangeSaving.value = true
|
||||
try {
|
||||
exchange.value = await putExchange({ exchange: value })
|
||||
ElMessage.success("已切换模拟盘,另一家交易所的凭证和历史记录仍保留")
|
||||
} finally {
|
||||
exchangeSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openExchangeEdit() {
|
||||
exchangeForm.value = { api_key: "", api_secret: "" }
|
||||
exchangeDialogVisible.value = true
|
||||
@@ -98,7 +108,7 @@ function openExchangeEdit() {
|
||||
async function saveExchange() {
|
||||
exchangeSaving.value = true
|
||||
try {
|
||||
exchange.value = await putExchange(exchangeForm.value)
|
||||
exchange.value = await putExchange({ exchange: exchange.value?.exchange, ...exchangeForm.value })
|
||||
exchangeDialogVisible.value = false
|
||||
ElMessage.success("已保存")
|
||||
} finally {
|
||||
@@ -253,8 +263,15 @@ loadExchange()
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
币安模拟盘
|
||||
<span class="ml-3 text-xs text-slate-500 font-normal">只连接币安模拟盘(demo-fapi.binance.com);在 demo.binance.com 的 API 管理创建 key,并开启合约交易权限</span>
|
||||
模拟盘交易所
|
||||
<el-select :model-value="exchange?.exchange" class="ml-3 w-36" :disabled="exchange?.running || exchangeSaving" @update:model-value="changeExchange">
|
||||
<el-option value="binance" label="币安模拟盘" />
|
||||
<el-option value="bybit" label="Bybit 模拟盘" />
|
||||
</el-select>
|
||||
<div class="mt-2 text-xs text-slate-500 font-normal">
|
||||
{{ exchange?.exchange === "bybit" ? "在 Bybit 主站切换到模拟交易后创建 API Key,并开启合约交易权限;实盘和 Testnet 的 Key 不可用。" : "在 demo.binance.com 的 API 管理创建 Key,并开启合约交易权限。" }}
|
||||
两家凭证分别保存;切换不会撤销原交易所的挂单,旧挂单仍须在原平台处理。
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<el-button :disabled="!exchange?.configured" :loading="exchangeTesting" @click="runExchangeTest">
|
||||
@@ -285,7 +302,7 @@ loadExchange()
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="exchangeDialogVisible" title="币安模拟盘 API key" width="560px">
|
||||
<el-dialog v-model="exchangeDialogVisible" :title="`${exchange?.exchange === 'bybit' ? 'Bybit' : '币安'}模拟盘 API Key`" width="560px">
|
||||
<el-form :model="exchangeForm" label-width="100px">
|
||||
<el-form-item label="API Key" required>
|
||||
<el-input v-model="exchangeForm.api_key" type="password" show-password autocomplete="new-password" />
|
||||
|
||||
@@ -14,6 +14,8 @@ const latest = ref<SkillVersion | null>(null)
|
||||
const content = ref("")
|
||||
const dirty = computed(() => selected.value?.kind === "skill" && content.value !== (latest.value?.content ?? ""))
|
||||
const saving = ref(false)
|
||||
const loadingContent = ref(false)
|
||||
let contentVersion = 0
|
||||
const historyVisible = ref(false)
|
||||
const preview = computed(() => renderMarkdown(content.value))
|
||||
|
||||
@@ -24,13 +26,25 @@ async function loadTree() {
|
||||
}
|
||||
|
||||
async function loadContent() {
|
||||
if (selected.value?.kind !== "skill") {
|
||||
latest.value = null
|
||||
content.value = ""
|
||||
const version = ++contentVersion
|
||||
const skill = selected.value
|
||||
latest.value = null
|
||||
content.value = ""
|
||||
if (skill?.kind !== "skill") {
|
||||
loadingContent.value = false
|
||||
return
|
||||
}
|
||||
latest.value = (await listSkillVersions(selected.value.id))[0] ?? null
|
||||
content.value = latest.value?.content ?? ""
|
||||
loadingContent.value = true
|
||||
try {
|
||||
const versions = await listSkillVersions(skill.id)
|
||||
if (version !== contentVersion)
|
||||
return
|
||||
latest.value = versions[0] ?? null
|
||||
content.value = latest.value?.content ?? ""
|
||||
} finally {
|
||||
if (version === contentVersion)
|
||||
loadingContent.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function select(node: SkillNode) {
|
||||
@@ -43,11 +57,18 @@ async function select(node: SkillNode) {
|
||||
}
|
||||
|
||||
async function saveDraft(): Promise<SkillVersion | null> {
|
||||
if (loadingContent.value || saving.value)
|
||||
return null
|
||||
if (!selected.value || !dirty.value)
|
||||
return latest.value
|
||||
const id = selected.value.id
|
||||
const version = contentVersion
|
||||
saving.value = true
|
||||
try {
|
||||
latest.value = await saveSkillDraft(selected.value.id, content.value)
|
||||
const saved = await saveSkillDraft(id, content.value)
|
||||
if (version !== contentVersion)
|
||||
return null
|
||||
latest.value = saved
|
||||
ElMessage.success(`已保存草稿 v${latest.value.version_no}`)
|
||||
await loadTree()
|
||||
return latest.value
|
||||
@@ -57,11 +78,18 @@ async function saveDraft(): Promise<SkillVersion | null> {
|
||||
}
|
||||
|
||||
async function publishLatest() {
|
||||
const skill = selected.value
|
||||
if (!skill)
|
||||
return
|
||||
const id = skill.id
|
||||
const contentRequest = contentVersion
|
||||
const version = await saveDraft()
|
||||
if (!version || !selected.value)
|
||||
if (!version || selectedId.value !== id || contentRequest !== contentVersion)
|
||||
return
|
||||
await ElMessageBox.confirm(`发布 v${version.version_no}?发布后 Chat 下一轮起按此版本判断。`, "发布确认", { type: "warning" })
|
||||
await publishSkillVersion(selected.value.id, version.id)
|
||||
if (selectedId.value !== id || contentRequest !== contentVersion)
|
||||
return
|
||||
await publishSkillVersion(id, version.id)
|
||||
ElMessage.success(`已发布 v${version.version_no}`)
|
||||
await loadTree()
|
||||
}
|
||||
@@ -148,6 +176,7 @@ loadTree()
|
||||
<div class="min-h-0 flex flex-1 gap-3">
|
||||
<textarea
|
||||
v-model="content" placeholder="用 Markdown 写下投资经验、判断流程或输出规范"
|
||||
:disabled="loadingContent || saving"
|
||||
class="box-border flex-1 resize-none border border-slate-300 rounded-lg border-solid p-3 text-sm text-slate-800 leading-6 font-mono outline-none focus:border-[var(--el-color-primary)]"
|
||||
/>
|
||||
<el-scrollbar class="flex-1 border border-slate-200 rounded-lg border-solid bg-slate-50/50">
|
||||
@@ -157,10 +186,10 @@ loadTree()
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<el-button :disabled="!dirty" :loading="saving" @click="saveDraft">
|
||||
<el-button :disabled="loadingContent || !dirty" :loading="saving" @click="saveDraft">
|
||||
保存草稿
|
||||
</el-button>
|
||||
<el-button type="primary" :disabled="!dirty && !selected.has_draft" :loading="saving" @click="publishLatest">
|
||||
<el-button type="primary" :disabled="loadingContent || (!dirty && !selected.has_draft)" :loading="saving" @click="publishLatest">
|
||||
{{ dirty ? "保存并发布" : "发布最新版本" }}
|
||||
</el-button>
|
||||
<el-button @click="historyVisible = true">
|
||||
|
||||
@@ -33,12 +33,13 @@ const drawerAnalysisId = ref<number | null>(null)
|
||||
const historyVisible = ref(false)
|
||||
const signalsVisible = ref(false)
|
||||
|
||||
const exchangeName = computed(() => status.value?.exchange === "bybit" ? "Bybit" : "币安")
|
||||
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 runningCount = computed(() => symbols.value.filter(s => s.running).length)
|
||||
const engineText = computed(() => snapshot.value ? `在线 · ${runningCount.value ? `${runningCount.value} 个币交易中` : "全部停止"}` : "离线")
|
||||
const engineText = computed(() => snapshot.value ? `${exchangeName.value} · 在线 · ${runningCount.value ? `${runningCount.value} 个币交易中` : "全部停止"}` : `${exchangeName.value} · 离线`)
|
||||
/** 各币交易资金合计,不超过账户权益 */
|
||||
const allocated = computed(() => symbols.value.reduce((sum, s) => sum + Number(s.capital_usdt ?? 0), 0))
|
||||
const symbolOrders = computed(() => {
|
||||
@@ -117,11 +118,12 @@ async function refresh() {
|
||||
}
|
||||
|
||||
async function refreshAnalysis() {
|
||||
const exchange = status.value?.exchange
|
||||
const symbol = selected.value
|
||||
if (!symbol)
|
||||
return
|
||||
const [latest, { plan, orders }] = await Promise.all([listAnalyses({ symbol, limit: 1 }), getSymbolPlan(symbol)])
|
||||
if (symbol !== selected.value)
|
||||
if (symbol !== selected.value || exchange !== status.value?.exchange)
|
||||
return
|
||||
latestAnalysis.value = latest[0] ?? null
|
||||
livePlan.value = plan
|
||||
@@ -155,10 +157,11 @@ function openAnalysis(id: number) {
|
||||
|
||||
/** 拉取最新一页,与已加载的合并(保留已翻页加载的旧事件) */
|
||||
async function refreshEvents() {
|
||||
const exchange = status.value?.exchange
|
||||
const symbol = selected.value
|
||||
const latest = await listEvents({ symbol: symbol || undefined, limit: EVENTS_PAGE })
|
||||
// 请求期间切换了币种:丢弃旧币种的结果
|
||||
if (symbol !== selected.value)
|
||||
if (symbol !== selected.value || exchange !== status.value?.exchange)
|
||||
return
|
||||
const newestId = events.value[0]?.id ?? 0
|
||||
const fresh = latest.filter(e => e.id > newestId)
|
||||
@@ -168,15 +171,18 @@ async function refreshEvents() {
|
||||
}
|
||||
|
||||
async function loadMoreEvents() {
|
||||
const exchange = status.value?.exchange
|
||||
const symbol = selected.value
|
||||
const older = await listEvents({ symbol: symbol || undefined, before: events.value.at(-1)?.id, limit: EVENTS_PAGE })
|
||||
if (symbol !== selected.value)
|
||||
if (symbol !== selected.value || exchange !== status.value?.exchange)
|
||||
return
|
||||
eventsExhausted.value = older.length < EVENTS_PAGE
|
||||
events.value = [...events.value, ...older]
|
||||
}
|
||||
|
||||
watch(selected, () => {
|
||||
watch([selected, () => status.value?.exchange], () => {
|
||||
historyVisible.value = false
|
||||
drawerVisible.value = false
|
||||
events.value = []
|
||||
latestAnalysis.value = null
|
||||
livePlan.value = null
|
||||
@@ -194,7 +200,7 @@ async function toggleRunning(s: TradingSymbol) {
|
||||
)
|
||||
} else {
|
||||
await ElMessageBox.confirm(
|
||||
`开始后,引擎会按触发条件和例行节奏自动分析 ${s.symbol},并按它的交易资金在币安模拟盘下单。`,
|
||||
`开始后,引擎会按触发条件和例行节奏自动分析 ${s.symbol},并按它的交易资金在 ${exchangeName.value} 模拟盘下单。`,
|
||||
`开始 ${s.symbol} 行情交易`,
|
||||
{ type: "info", confirmButtonText: "开始" }
|
||||
)
|
||||
@@ -235,6 +241,21 @@ async function changeEffort(effort: Effort | null) {
|
||||
status.value = await patchConfig({ reasoning_effort: effort })
|
||||
}
|
||||
|
||||
async function editDailyLimit() {
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
"所有币种和交易所共用,每日零点重新计数。手动分析计入今日次数,但不受自动分析上限限制。降低至已用次数时暂停新的自动分析,进行中的分析和持仓管理继续。",
|
||||
"设置每日分析上限",
|
||||
{
|
||||
inputValue: String(status.value!.today.limit),
|
||||
inputValidator: value => /^\d+$/.test(value) && Number(value) >= 1 && Number(value) <= 2147483647,
|
||||
inputErrorMessage: "请输入 1–2147483647 之间的整数",
|
||||
confirmButtonText: "保存"
|
||||
}
|
||||
)
|
||||
status.value = await patchConfig({ daily_analysis_limit: Number(value) })
|
||||
ElMessage.success("每日分析上限已保存,即时生效")
|
||||
}
|
||||
|
||||
async function closePosition(symbol: string) {
|
||||
await ElMessageBox.confirm(
|
||||
`撤销 ${symbol} 的全部挂单(含止损、止盈和手动挂的单),市价平掉持仓,并停止 ${symbol} 的行情交易(其他币不受影响)。`,
|
||||
@@ -338,7 +359,12 @@ onBeforeUnmount(() => clearInterval(timer))
|
||||
|
||||
<div class="w-56">
|
||||
<div class="flex items-center justify-between text-xs text-slate-500">
|
||||
今日分析
|
||||
<span class="flex items-center gap-1">
|
||||
今日分析
|
||||
<el-button link type="primary" size="small" @click="editDailyLimit">
|
||||
调整上限
|
||||
</el-button>
|
||||
</span>
|
||||
<span class="num text-sm text-slate-900 font-semibold">{{ status.today.analyses }} / {{ status.today.limit }}</span>
|
||||
</div>
|
||||
<el-progress :percentage="Math.min(100, status.today.analyses / status.today.limit * 100)" :show-text="false" :stroke-width="4" class="my-1" />
|
||||
@@ -372,7 +398,7 @@ onBeforeUnmount(() => clearInterval(timer))
|
||||
</el-card>
|
||||
|
||||
<el-alert v-if="!status.configured" type="warning" :closable="false" show-icon>
|
||||
还没有填写币安模拟盘 key,请先到「环境变量」页填写。
|
||||
还没有填写 {{ exchangeName }} 模拟盘 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")} 的数据)` : "" }}
|
||||
@@ -470,7 +496,7 @@ onBeforeUnmount(() => clearInterval(timer))
|
||||
<el-button v-if="symbols.some(s => s.symbol === row.symbol)" link type="danger" @click="closePosition(row.symbol)">
|
||||
平仓
|
||||
</el-button>
|
||||
<span v-else class="font-sans text-slate-400" title="不在交易列表中,程序不管理,请到币安网页处理">-</span>
|
||||
<span v-else class="font-sans text-slate-400" title="不在交易列表中,程序不管理,请到当前交易所网页处理">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
Reference in new issue
Block a user