From b50c134f5b826430b584b164befc8862ce2b9b85 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 25 Jul 2026 10:28:33 +0800 Subject: [PATCH] Add weekend open skip, expiry force-close, and Chinese trade labels. Co-authored-by: Cursor --- README.md | 1 + backend/app/api/settings.py | 11 ++ backend/app/api/sim.py | 15 +- backend/app/config.py | 1 + backend/app/main.py | 1 + backend/app/sim/ledger.py | 6 + backend/app/strategy/__init__.py | 3 +- backend/app/strategy/clock.py | 13 +- backend/app/strategy/engine.py | 117 +++++++++++++--- backend/app/strategy/exits.py | 15 ++ backend/tests/test_p1_p2_rules.py | 32 +++-- docs/商业化与授权方案.md | 220 ++++++++++++++++++++++++++++++ docs/开发方案.md | 18 +-- frontend/src/api/client.ts | 2 + frontend/src/labels.ts | 68 +++++++++ frontend/src/pages/Plan.tsx | 3 +- frontend/src/pages/Settings.tsx | 17 ++- frontend/src/pages/Stats.tsx | 17 ++- frontend/src/pages/Trades.tsx | 17 ++- 19 files changed, 525 insertions(+), 52 deletions(-) create mode 100644 docs/商业化与授权方案.md create mode 100644 frontend/src/labels.ts diff --git a/README.md b/README.md index f793b3f..83a579c8 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - [开发方案](docs/开发方案.md) - [代码结构](docs/代码结构.md) +- [商业化与授权方案](docs/商业化与授权方案.md) ## 访问(测试机) diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index c6d1fd3..b3bd254 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -19,6 +19,7 @@ KEYS = ( "net_profit_target", "premium_exit_multiple", "rest_seconds", + "skip_weekends", "initial_equity", "leverage", "min_option_hours", @@ -36,6 +37,7 @@ class StrategySettingsBody(BaseModel): net_profit_target: float | None = Field(default=None, ge=0.1, le=1_000_000) premium_exit_multiple: float | None = Field(default=None, ge=0.1, le=100) rest_seconds: int | None = Field(default=None, ge=0, le=3600) + skip_weekends: bool | None = None initial_equity: float | None = Field(default=None, ge=1000) leverage: float | None = Field(default=None, ge=1, le=125) min_option_hours: float | None = Field(default=None, ge=1, le=720) @@ -45,6 +47,12 @@ class StrategySettingsBody(BaseModel): option_qty_eth: float | None = Field(default=None, ge=0.01, le=100) +def _as_bool(raw: str | None, default: bool) -> bool: + if raw is None or raw == "": + return default + return str(raw).strip().lower() in ("1", "true", "yes", "on") + + def _read_settings() -> dict: db = get_db() s = get_settings() @@ -68,6 +76,9 @@ def _read_settings() -> dict: "rest_seconds": int( float(db.get_setting("rest_seconds", str(s.rest_seconds)) or s.rest_seconds) ), + "skip_weekends": _as_bool( + db.get_setting("skip_weekends", str(s.skip_weekends)), s.skip_weekends + ), "initial_equity": float( db.get_setting("initial_equity", str(s.initial_equity)) or s.initial_equity ), diff --git a/backend/app/api/sim.py b/backend/app/api/sim.py index 561d00c..e695594 100644 --- a/backend/app/api/sim.py +++ b/backend/app/api/sim.py @@ -5,11 +5,12 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field +from ..config import get_settings from ..market import get_gateway from ..models.db import get_db from ..sim.ledger import Ledger from ..sim.matcher import Matcher -from ..strategy.clock import window_key +from ..strategy.clock import can_open_new, window_key from ..strategy.group import next_group_id from .auth import require_user @@ -39,6 +40,13 @@ async def sim_open_group( ) -> dict: if Matcher().has_open_position(): raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组") + s = get_settings() + skip_weekends = Ledger().get_setting_bool("skip_weekends", s.skip_weekends) + if not can_open_new(skip_weekends=skip_weekends): + raise HTTPException( + status_code=409, + detail="周六/周日跳过开仓(上海时区)", + ) gw = get_gateway() pick = await gw.pick_for_open_async() if pick is None: @@ -53,12 +61,9 @@ async def sim_open_group( perp_side = "short" if force == "call" else "long" bias = "manual_" + force option_ask = pick.call_ask if force == "call" else pick.put_ask - from ..config import get_settings from ..strategy.selection import option_leverage - from ..sim.ledger import Ledger as Led - s = get_settings() - min_lev = Led().get_setting_float("min_option_leverage", s.min_option_leverage) + min_lev = Ledger().get_setting_float("min_option_leverage", s.min_option_leverage) lev = option_leverage(pick.underlying_px, option_ask) if lev is None or lev < min_lev: raise HTTPException( diff --git a/backend/app/config.py b/backend/app/config.py index 2e6e878..d9fb416 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -48,6 +48,7 @@ class Settings(BaseSettings): net_profit_target: float = 15.0 # fixed_usdt:净盈利 ≥ 该值(USDT) premium_exit_multiple: float = 1.0 # premium_multiple:净盈利 ≥ 权利金×倍数 rest_seconds: int = 300 + skip_weekends: bool = True # 上海时区周六日禁止新开仓(已有仓仍可平) leverage: float = 3.0 # 永续杠杆 min_option_hours: float = 12.0 # 期权最小剩余小时 min_option_leverage: float = 100.0 # 现价/卖一权利金 下限 diff --git a/backend/app/main.py b/backend/app/main.py index 37f0781..f2da2ff 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -35,6 +35,7 @@ async def lifespan(app: FastAPI): set_db(db) engine = StrategyEngine() set_engine(engine) + engine.ensure_loop() session = bootstrap_session(settings) try: diff --git a/backend/app/sim/ledger.py b/backend/app/sim/ledger.py index add5111..8d47ca7 100644 --- a/backend/app/sim/ledger.py +++ b/backend/app/sim/ledger.py @@ -65,3 +65,9 @@ class Ledger: if v is None or v == "": return default return str(v) + + def get_setting_bool(self, key: str, default: bool) -> bool: + v = self.db.get_setting(key) + if v is None or v == "": + return default + return str(v).strip().lower() in ("1", "true", "yes", "on") diff --git a/backend/app/strategy/__init__.py b/backend/app/strategy/__init__.py index 3821cf9..53e90c9 100644 --- a/backend/app/strategy/__init__.py +++ b/backend/app/strategy/__init__.py @@ -1,4 +1,4 @@ -from .clock import can_open_new, window_key +from .clock import can_open_new, is_weekend, window_key from .exits import check_exits from .group import next_group_id from .signal import Signal, decide @@ -10,6 +10,7 @@ __all__ = [ "check_exits", "decide", "get_engine", + "is_weekend", "next_group_id", "set_engine", "window_key", diff --git a/backend/app/strategy/clock.py b/backend/app/strategy/clock.py index 3e8fa3a..ed91a94 100644 --- a/backend/app/strategy/clock.py +++ b/backend/app/strategy/clock.py @@ -1,4 +1,4 @@ -"""日历日分组键(开仓时间窗已取消,由期权剩余时长约束)。""" +"""日历日分组键;可选周末跳过开仓(上海时区)。""" from __future__ import annotations @@ -17,13 +17,22 @@ def window_key(now: datetime | None = None) -> str: return now_sh(now).strftime("%Y%m%d") +def is_weekend(now: datetime | None = None) -> bool: + """上海时区:周六=5、周日=6。""" + return now_sh(now).weekday() >= 5 + + def can_open_new( now: datetime | None = None, *, + skip_weekends: bool = True, open_hhmm: str = "16:00", stop_hhmm: str = "08:00", ) -> bool: - """开仓窗已取消,始终允许(仍受期权剩余时长/杠杆筛选)。""" + """是否允许新开仓。开仓窗已取消;可选跳过周六日。持仓平仓不受此限制。""" + _ = open_hhmm, stop_hhmm + if skip_weekends and is_weekend(now): + return False return True diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 51d3a65..a01af83 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -12,8 +12,8 @@ from .session import get_session from ..models.db import get_db from ..sim.ledger import Ledger from ..sim.matcher import Matcher -from .clock import window_key -from .exits import check_exits, resolve_exit_target +from .clock import can_open_new, window_key +from .exits import check_expiry_close, check_exits, resolve_exit_target from .group import next_group_id logger = logging.getLogger(__name__) @@ -46,6 +46,7 @@ class StrategyEngine: initial_premium=float(upl.get("initial_premium") or 0), ) rest_sec = self.ledger.get_setting_int("rest_seconds", s.rest_seconds) + skip_weekends = self.ledger.get_setting_bool("skip_weekends", s.skip_weekends) leverage = self.ledger.get_setting_float("leverage", s.leverage) min_hours = self.ledger.get_setting_float("min_option_hours", s.min_option_hours) min_opt_lev = self.ledger.get_setting_float( @@ -59,6 +60,7 @@ class StrategyEngine: if last_error and "PriceResult" in str(last_error) and "__dict__" in str(last_error): self._set_state(last_error=None) last_error = None + allow_open = can_open_new(skip_weekends=skip_weekends) return { "running": bool(row["running"]), "phase": row["phase"], @@ -67,6 +69,7 @@ class StrategyEngine: "rest_until_ms": rest_until, "rest_left_sec": rest_left, "rest_seconds": rest_sec, + "skip_weekends": skip_weekends, "exit_mode": exit_mode, "net_profit_target": net_target, "premium_exit_multiple": prem_mult, @@ -74,7 +77,7 @@ class StrategyEngine: "leverage": leverage, "min_option_hours": min_hours, "min_option_leverage": min_opt_lev, - "can_open": True, + "can_open": allow_open, "last_error": last_error, "position": upl, "ledger": self.ledger.snapshot(), @@ -97,9 +100,13 @@ class StrategyEngine: async def start(self) -> dict[str, Any]: self._set_state(running=1, last_error=None, phase="idle") + self.ensure_loop() + return self.state() + + def ensure_loop(self) -> None: + """保证后台循环在跑(即使策略暂停,也要盯到期全平)。""" if self._task is None or self._task.done(): self._task = asyncio.create_task(self._loop(), name="strategy-engine") - return self.state() async def emergency_close(self) -> dict[str, Any]: async with self._lock: @@ -137,12 +144,73 @@ class StrategyEngine: ) return len(rows) + def _position_expiry_ms(self, upl: dict[str, Any]) -> int | None: + raw = upl.get("expiry_ms") + if raw is not None: + try: + return int(raw) + except (TypeError, ValueError): + pass + ymd = upl.get("expiry_ymd") + if ymd: + try: + from ..exchange.okx.parse import expiry_ms_from_ymd + + return int(expiry_ms_from_ymd(str(ymd))) + except Exception: + return None + return None + + async def _close_open_position( + self, + *, + reason: str, + bypass_liquidity: bool, + pending_close: bool, + ) -> None: + if not pending_close: + self._set_state(phase="closing", last_error=None) + r = await asyncio.to_thread( + self.matcher.close_group, + reason=reason, + bypass_liquidity=bypass_liquidity, + ) + if r.ok: + self._after_close() + elif r.liquidity_wait and not bypass_liquidity: + self._set_state(phase="liquidity_wait", last_error=r.detail) + else: + self._set_state(phase="closing", last_error=r.detail) + + async def _maybe_expiry_close(self) -> bool: + """若持仓已到期则强制全平。返回是否触发到期平仓。""" + pos = self.matcher.current_position() + if pos.get("status") != "open": + return False + upl = self.matcher.unrealized() + expired = check_expiry_close(expiry_ms=self._position_expiry_ms(upl)) + if not expired.should_close: + return False + st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1") + assert st is not None + pending = st["phase"] in ("liquidity_wait", "closing") + await self._close_open_position( + reason="expiry", + bypass_liquidity=True, + pending_close=pending, + ) + return True + async def _loop(self) -> None: logger.info("strategy engine loop started") while True: try: row = self.db.fetchone("SELECT running FROM strategy_state WHERE id=1") - if not row or not int(row["running"]): + running = bool(row and int(row["running"])) + if not running: + # 暂停时仍执行到期全平,避免拖过期 + async with self._lock: + await self._maybe_expiry_close() await asyncio.sleep(1) continue async with self._lock: @@ -180,6 +248,7 @@ class StrategyEngine: # 有未平仓:只盯平仓,绝不开下一组 if pos.get("status") == "open": upl = self.matcher.unrealized() + expired = check_expiry_close(expiry_ms=self._position_expiry_ms(upl)) decision = check_exits( net_pnl=float(upl.get("net_pnl") or 0), exit_mode=exit_mode, @@ -188,19 +257,18 @@ class StrategyEngine: initial_premium=float(upl.get("initial_premium") or 0), ) pending_close = st["phase"] in ("liquidity_wait", "closing") - if decision.should_close or pending_close: - reason = decision.reason or "liquidity_retry" - if not pending_close: - self._set_state(phase="closing", last_error=None) - r = await asyncio.to_thread( - self.matcher.close_group, reason=reason, bypass_liquidity=False - ) - if r.ok: - self._after_close() - elif r.liquidity_wait: - self._set_state(phase="liquidity_wait", last_error=r.detail) + if expired.should_close or decision.should_close or pending_close: + if expired.should_close: + reason = "expiry" + bypass = True else: - self._set_state(phase="closing", last_error=r.detail) + reason = decision.reason or "liquidity_retry" + bypass = False + await self._close_open_position( + reason=reason, + bypass_liquidity=bypass, + pending_close=pending_close, + ) else: self._set_state(phase="open", last_error=None) return @@ -214,10 +282,21 @@ class StrategyEngine: assert st is not None if st["phase"] in ("paused",): return + # 旧「轮次停开」状态:自动恢复为空闲以便继续 if st["phase"] in ("stopped", "outside_window"): self._set_state(phase="idle") + skip_weekends = self.ledger.get_setting_bool("skip_weekends", s.skip_weekends) + if not can_open_new(skip_weekends=skip_weekends): + self._set_state( + phase="weekend_skip", + last_error="周六/周日跳过开仓(上海时区);持仓仍可平仓", + ) + return + if st["phase"] == "weekend_skip": + self._set_state(phase="idle", last_error=None) + # 双保险:账本仍显示有仓则不开 if self.matcher.has_open_position(): self._set_state(phase="open", last_error="有未平仓,禁止开下一组") @@ -265,6 +344,6 @@ def get_engine() -> StrategyEngine: return _engine -def set_engine(e: StrategyEngine | None) -> None: +def set_engine(engine: StrategyEngine | None) -> None: global _engine - _engine = e + _engine = engine diff --git a/backend/app/strategy/exits.py b/backend/app/strategy/exits.py index a9e21d6..d496efc 100644 --- a/backend/app/strategy/exits.py +++ b/backend/app/strategy/exits.py @@ -1,5 +1,6 @@ from __future__ import annotations +import time from dataclasses import dataclass EXIT_MODE_FIXED = "fixed_usdt" @@ -28,6 +29,20 @@ def resolve_exit_target( return float(net_profit_target), EXIT_MODE_FIXED +def check_expiry_close( + *, + expiry_ms: int | None, + now_ms: int | None = None, +) -> ExitDecision: + """期权到期时刻(含)→ 强制全平。""" + if expiry_ms is None: + return ExitDecision(False, "", 0.0) + now = int(time.time() * 1000) if now_ms is None else int(now_ms) + if now >= int(expiry_ms): + return ExitDecision(True, "expiry", 0.0) + return ExitDecision(False, "", 0.0) + + def check_exits( *, net_pnl: float, diff --git a/backend/tests/test_p1_p2_rules.py b/backend/tests/test_p1_p2_rules.py index 0bdb66c..69063f0 100644 --- a/backend/tests/test_p1_p2_rules.py +++ b/backend/tests/test_p1_p2_rules.py @@ -4,7 +4,7 @@ from zoneinfo import ZoneInfo from app.sim.liquidity import bid_mark_ok from app.sim.pricing import option_fill, perp_fill from app.strategy.clock import can_open_new, window_key -from app.strategy.exits import check_exits +from app.strategy.exits import check_expiry_close, check_exits from app.strategy.signal import decide _SH = ZoneInfo("Asia/Shanghai") @@ -83,13 +83,18 @@ def test_option_open_close_pricing() -> None: assert c.fill_px < 10 -def test_window_always_open() -> None: - n = datetime(2026, 7, 24, 17, 0, tzinfo=_SH) - assert can_open_new(n) is True - assert window_key(n) == "20260724" - n2 = datetime(2026, 7, 24, 10, 0, tzinfo=_SH) - assert can_open_new(n2) is True - assert window_key(n2) == "20260724" +def test_window_weekend_skip() -> None: + # 2026-07-24 周五可开;25/26 周六日不可开 + fri = datetime(2026, 7, 24, 17, 0, tzinfo=_SH) + sat = datetime(2026, 7, 25, 12, 0, tzinfo=_SH) + sun = datetime(2026, 7, 26, 10, 0, tzinfo=_SH) + mon = datetime(2026, 7, 27, 9, 0, tzinfo=_SH) + assert can_open_new(fri, skip_weekends=True) is True + assert can_open_new(sat, skip_weekends=True) is False + assert can_open_new(sun, skip_weekends=True) is False + assert can_open_new(mon, skip_weekends=True) is True + assert can_open_new(sat, skip_weekends=False) is True + assert window_key(fri) == "20260724" def test_bid_mark_deviation_30pct() -> None: @@ -101,3 +106,14 @@ def test_bid_mark_deviation_30pct() -> None: ok3, why = bid_mark_ok(bid=None, mark=10.0, max_dev_pct=30) assert ok3 is False assert "买一" in why + + +def test_expiry_close() -> None: + assert check_expiry_close(expiry_ms=None).should_close is False + d = check_expiry_close(expiry_ms=1_000, now_ms=999) + assert d.should_close is False + d2 = check_expiry_close(expiry_ms=1_000, now_ms=1_000) + assert d2.should_close is True + assert d2.reason == "expiry" + d3 = check_expiry_close(expiry_ms=1_000, now_ms=1_001) + assert d3.should_close is True diff --git a/docs/商业化与授权方案.md b/docs/商业化与授权方案.md new file mode 100644 index 0000000..dc19d12 --- /dev/null +++ b/docs/商业化与授权方案.md @@ -0,0 +1,220 @@ +# eth_hedge_sim — 商业化与授权方案(初步) + +> 状态:草案,指导后续开发,细节可迭代。 +> 商业模式结论:**客户侧部署 + 软件授权**(不做中心化多租户托管)。 +> 关联文档:[开发方案](./开发方案.md)、[代码结构](./代码结构.md) + +--- + +## 1. 目标与边界 + +### 1.1 卖什么 + +| 项 | 约定 | +|----|------| +| 产品形态 | 可独立部署的对冲交易系统(SIM + 可选实盘) | +| 交付方式 | 安装包 / 一键脚本 / Docker(后续择一为主) | +| 收费方式 | **授权(License)**:按实例、按期限、按功能档 | +| 不卖什么 | 不承诺收益;不做代客理财;不集中托管客户 API Key | + +### 1.2 为什么不做 SaaS 托管 + +| 风险 | 说明 | +|------|------| +| 交易所限流 | REST/WS 按 IP、API Key 限流;多客户共机互相挤占 | +| 同 IP 风控 | 一公网 IP 对多交易所、多 Key 高频请求,易触发异常标记 | +| 密钥与责任 | 客户 Key 进你机房,合规与事故责任更重 | +| 运维成本 | 你要为所有客户的行情稳定性买单 | + +**原则:一客户一实例一出口 IP(或客户自有网络)。** + +### 1.3 产品定位一句话 + +> 客户在自己的服务器上部署;API Key 不出客户环境;你提供软件、更新与授权。 + +--- + +## 2. 交付与部署 + +### 2.1 目标交付形态(分阶段) + +| 阶段 | 形态 | 说明 | +|------|------|------| +| 现在(内部) | Git + `deploy/manage.sh` + PM2 | 已具备,继续打磨成「可复制安装」 | +| 商用 v1 | **一键部署脚本** + 环境检查 + 健康检查 | 客户 Ubuntu 机器上跑通 | +| 商用 v2 | **Docker Compose**(可选) | 降低环境差异;与脚本二选一或并存 | +| 商用 v3 | 离线包 / 镜像导出 | 内网客户、无法拉 Git 的场景 | + +### 2.2 客户侧必备条件 + +- 一台独立 Linux 主机(建议独立 IP) +- 出网访问目标交易所 API(OKX 等) +- 域名或 IP + HTTPS(可用反代) +- 客户自备交易所 API Key(只读行情 / 实盘交易按档位要求) + +### 2.3 部署流程(商用目标体验) + +1. 客户拿到安装脚本或镜像 + **授权码** +2. 填写 `.env`(账号、交易所 Key、授权码) +3. 一键安装 → 健康检查通过 +4. 浏览器登录 → SIM 可演示;实盘需对应授权档位 + 二次确认 + +### 2.4 更新策略 + +- 默认:客户机 `git pull` / 镜像升级 + 构建 + 仅 reload 本项目进程 +- 破坏性升级:附带迁移说明(配置项、数据库/状态文件) +- 授权未过期才允许拉取正式版更新(可选;初期可人工发版) + +--- + +## 3. 授权(License)设计 + +### 3.1 设计原则 + +- **正规客户好用,随手拷贝有门槛**(不做无法破解的完美 DRM) +- 校验失败时:允许登录看状态,但 **禁止启策略 / 禁止实盘下单**(策略可配置) +- 校验逻辑本地为主;可选短时在线激活(降低盗版批量复制) + +### 3.2 授权字段(建议) + +| 字段 | 用途 | +|------|------| +| `license_id` | 授权编号 | +| `customer_name` | 客户标识(展示/审计) | +| `expires_at` | 到期日 | +| `edition` | 档位:`sim` / `live` / `pro`(名称可调) | +| `instance_id` 或 `machine_fingerprint` | 绑定部署实例(主机指纹或安装时生成的 UUID) | +| `features` | 功能开关列表(可选) | +| `signature` | 对上述字段的签名(私钥在你方,公钥打进程序) | + +### 3.3 档位建议(初稿) + +| 档位 | 能力 | +|------|------| +| `sim` | 仅 SIM 撮合;实盘下单关闭 | +| `live` | SIM + 实盘下单(显式开关 + 二次确认) | +| `pro` | live + 多策略/高级报表/优先支持(后续) | + +### 3.4 绑定策略(由松到紧,可演进) + +1. **v1**:授权码 + 到期日 + 档位(不绑机,靠合同与信任) +2. **v2**:首次激活写入 `instance_id`,换机需重置授权 +3. **v3**(可选):定期在线心跳续期;断网宽限期 N 天 + +### 3.5 运行时行为 + +``` +启动 → 读 license → 验签 / 查到期 / 查档位 + ├─ 有效 → 按档位开放功能 + └─ 无效/过期 → UI 提示;策略不可启动;实盘接口 403 +``` + +本地文件建议:`data/license.json` 或环境变量 `LICENSE_KEY`(整段授权串)。 + +--- + +## 4. 技术架构约束(后续开发必须遵守) + +### 4.1 多租户与网络 + +- **不做**「一个进程服务多个无关客户」 +- **不做**「你方中心机房代跑客户实盘」作为主路径 +- 每个商用部署 = 独立进程 + 独立配置 + 独立日志 + 独立出口网络 + +### 4.2 密钥与安全 + +- 交易所 API Key **仅存客户机**(加密或系统权限保护) +- 禁止把客户 Key 回传到你方(授权校验除外的元数据也不含密钥) +- HTTPS、登录鉴权、改密、审计日志(谁启停、谁改参数)逐步补齐 + +### 4.3 模式隔离 + +| 模式 | 要求 | +|------|------| +| SIM | 零交易类写接口;行情只读 | +| LIVE | 显式开关 + 二次确认 + 授权档位 dual-check | +| 数据 | SIM 与 LIVE 成交/绩效 **分库或分表/分前缀**,不可混报 | + +### 4.4 可观测性(商用必需) + +- `/health`:进程、行情连接、授权状态(不含密钥) +- 结构化日志:开平仓原因、费用、流动性等待、紧急平仓 +- 绩效导出:按组 / 按日(收益、回撤、费用、胜率)— 客户自证与你售后都需要 + +--- + +## 5. 合规与产品话术(边界) + +- 定位:**交易辅助工具 / 策略执行软件**,非保本理财 +- 界面与合同:风险提示、用户自负盈亏、交易所账户属用户 +- 对外演示默认走 **SIM**;实盘由客户自行承担 Key 与资金风险 +- 法务文本后续单独立项;工程侧先把「免责展示位 + 二次确认」留好 + +--- + +## 6. 开发路线图 + +### Phase 0 — 现在(产品可用、可演示) + +- [x] SIM 对冲主流程、设置、Plan 桌面布局 +- [ ] 绩效与组维度报表可导出/可复盘 +- [ ] 部署脚本文档化到「陌生人能装上」的程度 +- [ ] SIM / 未来 LIVE 配置与文案边界清晰 + +### Phase 1 — 授权骨架(商用前提) + +- [ ] License 数据结构 + 本地验签 +- [ ] 到期 / 档位拦截(启策略、实盘 API) +- [ ] 设置页或启动页展示授权状态与到期日 +- [ ] 内部发码工具(私钥离线,不进客户仓库) + +### Phase 2 — 实盘与交付打磨 + +- [ ] LIVE 下单通路(已有规划则按开发方案推进) +- [ ] 安装向导:环境检查、端口、反向代理示例 +- [ ] 升级与回滚说明;配置迁移 +- [ ] 客户排障包:一键打包日志(脱敏) + +### Phase 3 — 商业运营配套 + +- [ ] 授权台账(客户、到期、续费、换机)— 可先表格,后小后台 +- [ ] 版本通道:stable / beta +- [ ] 支持档位与 SLA(人工即可) +- [ ] (可选)在线激活与换机工单 + +### 明确延后 + +- 中心化多租户 SaaS +- 复杂手机端优先设计 +- 支付系统内嵌(可先对公转账 + 人工发码) +- 完美防破解 + +--- + +## 7. 与当前仓库的衔接 + +| 现有能力 | 商用含义 | +|----------|----------| +| `deploy/manage.sh`、远程 `deploy_remote.py` | 演进为客户侧一键安装/更新的基础 | +| PM2 `eth-hedge-api`、端口 5155 | 单实例模型已符合「一客户一进程」 | +| 登录 `AUTH_USERNAME` / `AUTH_PASSWORD` | 保留为实例管理员;与 License 分层 | +| SIM 撮合 + 只读行情 | 作为 `sim` 档与售前演示默认路径 | +| 与 `crypto_monitor` 隔离 | 商用交付物必须自包含,禁止捆绑现网 | + +--- + +## 8. 待决事项(开发前拍板) + +1. 主交付形态优先:**脚本 + PM2** 还是 **Docker Compose**? +2. 授权 v1 是否绑机?还是先「码 + 到期日」? +3. 过期策略:只禁实盘,还是 SIM 一并只读? +4. 首发交易所是否仅 OKX,多所是否进 `pro`? +5. 价格与续费周期(工程不阻塞,商务可并行) + +--- + +## 9. 修订记录 + +| 日期 | 说明 | +|------|------| +| 2026-07-25 | 初稿:确定「部署 + 授权」、否决中心托管主路径、划定 Phase 0–3 | diff --git a/docs/开发方案.md b/docs/开发方案.md index f6fd720..9569761 100644 --- a/docs/开发方案.md +++ b/docs/开发方案.md @@ -32,11 +32,11 @@ ### 2.1 时间与次数 -- 期权合约:选 **次日 16:00** 到期;行权价默认 **ATM**(同到期、最接近指数/标记的同一行权价 Call+Put)。 -- 可开仓窗:业务日 **D 日 16:00** 起 → **D+1 日 08:00** 前。 -- **D+1 08:00 起禁止新开仓**(已有持仓仍按平仓规则处理,不强制到点清仓);下一窗等 **16:00**。 -- 每个业务窗最多 **3 轮**;同时最多 **1 组**仓。 -- 一轮全平结束后 **休息 5 分钟**(可配 `REST_SECONDS`),再自动开下一轮(未满 3 且仍在开仓窗)。 +- 期权合约:按 **剩余时长下限**(可配)筛选到期;行权价默认 **ATM**(同到期、最接近指数/标记的同一行权价 Call+Put)。 +- **开仓时间窗已取消**(全天可尝试开仓,仍受选约条件约束)。 +- **周六 / 周日跳过开仓**(上海时区,设置项 `skip_weekends`,默认开启):禁止新开仓;**已有持仓仍盯盘平仓**。 +- **不做横盘识别**:波动过滤难以精准,避免假信号;用周末跳过等确定性规则代替。 +- 同时最多 **1 组**仓;一轮全平结束后 **休息**(可配 `rest_seconds`,默认 300 秒)再开下一组。 ### 2.2 方向(Call 卖一 vs Put 卖一;期权只买入、永不为卖方) @@ -48,14 +48,14 @@ ### 2.3 平仓(任一触发 → 该组全平) -1. **权利金覆盖**:永续浮盈 ≥ 该组开仓锁定的 **期权初始权利金总额**(触发口径 **不含手续费**;费用单独记账)。 -2. **标的波动 N 点**(设置可配,默认 `EXIT_MOVE_POINTS=30`):相对开仓锁定的标的价(指数优先)绝对值走动 ≥ N → 全平。 - 主要用于永续方向错、期权方向对时的退出;永续方向对时同一 N 点也全平。 +1. **净盈利达标**(设置可选):固定 USDT,或初始权利金 × 倍数。 +2. **到期强制全平**:到达期权到期时刻(OKX 惯例 UTC 08:00 = 上海 16:00)→ 整组卖出;绕过买一流动性闸门(与紧急平仓同类)。策略暂停时仍执行。 + 波动小、拖到到期亏损权利金,按预算内成本接受。 平仓执行: - 永续:本地市价平仓。 -- 期权:多头平仓吃买一;买一深度需覆盖 2 ETH 名义;不足则不成交并记「流动性不足」,默认继续等待。 +- 期权:多头平仓吃买一;买一深度需覆盖名义(到期/紧急可绕过偏差与深度校验)。 ### 2.4 组(Group)标识 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d97861d..bfa686c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -120,6 +120,7 @@ export type PlanState = { window_key: string | null; rest_left_sec: number; rest_seconds: number; + skip_weekends?: boolean; exit_move_pct?: number; exit_mode: "fixed_usdt" | "premium_multiple"; net_profit_target: number; @@ -171,6 +172,7 @@ export type StrategySettings = { net_profit_target: number; premium_exit_multiple: number; rest_seconds: number; + skip_weekends: boolean; initial_equity: number; leverage: number; min_option_hours: number; diff --git a/frontend/src/labels.ts b/frontend/src/labels.ts new file mode 100644 index 0000000..2eb5b74 --- /dev/null +++ b/frontend/src/labels.ts @@ -0,0 +1,68 @@ +/** 交易相关英文码 → 中文展示(库内仍存英文)。 */ + +const STATUS_ZH: Record = { + open: "持仓中", + closed: "已平仓", +}; + +const SIDE_ZH: Record = { + long: "多", + short: "空", + call: "看涨", + put: "看跌", +}; + +const LEG_ZH: Record = { + perp: "永续", + option: "期权", +}; + +const ACTION_ZH: Record = { + open: "开仓", + close: "平仓", +}; + +const CLOSE_REASON_ZH: Record = { + fixed_usdt: "固定净盈利达标", + premium_multiple: "权利金倍数达标", + expiry: "到期自动全平", + emergency: "紧急全平", + manual: "手动平仓", + liquidity_retry: "流动性等待后续平仓", + unknown: "未知", +}; + +export function statusZh(v: string | null | undefined): string { + if (!v) return "—"; + return STATUS_ZH[v] || v; +} + +export function sideZh(v: string | null | undefined): string { + if (!v) return "—"; + return SIDE_ZH[v] || v; +} + +/** 永续方向 / 期权方向,如「多/看跌」 */ +export function positionSidesZh( + perp: string | null | undefined, + option: string | null | undefined, +): string { + return `${sideZh(perp)}/${sideZh(option)}`; +} + +export function fillDescZh(leg: string, action: string, side: string): string { + const l = LEG_ZH[leg] || leg; + const a = ACTION_ZH[action] || action; + const s = SIDE_ZH[side] || side; + // 期权买入开仓:「期权开多」;永续:「永续开多/开空」 + if (leg === "option" && action === "open") return "期权开多"; + if (leg === "option" && action === "close") return "期权平多"; + if (leg === "perp" && action === "open") return s === "多" ? "永续开多" : "永续开空"; + if (leg === "perp" && action === "close") return s === "多" ? "永续平多" : "永续平空"; + return `${l}${a}${s}`; +} + +export function closeReasonZh(v: string | null | undefined): string { + if (!v) return "—"; + return CLOSE_REASON_ZH[v] || v; +} diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 874a803..d649e26 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -44,6 +44,7 @@ const PHASE_ZH: Record = { stopped: "已停开", outside_window: "窗外", liquidity_wait: "流动性等待", + weekend_skip: "周末跳过", }; export default function PlanPage() { @@ -123,7 +124,7 @@ export default function PlanPage() {

自动对冲计划

- SIM 本地撮合 · 期权只买 · 剩余时长选到期 · 净盈利达标全平 + SIM 本地撮合 · 期权只买 · 净盈利达标或到期全平

{err ?
{err}
: null} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index b744712..f913ac0 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -27,6 +27,7 @@ export default function SettingsPage() { const [netTarget, setNetTarget] = useState(15); const [premMult, setPremMult] = useState(1); const [rest, setRest] = useState(300); + const [skipWeekends, setSkipWeekends] = useState(true); const [leverage, setLeverage] = useState(3); const [minHours, setMinHours] = useState(12); const [minOptLev, setMinOptLev] = useState(100); @@ -43,6 +44,7 @@ export default function SettingsPage() { setNetTarget(s.net_profit_target ?? 15); setPremMult(s.premium_exit_multiple ?? 1); setRest(s.rest_seconds); + setSkipWeekends(s.skip_weekends !== false); setLeverage(s.leverage ?? 3); setMinHours(s.min_option_hours ?? 12); setMinOptLev(s.min_option_leverage ?? 100); @@ -97,6 +99,7 @@ export default function SettingsPage() { net_profit_target: netTarget, premium_exit_multiple: premMult, rest_seconds: rest, + skip_weekends: skipWeekends, leverage, min_option_hours: minHours, min_option_leverage: minOptLev, @@ -134,7 +137,7 @@ export default function SettingsPage() { {tab === "strategy" ? (

- 无开仓时间窗;期权按剩余时长选到期 → 平值 → 校验杠杆;出场可选固定金额或权利金倍数。 + 无开仓时间窗;可选周六日跳过开仓;期权按剩余时长选到期 → 平值 → 校验杠杆;出场可选固定金额或权利金倍数。

{stratOk ?
{stratOk}
: null} {err && tab === "strategy" ?
{err}
: null} @@ -282,6 +285,18 @@ export default function SettingsPage() { onChange={(e) => setRest(Number(e.target.value))} />
+
+ + +
setErr(e instanceof Error ? e.message : String(e))); }, []); + const reasonEntries = s + ? Object.entries(s.close_reasons).sort((a, b) => b[1] - a[1]) + : []; + return (

统计

@@ -46,9 +51,17 @@ export default function StatsPage() { {s.total_fees.toFixed(2)} / {s.total_slip.toFixed(2)}
-
+
平仓原因 - {JSON.stringify(s.close_reasons)} + + {reasonEntries.length === 0 + ? "—" + : reasonEntries.map(([k, n]) => ( +
+ {closeReasonZh(k)} × {n} +
+ ))} +

按组盈亏

{s.equity_curve.map((x) => ( diff --git a/frontend/src/pages/Trades.tsx b/frontend/src/pages/Trades.tsx index 0da55ac..e1dad38 100644 --- a/frontend/src/pages/Trades.tsx +++ b/frontend/src/pages/Trades.tsx @@ -1,5 +1,11 @@ import { useEffect, useState } from "react"; import { apiFetch } from "../api/client"; +import { + closeReasonZh, + fillDescZh, + positionSidesZh, + statusZh, +} from "../labels"; type Group = { group_id: string; @@ -62,10 +68,12 @@ export default function TradesPage() { onClick={() => openGroup(g.group_id)} > - {g.group_id} · {g.status} · {g.perp_side}/{g.option_side} + {g.group_id} · {statusZh(g.status)} ·{" "} + {positionSidesZh(g.perp_side, g.option_side)} - PnL {Number(g.realized_pnl || 0).toFixed(2)} · {g.close_reason || "—"} + 盈亏 {Number(g.realized_pnl || 0).toFixed(2)} ·{" "} + {closeReasonZh(g.close_reason)}
)) @@ -77,10 +85,11 @@ export default function TradesPage() { {fills.map((f) => (
- {f.leg} {f.action} {f.side} + {fillDescZh(f.leg, f.action, f.side)} - px {f.fill_px.toFixed(4)} · qty {f.qty_eth} · fee {f.fee.toFixed(4)} + 价 {f.fill_px.toFixed(4)} · 数量 {f.qty_eth} · 手续费{" "} + {f.fee.toFixed(4)}
))}