diff --git a/backend/alembic/versions/20261015_c3f19d7a2e58_trading_capital.py b/backend/alembic/versions/20261015_c3f19d7a2e58_trading_capital.py new file mode 100644 index 0000000..389b402 --- /dev/null +++ b/backend/alembic/versions/20261015_c3f19d7a2e58_trading_capital.py @@ -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') diff --git a/backend/src/alphapulse/api/trading.py b/backend/src/alphapulse/api/trading.py index 99d6a40..a4dc160 100644 --- a/backend/src/alphapulse/api/trading.py +++ b/backend/src/alphapulse/api/trading.py @@ -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) diff --git a/backend/src/alphapulse/models/trading.py b/backend/src/alphapulse/models/trading.py index 93a7883..72291e3 100644 --- a/backend/src/alphapulse/models/trading.py +++ b/backend/src/alphapulse/models/trading.py @@ -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)) diff --git a/web/src/apis/trading.ts b/web/src/apis/trading.ts index 44966ad..ae83100 100644 --- a/web/src/apis/trading.ts +++ b/web/src/apis/trading.ts @@ -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 { return http.post("/trading/stop") } -export function patchConfig(body: { provider_id?: number | null, reasoning_effort?: Effort | null }): Promise { +export function patchConfig(body: { capital_usdt?: string | null, provider_id?: number | null, reasoning_effort?: Effort | null }): Promise { return http.patch("/trading/config", body) } diff --git a/web/src/pages/trading/index.vue b/web/src/pages/trading/index.vue index 4bc894e..2271da7 100644 --- a/web/src/pages/trading/index.vue +++ b/web/src/pages/trading/index.vue @@ -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 ? "交易中" : "已停止" }} {{ status.running ? "停止" : "开始行情交易" }}
- 权益{{ money(account?.margin_balance) }} USDT + 交易资金 + + 未设置 + + 设置 + +
+
+ 账户权益{{ money(account?.margin_balance) }} USDT
可用{{ money(account?.available_balance) }} USDT @@ -229,6 +259,9 @@ onBeforeUnmount(() => clearInterval(timer)) 还没有填写币安模拟盘 key,请先到「环境变量」页填写。 + + 还没有设置交易资金。引擎只按交易资金计算仓位,不会动用账户的全部余额;设置后才能开始行情交易。 + 同步模拟盘失败:{{ snapshot.error }}{{ snapshot.synced_at ? `(以下为 ${formatTime(snapshot.synced_at, "HH:mm:ss")} 的数据)` : "" }}