Add weekend open skip, expiry force-close, and Chinese trade labels.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-25 10:28:33 +08:00
parent 826362b556
commit b50c134f5b
19 changed files with 525 additions and 52 deletions
+1
View File
@@ -9,6 +9,7 @@
- [开发方案](docs/开发方案.md)
- [代码结构](docs/代码结构.md)
- [商业化与授权方案](docs/商业化与授权方案.md)
## 访问(测试机)
+11
View File
@@ -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
),
+10 -5
View File
@@ -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(
+1
View File
@@ -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 # 现价/卖一权利金 下限
+1
View File
@@ -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:
+6
View File
@@ -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")
+2 -1
View File
@@ -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",
+11 -2
View File
@@ -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
+98 -19
View File
@@ -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
+15
View File
@@ -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,
+24 -8
View File
@@ -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
+220
View File
@@ -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 |
+9 -9
View File
@@ -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)标识
+2
View File
@@ -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;
+68
View File
@@ -0,0 +1,68 @@
/** 交易相关英文码 → 中文展示(库内仍存英文)。 */
const STATUS_ZH: Record<string, string> = {
open: "持仓中",
closed: "已平仓",
};
const SIDE_ZH: Record<string, string> = {
long: "多",
short: "空",
call: "看涨",
put: "看跌",
};
const LEG_ZH: Record<string, string> = {
perp: "永续",
option: "期权",
};
const ACTION_ZH: Record<string, string> = {
open: "开仓",
close: "平仓",
};
const CLOSE_REASON_ZH: Record<string, string> = {
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;
}
+2 -1
View File
@@ -44,6 +44,7 @@ const PHASE_ZH: Record<string, string> = {
stopped: "已停开",
outside_window: "窗外",
liquidity_wait: "流动性等待",
weekend_skip: "周末跳过",
};
export default function PlanPage() {
@@ -123,7 +124,7 @@ export default function PlanPage() {
<div>
<h2 style={{ marginTop: 0 }}></h2>
<p style={{ color: "var(--muted)", marginTop: -8 }}>
SIM · · ·
SIM · ·
</p>
{err ? <div className="err">{err}</div> : null}
+16 -1
View File
@@ -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" ? (
<div className="card settings-card">
<p className="settings-lead">
</p>
{stratOk ? <div className="settings-ok">{stratOk}</div> : null}
{err && tab === "strategy" ? <div className="err">{err}</div> : null}
@@ -282,6 +285,18 @@ export default function SettingsPage() {
onChange={(e) => setRest(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="skipWe"></label>
<select
id="skipWe"
className="mono"
value={skipWeekends ? "1" : "0"}
onChange={(e) => setSkipWeekends(e.target.value === "1")}
>
<option value="1"></option>
<option value="0"></option>
</select>
</div>
<div className="field">
<label htmlFor="fee"></label>
<input
+15 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { apiFetch } from "../api/client";
import { closeReasonZh } from "../labels";
type Summary = {
groups: number;
@@ -22,6 +23,10 @@ export default function StatsPage() {
.catch((e) => setErr(e instanceof Error ? e.message : String(e)));
}, []);
const reasonEntries = s
? Object.entries(s.close_reasons).sort((a, b) => b[1] - a[1])
: [];
return (
<div className="card">
<h2 style={{ marginTop: 0 }}></h2>
@@ -46,9 +51,17 @@ export default function StatsPage() {
{s.total_fees.toFixed(2)} / {s.total_slip.toFixed(2)}
</span>
</div>
<div className="kv">
<div className="kv" style={{ alignItems: "flex-start" }}>
<span></span>
<span className="mono">{JSON.stringify(s.close_reasons)}</span>
<span className="mono" style={{ textAlign: "right" }}>
{reasonEntries.length === 0
? "—"
: reasonEntries.map(([k, n]) => (
<div key={k}>
{closeReasonZh(k)} × {n}
</div>
))}
</span>
</div>
<h3></h3>
{s.equity_curve.map((x) => (
+13 -4
View File
@@ -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)}
>
<span className="mono">
{g.group_id} · {g.status} · {g.perp_side}/{g.option_side}
{g.group_id} · {statusZh(g.status)} ·{" "}
{positionSidesZh(g.perp_side, g.option_side)}
</span>
<span className="mono">
PnL {Number(g.realized_pnl || 0).toFixed(2)} · {g.close_reason || ""}
{Number(g.realized_pnl || 0).toFixed(2)} ·{" "}
{closeReasonZh(g.close_reason)}
</span>
</div>
))
@@ -77,10 +85,11 @@ export default function TradesPage() {
{fills.map((f) => (
<div key={f.id} className="kv">
<span className="mono">
{f.leg} {f.action} {f.side}
{fillDescZh(f.leg, f.action, f.side)}
</span>
<span className="mono">
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)}
</span>
</div>
))}