Size demo trading by a configured capital instead of full account equity

Add trading_account.capital_usdt. Position sizing will use
min(capital, account equity); starting requires the capital to be set.
The trading page shows and edits it.
This commit is contained in:
eezd committed 2026-10-15 10:02:36 +08:00
1 parent 005943a54c
commit 7536c9c5de
5 files changed
+97 -6

No files matched your search

@@ -0,0 +1,29 @@
"""trading capital
Revision ID: c3f19d7a2e58
Revises: a54081406f23
Create Date: 2026-10-15 10:10:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c3f19d7a2e58'
down_revision: Union[str, Sequence[str], None] = 'a54081406f23'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# 用户要求只用设定的资金操盘,不动用账户全部余额(2026-10-15)
op.add_column('trading_account', sa.Column('capital_usdt', sa.Numeric(), nullable=True))
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column('trading_account', 'capital_usdt')
+26 -3
View File
@@ -2,10 +2,11 @@
import json
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -41,6 +42,9 @@ class StatusOut(BaseModel):
configured: bool
running: bool
running_changed_at: datetime | None
capital_usdt: Decimal | None
# 实际用于仓位计算的资金:min(交易资金, 账户权益);没有快照时无法计算
effective_capital: Decimal | None
provider_id: int | None
reasoning_effort: str | None
# trader 进程的心跳快照;进程离线时为空
@@ -49,6 +53,7 @@ class StatusOut(BaseModel):
class ConfigIn(BaseModel):
capital_usdt: Annotated[Decimal, Field(gt=0, decimal_places=2)] | None = None
provider_id: int | None = None
reasoning_effort: llm.Effort | None = None
@@ -108,10 +113,13 @@ async def get_status(session: Annotated[AsyncSession, Depends(get_session)]) ->
func.coalesce(func.sum(TradingAnalysis.usage["output_tokens"].as_integer()), 0),
).where(TradingAnalysis.started_at >= today)
)).one()
snapshot = await read_snapshot()
equity = snapshot and snapshot["account"] and Decimal(snapshot["account"]["margin_balance"])
return StatusOut(
configured=account.api_key_encrypted is not None, running=account.running,
running_changed_at=account.running_changed_at, provider_id=account.provider_id,
reasoning_effort=account.reasoning_effort, snapshot=await read_snapshot(),
running_changed_at=account.running_changed_at, capital_usdt=account.capital_usdt,
effective_capital=min(account.capital_usdt, equity) if account.capital_usdt and equity else None,
provider_id=account.provider_id, reasoning_effort=account.reasoning_effort, snapshot=snapshot,
today=TodayUsage(analyses=count, limit=DAILY_ANALYSIS_LIMIT, input_tokens=input_tokens, output_tokens=output_tokens),
)
@@ -120,6 +128,8 @@ async def set_running(session: AsyncSession, running: bool) -> StatusOut:
account = await get_account(session)
if running and account.api_key_encrypted is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "还没有填写模拟盘 key,请先到环境变量页填写")
if running and account.capital_usdt is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "还没有设置交易资金")
if account.running != running:
account.running = running
account.running_changed_at = datetime.now(LOCAL_TZ)
@@ -148,6 +158,19 @@ async def patch_config(body: ConfigIn, session: Annotated[AsyncSession, Depends(
fields = body.model_dump(exclude_unset=True)
if fields.get("provider_id") is not None and await session.get(AiProvider, fields["provider_id"]) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "模型配置不存在")
if "capital_usdt" in fields:
capital = fields["capital_usdt"]
if capital is None and account.running:
raise HTTPException(status.HTTP_409_CONFLICT, "交易中不能清空交易资金")
snapshot = await read_snapshot()
equity = snapshot and snapshot["account"] and Decimal(snapshot["account"]["margin_balance"])
if capital is not None and equity and capital > equity:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"交易资金不能超过账户权益 {equity:.2f} USDT")
if capital != account.capital_usdt:
session.add(TradingEvent(
kind="capital_changed",
message=f"交易资金设为 {capital} USDT" if capital is not None else "清空交易资金",
))
for key, value in fields.items():
setattr(account, key, value)
session.add(account)
+2
View File
@@ -20,6 +20,8 @@ class TradingAccount(Base):
api_key_hint: Mapped[str | None] = mapped_column(String(8))
running: Mapped[bool] = mapped_column(Boolean, default=False)
running_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# 交易资金:仓位计算按 min(交易资金, 账户权益),不动用账户的全部余额;未设置时不能开始交易
capital_usdt: Mapped[Decimal | None] = mapped_column(Numeric)
# 交易分析用的模型配置;为空时用默认配置
provider_id: Mapped[int | None] = mapped_column(ForeignKey("ai_providers.id", ondelete="SET NULL"))
reasoning_effort: Mapped[str | None] = mapped_column(String(8))
+5 -1
View File
@@ -60,6 +60,10 @@ export interface TradingStatus {
configured: boolean
running: boolean
running_changed_at: string | null
/** 设定的交易资金,十进制字符串;未设置时不能开始交易 */
capital_usdt: string | null
/** 实际用于仓位计算的资金:min(交易资金, 账户权益) */
effective_capital: string | null
provider_id: number | null
reasoning_effort: Effort | null
/** trader 进程离线时为空 */
@@ -109,7 +113,7 @@ export function stopTrading(): Promise<TradingStatus> {
return http.post("/trading/stop")
}
export function patchConfig(body: { provider_id?: number | null, reasoning_effort?: Effort | null }): Promise<TradingStatus> {
export function patchConfig(body: { capital_usdt?: string | null, provider_id?: number | null, reasoning_effort?: Effort | null }): Promise<TradingStatus> {
return http.patch("/trading/config", body)
}
+35 -2
View File
@@ -139,6 +139,22 @@ async function toggleRunning() {
}
}
async function editCapital() {
const { value } = await ElMessageBox.prompt(
`仓位按交易资金计算:单笔风险短线 1%、长线 2%,所有仓位名义价值合计不超过 3 倍(逐仓 3 倍时保证金合计不超过交易资金)。不能超过账户权益 ${money(account.value?.margin_balance)} USDT;账户权益低于它时按权益计算。`,
"设置交易资金(USDT)",
{
inputValue: status.value!.capital_usdt ? num(status.value!.capital_usdt) : "",
inputPattern: /^(?:[1-9]\d*|0)(?:\.\d{1,2})?$/,
inputErrorMessage: "请输入正数,最多两位小数",
confirmButtonText: "保存"
}
)
status.value = await patchConfig({ capital_usdt: value })
ElMessage.success("已保存")
await refreshEvents()
}
async function changeProvider(providerId: number | null) {
status.value = await patchConfig({ provider_id: providerId })
}
@@ -191,14 +207,28 @@ onBeforeUnmount(() => clearInterval(timer))
{{ status.running ? "交易中" : "已停止" }}
</el-tag>
<el-button
:type="status.running ? 'danger' : 'primary'" :loading="toggling" :disabled="!status.configured"
:type="status.running ? 'danger' : 'primary'" :loading="toggling"
:disabled="!status.running && (!status.configured || !status.capital_usdt)"
@click="toggleRunning"
>
{{ status.running ? "停止" : "开始行情交易" }}
</el-button>
</div>
<div class="stat">
<span>权益</span>{{ money(account?.margin_balance) }} USDT
<span>交易资金</span>
<template v-if="status.capital_usdt">
{{ money(status.capital_usdt) }} USDT
<span v-if="status.effective_capital && Number(status.effective_capital) < Number(status.capital_usdt)" class="text-[var(--el-color-warning)]!">
(权益不足,按 {{ money(status.effective_capital) }})
</span>
</template>
<b v-else class="text-[var(--el-color-danger)]">未设置</b>
<el-link type="primary" underline="never" class="ml-1" @click="editCapital">
设置
</el-link>
</div>
<div class="stat">
<span>账户权益</span>{{ money(account?.margin_balance) }} USDT
</div>
<div class="stat">
<span>可用</span>{{ money(account?.available_balance) }} USDT
@@ -229,6 +259,9 @@ onBeforeUnmount(() => clearInterval(timer))
<el-alert v-if="!status.configured" type="warning" :closable="false" show-icon>
还没有填写币安模拟盘 key,请先到「环境变量」页填写。
</el-alert>
<el-alert v-else-if="!status.capital_usdt" type="warning" :closable="false" show-icon>
还没有设置交易资金。引擎只按交易资金计算仓位,不会动用账户的全部余额;设置后才能开始行情交易。
</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>