Compare commits
170 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b63f6f0962 | |||
| 75f50fe083 | |||
| f675f9997a | |||
| 081afeba76 | |||
| e38039d99a | |||
| c1a84013b9 | |||
| fe346571b1 | |||
| 5f9901db0f | |||
| 5e2f332bdd | |||
| aa1a2da2b7 | |||
| 57cca5554e | |||
| aa14688a7e | |||
| fe5bb923d7 | |||
| 893a2cc115 | |||
| 9421ff7360 | |||
| e261df0009 | |||
| 1ddfe3f72e | |||
| 467f4f092f | |||
| 8b5080bdda | |||
| 9bb05113f3 | |||
| f5c553844f | |||
| d4d2110412 | |||
| 2e2d5ddda0 | |||
| b763937ec2 | |||
| a7bec5e121 | |||
| b0c331aa26 | |||
| 87910ed71a | |||
| 4fb3be35ef | |||
| 6c49c7d51c | |||
| 6c9825284f | |||
| 70f6cc2e7b | |||
| 9f3360e968 | |||
| ed3f4898dd | |||
| 15694e8ea8 | |||
| f3de3763bb | |||
| 73efad6fa5 | |||
| a84554e613 | |||
| 1314349fe5 | |||
| 72c84bb993 | |||
| dd8fbae0dd | |||
| 1c2d6ef3a9 | |||
| 2028251fc1 | |||
| 339f5e6db0 | |||
| 71a91484a3 | |||
| 86cf722117 | |||
| 2a33f74252 | |||
| 271865fa3d | |||
| 9c19afc8d4 | |||
| 8605efa2ed | |||
| cd23ea74a6 | |||
| 8dda7500df | |||
| 886b6dcc5b | |||
| a8d6795837 | |||
| 51e454b0f6 | |||
| 1522117eeb | |||
| a354811a6e | |||
| 3d7d754ba3 | |||
| 38e3e00fe9 | |||
| a1bf760a28 | |||
| c5d3d9d6c1 | |||
| e26a67176c | |||
| 860ebef4a7 | |||
| a90876d772 | |||
| 245b85ad27 | |||
| 425fc701bc | |||
| eca6d091e9 | |||
| 6ab27cebcd | |||
| 20e0c2cb9f | |||
| c3c7243dd7 | |||
| f7e5329915 | |||
| e7e733d9a9 | |||
| afe361ce47 | |||
| 0b8e5a0914 | |||
| 4bc238b014 | |||
| d41028b766 | |||
| 75a4175522 | |||
| 993189ce13 | |||
| 2181c81aba | |||
| baf928d064 | |||
| f19500bcd9 | |||
| 25ed46e3f2 | |||
| 7f22bffbc6 | |||
| 7352d10254 | |||
| 3f6e67661b | |||
| 09a763e47d | |||
| 22e6b68e6d | |||
| 55e94fe059 | |||
| 747434a65e | |||
| f6ea0dc399 | |||
| a00699aec3 | |||
| 4b4dca9e3c | |||
| fa7ff739a0 | |||
| fdbbde08df | |||
| 83ce50b24e | |||
| f9c2a63cbc | |||
| 94c65cbd6e | |||
| 644dcdf092 | |||
| d89aff3ad6 | |||
| 9f3395de2f | |||
| 28a329cb63 | |||
| 1c2c012dd7 | |||
| 05864d72c2 | |||
| 722c511543 | |||
| 26bc19f047 | |||
| c81ba147cc | |||
| 90be23e845 | |||
| 2ce67da8e8 | |||
| d049c5d317 | |||
| 845884fc67 | |||
| c73e36309e | |||
| 21c80f2ac9 | |||
| a908dccaba | |||
| 4bcf88b5cb | |||
| f360242188 | |||
| f53f2814ab | |||
| e5051fb309 | |||
| 1dc7914701 | |||
| 58e2bf3e8b | |||
| 19debee581 | |||
| 1755f67eca | |||
| 6886de0bad | |||
| f04a91efe6 | |||
| b43e33e24f | |||
| b5a061e758 | |||
| 4ef3b40353 | |||
| 4a79e010c4 | |||
| 791cc750da | |||
| c8231ea194 | |||
| aaccdcfc16 | |||
| f993a89a21 | |||
| 9dc363270e | |||
| 9a83dfe209 | |||
| 32c42b8447 | |||
| a2075ba73e | |||
| 846f3de525 | |||
| a7b75895e6 | |||
| d870178b83 | |||
| 7ebe1671b2 | |||
| cb4f6aaa4b | |||
| eb175820e9 | |||
| 890659f173 | |||
| ca499c6104 | |||
| 54f1857fa2 | |||
| 6f1ae14b3d | |||
| 29d59d6a53 | |||
| 58a4dafe9a | |||
| 9e0591c676 | |||
| ed3033d793 | |||
| b6156e0049 | |||
| 8e3c00641f | |||
| f11f89e760 | |||
| 0096467d14 | |||
| 8a9dee267f | |||
| 910c938d0a | |||
| 0a9e3aa95c | |||
| b64c742fc9 | |||
| 789ab43dbe | |||
| 61e8da1e8b | |||
| 40be3a5ab7 | |||
| 4ccfb838f6 | |||
| 58e9c8f85e | |||
| eb0eddbc9d | |||
| c5f40cba2b | |||
| a7216428ab | |||
| 77f66bf200 | |||
| 488b931959 | |||
| b89cba3b6e | |||
| e7f8e9201e | |||
| 301a464f29 | |||
| a4be294c06 |
@@ -1,18 +0,0 @@
|
||||
---
|
||||
description: After each completed code change, commit, push origin/main, and deploy to zk.hyf2.cc
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Auto push & deploy
|
||||
|
||||
When a user-facing code change is **finished** (not mid-debug / not "先不要改代码"):
|
||||
|
||||
1. Commit only the relevant files (skip unrelated CRLF-only docs noise).
|
||||
2. `git push origin main` to `https://git.bz121.com/dekun/crypto_monitor.git`.
|
||||
3. Deploy to production `zk.hyf2.cc`:`cd /opt/crypto_monitor && git pull && bash deploy/pull_and_restart.sh`.
|
||||
4. Confirm PM2 processes are online; briefly report commit hash + deploy status.
|
||||
|
||||
Do **not** wait for the user to say "推送并部署" again unless they cancel this habit.
|
||||
|
||||
SSH: Prefer key auth; if BatchMode fails, use existing Paramiko root login path used in this project.
|
||||
Do not print or put passwords in user-facing replies.
|
||||
@@ -3,5 +3,8 @@
|
||||
deploy/** text eol=lf
|
||||
# 文档统一 LF,避免 Windows 编辑后产生 CRLF 脏 diff
|
||||
docs/** text eol=lf
|
||||
# XMind 为 ZIP 二进制;须覆盖上面 docs/** 的 text/eol,否则入库会损坏打不开
|
||||
*.xmind -text -diff -merge -eol
|
||||
docs/**/*.xmind -text -diff -merge -eol
|
||||
# .env 模板统一 LF,避免 Linux PM2 source 报 $'\r': command not found
|
||||
**/.env.example text eol=lf
|
||||
|
||||
@@ -15,12 +15,17 @@
|
||||
**/.env.backup*
|
||||
**/.env.bak
|
||||
**/.env.local
|
||||
|
||||
# Cursor 本机规则/配置(勿提交;只留本地)
|
||||
.cursor/
|
||||
|
||||
manual_trading_hub/hub_settings.json
|
||||
manual_trading_hub/hub_backup_state.json
|
||||
manual_trading_hub/hub_fund_history.json
|
||||
manual_trading_hub/hub_supervisor_state.json
|
||||
manual_trading_hub/hub_ai_summaries.json
|
||||
manual_trading_hub/hub_ai_chat.json
|
||||
manual_trading_hub/amp_stats_history.json
|
||||
manual_trading_hub/hub_ai_fund_history.json
|
||||
manual_trading_hub/data/
|
||||
backups/
|
||||
|
||||
@@ -76,10 +76,9 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
|
||||
|
||||
# 是否开启 Binance 实盘下单(false=只做本地流程,true=真实下单)
|
||||
LIVE_TRADING_ENABLED=true
|
||||
# Binance API Key(需开通合约,万向划转等权限)
|
||||
BINANCE_API_KEY=REPLACE_WITH_BINANCE_API_KEY
|
||||
# Binance API Secret
|
||||
BINANCE_API_SECRET=REPLACE_WITH_BINANCE_API_SECRET
|
||||
# Binance API(仅服务器 .env 配置;新机保持为空,填真钥后 pm2 restart --update-env;勿用占位符以免鉴权狂打)
|
||||
BINANCE_API_KEY=
|
||||
BINANCE_API_SECRET=
|
||||
# 保证金模式:cross=全仓,isolated=逐仓
|
||||
BINANCE_MARGIN_MODE=cross
|
||||
# 持仓模式:hedge=双向(需账户开启双向持仓,下单带 positionSide);oneway=单向
|
||||
@@ -158,6 +157,8 @@ RISK_CONTROL_ENABLED=true
|
||||
RISK_COOLING_HOURS_MANUAL=4
|
||||
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
||||
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
||||
# 日亏损次数上限:平仓盈亏<0 计1次;达限当日冻结开仓;0=不启用
|
||||
RISK_DAILY_LOSS_LIMIT=2
|
||||
RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
||||
|
||||
# 资金与仓位刷新周期(秒)
|
||||
@@ -188,6 +189,8 @@ AUTO_TRANSFER_BJ_HOUR=8
|
||||
FORCE_CLOSE_BJ_HOUR=0
|
||||
# 是否启用强制清仓(默认关闭,true 才会在整点执行)
|
||||
FORCE_CLOSE_ENABLED=false
|
||||
# 强制清仓执行窗口(分钟,默认 5;该窗口内禁止开仓)
|
||||
FORCE_CLOSE_GRACE_MINUTES=5
|
||||
|
||||
# 推送与AI超时(秒)
|
||||
WECHAT_TIMEOUT_SECONDS=10
|
||||
|
||||
+144
-44
@@ -35,6 +35,11 @@ import sys
|
||||
if _REPO_ROOT not in sys.path:
|
||||
sys.path.insert(0, _REPO_ROOT)
|
||||
from lib.paths import common_static_dir
|
||||
from lib.exchange.api_credentials_lib import (
|
||||
is_exchange_auth_error,
|
||||
load_markets_public_fallback,
|
||||
normalize_api_credential,
|
||||
)
|
||||
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
|
||||
from lib.ai.ai_review_lib import (
|
||||
build_journal_ai_chart_path,
|
||||
@@ -257,6 +262,7 @@ from lib.common.history_window_lib import (
|
||||
utc_window_to_utc_sql_strings,
|
||||
)
|
||||
from lib.trade.trade_result_lib import (
|
||||
classify_exit_by_levels,
|
||||
count_winning_trades,
|
||||
filter_trade_records_excluding_miss,
|
||||
normalize_result_with_pnl,
|
||||
@@ -346,8 +352,8 @@ def _resolve_app_tz():
|
||||
|
||||
APP_TZ = _resolve_app_tz()
|
||||
LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true"
|
||||
BINANCE_API_KEY = (os.getenv("BINANCE_API_KEY") or "").strip()
|
||||
BINANCE_API_SECRET = (os.getenv("BINANCE_API_SECRET") or "").strip()
|
||||
BINANCE_API_KEY = normalize_api_credential(os.getenv("BINANCE_API_KEY"))
|
||||
BINANCE_API_SECRET = normalize_api_credential(os.getenv("BINANCE_API_SECRET"))
|
||||
BINANCE_MARGIN_MODE = (os.getenv("BINANCE_MARGIN_MODE") or "cross").strip().lower()
|
||||
# hedge=双向持仓(需 positionSide);oneway / single=单向持仓
|
||||
_raw_binance_pos = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower()
|
||||
@@ -411,7 +417,7 @@ _APP_STARTED_AT = time.time()
|
||||
_RECONCILE_FLAT_STREAK = {}
|
||||
KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m")
|
||||
FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98"))
|
||||
TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT")
|
||||
TRANSFER_CCY = (os.getenv("TRANSFER_CCY", "USDT") or "USDT").strip().upper() or "USDT"
|
||||
UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images"))
|
||||
ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true"
|
||||
ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()]
|
||||
@@ -489,6 +495,8 @@ if BINANCE_API_KEY and BINANCE_API_SECRET:
|
||||
exchange.apiKey = BINANCE_API_KEY
|
||||
exchange.secret = BINANCE_API_SECRET
|
||||
MARKETS_LOADED = False
|
||||
# 鉴权失败后停止私有 API(资金/持仓),避免坏钥反复请求;尤其 Gate 易封 IP
|
||||
EXCHANGE_AUTH_DISABLED_MSG = ""
|
||||
ACCOUNT_BALANCE_CACHE = {
|
||||
"updated_at": 0.0,
|
||||
"funding_usdt": None,
|
||||
@@ -1843,8 +1851,38 @@ def _compute_period_metrics(trades):
|
||||
}
|
||||
|
||||
|
||||
def _bounds_for_month_key(ym):
|
||||
"""ym: YYYY-MM → 该自然月首末日(北京日历)."""
|
||||
y, m = [int(x) for x in str(ym).split("-", 1)]
|
||||
start = f"{y:04d}-{m:02d}-01"
|
||||
if m == 12:
|
||||
end = f"{y:04d}-12-31"
|
||||
else:
|
||||
end = (datetime(y, m + 1, 1) - timedelta(days=1)).date().strftime("%Y-%m-%d")
|
||||
return start, end
|
||||
|
||||
|
||||
def _build_monthly_stats_rows(conn, all_tr, seg_key):
|
||||
"""按北京交易日所在自然月聚合;新月在前."""
|
||||
by_month = {}
|
||||
for p, t, td in all_tr:
|
||||
if not td or len(str(td)) < 7:
|
||||
continue
|
||||
mk = str(td)[:7]
|
||||
by_month.setdefault(mk, []).append((p, t, td))
|
||||
rows = []
|
||||
for mk in sorted(by_month.keys(), reverse=True):
|
||||
metrics = _compute_period_metrics(by_month[mk])
|
||||
ms, me = _bounds_for_month_key(mk)
|
||||
metrics["opens_count"] = _count_opens_for_segment(conn, ms, me, seg_key)
|
||||
metrics["range_label"] = f"{ms} ~ {me}"
|
||||
metrics["month_key"] = mk
|
||||
rows.append(metrics)
|
||||
return rows
|
||||
|
||||
|
||||
def compute_stats_bundle(conn, trading_day, now_dt=None):
|
||||
"""日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入."""
|
||||
"""日 / 周 / 月 / 全部 统计:平仓按北京时间交易日(默认 8:00 切日)计入."""
|
||||
now_dt = now_dt or app_now()
|
||||
pnls = _load_completed_trade_pnls(conn)
|
||||
total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0]
|
||||
@@ -1856,26 +1894,37 @@ def compute_stats_bundle(conn, trading_day, now_dt=None):
|
||||
day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day]
|
||||
week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end]
|
||||
month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end]
|
||||
all_tr = [(p, t, td) for p, t, td, _r in seg_rows if t]
|
||||
dm = _compute_period_metrics(day_tr)
|
||||
wm = _compute_period_metrics(week_tr)
|
||||
mm = _compute_period_metrics(month_tr)
|
||||
am = _compute_period_metrics(all_tr)
|
||||
dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key)
|
||||
wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key)
|
||||
mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key)
|
||||
am["opens_count"] = _count_opens_for_segment(conn, "1970-01-01", "9999-12-31", seg_key)
|
||||
dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)"
|
||||
wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)"
|
||||
mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)"
|
||||
return dm, wm, mm
|
||||
tds = [td for _, _, td in all_tr if td]
|
||||
if tds:
|
||||
am["range_label"] = f"全部历史 {min(tds)} ~ {max(tds)}(北京交易日)"
|
||||
else:
|
||||
am["range_label"] = "全部历史(暂无平仓)"
|
||||
am["monthly_rows"] = _build_monthly_stats_rows(conn, all_tr, seg_key)
|
||||
return dm, wm, mm, am
|
||||
|
||||
segments = []
|
||||
seg_defs = effective_stats_segment_defs(
|
||||
STATS_SEGMENT_DEFS, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED
|
||||
)
|
||||
for seg_key, seg_title, _meta in seg_defs:
|
||||
dm, wm, mm = slice_metrics(seg_key)
|
||||
segments.append({"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm})
|
||||
dm, wm, mm, am = slice_metrics(seg_key)
|
||||
segments.append(
|
||||
{"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm, "all": am}
|
||||
)
|
||||
|
||||
dm, wm, mm = slice_metrics("all")
|
||||
dm, wm, mm, am = slice_metrics("all")
|
||||
|
||||
return {
|
||||
"trading_day": trading_day,
|
||||
@@ -1883,6 +1932,7 @@ def compute_stats_bundle(conn, trading_day, now_dt=None):
|
||||
"day": dm,
|
||||
"week": wm,
|
||||
"month": mm,
|
||||
"all": am,
|
||||
"segments": segments,
|
||||
"stats_reset_hour": TRADING_DAY_RESET_HOUR,
|
||||
}
|
||||
@@ -2751,6 +2801,17 @@ def insert_trade_record(
|
||||
opened_at_ms=open_ts_ms,
|
||||
closed_at_ms=close_ts_ms,
|
||||
)
|
||||
try:
|
||||
from lib.trade.account_risk_lib import on_closed_trade_pnl
|
||||
|
||||
close_dt = parse_dt_for_trading_day(close_ts)
|
||||
on_closed_trade_pnl(
|
||||
conn,
|
||||
pnl_amount=pnl_amount,
|
||||
trading_day=get_trading_day(close_dt),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return tid
|
||||
|
||||
|
||||
@@ -2805,6 +2866,8 @@ def enrich_order_item(raw_item, current_capital):
|
||||
|
||||
|
||||
def ensure_exchange_live_ready():
|
||||
if EXCHANGE_AUTH_DISABLED_MSG:
|
||||
return False, EXCHANGE_AUTH_DISABLED_MSG
|
||||
if not LIVE_TRADING_ENABLED:
|
||||
return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)"
|
||||
if not (BINANCE_API_KEY and BINANCE_API_SECRET):
|
||||
@@ -2839,9 +2902,23 @@ def order_row_key_signal_type(row):
|
||||
|
||||
def exchange_private_api_configured():
|
||||
"""仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等."""
|
||||
if EXCHANGE_AUTH_DISABLED_MSG:
|
||||
return False
|
||||
return bool(BINANCE_API_KEY and BINANCE_API_SECRET)
|
||||
|
||||
|
||||
def _disable_private_api_after_auth_error(exc):
|
||||
global EXCHANGE_AUTH_DISABLED_MSG, BINANCE_API_KEY, BINANCE_API_SECRET
|
||||
from lib.exchange.api_credentials_lib import strip_ccxt_credentials
|
||||
|
||||
strip_ccxt_credentials(exchange)
|
||||
BINANCE_API_KEY = ""
|
||||
BINANCE_API_SECRET = ""
|
||||
EXCHANGE_AUTH_DISABLED_MSG = (
|
||||
f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启): {exc}"
|
||||
)
|
||||
|
||||
|
||||
def _float_balance_field(val):
|
||||
if val is None or val == "":
|
||||
return None
|
||||
@@ -3124,11 +3201,15 @@ def get_exchange_capitals(force=False):
|
||||
return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"]
|
||||
try:
|
||||
ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_binance_funding_usdt()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
if is_exchange_auth_error(e):
|
||||
_disable_private_api_after_auth_error(e)
|
||||
ACCOUNT_BALANCE_CACHE["funding_usdt"] = None
|
||||
try:
|
||||
ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_binance_swap_usdt_total()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
if is_exchange_auth_error(e):
|
||||
_disable_private_api_after_auth_error(e)
|
||||
# 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」
|
||||
ACCOUNT_BALANCE_CACHE["trading_usdt"] = None
|
||||
ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts
|
||||
@@ -3253,6 +3334,7 @@ def resolve_capital_base_for_key_open(conn, trading_day, live_capital):
|
||||
def precheck_risk(conn, symbol, direction):
|
||||
now = app_now()
|
||||
from lib.trade.account_risk_lib import account_risk_blocks_trading
|
||||
from lib.trade.force_close_lib import force_close_blocks_new_open
|
||||
|
||||
ok_risk, risk_reason = account_risk_blocks_trading(
|
||||
conn,
|
||||
@@ -3262,6 +3344,13 @@ def precheck_risk(conn, symbol, direction):
|
||||
)
|
||||
if not ok_risk:
|
||||
return False, risk_reason
|
||||
fc_block, fc_note = force_close_blocks_new_open(
|
||||
FORCE_CLOSE_ENABLED,
|
||||
FORCE_CLOSE_BJ_HOUR,
|
||||
now_ms=int(now.timestamp() * 1000),
|
||||
)
|
||||
if fc_block:
|
||||
return False, fc_note or "强制清仓窗口内暂不可开仓"
|
||||
if not trading_day_reset_allows_new_open(now):
|
||||
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
|
||||
from lib.trade.account_risk_lib import position_limit_reached
|
||||
@@ -3354,7 +3443,7 @@ def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price):
|
||||
|
||||
def get_contract_size(exchange_symbol):
|
||||
ensure_markets_loaded()
|
||||
market = exchange.market(exchange_symbol)
|
||||
market = exchange.market(normalize_exchange_symbol(exchange_symbol))
|
||||
return float(market.get("contractSize") or 1)
|
||||
|
||||
|
||||
@@ -3523,7 +3612,15 @@ def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None):
|
||||
def ensure_markets_loaded(force=False):
|
||||
global MARKETS_LOADED
|
||||
if force or not MARKETS_LOADED:
|
||||
exchange.load_markets(reload=force)
|
||||
try:
|
||||
exchange.load_markets(reload=force)
|
||||
except Exception as e:
|
||||
# 坏钥时立刻去掉签名再拉公开 markets,避免反复鉴权(尤其勿拖累同机 Gate)
|
||||
if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)):
|
||||
_disable_private_api_after_auth_error(e)
|
||||
load_markets_public_fallback(exchange, reload=True)
|
||||
else:
|
||||
raise
|
||||
MARKETS_LOADED = True
|
||||
|
||||
|
||||
@@ -4250,29 +4347,6 @@ def ms_to_app_local_str(ms):
|
||||
return app_now_str()
|
||||
|
||||
|
||||
def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price):
|
||||
"""根据成交价相对止盈/止损位归类;无法可靠归类时返回 None."""
|
||||
try:
|
||||
tp = float(take_profit)
|
||||
sl = float(stop_loss)
|
||||
ex = float(exit_price)
|
||||
trig = float(trigger_price)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12)
|
||||
if direction == "long":
|
||||
if ex >= tp - band:
|
||||
return "止盈"
|
||||
if ex <= sl + band:
|
||||
return "止损"
|
||||
else:
|
||||
if ex <= tp + band:
|
||||
return "止盈"
|
||||
if ex >= sl - band:
|
||||
return "止损"
|
||||
return None
|
||||
|
||||
|
||||
def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None):
|
||||
"""取开仓以来最近一笔减仓成交(与方向一致);失败返回 None."""
|
||||
if not (BINANCE_API_KEY and BINANCE_API_SECRET):
|
||||
@@ -6899,8 +6973,10 @@ def force_close_before_reset():
|
||||
if not FORCE_CLOSE_ENABLED:
|
||||
return
|
||||
now = app_now()
|
||||
# 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx)
|
||||
if now.hour != FORCE_CLOSE_BJ_HOUR:
|
||||
# 每天北京时间指定整点起 FORCE_CLOSE_GRACE_MINUTES 分钟内执行兜底清仓
|
||||
from lib.trade.force_close_lib import is_force_close_executing
|
||||
|
||||
if not is_force_close_executing(FORCE_CLOSE_BJ_HOUR, now_ms=int(now.timestamp() * 1000)):
|
||||
return
|
||||
conn = get_db()
|
||||
rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
|
||||
@@ -7272,14 +7348,22 @@ def render_main_page(page="trade", embed_mode=None):
|
||||
position_limit_count = count_position_limit_active_monitors(conn)
|
||||
opens_today = count_opens_for_trading_day(conn, trading_day)
|
||||
risk_status = hub_account_risk_status(conn)
|
||||
can_trade = can_trade_new_open(
|
||||
from lib.trade.open_trade_gate_lib import resolve_manual_open_gate
|
||||
|
||||
_open_gate = resolve_manual_open_gate(
|
||||
time_allows=trading_day_reset_allows_new_open(now),
|
||||
active_count=position_limit_count,
|
||||
max_active_positions=MAX_ACTIVE_POSITIONS,
|
||||
opens_today=opens_today,
|
||||
hard_limit=DAILY_OPEN_HARD_LIMIT,
|
||||
extra_blocks=not risk_status.get("can_trade", True),
|
||||
risk_status=risk_status,
|
||||
force_close_enabled=FORCE_CLOSE_ENABLED,
|
||||
force_close_bj_hour=FORCE_CLOSE_BJ_HOUR,
|
||||
now_ms=int(now.timestamp() * 1000),
|
||||
reset_hour=TRADING_DAY_RESET_HOUR,
|
||||
)
|
||||
can_trade = _open_gate["can_trade"]
|
||||
open_block_note = _open_gate["open_block_note"]
|
||||
key_rule_ctx = key_monitor_rule_template_context(
|
||||
kline_timeframe=KLINE_TIMEFRAME,
|
||||
key_breakout_amp_min_pct=KEY_BREAKOUT_AMP_MIN_PCT,
|
||||
@@ -7346,6 +7430,7 @@ def render_main_page(page="trade", embed_mode=None):
|
||||
price_refresh_seconds=PRICE_REFRESH_SECONDS,
|
||||
active_count=position_limit_count,
|
||||
can_trade=can_trade,
|
||||
open_block_note=open_block_note,
|
||||
opens_today=opens_today,
|
||||
daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT,
|
||||
daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD,
|
||||
@@ -7533,14 +7618,22 @@ def api_account_snapshot():
|
||||
|
||||
header_trade_stats = header_trade_stats_for_window(conn, _list_window_from_request(), APP_TZ)
|
||||
conn.close()
|
||||
can_trade = can_trade_new_open(
|
||||
from lib.trade.open_trade_gate_lib import resolve_manual_open_gate
|
||||
|
||||
_open_gate = resolve_manual_open_gate(
|
||||
time_allows=trading_day_reset_allows_new_open(now),
|
||||
active_count=position_limit_count,
|
||||
max_active_positions=MAX_ACTIVE_POSITIONS,
|
||||
opens_today=opens_today,
|
||||
hard_limit=DAILY_OPEN_HARD_LIMIT,
|
||||
extra_blocks=not risk_status.get("can_trade", True),
|
||||
risk_status=risk_status,
|
||||
force_close_enabled=FORCE_CLOSE_ENABLED,
|
||||
force_close_bj_hour=FORCE_CLOSE_BJ_HOUR,
|
||||
now_ms=int(now.timestamp() * 1000),
|
||||
reset_hour=TRADING_DAY_RESET_HOUR,
|
||||
)
|
||||
can_trade = _open_gate["can_trade"]
|
||||
open_block_note = _open_gate["open_block_note"]
|
||||
available_trading_usdt = get_available_trading_usdt()
|
||||
|
||||
unrealized_pnl = None
|
||||
@@ -7566,6 +7659,7 @@ def api_account_snapshot():
|
||||
"active_count": position_limit_count,
|
||||
"max_active_positions": MAX_ACTIVE_POSITIONS,
|
||||
"can_trade": can_trade,
|
||||
"open_block_note": open_block_note,
|
||||
"opens_today": opens_today,
|
||||
"daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT,
|
||||
"daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD,
|
||||
@@ -9545,7 +9639,9 @@ register_trade_records_api(
|
||||
def _dashboard_enrich_orders(items):
|
||||
from lib.instance.instance_dashboard_lib import enrich_order_items_with_marks
|
||||
|
||||
return enrich_order_items_with_marks(items, get_price=get_price)
|
||||
return enrich_order_items_with_marks(
|
||||
items, get_price=get_price, get_contract_size=get_contract_size
|
||||
)
|
||||
|
||||
|
||||
from lib.instance.instance_dashboard_register import register_instance_dashboard_routes
|
||||
@@ -9558,6 +9654,10 @@ register_instance_dashboard_routes(
|
||||
hedge_enabled=False,
|
||||
)
|
||||
|
||||
from lib.account_ledger.account_ledger_register import install_account_ledger
|
||||
|
||||
install_account_ledger(app, _REPO_ROOT, app_module=sys.modules[__name__], exchange_key="binance")
|
||||
|
||||
|
||||
@app.route("/api/journals")
|
||||
@login_required
|
||||
@@ -9859,7 +9959,7 @@ def manual_transfer():
|
||||
amount = float(request.form.get("amount", "0"))
|
||||
except Exception:
|
||||
flash("划转金额格式错误")
|
||||
return redirect("/settings")
|
||||
return redirect("/settings?settings_tab=transfer")
|
||||
from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip()
|
||||
to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip()
|
||||
ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account)
|
||||
@@ -9874,7 +9974,7 @@ def manual_transfer():
|
||||
flash(f"手动划转成功:{amount}U {from_account}->{to_account}")
|
||||
else:
|
||||
flash(f"手动划转失败:{msg}")
|
||||
return redirect("/settings")
|
||||
return redirect("/settings?settings_tab=transfer")
|
||||
|
||||
|
||||
def _journal_ai_chart_builder(row):
|
||||
|
||||
@@ -74,10 +74,9 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
|
||||
|
||||
# 是否开启 Gate 实盘下单(false=只做本地流程,true=真实下单)
|
||||
LIVE_TRADING_ENABLED=true
|
||||
# Gate API Key(实盘)
|
||||
GATE_API_KEY=REPLACE_WITH_GATE_API_KEY
|
||||
# Gate API Secret(实盘)
|
||||
GATE_API_SECRET=REPLACE_WITH_GATE_API_SECRET
|
||||
# Gate API(仅服务器 .env 配置;新机保持为空,填真钥后重启;错误密钥反复请求易导致 Gate 封 IP)
|
||||
GATE_API_KEY=
|
||||
GATE_API_SECRET=
|
||||
# 保证金模式:cross=全仓,isolated=逐仓
|
||||
GATE_TD_MODE=cross
|
||||
# 持仓筛选:hedge=双向持仓下按多空腿过滤;其它值(如 single)不按腿过滤
|
||||
@@ -160,6 +159,8 @@ RISK_CONTROL_ENABLED=true
|
||||
RISK_COOLING_HOURS_MANUAL=4
|
||||
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
||||
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
||||
# 日亏损次数上限:平仓盈亏<0 计1次;达限当日冻结开仓;0=不启用
|
||||
RISK_DAILY_LOSS_LIMIT=2
|
||||
RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
||||
|
||||
# 资金与仓位刷新周期(秒)
|
||||
@@ -190,6 +191,8 @@ AUTO_TRANSFER_BJ_HOUR=8
|
||||
FORCE_CLOSE_BJ_HOUR=0
|
||||
# 是否启用强制清仓(默认关闭,true 才会在整点执行)
|
||||
FORCE_CLOSE_ENABLED=false
|
||||
# 强制清仓执行窗口(分钟,默认 5;该窗口内禁止开仓)
|
||||
FORCE_CLOSE_GRACE_MINUTES=5
|
||||
|
||||
# 推送与AI超时(秒)
|
||||
WECHAT_TIMEOUT_SECONDS=10
|
||||
|
||||
+219
-72
@@ -35,6 +35,11 @@ import sys
|
||||
if _REPO_ROOT not in sys.path:
|
||||
sys.path.insert(0, _REPO_ROOT)
|
||||
from lib.paths import common_static_dir
|
||||
from lib.exchange.api_credentials_lib import (
|
||||
is_exchange_auth_error,
|
||||
load_markets_public_fallback,
|
||||
normalize_api_credential,
|
||||
)
|
||||
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
|
||||
from lib.ai.ai_review_lib import (
|
||||
build_journal_ai_chart_path,
|
||||
@@ -260,6 +265,7 @@ from lib.common.history_window_lib import (
|
||||
utc_window_to_utc_sql_strings,
|
||||
)
|
||||
from lib.trade.trade_result_lib import (
|
||||
classify_exit_by_levels,
|
||||
count_winning_trades,
|
||||
filter_trade_records_excluding_miss,
|
||||
normalize_result_with_pnl,
|
||||
@@ -345,8 +351,8 @@ def _resolve_app_tz():
|
||||
|
||||
APP_TZ = _resolve_app_tz()
|
||||
LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true"
|
||||
GATE_API_KEY = (os.getenv("GATE_API_KEY") or "").strip()
|
||||
GATE_API_SECRET = (os.getenv("GATE_API_SECRET") or "").strip()
|
||||
GATE_API_KEY = normalize_api_credential(os.getenv("GATE_API_KEY"))
|
||||
GATE_API_SECRET = normalize_api_credential(os.getenv("GATE_API_SECRET"))
|
||||
GATE_TD_MODE = (os.getenv("GATE_TD_MODE") or "cross").strip().lower()
|
||||
GATE_POS_MODE = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower()
|
||||
# 永续仓位止盈止损触发单:POST /futures/{settle}/price_orders,order_type=close-*-position(全平)
|
||||
@@ -404,7 +410,7 @@ KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m")
|
||||
_APP_STARTED_AT = time.time()
|
||||
_RECONCILE_FLAT_STREAK = {}
|
||||
FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98"))
|
||||
TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT")
|
||||
TRANSFER_CCY = (os.getenv("TRANSFER_CCY", "USDT") or "USDT").strip().upper() or "USDT"
|
||||
UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images"))
|
||||
ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true"
|
||||
ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()]
|
||||
@@ -477,6 +483,8 @@ if GATE_API_KEY and GATE_API_SECRET:
|
||||
exchange.apiKey = GATE_API_KEY
|
||||
exchange.secret = GATE_API_SECRET
|
||||
MARKETS_LOADED = False
|
||||
# 鉴权失败后停止私有 API,避免坏钥反复签名;Gate 尤其易封 IP
|
||||
EXCHANGE_AUTH_DISABLED_MSG = ""
|
||||
ACCOUNT_BALANCE_CACHE = {
|
||||
"updated_at": 0.0,
|
||||
"funding_usdt": None,
|
||||
@@ -1841,45 +1849,80 @@ def _compute_period_metrics(trades):
|
||||
}
|
||||
|
||||
|
||||
def _bounds_for_month_key(ym):
|
||||
"""ym: YYYY-MM → 该自然月首末日(北京日历)."""
|
||||
y, m = [int(x) for x in str(ym).split("-", 1)]
|
||||
start = f"{y:04d}-{m:02d}-01"
|
||||
if m == 12:
|
||||
end = f"{y:04d}-12-31"
|
||||
else:
|
||||
end = (datetime(y, m + 1, 1) - timedelta(days=1)).date().strftime("%Y-%m-%d")
|
||||
return start, end
|
||||
|
||||
|
||||
def _build_monthly_stats_rows(conn, all_tr, seg_key):
|
||||
"""按北京交易日所在自然月聚合;新月在前."""
|
||||
by_month = {}
|
||||
for p, t, td in all_tr:
|
||||
if not td or len(str(td)) < 7:
|
||||
continue
|
||||
mk = str(td)[:7]
|
||||
by_month.setdefault(mk, []).append((p, t, td))
|
||||
rows = []
|
||||
for mk in sorted(by_month.keys(), reverse=True):
|
||||
metrics = _compute_period_metrics(by_month[mk])
|
||||
ms, me = _bounds_for_month_key(mk)
|
||||
metrics["opens_count"] = _count_opens_for_segment(conn, ms, me, seg_key)
|
||||
metrics["range_label"] = f"{ms} ~ {me}"
|
||||
metrics["month_key"] = mk
|
||||
rows.append(metrics)
|
||||
return rows
|
||||
|
||||
|
||||
def compute_stats_bundle(conn, trading_day, now_dt=None):
|
||||
"""日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入."""
|
||||
"""日 / 周 / 月 / 全部 统计:平仓按北京时间交易日(默认 8:00 切日)计入."""
|
||||
now_dt = now_dt or app_now()
|
||||
pnls = _load_completed_trade_pnls(conn)
|
||||
total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0]
|
||||
w_start, w_end = _session_week_bounds(trading_day)
|
||||
m_start, m_end = _calendar_month_bounds(now_dt)
|
||||
|
||||
def in_week(tr):
|
||||
return tr[2] and w_start <= tr[2] <= w_end
|
||||
|
||||
def in_month(tr):
|
||||
return tr[2] and m_start <= tr[2] <= m_end
|
||||
|
||||
def slice_metrics(seg_key):
|
||||
seg_rows = [tr for tr in pnls if _pnl_row_matches_segment(tr[3], seg_key)]
|
||||
day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day]
|
||||
week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end]
|
||||
month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end]
|
||||
all_tr = [(p, t, td) for p, t, td, _r in seg_rows if t]
|
||||
dm = _compute_period_metrics(day_tr)
|
||||
wm = _compute_period_metrics(week_tr)
|
||||
mm = _compute_period_metrics(month_tr)
|
||||
am = _compute_period_metrics(all_tr)
|
||||
dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key)
|
||||
wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key)
|
||||
mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key)
|
||||
am["opens_count"] = _count_opens_for_segment(conn, "1970-01-01", "9999-12-31", seg_key)
|
||||
dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)"
|
||||
wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)"
|
||||
mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)"
|
||||
return dm, wm, mm
|
||||
tds = [td for _, _, td in all_tr if td]
|
||||
if tds:
|
||||
am["range_label"] = f"全部历史 {min(tds)} ~ {max(tds)}(北京交易日)"
|
||||
else:
|
||||
am["range_label"] = "全部历史(暂无平仓)"
|
||||
am["monthly_rows"] = _build_monthly_stats_rows(conn, all_tr, seg_key)
|
||||
return dm, wm, mm, am
|
||||
|
||||
segments = []
|
||||
seg_defs = effective_stats_segment_defs(
|
||||
STATS_SEGMENT_DEFS, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED
|
||||
)
|
||||
for seg_key, seg_title, _meta in seg_defs:
|
||||
dm, wm, mm = slice_metrics(seg_key)
|
||||
segments.append({"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm})
|
||||
dm, wm, mm, am = slice_metrics(seg_key)
|
||||
segments.append(
|
||||
{"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm, "all": am}
|
||||
)
|
||||
|
||||
dm, wm, mm = slice_metrics("all")
|
||||
dm, wm, mm, am = slice_metrics("all")
|
||||
|
||||
return {
|
||||
"trading_day": trading_day,
|
||||
@@ -1887,6 +1930,7 @@ def compute_stats_bundle(conn, trading_day, now_dt=None):
|
||||
"day": dm,
|
||||
"week": wm,
|
||||
"month": mm,
|
||||
"all": am,
|
||||
"segments": segments,
|
||||
"stats_reset_hour": TRADING_DAY_RESET_HOUR,
|
||||
}
|
||||
@@ -2445,6 +2489,17 @@ def insert_trade_record(
|
||||
sync_trade_records_from_exchange(conn, force=False)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from lib.trade.account_risk_lib import on_closed_trade_pnl
|
||||
|
||||
close_dt = parse_dt_for_trading_day(close_ts)
|
||||
on_closed_trade_pnl(
|
||||
conn,
|
||||
pnl_amount=pnl_amount,
|
||||
trading_day=get_trading_day(close_dt),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return tid
|
||||
|
||||
|
||||
@@ -2499,6 +2554,8 @@ def enrich_order_item(raw_item, current_capital):
|
||||
|
||||
|
||||
def ensure_exchange_live_ready():
|
||||
if EXCHANGE_AUTH_DISABLED_MSG:
|
||||
return False, EXCHANGE_AUTH_DISABLED_MSG
|
||||
if not LIVE_TRADING_ENABLED:
|
||||
return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)"
|
||||
if not (GATE_API_KEY and GATE_API_SECRET):
|
||||
@@ -2533,9 +2590,23 @@ def order_row_key_signal_type(row):
|
||||
|
||||
def exchange_private_api_configured():
|
||||
"""仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等."""
|
||||
if EXCHANGE_AUTH_DISABLED_MSG:
|
||||
return False
|
||||
return bool(GATE_API_KEY and GATE_API_SECRET)
|
||||
|
||||
|
||||
def _disable_private_api_after_auth_error(exc):
|
||||
global EXCHANGE_AUTH_DISABLED_MSG, GATE_API_KEY, GATE_API_SECRET
|
||||
from lib.exchange.api_credentials_lib import strip_ccxt_credentials
|
||||
|
||||
strip_ccxt_credentials(exchange)
|
||||
GATE_API_KEY = ""
|
||||
GATE_API_SECRET = ""
|
||||
EXCHANGE_AUTH_DISABLED_MSG = (
|
||||
f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启;勿反复试错以免 Gate 封 IP): {exc}"
|
||||
)
|
||||
|
||||
|
||||
def _extract_usdt_total(balance):
|
||||
usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {}
|
||||
total_map = balance.get("total", {}) if isinstance(balance, dict) else {}
|
||||
@@ -2793,11 +2864,15 @@ def get_exchange_capitals(force=False):
|
||||
return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"]
|
||||
try:
|
||||
ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_gate_funding_usdt()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
if is_exchange_auth_error(e):
|
||||
_disable_private_api_after_auth_error(e)
|
||||
ACCOUNT_BALANCE_CACHE["funding_usdt"] = None
|
||||
try:
|
||||
ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_usdt_by_types(["swap", "spot"])
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
if is_exchange_auth_error(e):
|
||||
_disable_private_api_after_auth_error(e)
|
||||
# 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」
|
||||
ACCOUNT_BALANCE_CACHE["trading_usdt"] = None
|
||||
ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts
|
||||
@@ -2923,6 +2998,7 @@ def resolve_capital_base_for_key_open(conn, trading_day, live_capital):
|
||||
def precheck_risk(conn, symbol, direction):
|
||||
now = app_now()
|
||||
from lib.trade.account_risk_lib import account_risk_blocks_trading
|
||||
from lib.trade.force_close_lib import force_close_blocks_new_open
|
||||
|
||||
ok_risk, risk_reason = account_risk_blocks_trading(
|
||||
conn,
|
||||
@@ -2932,6 +3008,13 @@ def precheck_risk(conn, symbol, direction):
|
||||
)
|
||||
if not ok_risk:
|
||||
return False, risk_reason
|
||||
fc_block, fc_note = force_close_blocks_new_open(
|
||||
FORCE_CLOSE_ENABLED,
|
||||
FORCE_CLOSE_BJ_HOUR,
|
||||
now_ms=int(now.timestamp() * 1000),
|
||||
)
|
||||
if fc_block:
|
||||
return False, fc_note or "强制清仓窗口内暂不可开仓"
|
||||
if not trading_day_reset_allows_new_open(now):
|
||||
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
|
||||
from lib.trade.account_risk_lib import position_limit_reached
|
||||
@@ -3024,7 +3107,7 @@ def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price):
|
||||
|
||||
def get_contract_size(exchange_symbol):
|
||||
ensure_markets_loaded()
|
||||
market = exchange.market(exchange_symbol)
|
||||
market = exchange.market(normalize_exchange_symbol(exchange_symbol))
|
||||
return float(market.get("contractSize") or 1)
|
||||
|
||||
|
||||
@@ -3289,7 +3372,15 @@ def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None):
|
||||
def ensure_markets_loaded(force=False):
|
||||
global MARKETS_LOADED
|
||||
if force or not MARKETS_LOADED:
|
||||
exchange.load_markets(reload=force)
|
||||
try:
|
||||
exchange.load_markets(reload=force)
|
||||
except Exception as e:
|
||||
# Gate 对无效签名/坏钥敏感,失败后立即改公开 markets,勿反复带钥请求
|
||||
if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)):
|
||||
_disable_private_api_after_auth_error(e)
|
||||
load_markets_public_fallback(exchange, reload=True)
|
||||
else:
|
||||
raise
|
||||
MARKETS_LOADED = True
|
||||
|
||||
|
||||
@@ -3705,46 +3796,99 @@ def _coerce_float(*values):
|
||||
return None
|
||||
|
||||
|
||||
def _gate_is_cross_margin(position, info):
|
||||
mode = str(position.get("marginMode") or info.get("pos_margin_mode") or "").lower()
|
||||
if "cross" in mode:
|
||||
return True
|
||||
lev = _coerce_float(info.get("leverage"), position.get("leverage"))
|
||||
return lev is not None and lev == 0
|
||||
|
||||
|
||||
def _gate_effective_leverage(position, info, order_leverage=None):
|
||||
lev = _coerce_float(position.get("leverage"), info.get("leverage"))
|
||||
if lev is not None and lev > 0:
|
||||
return lev
|
||||
cross_lev = _coerce_float(info.get("cross_leverage_limit"))
|
||||
if cross_lev is not None and cross_lev > 0:
|
||||
return cross_lev
|
||||
if order_leverage is not None:
|
||||
try:
|
||||
ol = float(order_leverage)
|
||||
if ol > 0:
|
||||
return ol
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _gate_estimated_initial_margin(notional, leverage):
|
||||
"""Gate App 口径:仓位价值/杠杆 + 预估平仓 taker 费(0.075%)."""
|
||||
if notional is None or notional <= 0 or leverage is None or leverage <= 0:
|
||||
return None
|
||||
return notional / float(leverage) + notional * 0.00075
|
||||
|
||||
|
||||
def _gate_margin_matches_unrealized(margin, unrealized):
|
||||
if margin is None or unrealized is None:
|
||||
return False
|
||||
return abs(float(margin) - float(unrealized)) <= max(0.02, abs(float(unrealized)) * 0.05)
|
||||
|
||||
|
||||
def _gate_resolve_initial_margin(position, info, *, notional, unrealized, order_leverage=None):
|
||||
"""全仓下 API margin 偶发等于 unrealised_pnl;优先 value/杠杆,逐仓仍信 API."""
|
||||
api_margin = _coerce_float(
|
||||
info.get("initial_margin"),
|
||||
position.get("initialMargin"),
|
||||
position.get("collateral"),
|
||||
position.get("margin"),
|
||||
info.get("margin"),
|
||||
info.get("iso_margin"),
|
||||
info.get("position_margin"),
|
||||
info.get("initialMargin"),
|
||||
)
|
||||
eff_lev = _gate_effective_leverage(position, info, order_leverage)
|
||||
estimated = _gate_estimated_initial_margin(notional, eff_lev) if eff_lev else None
|
||||
if _gate_is_cross_margin(position, info):
|
||||
if estimated and estimated > 0:
|
||||
if (
|
||||
api_margin is None
|
||||
or api_margin <= 0
|
||||
or _gate_margin_matches_unrealized(api_margin, unrealized)
|
||||
or api_margin < estimated * 0.6
|
||||
):
|
||||
return estimated
|
||||
if api_margin is not None and api_margin > 0 and not _gate_margin_matches_unrealized(
|
||||
api_margin, unrealized
|
||||
):
|
||||
return api_margin
|
||||
return estimated
|
||||
if api_margin is not None and api_margin > 0:
|
||||
return api_margin
|
||||
return estimated
|
||||
|
||||
|
||||
def parse_ccxt_position_metrics(position, order_leverage=None):
|
||||
"""
|
||||
从 ccxt 统一持仓结构解析保证金/名义/未实现盈亏(Gate 等所字段略有差异,做多键兜底).
|
||||
与 App「仓位保证金」对齐时优先用 initialMargin;缺失时再尝试 info 内字段.
|
||||
全仓优先 value/cross_leverage_limit(+平仓费);API margin 若≈unrealised_pnl 则弃用.
|
||||
"""
|
||||
if not position:
|
||||
return None
|
||||
p = position
|
||||
info = p.get("info", {}) or {}
|
||||
# Gate 全仓:ccxt 的 initialMargin 常为空;collateral 来自 API 的 margin,与 App「保证金」一致
|
||||
initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin"))
|
||||
if initial is None or initial <= 0:
|
||||
initial = _coerce_float(
|
||||
info.get("margin"),
|
||||
info.get("cross_margin"),
|
||||
info.get("iso_margin"),
|
||||
info.get("initial_margin"),
|
||||
info.get("position_margin"),
|
||||
info.get("initialMargin"),
|
||||
)
|
||||
notional = _coerce_float(p.get("notional"), p.get("notionalValue"))
|
||||
if notional is None or notional <= 0:
|
||||
notional = _coerce_float(info.get("value"))
|
||||
if notional is not None:
|
||||
notional = abs(notional)
|
||||
# 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近)
|
||||
if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage:
|
||||
try:
|
||||
lev = float(order_leverage)
|
||||
if lev > 0:
|
||||
approx = notional / lev
|
||||
if approx > 0:
|
||||
initial = approx
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
unrealized = _coerce_float(
|
||||
p.get("unrealizedPnl"),
|
||||
info.get("unrealised_pnl"),
|
||||
info.get("unrealized_pnl"),
|
||||
)
|
||||
initial = _gate_resolve_initial_margin(
|
||||
p, info, notional=notional, unrealized=unrealized, order_leverage=order_leverage
|
||||
)
|
||||
mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("mark_price"), info.get("markPrice"))
|
||||
out = {}
|
||||
if initial is not None and initial > 0:
|
||||
@@ -3881,29 +4025,6 @@ def ms_to_app_local_str(ms):
|
||||
return app_now_str()
|
||||
|
||||
|
||||
def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price):
|
||||
"""根据成交价相对止盈/止损位归类;无法可靠归类时返回 None."""
|
||||
try:
|
||||
tp = float(take_profit)
|
||||
sl = float(stop_loss)
|
||||
ex = float(exit_price)
|
||||
trig = float(trigger_price)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12)
|
||||
if direction == "long":
|
||||
if ex >= tp - band:
|
||||
return "止盈"
|
||||
if ex <= sl + band:
|
||||
return "止损"
|
||||
else:
|
||||
if ex <= tp + band:
|
||||
return "止盈"
|
||||
if ex >= sl - band:
|
||||
return "止损"
|
||||
return None
|
||||
|
||||
|
||||
def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None):
|
||||
"""取开仓以来最近一笔减仓成交(与方向一致);失败返回 None."""
|
||||
if not (GATE_API_KEY and GATE_API_SECRET):
|
||||
@@ -6528,8 +6649,10 @@ def force_close_before_reset():
|
||||
if not FORCE_CLOSE_ENABLED:
|
||||
return
|
||||
now = app_now()
|
||||
# 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx)
|
||||
if now.hour != FORCE_CLOSE_BJ_HOUR:
|
||||
# 每天北京时间指定整点起 FORCE_CLOSE_GRACE_MINUTES 分钟内执行兜底清仓
|
||||
from lib.trade.force_close_lib import is_force_close_executing
|
||||
|
||||
if not is_force_close_executing(FORCE_CLOSE_BJ_HOUR, now_ms=int(now.timestamp() * 1000)):
|
||||
return
|
||||
conn = get_db()
|
||||
rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
|
||||
@@ -7050,14 +7173,22 @@ def render_main_page(page="trade", embed_mode=None):
|
||||
position_limit_count = count_position_limit_active_monitors(conn)
|
||||
opens_today = count_opens_for_trading_day(conn, trading_day)
|
||||
risk_status = hub_account_risk_status(conn)
|
||||
can_trade = can_trade_new_open(
|
||||
from lib.trade.open_trade_gate_lib import resolve_manual_open_gate
|
||||
|
||||
_open_gate = resolve_manual_open_gate(
|
||||
time_allows=trading_day_reset_allows_new_open(now),
|
||||
active_count=position_limit_count,
|
||||
max_active_positions=MAX_ACTIVE_POSITIONS,
|
||||
opens_today=opens_today,
|
||||
hard_limit=DAILY_OPEN_HARD_LIMIT,
|
||||
extra_blocks=not risk_status.get("can_trade", True),
|
||||
risk_status=risk_status,
|
||||
force_close_enabled=FORCE_CLOSE_ENABLED,
|
||||
force_close_bj_hour=FORCE_CLOSE_BJ_HOUR,
|
||||
now_ms=int(now.timestamp() * 1000),
|
||||
reset_hour=TRADING_DAY_RESET_HOUR,
|
||||
)
|
||||
can_trade = _open_gate["can_trade"]
|
||||
open_block_note = _open_gate["open_block_note"]
|
||||
key_rule_ctx = key_monitor_rule_template_context(
|
||||
kline_timeframe=KLINE_TIMEFRAME,
|
||||
key_breakout_amp_min_pct=KEY_BREAKOUT_AMP_MIN_PCT,
|
||||
@@ -7121,6 +7252,7 @@ def render_main_page(page="trade", embed_mode=None):
|
||||
price_refresh_seconds=PRICE_REFRESH_SECONDS,
|
||||
active_count=position_limit_count,
|
||||
can_trade=can_trade,
|
||||
open_block_note=open_block_note,
|
||||
opens_today=opens_today,
|
||||
daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT,
|
||||
daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD,
|
||||
@@ -7329,14 +7461,22 @@ def api_account_snapshot():
|
||||
|
||||
header_trade_stats = header_trade_stats_for_window(conn, _list_window_from_request(), APP_TZ)
|
||||
conn.close()
|
||||
can_trade = can_trade_new_open(
|
||||
from lib.trade.open_trade_gate_lib import resolve_manual_open_gate
|
||||
|
||||
_open_gate = resolve_manual_open_gate(
|
||||
time_allows=trading_day_reset_allows_new_open(now),
|
||||
active_count=position_limit_count,
|
||||
max_active_positions=MAX_ACTIVE_POSITIONS,
|
||||
opens_today=opens_today,
|
||||
hard_limit=DAILY_OPEN_HARD_LIMIT,
|
||||
extra_blocks=not risk_status.get("can_trade", True),
|
||||
risk_status=risk_status,
|
||||
force_close_enabled=FORCE_CLOSE_ENABLED,
|
||||
force_close_bj_hour=FORCE_CLOSE_BJ_HOUR,
|
||||
now_ms=int(now.timestamp() * 1000),
|
||||
reset_hour=TRADING_DAY_RESET_HOUR,
|
||||
)
|
||||
can_trade = _open_gate["can_trade"]
|
||||
open_block_note = _open_gate["open_block_note"]
|
||||
available_trading_usdt = get_available_trading_usdt()
|
||||
|
||||
unrealized_pnl = None
|
||||
@@ -7365,6 +7505,7 @@ def api_account_snapshot():
|
||||
"active_count": position_limit_count,
|
||||
"max_active_positions": MAX_ACTIVE_POSITIONS,
|
||||
"can_trade": can_trade,
|
||||
"open_block_note": open_block_note,
|
||||
"opens_today": opens_today,
|
||||
"daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT,
|
||||
"daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD,
|
||||
@@ -9393,7 +9534,9 @@ register_trade_records_api(
|
||||
def _dashboard_enrich_orders(items):
|
||||
from lib.instance.instance_dashboard_lib import enrich_order_items_with_marks
|
||||
|
||||
return enrich_order_items_with_marks(items, get_price=get_price)
|
||||
return enrich_order_items_with_marks(
|
||||
items, get_price=get_price, get_contract_size=get_contract_size
|
||||
)
|
||||
|
||||
|
||||
from lib.instance.instance_dashboard_register import register_instance_dashboard_routes
|
||||
@@ -9406,6 +9549,10 @@ register_instance_dashboard_routes(
|
||||
hedge_enabled=False,
|
||||
)
|
||||
|
||||
from lib.account_ledger.account_ledger_register import install_account_ledger
|
||||
|
||||
install_account_ledger(app, _REPO_ROOT, app_module=sys.modules[__name__], exchange_key="gate")
|
||||
|
||||
|
||||
@app.route("/api/journals")
|
||||
@login_required
|
||||
@@ -9716,7 +9863,7 @@ def manual_transfer():
|
||||
amount = float(request.form.get("amount", "0"))
|
||||
except Exception:
|
||||
flash("划转金额格式错误")
|
||||
return redirect("/settings")
|
||||
return redirect("/settings?settings_tab=transfer")
|
||||
from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip()
|
||||
to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip()
|
||||
ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account)
|
||||
@@ -9731,7 +9878,7 @@ def manual_transfer():
|
||||
flash(f"手动划转成功:{amount}U {from_account}->{to_account}")
|
||||
else:
|
||||
flash(f"手动划转失败:{msg}")
|
||||
return redirect("/settings")
|
||||
return redirect("/settings?settings_tab=transfer")
|
||||
|
||||
|
||||
def _journal_ai_chart_builder(row):
|
||||
|
||||
@@ -79,12 +79,14 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
|
||||
|
||||
# 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单)
|
||||
LIVE_TRADING_ENABLED=true
|
||||
# OKX API Key(实盘)
|
||||
OKX_API_KEY=REPLACE_WITH_OKX_API_KEY
|
||||
# OKX API Secret(实盘)
|
||||
OKX_API_SECRET=REPLACE_WITH_OKX_API_SECRET
|
||||
# OKX API Passphrase(实盘)
|
||||
OKX_API_PASSPHRASE=REPLACE_WITH_OKX_API_PASSPHRASE
|
||||
# =============================================================================
|
||||
# OKX 账户 API(永续+期权共用;仅服务器 .env 手改,前端不展示)
|
||||
# 新机保持为空;填真钥后 pm2 restart --update-env(含子代理)
|
||||
# 旧键 OKX_OPTIONS_API_* 已废弃:若 OKX_API_* 为空,启动时会从 OPTIONS 键回填
|
||||
# =============================================================================
|
||||
OKX_API_KEY=
|
||||
OKX_API_SECRET=
|
||||
OKX_API_PASSPHRASE=
|
||||
# 保证金模式:cross=全仓,isolated=逐仓
|
||||
OKX_TD_MODE=cross
|
||||
# 持仓模式:hedge=双向持仓,net=单向净持仓
|
||||
@@ -99,40 +101,70 @@ OKX_POSITION_INST_TYPE=SWAP
|
||||
EXCHANGE_DISPLAY_NAME=OKX
|
||||
# 企业微信推送里展示的账户备注
|
||||
# OKX_ACCOUNT_LABEL=
|
||||
# 顶栏是否显示 USDT 资金/交易账户(热更);false 时总资金仅计期权 USDC 侧
|
||||
OKX_SHOW_PERP_FUNDS=true
|
||||
|
||||
# =============================================================================
|
||||
# 期权(主账户 API,与永续子账户 OKX_API_* 分离;修改后须重启 PM2)
|
||||
# 期权模块(与上方 OKX_API_* 同源;修改启用开关后须重启 PM2)
|
||||
# 详见 docs/期权方案.md 与 docs/期权用法.md
|
||||
# =============================================================================
|
||||
OKX_OPTIONS_ENABLED=false
|
||||
OKX_OPTIONS_API_KEY=
|
||||
OKX_OPTIONS_API_SECRET=
|
||||
OKX_OPTIONS_API_PASSPHRASE=
|
||||
OKX_OPTIONS_ACCOUNT_LABEL=主账户·期权
|
||||
# 以下 OKX_OPTIONS_API_* 已废弃,请勿再配置(仅兼容旧部署回填)
|
||||
# OKX_OPTIONS_API_KEY=
|
||||
# OKX_OPTIONS_API_SECRET=
|
||||
# OKX_OPTIONS_API_PASSPHRASE=
|
||||
OKX_OPTIONS_ACCOUNT_LABEL=账户·期权
|
||||
# 单笔期权本位: coin(默认,币本位+USDT买币桥) | usdc(权利金USDC;对冲仍仅USDC)
|
||||
OKX_OPTIONS_MARGIN_MODE=coin
|
||||
OKX_OPTIONS_TRADE_BUDGET_USDC=10
|
||||
OKX_OPTIONS_BUDGET_BUFFER=0.95
|
||||
# 币本位:按交易户USDT×缓冲复利;上限开关默认关(靠人工转走)
|
||||
OKX_OPTIONS_COIN_COMPOUND=true
|
||||
OKX_OPTIONS_COIN_BUDGET_USDT=10
|
||||
OKX_OPTIONS_COIN_MAX_USDT_ENABLED=false
|
||||
OKX_OPTIONS_COIN_MAX_USDT=50
|
||||
# 现货买入相对权利金缓冲:1.10=多买10%;也可写 0.10。按最大可开张数×权利金×缓冲买币,不全额兑换
|
||||
OKX_OPTIONS_COIN_SPOT_BUY_BUFFER=1.10
|
||||
# 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算
|
||||
OKX_OPTIONS_COMPOUND_FULL_ENABLED=true
|
||||
OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED=false
|
||||
OKX_OPTIONS_COMPOUND_FULL_CAP_USDC=300
|
||||
# 交易模式三选一(热更):options=单独期权 / perp_options=永期对冲 / options_options=期期对冲
|
||||
# 选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权,仓位按「对冲组数上限」
|
||||
OKX_TRADE_MODE=options
|
||||
# 仅单独期权模式:期权同时持仓上限(笔);0=不限制;同合约加仓不占新笔数;热更
|
||||
OKX_OPTIONS_MAX_ACTIVE_POSITIONS=0
|
||||
OKX_OPTIONS_DEFAULT_UNDERLY=ETH
|
||||
# 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部
|
||||
OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED=true
|
||||
OKX_OPTIONS_MAX_DTE_DAYS=2
|
||||
OKX_OPTIONS_CHAIN_MAX_DTE_DAYS=14
|
||||
OKX_SUB_ACCOUNT_NAME=
|
||||
OKX_OPTIONS_ITM_MAX_DIST_USD=30
|
||||
OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0
|
||||
OKX_OPTIONS_POLL_SECONDS=15
|
||||
OKX_OPTIONS_TD_MODE=isolated
|
||||
OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
|
||||
# 目标平仓门控(目标触达后自动平才校验;比较口径均为 USDT 估值)
|
||||
OKX_OPTIONS_CLOSE_GATE_MODE=premium
|
||||
# 全局倍数可选;留空则按本位用下方 COIN/USDC
|
||||
OKX_OPTIONS_CLOSE_RECYCLE_MULT=
|
||||
OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN=1.05
|
||||
OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC=2
|
||||
OKX_OPTIONS_CLOSE_NET_PNL_MIN_U=0
|
||||
OKX_OPTIONS_CLOSE_HOLD_SECONDS=120
|
||||
# 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败
|
||||
OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12
|
||||
|
||||
# =============================================================================
|
||||
# 对冲计划(仅 OKX;前端 env「对冲计划」;详见 docs/对冲计划开发方案.md)
|
||||
# 对冲计划(仅 OKX;由 OKX_TRADE_MODE 控制是否启用;详见 docs/对冲计划开发方案.md)
|
||||
# =============================================================================
|
||||
# 以下三项已由 OKX_TRADE_MODE 取代,保留兼容旧部署(未配置 TRADE_MODE 时仍可读)
|
||||
HEDGE_PLAN_ENABLED=false
|
||||
# 页面 Tab 显示(默认全部显示,可单独关闭;不影响已有进行中/历史计划)
|
||||
HEDGE_PLAN_SHOW_PERP_OPTIONS=true
|
||||
HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=true
|
||||
HEDGE_PLAN_LIVE_ORDER=false
|
||||
# 永期子模式:true=以期权为主;false=保险模式(页面标题前标识,不可页内切换)
|
||||
HEDGE_PLAN_OPTION_PRIMARY=true
|
||||
HEDGE_PLAN_OPEN_ORDER=options_first
|
||||
HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true
|
||||
HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false
|
||||
@@ -147,6 +179,7 @@ HEDGE_PLAN_OO_BIAS_RATIO=0.7
|
||||
HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE=true
|
||||
# 半腿失败改手动补开(默认 true):不自动平已成腿,计划挂 partial,页面补开;开启时下方自动平强制无效
|
||||
HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL=true
|
||||
# 对冲组数上限(默认 1;opening/active/partial 计入);仅永期/期期模式生效;热更
|
||||
MAX_ACTIVE_HEDGE_PLANS=1
|
||||
HEDGE_PLAN_MONITOR_POLL_SECONDS=15
|
||||
# 半腿失败自动平期权;若 MANUAL_COMPLETE_ON_PARTIAL=true 则运行时强制无效(建议一并写成 false)
|
||||
@@ -219,6 +252,8 @@ RISK_CONTROL_ENABLED=true
|
||||
RISK_COOLING_HOURS_MANUAL=4
|
||||
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
||||
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
||||
# 日亏损次数上限:平仓盈亏<0 计1次;达限当日冻结开仓;0=不启用
|
||||
RISK_DAILY_LOSS_LIMIT=2
|
||||
RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
||||
|
||||
# 资金与仓位刷新周期(秒)
|
||||
@@ -251,6 +286,8 @@ AUTO_TRANSFER_BJ_HOUR=8
|
||||
FORCE_CLOSE_BJ_HOUR=0
|
||||
# 是否启用强制清仓(默认关闭,true 才会在整点执行)
|
||||
FORCE_CLOSE_ENABLED=false
|
||||
# 强制清仓执行窗口(分钟,默认 5;该窗口内禁止开仓)
|
||||
FORCE_CLOSE_GRACE_MINUTES=5
|
||||
|
||||
# 推送与AI超时(秒)
|
||||
WECHAT_TIMEOUT_SECONDS=10
|
||||
|
||||
+281
-82
@@ -35,6 +35,11 @@ import sys
|
||||
if _REPO_ROOT not in sys.path:
|
||||
sys.path.insert(0, _REPO_ROOT)
|
||||
from lib.paths import common_static_dir
|
||||
from lib.exchange.api_credentials_lib import (
|
||||
is_exchange_auth_error,
|
||||
load_markets_public_fallback,
|
||||
normalize_api_credential,
|
||||
)
|
||||
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
|
||||
from lib.ai.ai_review_lib import (
|
||||
build_journal_ai_chart_path,
|
||||
@@ -256,6 +261,7 @@ from lib.common.history_window_lib import (
|
||||
utc_window_to_utc_sql_strings,
|
||||
)
|
||||
from lib.trade.trade_result_lib import (
|
||||
classify_exit_by_levels,
|
||||
count_winning_trades,
|
||||
filter_trade_records_excluding_miss,
|
||||
normalize_result_with_pnl,
|
||||
@@ -342,16 +348,29 @@ def _resolve_app_tz():
|
||||
|
||||
APP_TZ = _resolve_app_tz()
|
||||
LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true"
|
||||
OKX_API_KEY = os.getenv("OKX_API_KEY", "")
|
||||
OKX_API_SECRET = os.getenv("OKX_API_SECRET", "")
|
||||
OKX_API_PASSPHRASE = os.getenv("OKX_API_PASSPHRASE", "")
|
||||
|
||||
|
||||
def _promote_legacy_options_api_keys() -> None:
|
||||
"""1B: OKX_API_* 为空时,用废弃的 OKX_OPTIONS_API_* 回填到进程环境."""
|
||||
if (os.getenv("OKX_API_KEY") or "").strip():
|
||||
return
|
||||
legacy_key = (os.getenv("OKX_OPTIONS_API_KEY") or "").strip()
|
||||
legacy_secret = (os.getenv("OKX_OPTIONS_API_SECRET") or "").strip()
|
||||
legacy_pass = (os.getenv("OKX_OPTIONS_API_PASSPHRASE") or "").strip()
|
||||
if not (legacy_key and legacy_secret and legacy_pass):
|
||||
return
|
||||
os.environ["OKX_API_KEY"] = legacy_key
|
||||
os.environ["OKX_API_SECRET"] = legacy_secret
|
||||
os.environ["OKX_API_PASSPHRASE"] = legacy_pass
|
||||
|
||||
|
||||
_promote_legacy_options_api_keys()
|
||||
OKX_API_KEY = normalize_api_credential(os.getenv("OKX_API_KEY"))
|
||||
OKX_API_SECRET = normalize_api_credential(os.getenv("OKX_API_SECRET"))
|
||||
OKX_API_PASSPHRASE = normalize_api_credential(os.getenv("OKX_API_PASSPHRASE"))
|
||||
OKX_OPTIONS_ENABLED = os.getenv("OKX_OPTIONS_ENABLED", "false").lower() in ("1", "true", "yes", "on")
|
||||
OKX_OPTIONS_API_KEY = os.getenv("OKX_OPTIONS_API_KEY", "")
|
||||
OKX_OPTIONS_API_SECRET = os.getenv("OKX_OPTIONS_API_SECRET", "")
|
||||
OKX_OPTIONS_API_PASSPHRASE = os.getenv("OKX_OPTIONS_API_PASSPHRASE", "")
|
||||
OKX_OPTIONS_TRADE_BUDGET_USDC = float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC", "10"))
|
||||
OKX_OPTIONS_DEFAULT_UNDERLY = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper()
|
||||
OKX_SUB_ACCOUNT_NAME = (os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip()
|
||||
OKX_TD_MODE = os.getenv("OKX_TD_MODE", "cross")
|
||||
OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge")
|
||||
EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "OKX").strip() or "OKX"
|
||||
@@ -384,7 +403,7 @@ BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC = max(
|
||||
_BREAKEVEN_LAST_EX_SYNC: dict[int, float] = {}
|
||||
KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m")
|
||||
FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98"))
|
||||
TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT")
|
||||
TRANSFER_CCY = (os.getenv("TRANSFER_CCY", "USDT") or "USDT").strip().upper() or "USDT"
|
||||
OKX_POSITION_INST_TYPE = os.getenv("OKX_POSITION_INST_TYPE", "SWAP")
|
||||
EXCHANGE_POSITION_SYNC_FROM_BJ = (os.getenv("EXCHANGE_POSITION_SYNC_FROM_BJ") or "").strip()
|
||||
EXCHANGE_POSITION_HISTORY_LIMIT = max(50, min(1000, int(os.getenv("EXCHANGE_POSITION_HISTORY_LIMIT", "200"))))
|
||||
@@ -466,7 +485,7 @@ os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
os.makedirs(ORDER_CHART_DIR, exist_ok=True)
|
||||
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
|
||||
|
||||
# 换成 OKX 永续
|
||||
# 同一套 OKX_API_*:swap 客户端跑永续,option 客户端跑期权(身份相同,defaultType 不同)
|
||||
exchange = ccxt.okx({
|
||||
"enableRateLimit": True,
|
||||
"options": {"defaultType": "swap"}, # OKX 用 swap 表示永续
|
||||
@@ -486,12 +505,13 @@ exchange_options = ccxt.okx(
|
||||
)
|
||||
if OKX_CCXT_PROXIES:
|
||||
exchange_options.proxies = OKX_CCXT_PROXIES
|
||||
if OKX_OPTIONS_API_KEY and OKX_OPTIONS_API_SECRET and OKX_OPTIONS_API_PASSPHRASE:
|
||||
exchange_options.apiKey = OKX_OPTIONS_API_KEY
|
||||
exchange_options.secret = OKX_OPTIONS_API_SECRET
|
||||
exchange_options.password = OKX_OPTIONS_API_PASSPHRASE
|
||||
if OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE:
|
||||
exchange_options.apiKey = OKX_API_KEY
|
||||
exchange_options.secret = OKX_API_SECRET
|
||||
exchange_options.password = OKX_API_PASSPHRASE
|
||||
|
||||
MARKETS_LOADED = False
|
||||
EXCHANGE_AUTH_DISABLED_MSG = ""
|
||||
ACCOUNT_BALANCE_CACHE = {
|
||||
"updated_at": 0.0,
|
||||
"funding_usdt": None,
|
||||
@@ -1842,8 +1862,38 @@ def _compute_period_metrics(trades):
|
||||
}
|
||||
|
||||
|
||||
def _bounds_for_month_key(ym):
|
||||
"""ym: YYYY-MM → 该自然月首末日(北京日历)."""
|
||||
y, m = [int(x) for x in str(ym).split("-", 1)]
|
||||
start = f"{y:04d}-{m:02d}-01"
|
||||
if m == 12:
|
||||
end = f"{y:04d}-12-31"
|
||||
else:
|
||||
end = (datetime(y, m + 1, 1) - timedelta(days=1)).date().strftime("%Y-%m-%d")
|
||||
return start, end
|
||||
|
||||
|
||||
def _build_monthly_stats_rows(conn, all_tr, seg_key):
|
||||
"""按北京交易日所在自然月聚合;新月在前."""
|
||||
by_month = {}
|
||||
for p, t, td in all_tr:
|
||||
if not td or len(str(td)) < 7:
|
||||
continue
|
||||
mk = str(td)[:7]
|
||||
by_month.setdefault(mk, []).append((p, t, td))
|
||||
rows = []
|
||||
for mk in sorted(by_month.keys(), reverse=True):
|
||||
metrics = _compute_period_metrics(by_month[mk])
|
||||
ms, me = _bounds_for_month_key(mk)
|
||||
metrics["opens_count"] = _count_opens_for_segment(conn, ms, me, seg_key)
|
||||
metrics["range_label"] = f"{ms} ~ {me}"
|
||||
metrics["month_key"] = mk
|
||||
rows.append(metrics)
|
||||
return rows
|
||||
|
||||
|
||||
def compute_stats_bundle(conn, trading_day, now_dt=None):
|
||||
"""日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入."""
|
||||
"""日 / 周 / 月 / 全部 统计:平仓按北京时间交易日(默认 8:00 切日)计入."""
|
||||
now_dt = now_dt or app_now()
|
||||
pnls = _load_completed_trade_pnls(conn)
|
||||
total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0]
|
||||
@@ -1855,26 +1905,37 @@ def compute_stats_bundle(conn, trading_day, now_dt=None):
|
||||
day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day]
|
||||
week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end]
|
||||
month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end]
|
||||
all_tr = [(p, t, td) for p, t, td, _r in seg_rows if t]
|
||||
dm = _compute_period_metrics(day_tr)
|
||||
wm = _compute_period_metrics(week_tr)
|
||||
mm = _compute_period_metrics(month_tr)
|
||||
am = _compute_period_metrics(all_tr)
|
||||
dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key)
|
||||
wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key)
|
||||
mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key)
|
||||
am["opens_count"] = _count_opens_for_segment(conn, "1970-01-01", "9999-12-31", seg_key)
|
||||
dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)"
|
||||
wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)"
|
||||
mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)"
|
||||
return dm, wm, mm
|
||||
tds = [td for _, _, td in all_tr if td]
|
||||
if tds:
|
||||
am["range_label"] = f"全部历史 {min(tds)} ~ {max(tds)}(北京交易日)"
|
||||
else:
|
||||
am["range_label"] = "全部历史(暂无平仓)"
|
||||
am["monthly_rows"] = _build_monthly_stats_rows(conn, all_tr, seg_key)
|
||||
return dm, wm, mm, am
|
||||
|
||||
segments = []
|
||||
seg_defs = effective_stats_segment_defs(
|
||||
STATS_SEGMENT_DEFS, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED
|
||||
)
|
||||
for seg_key, seg_title, _meta in seg_defs:
|
||||
dm, wm, mm = slice_metrics(seg_key)
|
||||
segments.append({"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm})
|
||||
dm, wm, mm, am = slice_metrics(seg_key)
|
||||
segments.append(
|
||||
{"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm, "all": am}
|
||||
)
|
||||
|
||||
dm, wm, mm = slice_metrics("all")
|
||||
dm, wm, mm, am = slice_metrics("all")
|
||||
|
||||
return {
|
||||
"trading_day": trading_day,
|
||||
@@ -1882,6 +1943,7 @@ def compute_stats_bundle(conn, trading_day, now_dt=None):
|
||||
"day": dm,
|
||||
"week": wm,
|
||||
"month": mm,
|
||||
"all": am,
|
||||
"segments": segments,
|
||||
"stats_reset_hour": TRADING_DAY_RESET_HOUR,
|
||||
}
|
||||
@@ -2364,6 +2426,17 @@ def insert_trade_record(
|
||||
sync_trade_records_from_exchange(conn, force=False)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from lib.trade.account_risk_lib import on_closed_trade_pnl
|
||||
|
||||
close_dt = parse_dt_for_trading_day(close_ts)
|
||||
on_closed_trade_pnl(
|
||||
conn,
|
||||
pnl_amount=pnl_amount,
|
||||
trading_day=get_trading_day(close_dt),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return tid
|
||||
|
||||
|
||||
@@ -2418,6 +2491,8 @@ def enrich_order_item(raw_item, current_capital):
|
||||
|
||||
|
||||
def ensure_okx_live_ready():
|
||||
if EXCHANGE_AUTH_DISABLED_MSG:
|
||||
return False, EXCHANGE_AUTH_DISABLED_MSG
|
||||
if not LIVE_TRADING_ENABLED:
|
||||
return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)"
|
||||
if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE):
|
||||
@@ -2450,6 +2525,23 @@ def order_row_key_signal_type(row):
|
||||
return None
|
||||
|
||||
|
||||
def _disable_private_api_after_auth_error(exc):
|
||||
global EXCHANGE_AUTH_DISABLED_MSG, OKX_API_KEY, OKX_API_SECRET, OKX_API_PASSPHRASE
|
||||
from lib.exchange.api_credentials_lib import strip_ccxt_credentials
|
||||
|
||||
strip_ccxt_credentials(exchange)
|
||||
try:
|
||||
strip_ccxt_credentials(exchange_options)
|
||||
except Exception:
|
||||
pass
|
||||
OKX_API_KEY = ""
|
||||
OKX_API_SECRET = ""
|
||||
OKX_API_PASSPHRASE = ""
|
||||
EXCHANGE_AUTH_DISABLED_MSG = (
|
||||
f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启): {exc}"
|
||||
)
|
||||
|
||||
|
||||
def _extract_usdt_total(balance):
|
||||
usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {}
|
||||
total_map = balance.get("total", {}) if isinstance(balance, dict) else {}
|
||||
@@ -2570,8 +2662,9 @@ def get_exchange_capitals(force=False):
|
||||
ACCOUNT_BALANCE_CACHE["funding_usdt"] = funding
|
||||
ACCOUNT_BALANCE_CACHE["trading_usdt"] = trading
|
||||
ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
if is_exchange_auth_error(e):
|
||||
_disable_private_api_after_auth_error(e)
|
||||
return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"]
|
||||
|
||||
|
||||
@@ -2662,6 +2755,7 @@ def trading_day_reset_allows_new_open(now, conn=None):
|
||||
def precheck_risk(conn, symbol, direction):
|
||||
now = app_now()
|
||||
from lib.trade.account_risk_lib import account_risk_blocks_trading
|
||||
from lib.trade.force_close_lib import force_close_blocks_new_open
|
||||
|
||||
ok_risk, risk_reason = account_risk_blocks_trading(
|
||||
conn,
|
||||
@@ -2671,6 +2765,13 @@ def precheck_risk(conn, symbol, direction):
|
||||
)
|
||||
if not ok_risk:
|
||||
return False, risk_reason
|
||||
fc_block, fc_note = force_close_blocks_new_open(
|
||||
FORCE_CLOSE_ENABLED,
|
||||
FORCE_CLOSE_BJ_HOUR,
|
||||
now_ms=int(now.timestamp() * 1000),
|
||||
)
|
||||
if fc_block:
|
||||
return False, fc_note or "强制清仓窗口内暂不可开仓"
|
||||
if not trading_day_reset_allows_new_open(now):
|
||||
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
|
||||
from lib.trade.account_risk_lib import position_limit_reached
|
||||
@@ -2764,7 +2865,7 @@ def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price):
|
||||
def get_contract_size(exchange_symbol):
|
||||
try:
|
||||
ensure_markets_loaded()
|
||||
market = exchange.market(exchange_symbol)
|
||||
market = exchange.market(normalize_okx_symbol(exchange_symbol))
|
||||
return float(market.get("contractSize") or 1)
|
||||
except Exception:
|
||||
return 1.0
|
||||
@@ -2794,7 +2895,14 @@ def build_okx_order_params(direction, reduce_only=False):
|
||||
def ensure_markets_loaded(force=False):
|
||||
global MARKETS_LOADED
|
||||
if force or not MARKETS_LOADED:
|
||||
exchange.load_markets(reload=force)
|
||||
try:
|
||||
exchange.load_markets(reload=force)
|
||||
except Exception as e:
|
||||
if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)):
|
||||
_disable_private_api_after_auth_error(e)
|
||||
load_markets_public_fallback(exchange, reload=True)
|
||||
else:
|
||||
raise
|
||||
MARKETS_LOADED = True
|
||||
|
||||
|
||||
@@ -2910,6 +3018,8 @@ def _okx_place_tp_sl_orders(exchange_symbol, direction, amount, stop_loss, take_
|
||||
|
||||
|
||||
def exchange_private_api_configured():
|
||||
if EXCHANGE_AUTH_DISABLED_MSG:
|
||||
return False
|
||||
return bool(OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE)
|
||||
|
||||
|
||||
@@ -3370,29 +3480,6 @@ def ms_to_app_local_str(ms):
|
||||
return app_now_str()
|
||||
|
||||
|
||||
def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price):
|
||||
"""根据成交价相对止盈/止损位归类;无法可靠归类时返回 None."""
|
||||
try:
|
||||
tp = float(take_profit)
|
||||
sl = float(stop_loss)
|
||||
ex = float(exit_price)
|
||||
trig = float(trigger_price)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12)
|
||||
if direction == "long":
|
||||
if ex >= tp - band:
|
||||
return "止盈"
|
||||
if ex <= sl + band:
|
||||
return "止损"
|
||||
else:
|
||||
if ex <= tp + band:
|
||||
return "止盈"
|
||||
if ex >= sl - band:
|
||||
return "止损"
|
||||
return None
|
||||
|
||||
|
||||
def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None):
|
||||
"""取开仓以来最近一笔减仓成交(与方向一致);失败返回 None."""
|
||||
if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE):
|
||||
@@ -6355,8 +6442,10 @@ def force_close_before_reset():
|
||||
if not FORCE_CLOSE_ENABLED:
|
||||
return
|
||||
now = app_now()
|
||||
# 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx)
|
||||
if now.hour != FORCE_CLOSE_BJ_HOUR:
|
||||
# 每天北京时间指定整点起 FORCE_CLOSE_GRACE_MINUTES 分钟内执行兜底清仓
|
||||
from lib.trade.force_close_lib import is_force_close_executing
|
||||
|
||||
if not is_force_close_executing(FORCE_CLOSE_BJ_HOUR, now_ms=int(now.timestamp() * 1000)):
|
||||
return
|
||||
conn = get_db()
|
||||
rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
|
||||
@@ -6554,8 +6643,8 @@ def render_main_page(page="trade", embed_mode=None):
|
||||
from lib.instance.instance_embed_context_lib import (
|
||||
embed_render_plan,
|
||||
minimal_stats_bundle,
|
||||
options_funding_label,
|
||||
profit_loss_ratio_from_trades,
|
||||
show_perp_funds_enabled,
|
||||
total_funds_usdt,
|
||||
trade_records_summary,
|
||||
)
|
||||
@@ -6571,22 +6660,39 @@ def render_main_page(page="trade", embed_mode=None):
|
||||
options_funding_usdc = None
|
||||
options_funding_usdt = None
|
||||
options_trading_usdt = None
|
||||
options_funding_eth = None
|
||||
options_trading_eth = None
|
||||
options_trading_btc = None
|
||||
options_margin_mode = "coin"
|
||||
options_underly = "ETH"
|
||||
if (
|
||||
OKX_OPTIONS_ENABLED
|
||||
and exchange_options.apiKey
|
||||
and embed_mode != "fragment"
|
||||
):
|
||||
try:
|
||||
from lib.exchange.okx_options_lib import options_header_balances
|
||||
from lib.exchange.okx_options_lib import options_header_balance_pack
|
||||
|
||||
options_trading_usdc, options_funding_usdc, options_funding_usdt, options_trading_usdt = options_header_balances(
|
||||
exchange_options
|
||||
)
|
||||
_op = options_header_balance_pack(exchange_options)
|
||||
options_trading_usdc = _op.get("trading_usdc")
|
||||
options_funding_usdc = _op.get("funding_usdc")
|
||||
options_funding_usdt = _op.get("funding_usdt")
|
||||
options_trading_usdt = _op.get("trading_usdt")
|
||||
options_funding_eth = _op.get("funding_eth")
|
||||
options_trading_eth = _op.get("trading_eth")
|
||||
options_trading_btc = _op.get("trading_btc")
|
||||
options_margin_mode = _op.get("options_margin_mode") or "coin"
|
||||
options_underly = _op.get("options_underly") or "ETH"
|
||||
except Exception:
|
||||
options_trading_usdc = None
|
||||
options_funding_usdc = None
|
||||
options_funding_usdt = None
|
||||
options_trading_usdt = None
|
||||
options_funding_eth = None
|
||||
options_trading_eth = None
|
||||
options_trading_btc = None
|
||||
options_margin_mode = "coin"
|
||||
options_underly = "ETH"
|
||||
recommended_capital = get_recommended_capital(current_capital)
|
||||
key_list = (
|
||||
conn.execute("SELECT * FROM key_monitors").fetchall() if plan.key_list else []
|
||||
@@ -6655,14 +6761,22 @@ def render_main_page(page="trade", embed_mode=None):
|
||||
open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR
|
||||
opens_today = count_opens_for_trading_day(conn, trading_day)
|
||||
risk_status = hub_account_risk_status(conn)
|
||||
can_trade = can_trade_new_open(
|
||||
from lib.trade.open_trade_gate_lib import resolve_manual_open_gate
|
||||
|
||||
_open_gate = resolve_manual_open_gate(
|
||||
time_allows=trading_day_reset_allows_new_open(now, conn),
|
||||
active_count=position_limit_count,
|
||||
max_active_positions=MAX_ACTIVE_POSITIONS,
|
||||
opens_today=opens_today,
|
||||
hard_limit=DAILY_OPEN_HARD_LIMIT,
|
||||
extra_blocks=not risk_status.get("can_trade", True),
|
||||
risk_status=risk_status,
|
||||
force_close_enabled=FORCE_CLOSE_ENABLED,
|
||||
force_close_bj_hour=FORCE_CLOSE_BJ_HOUR,
|
||||
now_ms=int(now.timestamp() * 1000),
|
||||
reset_hour=TRADING_DAY_RESET_HOUR,
|
||||
)
|
||||
can_trade = _open_gate["can_trade"]
|
||||
open_block_note = _open_gate["open_block_note"]
|
||||
key_rule_ctx = {}
|
||||
if page in ("key_monitor", "trade") or page in (
|
||||
"strategy",
|
||||
@@ -6703,6 +6817,11 @@ def render_main_page(page="trade", embed_mode=None):
|
||||
from lib.instance.instance_display_prefs_lib import display_prefs_template_context
|
||||
|
||||
_display_ctx = display_prefs_template_context(get_db)
|
||||
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
|
||||
|
||||
_okx_trade_mode = get_okx_trade_mode()
|
||||
_hedge_mode_on = _okx_trade_mode in ("perp_options", "options_options")
|
||||
_show_perp_funds = show_perp_funds_enabled(exchange_key="okx")
|
||||
template_ctx = dict(
|
||||
page=page,
|
||||
key=key_list,
|
||||
@@ -6714,17 +6833,22 @@ def render_main_page(page="trade", embed_mode=None):
|
||||
rate=rate,
|
||||
profit_loss_ratio=profit_loss_ratio,
|
||||
total_funds=total_funds_usdt(
|
||||
funding_usdt,
|
||||
current_capital,
|
||||
funding_usdt if _show_perp_funds else None,
|
||||
current_capital if _show_perp_funds else None,
|
||||
options_trading_usdc,
|
||||
options_funding_usdc,
|
||||
options_funding_usdt,
|
||||
options_trading_usdt,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
options_funding_usdc=options_funding_usdc,
|
||||
options_funding_usdt=options_funding_usdt,
|
||||
options_trading_usdc=options_trading_usdc,
|
||||
options_trading_usdt=options_trading_usdt,
|
||||
options_funding_eth=options_funding_eth,
|
||||
options_trading_eth=options_trading_eth,
|
||||
options_trading_btc=options_trading_btc,
|
||||
options_margin_mode=options_margin_mode,
|
||||
options_underly=options_underly,
|
||||
trading_day=trading_day,
|
||||
daily_start_capital=DAILY_START_CAPITAL,
|
||||
current_capital=current_capital,
|
||||
@@ -6744,6 +6868,7 @@ def render_main_page(page="trade", embed_mode=None):
|
||||
price_refresh_seconds=PRICE_REFRESH_SECONDS,
|
||||
active_count=position_limit_count,
|
||||
can_trade=can_trade,
|
||||
open_block_note=open_block_note,
|
||||
opens_today=opens_today,
|
||||
daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT,
|
||||
daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD,
|
||||
@@ -6788,21 +6913,35 @@ def render_main_page(page="trade", embed_mode=None):
|
||||
journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR,
|
||||
key_rule_ctx=key_rule_ctx,
|
||||
funds_fmt=format_funds_u,
|
||||
options_funding_label=options_funding_label,
|
||||
# options_funding_label / trading_account_label 由 embed_context_extras 注入,勿重复写进 dict
|
||||
exchange_display=EXCHANGE_DISPLAY_NAME,
|
||||
options_enabled=OKX_OPTIONS_ENABLED,
|
||||
show_perp_funds=_show_perp_funds or (options_margin_mode == "coin"),
|
||||
options_nav_visible=True,
|
||||
hedge_plan_enabled=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"),
|
||||
hedge_plan_nav_visible=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"),
|
||||
hedge_plan_show_perp_options=os.getenv("HEDGE_PLAN_SHOW_PERP_OPTIONS", "true").lower()
|
||||
in ("1", "true", "yes", "on"),
|
||||
hedge_plan_show_options_options=os.getenv("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "true").lower()
|
||||
in ("1", "true", "yes", "on"),
|
||||
okx_trade_mode=_okx_trade_mode,
|
||||
options_open_allowed=_okx_trade_mode == "options",
|
||||
hedge_plan_enabled=_hedge_mode_on,
|
||||
hedge_plan_nav_visible=_hedge_mode_on,
|
||||
hedge_plan_show_perp_options=_okx_trade_mode == "perp_options",
|
||||
hedge_plan_show_options_options=_okx_trade_mode == "options_options",
|
||||
hedge_plan_oo_close_mode_enabled=os.getenv("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", "true").lower()
|
||||
in ("1", "true", "yes", "on"),
|
||||
hedge_plan_option_primary=os.getenv("HEDGE_PLAN_OPTION_PRIMARY", "true").lower()
|
||||
in ("1", "true", "yes", "on"),
|
||||
hedge_plan_budget_buffer=float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"),
|
||||
options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC,
|
||||
options_budget_buffer=float(os.getenv("OKX_OPTIONS_BUDGET_BUFFER") or "0.95"),
|
||||
options_compound_full_enabled=os.getenv(
|
||||
"OKX_OPTIONS_COMPOUND_FULL_ENABLED", "true"
|
||||
).lower()
|
||||
in ("1", "true", "yes", "on"),
|
||||
options_compound_full_cap_enabled=os.getenv(
|
||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false"
|
||||
).lower()
|
||||
in ("1", "true", "yes", "on"),
|
||||
options_compound_full_cap_usdc=float(
|
||||
os.getenv("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC") or "300"
|
||||
),
|
||||
options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY,
|
||||
options_chain_ask_liq_filter=os.getenv(
|
||||
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true"
|
||||
@@ -6967,19 +7106,38 @@ def api_account_snapshot():
|
||||
options_funding_usdc = None
|
||||
options_funding_usdt = None
|
||||
options_trading_usdt = None
|
||||
options_funding_eth = None
|
||||
options_trading_eth = None
|
||||
options_trading_btc = None
|
||||
options_margin_mode = "coin"
|
||||
options_underly = "ETH"
|
||||
options_index_px = None
|
||||
if OKX_OPTIONS_ENABLED and exchange_options.apiKey:
|
||||
try:
|
||||
from lib.exchange.okx_options_lib import options_header_balances
|
||||
from lib.exchange.okx_options_lib import fetch_index_price, options_header_balance_pack
|
||||
|
||||
options_trading_usdc, options_funding_usdc, options_funding_usdt, options_trading_usdt = options_header_balances(
|
||||
exchange_options,
|
||||
force=force_refresh,
|
||||
)
|
||||
_op = options_header_balance_pack(exchange_options, force=force_refresh)
|
||||
options_trading_usdc = _op.get("trading_usdc")
|
||||
options_funding_usdc = _op.get("funding_usdc")
|
||||
options_funding_usdt = _op.get("funding_usdt")
|
||||
options_trading_usdt = _op.get("trading_usdt")
|
||||
options_funding_eth = _op.get("funding_eth")
|
||||
options_trading_eth = _op.get("trading_eth")
|
||||
options_trading_btc = _op.get("trading_btc")
|
||||
options_margin_mode = _op.get("options_margin_mode") or "coin"
|
||||
options_underly = _op.get("options_underly") or "ETH"
|
||||
options_index_px = fetch_index_price(exchange_options, options_underly)
|
||||
except Exception:
|
||||
options_trading_usdc = None
|
||||
options_funding_usdc = None
|
||||
options_funding_usdt = None
|
||||
options_trading_usdt = None
|
||||
options_funding_eth = None
|
||||
options_trading_eth = None
|
||||
options_trading_btc = None
|
||||
options_margin_mode = "coin"
|
||||
options_underly = "ETH"
|
||||
options_index_px = None
|
||||
recommended_capital = get_recommended_capital(current_capital)
|
||||
from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors
|
||||
|
||||
@@ -6990,19 +7148,31 @@ def api_account_snapshot():
|
||||
active_pnl_rows = conn.execute(
|
||||
"SELECT exchange_symbol, symbol, direction FROM order_monitors WHERE status='active'"
|
||||
).fetchall()
|
||||
from lib.instance.instance_embed_context_lib import header_trade_stats_for_window, total_funds_usdt
|
||||
from lib.instance.instance_embed_context_lib import (
|
||||
header_trade_stats_for_window,
|
||||
show_perp_funds_enabled,
|
||||
total_funds_usdt,
|
||||
)
|
||||
|
||||
header_trade_stats = header_trade_stats_for_window(conn, _list_window_from_request(), APP_TZ)
|
||||
conn.close()
|
||||
open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR
|
||||
can_trade = can_trade_new_open(
|
||||
from lib.trade.open_trade_gate_lib import resolve_manual_open_gate
|
||||
|
||||
_open_gate = resolve_manual_open_gate(
|
||||
time_allows=trading_day_reset_allows_new_open(now),
|
||||
active_count=position_limit_count,
|
||||
max_active_positions=MAX_ACTIVE_POSITIONS,
|
||||
opens_today=opens_today,
|
||||
hard_limit=DAILY_OPEN_HARD_LIMIT,
|
||||
extra_blocks=not risk_status.get("can_trade", True),
|
||||
risk_status=risk_status,
|
||||
force_close_enabled=FORCE_CLOSE_ENABLED,
|
||||
force_close_bj_hour=FORCE_CLOSE_BJ_HOUR,
|
||||
now_ms=int(now.timestamp() * 1000),
|
||||
reset_hour=TRADING_DAY_RESET_HOUR,
|
||||
)
|
||||
can_trade = _open_gate["can_trade"]
|
||||
open_block_note = _open_gate["open_block_note"]
|
||||
available_trading_usdt = get_available_trading_usdt()
|
||||
|
||||
unrealized_pnl = None
|
||||
@@ -7038,23 +7208,33 @@ def api_account_snapshot():
|
||||
from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc
|
||||
|
||||
options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options)
|
||||
unrealized_pnl = merge_unrealized_pnl_components(unrealized_pnl, options_unrealized_pnl)
|
||||
# 币本位期权盈亏单位为币,禁止与永续 U 混加(且 merge 只保留 2 位会把 0.0019 抹成 0)
|
||||
if options_margin_mode != "coin":
|
||||
unrealized_pnl = merge_unrealized_pnl_components(unrealized_pnl, options_unrealized_pnl)
|
||||
except Exception:
|
||||
options_unrealized_pnl = None
|
||||
_show_perp_funds = show_perp_funds_enabled(exchange_key="okx") or (options_margin_mode == "coin")
|
||||
return jsonify({
|
||||
"funding_usdt": funding_usdt,
|
||||
"current_capital": current_capital,
|
||||
"show_perp_funds": _show_perp_funds,
|
||||
"options_funding_usdc": options_funding_usdc,
|
||||
"options_funding_usdt": options_funding_usdt,
|
||||
"options_trading_usdc": options_trading_usdc,
|
||||
"options_trading_usdt": options_trading_usdt,
|
||||
"options_funding_eth": options_funding_eth,
|
||||
"options_trading_eth": options_trading_eth,
|
||||
"options_trading_btc": options_trading_btc,
|
||||
"options_margin_mode": options_margin_mode,
|
||||
"options_underly": options_underly,
|
||||
"options_index_px": options_index_px,
|
||||
"total_funds": total_funds_usdt(
|
||||
funding_usdt,
|
||||
current_capital,
|
||||
funding_usdt if _show_perp_funds else None,
|
||||
current_capital if _show_perp_funds else None,
|
||||
options_trading_usdc,
|
||||
options_funding_usdc,
|
||||
options_funding_usdt,
|
||||
options_trading_usdt,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None,
|
||||
"unrealized_pnl": unrealized_pnl,
|
||||
@@ -7063,6 +7243,7 @@ def api_account_snapshot():
|
||||
"active_count": position_limit_count,
|
||||
"max_active_positions": MAX_ACTIVE_POSITIONS,
|
||||
"can_trade": can_trade,
|
||||
"open_block_note": open_block_note,
|
||||
"opens_today": opens_today,
|
||||
"daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT,
|
||||
"daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD,
|
||||
@@ -7446,9 +7627,14 @@ def api_price_snapshot():
|
||||
)
|
||||
|
||||
options_unrealized_pnl = None
|
||||
options_index_px = None
|
||||
options_margin_mode = None
|
||||
options_underly = None
|
||||
if OKX_OPTIONS_ENABLED and exchange_options.apiKey:
|
||||
try:
|
||||
from lib.options.options_positions_lib import sum_options_net_pnl_usdc
|
||||
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
|
||||
from lib.exchange.okx_options_lib import fetch_index_price
|
||||
|
||||
opt_cfg = app.extensions.get("options_cfg")
|
||||
if opt_cfg:
|
||||
@@ -7457,6 +7643,9 @@ def api_price_snapshot():
|
||||
from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc
|
||||
|
||||
options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options)
|
||||
options_margin_mode = normalize_options_margin_mode()
|
||||
options_underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
|
||||
options_index_px = fetch_index_price(exchange_options, options_underly)
|
||||
except Exception:
|
||||
options_unrealized_pnl = None
|
||||
|
||||
@@ -7467,6 +7656,9 @@ def api_price_snapshot():
|
||||
"position_marks": position_marks,
|
||||
"positions_raw_count": len(all_swap_positions),
|
||||
"options_unrealized_pnl": options_unrealized_pnl,
|
||||
"options_index_px": options_index_px,
|
||||
"options_margin_mode": options_margin_mode,
|
||||
"options_underly": options_underly,
|
||||
**force_close_template_context(
|
||||
FORCE_CLOSE_ENABLED,
|
||||
FORCE_CLOSE_BJ_HOUR,
|
||||
@@ -9096,9 +9288,12 @@ def _dashboard_fetch_options_positions():
|
||||
def _dashboard_enrich_orders(items):
|
||||
from lib.instance.instance_dashboard_lib import enrich_order_items_with_marks
|
||||
|
||||
return enrich_order_items_with_marks(items, get_price=get_price)
|
||||
return enrich_order_items_with_marks(
|
||||
items, get_price=get_price, get_contract_size=get_contract_size
|
||||
)
|
||||
|
||||
|
||||
from lib.hedge_plan.okx_trade_mode_lib import hedge_module_enabled
|
||||
from lib.instance.instance_dashboard_register import register_instance_dashboard_routes
|
||||
|
||||
register_instance_dashboard_routes(
|
||||
@@ -9107,9 +9302,13 @@ register_instance_dashboard_routes(
|
||||
get_db=get_db,
|
||||
fetch_options_positions=_dashboard_fetch_options_positions,
|
||||
enrich_orders=_dashboard_enrich_orders,
|
||||
hedge_enabled=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"),
|
||||
hedge_enabled=hedge_module_enabled,
|
||||
)
|
||||
|
||||
from lib.account_ledger.account_ledger_register import install_account_ledger
|
||||
|
||||
install_account_ledger(app, _REPO_ROOT, app_module=sys.modules[__name__], exchange_key="okx")
|
||||
|
||||
|
||||
@app.route("/api/journals")
|
||||
@login_required
|
||||
@@ -9444,7 +9643,7 @@ def manual_transfer():
|
||||
amount = float(request.form.get("amount", "0"))
|
||||
except Exception:
|
||||
flash("划转金额格式错误")
|
||||
return redirect("/settings")
|
||||
return redirect("/settings?settings_tab=transfer")
|
||||
from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip()
|
||||
to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip()
|
||||
ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account)
|
||||
@@ -9466,7 +9665,7 @@ def manual_transfer():
|
||||
flash(f"手动划转成功:{amount}U {from_account}->{to_account}")
|
||||
else:
|
||||
flash(f"手动划转失败:{msg}")
|
||||
return redirect("/settings")
|
||||
return redirect("/settings?settings_tab=transfer")
|
||||
|
||||
|
||||
def _journal_ai_chart_builder(row):
|
||||
|
||||
+2
-2
@@ -40,9 +40,9 @@ bash /opt/crypto_monitor/deploy/manage.sh
|
||||
|
||||
- 登录账号: **admin**
|
||||
- 登录密码: **admin123**
|
||||
- 浏览器配置: 各所 **env 配置**(API,风控) + 中控 **系统设置**
|
||||
- 浏览器配置: 各所 **env 配置**(风控等) + 中控 **系统设置**;**交易所 API 仅服务器 `.env` 手改**(新机默认为空)
|
||||
|
||||
**无需 SSH 编辑 `.env` 填 API**;密钥由 `bootstrap_deploy_secrets.py` 自动生成.
|
||||
**无需 SSH 编辑 `.env` 填通信/登录类密钥**;交易所 API 须在服务器写入各所 `.env`(新机为空).
|
||||
|
||||
| 地址 | 端口 |
|
||||
|------|------|
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
# 标的时段振幅统计 — 开发方案
|
||||
|
||||
> 状态:**方案冻结**(按本文实现;改需求先改本文).
|
||||
> 范围:**中控**新增只读统计工具;不改开平仓、不接 AI 教练(首版).
|
||||
> 数据源:**仅 OKX**.
|
||||
> 相关:[交易执行手册-期权与Gate.md](./交易执行手册-期权与Gate.md)(16:00 会话窗纪律) · [振幅统计说明.md](./振幅统计说明.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在中控提供 **自定义时段、固定 16:00 收窗** 的历史振幅档案:
|
||||
|
||||
- **标的下拉**:`ETH` / `BTC`(默认 ETH)
|
||||
- 按整点起点 + **终点固定北京时间 16:00** 切出每日统计窗
|
||||
- 回溯周期可选(1 月 / 2 月 / 3 月 / 半年 / 1 年 / 自定义)
|
||||
- 日表明细分页展示;下方为汇总统计
|
||||
- 每次有效计算可写入 **历史**;支持 **下载**(明细 + 统计摘要)
|
||||
|
||||
定位:服务一天期期权开仓前的「空间」判断(已实现波动点数档案),**不算 IV / 权利金 / Greeks**.
|
||||
|
||||
---
|
||||
|
||||
## 2. 不做(首版外)
|
||||
|
||||
- 币安 / Gate 等非 OKX 价源
|
||||
- 百分比振幅列(可后加「参考 %」,不进必须统计)
|
||||
- 未完成窗(当天尚未到 16:00)计入样本
|
||||
- 自动推送企业微信 / 注入交易教练
|
||||
- 中控代下单或改期权仓
|
||||
|
||||
---
|
||||
|
||||
## 3. 时间与样本规则
|
||||
|
||||
### 3.1 时区与终点
|
||||
|
||||
- 时区:**Asia/Shanghai(北京时间)**
|
||||
- **到期/收窗时刻固定 `16:00`**,不可改
|
||||
- 起点时刻:**仅整点** `00:00`~`23:00`(下拉选择)
|
||||
|
||||
### 3.2 跨天切窗(结算日 D)
|
||||
|
||||
对每个结算日 **D**(窗终点 = `D 日 16:00`):
|
||||
|
||||
| 起点整点 T | 窗起点 | 窗终点 |
|
||||
|------------|--------|--------|
|
||||
| `T >= 16:00` | **D-1 日 T:00** | D 日 16:00 |
|
||||
| `T < 16:00` | **D 日 T:00** | D 日 16:00 |
|
||||
|
||||
示例:
|
||||
|
||||
| 用户选择 | 某一结算日 D 的实际窗 |
|
||||
|----------|------------------------|
|
||||
| 16:00 → 16:00 | D-1 16:00 → D 16:00 |
|
||||
| 22:00 → 16:00 | D-1 22:00 → D 16:00 |
|
||||
| 08:00 → 16:00 | D 08:00 → D 16:00 |
|
||||
|
||||
### 3.3 回溯周期
|
||||
|
||||
| 选项 | 含义(完整收窗个数,约) |
|
||||
|------|------------------------|
|
||||
| 1 个月 | 约 30 个结算日 |
|
||||
| 2 个月 | 约 60 个结算日(默认推荐) |
|
||||
| 3 个月 | 约 90 个结算日 |
|
||||
| 半年 | 约 180 个结算日 |
|
||||
| 1 年 | 约 365 个结算日 |
|
||||
| 自定义 | 用户输入天数 N(`7`~`400`,可配置上下限) |
|
||||
|
||||
说明:
|
||||
|
||||
- 「月」按 **日历回溯 + 完整 16:00 收窗** 计数,不足整天的末日不入样
|
||||
- 仅纳入 **已结束** 的窗(`now >= D 16:00`);进行中的今天不入样
|
||||
|
||||
### 3.4 标的与价源(OKX)
|
||||
|
||||
| UI 下拉 | 价源(优先) | 降级(仅指数失败时) |
|
||||
|---------|------------|---------------------|
|
||||
| **ETH** | OKX **ETH-USD 指数** | OKX `ETH/USDT` 永续标记 |
|
||||
| **BTC** | OKX **BTC-USD 指数** | OKX `BTC/USDT` 永续标记 |
|
||||
|
||||
约束:
|
||||
|
||||
- **交易所固定 OKX**,UI 不提供其它所
|
||||
- 具体指数/合约符号以实现时 OKX 接口与 `hub_ohlcv` 对齐为准;结果与下载须标注 `exchange=okx` + 实际价源
|
||||
- K 线粒度:**1H**(与整点起止对齐,优先);同一作业内不得混用粒度.若后续要更细高低点,可升 5m/1m(P2)
|
||||
|
||||
---
|
||||
|
||||
## 4. 指标口径(点数,非百分比)
|
||||
|
||||
全部为 **绝对价格点数**(标的报价差;BTC/ETH 各自用自身价格刻度).
|
||||
|
||||
设窗内:
|
||||
|
||||
- `O` = 起点时刻价(或起点分钟 K 的 open)
|
||||
- `H` = 窗内最高
|
||||
- `L` = 窗内最低
|
||||
- `C` = 终点 16:00 价(或该分钟 close)
|
||||
|
||||
| 字段 | 算法 | 例(O=2000,H=2500,L=1800) |
|
||||
|------|------|---------------------------|
|
||||
| 开盘价 | `O` | 2000 |
|
||||
| 最高价 | `H` | 2500 |
|
||||
| 最低价 | `L` | 1800 |
|
||||
| 收盘/窗末价 | `C` | (另算) |
|
||||
| 开→高距离 | `H − O` | **500** |
|
||||
| 开→低距离 | `O − L` | **200** |
|
||||
| **振幅** | `(H−O)+(O−L)` = **`H−L`** | **700** |
|
||||
| 涨跌值 | `C − O`(可正负) | 可选列,首版建议保留 |
|
||||
|
||||
**必须统计(汇总层):**
|
||||
|
||||
- **最大振幅**(值 + 对应结算日)
|
||||
- **开→高距离**:最大、均值(建议)
|
||||
- **开→低距离**:最大、均值(建议)
|
||||
|
||||
可选汇总(首版建议带上,成本低):
|
||||
|
||||
- 振幅均值 / 中位数
|
||||
- 上涨窗占比(`C>O`)、下跌窗占比
|
||||
- 振幅 ≥ 用户阈值 X 点数的天数(X 可填,默认空=不算)
|
||||
|
||||
---
|
||||
|
||||
## 5. 界面(中控)
|
||||
|
||||
### 5.1 入口
|
||||
|
||||
- 顶栏新增导航项:**「振幅统计」**或 **「期权统计」**(最终文案实现时定一处;设置里可隐藏)
|
||||
- 手机端进「更多」
|
||||
|
||||
### 5.2 Tab
|
||||
|
||||
| Tab | 作用 |
|
||||
|-----|------|
|
||||
| **统计** | 配参数 → 计算 → 看日表+汇总 → 下载 / 存历史 |
|
||||
| **历史** | 过往作业列表;打开复看;再下载 |
|
||||
|
||||
### 5.3 「统计」页布局
|
||||
|
||||
1. **参数区**
|
||||
- **标的**:下拉 `ETH` / `BTC`(默认 ETH)
|
||||
- 数据源:只读展示 `OKX`
|
||||
- 起点整点:下拉 `00`~`23`(默认 `16`)
|
||||
- 终点:固定展示 `16:00`(不可改)
|
||||
- 周期:单选 `1月 / 2月 / 3月 / 半年 / 1年 / 自定义`
|
||||
- 自定义天数:仅自定义时显示
|
||||
- 按钮:`计算` · `保存到历史` · `下载`
|
||||
2. **日表明细**(分页,如每页 20 行;排序默认结算日倒序)
|
||||
3. **下方汇总区**(本次全样本,不是当前页)
|
||||
|
||||
### 5.4 「历史」页
|
||||
|
||||
每条记录至少:
|
||||
|
||||
- 创建时间、**标的**、起点整点、周期/天数、价源(OKX+指数/标记)、样本数
|
||||
- 最大振幅(+日期)
|
||||
- 操作:查看 / 下载 / 删除
|
||||
|
||||
**写入规则(建议):** 用户点击 **「保存到历史」** 才入库;仅点「计算」不自动灌历史(避免误点刷屏).若产品坚持「输入一次就算进历史」,可改为计算成功自动写入——实现前在本文改为冻结口径.
|
||||
|
||||
> 当前方案冻结倾向:**显式「保存到历史」**.
|
||||
|
||||
---
|
||||
|
||||
## 6. 下载
|
||||
|
||||
格式:优先 **CSV**(UTF-8 BOM,Excel 可开);或单文件双段.
|
||||
|
||||
必须包含:
|
||||
|
||||
1. **日表明细**(本次全部结算日,非当前页)
|
||||
2. **统计摘要**:标的、交易所 OKX、价源、最大振幅(+日)、开→高最大/均值、开→低最大/均值、样本数、起点整点、终点 16:00、周期、生成时间
|
||||
|
||||
文件名示例:`okx_eth_amp_22to16_60d_20260723.csv` / `okx_btc_amp_16to16_90d_20260723.csv`
|
||||
|
||||
---
|
||||
|
||||
## 7. 数据与实现要点
|
||||
|
||||
### 7.1 复用
|
||||
|
||||
- 优先复用中控 `hub_ohlcv` / `hub_kline_store`,按 `exchange_key=okx` + 标的对应指数/合约拉齐历史 K 线并本地缓存
|
||||
- 首次 1 年 × 1m 数据量较大:计算前检查缓存覆盖;缺口再增量拉取;UI 显示进度/耗时提示
|
||||
- BTC / ETH 缓存键分离
|
||||
|
||||
### 7.2 后端模块(建议)
|
||||
|
||||
| 路径 | 职责 |
|
||||
|------|------|
|
||||
| `lib/hub/amp_stats_lib.py` | 标的映射、切窗、算日行、汇总 |
|
||||
| `manual_trading_hub/` 路由 + 静态页 | UI / API |
|
||||
| `manual_trading_hub/amp_stats_history.json`(或 sqlite) | 历史作业 |
|
||||
|
||||
### 7.3 API 草稿
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `POST` | `/api/amp-stats/compute` | body: `symbol`(eth\|btc), start_hour, period\|days → 日表+汇总 |
|
||||
| `GET` | `/api/amp-stats/history` | 历史列表(可按 symbol 筛选) |
|
||||
| `POST` | `/api/amp-stats/history` | 保存当前结果 |
|
||||
| `GET` | `/api/amp-stats/history/{id}` | 详情 |
|
||||
| `DELETE` | `/api/amp-stats/history/{id}` | 删除 |
|
||||
| `GET` | `/api/amp-stats/export` | query 或 history id → 文件下载 |
|
||||
|
||||
### 7.4 性能
|
||||
|
||||
- 2 个月 × 1m:可接受同步(数十秒级需有 loading)
|
||||
- 1 年:建议异步任务或分块拉齐后再算;首版可限制「自定义 > 180 天」需确认二次点击
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收清单
|
||||
|
||||
- [ ] 标的下拉 ETH / BTC 可切换;数据源固定 OKX
|
||||
- [ ] 起点仅整点;终点 UI 固定 16:00
|
||||
- [ ] `22→16` / `16→16` / `08→16` 跨天规则与 §3.2 一致
|
||||
- [ ] 周期六档 + 自定义天数生效;默认 2 个月
|
||||
- [ ] 日表含:开高低收、开→高、开→低、振幅(点数)、涨跌值
|
||||
- [ ] 例:O=2000,H=2500,L=1800 → 开→高 500、开→低 200、振幅 700
|
||||
- [ ] 汇总含最大振幅(+日)、开→高/开→低统计
|
||||
- [ ] 分页只影响展示;汇总与下载用全样本
|
||||
- [ ] 未到 16:00 的当日不入样
|
||||
- [ ] 保存历史含标的字段 / 回看 / 删除
|
||||
- [ ] 下载含明细 + 统计摘要(含标的与 OKX)
|
||||
- [ ] 电脑与手机均可完成计算与下载(手机下载走系统分享/保存即可)
|
||||
|
||||
---
|
||||
|
||||
## 9. 分期
|
||||
|
||||
| 阶段 | 内容 |
|
||||
|------|------|
|
||||
| **P0** | 统计 Tab:标的下拉(ETH/BTC) + 参数 + 计算 + 日表分页 + 汇总 + 下载(不经历史) |
|
||||
| **P1** | 历史 Tab:保存 / 列表 / 回看 / 再下载 / 删除 |
|
||||
| **P2** | 缓存加速、长周期异步、振幅阈值天数、可选 % 参考列 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 待冻结(实现前确认)
|
||||
|
||||
| # | 问题 | 当前倾向 |
|
||||
|---|------|----------|
|
||||
| 1 | 历史写入:自动 vs 点保存 | **点保存** |
|
||||
| 2 | 下载 CSV vs Excel | **CSV** |
|
||||
| 3 | 价源 | **OKX 指数优先**(ETH-USD / BTC-USD);失败再降级永续标记 |
|
||||
| 4 | K 线 1m vs 5m vs 1H | **1H**(整点窗) |
|
||||
| 5 | 导航文案 | **「振幅统计」** |
|
||||
|
||||
**已冻结(开工口径):** 点保存进历史 · CSV · OKX 指数优先 · **1H K 线**(整点对齐,降低拉取量;与整点窗一致) · 导航「振幅统计」.
|
||||
|
||||
确认后将本文状态改为 **方案冻结**,再开工实现.
|
||||
|
||||
---
|
||||
|
||||
## 11. 修订记录
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-07-23 | 初稿:中控 ETH 时段振幅统计;点数口径;周期档位;16:00 固定收窗;历史+下载 |
|
||||
| 2026-07-23 | 支持 BTC/ETH 下拉;数据源固定 OKX 指数(可降级永续标记);模块/API 改名为 amp-stats |
|
||||
@@ -0,0 +1,279 @@
|
||||
# OKX 单笔期权 · 币本位模式(USDT 桥 + 复利)— 开发方案
|
||||
|
||||
> 状态:**已实现首版**(按本文落地;改需求先改本文).
|
||||
> 范围:**`crypto_monitor_okx` 单笔期权开平** + **中控对 OKX 期权只读字段**(能识别币本位);对冲计划(永期/期期)**不接币本位**.
|
||||
> **硬约束:本次不改 Gate**(不改 `crypto_monitor_gate/`、不改 Gate 专用模板/静态/测试;共享 `lib` 若动刀不得改变 Gate 启动与交易行为).
|
||||
> 相关:[期权方案.md](./期权方案.md) · [期权用法.md](./期权用法.md) · [期权开平仓与监控说明.md](./期权开平仓与监控说明.md) · [position-sizing-mode.md](./position-sizing-mode.md) · [更新文档.md](./更新文档.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
当前单笔期权仅支持 **USDⓈ 本位**(权利金 **USDC**):人工 USDT→USDC 兑换/划转后,按 `OKX_OPTIONS_TRADE_BUDGET_USDC` 卖一开 / 买一平.
|
||||
|
||||
实盘观察:**部分到期与行权附近,币本位期权流动性往往好于 USDC 期权**,更利于「只锁卖一 / 买一」的成交质量.
|
||||
|
||||
币本位权利金用 **ETH/BTC** 支付,操作者仍习惯用 **USDT** 思考本金与复利.因此需要一条自动资金桥,并支持交易账户 USDT 滚仓放大.
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标(首版)
|
||||
|
||||
1. **env 切换**单笔期权模式:`usdc`(现状) ↔ `coin`(币本位 + USDT↔ETH/BTC 桥).
|
||||
2. **币本位开仓**:按交易账户 USDT 预算 **先买满现货** → 再用币 **尽量开满** 期权(不按权利金精算买币数量).
|
||||
3. **币本位平仓**:期权卖出成功后,**自动现货市价**把剩余标的币卖回 USDT.
|
||||
4. **USDT 全仓复利**:每轮预算默认 = 交易账户 USDT × 缓冲(0.95);赚留在交易户则下一轮自动变大;减规模靠 **人工转走**.
|
||||
5. **可选单笔上限**:开关默认 **关闭**;开启后 `min(账户×0.95, N U)`.
|
||||
6. **有未平单笔期权或桥流程半成品时,拒绝切换模式**.
|
||||
7. **对冲计划**继续只走 USDC 路径;币本位模式下对冲开仓保持不可用或明确提示未支持.
|
||||
8. **中控不代下期权单**,但监控/快照/持仓卡片等 **只读字段须能识别币本位**(见 §7.5).
|
||||
9. **不涉及 Gate** 任何业务改动.
|
||||
|
||||
---
|
||||
|
||||
## 3. 不做(首版外)
|
||||
|
||||
- 对冲计划(永期/期期)币本位腿或双模式混开
|
||||
- 盘中按单笔切换本位(必须 env + 重启/无仓校验)
|
||||
- 按权利金精确计算后再买现货(明确不做;见 §5)
|
||||
- 自动把资金账户 USDT 划入交易账户(首版只读 **交易账户** 可用 USDT;不足则提示人工划转)
|
||||
- 市价平期权(继续沿用现有「买一限价、禁市价平」纪律,除非另改总则)
|
||||
- 多笔并行单笔期权仓(维持「一次一仓」)
|
||||
- **中控代下 / 中控内嵌开平仓按钮**触发币本位或 USDC 期权下单(开平仍只在 OKX 实例页)
|
||||
- **任何 Gate 相关改动**(含为「顺便统一」去动 Gate 模板或共享路径上的 Gate 分支)
|
||||
|
||||
---
|
||||
|
||||
## 4. 模式开关与互斥
|
||||
|
||||
### 4.1 env(草案)
|
||||
|
||||
| 变量 | 含义 | 默认 |
|
||||
|------|------|------|
|
||||
| `OKX_OPTIONS_MARGIN_MODE` | `usdc` \| `coin` | `coin` |
|
||||
| `OKX_OPTIONS_TRADE_BUDGET_USDC` | USDC 模式单笔权利金预算上限(现有) | `10` |
|
||||
| `OKX_OPTIONS_BUDGET_BUFFER` | 预算缓冲(现有,币本位复利亦用) | `0.95` |
|
||||
| `OKX_OPTIONS_COIN_COMPOUND` | 币本位是否按交易户 USDT 复利 | `true`(建议默认开) |
|
||||
| `OKX_OPTIONS_COIN_BUDGET_USDT` | 复利关闭时的固定 USDT 预算;或作展示参考 | `10` |
|
||||
| `OKX_OPTIONS_COIN_MAX_USDT_ENABLED` | 单笔不超过 N U 开关 | `false`(**默认关**) |
|
||||
| `OKX_OPTIONS_COIN_MAX_USDT` | 上限 N(仅开关开启时生效) | 如 `50`(可改) |
|
||||
| `OKX_OPTIONS_COIN_SPOT_BUY_BUFFER` | 现货买入相对权利金倍数(也可写 `0.10`=+10%) | `1.10` |
|
||||
|
||||
开仓买币:**先按预算估最大可开张数 → 买币 USDT ≈ 张数×卖一权利金×现货缓冲**,不全额把预算换成币。
|
||||
|
||||
说明:
|
||||
|
||||
- **主路径(复利开 + 上限关)**:`budget_usdt = trading_usdt_available × OKX_OPTIONS_BUDGET_BUFFER`.
|
||||
- **上限开**:`budget_usdt = min(上式, OKX_OPTIONS_COIN_MAX_USDT)`.
|
||||
- **复利关**:`budget_usdt = OKX_OPTIONS_COIN_BUDGET_USDT × buffer`(或直接固定值,实现时二选一写死一种,避免歧义;推荐 `固定值 × buffer` 与现 USDC 习惯一致).
|
||||
|
||||
### 4.2 切换门禁
|
||||
|
||||
| 条件 | 行为 |
|
||||
|------|------|
|
||||
| 本地/交易所存在未平 **单笔期权** 持仓 | **拒绝**切换 `usdc`↔`coin` |
|
||||
| 存在未完成桥状态(已买币未开期权、已平期权未卖回 USDT 等) | **拒绝**切换 |
|
||||
| 对冲计划运行中 | **不阻断**单笔模式切换,但币本位下对冲仍不可开新币本位腿;UI 标明对冲仅 USDC |
|
||||
| 无仓且无半成品 | 允许改 env 并重启后生效 |
|
||||
|
||||
启动或保存配置时若检测到「模式与当前持仓族不一致」,应拒绝进入交易或强制只读提示,避免按错误货币计价.
|
||||
|
||||
---
|
||||
|
||||
## 5. 币本位资金桥与开平流水
|
||||
|
||||
### 5.1 开仓(先买满,再开满)
|
||||
|
||||
```
|
||||
1. 读取交易账户 USDT 可用
|
||||
2. 计算 budget_usdt(§4.1)
|
||||
3. 现货市价:用约 budget_usdt 买入标的币(ETH 或 BTC,与所选期权一致)
|
||||
4. 用账户中可用于权利金的标的币,按卖一限价尽量开满币本位期权
|
||||
- 受:最小张数、卖一深度、单笔一仓规则约束
|
||||
- 不要求「币数量精确等于权利金」;允许开满后仍残留部分币
|
||||
5. 本地记录本轮:模式=coin、budget_usdt、买入币数量/成本、期权成交、桥状态=holding
|
||||
```
|
||||
|
||||
### 5.2 平仓(先平期权,再卖回 USDT)
|
||||
|
||||
```
|
||||
1. 按现有纪律买一限价卖出期权(可分批深度)
|
||||
2. 期权仓清零(或本轮目标完成)后:
|
||||
现货市价卖出账户内「本桥残留 + 平仓回收」相关标的币 → USDT
|
||||
3. 桥状态=closed;交易账户 USDT 更新 → 下一轮自动按新余额复利
|
||||
```
|
||||
|
||||
### 5.3 失败回滚(必须)
|
||||
|
||||
| 失败点 | 处理 |
|
||||
|--------|------|
|
||||
| 现货买入失败 | 不开期权;报错 |
|
||||
| 现货买入成功、期权开仓失败/无卖一 | **自动市价卖回 USDT**;桥状态回滚;告警 |
|
||||
| 期权平仓成功、现货卖回失败 | 持仓显示/告警 **「待卖回 USDT」**;提供仅重试卖币接口;拒绝新开仓直至清理 |
|
||||
| 半成品状态下进程重启 | 启动扫描未完成桥,提示或自动尝试卖回 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 复利与「人工转走」
|
||||
|
||||
### 6.1 口径
|
||||
|
||||
- **加仓/放大**:利润留在 **交易账户 USDT**,下一轮 `×0.95` 自动变大(例:10U 一轮后约 20U → 下一轮约 19U 预算).
|
||||
- **缩小**:运营者 **人工** 将 USDT 转出交易账户(划转到资金账户/提现/他用);系统不自动「复位到 10U」.
|
||||
- **单笔上限开关**(`OKX_OPTIONS_COIN_MAX_USDT_ENABLED`):
|
||||
- **默认关闭** → 纯靠人工转走控规模.
|
||||
- **开启** → `min(账户×0.95, N)`,防止单笔过大.
|
||||
|
||||
### 6.2 与永续「全仓」的关系
|
||||
|
||||
思想同类(吃可用 × 缓冲),但资产不同:
|
||||
|
||||
- 永续全仓:USDT 保证金 × 杠杆 → 合约名义
|
||||
- 币本位单笔:USDT × 缓冲 → 现货币 → 期权权利金
|
||||
|
||||
**不要**复用 `POSITION_SIZING_MODE=full_margin` 直接驱动期权;用 §4.1 独立开关,避免永续模式与期权桥耦合.
|
||||
|
||||
### 6.3 一次一仓
|
||||
|
||||
复利放大后必须坚持:**同时仅一个单笔期权仓**.新开前检查无持仓、无「待卖回」半成品.
|
||||
|
||||
---
|
||||
|
||||
## 7. 产品与 UI
|
||||
|
||||
### 7.1 模式可见性
|
||||
|
||||
- 顶栏或期权设置页展示当前:`单笔期权模式: USDC / 币本位`.
|
||||
- 币本位时展示:交易户 USDT、本轮预估预算(`×0.95` 与是否触达 N 上限)、桥状态.
|
||||
- USDC 模式保持现有 USDC 余额与预算展示.
|
||||
|
||||
### 7.2 开仓按钮文案(示例)
|
||||
|
||||
- 币本位:`买币并开仓(预算 ≈ xx USDT)`
|
||||
- 确认框写明:将市价买 ETH/BTC → 限价买期权;失败会尝试卖回 USDT.
|
||||
|
||||
### 7.3 对冲
|
||||
|
||||
- 币本位模式下:对冲计划入口保持「仅 USDC / 未支持币本位」禁用或只读测算.
|
||||
- 不在此模式自动把对冲预算改成 USDT 桥.
|
||||
|
||||
### 7.4 复盘字段(建议)
|
||||
|
||||
单笔 round-trip 尽量可拆:
|
||||
|
||||
- 期权腿盈亏(币或折合 USDT)
|
||||
- 桥兑换盈亏(买币成本 vs 卖币回收)
|
||||
- 合计 USDT 变化(对复利最有意义)
|
||||
|
||||
首版若难拆细,至少记录:**开仓前 USDT、平仓卖币后 USDT、差值**.
|
||||
|
||||
### 7.5 中控只读(要做)与不下单(不做)
|
||||
|
||||
中控保持现有分工:**监控只读 + 点「期权」进 OKX 实例操作**;本方案**不**在中控增加开平仓/买币桥按钮.
|
||||
|
||||
只读侧须能区分并展示币本位,避免仍按「一律 USDC 权利金」误读.实例上报快照/期权字段建议至少包含:
|
||||
|
||||
| 字段(名可调) | 含义 |
|
||||
|--------------|------|
|
||||
| `options_margin_mode` | `usdc` \| `coin` |
|
||||
| 持仓行可辨本位 | 合约族/结算币/标签,卡片上能看出「币本位」或「USDC」 |
|
||||
| 币本位时预算口径 | 可选:交易户 USDT、本轮 `×0.95` 预估预算、是否触达 N 上限 |
|
||||
| 桥状态(若有半成品) | 如 `holding` / `pending_sell_spot`(待卖回 USDT),中控只展示与告警,不代执行 |
|
||||
|
||||
展示落点(与现网对齐即可,不新开中控交易页):
|
||||
|
||||
- OKX 账户监控里的期权区块 / 期权持仓卡片
|
||||
- 推给教练等用的监控快照文案(若已注入期权行,须带本位标记,避免 AI/人工当成 USDC)
|
||||
|
||||
**不做:**中控代下单、中控触发买 ETH/卖 ETH、中控改 env 切模式.
|
||||
|
||||
---
|
||||
|
||||
## 8. 技术要点
|
||||
|
||||
### 8.1 合约与报价
|
||||
|
||||
- USDC 模式:继续 `ETH-USD_UM` / `BTC-USD_UM` 等现有路径.
|
||||
- 币本位模式:走 OKX **币本位期权**合约族(实现时以 OKX/ccxt 实际 `instId`/settle 为准,写入适配层,勿与 UM 混用同一计价假设).
|
||||
- 权利金与张数换算按币本位规则单独实现;复用「卖一开、买一平、深度校验」状态机,不复用 USDC 金额公式硬套.
|
||||
|
||||
### 8.2 模块建议
|
||||
|
||||
| 块 | 职责 |
|
||||
|----|------|
|
||||
| 模式读取 + 门禁 | env、有仓拒切、启动一致性 |
|
||||
| `options_spot_bridge_lib`(名可调) | USDT↔币 市价买卖、回滚、待卖回重试 |
|
||||
| 开平编排 | 买满 → 开满 → 平 → 卖回 状态机 |
|
||||
| 定价/张数 | 币本位分支 |
|
||||
| UI/API | 预算预览、确认、半成品提示 |
|
||||
| 中控只读 | 消费实例快照中的 `options_margin_mode` 等字段;卡片/文案可识别币本位;**无下单 API** |
|
||||
| Gate | **不纳入**;禁止为本次需求修改 Gate 树 |
|
||||
|
||||
现货下单可与现有账户兑换/划转能力并列,但 **桥必须可自动、可回滚**,与「人工 USDT→USDC」不同.
|
||||
|
||||
共享 `lib/options*` / 快照序列化若调整:仅扩展 OKX 期权载荷;Binance/Gate 账户快照路径保持原样.
|
||||
|
||||
### 8.3 权限与账户
|
||||
|
||||
- API 需具备:交易账户现货市价、期权开平.
|
||||
- 预算只认 **交易账户 USDT**;资金账户有钱但交易户不足 → 明确提示先划转(首版不自动划).
|
||||
|
||||
### 8.4 测试
|
||||
|
||||
- 预算计算:复利开/关、上限开/关、余额边界.
|
||||
- 状态机:开仓失败回滚卖币;平仓后卖币失败 → 待卖回 → 重试成功.
|
||||
- 门禁:有仓切换拒绝;一次一仓.
|
||||
- 回归: `margin_mode=usdc` 时行为与现网一致;对冲仍仅 USDC.
|
||||
- 中控只读:快照含本位字段时卡片/文案可区分 `usdc`/`coin`.
|
||||
- Gate:本次 diff **不应出现** `crypto_monitor_gate/` 业务文件变更.
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
1. `usdc` 模式:单笔期权行为与现网一致.
|
||||
2. `coin` 模式:一轮开平后交易户 USDT 变化符合「买币→期权→卖币」;无异常残留币(或残留时必有待卖回告警).
|
||||
3. 复利:人为把交易户从约 10U 做到约 20U 后,下一轮预览预算约为 `20×0.95`(上限关闭时).
|
||||
4. 上限开关默认关;开启后预算不超过 N.
|
||||
5. 有持仓或半成品时切换模式被拒绝.
|
||||
6. 币本位下对冲不能误开币本位腿.
|
||||
7. 开仓失败自动卖回 USDT,不留下无主现货.
|
||||
8. 中控:**无**期权下单入口新增;监控/快照/持仓只读能看出当前为币本位或 USDC.
|
||||
9. Gate:无相关代码改动;Gate 实例行为与改前一致.
|
||||
|
||||
---
|
||||
|
||||
## 10. 实现顺序建议
|
||||
|
||||
1. 模式 env + 有仓/半成品门禁 + OKX 实例 UI 展示当前模式
|
||||
2. 现货桥(买/卖/回滚/待卖回) + 单测
|
||||
3. 币本位合约适配 + 卖一开/买一平接入编排
|
||||
4. 复利预算预览与开仓确认
|
||||
5. 上限开关
|
||||
6. 快照字段上报 + **中控只读识别币本位**(卡片/文案;不下单)
|
||||
7. 文档:`期权用法.md` 增补币本位章节;`更新文档.md` 记一笔
|
||||
|
||||
---
|
||||
|
||||
## 11. 决策摘要(已拍板)
|
||||
|
||||
| 决策 | 结论 |
|
||||
|------|------|
|
||||
| 对冲 | 暂不接币本位 |
|
||||
| 单笔模式 | env:`usdc` ↔ `coin` |
|
||||
| 有持仓切换 | **拒绝** |
|
||||
| 买币方式 | **先买满预算 USDT 对应的币,再开满期权**(不按权利金精算) |
|
||||
| 复利 | 交易账户 USDT × 0.95;人工转走控规模 |
|
||||
| 单笔不超过 N U | **独立开关,默认关闭** |
|
||||
| 中控 | **不下单**;只读字段/快照**能识别币本位** |
|
||||
| Gate | **本次不改** |
|
||||
| 动机 | 币本位流动性往往优于 USDC,利于成交 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 风险与说明
|
||||
|
||||
- 现货双边手续费与滑点会吃掉部分「名义预算」;小资金下占比更明显.
|
||||
- 持仓期间若账户内残留标的币,平仓卖回时含现货汇率盈亏,需与期权腿区分看待.
|
||||
- 流动性优势随到期、行权、标的变化,不保证每一张合约都厚于 USDC;开仓仍以当场卖一深度为准.
|
||||
- 本方案不改变「符合机会才做、不符合就等」的交易纪律;仅改单笔期权的资金路径与合约族.
|
||||
@@ -41,6 +41,7 @@
|
||||
|------|------|
|
||||
| 第 1 次用户主动平仓 | 默认 **4h** 冷静期 |
|
||||
| 第 2 次用户主动平仓(同一交易日) | **日冻结** |
|
||||
| 平仓亏损达 `RISK_DAILY_LOSS_LIMIT` 次(同一交易日) | **日冻结**(默认 2 次;`0`=不启用) |
|
||||
| 复盘勾选任意情绪标签 | **日冻结** |
|
||||
| 复盘:离场=手动平仓 且说明非空 | 将当前冷静期降为 **1h**(须处于 4h 档冷静期中) |
|
||||
|
||||
@@ -77,11 +78,15 @@ RISK_CONTROL_ENABLED=true
|
||||
RISK_COOLING_HOURS_MANUAL=4
|
||||
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
||||
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
||||
RISK_DAILY_LOSS_LIMIT=2
|
||||
RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
||||
TRADING_DAY_RESET_HOUR=8
|
||||
APP_TIMEZONE=Asia/Shanghai
|
||||
```
|
||||
|
||||
- `RISK_DAILY_LOSS_LIMIT`:任意已平仓交易若盈亏 < 0 计 1 次(含止损/止盈后仍亏损等);达上限当日冻结开仓;`0` 表示不因亏损次数冻结.
|
||||
- `RISK_MANUAL_CLOSE_DAILY_LIMIT`:仅计**用户主动平仓**次数(与亏损次数独立).
|
||||
|
||||
`RISK_COOLING_HOURS_EXTERNAL` 已废弃(外部平仓不再触发风控).
|
||||
|
||||
## API 与 `risk_status` 字段
|
||||
@@ -102,6 +107,7 @@ APP_TIMEZONE=Asia/Shanghai
|
||||
| `can_trade` | 是否允许新开仓(仅风控维度) |
|
||||
| `reason` | 悬停提示文案 |
|
||||
| `active_count` / `max_active_positions` | 当前活跃持仓与 `.env` 中 `MAX_ACTIVE_POSITIONS` |
|
||||
| `daily_loss_count` / `daily_loss_limit` | 当日亏损笔数与上限(`0` 上限表示未启用) |
|
||||
| `cooloff_until_ms` | 1h/4h 冷静期结束时间戳(毫秒) |
|
||||
| `freeze_until_ms` | 倒计时结束时间戳(日冻结为下一交易日切点) |
|
||||
| `freeze_remaining_sec` | 服务端计算的剩余秒数(供调试) |
|
||||
@@ -123,7 +129,7 @@ APP_TIMEZONE=Asia/Shanghai
|
||||
|
||||
## 相关代码
|
||||
|
||||
- `account_risk_lib.py` — 状态机,`enrich_risk_status_countdown`,`apply_position_limit_risk`,`on_user_initiated_close`
|
||||
- `account_risk_lib.py` — 状态机,`enrich_risk_status_countdown`,`apply_position_limit_risk`,`on_user_initiated_close`,`on_closed_trade_pnl`
|
||||
- `hub_bridge.py` — `/api/hub/account-risk/user-close`
|
||||
- `manual_trading_hub/hub.py` — 中控平仓成功后调用 user-close
|
||||
- `strategy_trend_register.py` — `stop_trend_pullback` 结束计划时登记风控
|
||||
|
||||
+10
-8
@@ -14,6 +14,7 @@
|
||||
| **前端仅中文** | 页面只显示中文标签与说明,不显示 `APP_XXX` 等变量名 |
|
||||
| **账户密码不进本页** | 登录用户名/密码在 **系统设置 → 账户密码修改** 中维护 |
|
||||
| **密钥自动托管** | 中控通信密钥,登录会话密钥由 **首次部署脚本自动生成并写入**(一次生成,不轮换),本页不提供编辑 |
|
||||
| **交易所 API 不进本页** | `OKX/BINANCE/GATE_API_*` 仅在服务器实例目录 `.env` 配置;新机默认为空,填真钥后 `pm2 restart --update-env` |
|
||||
| **AI 仅中控配置** | OpenAI / Ollama 等 AI 项已从中控 **系统设置 → AI 配置** 统一维护并同步三所,本页不再展示 |
|
||||
| **保存标注** | 每项标注「保存即生效」或「需重启」;含需重启项时可用「保存并重启」 |
|
||||
|
||||
@@ -61,13 +62,13 @@ Binance / Gate 无期权模块时,第三列最后一格不显示或显示「本
|
||||
| 中文名 | 说明 | 重启 |
|
||||
|--------|------|------|
|
||||
| 开启实盘下单 | 关闭时仅走本地流程,不向交易所发单 | 需重启 |
|
||||
| API Key | 永续子账户 API Key | 需重启 |
|
||||
| API Secret | 永续子账户 Secret | 需重启 |
|
||||
| API Passphrase | 仅 OKX 显示 | 需重启 |
|
||||
| 保证金模式 | 全仓 / 逐仓 | 需重启 |
|
||||
| 持仓模式 | 双向 / 单向净持仓等(按所) | 需重启 |
|
||||
| 仓位查询类型 | 仅 OKX:如 SWAP | 需重启 |
|
||||
| 账户备注 | 企业微信推送中显示的交易所备注 | 保存即生效 |
|
||||
| 显示永续资金 | 仅 OKX:关闭后顶栏隐藏 USDT 资金/交易账户,总资金仅计期权 USDC 侧 | 保存即生效 |
|
||||
|
||||
**交易所 API Key / Secret / Passphrase 不在本页**:请 SSH 编辑各所 `crypto_monitor_*/.env`,新机部署后应为空;配好真钥后重启对应 Flask 与子代理(`pm2 restart … --update-env`).占位符或错误密钥会导致鉴权失败,Gate 上反复请求还可能封 IP.
|
||||
|
||||
**本卡片不包含**:网页登录账号密码,是否关闭登录校验,中控通信密钥.
|
||||
|
||||
@@ -107,8 +108,8 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
|
||||
| 切点前禁止新开仓 | |
|
||||
| 最大同时持仓 | |
|
||||
| 人工最低盈亏比 | |
|
||||
| 强制清仓开关 | |
|
||||
| 强制清仓整点(北京) | |
|
||||
| 强制清仓开关 | `FORCE_CLOSE_ENABLED`;开启后在指定北京整点小时内,市价平掉本地 active 监控仓 |
|
||||
| 强制清仓整点(北京) | `FORCE_CLOSE_BJ_HOUR`(0–23);例 `8` 表示 08:00~08:59;仅扫监控仓,不含交易所裸仓 |
|
||||
|
||||
---
|
||||
|
||||
@@ -131,6 +132,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
|
||||
| 手动平仓冷静(小时) | |
|
||||
| 复盘情绪冷静(小时) | |
|
||||
| 日手动平仓次数上限 | |
|
||||
| 日亏损次数上限 | 默认2;达限当日冻结开仓;0=不启用 |
|
||||
| 情绪标签日冻结 | |
|
||||
|
||||
详见 [account-risk-cooldown.md](./account-risk-cooldown.md).
|
||||
@@ -168,8 +170,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
|
||||
|
||||
| 中文名 | 说明 |
|
||||
|--------|------|
|
||||
| 启用期权模块 | |
|
||||
| 期权 API Key / Secret / Passphrase | 主账户,与永续子账户分离 |
|
||||
| 启用期权模块 | 与永续共用 `OKX_API_*`;不再单独配置期权密钥 |
|
||||
| 期权账户备注 | |
|
||||
| 单笔预算(USDC) | |
|
||||
| 预算缓冲比例 | |
|
||||
@@ -195,6 +196,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
|
||||
|
||||
- 服务:`APP_HOST`,`APP_PORT`,`APP_DEBUG`
|
||||
- 数据:`DB_PATH`,`UPLOAD_DIR`
|
||||
- **交易所 API**:`OKX_API_*`,`BINANCE_API_*`,`GATE_API_*`(仅 SSH;新机应为空)
|
||||
- 关键位门控:全部 `KEY_*`,`KLINE_*`
|
||||
- 轮询与同步:`BALANCE_REFRESH_SECONDS`,`PRICE_REFRESH_SECONDS`,`MONITOR_POLL_SECONDS`,`BREAKEVEN_*`,`RECONCILE_*`
|
||||
- 代理:`OKX_SOCKS_PROXY`,`BINANCE_HTTP_PROXY` 等
|
||||
@@ -211,7 +213,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
|
||||
| 能力 | env 配置 | 系统设置 | 中控系统设置 |
|
||||
|------|----------|----------|--------------|
|
||||
| 登录用户名/密码 | ❌ | ✅ 账户密码修改 | ✅ 中控账户密码 |
|
||||
| 交易所 API | ✅(各所自配) | ❌ | ❌ |
|
||||
| 交易所 API | ❌(仅服务器 `.env`) | ❌ | ❌ |
|
||||
| AI / OpenAI | ❌ | ❌ | ✅ AI 配置(同步三所) |
|
||||
| 导航/区块显示 | ❌ | ✅ 导航显示 | ✅ 显示与导航 |
|
||||
| 手动资金划转 | ❌ | ✅ 永续资金划转 | ❌ |
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
「内照明心」页(`/archive`)用于 **复盘语录 + 交易记录回顾 + 按需 K 线**.左侧维护每日复盘语录(最多 100 条);右侧按日期区间列出开仓记录,展示区间统计,并可展开 K 线图表对照单笔交易.
|
||||
|
||||
顶栏有 **永续 / 期权** 品种切换:
|
||||
|
||||
| 品种 | 数据 | 说明 |
|
||||
|------|------|------|
|
||||
| **永续** | 三所 `trade_records` → `archive_trade_cache` | 含犯病标签、K 线 |
|
||||
| **期权** | OKX `options_review_trades` → `archive_options_trade_cache` | 独立 Tab;同步进中控库后离线可看;默认排除对冲腿 |
|
||||
|
||||
同步:「同步」按钮与后台 4h 任务会同时拉永续与期权(仅 `capabilities` 含 `options` 的账户).
|
||||
|
||||
与行情区 `hub_kline.db`(15 天滚动缓存)**完全独立**:档案库只增不删,从建档起永久保留.
|
||||
|
||||
## 页面布局
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
| 文档 | 实例 | 状态 |
|
||||
|------|------|------|
|
||||
| [交易执行手册-v2-期权与合约.md](../交易执行手册-v2-期权与合约.md) | 中控「策略说明」·执行手册v2 | **现行**:无对冲;1H→空间→结构→定损盈→期权/合约 |
|
||||
| [交易执行手册-期权与Gate.md](../交易执行手册-期权与Gate.md) | 中控「策略说明」·执行手册v1 | 含对冲;历史对照 |
|
||||
| [交易行为准则-开单三检.md](../交易行为准则-开单三检.md) | 中控「策略说明」·行为准则 | 开单前信号/流程/情绪三检 |
|
||||
| [binance-alt-trend-long.md](./binance-alt-trend-long.md) | 币安山寨·多头趋势 | v0.4 讨论稿 |
|
||||
| [okx-trend-both.md](./okx-trend-both.md) | OKX·多空趋势 | v0.4 讨论稿 |
|
||||
| [gate-intraday.md](./gate-intraday.md) | Gate·BTC 日内 | v0.2 |
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"exchange": "behavior",
|
||||
"title": "开单三检清单",
|
||||
"version": "v0.1",
|
||||
"groups": [
|
||||
{
|
||||
"title": "信号判断",
|
||||
"items": [
|
||||
"最核心、最明确的一个点位/结构确认已写清",
|
||||
"该确认本身足够清晰(不是靠一长串宏大叙事)",
|
||||
"已过主链条:1H方向 → 空间 → 结构 → 定损盈 → 选工具(期权/合约,无对冲);不够格则空仓"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "流程确认",
|
||||
"items": [
|
||||
"账户资金与当日额度符合要求",
|
||||
"单笔风险 / 组合敞口在手册预算内",
|
||||
"无跳步;超限则暂停开单"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "情绪自检",
|
||||
"items": [
|
||||
"心态是「符合系统所以做」,不是「证明自己」",
|
||||
"无怕踏空 → 否则放弃",
|
||||
"无回本 / 报复交易念头 → 否则放弃",
|
||||
"不需要再找更多开单理由"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"exchange": "playbook_v2",
|
||||
"title": "执行手册 v2 开仓清单(无对冲)",
|
||||
"version": "v0.1",
|
||||
"groups": [
|
||||
{
|
||||
"title": "主链条",
|
||||
"items": [
|
||||
"1H 方向清楚(含明显 N 字);跟的是 1H 波段",
|
||||
"空间足够(支撑/阻力;至少约 ≥2%)",
|
||||
"结构已出现且量级够(约 8h+ / 48 根 15m)",
|
||||
"止损按模型:结构突破=外沿;假突破=针尖;止盈与 RR 已接受",
|
||||
"工具只在「期权 / 合约」中选择;未开对冲"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "账户与仓位",
|
||||
"items": [
|
||||
"只动 OKX 期权或 Gate 合约;其它账户零操作",
|
||||
"期权:约 10U、一次一仓;合约:止损约 5U、本位置次数未超两次",
|
||||
"合计最坏风险可接受(约 ≤20U 量级)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "离场与心态",
|
||||
"items": [
|
||||
"期权离场只认规则止盈或到期;开仓后中间不手平",
|
||||
"不是「今天也要开点期权」;过检才开,不过则空仓",
|
||||
"已过开单三检(信号 / 流程 / 情绪)"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -186,21 +186,22 @@
|
||||
- **禁止** 盘中亏着 **手点平仓** 充当止损(破坏统计与连错规则).
|
||||
- 若违规手动平亏:**视为当日纪律失败,建议停手**;复盘结果 **不得** 记为「止损」糊弄统计.
|
||||
|
||||
### 9.3 时间出场:仅 0 点(程序已实现)
|
||||
### 9.3 时间出场:整点强制清仓(程序已实现,可开关)
|
||||
|
||||
- **唯一** 时间类出场:**当日 0:00(北京时间)前必须空仓**(赚赔都平).
|
||||
- **不使用** 下单表单里的 1h / 2h / 4h「开仓后 N 小时平」(`time_close`);与本策略无关.
|
||||
- **程序兜底**(三所共用,Gate 已启用):
|
||||
- **程序兜底**(三所共用;Gate 可用 env 开关):
|
||||
|
||||
| env | 说明 |
|
||||
|-----|------|
|
||||
| `FORCE_CLOSE_ENABLED=true` | 开启整点强制清仓 |
|
||||
| `FORCE_CLOSE_BJ_HOUR=0` | 北京时间 **0 点那一小时**(00:00~00:59)执行 |
|
||||
| `FORCE_CLOSE_ENABLED` | `true` 开启 / `false` 关闭整点强制清仓 |
|
||||
| `FORCE_CLOSE_BJ_HOUR` | 北京时间整点小时(如 `0`=`00:00~00:59`,`8`=`08:00~08:59`) |
|
||||
|
||||
- 实现:`force_close_before_reset()`(各实例 `app.py` 后台循环调用).
|
||||
- 行为:对该小时仍 **active** 的 `order_monitors` **市价全平**,取消交易所触发单,写交易记录.
|
||||
- **系统结果字段**:`result = 强制清仓`;备注含「北京时间 0:00 整点风控清仓」.
|
||||
- **策略口语「0 点平仓」= 系统「强制清仓」**,统计连错时按 §8 盈亏判定,不按字段名区分.
|
||||
- 行为:开启时,在该整点小时内对仍 **active** 的 `order_monitors` **市价全平**,取消交易所触发单,写交易记录.
|
||||
- **系统结果字段**:`result = 强制清仓`;备注含「北京时间 X:00 整点风控清仓」.
|
||||
- **仅扫本地监控仓**;交易所裸仓且无 active 监控时**不会**被此逻辑平掉.
|
||||
- UI:开仓规则说明「平仓 / 委托 / 强制清仓」折叠区 + 顶栏徽章(开启时).
|
||||
- 持仓卡可手动「平仓 / 委托 / 撤止盈止损」(与纪律策略并行;策略上仍不建议亏着手平充当止损).
|
||||
|
||||
> **与 `TRADING_DAY_RESET_HOUR=8` 无关**:后者只切 **交易日**(统计,8 点前禁开等),**不会**自动平仓.
|
||||
|
||||
@@ -244,16 +245,16 @@
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 日内 profile 判定 | `is_intraday_trading_profile()`(`lib/trade/entry_model_lib.py`) |
|
||||
| 0 点强制清仓 | `FORCE_CLOSE_ENABLED` + `FORCE_CLOSE_BJ_HOUR`;`force_close_before_reset()`;结果 **`强制清仓`** |
|
||||
| UI 标识 | 顶栏 **强制清仓 已开启** 徽章 + 持仓卡片 **倒计时**(三所 + 中控) |
|
||||
| 整点强制清仓 | `FORCE_CLOSE_ENABLED` + `FORCE_CLOSE_BJ_HOUR`;`force_close_before_reset()`;结果 **`强制清仓`** |
|
||||
| UI 标识 | 顶栏徽章(开启时) + 开仓规则说明折叠区;持仓卡可手动平仓/委托 |
|
||||
| 交易记录展示 | 三所 UI / 中控:`强制清仓` 与止损同类 badge |
|
||||
| 三所统一 | 币安 / OKX / Gate 同一函数与 env;**将来改日内只需各所 `.env` 打开,无需改代码** |
|
||||
| 三所统一 | 币安 / OKX / Gate 同一函数与 env;**改日内只需各所 `.env`**,无需改代码 |
|
||||
|
||||
Gate 当前建议 env(节选):
|
||||
Gate 示例 env(节选;是否开启按账户纪律决定):
|
||||
|
||||
```env
|
||||
FORCE_CLOSE_ENABLED=true
|
||||
FORCE_CLOSE_BJ_HOUR=0
|
||||
FORCE_CLOSE_ENABLED=false
|
||||
FORCE_CLOSE_BJ_HOUR=8
|
||||
TRADING_DAY_RESET_HOUR=8
|
||||
```
|
||||
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ python3 scripts/bootstrap_deploy_secrets.py
|
||||
|
||||
## 4. 实例 env 配置页变更
|
||||
|
||||
三所 **env 配置** 页已 **移除「AI 复盘」卡片**.交易所 API,企业微信,交易执行等仍各所自配.
|
||||
三所 **env 配置** 页已 **移除「AI 复盘」卡片**.企业微信、交易执行等仍各所自配;**交易所 API 仅服务器 `.env`**,前端不再展示.
|
||||
|
||||
实例侧若通过 API 提交已移除的 AI 键,会被白名单过滤,不会写入.
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# 交易执行手册 v2(期权 / 合约 · 无对冲)
|
||||
|
||||
> 个人开单纪律第二版(2026-07-24 起)。
|
||||
> **相对 v1:去掉期期对冲 / 偏置对冲;工具只留期权与合约。**
|
||||
> 目标:少而精、珍惜机会、样本干净;**不保证收益**。
|
||||
> 旧版(含对冲)见 [交易执行手册-期权与Gate.md](./交易执行手册-期权与Gate.md)。
|
||||
> **开单前先过** [交易行为准则-开单三检.md](./交易行为准则-开单三检.md);本手册管怎么做单。
|
||||
|
||||
---
|
||||
|
||||
## 1. 主链条(强制)
|
||||
|
||||
```
|
||||
1H 方向 → 空间 → 结构 → 定损盈 → 选工具(期权 / 合约)
|
||||
```
|
||||
|
||||
任一步不过 → **空仓等待**,不为开单找理由。
|
||||
|
||||
| 步骤 | 做什么 | 否决 |
|
||||
|------|--------|------|
|
||||
| **1H 方向** | 趋势周期以 **1H** 为准;1H 上要有明显 **N 字**。跟 1H 波段,不跟 4H 打架硬做。例:4H 多、1H 空 → 做 1H 空头波段 | 1H 方向不清、无 N 字 |
|
||||
| **空间** | 做空看下方支撑,做多看上方阻力;至少约 **≥2%** 才值得谈(常期望更大空间,如 ~5%) | 空间不够、贴着墙 |
|
||||
| **结构** | 方向与空间过关后,在 **15m / 5m** 等结构;结构量级至少约 **8h+**(约 **48 根 15m**)。形态:收敛 / 两段式回调 / 箱体 / 假突破等 | 结构未出现、磨不够就抢跑 |
|
||||
| **定损盈** | 结构出现后定义止损、止盈,算盈亏比。结构突破 → 止损在 **结构外沿**;假突破 → 止损在 **假突破针尖** | 损盈说不清、RR 不接受 |
|
||||
| **选工具** | 只在上四步都齐之后选:**期权** 或 **合约**。波段有足够时间考虑,不急着下手 | 用对冲、或「每天都要开点期权」 |
|
||||
|
||||
**丢掉对冲。** 对冲易带来「有保护就能多做」的幻觉;本版不做期期对冲、不做偏置对冲壳。
|
||||
|
||||
---
|
||||
|
||||
## 2. 总原则
|
||||
|
||||
1. **工具只有期权与合约**;同一时段尽量只让一边「说话」。
|
||||
2. **看不懂不做**;过滤比频率重要。日更不是目标,过检才是。
|
||||
3. 动手前先过 **开单三检**(信号 → 流程 → 情绪);不过 → 空仓。
|
||||
4. 玩法必须走完主链条;不够格 → 空仓。
|
||||
5. 期权离场只认:**系统/规则止盈** 与 **到期**;**开仓后中间不手动平仓**(紧急例外不进策略样本)。
|
||||
6. 过程可控、结果随缘:用规则管仓位与次数,不追求每天打满。
|
||||
|
||||
---
|
||||
|
||||
## 3. 账户与分工
|
||||
|
||||
| 账户 | 角色 | 说明 |
|
||||
|------|------|------|
|
||||
| OKX 期权 | **主业之一** | 方向单(虚值等);**不做对冲腿** |
|
||||
| Gate 合约 | **主业之一** | 结构清楚时的波段;与期权尽量错开 |
|
||||
| 其它 | 暂不做 | 减少分心与样本污染 |
|
||||
|
||||
**到期选择(期权)**
|
||||
|
||||
- 方向单默认 **一天期**。
|
||||
- 尽量在 **北京时间下午 4 点后** 开 **次日到期**,覆盖较完整的美盘 + 亚盘 + 欧盘窗口。
|
||||
- 更长故事优先考虑合约,不强行拉长期权。
|
||||
|
||||
---
|
||||
|
||||
## 4. 入场逻辑(两类工具)
|
||||
|
||||
开仓前先判断:当前是 **买方向的期权表达**,还是 **合约波段**。
|
||||
|
||||
### 4.1 方向明确 · 结构到位 → 期权
|
||||
|
||||
- **条件**:主链条全部过关;常用结构突破或假突破模型在 15m/5m 成立。
|
||||
- **工具**:**一天期期权方向单**(空间够时优先考虑 **虚值**:同止损口径下盈亏比往往更高)。
|
||||
- **离场**:规则止盈或到期;不手平。
|
||||
- **默认**:先只开期权,不上合约。
|
||||
|
||||
### 4.2 结构到位 · 更适合合约 → 合约
|
||||
|
||||
- **条件**:主链条过关;位置极明确;同一位置机会计数见 Gate 纪律。
|
||||
- **工具**:Gate 合约波段;止损挂在模型对应位置(外沿 / 针尖)。
|
||||
- **独立假突破**(没有先开突破期权时):优先 **只做合约** 或 **空仓**,勿与「突破期权后再加仓」混用同一套仓。
|
||||
|
||||
### 4.3 明确不做
|
||||
|
||||
- 横盘「买波动」的 **期期对冲**(Call+Put)。
|
||||
- 任何「对冲壳 + 偏置」伪装成单边。
|
||||
- 为了「今天也开点期权」而破主链条。
|
||||
|
||||
---
|
||||
|
||||
## 5. 仓位与风险预算
|
||||
|
||||
**总资金参考:约 800U。**
|
||||
|
||||
| 项目 | 规则 |
|
||||
|------|------|
|
||||
| 单笔期权 | 约 **10U** 权利金预算;**一次只持有一个期权仓位** |
|
||||
| Gate 合约 | 日内保证金约 **50U**、约 **10 倍**;有单才用,无单为 0 |
|
||||
| 合约止损 | 一般约 **5U**;单笔最大亏损不超过约 **10U** |
|
||||
| 日损失心理框 | 期权+合约若都错:合计大约 **≤20U**;都对时期望可到 **40U+**(理想情形,非每日目标) |
|
||||
|
||||
相对 800U:单笔约 **1.25%** 量级;全错一天约 **2.5%** 量级——防守优先。
|
||||
|
||||
**叠加红线**
|
||||
|
||||
- 期权一仓 + 合约同日存在时,按合计风险接受最坏约 20U,且尽量少「同向双开」。
|
||||
- 不为「好像有保护」放大仓位(本版已无对冲保护叙事)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 合约日纪律(Gate)
|
||||
|
||||
1. 只做 **很明确的位置**;不明确基本不做。
|
||||
2. 动手前想清:**如何进场**(假突破 / 结构突破)。
|
||||
3. **同一位置最多两次机会**:结构突破、假突破。
|
||||
4. **两次都错 → 当日不再做单**(即使后面更「看起来清楚」也留到明天)。
|
||||
5. 止损约 **5U**;波段规则开仓前想清。
|
||||
6. 离场以结构止盈/止损为准。
|
||||
|
||||
---
|
||||
|
||||
## 7. 期权日纪律(OKX)
|
||||
|
||||
1. **不手动平仓**;只等规则止盈或到期(紧急手平标记为非策略样本)。
|
||||
2. 一次一仓;约 10U 权利金。
|
||||
3. **不做对冲**;不做「每天默认开期权」。
|
||||
4. 结构突破 / 假突破用期权表达时,损位跟模型:外沿 / 针尖。
|
||||
5. 默认一天期;优先完整会话窗口再开。
|
||||
|
||||
---
|
||||
|
||||
## 8. 开仓前自检清单
|
||||
|
||||
- [ ] 今天是否只动「期权 / 合约」,其它账户零操作?是否 **未开对冲**?
|
||||
- [ ] **1H 方向**是否清楚(含 N 字)?
|
||||
- [ ] **空间**是否足够(支撑/阻力,至少约 ≥2%)?
|
||||
- [ ] **结构**是否出现且量级够(约 8h+ / 48×15m)?
|
||||
- [ ] **止损 / 止盈**是否按模型定好(外沿或针尖)?RR 是否接受?
|
||||
- [ ] **工具**选的是期权还是合约?理由是否写清?
|
||||
- [ ] 期权:止盈条件与「接受到期」是否写清?
|
||||
- [ ] 合约:本位置第几次机会?止损约 5U 设好了吗?今日两次是否已用完?
|
||||
|
||||
---
|
||||
|
||||
## 9. 一句话版本
|
||||
|
||||
> **1H 定方向 → 量空间 → 等够级别的结构 → 按模型定损盈 → 只在期权与合约里选工具;不对冲;期权不手平;一位置两次,错完收工;珍惜机会,日更不是目标。**
|
||||
|
||||
---
|
||||
|
||||
## 10. 修订记录
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-07-24 | v2 初版:去掉对冲;主链条 1H→空间→结构→定损盈→期权/合约;吸收假突破针尖 / 结构外沿止损口径 |
|
||||
@@ -0,0 +1,149 @@
|
||||
# 交易执行手册 v1(期权为主 · Gate 为辅 · 含对冲)
|
||||
|
||||
> 个人开单纪律与仓位规则(2026-07 起)。**本版保留对冲,仅作历史/对照。**
|
||||
> **现行主版本请用** [交易执行手册-v2-期权与合约.md](./交易执行手册-v2-期权与合约.md)(无对冲:1H→空间→结构→定损盈→期权/合约)。
|
||||
> 目标:少而精、可控回撤、样本干净;**不保证收益**。
|
||||
> 工具:OKX 期权(主)+ Gate 合约(辅);其它账户暂不做。
|
||||
> **开单前先过** [交易行为准则-开单三检.md](./交易行为准则-开单三检.md)(信号 / 流程 / 情绪);本手册管怎么做单。
|
||||
---
|
||||
|
||||
## 1. 总原则
|
||||
|
||||
1. **主做期权,合约为辅**;同一时段尽量只让一边「说话」。
|
||||
2. **看不懂不做**;过滤比频率重要。
|
||||
3. 动手前先过 **开单三检**(信号判断 → 流程确认 → 情绪自检);不过 → 空仓。详见 [行为准则](./交易行为准则-开单三检.md)。
|
||||
4. 开仓前再过玩法三关:**方向 → 空间 → 值不值得**。不够格 → 空仓。
|
||||
5. 期权离场只认:**止盈(规则触发)** 与 **到期**;**不手动平仓**(紧急例外单不算策略样本)。
|
||||
6. 过程可控、结果随缘:用规则管仓位与次数,不追求每天打满理想上限。
|
||||
|
||||
---
|
||||
|
||||
## 2. 账户与分工
|
||||
|
||||
| 账户 | 角色 | 说明 |
|
||||
|------|------|------|
|
||||
| OKX 期权 | **主业** | 横盘对冲 / 方向单 / 偏置对冲 |
|
||||
| Gate 合约 | **辅业** | 结构清楚时的波段;与期权尽量错开 |
|
||||
| 其它 | 暂不做 | 减少分心与样本污染 |
|
||||
|
||||
**到期选择(期权)**
|
||||
|
||||
- 方向单、对冲默认 **一天期**。
|
||||
- 尽量在 **北京时间下午 4 点后** 开 **次日到期**,覆盖较完整的美盘 + 亚盘 + 欧盘窗口。
|
||||
- Gate 波段样本里最长持仓约十余小时量级 → 一天期权通常够表达;更长故事优先考虑合约,不强行拉长期权。
|
||||
|
||||
---
|
||||
|
||||
## 3. 入场逻辑(三类)
|
||||
|
||||
开仓前先判断:当前是 **买波动** 还是 **买方向**。
|
||||
|
||||
### 3.1 横盘 → 期期对冲
|
||||
|
||||
- **条件**:横盘已持续较久(例如满约 12 小时),方向不明。
|
||||
- **工具**:一天期 Call + Put(对冲);总权利金预算见仓位章。
|
||||
- **意图**:买接下来的波动,不赌单边。
|
||||
- **期间**:一般 **不再开 Gate 方向单**(已在买波动,勿叠同一宏观暴露)。
|
||||
|
||||
### 3.2 方向明确 · 结构突破 → 期权
|
||||
|
||||
- **条件**:方向、空间、值不值得均过关;结构突破成立。
|
||||
- **工具**:**一天期期权方向单**(或明显顺势结构)。
|
||||
- **离场**:目标止盈或到期;不手平。
|
||||
- **默认**:先只开期权,不上合约。
|
||||
|
||||
### 3.3 结构突破后 · 反向假突破确认 → 可加合约
|
||||
|
||||
- **条件**:已有结构突破的期权表达;随后出现反向假突破且确认失败、续原方向。
|
||||
- **工具**:Gate 合约 **小仓加强**(止损纪律见下)。
|
||||
- **注意**:BTC 合约与 ETH 期权高度相关,属加重暴露,不是分散;仓位按「一笔故事」计风险。
|
||||
- **假突破定义**需事先写死(相对哪段结构、如何确认收回),避免临场随便加仓。
|
||||
|
||||
### 3.4 独立假突破(没有先开突破期权时)
|
||||
|
||||
- 按「假破专用」处理:优先 **只做合约** 或 **空仓**,勿与「突破后再假破加仓」混用同一套仓。
|
||||
|
||||
---
|
||||
|
||||
## 4. 对冲偏好(偏置对冲)
|
||||
|
||||
在「尽量用对冲」的前提下:
|
||||
|
||||
- 对冲内常带 **做多/做空比例**;若略偏多,则 **做多一侧比例更高**。
|
||||
- 顺势侧尽量用 **实值(或更实)**:
|
||||
- 方向对了:可能 **少赚一点**(相对纯单边);
|
||||
- 方向错了:争取 **不亏或少亏**(相对虚值双买两边磨光)。
|
||||
- **总权利金仍锁在对冲预算内**(见仓位);偏置只调张数/行权远近,不偷偷加预算。
|
||||
- **偏置有度**(例如勿极端到名存实亡的单边);完全没方向时更接近均分/近平值;方向非常明确时应走单边期权,不必硬套对冲壳。
|
||||
- 复盘建议区分:**中性对冲** vs **偏多/偏空对冲**,以便检验偏置是否真压低亏损。
|
||||
|
||||
---
|
||||
|
||||
## 5. 仓位与风险预算
|
||||
|
||||
**总资金参考:约 800U。**
|
||||
|
||||
| 项目 | 规则 |
|
||||
|------|------|
|
||||
| 单笔期权 | 约 **10U** 权利金预算;**一次只持有一个期权仓位** |
|
||||
| 期期对冲 | **合计约 10U**(两腿加总,不是各 10) |
|
||||
| Gate 合约 | 日内保证金约 **50U**、约 **10 倍**;有单才用,无单为 0 |
|
||||
| 合约止损 | 一般约 **5U**;单笔最大亏损不超过约 **10U** |
|
||||
| 日损失心理框 | 期权+合约若都错:合计大约 **≤20U**;都对时期望可到 **40U+**(理想情形,非每日目标) |
|
||||
|
||||
相对 800U:单笔约 **1.25%** 量级;全错一天约 **2.5%** 量级——防守优先。
|
||||
|
||||
**叠加红线**
|
||||
|
||||
- 期权一仓 + 合约加仓同日存在时,按合计风险接受最坏约 20U,且尽量少「同向双开」。
|
||||
- 不因「期权偏置可能少亏」而放大合约。
|
||||
|
||||
---
|
||||
|
||||
## 6. 合约日纪律(Gate)
|
||||
|
||||
1. 只做 **很明确的位置**;不明确基本不做。
|
||||
2. 动手前想清:**如何进场**。
|
||||
3. **同一位置最多两次机会**:结构突破、假突破。
|
||||
4. **两次都错 → 当日不再做单**(即使后面更「看起来清楚」也留到明天)。
|
||||
5. 止损约 **5U**;波段规则(含是否时间离场)开仓前想清。
|
||||
6. 已关闭「强制清仓」误伤策略意图时,离场以结构止盈/止损为准;历史里「强制清仓但盈利」按规则结果理解,复盘看盈亏与结构。
|
||||
|
||||
---
|
||||
|
||||
## 7. 期权日纪律(OKX)
|
||||
|
||||
1. **不手动平仓**;只等规则止盈或到期(紧急手平标记为非策略样本)。
|
||||
2. 一次一仓;对冲共 10U。
|
||||
3. 横盘对冲期间一般不开 Gate 方向单。
|
||||
4. 结构突破用期权表达;假破加强才考虑合约。
|
||||
5. 默认一天期;优先完整会话窗口再开。
|
||||
|
||||
---
|
||||
|
||||
## 8. 开仓前自检清单
|
||||
|
||||
- [ ] 今天是否只动「期权 / Gate」,其它账户零操作?
|
||||
- [ ] 买波动还是买方向?工具选对了吗?
|
||||
- [ ] 方向 / 空间 / 值不值得是否都过关?
|
||||
- [ ] 期权:止盈条件与「接受到期」是否写清?
|
||||
- [ ] 对冲:比例与实值偏置是否有度?总预算是否仍 ≤10U?
|
||||
- [ ] 合约:本位置第几次机会?止损约 5U 设好了吗?
|
||||
- [ ] 若加合约:是否已有突破期权且假破确认?是否当成一笔故事控总风险?
|
||||
- [ ] 今日合约两点机会是否已用完?(用完则收工)
|
||||
|
||||
---
|
||||
|
||||
## 9. 一句话版本
|
||||
|
||||
> **横盘对冲(可偏置实值);突破用一天期权;假破确认后小仓合约加强;先过方向/空间/值不值得;期权不手平;一位置两次,错完收工;单笔小亏、组合回撤可控。**
|
||||
|
||||
---
|
||||
|
||||
## 10. 修订记录
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-07-21 | 初版:根据实盘讨论整理(期权为主、Gate 为辅、仓位与日停手规则) |
|
||||
| 2026-07-23 | 挂钩开单三检行为准则 |
|
||||
| 2026-07-24 | 标注为 v1(含对冲);现行纪律迁至执行手册 v2 |
|
||||
Binary file not shown.
@@ -0,0 +1,115 @@
|
||||
# 交易行为准则(开单三检)
|
||||
|
||||
> 个人强制思维动作 · 初级版(2026-07)。
|
||||
> **不是策略**,是开单前的「交易防火墙」:保证动作在可控轨道上,**不判断这笔会不会赚钱**。
|
||||
> 来源:中控 AI 复盘对话(2026-07-22)与本人归纳。
|
||||
> 仓位与玩法细则见 [交易执行手册-期权与Gate.md](./交易执行手册-期权与Gate.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 一句话
|
||||
|
||||
> **信号够不够清晰?流程有没有跑通?情绪是不是在证明自己?三检不过 → 不开。**
|
||||
|
||||
复盘成败的第一标准:**三检是否完整完成**,而不是这笔盈亏。
|
||||
|
||||
---
|
||||
|
||||
## 2. 总循环
|
||||
|
||||
```
|
||||
信号判断 → 流程确认 → 情绪自检 → 全部通过
|
||||
→ 开仓 → 等待系统结果(止盈 / 止损 / 到期)
|
||||
→ 本次结束 → 复盘整环 → 等待下一个信号
|
||||
```
|
||||
|
||||
任一步否决 → **空仓离开**,不找补丁理由硬开。
|
||||
|
||||
---
|
||||
|
||||
## 3. 开单前:三秒停顿
|
||||
|
||||
手要动之前,强制停顿,把注意力从宏大叙事拉回内部三点:
|
||||
|
||||
1. 我的**核心信号**是什么?
|
||||
2. **安全流程**跑通了吗?
|
||||
3. 我现在是冷静执行,还是急着证明 / 怕踏空 / 想回本?
|
||||
|
||||
---
|
||||
|
||||
## 4. 三检细则
|
||||
|
||||
### 4.1 信号判断(Signal Judgment)
|
||||
|
||||
**问:** 这次入场,最核心、最明确的那一个点位 / 结构确认是什么?它本身够不够清晰?
|
||||
|
||||
| 通过 | 否决 |
|
||||
|------|------|
|
||||
| 能用一句话说清「唯一核心确认」 | 说不清、要靠一长串宏观故事才能自圆其说 |
|
||||
| 点位 / 结构本身已经够清楚 | 「好像有戏」但确认点模糊 |
|
||||
| 只描述事实与系统条件 | 堆细节证明自己分析很厉害 |
|
||||
|
||||
对照执行手册时:先过 **1H 方向 → 空间 → 结构 → 定损盈 → 选工具(期权/合约)**;不够格 → 空仓(见手册 v2)。
|
||||
|
||||
### 4.2 流程确认(Process Confirmation)
|
||||
|
||||
**问:** 决定执行前,有没有按设定步骤检查资金与风险敞口?内部安全流程跑通了吗?
|
||||
|
||||
| 通过 | 否决 / 暂停 |
|
||||
|------|-------------|
|
||||
| 账户资金与当日额度符合要求 | 资金或次数已触限 |
|
||||
| 单笔风险 / 组合敞口在手册预算内 | 单笔或日最坏超限 → **暂停开单** |
|
||||
| 该走的检查项没有跳步 | 「先开了再说」 |
|
||||
|
||||
细则数字以执行手册仓位章为准(单笔期权、对冲总权利金、Gate 止损与日停手等)。
|
||||
|
||||
### 4.3 情绪自检(Emotional Self-Check)
|
||||
|
||||
**问:** 看到复杂结构与逻辑时,内心是什么?是「必须证明分析是对的」,还是「符合系统要求,所以做」?
|
||||
|
||||
| 通过 | 否决(果断放弃) |
|
||||
|------|------------------|
|
||||
| 「符合系统信号 + 账户没问题 → 开」 | 「怕踏空」 |
|
||||
| 不需要再找更多开单理由 | 「上回亏了,这单要回本」 |
|
||||
| 旁观者视角、可接受空仓 | 「必须证明我是对的」 |
|
||||
|
||||
**原则:** 不为开单找理由;情绪红灯亮了,信号再好看也不开。
|
||||
|
||||
---
|
||||
|
||||
## 5. 开仓后纪律(与手册一致)
|
||||
|
||||
- 开仓后:**等待系统结果**(规则止盈 / 止损 / 到期),不靠情绪手平(紧急例外不算策略样本)。
|
||||
- 持仓期盯的是「程序与纪律是否正常」,不是浮盈浮亏数字本身。
|
||||
- 无信号时的空档也算训练:反复在脑子里空跑三检,比硬找单更重要。
|
||||
|
||||
---
|
||||
|
||||
## 6. 复盘只记什么
|
||||
|
||||
每次交易(含未开成的冲动)建议只记:
|
||||
|
||||
1. 信号判断:做了吗?核心确认写了什么?是否清晰?
|
||||
2. 流程确认:资金 / 敞口是否过关?有无跳步?
|
||||
3. 情绪自检:当时心态是哪一类?有无怕踏空 / 回本?
|
||||
4. 结果:止盈 / 止损 / 到期 / 未开 — **结果不推翻「三检是否完成」这一评分。**
|
||||
|
||||
---
|
||||
|
||||
## 7. 与执行手册的分工
|
||||
|
||||
| 文档 | 管什么 |
|
||||
|------|--------|
|
||||
| **本准则** | 能不能动手(防火墙 / 操作系统) |
|
||||
| **执行手册** | 怎么做单(期权 / Gate、仓位、离场) |
|
||||
|
||||
先过本准则三检,再谈手册里的玩法与仓位。
|
||||
|
||||
---
|
||||
|
||||
## 8. 修订记录
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-07-23 | 初级版:三检 + 总循环 + 红线;对齐 AI 复盘与本人总结 |
|
||||
| 2026-07-24 | 信号检对齐执行手册 v2 主链条(1H→空间→结构→定损盈→期权/合约) |
|
||||
@@ -0,0 +1,233 @@
|
||||
# 实盘下单 · 盘口深度预览 — 开发方案
|
||||
|
||||
> 状态:**方案待实现**(按本文落地;改需求先改本文).
|
||||
> 范围:**三所实例**实盘下单监控(Binance / OKX / Gate);中控嵌入同一表单时一并带上.
|
||||
> 相关:[manual-order-rr-preview.md](./manual-order-rr-preview.md) · [position-sizing-mode.md](./position-sizing-mode.md) · 期权侧已有「卖一开 / 买一平」深度硬约束(本方案**不照搬硬挡**,首版以预览为主).
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
实盘下单表单目前只展示 **标的现价/标记价**,再按止损与计仓模式算出预估风险 / 预估 RR.
|
||||
|
||||
- **资金小**:名义仓位通常远小于盘口前几档,市价成交贴近买卖一,现价参考够用.
|
||||
- **资金大**(尤其 `POSITION_SIZING_MODE=full_margin`):名义 = 可用保证金 × 缓冲 × 杠杆,容易到数十万 U. 市价单会沿对手盘穿档,入场均价偏离「现价」后,止损距离与有效盈亏比都会偏.
|
||||
|
||||
典型例子:
|
||||
|
||||
| 条件 | 含义 |
|
||||
|------|------|
|
||||
| 可用约 1 万 U,20 倍杠杆,全仓 | 计划名义约 **20 万 U** |
|
||||
| **市价做空** | 立刻卖出 ≈ 20 万 U 名义 → 吃 **买单(bid)** |
|
||||
| **市价做多** | 立刻买入 ≈ 20 万 U 名义 → 吃 **卖单(ask)** |
|
||||
|
||||
用户需要的不是整本订单簿娱乐墙,而是回答:
|
||||
|
||||
> 当前计划名义下,对手盘前几档**能不能接住**,接住后的**预估均价 / 滑点**大概多少?
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标(首版)
|
||||
|
||||
在「实盘下单监控」开仓区增加 **计划名义 vs 对手盘深度** 的只读预览:
|
||||
|
||||
1. 按当前表单算出的 **计划名义(USDT)** 与 **方向**,取对应一侧盘口.
|
||||
2. 从最优档往外累加,直到累计名义 ≥ 计划名义(或盘口耗尽).
|
||||
3. 展示:吃到第几档、累计可吸收名义、预估成交均价(VWAP)、相对参考价的滑点(bps 或 %).
|
||||
4. **不拦截下单**(首版);可选标黄提示,见 §6.
|
||||
|
||||
与现有「预估风险 / 预估盈利 / 预估盈亏比」并列,作为下单前参考,不替代服务端风控与交易所真实成交.
|
||||
|
||||
---
|
||||
|
||||
## 3. 不做(首版外)
|
||||
|
||||
- 完整 20/50 档盘口图、深度图动画、WebSocket 持续推送盘口(首版 REST 轮询即可)
|
||||
- 按深度 **自动缩仓** 或 **禁止开仓**(期权硬约束那套;列为二期,见 §10)
|
||||
- 限价挂单的「挂单价到盘口距离」专项(可后加;首版聚焦市价吃单路径)
|
||||
- 平仓/止损单穿档预估(开仓侧先做;平仓可二期)
|
||||
- 改开仓逻辑、改计仓公式、改交易所下单路径
|
||||
- 中控独立深度页或跨所聚合盘口
|
||||
|
||||
---
|
||||
|
||||
## 4. 产品规则
|
||||
|
||||
### 4.1 对手盘方向
|
||||
|
||||
| 用户方向 | 市价开仓动作 | 累加侧 |
|
||||
|----------|--------------|--------|
|
||||
| 做多(long) | 买入 | **卖盘 asks**(卖一 → 卖 N) |
|
||||
| 做空(short) | 卖出 | **买盘 bids**(买一 → 买 N) |
|
||||
|
||||
### 4.2 计划名义从哪来
|
||||
|
||||
与现有开仓计仓一致,优先复用服务端已有 sizing 口径(避免前后端各算一套):
|
||||
|
||||
| 计仓模式 | 计划名义 |
|
||||
|----------|----------|
|
||||
| `full_margin` | `notional_value` ≈ 可用 × 缓冲 × 杠杆(与 `compute_full_margin_sizing` 一致) |
|
||||
| `risk`(以损定仓) | 由风险金额与止损距离反推的仓位名义(与现开仓 `add_order` 路径一致) |
|
||||
|
||||
表单未填齐止损/方向/币种、或无法取可用保证金时:深度预览显示「—」,不报错打断填写.
|
||||
|
||||
### 4.3 参考价与滑点
|
||||
|
||||
- **参考价**:优先与表单现价条同一口径(标记价/最新价,跟现有 `symbol_live_price` / `order_defaults` 一致).
|
||||
- **预估均价(VWAP)**:按所吃各档 `价格 × 该档名义` 加权.
|
||||
- **滑点**:
|
||||
- 做多: `(vwap - ref) / ref`(越正越差)
|
||||
- 做空: `(ref - vwap) / ref`(越正越差)
|
||||
- 展示可用 **bps**(1 bps = 0.01%)或 `%`,UI 统一一种即可(建议 bps,大单更直观).
|
||||
|
||||
### 4.4 盘口档数
|
||||
|
||||
- 请求深度建议 **5~20 档**(实现时三所取各自 API 稳妥上限,默认 20).
|
||||
- 累加只展示「覆盖计划名义所需」的档位摘要,不必把未吃到的远档全部渲染.
|
||||
- 若累加后仍 `< 计划名义`:明确写 **深度不足 / 缺口约 X U**,不要伪装成已完全覆盖.
|
||||
|
||||
### 4.5 文案示例(空单 20 万 U)
|
||||
|
||||
```
|
||||
对手盘(买):买一~买4 累计约 23.1 万 U · 预估均价 63480(相对现价约 5 bps)
|
||||
```
|
||||
|
||||
深度不足时:
|
||||
|
||||
```
|
||||
对手盘(买):前 20 档累计约 12.4 万 U · 缺口约 7.6 万 U · 预估均价按已有档估算(仅供参考)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 界面位置
|
||||
|
||||
放在实盘下单开仓区、现有预览条附近,避免抢主按钮视觉:
|
||||
|
||||
| 区域 | 建议 |
|
||||
|------|------|
|
||||
| 现价条旁或下方 | 一行摘要即可(§4.5) |
|
||||
| `#order-plan-preview` | 可增一项「盘口深度」或独立 `#order-depth-preview` |
|
||||
| 详细档位 | 首版可不展开;若展开,仅列出已累加到的那几档(价/量/累计名义) |
|
||||
|
||||
小资金且滑点低于阈值时,可用灰色弱提示「前 N 档已覆盖,滑点可忽略」,避免噪音.
|
||||
|
||||
---
|
||||
|
||||
## 6. 提示阈值(软提示,不挡单)
|
||||
|
||||
建议可配置(`.env`,有默认值),仅影响颜色/文案:
|
||||
|
||||
| 变量(草案) | 含义 | 默认建议 |
|
||||
|------------|------|----------|
|
||||
| `MANUAL_DEPTH_WARN_BPS` | 预估滑点 ≥ 此值标黄 | `5` |
|
||||
| `MANUAL_DEPTH_ALERT_BPS` | 预估滑点 ≥ 此值标红/强调 | `15` |
|
||||
| `MANUAL_DEPTH_SHORTFALL_WARN` | 累计名义 < 计划名义时强调 | 开 |
|
||||
|
||||
首版:**不**因此 `disabled` 开仓按钮;与期权「无卖一禁止开仓」区分开.
|
||||
|
||||
---
|
||||
|
||||
## 7. 技术设计
|
||||
|
||||
### 7.1 API(三所各暴露,或抽到 `lib/` 共用 handler)
|
||||
|
||||
建议新增(名称可微调):
|
||||
|
||||
`GET /api/order_depth_preview`
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `symbol` | 与开仓表单一致 |
|
||||
| `direction` | `long` / `short` |
|
||||
| `sl` / `sl_pct` / `fixed_rr` / `sltp_mode` 等 | 以损定仓算名义时需要;全仓模式可只传 symbol+direction |
|
||||
| 或直接传 `notional_usdt` | 若前端已从其它 preview API 拿到名义,可减少重复计算(**二选一,实现时定一种主路径**) |
|
||||
|
||||
响应草案:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"side": "bid",
|
||||
"ref_px": 63512.3,
|
||||
"plan_notional_usdt": 200000,
|
||||
"covered_notional_usdt": 231000,
|
||||
"shortfall_usdt": 0,
|
||||
"levels_used": 4,
|
||||
"vwap": 63480.0,
|
||||
"slippage_bps": 5.1,
|
||||
"levels": [
|
||||
{"px": 63510, "sz": "...", "notional_usdt": 50000, "cum_notional_usdt": 50000}
|
||||
],
|
||||
"msg": ""
|
||||
}
|
||||
```
|
||||
|
||||
失败(拉盘口失败、币种无效):`ok=false` + 简短 `msg`;前端显示「深度暂不可用」,不影响开仓。
|
||||
|
||||
### 7.2 交易所盘口
|
||||
|
||||
| 所 | 合约盘口 | 注意 |
|
||||
|----|----------|------|
|
||||
| Binance | USD-M 深度 | 数量单位换算成 USDT 名义 |
|
||||
| OKX | swap books | 同左;与期权 `fetch_option_book_depth` **分开**,勿混用期权接口 |
|
||||
| Gate | futures order book | 同左 |
|
||||
|
||||
公共逻辑建议落在 `lib/trade/`(例如 `manual_order_depth_preview_lib.py`):输入档位列表 + 计划名义 + 方向 → 输出 VWAP / 缺口 / levels_used.
|
||||
各所只负责 **拉 book + 单位换算成 USDT 名义**.
|
||||
|
||||
### 7.3 前端
|
||||
|
||||
- 共享脚本(建议):`lib/common/static/manual_order_depth_preview.js`
|
||||
- 与 `manual_order_rr_preview.js` 同样在币种/方向/止损/模式变更时 debounce 刷新
|
||||
- 轮询间隔建议 3~5s(仅表单可见且字段有效时);切页或无焦点可停
|
||||
- 三所 `index` / 嵌入 fragment 引入同一脚本
|
||||
|
||||
### 7.4 测试
|
||||
|
||||
- 纯函数:给定假盘口 + 名义,断言 `levels_used` / `vwap` / `shortfall`
|
||||
- 方向: long 只吃 ask, short 只吃 bid
|
||||
- 深度不足与刚好覆盖边界
|
||||
- 不要求联调真盘口也能合入(真盘口可手工验一次 BTC/山寨对比)
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
1. 全仓 + 已知杠杆下,预览「计划名义」与开仓实际计仓名义同量级(允许四舍五入误差).
|
||||
2. 市价空只反映买盘累加;市价多只反映卖盘累加.
|
||||
3. BTC 厚盘:小名义常显示「前 1~2 档已覆盖、滑点很低」.
|
||||
4. 人为放大名义或选薄流动性标的:能看到多档累加或「深度不足」.
|
||||
5. 拉盘口失败时不阻断开仓按钮.
|
||||
6. 中控嵌入实盘下单同样可见(与实例页同源表单).
|
||||
|
||||
---
|
||||
|
||||
## 9. 实现顺序建议
|
||||
|
||||
1. `lib/trade` 累加/VWAP 纯函数 + 单测
|
||||
2. 一所(建议 OKX 或当前主力所)拉 book + API + 前端一行预览
|
||||
3. 抽换算差异,补 Binance / Gate
|
||||
4. 接入软提示阈值与文案打磨
|
||||
5. 文档验收记录补进本文或 `docs/更新文档.md`
|
||||
|
||||
---
|
||||
|
||||
## 10. 二期(明确不做进首版)
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 深度不够自动缩名义 | 类似期权 `cap_by_ask_depth` |
|
||||
| 滑点超阈值二次确认 / 禁止市价 | 产品确认后再做硬门禁 |
|
||||
| 平仓与止损穿档预估 | 持仓卡或平仓按钮旁 |
|
||||
| WS 盘口 | 降低 REST 压力、更即时 |
|
||||
| 限价开仓:挂单价相对盘口位置 | 另一套提示 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 决策摘要(已拍板)
|
||||
|
||||
- **要做**:按计划名义展示「覆盖该名义所需」的对手盘摘要 + 预估均价/滑点.
|
||||
- **做空看买单,做多看卖单**.
|
||||
- **首版只展示 + 软提示,不挡单**.
|
||||
- **不为小资金做整屏盘口墙**;大名义时深度预览才有关键决策价值.
|
||||
@@ -0,0 +1,32 @@
|
||||
# 审计修复报告 · 永期「以期权为主」(2026-08-09)
|
||||
|
||||
## 范围
|
||||
|
||||
新增 `option_primary` 子模式(UI 开关 + 后端校验/开仓/监控),保险模式路径保持不变。
|
||||
|
||||
## 审计发现与处置
|
||||
|
||||
| 级别 | 问题 | 处置 |
|
||||
|------|------|------|
|
||||
| High | 期权已平、永续平仓失败后监控不再重试(双腿均须 open) | 增加 `_tick_po_option_primary_pending`,仅补平永续 |
|
||||
| High | 双目标触达时期权路径因买一/净利跳过,永续目标永不执行 | 期权路径失败且 `hit_perp` 时 fallthrough 永续目标 |
|
||||
| High | 两腿仍 open 但期权到期无处理,裸奔永续 | `_tick_po_option_primary_both_expired` 结算期权并平永续 |
|
||||
| High | 目标点数=0 开仓后易立即触发 | 校验与 `target_hit` 要求点数 **>0** |
|
||||
| Medium | start 未传 leverage 时被写成 10x | 期权为主缺省杠杆 **100** |
|
||||
| Medium | 服务端 `moneyness=atm` 未强制 ATM | 文档注明;UI 平值筛选仍严格;间隔门兜底 |
|
||||
| Medium | 平仓永续盈亏用估价 | 已知;不阻塞平仓,统计近似 |
|
||||
|
||||
## 保险模式回归
|
||||
|
||||
- `validate_start_body` 非 `option_primary` 仍强制 Put/Call + TP/SL 几何
|
||||
- `build_po_path_plan` 仅在 `option_primary` 时翻转永续方向并去掉 attach_tpsl
|
||||
- `_tick_po` 仅在 `option_primary` 为假时走原 TP/SL 路径
|
||||
|
||||
## 测试
|
||||
|
||||
`python -m unittest tests.test_hedge_plan_option_primary tests.test_hedge_plan_orders tests.test_hedge_plan_moneyness -v` — 通过。
|
||||
|
||||
## 文档
|
||||
|
||||
- 新增 `docs/对冲计划-以期权为主.md`
|
||||
- 更新 `docs/对冲计划-选约与虚实值.md`
|
||||
@@ -0,0 +1,80 @@
|
||||
# 策略与逻辑审计修复报告
|
||||
|
||||
- 日期: 2026-07-30
|
||||
- 范围: OKX 三选一模式 / 永期·期期对冲监控与开平 / 单独期权开平 / 互斥与复盘钳制
|
||||
- 准则: 以资金与仓位正确性为准(假平仓、未成交落库、跨模式拆组等)
|
||||
- 复审: 共 3 轮深度复审;最终 **剩余 P0 = 0**
|
||||
|
||||
## 修复总览
|
||||
|
||||
| 轮次 | 结果 |
|
||||
|------|------|
|
||||
| 初审 | 约 10 项 P0 + 多项 P1(监控假平、落库≠成交、模式互斥缺口等) |
|
||||
| 复审 1 | 18/20 已修;发现 SL 待平可误判 TP、监控可重复启动、单独开仓仍可缩量 |
|
||||
| 复审 2 | 上述 3 项已修;剩余若干 P1 |
|
||||
| 复审 3 | P1 再收口(互斥/目标监控 fail-closed、already_flat 二次验仓、监控启动锁);**P0 清零** |
|
||||
|
||||
## 已修复关键项(原审计编号)
|
||||
|
||||
### 永期监控 / 对冲平仓
|
||||
- **H1** `live is None` 不再当已平;仅 `live==0` 且过开仓宽限期后处理
|
||||
- **H2** 止损强平失败不写 `closed`,写 `perp_sl_pending_opt` 并重试
|
||||
- **H3** TP/SL 分类:歧义偏 SL;未知跳过;`*_pending_opt` 粘滞不再被 mark 反弹改判
|
||||
- **H4** `_sell_option` 改为 `close_option_by_bid1`,要求 `fully_closed`;`already_flat` 二次验仓
|
||||
- **H7** `partial` 计划纳入监控
|
||||
- **H5** IOC 部分成交后尝试立刻平掉孤儿仓
|
||||
- **H6** `/start` 进程内锁 + 闸门重检
|
||||
- **H8** 服务端校验 long↔Put / short↔Call 与 TP/SL 几何
|
||||
- 卖一深度不足时拒绝缩量成交(对冲买入)
|
||||
|
||||
### 单独期权
|
||||
- **O1** 开仓 IOC + `wait_option_order_full_fill`,成交后再落 `open`
|
||||
- **O2** 平仓后持仓 `None` 不标 `fully_closed`
|
||||
- **O3** 禁止期权页 close/target 拆对冲腿;目标监控跳过托管合约
|
||||
- **O4** 模式/互斥校验异常 fail-closed
|
||||
- 开仓拒绝卖一深度不足时的静默缩量
|
||||
- stub 买一路径不再撤掉他人挂单;门控通过改在下单接受后标记
|
||||
|
||||
### 三选一模式 / UI
|
||||
- **M1** Jinja 去掉 `| default(true)`,避免 `False` 显示成 Tab
|
||||
- **M2** 监控线程始终启动(单独期权也收口遗留计划)+ 单例锁
|
||||
- **M3** env 展示 `OKX_TRADE_MODE` 与 `get_okx_trade_mode()` 一致
|
||||
- 仪表盘始终展示进行中对冲;`complete-leg` 校验当前模式
|
||||
- 复盘 API 按模式钳制 `source_type`
|
||||
|
||||
## 测试
|
||||
|
||||
```text
|
||||
python -m unittest tests.test_hedge_po_monitor_safety tests.test_hedge_plan_orders \
|
||||
tests.test_okx_trade_mode tests.test_hedge_options_exclusive \
|
||||
tests.test_hedge_plan_end tests.test_hedge_partial_manual -v
|
||||
→ OK (35)
|
||||
```
|
||||
|
||||
新增: `tests/test_hedge_po_monitor_safety.py`(含 None 跳过、分类、SL sticky)
|
||||
|
||||
## 残留非关键项(P1,可后续迭代)
|
||||
|
||||
1. 连续两次持仓列表均为空时,仍可能把「短暂漏仓」当成已平(对冲路径已有二次验仓;目标/手动路径仍单次)
|
||||
2. 部分成交后若孤儿平仓也失败,需人工处理(已返回 `orphan_close`)
|
||||
3. 期权页对托管腿仍可能显示按钮,但 API 已拒绝
|
||||
|
||||
## 主要改动文件
|
||||
|
||||
- `lib/hedge_plan/hedge_plan_monitor_lib.py`
|
||||
- `lib/hedge_plan/hedge_plan_orders_lib.py`
|
||||
- `lib/hedge_plan/hedge_plan_register.py`
|
||||
- `lib/hedge_plan/hedge_plan_db.py`
|
||||
- `lib/hedge_plan/hedge_options_exclusive_lib.py`
|
||||
- `lib/hedge_plan/templates/hedge_plan_panel.html`
|
||||
- `lib/options/options_close_exec_lib.py`
|
||||
- `lib/options/options_register.py`
|
||||
- `lib/options/options_target_lib.py`
|
||||
- `lib/options/options_review_register.py`
|
||||
- `lib/env/env_ui_manifest.py`
|
||||
- `lib/instance/instance_dashboard_lib.py`
|
||||
- `tests/test_hedge_po_monitor_safety.py` 等
|
||||
|
||||
## 部署
|
||||
|
||||
见本轮 commit + `zk.hyf2.cc` `deploy/pull_and_restart.sh` 结果。
|
||||
@@ -0,0 +1,26 @@
|
||||
# 审计修复报告:账户流水(2026-08-10)
|
||||
|
||||
## 范围
|
||||
|
||||
新增「账户流水」功能:`lib/account_ledger/*`、`lib/exchange/*_ledger_lib.py`、三所 `app.py` 安装、导航显示开关、前端 SSE 页。
|
||||
|
||||
## 结论
|
||||
|
||||
**可上线。** 认证、SQL、SSE 载荷范围、单实例隔离与现有实例模式一致。发现 1 项中危并已在同批修复。
|
||||
|
||||
## 发现与处理
|
||||
|
||||
| 级别 | 问题 | 处理 |
|
||||
|------|------|------|
|
||||
| 中 | `POST /api/account_ledger/refresh` 可把 `start_ms` 拉到极早,触发大量交易所分页请求;无冷却 | 同步窗口强制 `LOOKBACK_DAYS` 下限;手动同步默认 30s 冷却 |
|
||||
| 低 | 导航关闭仍可直连 URL(与数据看板相同,仅 UI 隐藏) | 保持与现有 display pref 一致;embed `tab_allowed` 仍 403 |
|
||||
| 信息 | 交易所异常文案写入 `last_error` 展示 | 可接受;未记录密钥 |
|
||||
|
||||
## 验证
|
||||
|
||||
- `python -m unittest tests.test_account_ledger_normalize` 通过
|
||||
- 三所仅增加 `install_account_ledger`,不改动开仓/风控主路径
|
||||
|
||||
## 使用提醒
|
||||
|
||||
默认导航关闭;需在系统设置打开「账户流水」。数据来自交易所,首次打开可能需等待一轮后台同步或点「立即同步」。
|
||||
@@ -0,0 +1,71 @@
|
||||
# 对冲计划 · 永期「以期权为主」
|
||||
|
||||
> 实现日:2026-08-09 · 在现有永期**保险模式**上增加子模式,不新增 `OKX_TRADE_MODE`。
|
||||
> 模式由 env `HEDGE_PLAN_OPTION_PRIMARY` 切换(默认 `true`),页面标题前显示标识,不可在页内切换。
|
||||
|
||||
## 1. 模式对照
|
||||
|
||||
| | 保险模式(`OPTION_PRIMARY=false`) | 以期权为主(`true`) |
|
||||
|--|------------------|-------------------|
|
||||
| UI 做多 | 永续多 + 买 Put | 买 Call + 永续空 |
|
||||
| UI 做空 | 永续空 + 买 Call | 买 Put + 永续多 |
|
||||
| 左卡 | 开仓价 / 张数 / TP / SL | 资金与杠杆 / 选约条件 / 出场条件 三组 |
|
||||
| 右卡 | 上永续行情 · 下期权链 | 同上;仅展示间隔+类型+杠杆达标候选 |
|
||||
| 选约 | 仅实值/平值 | 类型下拉(默认虚值)+间隔+杠杆门槛 |
|
||||
| 开仓 | 受 `HEDGE_PLAN_OPEN_ORDER` | **策略启动=盯盘**(status=`watching`),达标后才先期权后市价永续(**不挂**交易所 TP/SL) |
|
||||
| 出场 | 交易所 TP/SL | 相对 K 的点数目标分叉 |
|
||||
|
||||
## 2. 左卡默认
|
||||
|
||||
| 字段 | 默认 |
|
||||
|------|------|
|
||||
| 权利金 | 用户填(USDC 预算) |
|
||||
| 永续杠杆 | 100 |
|
||||
| 期权杠杆 | 实/平 100;虚 200 |
|
||||
| 期权:永续比例 | 实/平 2;虚 4 |
|
||||
| 到期时间(最短 h) | 36 |
|
||||
| 期权间隔(点) | 15 |
|
||||
| 期权/永续目标位 | 相对 K 点数,须 **>0** |
|
||||
|
||||
## 3. 定仓
|
||||
|
||||
```
|
||||
usable = 权利金 × 0.95
|
||||
eth_qty = floor2(usable / ask) # ETH 名义,两位小数
|
||||
sheets = floor(eth_qty / ct_mult) # 整张
|
||||
perp_eth = eth_qty / 比例
|
||||
contracts = perp_eth / contract_size
|
||||
```
|
||||
|
||||
启动前再拉卖一重算;卖一深度不足则缩量。
|
||||
|
||||
## 4. 出场
|
||||
|
||||
触达任一目标位(做多 `index ≥ K+N`,做空 `index ≤ K−N`)后立即执行:
|
||||
|
||||
| 触达 | 规则 |
|
||||
|------|------|
|
||||
| **期权目标** | 验买一流动性 + **扣费净利 > 0** → 先平期权再平永续 |
|
||||
| **永续目标** | 市价平永续;期权 `hold_to_expiry` 至到期结算 |
|
||||
|
||||
净利:平仓/卖出手续费**按买入费率**估算(`HEDGE_PLAN_FEE_RATE` / `OKX_TAKER_FEE`,默认 0.0005)。
|
||||
|
||||
若期权目标因买一/净利未过、但永续目标已触达 → 改走永续目标。
|
||||
期权已平永续失败 → `opt_target_perp_pending` 下轮只补平永续。
|
||||
两腿仍开但期权到期 → 结算期权并平永续,避免裸奔。
|
||||
|
||||
## 5. 代码落点
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `lib/hedge_plan/hedge_plan_option_primary_lib.py` | 定仓/方向/目标/净利/校验 |
|
||||
| `hedge_plan_orders_lib.py` | 路径、开平永续、启动前定仓刷新 |
|
||||
| `hedge_plan_monitor_lib.py` | `_tick_po_option_primary*` |
|
||||
| `hedge_plan_register.py` / `hedge_plan_db.py` | preview/start/persist;`options-chain?option_primary&min_hours&strike_interval` |
|
||||
| `hedge_plan.js` + `hedge_plan_panel.html` | env 模式标识、左三组参数、右上永续/右下期权 |
|
||||
|
||||
## 6. 测试
|
||||
|
||||
```bash
|
||||
python -m unittest tests.test_hedge_plan_option_primary -v
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
# 对冲计划 · 选约与虚实值
|
||||
|
||||
> 实现日:2026-08-05 · 吸收 `eth_hedge_sim`(比特骆驼)选约几何;退出仍用本仓 TP/SL/S*,**不**移植仿真「净盈亏 15U 离场」。
|
||||
|
||||
## 1. 冻结规则
|
||||
|
||||
| 计划类型 | 允许虚实值 | 禁止 | 推荐模板 |
|
||||
|----------|------------|------|----------|
|
||||
| **永期保险** `perp_options`(开关关) | 实值、平值 | **虚值** | 距指数最近的实值/平值(做多 Put / 做空 Call) |
|
||||
| **永期以期权为主** `option_primary=1` | 实值、平值、**虚值** | —(间隔+杠杆门) | 做多 Call+永续空 / 做空 Put+永续多;详见 `docs/对冲计划-以期权为主.md` |
|
||||
| **期期** `options_options` | 平值、虚值 | **实值** | 平值跨式(ATM C+P);双虚值(OTM C+P) |
|
||||
|
||||
口径与 `lib/options/options_pricing_lib.option_moneyness` 一致:ATM 带 = `max(指数×0.2%, 2U)`。
|
||||
|
||||
永期几何兜底(与仿真一致):
|
||||
|
||||
- Call 实值/平值:`K ≤ S`
|
||||
- Put 实值/平值:`K ≥ S`
|
||||
|
||||
## 2. 代码落点
|
||||
|
||||
| 层 | 文件 | 作用 |
|
||||
|----|------|------|
|
||||
| 选约/校验库 | `lib/hedge_plan/hedge_plan_moneyness_lib.py` | `is_itm_or_atm` / `is_atm_or_otm` / `pick_*` / `recommend_oo_legs` / `validate_*` |
|
||||
| 启动门禁 | `hedge_plan_orders_lib.validate_start_body` | 测算外再拦一遍;防绕过 UI 直 POST |
|
||||
| 测算 | `hedge_plan_register._preview_po/_preview_oo` | 预览同样拒绝违规腿 |
|
||||
| UI | `hedge_plan.js` + `hedge_plan_panel.html` | 筛选锁定、推荐按钮、选用前校验 |
|
||||
| Env | `env_ui_manifest` 永期分组 | `HEDGE_PLAN_ITM_MAX_DIST_USD` / `MIN_OPTION_HOURS` / `MIN_OPTION_LEVERAGE` |
|
||||
|
||||
## 3. Env
|
||||
|
||||
| 键 | 默认 | 说明 |
|
||||
|----|------|------|
|
||||
| `HEDGE_PLAN_ITM_MAX_DIST_USD` | 空→沿用 `OKX_OPTIONS_ITM_MAX_DIST_USD`(常 30) | 永期过深实值上限;0=不限 |
|
||||
| `HEDGE_PLAN_MIN_OPTION_HOURS` | 8 | 仅当请求带 `hours_to_expiry` 时生效 |
|
||||
| `HEDGE_PLAN_MIN_OPTION_LEVERAGE` | 0 | `指数/卖一`;0=关闭 |
|
||||
|
||||
## 4. 可用性审计
|
||||
|
||||
| 项 | 结论 |
|
||||
|----|------|
|
||||
| 默认筛选 | 永期默认「实值/平值」;期期默认「平/虚」—减少误选 |
|
||||
| 推荐一键 | 永期「推荐」;期期「推荐跨式 / 推荐双虚」—降低手选成本 |
|
||||
| 文案 | 规则说明与 alert 明确禁虚(永期)/禁实(期期) |
|
||||
| 服务端一致 | UI 过滤可绕过时,preview/start 仍会 400 |
|
||||
| 兼容旧 API | 未传 `strike` 时从 `inst_id` 解析;未传 `index_px` 时永期用 `entry`、期期用上下破中点 |
|
||||
| 以期权为主 | 见 `docs/对冲计划-以期权为主.md`:点数目标+扣费净利出场(非仿真 15U 固定);保险模式仍不接仿真净盈亏离场 |
|
||||
|
||||
**已知局限:**
|
||||
|
||||
- 链上 `moneyness` 依赖刷新时指数;剧烈跳动后需「刷新链」再选。
|
||||
- `MIN_OPTION_HOURS` 需前端/调用方传入 `hours_to_expiry` 才校验(当前链行未必带该字段)。
|
||||
- 期期「推荐跨式」优先 ATM,若无 ATM 会回退到最近允许档(含 OTM)。
|
||||
|
||||
## 5. 安全性审计
|
||||
|
||||
| 风险 | 控制 |
|
||||
|------|------|
|
||||
| 客户端改包选虚值永期保险 | `validate_start_body` + preview 服务端拒绝 |
|
||||
| 客户端选实值期期腿 | 同上 |
|
||||
| 过深实值权利金过贵 / 杠杆过低 | `ITM_MAX_DIST` + 可选 `MIN_OPTION_LEVERAGE` |
|
||||
| 误开实盘 | 既有 `HEDGE_PLAN_LIVE_ORDER` ∩ `LIVE_TRADING_ENABLED` ∩ 全仓(永期)门禁不变 |
|
||||
| 保险模式平仓 | 不变:交易所 TP/SL |
|
||||
| 以期权为主平仓 | 独立监控分支;不改保险模式路径 |
|
||||
|
||||
## 6. 测试
|
||||
|
||||
```bash
|
||||
python -m unittest tests.test_hedge_plan_moneyness tests.test_hedge_plan_orders -v
|
||||
```
|
||||
|
||||
覆盖:虚实值几何、永期拒 OTM、期期拒 ITM、`validate_start_body` 集成。
|
||||
|
||||
## 7. 与开发方案对齐
|
||||
|
||||
更新 `docs/对冲计划开发方案.md` §3.2 / §4.1 选约约束,与本文件一致。
|
||||
+7
-4
@@ -12,8 +12,8 @@
|
||||
|
||||
| 产品名 | 英文键 | 含义 |
|
||||
|--------|--------|------|
|
||||
| **永期对冲** | `perp_options` | 永续(子账户) + 买方期权(主账户) |
|
||||
| **期期对冲** | `options_options` | 主账户内两条买方期权腿 |
|
||||
| **永期对冲** | `perp_options` | 同账户永续 + 买方期权 |
|
||||
| **期期对冲** | `options_options` | 同账户内两条买方期权腿 |
|
||||
|
||||
页面/导航展示用中文名;API/DB 用英文键.
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
|
||||
- 行情自动拉 OKX 期权链(复用 `build_option_chain`).
|
||||
- **报价形态:列表式**;多仓默认筛 **Put**,空仓默认筛 **Call**.
|
||||
- **虚实值(冻结):**仅允许 **实值或平值**,**禁止虚值**(保险腿须有内在价值或贴近平值).详见 [对冲计划-选约与虚实值.md](./对冲计划-选约与虚实值.md).
|
||||
- 页面默认筛「实值/平值」,提供「推荐」取距指数最近档;服务端 `validate_start_body` / preview 二次校验.
|
||||
- 权利金默认按 **卖一 ask** 估算;开仓限价买入.
|
||||
|
||||
### 3.3 左右布局
|
||||
@@ -93,7 +95,8 @@
|
||||
|
||||
- **T 型报价链**(复用期权页 T 型样式/数据结构).
|
||||
- 用户选 **腿 A + 腿 B**(通常 Call + Put,或主方向 + 尾部).
|
||||
- 预算:`B = min(交易户 USDC × OKX_OPTIONS_BUDGET_BUFFER, OKX_OPTIONS_TRADE_BUDGET_USDC)`(默认 buffer=0.95).
|
||||
- **虚实值(冻结):**两腿仅允许 **平值或虚值**,**禁止实值**;推荐模板:平值跨式 / 双虚值.详见 [对冲计划-选约与虚实值.md](./对冲计划-选约与虚实值.md).
|
||||
- 预算:`B = min(交易户 USDC × 对冲缓冲 HEDGE_PLAN_BUDGET_BUFFER, 单笔预算)`(默认 buffer=0.95;与期权页 buffer 独立).
|
||||
- 自动张数(选齐两腿后写入,可手改):
|
||||
- **同张数**(默认):最大 `n` 使 `n×(cost_A+cost_B) ≤ B`,两腿均填 `n`
|
||||
- **做多 / 做空**:须一 Call 一 Put;主:次默认 **7:3**(`HEDGE_PLAN_OO_BIAS_RATIO`,可改)
|
||||
@@ -217,7 +220,7 @@
|
||||
|
||||
| 侧 | 来源 |
|
||||
|----|------|
|
||||
| 永续行情/规格 | OKX 子账户 ccxt:ticker + 现有 `/api/hub/market` 规格逻辑 |
|
||||
| 永续行情/规格 | OKX 账户 ccxt:ticker + 现有 `/api/hub/market` 规格逻辑 |
|
||||
| 期权链 | `build_option_chain` / `/api/options/chain`(本实例直连,无需中控代理) |
|
||||
| 指数价 | 期权 `index_px`,左右对齐 |
|
||||
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
|
||||
| 类型 | 账户 | 作用 |
|
||||
|------|------|------|
|
||||
| **永期对冲** | 永续子账户 + 期权主账户买方 | 全仓做方向,期权买保险 |
|
||||
| **期期对冲** | 仅期权主账户双买方 | 目标价兑现盈利腿,亏损腿到期 |
|
||||
| **永期对冲** | 同账户永续 + 期权买方 | 全仓做方向,期权买保险 |
|
||||
| **期期对冲** | 同账户双买方期权 | 目标价兑现盈利腿,亏损腿到期 |
|
||||
|
||||
### 永期结束与统计
|
||||
|
||||
|
||||
+12
-2
@@ -6,7 +6,17 @@
|
||||
|
||||
| 标签 | 指向提交 | 说明 |
|
||||
|------|----------|------|
|
||||
| `snapshot/20260721` | `2a60d47` | 2026-07-21:仓库代码统计文档、期权复盘亮色主题、对冲腿盈亏时区修复、本快照说明等 |
|
||||
| `snapshot/20260820` | `2028251` | 2026-08-20:币本位期权开发前快照;含盘口深度预览方案、OKX单笔期权币本位+USDT桥+复利开发方案;对冲暂不接币本位 |
|
||||
| `snapshot/20260728-2` | `05864d7` | 2026-07-28 午后:振幅统计改为波动点数→振幅占比、两日振幅(例25日16:00→27日16:00);去掉买跨/永期对照 |
|
||||
| `snapshot/20260728` | `c73e363` | 2026-07-28:中控永期对冲计算器(由波动推仓位 / 由比例推点数)、说明文档 |
|
||||
| `snapshot/20260727` | `f53f281` | 2026-07-27:实例手机壳(下单/持仓/期权)、著作权声明、托管合同(一用户一机)、服务说明与报价说明 |
|
||||
| `snapshot/20260726-2` | `4a79e01` | 2026-07-26 午:执行手册脑图(业务主题)、`.xmind` 按二进制入库、去掉缩略图避免 Gitea raw 换行损坏 |
|
||||
| `snapshot/20260726` | `a2075ba` | 2026-07-26:Gate划转币种大写修复、系统设置划转页签停留、自动划转账户/币种下拉默认、期权「按可用余额打满」=min(余额,单笔预算)及说明 |
|
||||
| `snapshot/20260724` | `890659f` | 2026-07-24:执行手册v2(无对冲)、监控/策略页签显隐、内照明心期权档案同步、期权开平仓微信必发、实例导航显隐持仓/实盘下单等 |
|
||||
| `snapshot/20260723-2` | `9e0591c` | 2026-07-23:策略对比页(合约/单期权/期期7:3)、监控与看板隐藏浮盈偏好、对比页卡片内边距等 |
|
||||
| `snapshot/20260723-pre-amp-stats` | `40be3a5` | 2026-07-23:振幅统计开发前;含执行手册进教练、日亏损冻结、手机监控 UI、振幅统计开发方案等 |
|
||||
| `snapshot/20260721-2` | `a721642` | 2026-07-21 晚:日亏损次数冻结、交易执行手册入中控策略说明、期权/Gate 执行手册文档等 |
|
||||
| `snapshot/20260721` | `1a163c0` | 2026-07-21:仓库代码统计文档、期权复盘亮色主题、对冲腿盈亏时区修复、本快照说明等 |
|
||||
|
||||
## 历史标签(节选)
|
||||
|
||||
@@ -25,7 +35,7 @@
|
||||
git tag -l 'snapshot/*'
|
||||
|
||||
# 检出快照(只读查看,勿在此分支直接开发)
|
||||
git checkout snapshot/20260721
|
||||
git checkout snapshot/20260728-2
|
||||
|
||||
# 回到主线
|
||||
git checkout main
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
# 振幅统计(中控)
|
||||
|
||||
中控只读工具:按自定义整点起点、**固定北京时间 16:00 收窗**,统计 OKX 上 ETH/BTC 的历史「点数振幅」档案,辅助一天期期权判断空间。
|
||||
|
||||
> 开发方案见 [ETH时段振幅统计-开发方案.md](./ETH时段振幅统计-开发方案.md)。
|
||||
> **不改下单链路**;不算 IV。
|
||||
> 买跨 / 永期对冲测算请用中控 **策略计算器**,本页不再做对照盈亏。
|
||||
|
||||
---
|
||||
|
||||
## 入口
|
||||
|
||||
- 顶栏 **振幅统计**(`/amp-stats`)
|
||||
- 手机端:**更多 → 振幅统计**
|
||||
- 可在系统设置里隐藏该导航
|
||||
|
||||
---
|
||||
|
||||
## 怎么用
|
||||
|
||||
1. 打开 **统计** Tab
|
||||
2. 选择 **标的** ETH / BTC;数据源固定 **OKX**
|
||||
3. **起点整点**(00–23);终点固定 **16:00**
|
||||
4. **周期**:1 月 / 2 月 / 3 月 / 半年 / 1 年 / 自定义天数(默认 2 个月)
|
||||
5. 可选填 **波动点数**(如 `50`)→ 看振幅达标占比
|
||||
6. 点 **计算** → 下方看汇总 + 振幅占比 + 分页日表
|
||||
7. 需要留存时点 **保存到历史**;**下载 CSV** 含摘要 + 全日明细
|
||||
|
||||
**跨天例子**
|
||||
|
||||
| 起点 | 含义(结算日 D) |
|
||||
|------|------------------|
|
||||
| 22:00 | 昨天 22:00 → 今天 16:00 |
|
||||
| 16:00 | 昨天 16:00 → 今天 16:00 |
|
||||
| 08:00 | 今天 08:00 → 今天 16:00 |
|
||||
|
||||
未到当日 16:00 的「今天」不入样本。
|
||||
|
||||
---
|
||||
|
||||
## 指标(点数)
|
||||
|
||||
设开盘 O、最高 H、最低 L、收盘 C:
|
||||
|
||||
| 字段 | 算法 |
|
||||
|------|------|
|
||||
| 开→高 | `H − O`(一边波动) |
|
||||
| 开→低 | `O − L`(另一边波动) |
|
||||
| **振幅** | `H − L`(= 开→高 + 开→低),窗为起点整点 → 当日 16:00 |
|
||||
| **两日振幅** | 同上口径,但起点再往前推 1 天;例起点 16:00、结算 27 日 → **25日16:00 → 27日16:00** |
|
||||
| 涨跌值 | `C − O`(单日窗) |
|
||||
|
||||
例:O=2000,H=2500,L=1800 → 开→高 500,开→低 200,振幅 **700**。
|
||||
|
||||
汇总必含:最大振幅(及日期)、两日振幅最大/均值/中位、开→高/开→低的最大与均值等。
|
||||
|
||||
K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / BTC-USD),失败再降级永续标记。
|
||||
近期 K 线接口约仅 **1440** 根(1H≈60 天);更长周期自动续拉 `history-index-candles` / `history-candles`。
|
||||
分页带间隔,遇 OKX **429** 会自动退避重试(长周期首次会慢一些)。
|
||||
|
||||
---
|
||||
|
||||
## 波动点数 → 振幅占比
|
||||
|
||||
表单可填 **波动点数**(如 `50`)。填写后下方 **振幅占比** 块显示:
|
||||
|
||||
| 汇总项 | 口径 |
|
||||
|--------|------|
|
||||
| 振幅≥点数 | 单日窗 `H−L ≥ 点数` 的天数与**占比**(主指标) |
|
||||
| 两日振幅≥点数 | 两日窗振幅 ≥ 点数 的天数与占比 |
|
||||
| 开→高≥点数 | `H−O ≥ 点数` 天数与占比 |
|
||||
| 开→低≥点数 | `O−L ≥ 点数` 天数与占比 |
|
||||
| \|涨跌\|≥点数 | `\|C−O\| ≥ 点数` 天数与占比 |
|
||||
|
||||
日表保留 **开→高 / 开→低**、**振幅**、**两日振幅**(悬停可见两日窗起止),并标 **振幅达标**。
|
||||
改点数 / 周末筛选会在已有日表上**本地重算**(不重拉 K 线)。
|
||||
|
||||
### 周末
|
||||
|
||||
- 下拉:**全部**(默认)/ **排除周末** / **仅周末**
|
||||
- 按 **结算日** 北京时间星期判断;表中六、日带标注并高亮
|
||||
|
||||
---
|
||||
|
||||
## 历史 Tab
|
||||
|
||||
- 仅 **保存到历史** 后出现(不会一算就自动入库)
|
||||
- 可查看、再下载、删除
|
||||
- 数据文件:`manual_trading_hub/amp_stats_history.json`(勿当密钥提交)
|
||||
|
||||
---
|
||||
|
||||
## 相关代码
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `lib/hub/amp_stats_lib.py` | 切窗、汇总、OKX 拉取、CSV |
|
||||
| `manual_trading_hub/amp_stats_routes.py` | API |
|
||||
| `manual_trading_hub/amp_stats_store.py` | 历史 JSON |
|
||||
| `manual_trading_hub/static/amp_stats.js` | 前端 |
|
||||
| `tests/test_amp_stats_lib.py` | 单测 |
|
||||
|
||||
---
|
||||
|
||||
## 修订
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-07-23 | 首版上线说明 |
|
||||
| 2026-07-23 | 买跨对照、周末筛选、止盈点 |
|
||||
| 2026-07-28 | 永期对冲对照(后已移除) |
|
||||
| 2026-07-28 | 去掉买跨/永期;改为波动点数→振幅占比 |
|
||||
| 2026-07-28 | 增加两日振幅(例 25日16:00→27日16:00) |
|
||||
@@ -4,6 +4,36 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-20 · OKX 单笔期权币本位 + USDT 桥 + 复利
|
||||
|
||||
### 修改原因
|
||||
|
||||
币本位期权流动性往往好于 USDC;操作者仍用 USDT 思考本金。需 env 切换本位、自动 USDT↔币桥、交易户 USDT×0.95 复利;对冲仍仅 USDC;中控只读识别本位;不改 Gate。
|
||||
|
||||
### 修改的地方
|
||||
|
||||
| 文件 | 改动摘要 |
|
||||
|------|----------|
|
||||
| `lib/options/options_margin_mode_lib.py` | 本位/合约族/USDT 预算/按币算张数 |
|
||||
| `lib/options/options_spot_bridge_lib.py` | 买币/卖回/桥状态表/回滚 |
|
||||
| `lib/options/options_coin_open_lib.py` | 买满→开满编排;平后卖回 |
|
||||
| `options_register` / `okx_options_lib` / close_exec | 链族切换、开平接入、retry-sell |
|
||||
| `options_hub_lib` + 中控 `app.js` / AI context | 只读字段识别币本位 |
|
||||
| `hedge_plan_register` | 币本位禁止开对冲 |
|
||||
| `env_*` / `.env.example` | 新 env;MARGIN_MODE 需重启;有仓拒切 |
|
||||
| `docs/OKX单笔期权-币本位与USDT桥-开发方案.md` | 方案(已有) |
|
||||
|
||||
### 交付之后的验收
|
||||
|
||||
1. `OKX_OPTIONS_MARGIN_MODE=usdc` 行为与现网一致.
|
||||
2. `=coin` 时链为 `ETH-USD`(非 `_UM`);开仓走买币再开期权;失败回滚卖币.
|
||||
3. 平仓清空后卖回本桥币量;失败可 `POST /api/options/spot-bridge/retry-sell`.
|
||||
4. 预算默认交易户 USDT×0.95;上限开关默认关.
|
||||
5. 中控期权卡显示本位标签;无下单.
|
||||
6. Gate 无改动.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-19 · 期权复盘详情改为对话框 + 截图显示修复
|
||||
|
||||
### 修改原因
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# 服务说明与报价说明
|
||||
|
||||
> 本文说明本系统的定位、适用对象、托管方式与参考报价。
|
||||
> 配套文件:`著作权声明.md`、`软件使用授权合同-模板.md`(托管服务与软件使用合同)。
|
||||
> **本系统以著作权人自用为主**;对外托管属个案合作,并非标准化「卖工具」业务。
|
||||
|
||||
---
|
||||
|
||||
## 1. 这是什么
|
||||
|
||||
`crypto_monitor`(加密货币交易监控与中控系统)由著作权人 **马建军** 历时约三个月持续开发,用于自身实盘交易中的:
|
||||
|
||||
- 多交易所实例监控与下单辅助
|
||||
- 风控与纪律约束(如日亏冻结、执行规则落地到系统)
|
||||
- 复盘、关键位、期权/合约相关流程(以实际开通功能为准)
|
||||
- 中控统一查看与管理
|
||||
|
||||
开发目的首先是:**把交易习惯钉进系统,减少情绪单与随意操作**,而不是面向市场量产销售的通用软件商品。
|
||||
|
||||
---
|
||||
|
||||
## 2. 定位与适用对象
|
||||
|
||||
### 2.1 定位
|
||||
|
||||
| 是 | 不是 |
|
||||
|----|------|
|
||||
| 全职(或准全职)交易者的执行与纪律系统 | 兼职「玩玩」的下单插件 |
|
||||
| 规则、限制、复盘一起用的工作台 | 帮你加杠杆、追涨杀跌的「发财工具」 |
|
||||
| 著作权人自用为主;对外仅少量托管 | 开源产品或标准化 SaaS 大卖场 |
|
||||
|
||||
### 2.2 适合
|
||||
|
||||
- 以交易为主要工作、愿意按规则执行的人
|
||||
- 认同执行手册与系统内限制(含开仓限制、冻结等)
|
||||
- 接受「一户一机、不交付源码、按期付费」的托管方式
|
||||
- 账户规模与付费意愿匹配(服务费不应明显高于可承受的交易成本)
|
||||
|
||||
### 2.3 不适合(一般不承接)
|
||||
|
||||
- 兼职、偶尔开几单的小散
|
||||
- 只想要更快开仓、更高杠杆,不愿接受纪律约束
|
||||
- 要求交付源码、私有仓库权限或「买断随便改」
|
||||
- 希望多人共用一台服务器以压低费用
|
||||
|
||||
**说明:** 不适合不等于否定任何人,而是产品与服务形态不匹配;强行上线往往浪费双方时间。
|
||||
|
||||
---
|
||||
|
||||
## 3. 对外怎么提供(若合作)
|
||||
|
||||
默认且唯一推荐的方式:
|
||||
|
||||
1. **著作权人提供专属服务器**(一用户一服务器,不与其他客户共用)
|
||||
2. **部署中控与实例**,配置域名 / HTTPS
|
||||
3. 客户仅获得 **访问地址 + 登录账号**
|
||||
4. **不交付源代码**、不开放 Git、不移交服务器 root(由甲方代持运维)
|
||||
|
||||
合作前建议:先阅读相关执行/行为说明,确认认同纪律设计,再谈部署与费用。
|
||||
|
||||
正式合作须签署《托管服务与软件使用合同》(见合同模板)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 费用构成
|
||||
|
||||
费用分四项,建议在报价单中分列,避免被理解成「只卖服务器」:
|
||||
|
||||
| 费用 | 含义 | 通常周期 |
|
||||
|------|------|----------|
|
||||
| 服务器费用 | 该客户专属云主机、带宽、磁盘等 | 月 / 年 |
|
||||
| 域名费用 | 域名注册或续费(代持或客户自带域名) | 年 |
|
||||
| 部署费用 | 首次装机、证书、上线、基础培训 | 一次性 |
|
||||
| 程序使用费 | 软件托管使用权、基础更新与运维响应 | 月 / 年 |
|
||||
|
||||
续费年一般不再收部署费(大改版或迁移可另议)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 参考报价(非标价,可协商)
|
||||
|
||||
以下为**面向全职交易者、个案托管**的参考区间(人民币)。
|
||||
因以自用为主、名额有限,实际以当时口头/书面报价为准,可高于下列下限。
|
||||
|
||||
### 5.1 分项参考
|
||||
|
||||
| 项目 | 参考区间 | 备注 |
|
||||
|------|----------|------|
|
||||
| 服务器费用 | **200–400 元/月** | 按机型实报或固定档;专属机,不共用 |
|
||||
| 域名费用 | **60–120 元/年** | 实报实销;客户自带域名可减免 |
|
||||
| 部署费用 | **2,000–5,000 元** | 一次性;含上线与基础使用说明 |
|
||||
| 程序使用费 | **1,000–2,500 元/月** 或 **10,000–25,000 元/年** | 年付可相当于少收 1~2 个月 |
|
||||
|
||||
### 5.2 首年打包示意(便于沟通)
|
||||
|
||||
| 档位 | 首年大约量级 | 思路 |
|
||||
|------|--------------|------|
|
||||
| 协作档 | 约 **1.5–2.5 万** | 部署中档 + 服务器 + 使用费中低 |
|
||||
| 标准档 | 约 **2–4 万** | 部署与使用费取中高,含优先响应 |
|
||||
|
||||
**不提供:** 低价引流套餐、兼职小资金特惠、源码买断(若极少数个案谈源码,须另签合同且价格远高于年使用费,默认不做)。
|
||||
|
||||
### 5.3 付款与停服
|
||||
|
||||
- 部署费 + 首周期费用:签约后约定日内支付,到账后排期部署
|
||||
- 续费:到期前支付;逾期可暂停访问,严重逾期可停服并释放专属服务器
|
||||
- 细节以合同条款为准
|
||||
|
||||
---
|
||||
|
||||
## 6. 服务边界(简要)
|
||||
|
||||
**甲方(马建军)合理范围内可提供:**
|
||||
|
||||
- 专属机上的首次部署与基础运维
|
||||
- 程序常规更新、进程异常处理
|
||||
- 约定范围内的使用说明
|
||||
|
||||
**一般不包含(除非另议):**
|
||||
|
||||
- 代客交易、代管资金、投资建议
|
||||
- 保证盈利或胜率
|
||||
- 7×24 即时响应当成「专职客服」
|
||||
- 按客户要求无限改需求而不另计定制费
|
||||
|
||||
交易盈亏由客户自行承担;系统为辅助与纪律工具。
|
||||
|
||||
---
|
||||
|
||||
## 7. 知识产权
|
||||
|
||||
- 软件与文档著作权归 **马建军** 所有,见 `著作权声明.md`
|
||||
- 托管仅授权约定范围内的使用权,**不转移著作权、不交付源码**
|
||||
- 仓库为私有保存;私有不影响著作权主张
|
||||
|
||||
---
|
||||
|
||||
## 8. 联系
|
||||
|
||||
- 著作权人 / 服务提供方:马建军
|
||||
- 电话:18364911125
|
||||
|
||||
意向合作请说明:交易经验与是否全职、大致账户规模(可不精确)、希望开通的交易所、是否接受系统纪律限制。
|
||||
**谢绝:** 仅询源码价格、要求多人共用一台服务器、明确表示不接受任何交易限制的需求。
|
||||
|
||||
---
|
||||
|
||||
*文档版本:与仓库同步维护;报价为参考,最终以双方确认的报价单与合同为准。*
|
||||
+4
-4
@@ -1,15 +1,15 @@
|
||||
# 期权对冲方案分析
|
||||
|
||||
> 适用范围:OKX **永续子账户**(USDT 本位) + **期权主账户**(USDⓈ 本位买方).
|
||||
> 适用范围:OKX **同一账户**(`OKX_API_*`):USDT 永续 + USDⓈ 期权买方.
|
||||
> 本文档为 **策略与操盘说明**,非系统自动下单功能;组合须 **人工** 在永续页与期权页分别执行.
|
||||
|
||||
---
|
||||
|
||||
## 1. 前提与账户分工
|
||||
|
||||
| 维度 | 永续合约(子账户) | 期权(主账户) |
|
||||
|------|------------------|--------------|
|
||||
| API | `OKX_API_*` | `OKX_OPTIONS_API_*` |
|
||||
| 维度 | 永续合约 | 期权 |
|
||||
|------|----------|------|
|
||||
| API | `OKX_API_*`(swap 客户端) | `OKX_API_*`(option 客户端) |
|
||||
| 系统页面 | 实盘下单 / 关键位 / 策略 | 期权 |
|
||||
| 保证金 | USDT | USDC / USDG |
|
||||
| 本系统能力 | 开平仓、止损、关键位 | **仅买方** 开平仓,无组合单 |
|
||||
|
||||
+16
-7
@@ -47,6 +47,13 @@
|
||||
- 首次通过后,同仓**续批**只再验流动性,不再重跑 2 分钟计时.
|
||||
- 无有效买一或门控未就绪 → 本轮不挂单,等下一轮;已有未成交卖平单则等成交,不撤了重挂.
|
||||
|
||||
### 2.4 翻倍出场(可选)
|
||||
|
||||
- 开仓勾选或持仓卡开启;倍数默认 **1**(盈利金额 = 初始权利金).
|
||||
- 触发条件:买一可回收 ≥ 权利金 × (1 + 倍数);达标后走买一限价平,**不再**额外卡「回收≥2×」门控(倍数本身已是出场条件).
|
||||
- 可随时关闭;与目标位监控并行,谁先达标谁平.
|
||||
- 与「翻倍提醒」独立:提醒只推微信,翻倍出场会真正挂平仓单.
|
||||
|
||||
---
|
||||
|
||||
## 3. 监控逻辑
|
||||
@@ -58,19 +65,21 @@
|
||||
| 未成交委托 | 期权下单区右侧「委托」列表展示开/平仓限价单,可手动撤销;页面轮询刷新 |
|
||||
| 平仓挂单超时 | 卖出平仓限价超 TTL 未成交 → 自动撤单(默认 10 分钟) |
|
||||
| 目标位 | 独立监控表;触发后买一平;推送企业微信(防重复) |
|
||||
| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次 |
|
||||
| 翻倍出场 | 开仓/持仓可开关;自选倍数(默认1);1倍=盈利等于权利金(可回收≥2×权利金)达标后买一限价平;可随时关闭;与目标位并行 |
|
||||
| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次(仅提醒,不平仓) |
|
||||
| 到期 | 无系统止损;到期交割/保险腿自灭(对冲计划另有退出规则) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 平仓校验(门控)
|
||||
|
||||
| 门控 | 手动买一平 | 目标自动平 | 说明 |
|
||||
|------|------------|------------|------|
|
||||
| 有效流动性 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 |
|
||||
| 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | 通过后同仓续批只验流动性 |
|
||||
| 锁定买一价 | ✅ | ✅ | 下单价 = 通过校验时的买一 |
|
||||
| 市价兜底 | ❌ | ❌ | 永不市价 |
|
||||
| 门控 | 手动买一平 | 目标自动平 | 翻倍出场 | 说明 |
|
||||
|------|------------|------------|----------|------|
|
||||
| 有效流动性 | ✅ 必验 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 |
|
||||
| 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | ❌(倍数即条件) | 目标平仓专用门控 |
|
||||
| 回收 ≥ 权利金×(1+倍数) | ❌ | ❌ | ✅ 触发条件 | 1倍 ⇒ 回收≥2×权利金 |
|
||||
| 锁定买一价 | ✅ | ✅ | ✅ | 下单价 = 通过校验时的买一 |
|
||||
| 市价兜底 | ❌ | ❌ | ❌ | 永不市价 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+26
-99
@@ -1,15 +1,15 @@
|
||||
# OKX 期权模块 — 技术方案
|
||||
|
||||
> 适用范围:`crypto_monitor_okx` 实例;与永续子账户并行,不新增 PM2 进程.
|
||||
> 适用范围:`crypto_monitor_okx` 实例;永续与期权共用同一套 `OKX_API_*`,不新增 PM2 进程.
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在现有 OKX 监控实例中增加 **USDⓈ 本位期权(买方)** 能力:
|
||||
|
||||
- 永续/关键位:继续走 **子账户 API-A**(现有 `OKX_API_*`)
|
||||
- 期权:走 **主账户 API-B**(`OKX_OPTIONS_API_*`)
|
||||
- 资金展示对齐 OKX:**资金账户 / 交易账户**,分币种显示 USDT,USDC,USDG
|
||||
- 支持 **手动 USDT→USDC 兑换** 与 **USDC 账户划转**
|
||||
- 永续/关键位与期权:**同一账户 API**(`OKX_API_*`)
|
||||
- 两个 ccxt 客户端:`exchange`(defaultType=swap)与 `exchange_options`(defaultType=option),身份相同
|
||||
- 顶栏资金:**资金账户/交易账户=USDT**;**期权资金/期权交易=USDC**
|
||||
- 支持 **手动 USDT→USDC 兑换** 与 **账户内划转**(无主↔子划转)
|
||||
- **无总资金池上限**;单笔权利金上限可配置(默认 10 USDC)
|
||||
|
||||
## 2. 交易规则(硬约束)
|
||||
@@ -32,8 +32,8 @@
|
||||
|
||||
```
|
||||
crypto_okx(单 PM2)
|
||||
├── exchange (swap) ← OKX_API_* 子账户
|
||||
└── exchange_options ← OKX_OPTIONS_API_* 主账户
|
||||
├── exchange (swap) ← OKX_API_*
|
||||
└── exchange_options (option)← 同一套 OKX_API_*
|
||||
|
||||
lib/options/
|
||||
├── okx_options_lib.py # 封装于 lib/exchange/
|
||||
@@ -43,109 +43,36 @@ lib/options/
|
||||
└── options_register.py # 路由 + 监控线程
|
||||
```
|
||||
|
||||
**隔离:** 期权模块只调用 `exchange_options`;永续逻辑只调用 `exchange`.
|
||||
**说明:** `defaultType` 分离避免错路由;密钥身份唯一.旧 `OKX_OPTIONS_API_*` 已废弃.
|
||||
|
||||
## 4. 资金与兑换
|
||||
|
||||
### 4.1 展示(期权页顶栏)
|
||||
### 4.1 展示(实例顶栏)
|
||||
|
||||
| 账户 | 币种 |
|
||||
|------|------|
|
||||
| 资金账户 | USDT,USDC(若有) |
|
||||
| 交易账户 | USDT,USDC,USDG(若有) |
|
||||
- **资金账户 / 交易账户**:USDT(永续侧)
|
||||
- **期权资金账户 / 期权交易账户**:USDC
|
||||
- 总资金:USDT + USDC(1:1),同账户 USDT 不重复累加期权侧 USDT
|
||||
|
||||
不展示「练手池」等抽象记账名称.
|
||||
### 4.2 兑换与划转
|
||||
|
||||
### 4.2 推荐操作流程
|
||||
- 系统设置「币种兑换」:资金账户内 USDT ↔ USDC
|
||||
- 「期权划转」:同账户 funding ↔ trading(USDC/USDT)
|
||||
- **已移除**主↔子账户划转
|
||||
|
||||
```
|
||||
资金账户 USDT
|
||||
→ [手动兑换 USDT→USDC](OKX Convert API,资金账户内)
|
||||
→ [划转到交易账户](USDC)
|
||||
→ 交易账户 USDC
|
||||
→ [限价买入期权]
|
||||
```
|
||||
|
||||
### 4.3 API
|
||||
|
||||
| 接口 | OKX |
|
||||
|------|-----|
|
||||
| 余额 | `fetch_balance`(funding / trading)+ `GET /api/v5/asset/balances` |
|
||||
| 询价兑换 | `POST /api/v5/asset/convert/estimate-quote` |
|
||||
| 确认兑换 | `POST /api/v5/asset/convert/trade` |
|
||||
| 划转 | `exchange.transfer(ccy, amt, from, to)` |
|
||||
|
||||
## 5. 配置项(`.env`)
|
||||
## 5. 环境变量(要点)
|
||||
|
||||
```bash
|
||||
OKX_OPTIONS_ENABLED=false
|
||||
OKX_OPTIONS_API_KEY=
|
||||
OKX_OPTIONS_API_SECRET=
|
||||
OKX_OPTIONS_API_PASSPHRASE=
|
||||
OKX_OPTIONS_ACCOUNT_LABEL=主账户·期权
|
||||
|
||||
OKX_API_KEY=
|
||||
OKX_API_SECRET=
|
||||
OKX_API_PASSPHRASE=
|
||||
OKX_OPTIONS_ENABLED=true
|
||||
OKX_OPTIONS_TRADE_BUDGET_USDC=10
|
||||
OKX_OPTIONS_BUDGET_BUFFER=0.95
|
||||
OKX_OPTIONS_DEFAULT_UNDERLY=ETH
|
||||
OKX_OPTIONS_MAX_DTE_DAYS=2
|
||||
OKX_OPTIONS_ITM_MAX_DIST_USD=30
|
||||
OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0
|
||||
OKX_OPTIONS_POLL_SECONDS=15
|
||||
OKX_OPTIONS_TD_MODE=cross
|
||||
# 市价平仓已在代码中硬关闭,此变量无效,可删
|
||||
# OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
|
||||
OKX_OPTIONS_CLOSE_RECYCLE_MULT=2
|
||||
OKX_OPTIONS_CLOSE_HOLD_SECONDS=120
|
||||
# 平仓限价挂单超时自动撤(秒),默认 600=10 分钟;联调可临时改 60
|
||||
OKX_OPTIONS_PENDING_TTL_SECONDS=600
|
||||
OKX_OPTIONS_ACCOUNT_LABEL=账户·期权
|
||||
```
|
||||
|
||||
平仓执行:**只锁买一限价**,说明见 [期权开平仓与监控说明.md](./期权开平仓与监控说明.md);线上 `/options/guide`.
|
||||
详见 [env配置说明.md](./env配置说明.md) 与 `.env.example`.
|
||||
|
||||
修改 `.env` 后须 `pm2 restart crypto_okx`.
|
||||
## 6. 相关文档
|
||||
|
||||
## 6. 数据库
|
||||
|
||||
### `options_trades`
|
||||
|
||||
记录本地开仓/平仓,权利金,翻倍提醒状态.
|
||||
|
||||
### `options_convert_log` / `options_transfer_log`
|
||||
|
||||
可选记录兑换与划转操作.
|
||||
|
||||
## 7. HTTP 路由
|
||||
|
||||
| 方法 | 路径 |
|
||||
|------|------|
|
||||
| GET | `/options` |
|
||||
| GET | `/options/guide` | 开平仓与监控说明(独立页) |
|
||||
| GET | `/api/options/balances` |
|
||||
| GET | `/api/options/chain` |
|
||||
| GET | `/api/options/quote` |
|
||||
| POST | `/api/options/open` |
|
||||
| POST | `/api/options/close` |
|
||||
| POST | `/api/options/convert/quote` |
|
||||
| POST | `/api/options/convert/execute` |
|
||||
| POST | `/api/options/transfer` |
|
||||
| GET | `/api/options/positions` |
|
||||
|
||||
## 8. 分阶段交付
|
||||
|
||||
1. **基础设施**:双 API,余额,文档,设置页说明
|
||||
2. **兑换 + 划转**:资金账户 USDT→USDC,划转到交易户
|
||||
3. **交易**:链,报价,开平仓,持仓
|
||||
4. **监控**:翻倍微信提醒
|
||||
|
||||
## 9. 不在一期范围
|
||||
|
||||
- 卖方,组合单,RFQ
|
||||
- 自动 USDT↔USDC
|
||||
- `manual-agent-okx` / 中控聚合
|
||||
- 币本位期权
|
||||
|
||||
## 10. 安全
|
||||
|
||||
- 期权 API:**交易 + 读**,禁止提币
|
||||
- 日志不输出 Secret
|
||||
- 下单前校验 `client is exchange_options`
|
||||
- [期权用法.md](./期权用法.md)
|
||||
- [对冲计划开发方案.md](./对冲计划开发方案.md)
|
||||
|
||||
+30
-84
@@ -2,37 +2,37 @@
|
||||
|
||||
## 1. 前置条件
|
||||
|
||||
1. OKX **主账户**已开通期权(USDⓈ 本位),且 App 中可见 `ETHUSD UM` / `BTCUSD UM`.
|
||||
2. 在 `crypto_monitor_okx/.env` 配置 **期权专用 API**(与永续子账户分开):
|
||||
1. OKX 账户已开通期权(USDⓈ 本位),且 App 中可见 `ETHUSD UM` / `BTCUSD UM`.
|
||||
2. 在 `crypto_monitor_okx/.env` 配置 **唯一账户 API**(永续与期权共用):
|
||||
|
||||
```bash
|
||||
OKX_API_KEY=你的账户Key
|
||||
OKX_API_SECRET=...
|
||||
OKX_API_PASSPHRASE=...
|
||||
OKX_OPTIONS_ENABLED=true
|
||||
OKX_OPTIONS_API_KEY=你的主账户Key
|
||||
OKX_OPTIONS_API_SECRET=...
|
||||
OKX_OPTIONS_API_PASSPHRASE=...
|
||||
```
|
||||
|
||||
3. 重启实例:`pm2 restart crypto_okx`
|
||||
3. 重启实例:`pm2 restart crypto_okx --update-env`
|
||||
|
||||
> 永续仍用原有 `OKX_API_*`(子账户);期权只用 `OKX_OPTIONS_API_*`(主账户).
|
||||
> 旧 `OKX_OPTIONS_API_*` 已废弃.若仅残留 OPTIONS 键而 `OKX_API_*` 为空,启动会自动回填.
|
||||
|
||||
## 2. 资金准备
|
||||
|
||||
期权权利金使用 **USDC 或 USDG**,不能直接用 USDT 买入.
|
||||
期权权利金使用 **USDC**,不能直接用 USDT 买入.
|
||||
|
||||
### 推荐步骤
|
||||
|
||||
1. 打开 **期权** 页,查看顶栏:
|
||||
- **资金账户**:USDT 余额
|
||||
- **交易账户**:USDC 余额(买期权从这里扣)
|
||||
1. 查看顶栏:
|
||||
- **资金账户 / 交易账户**:USDT
|
||||
- **期权资金账户 / 期权交易账户**:USDC
|
||||
2. **币种兑换**(资金账户内)
|
||||
- 从 USDT 兑换为 USDC
|
||||
- 先点 **询价**,确认预估获得量后点 **确认兑换**
|
||||
3. **账户划转**
|
||||
3. **账户内划转**
|
||||
- 从:资金账户 → 到:交易账户
|
||||
- 币种:USDC
|
||||
- 将兑换得到的 USDC 划到交易账户
|
||||
4. 确认 **交易账户 USDC** 足够支付本笔权利金
|
||||
4. 确认 **期权交易账户 USDC** 足够支付本笔权利金
|
||||
|
||||
系统 **不会** 自动兑换或划转,避免误动资金.
|
||||
|
||||
@@ -74,88 +74,34 @@ OKX_OPTIONS_API_PASSPHRASE=...
|
||||
|
||||
## 5. 微信提醒
|
||||
|
||||
当某笔持仓 **未实现盈亏 ≥ 已付权利金的 100%**(翻倍)时,会发 **一条** 企业微信提醒(同一笔只提醒一次).
|
||||
|
||||
需已配置 `WECHAT_WEBHOOK`.
|
||||
|
||||
| 场景 | 标题 | 说明 |
|
||||
|------|------|------|
|
||||
| **开仓** | 【OKX期权·开仓】 | 下单成功并写入本地后必发(幂等) |
|
||||
| **平仓** | 【OKX期权·平仓】 | 手动全平 / 目标位全平 / 到期或交易所平仓同步后必发(幂等) |
|
||||
| 浮盈翻倍 | 【OKX期权·翻倍提醒】 | 未实现盈亏 ≥ 已付权利金约 100%,同一笔只提醒一次 |
|
||||
| 挂单超时撤销 | 【OKX期权·挂单超时撤销】 | 平仓挂单超时被系统撤销 |
|
||||
|
||||
## 6. 与永续 / 对冲计划的关系
|
||||
|
||||
| | 永续(子账户) | 期权(主账户) |
|
||||
|--|----------------|----------------|
|
||||
| API | `OKX_API_*` | `OKX_OPTIONS_API_*` |
|
||||
| | 永续 | 期权 |
|
||||
|--|------|------|
|
||||
| API | `OKX_API_*`(同一套) | `OKX_API_*`(同一套) |
|
||||
| 页面 | 实盘下单 / 关键位 | 期权 · 对冲计划 |
|
||||
| 资金顶栏 | USDT 资金户+交易户 | 期权页单独显示 USDC 等 |
|
||||
|
||||
两套资金 **不合并** 显示.
|
||||
| 顶栏 | USDT 资金户+交易户 | USDC 期权资金+期权交易 |
|
||||
|
||||
**期期对冲张数**(对冲计划页,与单独开期权共用预算算法):
|
||||
|
||||
| 模式 | 说明 |
|
||||
|------|------|
|
||||
| 同张数(默认) | 两腿同 `n`,总权利金 ≤ 预算 |
|
||||
| 做多 | Call:Put 按主腿占比(默认 7:3) |
|
||||
| 做空 | Put:Call 按主腿占比(默认 7:3) |
|
||||
| 按比例 | 主腿/次腿按 `HEDGE_PLAN_OO_BIAS_*` |
|
||||
|
||||
拆分口径与比例见 env:`HEDGE_PLAN_OO_BIAS_SPLIT_BY`(`budget` 默认 / `sheets`=先算同张数总张数 `2n` 再拆)、`HEDGE_PLAN_OO_BIAS_RATIO`(默认 `0.7`)。细则见 [对冲计划开发方案.md](./对冲计划开发方案.md) §4.1、[系统说明.md](./系统说明.md)。
|
||||
## 7. 常见问题
|
||||
|
||||
## 7. 配置说明
|
||||
**Q:以前的期权专用密钥还要配吗?**
|
||||
- 不需要.统一写到 `OKX_API_*`.
|
||||
|
||||
| 变量 | 默认 | 含义 |
|
||||
|------|------|------|
|
||||
| `OKX_OPTIONS_TRADE_BUDGET_USDC` | 10 | 单笔权利金上限 |
|
||||
| `OKX_OPTIONS_BUDGET_BUFFER` | 0.95 | 算张数时预留 5% 缓冲 |
|
||||
| `OKX_OPTIONS_MAX_DTE_DAYS` | 2 | 最多选几天内到期 |
|
||||
| `OKX_OPTIONS_ITM_MAX_DIST_USD` | 30 | 轻度实值:价内不超过多少 USD |
|
||||
| `OKX_OPTIONS_PROFIT_ALERT_RATIO` | 1.0 | 浮盈/权利金 ≥ 此值推送 |
|
||||
| `HEDGE_PLAN_OO_BIAS_SPLIT_BY` | budget | 期期做多/做空:按预算或按张数拆 |
|
||||
| `HEDGE_PLAN_OO_BIAS_RATIO` | 0.7 | 期期做多/做空主腿占比 |
|
||||
|
||||
## 8. 期权复盘(含对冲)
|
||||
|
||||
仅 **OKX** 实例提供独立页 **期权复盘**(`/options/review`),与合约「交易记录与复盘」完全隔离.
|
||||
|
||||
### 数据来源
|
||||
|
||||
| 类型 | source_type | 来源 | 粒度 |
|
||||
|------|-------------|------|------|
|
||||
| 纯期权 | `option_spot` | 本地 `options_trades` 已平仓 | 一仓一条 |
|
||||
| 永期对冲 | `perp_options` | 本地 `hedge_plans` 且 `status=closed` | **一计划一条** |
|
||||
| 期期对冲 | `options_options` | 同上 | **一计划一条** |
|
||||
|
||||
- 打开复盘页即自动读取本地记录,**不访问交易所**.
|
||||
- 对冲盈亏主口径:`realized_pnl_total`;详情另显永续/期权分项.
|
||||
- 若某纯期权 `inst_id` 已出现在对冲腿中,默认标记排除,避免总盈亏双计.
|
||||
- 人工复盘字段存在 `options_review_entries`,刷新本地源**不会覆盖**.
|
||||
|
||||
### 图片
|
||||
|
||||
- 目录:`static/images/options_journal/`
|
||||
- 文件名:`options_journal_{draftId}_{5m|15m|1h|4h}.ext`(与合约复盘同周期槽位)
|
||||
- 备份时与 `crypto.db` 一并打包即可;勿与合约 `journal_*` 截图混用.
|
||||
|
||||
### 页面
|
||||
|
||||
顶部三个 Tab:**期权交易记录** / **期期对冲记录** / **永期对冲记录**.点击列表行后在下方打开「复盘记录上传」,支持四周期即时截图与情绪标签.
|
||||
|
||||
### 统计
|
||||
|
||||
同页 KPI + 分组:类型、标的、策略标签、对冲结束原因、持有周期、Call/Put.策略维度仅统计已填策略标签的记录.
|
||||
|
||||
## 9. 常见问题
|
||||
|
||||
**Q:为什么买不了?**
|
||||
- 交易账户 USDC 不足 → 先兑换再划转
|
||||
- 卖一价过高,10U 预算买不到 1 张 → 选更便宜合约或提高 `OKX_OPTIONS_TRADE_BUDGET_USDC`
|
||||
- 期权 API 未配置或 `OKX_OPTIONS_ENABLED=false`
|
||||
|
||||
**Q:报价 15 是每张 15U 吗?**
|
||||
- 不是.15 是 **每 1 ETH** 的报价;每张(0.01 ETH)约 0.15 USDC.
|
||||
|
||||
**Q:子账户能开期权吗?**
|
||||
- 本系统期权走主账户 API;子账户永续不受影响.
|
||||
|
||||
## 10. 风险说明
|
||||
|
||||
- 买方最大亏损为 **权利金**;近期实值仍会时间衰减
|
||||
- 限价单可能因无流动性未成交
|
||||
- 请先在小额下验证兑换,划转,开平仓全流程
|
||||
**Q:还能主↔子划转吗?**
|
||||
- 已移除.只保留同账户内划转与币种兑换.
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
# 永期对冲计算器
|
||||
|
||||
中控 **策略计算器** 第 3 个 tab:永期对冲。用于离线测算「永续 1 币 + 买方期权」在目标盈利口径下的期权仓位,或按永续:期权比例反推达目标所需波动点数。
|
||||
|
||||
入口:中控 → 策略计算器 → **永期对冲**。
|
||||
API:`POST /api/calculator/perp-options`
|
||||
逻辑库:`lib/hub/hub_perp_options_calc_lib.py`
|
||||
单测:`tests/test_hub_perp_options_calc_lib.py`
|
||||
|
||||
与实例页「对冲计划」不同:本页**不实盘下单、不拉期权链**,价格与杠杆均为手填。
|
||||
|
||||
---
|
||||
|
||||
## 共同假设
|
||||
|
||||
| 项 | 口径 |
|
||||
|----|------|
|
||||
| 品种 | BTC / ETH |
|
||||
| 永续仓位 | 固定 **1 币** |
|
||||
| 单币权利金 | `现价 / 期权杠杆`(例:1800÷100=18U) |
|
||||
| 权利金 | **按全亏**计入;忽略时间价值 / Theta |
|
||||
| 永续手续费 | 开+平各 `0.05%`(`PERP_TAKER_FEE_RATE`,默认 0.0005) |
|
||||
| 期权手续费 | **不算** |
|
||||
| 交易资金 | 仅参考:与 `现价/永续杠杆` 比保证金是否够开 |
|
||||
| `ct_mult` | 默认 0.01;张数 = 期权币数 / ct_mult |
|
||||
| 展示 | 金额与点数统一 **小数点后两位** |
|
||||
|
||||
---
|
||||
|
||||
## 模式一:由波动推期权仓位(`calc_mode=size`)
|
||||
|
||||
已知波动(点数或波动率%)、目标盈利、期权杠杆 → 反推期权开多少币/张。
|
||||
|
||||
### 公式
|
||||
|
||||
```text
|
||||
单币权利金 = 现价 / 期权杠杆
|
||||
永续毛收益 = 波动点数 × 1
|
||||
(波动率模式:现价 × 波动率% × 1)
|
||||
平仓价 ≈ 现价 + 波动点数(永续方向对按上涨测算)
|
||||
永续手续费 = (开仓名义 + 平仓名义) × 0.05%
|
||||
|
||||
权利金预算 = 永续毛收益 − 目标盈利 − 永续手续费
|
||||
期权币数 = 权利金预算 / 单币权利金
|
||||
期权张数 = 期权币数 / ct_mult
|
||||
```
|
||||
|
||||
若权利金预算 ≤ 0:提示「波动收益不足以覆盖目标盈利+手续费,无法开期权」。
|
||||
|
||||
### 情景
|
||||
|
||||
**A · 永续方向对(期权全亏)**
|
||||
|
||||
```text
|
||||
净利 = 永续毛收益 − 权利金总额 − 永续手续费
|
||||
(设计上 ≈ 目标盈利)
|
||||
```
|
||||
|
||||
**B · 期权方向对(永续 1 币反向亏同等波动)**
|
||||
|
||||
```text
|
||||
期权内在 = 期权币数 × 波动点数
|
||||
期权净利 = 期权内在 − 权利金总额
|
||||
永续亏损 = −永续毛收益
|
||||
组合净利 = 期权净利 + 永续亏损
|
||||
```
|
||||
|
||||
**C · 横盘(最大亏损)**
|
||||
|
||||
波动≈0、期权到期无内在价值:
|
||||
|
||||
```text
|
||||
永续盈亏 ≈ 0
|
||||
永续开平手续费 = 2 × 现价 × 1 × 0.05% (同价开平)
|
||||
最大亏损 = 权利金总额 + 永续开平手续费
|
||||
组合净利 = −最大亏损
|
||||
```
|
||||
|
||||
忽略资金费 / Theta 过程中的中间态;口径与「权利金按全亏」一致。
|
||||
|
||||
### 手测示例
|
||||
|
||||
现价 1800、波动 50 点、目标盈利 15、期权杠杆 100、永续杠杆 10:
|
||||
|
||||
| 量 | 约值 |
|
||||
|----|------|
|
||||
| 单币权利金 | 18U |
|
||||
| 永续手续费 | 1.83U |
|
||||
| 权利金预算 | 33.18U |
|
||||
| 期权币数 / 张数 | ≈1.84 币 / ≈184 张 |
|
||||
| A 净利 | ≈15U |
|
||||
| B 期权净利 / 组合 | ≈59U / ≈9U |
|
||||
|
||||
---
|
||||
|
||||
## 模式二:由币数推波动点数(`calc_mode=points`)
|
||||
|
||||
已知永续币数 / 期权币数(如 **1:2** 或 **2:4**)、目标盈利、期权杠杆 → 反推两套情景要涨/跌多少点才能达到目标。
|
||||
|
||||
**按绝对币数**,不再把输入归一到「永续 1 币」。填 2 与 4 → 永续 2 币 + 期权 4 币(权利金、保证金、手续费均按 2 倍于 1:2 放大;达同一目标盈利所需点数会变小)。
|
||||
|
||||
### 仓位
|
||||
|
||||
```text
|
||||
永续币数 = 输入的永续币数
|
||||
期权币数 = 输入的期权币数
|
||||
权利金总额 = 期权币数 × (现价 / 期权杠杆)
|
||||
永续保证金 = 现价 × 永续币数 / 永续杠杆
|
||||
```
|
||||
|
||||
### 情景 A · 永续方向对
|
||||
|
||||
净利 = 目标盈利:
|
||||
|
||||
```text
|
||||
qty×move − 权利金 − fee(move,qty) = 目标
|
||||
fee = (2×现价 + move) × qty × 0.05%
|
||||
|
||||
move = (目标 + 权利金 + 2×现价×qty×0.05%) / (qty × (1 − 0.05%))
|
||||
```
|
||||
|
||||
### 情景 B · 期权方向对(以组合净利为准)
|
||||
|
||||
组合净利 = 目标盈利:
|
||||
|
||||
```text
|
||||
组合 = 期权币数×move − 权利金 − 永续币数×move
|
||||
= move×(期权币数 − 永续币数) − 权利金
|
||||
|
||||
move = (目标 + 权利金) / (期权币数 − 永续币数)
|
||||
```
|
||||
|
||||
要求期权币数 > 永续币数;若相等,组合恒为 −权利金,无法解出正目标。
|
||||
|
||||
结果区展示:所需波动点数(及折合%)、组合净利、其中期权净利、其中永续盈亏。
|
||||
|
||||
### 手测示例
|
||||
|
||||
现价 1800、目标 15、期权杠杆 100、币数 1:2 → 权利金总额 36U:
|
||||
|
||||
| 情景 | 所需点数(约) |
|
||||
|------|----------------|
|
||||
| A 永续方向对(净利=15) | ≈52.83 |
|
||||
| B 组合净利=15 | 51.00 |
|
||||
| C 横盘最大亏损 | 37.80(权利金 36 + 同价开平费 1.8) |
|
||||
|
||||
币数 **2:4**(权利金 72U、保证金 360U):
|
||||
|
||||
| 情景 | 约值 |
|
||||
|------|------|
|
||||
| 仓位 | 永续 2 币 / 期权 4 币(400 张) |
|
||||
| A 所需点数 | ≈45.32 |
|
||||
| B 组合达目标 | 43.50 |
|
||||
| C 横盘最大亏损 | 75.60 |
|
||||
|
||||
---
|
||||
|
||||
## API 请求体(摘要)
|
||||
|
||||
```json
|
||||
{
|
||||
"calc_mode": "size | points",
|
||||
"base": "ETH",
|
||||
"spot": 1800,
|
||||
"capital_usdt": 3000,
|
||||
"target_profit_u": 15,
|
||||
"move_mode": "points",
|
||||
"move_value": 50,
|
||||
"perp_leverage": 10,
|
||||
"option_leverage": 100,
|
||||
"ct_mult": 0.01,
|
||||
"ratio_perp": 1,
|
||||
"ratio_opt": 2
|
||||
}
|
||||
```
|
||||
|
||||
- `size` 模式必填 `move_value`;`points` 模式用 `ratio_perp` / `ratio_opt`,可不填波动。
|
||||
|
||||
---
|
||||
|
||||
## 相关文件
|
||||
|
||||
| 路径 | 作用 |
|
||||
|------|------|
|
||||
| `lib/hub/hub_perp_options_calc_lib.py` | 纯函数测算 |
|
||||
| `manual_trading_hub/hub.py` | `POST /api/calculator/perp-options` |
|
||||
| `manual_trading_hub/static/index.html` | 计算器 tab UI |
|
||||
| `manual_trading_hub/static/calculator.js` | 提交与结果渲染 |
|
||||
| `lib/trade/trade_fee_lib.py` | 永续双边手续费 |
|
||||
|
||||
## 不做
|
||||
|
||||
实盘开平仓、拉 OKX 期权链卖一、把本页结果自动写入对冲计划。
|
||||
|
||||
振幅统计页可对历史日表做同口径对照,见 [振幅统计说明.md](./振幅统计说明.md)「永期对冲对照」。
|
||||
@@ -0,0 +1,62 @@
|
||||
# 策略对比说明
|
||||
|
||||
中控独立页 **策略对比**(`/compare`):在同一风险额 `R` 下,对比三种工具的止盈能力与止损/踏空路径。
|
||||
|
||||
## 用途
|
||||
|
||||
回答两件事:
|
||||
|
||||
1. **盈利时谁更厉害**:干净止盈路径下各赚多少 U
|
||||
2. **谁更易亏 / 更易踏空**:合约止损后踏空;期权/对冲最坏亏满权利金,但踏空路径下常仍可持有到目标
|
||||
|
||||
不是精确概率模型。到期「小盈/小亏」与 4 点收盘相关,**未纳入主表与推荐**。
|
||||
|
||||
## 入口
|
||||
|
||||
- 顶栏「策略对比」;设置 → 显示与导航可隐藏(`show_nav_compare`)
|
||||
- API:`POST /api/compare/calc`(页面即时调用,价格均为手填)
|
||||
|
||||
## 输入
|
||||
|
||||
| 区块 | 字段 |
|
||||
|------|------|
|
||||
| 公共 | 标的 ETH/BTC、方向、入场价、风险 R、统一止损、止盈 |
|
||||
| 单期权 | Call/Put、行权价、卖一(每币)、可选目标价 |
|
||||
| 期期 | 主腿/次腿 各自行权与卖一;预算固定 **7:3** |
|
||||
|
||||
卖一口径与对冲计划一致:`单张成本 = 卖一 × ct_mult`(默认 `ct_mult=0.01`)。
|
||||
|
||||
## 仓位
|
||||
|
||||
- **合约**:`张数 = floor(R / (|入场−止损| × 面值))`,默认面值 0.01
|
||||
- **单期权**:`张数 = floor(R / 单张成本)`
|
||||
- **期期**:主预算 `0.7R`、次预算 `0.3R`,各自 `floor(预算/单张成本)`
|
||||
|
||||
## 主情景(A/B/C)
|
||||
|
||||
| 路径 | 合约 | 单期权 / 期期 |
|
||||
|------|------|----------------|
|
||||
| A 干净止盈 | 入场→止盈盈亏 | 目标价内在价值 − 已付权利金(近似) |
|
||||
| B 打止损 | −实际止损额(≈R) | 止损价处内在−权利金;并注最坏 −权利金 |
|
||||
| C 先止损再去止盈 | **本单仍为止损亏损**;旁注踏空未拿到的原止盈空间 | **仍持有**至目标价,结果同 A(抗踏空对照) |
|
||||
|
||||
期权止盈按**内在价值近似**,不是盘口卖出价。
|
||||
|
||||
## 推荐规则(可解释)
|
||||
|
||||
1. 比较三者 A / R
|
||||
2. 若合约止盈明显高于另两者(≥1.15×)→ 倾向合约,并提示踏空
|
||||
3. 否则若存在踏空对照(合约亏、期权类 C 仍为正)→ 倾向单期权或期期(期期与单腿接近时优先期期)
|
||||
4. 平局:抗踏空优先期权类,赔付碾压则合约
|
||||
|
||||
## 手测示例
|
||||
|
||||
`ETH` 做多,入场 3500,止损 3400,止盈 3700,R=10;单 Call 行权 3600 卖一 50;对冲主 Call 3600/50、次 Put 3400/30:
|
||||
|
||||
- 合约约 10 张,止损 −10U,止盈约 +20U,踏空未拿到约 +20U
|
||||
- 单期权约 20 张,权利金 10U,止盈约 +10U,最坏 −10U
|
||||
- 期期主 14 / 次 10 张
|
||||
|
||||
## 不做
|
||||
|
||||
实盘下单、拉交易所卖一(二期可选)、历史回测入库。
|
||||
+2
-2
@@ -66,7 +66,7 @@
|
||||
| 网页登录密码 | ✅ | 本区块 |
|
||||
| 中控通信密钥 `HUB_BRIDGE_TOKEN` | ❌ | 部署时自动生成,中控与实例一致 |
|
||||
| 登录会话密钥 `FLASK_SECRET_KEY` | ❌ | 部署时自动生成,三所相同 |
|
||||
| 交易所 API | ❌ | 在 **env 配置** 页(各所自配) |
|
||||
| 交易所 API | ❌ | **仅服务器** 各所 `.env`(`*_API_KEY` 等;前端 env 页已移除) |
|
||||
| AI 复盘 / OpenAI | ❌ | 在中控 **系统设置 → AI 配置**(同步三所) |
|
||||
|
||||
### 操作流程
|
||||
@@ -84,7 +84,7 @@
|
||||
|
||||
### 用途
|
||||
|
||||
在 **子账户永续** 场景下,于 **资金账户(funding)** 与 **交易账户(swap)** 之间手动划转 USDT.
|
||||
在 **OKX 账户** 场景下,于 **资金账户(funding)** 与 **交易账户(swap)** 之间手动划转 USDT.
|
||||
|
||||
### 与 env 配置的关系
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# 著作权声明
|
||||
|
||||
## 作品信息
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 作品名称 | crypto_monitor(加密货币交易监控与中控系统) |
|
||||
| 作品形式 | 计算机软件及相关技术文档 |
|
||||
| 著作权人 | 马建军 |
|
||||
| 联系电话 | 18364911125 |
|
||||
| 权利主张起始 | 2026 年(以本仓库首次提交及后续持续开发为准) |
|
||||
|
||||
## 权利声明
|
||||
|
||||
本仓库所含下列内容之著作权归 **马建军** 所有:
|
||||
|
||||
1. 源代码、脚本、配置模板与部署相关文件;
|
||||
2. 界面文案、说明文档、执行手册、策略与设计类文档;
|
||||
3. 由著作权人创作并纳入本仓库的图表、脑图及其他配套材料。
|
||||
|
||||
**Copyright © 2026 马建军. 保留所有权利。**
|
||||
|
||||
未经著作权人书面许可,任何单位或个人不得擅自:
|
||||
|
||||
- 复制、传播、公开披露本仓库全部或部分内容;
|
||||
- 出售、出租、赠与或以任何方式向第三方提供本软件或其衍生版本;
|
||||
- 删除或篡改本声明及表明著作权归属的标识。
|
||||
|
||||
本仓库计划以私有方式保存;私有并不影响著作权人对本作品享有的权利。
|
||||
|
||||
## 证明与版本痕迹
|
||||
|
||||
本作品的创作过程以 Git 提交历史、远程私有仓库记录及快照标签(如 `snapshot/*`)为时间线依据。著作权主张以本声明与上述开发痕迹为准。
|
||||
|
||||
## 免责(与著作权并列说明)
|
||||
|
||||
本软件及相关文档仅供著作权人授权范围内的交易辅助与内部使用。市场有风险,交易决策与盈亏由使用者自行承担;本声明不构成任何投资建议。
|
||||
|
||||
## 对外提供方式
|
||||
|
||||
著作权人对外提供本软件的**默认方式**为:由著作权人为每位客户提供**专属服务器**(一用户一服务器,不与其他客户共用同一台机器)与部署,客户通过访问地址与账号使用,并缴纳服务器费、域名费、部署费及程序使用费;**不交付源代码**。
|
||||
|
||||
对外托管或授权使用时,请签署《托管服务与软件使用合同》(模板见同目录 `软件使用授权合同-模板.md`)。服务定位、适用对象与参考报价见 `服务说明与报价说明.md`。未签署有效合同的,除著作权人本人外,任何人均无权使用、复制或传播本软件。
|
||||
|
||||
## 联系
|
||||
|
||||
- 著作权人:马建军
|
||||
- 电话:18364911125
|
||||
|
||||
本声明随仓库版本一并维护;如有更新,以仓库中最新文本为准。
|
||||
@@ -0,0 +1,71 @@
|
||||
# 账户流水(三所统一)
|
||||
|
||||
从**交易所 API**拉取资金账户与交易账户账单,在实例内展示。
|
||||
不使用程序本地 `transfer_logs` 作为主数据源。
|
||||
|
||||
## 能力概览
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 导航 | 「账户流水」Tab;默认关闭,在 **系统设置 → 导航显示** 打开 |
|
||||
| Tab | **资金账户** / **交易账户** |
|
||||
| 分页 | 每页 10 条,时间倒序 |
|
||||
| 时间窗 | 跟随顶栏 UTC **预设**(与列表 `list_window` 一致) |
|
||||
| 同步 | 后台约 **120s** 拉一次交易所;完成后 **SSE** 推版本,前端自动刷新 |
|
||||
| 币种 | USDT;OKX 另含 **USDC** |
|
||||
| 三所 | Binance / OKX / Gate 同一套 UI 与路由 |
|
||||
|
||||
## 使用
|
||||
|
||||
1. 系统设置 → 导航显示 → 勾选「账户流水」→ 保存
|
||||
2. 顶栏选预设时间并点「应用」
|
||||
3. 打开「账户流水」,切换资金/交易 Tab;可点「立即同步」
|
||||
|
||||
## API
|
||||
|
||||
| 路由 | 说明 |
|
||||
|------|------|
|
||||
| `GET /api/account_ledger?account=funding\|trading&page=1` | 按当前 session 时间窗分页查询缓存 |
|
||||
| `GET /api/account_ledger/stream` | SSE,`event: ledger`,载荷含 `ledger_version` |
|
||||
| `POST /api/account_ledger/refresh` | 手动触发同步(有冷却,默认 30s) |
|
||||
|
||||
均需登录(与实例其他 API 相同)。
|
||||
|
||||
## 交易所数据源
|
||||
|
||||
| 所 | 资金账户 | 交易账户 |
|
||||
|----|----------|----------|
|
||||
| Gate | spot `account_book`(USDT) | USDT 永续 `account_book` |
|
||||
| OKX | `asset/bills`(USDT+USDC) | `account/bills` + `bills-archive`(USDT+USDC) |
|
||||
| Binance | 充提 + `fetch_transfers`(USDT) | U 本位 `fapi` income(USDT) |
|
||||
|
||||
后台默认回看 **90 天**(`ACCOUNT_LEDGER_LOOKBACK_DAYS`),写入本地 SQLite 缓存后再按顶栏时间窗过滤展示。
|
||||
「全部 / 近 6 月」等超出回看窗口的部分,仅能看到缓存内数据。
|
||||
|
||||
## 环境变量(可选)
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `ACCOUNT_LEDGER_POLL_SEC` | `120` | 后台轮询秒数 |
|
||||
| `ACCOUNT_LEDGER_LOOKBACK_DAYS` | `90` | 拉取与手动同步上限天数 |
|
||||
| `ACCOUNT_LEDGER_MANUAL_COOLDOWN_SEC` | `30` | 手动同步冷却 |
|
||||
| `ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC` | `25` | SSE 心跳 |
|
||||
|
||||
## 代码位置
|
||||
|
||||
```
|
||||
lib/account_ledger/ # DB / 同步 / SSE / 注册 / 面板
|
||||
lib/exchange/*_ledger_lib.py # 三所拉取适配
|
||||
lib/common/static/account_ledger.js
|
||||
```
|
||||
|
||||
三所 `app.py` 调用:`install_account_ledger(..., exchange_key=...)`。
|
||||
|
||||
## 审计摘要(2026-08-10)
|
||||
|
||||
- 路由均 `@login_required`;SSE 仅推版本号,不含账单正文
|
||||
- SQL 参数化;`account` 白名单;币种服务端固定
|
||||
- 前端表格字段 `escapeHtml`
|
||||
- **已修复**:手动同步强制套用 lookback 上限 + 冷却,避免滥用刷交易所 API
|
||||
|
||||
详见同目录旁注或 PR 说明;安全复查子代理结论:修复后无未关闭的中高危项。
|
||||
@@ -0,0 +1,192 @@
|
||||
# 托管服务与软件使用合同(模板)
|
||||
|
||||
> 说明:本文为**合同模板**,适用于甲方(马建军)提供**专属服务器**与部署、乙方通过网页/账号使用软件、**不交付源码**的托管模式。
|
||||
> **一用户一服务器**:每位客户单独一台(套)服务器,不与其他客户共用同一台服务器。
|
||||
> 与仓库内《著作权声明》配套:声明主张权利;本合同约定服务范围、费用与使用边界。
|
||||
> 签署前请双方核对条款;金额较大或长期合作,建议再请律师审阅。
|
||||
|
||||
---
|
||||
|
||||
**合同编号:** ________________
|
||||
**签订日期:** ______ 年 ____ 月 ____ 日
|
||||
**签订地点:** ________________
|
||||
|
||||
## 甲方(服务提供方 / 著作权人)
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 姓名 | 马建军 |
|
||||
| 联系电话 | 18364911125 |
|
||||
| 身份证件号码 | ________________(签署时填写) |
|
||||
| 住址 | ________________(签署时填写,选填) |
|
||||
|
||||
## 乙方(客户 / 使用方)
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 姓名 / 名称 | ________________ |
|
||||
| 证件类型及号码 | ________________ |
|
||||
| 联系电话 | ________________ |
|
||||
| 住址 / 住所地 | ________________(选填) |
|
||||
|
||||
甲乙双方就甲方在其控制的服务器上部署、运维 `crypto_monitor`(加密货币交易监控与中控系统,以下称「本软件」),并向乙方提供**托管使用服务**,经协商一致,订立本合同。
|
||||
|
||||
---
|
||||
|
||||
## 第一条 服务内容与交付方式
|
||||
|
||||
1.1 **服务模式**:甲方为乙方提供**专属**云服务器(或等价专属托管环境)、域名解析(或子域名)、程序部署与运行维护;乙方通过甲方提供的 **访问地址与账号** 使用本软件,**不交付、不提供** 源代码、私有仓库权限、部署脚本全集或可用于独立重建系统的技术资料。
|
||||
|
||||
1.2 **一用户一服务器**:本合同项下服务器**仅供乙方使用**,不与其他客户共用同一台服务器、同一操作系统实例或同一套生产部署环境。甲方不得将其他客户的程序、数据或账号部署于本合同约定的专属服务器上。
|
||||
|
||||
1.3 **交付物**(勾选实际提供项):
|
||||
- [ ] 专属服务器标识 / 实例 ID(选填):________________
|
||||
- [ ] 中控访问地址:________________
|
||||
- [ ] 实例访问地址(交易所):________________ / ________________ / ________________
|
||||
- [ ] 登录账号:________________(或另行发放)
|
||||
- [ ] 使用说明 / 培训(____ 次,每次 ____ 分钟,选填)
|
||||
|
||||
1.4 **不包含**(除非另签书面补充协议并另付费):源码转让、源码只读权限、独立私有化部署包、二次开发源代码交付、数据库完整镜像导出用于迁移至第三方系统、服务器 root/控制台账号移交(服务器由甲方代持运维)。
|
||||
|
||||
1.5 本软件著作权及部署架构归甲方所有。专属服务器的云账号/机器所有权或租赁关系由甲方管理,乙方取得的是**该服务器上本软件的有限使用权**,不转让著作权、商标权、服务器所有权及其他知识产权。
|
||||
|
||||
---
|
||||
|
||||
## 第二条 授权范围与使用限制
|
||||
|
||||
2.1 **授权性质**:普通、非独占、不可再许可;仅限本合同约定的**专属服务器**及域名/访问地址范围内使用。
|
||||
|
||||
2.2 **使用主体**:仅限乙方本人及经甲方书面确认的 ______ 名操作人员;账号不得转借、共享给合同外第三方。
|
||||
|
||||
2.3 **使用目的**:仅限乙方自身交易辅助、内部监控与运营;不得将本软件或实质相同的功能作为产品/服务向不特定公众或第三方收费提供。
|
||||
|
||||
2.4 **服务期限**:
|
||||
- 自 ______ 年 ____ 月 ____ 日起,至 ______ 年 ____ 月 ____ 日止;
|
||||
- 期满前 ______ 日双方可协商续签;期满未续费且未书面延期的,甲方有权停服并回收该专属服务器资源。
|
||||
|
||||
2.5 乙方不得实施下列行为:
|
||||
1. 要求或试图获取源码、Git 仓库、服务器 root/云控制台权限(合同另有约定的除外);
|
||||
2. 复制、传播、截图外传足以重建系统的架构说明、配置全集或程序文件;
|
||||
3. 对系统进行反向工程、抓包重建、或委托他人仿制同类托管产品对外经营;
|
||||
4. 将访问账号、域名、API 密钥用于合同约定外的用途或转售;
|
||||
5. 攻击、扫描本合同专属服务器或甲方其他基础设施。
|
||||
|
||||
---
|
||||
|
||||
## 第三条 费用与支付
|
||||
|
||||
3.1 乙方按下列项目向甲方支付费用(勾选并填写金额;可打包为「标准套餐价」并在备注中列明分项):
|
||||
|
||||
| 费用项目 | 说明 | 金额(元) | 计费周期 |
|
||||
|----------|------|------------|----------|
|
||||
| 服务器费用 | **乙方专属**云主机、带宽、磁盘等(不与其他客户分摊同一台机器) | ¥ ______ | □月付 □年付 |
|
||||
| 域名费用 | 域名注册/续费(域名归属:□甲方代持 □乙方自有,解析由甲方配置) | ¥ ______ | □年付 |
|
||||
| 部署费用 | 在专属服务器上首次环境搭建、证书、实例与中控上线(一次性) | ¥ ______ | 一次性 |
|
||||
| 程序使用费 | 本软件托管使用权、日常更新与基础运维 | ¥ ______ | □月付 □年付 |
|
||||
|
||||
3.2 **合计**(首年 / 首月应付):人民币(大写)________________ 元整(¥ ________)。
|
||||
|
||||
3.3 **支付方式与时间**:________________(如:签约后 ____ 日内付部署费+首周期费用;之后每 ____ 提前 ____ 日支付续费)。
|
||||
|
||||
3.4 **续费**:服务期满前,乙方按 3.1 约定支付下一周期费用;逾期超过 ______ 日未付的,甲方有权暂停服务;逾期超过 ______ 日仍未付的,甲方有权解除合同并停服,已付未消费部分按实际服务天数抵扣后退还(部署费是否退还:□不退 □按约定 ________________)。
|
||||
|
||||
3.5 **价格调整**:续签时,因云厂商涨价、域名涨价或功能范围扩大,甲方可提前 ______ 日书面通知调整后续周期价格;乙方不同意调整的,可在当前周期结束后不再续签。
|
||||
|
||||
3.6 [ ] 本次为试用 / 友情托管:期限至 ______,费用减免 ________________,乙方仍须遵守第二条全部限制。
|
||||
|
||||
---
|
||||
|
||||
## 第四条 部署、运维与更新
|
||||
|
||||
4.1 **甲方责任**(合理范围内):
|
||||
- 按约定完成首次部署并使乙方可以登录使用;
|
||||
- 程序版本更新、安全补丁、PM2/进程异常重启等**基础运维**(具体 SLA:________________,如「工作日 24 小时内响应」);
|
||||
- 因交易所 API 变更导致的**常规适配**(重大重构另议)。
|
||||
|
||||
4.2 **乙方责任**:
|
||||
- 提供合法有效的交易所 API 等密钥信息,并保证账户使用合规;
|
||||
- 妥善保管登录密码;因乙方泄露导致的损失由乙方承担;
|
||||
- 按约定及时支付各项费用。
|
||||
|
||||
4.3 **数据**:乙方在系统中的交易记录、配置等业务数据归属乙方,并存放于本合同专属服务器;甲方为运维可接触相关数据,但不得用于合同约定外的目的,亦不得将乙方数据混存于其他客户服务器。合同终止后,乙方可申请导出**业务数据**(格式:________________,费用:________________);**不包含**源码与部署环境镜像。
|
||||
|
||||
4.4 **停服与备份**:甲方在停服前 ______ 日通知乙方(因乙方欠费紧急停服除外);停服后该专属服务器上的数据保留 ______ 日,逾期可删除并释放服务器资源。
|
||||
|
||||
---
|
||||
|
||||
## 第五条 保密
|
||||
|
||||
5.1 乙方对知悉的本软件存在、界面逻辑、非公开功能、报价及甲方技术方案负有保密义务。
|
||||
|
||||
5.2 甲方对乙方的 API 密钥、账户信息负有保密义务,除运维必需与法律要求外不得向第三方披露。
|
||||
|
||||
5.3 保密期限:合同存续期间及终止后 ______ 年(未填则视为 5 年)。
|
||||
|
||||
---
|
||||
|
||||
## 第六条 免责与风险提示
|
||||
|
||||
6.1 本软件为交易辅助工具,不构成投资建议。市场有风险,乙方交易决策与盈亏自行承担。
|
||||
|
||||
6.2 因行情、交易所接口变更、网络故障、云厂商故障、乙方误操作等导致的交易或间接损失,在法律允许范围内甲方不承担责任;因甲方故意或重大过失造成的服务长时间不可用除外(可约定:连续不可用超过 ____ 小时按比例退还当期程序使用费)。
|
||||
|
||||
6.3 甲方保证其有权提供本托管服务并享有本软件著作权;乙方保证身份信息及资金账户来源合法。
|
||||
|
||||
---
|
||||
|
||||
## 第七条 违约责任
|
||||
|
||||
7.1 乙方欠费、外传账号、试图获取源码或违反第二条的,甲方有权**暂停或立即终止服务**,并要求:
|
||||
1. 停止违约行为;
|
||||
2. 支付欠费及违约金人民币 ________ 元(或按实际损失);
|
||||
3. 赔偿甲方维权合理费用。
|
||||
|
||||
7.2 甲方无正当理由逾期未完成首次部署超过 ______ 日,或恶意长期停服且无合理解释的,乙方有权解除合同并要求退还已付未消费部分(部署费处理按 3.4 约定)。
|
||||
|
||||
---
|
||||
|
||||
## 第八条 合同解除与终止
|
||||
|
||||
8.1 协商一致可书面解除。
|
||||
|
||||
8.2 一方严重违约,守约方书面通知后 ______ 日内仍未改正的,守约方可解除。
|
||||
|
||||
8.3 终止后:乙方停止使用;甲方关闭访问权限;双方按第四条、第五条履行数据与保密义务。
|
||||
|
||||
---
|
||||
|
||||
## 第九条 争议解决
|
||||
|
||||
因本合同引起的争议,双方协商解决;协商不成的,提交甲方住所地有管辖权的人民法院诉讼解决(或:提交 ________ 仲裁委员会仲裁)。
|
||||
|
||||
---
|
||||
|
||||
## 第十条 其他
|
||||
|
||||
10.1 未尽事宜可签订补充协议。
|
||||
|
||||
10.2 本合同一式贰份,甲乙双方各执壹份,具有同等法律效力。
|
||||
|
||||
10.3 附件(如有):□《著作权声明》副本 □《服务说明与报价说明》 □ 服务清单 / 报价单 □ 域名与实例列表 □ 其他:________
|
||||
|
||||
---
|
||||
|
||||
## 签署栏
|
||||
|
||||
**甲方(服务提供方 / 著作权人):**
|
||||
|
||||
签名:________________ 日期:______ 年 ____ 月 ____ 日
|
||||
|
||||
**乙方(客户):**
|
||||
|
||||
签名 / 盖章:________________ 日期:______ 年 ____ 月 ____ 日
|
||||
|
||||
---
|
||||
|
||||
## 填写提示(签署前可删本段)
|
||||
|
||||
1. **标准商业路径**:专属服务器费 + 域名费 + 部署费(首单)+ 程序使用费(按月/年)— 四项建议在报价单里写清,合同 3.1 表格与报价一致。
|
||||
2. **一用户一服务器**:新客户开新机器;不要把多名客户塞进同一台 VPS。
|
||||
3. **源码**:默认一律不交付;若客户坚持私有化,应另签高价「源码许可/买断」合同,与本托管模板分开。
|
||||
4. **自用**:著作权人本人使用无需签本合同,见《著作权声明》。
|
||||
5. **不要**在仓库添加开源 `LICENSE`(MIT 等),与「保留所有权利 + 托管授权」冲突。
|
||||
@@ -0,0 +1 @@
|
||||
"""实例账户流水(交易所资金/交易账户账单)."""
|
||||
@@ -0,0 +1,186 @@
|
||||
"""账户流水 SQLite 缓存."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from lib.account_ledger.account_ledger_normalize import PAGE_SIZE, VALID_ACCOUNTS
|
||||
|
||||
|
||||
def ensure_account_ledger_tables(conn) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS account_ledger_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account TEXT NOT NULL,
|
||||
ccy TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
balance_after REAL,
|
||||
kind TEXT,
|
||||
raw_type TEXT,
|
||||
symbol TEXT,
|
||||
ref_id TEXT NOT NULL,
|
||||
ts_ms INTEGER NOT NULL,
|
||||
note TEXT,
|
||||
synced_at REAL,
|
||||
UNIQUE(account, ref_id, ccy, ts_ms)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_account_ledger_acc_ts "
|
||||
"ON account_ledger_entries(account, ts_ms DESC)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS account_ledger_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def meta_get(conn, key: str, default: str = "") -> str:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM account_ledger_meta WHERE key=?", (key,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return default
|
||||
try:
|
||||
return str(row[0] if not hasattr(row, "keys") else row["value"])
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def meta_set(conn, key: str, value: str) -> None:
|
||||
conn.execute(
|
||||
"INSERT INTO account_ledger_meta(key, value) VALUES(?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
(key, str(value)),
|
||||
)
|
||||
|
||||
|
||||
def upsert_entries(conn, rows: list[dict[str, Any]]) -> int:
|
||||
if not rows:
|
||||
return 0
|
||||
now = time.time()
|
||||
n = 0
|
||||
for r in rows:
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO account_ledger_entries(
|
||||
account, ccy, amount, balance_after, kind, raw_type,
|
||||
symbol, ref_id, ts_ms, note, synced_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(account, ref_id, ccy, ts_ms) DO UPDATE SET
|
||||
amount=excluded.amount,
|
||||
balance_after=excluded.balance_after,
|
||||
kind=excluded.kind,
|
||||
raw_type=excluded.raw_type,
|
||||
symbol=excluded.symbol,
|
||||
note=excluded.note,
|
||||
synced_at=excluded.synced_at
|
||||
""",
|
||||
(
|
||||
r["account"],
|
||||
r["ccy"],
|
||||
float(r["amount"]),
|
||||
r.get("balance_after"),
|
||||
r.get("kind") or "other",
|
||||
r.get("raw_type") or "",
|
||||
r.get("symbol") or "",
|
||||
r["ref_id"],
|
||||
int(r["ts_ms"]),
|
||||
r.get("note") or "",
|
||||
now,
|
||||
),
|
||||
)
|
||||
n += 1
|
||||
except Exception:
|
||||
continue
|
||||
conn.commit()
|
||||
return n
|
||||
|
||||
|
||||
def query_entries(
|
||||
conn,
|
||||
*,
|
||||
account: str,
|
||||
start_ms: int,
|
||||
end_ms: int,
|
||||
page: int = 1,
|
||||
page_size: int = PAGE_SIZE,
|
||||
currencies: Optional[list[str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
acc = (account or "").strip().lower()
|
||||
if acc not in VALID_ACCOUNTS:
|
||||
return {"items": [], "total": 0, "page": 1, "page_size": page_size, "pages": 0}
|
||||
page = max(1, int(page or 1))
|
||||
page_size = max(1, min(50, int(page_size or PAGE_SIZE)))
|
||||
start_ms = int(start_ms)
|
||||
end_ms = int(end_ms)
|
||||
params: list[Any] = [acc, start_ms, end_ms]
|
||||
ccy_sql = ""
|
||||
if currencies:
|
||||
ccy_list = [c.strip().upper() for c in currencies if c and str(c).strip()]
|
||||
if ccy_list:
|
||||
placeholders = ",".join("?" for _ in ccy_list)
|
||||
ccy_sql = f" AND ccy IN ({placeholders})"
|
||||
params.extend(ccy_list)
|
||||
total = conn.execute(
|
||||
f"SELECT COUNT(*) FROM account_ledger_entries "
|
||||
f"WHERE account=? AND ts_ms>=? AND ts_ms<=?{ccy_sql}",
|
||||
params,
|
||||
).fetchone()[0]
|
||||
total = int(total or 0)
|
||||
pages = (total + page_size - 1) // page_size if total else 0
|
||||
if pages and page > pages:
|
||||
page = pages
|
||||
offset = (page - 1) * page_size
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT account, ccy, amount, balance_after, kind, raw_type, symbol,
|
||||
ref_id, ts_ms, note
|
||||
FROM account_ledger_entries
|
||||
WHERE account=? AND ts_ms>=? AND ts_ms<=?{ccy_sql}
|
||||
ORDER BY ts_ms DESC, id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
params + [page_size, offset],
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
if hasattr(r, "keys"):
|
||||
d = {k: r[k] for k in r.keys()}
|
||||
else:
|
||||
d = {
|
||||
"account": r[0],
|
||||
"ccy": r[1],
|
||||
"amount": r[2],
|
||||
"balance_after": r[3],
|
||||
"kind": r[4],
|
||||
"raw_type": r[5],
|
||||
"symbol": r[6],
|
||||
"ref_id": r[7],
|
||||
"ts_ms": r[8],
|
||||
"note": r[9],
|
||||
}
|
||||
from lib.account_ledger.account_ledger_normalize import kind_label_zh
|
||||
|
||||
d["kind_label"] = kind_label_zh(d.get("kind") or "")
|
||||
items.append(d)
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"pages": pages,
|
||||
}
|
||||
|
||||
|
||||
def prune_older_than(conn, min_ts_ms: int) -> None:
|
||||
conn.execute("DELETE FROM account_ledger_entries WHERE ts_ms < ?", (int(min_ts_ms),))
|
||||
conn.commit()
|
||||
@@ -0,0 +1,192 @@
|
||||
"""账户流水:交易所原始记录 → 统一行模型."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
ACCOUNT_FUNDING = "funding"
|
||||
ACCOUNT_TRADING = "trading"
|
||||
VALID_ACCOUNTS = frozenset({ACCOUNT_FUNDING, ACCOUNT_TRADING})
|
||||
|
||||
PAGE_SIZE = 10
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> Optional[float]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _safe_int(v: Any) -> Optional[int]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
n = float(v)
|
||||
if n > 1e12:
|
||||
return int(n)
|
||||
if n > 1e9:
|
||||
return int(n)
|
||||
return int(n)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _ts_ms(v: Any) -> Optional[int]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
n = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if n > 1e12:
|
||||
return int(n)
|
||||
if n > 1e10:
|
||||
return int(n)
|
||||
return int(n * 1000.0)
|
||||
|
||||
|
||||
def kind_from_raw(raw_type: str, amount: Optional[float] = None) -> str:
|
||||
t = (raw_type or "").strip().lower()
|
||||
if not t:
|
||||
return "other"
|
||||
if "deposit" in t or t in ("1", "funding_deposit"):
|
||||
return "deposit"
|
||||
if "withdraw" in t or "withdrawal" in t:
|
||||
return "withdraw"
|
||||
if "transfer" in t or "dnw" in t or t in ("2", "18", "19"):
|
||||
if amount is not None and amount < 0:
|
||||
return "transfer_out"
|
||||
if amount is not None and amount > 0:
|
||||
return "transfer_in"
|
||||
return "transfer"
|
||||
if "funding" in t and "fee" in t:
|
||||
return "funding_fee"
|
||||
if t in ("funding_fee", "fundingfee", "8"):
|
||||
return "funding_fee"
|
||||
if "commission" in t or "fee" in t or t in ("commission", "5", "fee"):
|
||||
return "commission"
|
||||
if "realiz" in t or "pnl" in t or t in ("realized_pnl", "realizedpnl", "3"):
|
||||
return "realized_pnl"
|
||||
if "liqui" in t:
|
||||
return "liquidate"
|
||||
return "other"
|
||||
|
||||
|
||||
def kind_label_zh(kind: str) -> str:
|
||||
return {
|
||||
"deposit": "充值",
|
||||
"withdraw": "提现",
|
||||
"transfer": "划转",
|
||||
"transfer_in": "划入",
|
||||
"transfer_out": "划出",
|
||||
"realized_pnl": "已实现盈亏",
|
||||
"funding_fee": "资金费",
|
||||
"commission": "手续费",
|
||||
"liquidate": "强平",
|
||||
"other": "其他",
|
||||
}.get((kind or "").strip().lower(), "其他")
|
||||
|
||||
|
||||
def make_ref_id(*parts: Any) -> str:
|
||||
bits = []
|
||||
for p in parts:
|
||||
if p is None:
|
||||
continue
|
||||
s = str(p).strip()
|
||||
if s:
|
||||
bits.append(s)
|
||||
return "|".join(bits) if bits else ""
|
||||
|
||||
|
||||
def normalize_row(
|
||||
*,
|
||||
account: str,
|
||||
ccy: str,
|
||||
amount: Any,
|
||||
ts_ms: Any,
|
||||
ref_id: str,
|
||||
raw_type: str = "",
|
||||
balance_after: Any = None,
|
||||
symbol: str = "",
|
||||
note: str = "",
|
||||
kind: str = "",
|
||||
) -> Optional[dict[str, Any]]:
|
||||
acc = (account or "").strip().lower()
|
||||
if acc not in VALID_ACCOUNTS:
|
||||
return None
|
||||
ccy_u = (ccy or "").strip().upper()
|
||||
if not ccy_u:
|
||||
return None
|
||||
amt = _safe_float(amount)
|
||||
if amt is None:
|
||||
return None
|
||||
ts = _ts_ms(ts_ms)
|
||||
if ts is None or ts <= 0:
|
||||
return None
|
||||
rid = (ref_id or "").strip() or make_ref_id(acc, ccy_u, ts, amt, raw_type)
|
||||
k = (kind or "").strip().lower() or kind_from_raw(raw_type, amt)
|
||||
bal = _safe_float(balance_after)
|
||||
return {
|
||||
"account": acc,
|
||||
"ccy": ccy_u,
|
||||
"amount": amt,
|
||||
"balance_after": bal,
|
||||
"kind": k,
|
||||
"kind_label": kind_label_zh(k),
|
||||
"raw_type": (raw_type or "").strip()[:120],
|
||||
"symbol": (symbol or "").strip()[:80],
|
||||
"ref_id": rid[:200],
|
||||
"ts_ms": int(ts),
|
||||
"note": (note or "").strip()[:240],
|
||||
}
|
||||
|
||||
|
||||
def from_ccxt_ledger_entry(entry: dict[str, Any], *, account: str) -> Optional[dict[str, Any]]:
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
info = entry.get("info") if isinstance(entry.get("info"), dict) else {}
|
||||
amount = entry.get("amount")
|
||||
if amount is None:
|
||||
amount = entry.get("change")
|
||||
if amount is None:
|
||||
amount = info.get("balChg") or info.get("change") or info.get("income") or info.get("amount")
|
||||
ts = entry.get("timestamp") or entry.get("datetime")
|
||||
if ts is None:
|
||||
ts = info.get("time") or info.get("uTime") or info.get("ts") or info.get("create_time") or info.get("createDate")
|
||||
ccy = entry.get("currency") or info.get("ccy") or info.get("asset") or info.get("currency") or "USDT"
|
||||
raw_type = (
|
||||
entry.get("type")
|
||||
or entry.get("status")
|
||||
or info.get("type")
|
||||
or info.get("incomeType")
|
||||
or info.get("change_type")
|
||||
or info.get("subType")
|
||||
or ""
|
||||
)
|
||||
if isinstance(raw_type, (int, float)):
|
||||
raw_type = str(raw_type)
|
||||
balance_after = entry.get("balance") or info.get("bal") or info.get("balance")
|
||||
symbol = entry.get("symbol") or info.get("instId") or info.get("symbol") or info.get("contract") or ""
|
||||
ref = (
|
||||
entry.get("id")
|
||||
or info.get("billId")
|
||||
or info.get("tranId")
|
||||
or info.get("id")
|
||||
or info.get("trade_id")
|
||||
or ""
|
||||
)
|
||||
note = entry.get("description") or info.get("info") or info.get("text") or ""
|
||||
return normalize_row(
|
||||
account=account,
|
||||
ccy=str(ccy),
|
||||
amount=amount,
|
||||
ts_ms=ts,
|
||||
ref_id=str(ref) if ref != "" else make_ref_id(account, ccy, ts, amount, raw_type),
|
||||
raw_type=str(raw_type),
|
||||
balance_after=balance_after,
|
||||
symbol=str(symbol or ""),
|
||||
note=str(note or ""),
|
||||
)
|
||||
@@ -0,0 +1,208 @@
|
||||
"""三所统一:账户流水路由 + 后台同步安装."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Callable
|
||||
|
||||
from flask import Flask, Response, jsonify, request, session, stream_with_context
|
||||
from jinja2 import ChoiceLoader, FileSystemLoader
|
||||
|
||||
from lib.account_ledger.account_ledger_db import ensure_account_ledger_tables, query_entries
|
||||
from lib.account_ledger.account_ledger_normalize import (
|
||||
ACCOUNT_FUNDING,
|
||||
ACCOUNT_TRADING,
|
||||
PAGE_SIZE,
|
||||
VALID_ACCOUNTS,
|
||||
)
|
||||
from lib.account_ledger.account_ledger_sync import account_ledger_store
|
||||
from lib.common.history_window_lib import resolve_list_window
|
||||
|
||||
|
||||
def attach_account_ledger_templates(app: Flask, repo_root: str) -> None:
|
||||
tpl_dir = os.path.join(repo_root, "lib", "account_ledger", "templates")
|
||||
if not os.path.isdir(tpl_dir):
|
||||
return
|
||||
existing = app.jinja_loader
|
||||
loaders = [FileSystemLoader(tpl_dir)]
|
||||
if existing is not None:
|
||||
if isinstance(existing, ChoiceLoader):
|
||||
loaders = list(existing.loaders) + loaders
|
||||
else:
|
||||
loaders.insert(0, existing)
|
||||
app.jinja_loader = ChoiceLoader(loaders)
|
||||
|
||||
|
||||
def _build_fetch_fn(exchange_key: str, app_module: Any) -> Callable:
|
||||
ex_key = (exchange_key or "").strip().lower()
|
||||
exchange = getattr(app_module, "exchange", None)
|
||||
ensure_markets = getattr(app_module, "ensure_markets_loaded", None)
|
||||
|
||||
def _fetch(*, start_ms: int, end_ms: int):
|
||||
if exchange is None:
|
||||
return [], ["exchange missing"]
|
||||
if ex_key == "okx":
|
||||
from lib.exchange.okx_ledger_lib import fetch_okx_account_ledger
|
||||
|
||||
return fetch_okx_account_ledger(
|
||||
exchange,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
ensure_markets=ensure_markets,
|
||||
)
|
||||
if ex_key == "binance":
|
||||
from lib.exchange.binance_ledger_lib import fetch_binance_account_ledger
|
||||
|
||||
return fetch_binance_account_ledger(
|
||||
exchange,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
ensure_markets=ensure_markets,
|
||||
)
|
||||
from lib.exchange.gate_ledger_lib import fetch_gate_account_ledger
|
||||
|
||||
return fetch_gate_account_ledger(
|
||||
exchange,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
ensure_markets=ensure_markets,
|
||||
)
|
||||
|
||||
return _fetch
|
||||
|
||||
|
||||
def _currencies_for_exchange(exchange_key: str) -> list[str]:
|
||||
if (exchange_key or "").strip().lower() == "okx":
|
||||
return ["USDT", "USDC"]
|
||||
return ["USDT"]
|
||||
|
||||
|
||||
def install_account_ledger(
|
||||
app: Flask,
|
||||
repo_root: str,
|
||||
app_module: Any,
|
||||
*,
|
||||
exchange_key: str = "",
|
||||
) -> None:
|
||||
ex = (exchange_key or "").strip().lower()
|
||||
if not ex:
|
||||
mod_name = getattr(app_module, "__name__", "") or ""
|
||||
if "okx" in mod_name.lower():
|
||||
ex = "okx"
|
||||
elif "binance" in mod_name.lower():
|
||||
ex = "binance"
|
||||
else:
|
||||
ex = "gate"
|
||||
exchange_key = ex
|
||||
|
||||
attach_account_ledger_templates(app, repo_root)
|
||||
get_db = app_module.get_db
|
||||
login_required = app_module.login_required
|
||||
|
||||
# 初始化表
|
||||
try:
|
||||
conn = get_db()
|
||||
try:
|
||||
ensure_account_ledger_tables(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
account_ledger_store.configure(
|
||||
get_db=get_db,
|
||||
fetch_fn=_build_fetch_fn(exchange_key, app_module),
|
||||
exchange_key=str(exchange_key),
|
||||
)
|
||||
account_ledger_store.start()
|
||||
app.extensions["account_ledger_exchange"] = str(exchange_key).lower()
|
||||
|
||||
def _list_window():
|
||||
resolve = getattr(app_module, "_list_window_from_request", None)
|
||||
if callable(resolve):
|
||||
return resolve()
|
||||
return resolve_list_window(request.args, session)
|
||||
|
||||
@app.route("/api/account_ledger")
|
||||
@login_required
|
||||
def api_account_ledger():
|
||||
account = (request.args.get("account") or ACCOUNT_FUNDING).strip().lower()
|
||||
if account not in VALID_ACCOUNTS:
|
||||
account = ACCOUNT_FUNDING
|
||||
try:
|
||||
page = int(request.args.get("page") or 1)
|
||||
except Exception:
|
||||
page = 1
|
||||
win = _list_window()
|
||||
start_ms = int(win.get("start_ms") or 0)
|
||||
end_ms = int(win.get("end_ms") or 0)
|
||||
ccys = _currencies_for_exchange(app.extensions.get("account_ledger_exchange") or "")
|
||||
conn = get_db()
|
||||
try:
|
||||
ensure_account_ledger_tables(conn)
|
||||
data = query_entries(
|
||||
conn,
|
||||
account=account,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
page=page,
|
||||
page_size=PAGE_SIZE,
|
||||
currencies=ccys,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
st = account_ledger_store.status_dict()
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"account": account,
|
||||
"window": {
|
||||
"preset": win.get("preset"),
|
||||
"label": win.get("label"),
|
||||
"start_ms": start_ms,
|
||||
"end_ms": end_ms,
|
||||
},
|
||||
"currencies": ccys,
|
||||
**data,
|
||||
**st,
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/account_ledger/stream")
|
||||
@login_required
|
||||
def api_account_ledger_stream():
|
||||
return Response(
|
||||
stream_with_context(account_ledger_store.iter_sse()),
|
||||
mimetype="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
@app.route("/api/account_ledger/refresh", methods=["POST"])
|
||||
@login_required
|
||||
def api_account_ledger_refresh():
|
||||
win = _list_window()
|
||||
body = request.get_json(silent=True) or {}
|
||||
start_ms = body.get("start_ms", win.get("start_ms"))
|
||||
end_ms = body.get("end_ms", win.get("end_ms"))
|
||||
try:
|
||||
start_i = int(start_ms) if start_ms is not None else None
|
||||
end_i = int(end_ms) if end_ms is not None else None
|
||||
except Exception:
|
||||
start_i, end_i = None, None
|
||||
result = account_ledger_store.sync_once(
|
||||
reason="manual", start_ms=start_i, end_ms=end_i
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
@app.route("/account_ledger")
|
||||
@login_required
|
||||
def account_ledger_page():
|
||||
from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled
|
||||
|
||||
redir = redirect_to_embed_shell_if_enabled("account_ledger")
|
||||
if redir is not None:
|
||||
return redir
|
||||
return app_module.render_main_page("account_ledger")
|
||||
@@ -0,0 +1,252 @@
|
||||
"""账户流水:后台定时拉取交易所 + SSE 版本推送."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from lib.account_ledger.account_ledger_db import (
|
||||
ensure_account_ledger_tables,
|
||||
meta_get,
|
||||
meta_set,
|
||||
prune_older_than,
|
||||
upsert_entries,
|
||||
)
|
||||
|
||||
ACCOUNT_LEDGER_POLL_SEC = float(os.getenv("ACCOUNT_LEDGER_POLL_SEC", "120"))
|
||||
ACCOUNT_LEDGER_LOOKBACK_DAYS = int(os.getenv("ACCOUNT_LEDGER_LOOKBACK_DAYS", "90"))
|
||||
ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC = float(os.getenv("ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC", "25"))
|
||||
|
||||
|
||||
class AccountLedgerStore:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self.version = 0
|
||||
self._subscribers: list[queue.Queue[str | None]] = []
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._syncing = False
|
||||
self._get_db: Optional[Callable] = None
|
||||
self._fetch_fn: Optional[Callable[..., tuple[list[dict[str, Any]], list[str]]]] = None
|
||||
self._exchange_key = ""
|
||||
self.last_sync_at: Optional[float] = None
|
||||
self.last_error: str = ""
|
||||
self.last_upserted: int = 0
|
||||
self._last_manual_at: float = 0.0
|
||||
self._manual_cooldown_sec = float(os.getenv("ACCOUNT_LEDGER_MANUAL_COOLDOWN_SEC", "30"))
|
||||
|
||||
def configure(
|
||||
self,
|
||||
*,
|
||||
get_db: Callable,
|
||||
fetch_fn: Callable[..., tuple[list[dict[str, Any]], list[str]]],
|
||||
exchange_key: str,
|
||||
) -> None:
|
||||
self._get_db = get_db
|
||||
self._fetch_fn = fetch_fn
|
||||
self._exchange_key = (exchange_key or "").strip().lower()
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
if not self._get_db or not self._fetch_fn:
|
||||
return
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._loop, daemon=True, name=f"account-ledger-{self._exchange_key or 'x'}"
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
self._broadcast(close=True)
|
||||
|
||||
def lookback_bounds_ms(self, start_ms: Optional[int] = None, end_ms: Optional[int] = None) -> tuple[int, int]:
|
||||
now = datetime.now(timezone.utc)
|
||||
end = int(end_ms) if end_ms is not None else int(now.timestamp() * 1000)
|
||||
floor = int(end - ACCOUNT_LEDGER_LOOKBACK_DAYS * 86400 * 1000)
|
||||
if start_ms is not None:
|
||||
start = max(int(start_ms), floor)
|
||||
else:
|
||||
start = floor
|
||||
if start > end:
|
||||
start, end = end, start
|
||||
return start, end
|
||||
|
||||
def sync_once(
|
||||
self,
|
||||
*,
|
||||
reason: str = "poll",
|
||||
start_ms: Optional[int] = None,
|
||||
end_ms: Optional[int] = None,
|
||||
) -> dict[str, Any]:
|
||||
if not self._get_db or not self._fetch_fn:
|
||||
return {"ok": False, "msg": "未配置"}
|
||||
with self._lock:
|
||||
if self._syncing:
|
||||
return {"ok": True, "busy": True, "ledger_version": self.version}
|
||||
if reason == "manual":
|
||||
gap = time.time() - self._last_manual_at
|
||||
if gap < self._manual_cooldown_sec:
|
||||
wait = int(self._manual_cooldown_sec - gap) + 1
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": f"同步过于频繁,请 {wait}s 后再试",
|
||||
"ledger_version": self.version,
|
||||
}
|
||||
self._syncing = True
|
||||
try:
|
||||
start, end = self.lookback_bounds_ms(start_ms, end_ms)
|
||||
rows, errors = self._fetch_fn(start_ms=start, end_ms=end)
|
||||
conn = self._get_db()
|
||||
try:
|
||||
ensure_account_ledger_tables(conn)
|
||||
n = upsert_entries(conn, rows or [])
|
||||
# 保留略宽于 lookback 的缓存
|
||||
prune_ms = int(
|
||||
(datetime.now(timezone.utc).timestamp() - (ACCOUNT_LEDGER_LOOKBACK_DAYS + 7) * 86400)
|
||||
* 1000
|
||||
)
|
||||
prune_older_than(conn, prune_ms)
|
||||
self.last_sync_at = time.time()
|
||||
self.last_upserted = n
|
||||
self.last_error = "; ".join(errors[:3]) if errors else ""
|
||||
meta_set(conn, "last_sync_at", str(self.last_sync_at))
|
||||
meta_set(conn, "last_error", self.last_error)
|
||||
meta_set(conn, "last_upserted", str(n))
|
||||
conn.commit()
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if reason == "manual":
|
||||
self._last_manual_at = time.time()
|
||||
ver = self.bump(reason)
|
||||
return {
|
||||
"ok": True,
|
||||
"ledger_version": ver,
|
||||
"upserted": n,
|
||||
"errors": errors,
|
||||
"start_ms": start,
|
||||
"end_ms": end,
|
||||
}
|
||||
except Exception as e:
|
||||
self.last_error = str(e)
|
||||
try:
|
||||
conn = self._get_db()
|
||||
try:
|
||||
ensure_account_ledger_tables(conn)
|
||||
meta_set(conn, "last_error", self.last_error)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": False, "msg": str(e), "ledger_version": self.version}
|
||||
finally:
|
||||
with self._lock:
|
||||
self._syncing = False
|
||||
|
||||
def bump(self, reason: str = "poll") -> int:
|
||||
with self._lock:
|
||||
self.version += 1
|
||||
ver = self.version
|
||||
payload = json.dumps(
|
||||
{"ledger_version": ver, "reason": reason, "exchange": self._exchange_key},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
self._broadcast(payload)
|
||||
return ver
|
||||
|
||||
def status_dict(self) -> dict[str, Any]:
|
||||
last_at = self.last_sync_at
|
||||
if last_at is None and self._get_db:
|
||||
try:
|
||||
conn = self._get_db()
|
||||
try:
|
||||
ensure_account_ledger_tables(conn)
|
||||
raw = meta_get(conn, "last_sync_at", "")
|
||||
if raw:
|
||||
last_at = float(raw)
|
||||
self.last_error = meta_get(conn, "last_error", self.last_error)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"ledger_version": self.version,
|
||||
"poll_sec": ACCOUNT_LEDGER_POLL_SEC,
|
||||
"lookback_days": ACCOUNT_LEDGER_LOOKBACK_DAYS,
|
||||
"last_sync_at": last_at,
|
||||
"last_error": self.last_error,
|
||||
"last_upserted": self.last_upserted,
|
||||
"exchange": self._exchange_key,
|
||||
}
|
||||
|
||||
def _loop(self) -> None:
|
||||
# 启动后稍等再拉,避免和启动高峰撞车
|
||||
if self._stop.wait(3):
|
||||
return
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
self.sync_once(reason="poll")
|
||||
except Exception:
|
||||
pass
|
||||
if self._stop.wait(ACCOUNT_LEDGER_POLL_SEC):
|
||||
break
|
||||
|
||||
def _broadcast(self, event: str | None = None, *, close: bool = False) -> None:
|
||||
with self._lock:
|
||||
subs = list(self._subscribers)
|
||||
dead: list[queue.Queue[str | None]] = []
|
||||
for q in subs:
|
||||
try:
|
||||
q.put_nowait(None if close else event)
|
||||
except Exception:
|
||||
dead.append(q)
|
||||
if dead:
|
||||
with self._lock:
|
||||
for q in dead:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
def _subscribe(self) -> queue.Queue[str | None]:
|
||||
q: queue.Queue[str | None] = queue.Queue(maxsize=16)
|
||||
with self._lock:
|
||||
self._subscribers.append(q)
|
||||
return q
|
||||
|
||||
def _unsubscribe(self, q: queue.Queue[str | None]) -> None:
|
||||
with self._lock:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
def iter_sse(self) -> Iterator[str]:
|
||||
q = self._subscribe()
|
||||
try:
|
||||
yield f"event: ledger\ndata: {json.dumps({'ledger_version': self.version, 'reason': 'hello'}, ensure_ascii=False)}\n\n"
|
||||
last_hb = time.time()
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
item = q.get(timeout=1.0)
|
||||
except queue.Empty:
|
||||
item = "timeout"
|
||||
if item is None:
|
||||
break
|
||||
if item != "timeout":
|
||||
yield f"event: ledger\ndata: {item}\n\n"
|
||||
last_hb = time.time()
|
||||
elif time.time() - last_hb >= ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC:
|
||||
yield ": heartbeat\n\n"
|
||||
last_hb = time.time()
|
||||
finally:
|
||||
self._unsubscribe(q)
|
||||
|
||||
|
||||
account_ledger_store = AccountLedgerStore()
|
||||
@@ -0,0 +1,72 @@
|
||||
{# 账户流水:资金/交易 Tab · 交易所账单 · SSE #}
|
||||
<div class="card full account-ledger-card" id="account-ledger-root" data-account-ledger="1">
|
||||
<div class="account-ledger-head">
|
||||
<div>
|
||||
<h2 style="margin-bottom:4px">账户流水</h2>
|
||||
<p class="muted account-ledger-desc">拉取交易所资金账户与交易账户账单 · 时间跟随顶栏 UTC 预设 · 约 2 分钟自动同步</p>
|
||||
</div>
|
||||
<div class="account-ledger-head-actions">
|
||||
<span class="muted" id="account-ledger-sync">—</span>
|
||||
<button type="button" class="btn-sm" id="account-ledger-refresh">立即同步</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="account-ledger-tabs" role="tablist">
|
||||
<button type="button" class="account-ledger-tab active" data-ledger-account="funding" role="tab" aria-selected="true">资金账户</button>
|
||||
<button type="button" class="account-ledger-tab" data-ledger-account="trading" role="tab" aria-selected="false">交易账户</button>
|
||||
</div>
|
||||
<p class="muted account-ledger-status" id="account-ledger-status"></p>
|
||||
<div class="account-ledger-table-wrap panel-scroll">
|
||||
<table class="account-ledger-table" id="account-ledger-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间(北京)</th>
|
||||
<th>币种</th>
|
||||
<th>类型</th>
|
||||
<th>变动</th>
|
||||
<th>余额</th>
|
||||
<th>合约/备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="account-ledger-tbody">
|
||||
<tr><td colspan="6" class="muted">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="account-ledger-pager" id="account-ledger-pager">
|
||||
<button type="button" class="btn-sm" id="account-ledger-prev" disabled>上一页</button>
|
||||
<span class="muted" id="account-ledger-page-info">—</span>
|
||||
<button type="button" class="btn-sm" id="account-ledger-next" disabled>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.account-ledger-card { grid-column: 1 / -1; }
|
||||
.account-ledger-head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: 12px; flex-wrap: wrap; margin-bottom: 10px;
|
||||
}
|
||||
.account-ledger-head-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.account-ledger-tabs {
|
||||
display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap;
|
||||
}
|
||||
.account-ledger-tab {
|
||||
border: 1px solid rgba(140,160,200,.35);
|
||||
background: transparent; color: #c5cbe0;
|
||||
border-radius: 6px; padding: 6px 14px; cursor: pointer; font-size: .9rem;
|
||||
}
|
||||
.account-ledger-tab.active {
|
||||
background: #1f3a5a; border-color: #3d6f9c; color: #e8f1ff;
|
||||
}
|
||||
.account-ledger-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||
.account-ledger-table th, .account-ledger-table td {
|
||||
padding: 8px 10px; border-bottom: 1px solid rgba(120,130,160,.2); text-align: left;
|
||||
}
|
||||
.account-ledger-table th { color: #9aa3bd; font-weight: 600; }
|
||||
.account-ledger-amt-pos { color: #3ecf8e; }
|
||||
.account-ledger-amt-neg { color: #f07178; }
|
||||
.account-ledger-pager {
|
||||
display: flex; align-items: center; justify-content: flex-end; gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.account-ledger-status { min-height: 1.2em; margin: 0 0 8px; }
|
||||
.account-ledger-table-wrap { max-height: min(60vh, 560px); overflow: auto; }
|
||||
</style>
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* 账户流水:资金/交易 Tab · 分页 10 · SSE 自动刷新 · 时间窗跟随顶栏预设.
|
||||
*/
|
||||
(function (global) {
|
||||
const PAGE_SIZE = 10;
|
||||
let account = "funding";
|
||||
let page = 1;
|
||||
let pages = 0;
|
||||
let localVersion = 0;
|
||||
let es = null;
|
||||
let reconnectTimer = null;
|
||||
let loading = false;
|
||||
let booted = false;
|
||||
|
||||
function root() {
|
||||
const active = document.querySelector('.embed-tab-pane.is-active-pane [data-account-ledger="1"]');
|
||||
if (active) return active;
|
||||
return document.getElementById("account-ledger-root");
|
||||
}
|
||||
|
||||
function $(id) {
|
||||
const r = root();
|
||||
return (r && r.querySelector("#" + id)) || document.getElementById(id);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function listWindowQs() {
|
||||
if (typeof global.listWindowQueryString === "function") {
|
||||
const q = global.listWindowQueryString();
|
||||
return q ? (q.charAt(0) === "?" ? q.slice(1) : q) : "";
|
||||
}
|
||||
try {
|
||||
return new URLSearchParams(location.search).toString();
|
||||
} catch (_) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function fmtBj(ms) {
|
||||
const n = Number(ms);
|
||||
if (!Number.isFinite(n) || n <= 0) return "—";
|
||||
try {
|
||||
const d = new Date(n);
|
||||
const parts = new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(d);
|
||||
const get = (t) => (parts.find((p) => p.type === t) || {}).value || "";
|
||||
return (
|
||||
get("year") +
|
||||
"-" +
|
||||
get("month") +
|
||||
"-" +
|
||||
get("day") +
|
||||
" " +
|
||||
get("hour") +
|
||||
":" +
|
||||
get("minute") +
|
||||
":" +
|
||||
get("second")
|
||||
);
|
||||
} catch (_) {
|
||||
return "—";
|
||||
}
|
||||
}
|
||||
|
||||
function fmtAmt(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
const cls = n > 0 ? "account-ledger-amt-pos" : n < 0 ? "account-ledger-amt-neg" : "";
|
||||
const sign = n > 0 ? "+" : "";
|
||||
return '<span class="' + cls + '">' + sign + n.toFixed(6).replace(/\.?0+$/, "") + "</span>";
|
||||
}
|
||||
|
||||
function fmtBal(v) {
|
||||
if (v == null || v === "") return "—";
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
return n.toFixed(6).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function setStatus(msg, isErr) {
|
||||
const el = $("account-ledger-status");
|
||||
if (!el) return;
|
||||
el.textContent = msg || "";
|
||||
el.style.color = isErr ? "#f07178" : "";
|
||||
}
|
||||
|
||||
function setSyncLabel(data) {
|
||||
const el = $("account-ledger-sync");
|
||||
if (!el) return;
|
||||
const ts = data && data.last_sync_at;
|
||||
if (!ts) {
|
||||
el.textContent = "尚未同步";
|
||||
return;
|
||||
}
|
||||
el.textContent = "同步 " + fmtBj(Number(ts) * 1000);
|
||||
}
|
||||
|
||||
function renderRows(items) {
|
||||
const tbody = $("account-ledger-tbody");
|
||||
if (!tbody) return;
|
||||
if (!items || !items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="muted">当前时间窗暂无流水</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = items
|
||||
.map(function (it) {
|
||||
const note = [it.symbol, it.note, it.raw_type].filter(Boolean).join(" · ");
|
||||
return (
|
||||
"<tr>" +
|
||||
"<td>" +
|
||||
escapeHtml(fmtBj(it.ts_ms)) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(it.ccy || "") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(it.kind_label || it.kind || "") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmtAmt(it.amount) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(fmtBal(it.balance_after)) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(note || "—") +
|
||||
"</td>" +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderPager(data) {
|
||||
pages = Number(data.pages || 0);
|
||||
page = Number(data.page || 1);
|
||||
const info = $("account-ledger-page-info");
|
||||
const prev = $("account-ledger-prev");
|
||||
const next = $("account-ledger-next");
|
||||
if (info) {
|
||||
info.textContent =
|
||||
"第 " + page + " / " + (pages || 1) + " 页 · 共 " + (data.total || 0) + " 条 · 每页 " + PAGE_SIZE;
|
||||
}
|
||||
if (prev) prev.disabled = page <= 1;
|
||||
if (next) next.disabled = !pages || page >= pages;
|
||||
}
|
||||
|
||||
async function loadList(opts) {
|
||||
const r = root();
|
||||
if (!r) return;
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
const force = opts && opts.force;
|
||||
try {
|
||||
if (!force) setStatus("加载中…");
|
||||
const qs = new URLSearchParams(listWindowQs());
|
||||
qs.set("account", account);
|
||||
qs.set("page", String(page));
|
||||
const res = await fetch("/api/account_ledger?" + qs.toString(), {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const data = await res.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(data.msg || res.statusText || "加载失败");
|
||||
}
|
||||
if (data.ledger_version != null) localVersion = Number(data.ledger_version) || localVersion;
|
||||
renderRows(data.items || []);
|
||||
renderPager(data);
|
||||
setSyncLabel(data);
|
||||
const winLabel = (data.window && data.window.label) || "";
|
||||
const err = data.last_error ? " · 同步提示: " + data.last_error : "";
|
||||
setStatus(
|
||||
(winLabel ? "时间窗 " + winLabel + " · " : "") +
|
||||
(account === "trading" ? "交易账户" : "资金账户") +
|
||||
err,
|
||||
!!data.last_error
|
||||
);
|
||||
} catch (e) {
|
||||
setStatus(e.message || String(e), true);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshNow() {
|
||||
setStatus("正在从交易所同步…");
|
||||
try {
|
||||
const res = await fetch("/api/account_ledger/refresh", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
const data = await res.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(data.msg || "同步失败");
|
||||
}
|
||||
await loadList({ force: true });
|
||||
} catch (e) {
|
||||
setStatus(e.message || String(e), true);
|
||||
}
|
||||
}
|
||||
|
||||
function bindUi() {
|
||||
const r = root();
|
||||
if (!r || r.getAttribute("data-ledger-bound") === "1") return;
|
||||
r.setAttribute("data-ledger-bound", "1");
|
||||
r.querySelectorAll(".account-ledger-tab").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
const acc = btn.getAttribute("data-ledger-account") || "funding";
|
||||
if (acc === account) return;
|
||||
account = acc;
|
||||
page = 1;
|
||||
r.querySelectorAll(".account-ledger-tab").forEach(function (b) {
|
||||
const on = b.getAttribute("data-ledger-account") === account;
|
||||
b.classList.toggle("active", on);
|
||||
b.setAttribute("aria-selected", on ? "true" : "false");
|
||||
});
|
||||
loadList();
|
||||
});
|
||||
});
|
||||
const prev = $("account-ledger-prev");
|
||||
const next = $("account-ledger-next");
|
||||
const ref = $("account-ledger-refresh");
|
||||
if (prev)
|
||||
prev.addEventListener("click", function () {
|
||||
if (page > 1) {
|
||||
page -= 1;
|
||||
loadList();
|
||||
}
|
||||
});
|
||||
if (next)
|
||||
next.addEventListener("click", function () {
|
||||
if (!pages || page < pages) {
|
||||
page += 1;
|
||||
loadList();
|
||||
}
|
||||
});
|
||||
if (ref) ref.addEventListener("click", refreshNow);
|
||||
}
|
||||
|
||||
function connectSse() {
|
||||
if (es) {
|
||||
try {
|
||||
es.close();
|
||||
} catch (_) {}
|
||||
es = null;
|
||||
}
|
||||
if (typeof EventSource === "undefined") return;
|
||||
try {
|
||||
es = new EventSource("/api/account_ledger/stream");
|
||||
es.addEventListener("ledger", function (ev) {
|
||||
let data = {};
|
||||
try {
|
||||
data = JSON.parse(ev.data || "{}");
|
||||
} catch (_) {}
|
||||
const ver = Number(data.ledger_version || 0);
|
||||
if (ver && ver !== localVersion) {
|
||||
localVersion = ver;
|
||||
loadList({ force: true });
|
||||
}
|
||||
});
|
||||
es.onerror = function () {
|
||||
try {
|
||||
es.close();
|
||||
} catch (_) {}
|
||||
es = null;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(connectSse, 5000);
|
||||
};
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function boot() {
|
||||
const r = root();
|
||||
if (!r) return;
|
||||
bindUi();
|
||||
if (!booted) {
|
||||
booted = true;
|
||||
connectSse();
|
||||
}
|
||||
loadList();
|
||||
}
|
||||
|
||||
function onTabActivated(tab) {
|
||||
if (tab !== "account_ledger") return;
|
||||
boot();
|
||||
}
|
||||
|
||||
global.AccountLedgerPage = {
|
||||
boot: boot,
|
||||
onTabActivated: onTabActivated,
|
||||
reload: function () {
|
||||
page = 1;
|
||||
loadList();
|
||||
},
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const page =
|
||||
(document.body && document.body.getAttribute("data-page")) ||
|
||||
(document.body && document.body.getAttribute("data-initial-tab")) ||
|
||||
"";
|
||||
if (page === "account_ledger" || root()) {
|
||||
// embed 延后到 tab 激活;独立页直接 boot
|
||||
if (!document.body || document.body.getAttribute("data-embed-shell") !== "1") {
|
||||
boot();
|
||||
} else if (page === "account_ledger") {
|
||||
boot();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("instance-embed-tab-activated", function (ev) {
|
||||
const tab = ev && ev.detail && ev.detail.tab;
|
||||
onTabActivated(tab);
|
||||
});
|
||||
})(window);
|
||||
@@ -35,7 +35,8 @@
|
||||
delete form.dataset.submitGuard;
|
||||
form.classList.remove("is-form-submitting");
|
||||
submitButtons(form).forEach(function (btn) {
|
||||
btn.disabled = false;
|
||||
// 风控灰显(开仓门禁)保持禁用
|
||||
btn.disabled = btn.classList.contains("is-blocked");
|
||||
var orig = btn.dataset.submitGuardOrig;
|
||||
if (orig !== undefined) {
|
||||
if (btn.tagName === "BUTTON") btn.textContent = orig;
|
||||
|
||||
+1081
-134
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
||||
(function (global) {
|
||||
const TAB_PATH = {
|
||||
dashboard: "/dashboard",
|
||||
account_ledger: "/account_ledger",
|
||||
key_monitor: "/key_monitor",
|
||||
trade: "/trade",
|
||||
strategy: "/strategy",
|
||||
@@ -61,6 +62,11 @@
|
||||
document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => {
|
||||
a.classList.toggle("active", a.getAttribute("data-embed-tab") === tab);
|
||||
});
|
||||
if (global.InstanceMobileNav && typeof global.InstanceMobileNav.onTabChange === "function") {
|
||||
global.InstanceMobileNav.onTabChange(tab);
|
||||
} else if (global.InstanceMobileNav && typeof global.InstanceMobileNav.syncTabActive === "function") {
|
||||
global.InstanceMobileNav.syncTabActive(tab);
|
||||
}
|
||||
}
|
||||
|
||||
function pageNavAllowed(tab) {
|
||||
@@ -109,6 +115,9 @@
|
||||
if (tab === "dashboard" && global.InstanceDashboard && typeof global.InstanceDashboard.init === "function") {
|
||||
global.InstanceDashboard.init(!!revisit);
|
||||
}
|
||||
if (tab === "account_ledger" && global.AccountLedgerPage && typeof global.AccountLedgerPage.boot === "function") {
|
||||
global.AccountLedgerPage.boot();
|
||||
}
|
||||
if (!revisit && tab === "strategy" && typeof global.initStrategyRollForm === "function") {
|
||||
global.initStrategyRollForm();
|
||||
}
|
||||
@@ -235,12 +244,61 @@
|
||||
const parts = [];
|
||||
if (qs) parts.push(qs);
|
||||
parts.push("embed=1");
|
||||
if (tab === "settings") {
|
||||
try {
|
||||
const st = new URLSearchParams(location.search).get("settings_tab");
|
||||
if (st) parts.push("settings_tab=" + encodeURIComponent(st));
|
||||
} catch (_) {}
|
||||
}
|
||||
return url + "?" + parts.join("&");
|
||||
}
|
||||
|
||||
function setSettingsSubTabInUrl(key) {
|
||||
if (!key) return;
|
||||
try {
|
||||
const q = new URLSearchParams(location.search);
|
||||
q.set("tab", "settings");
|
||||
q.set("settings_tab", key);
|
||||
q.set("embed", "1");
|
||||
history.replaceState(null, "", "/embed?" + q.toString());
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function activateSettingsSubTab(key) {
|
||||
if (!key) return;
|
||||
setSettingsSubTabInUrl(key);
|
||||
const pane = tabPanes.get("settings") || document;
|
||||
const radio = pane.querySelector(
|
||||
'input.env-tab-radio[data-settings-tab="' + key + '"]'
|
||||
);
|
||||
if (radio) radio.checked = true;
|
||||
}
|
||||
|
||||
function formActionPath(form) {
|
||||
try {
|
||||
return new URL(form.action || "", location.href).pathname.replace(/\/$/, "") || "/";
|
||||
} catch (_) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function maybeKeepSettingsSubTabAfterForm(form) {
|
||||
const path = formActionPath(form);
|
||||
if (path === "/manual_transfer") {
|
||||
setSettingsSubTabInUrl("transfer");
|
||||
return "transfer";
|
||||
}
|
||||
if (path.indexOf("/api/options/transfer") >= 0) {
|
||||
setSettingsSubTabInUrl("options_transfer");
|
||||
return "options_transfer";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function fetchTabHtml(tab) {
|
||||
const r = await fetch(embedPageUrl(tab), {
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
headers: { "X-Instance-Soft-Nav": "1" },
|
||||
});
|
||||
const ct = (r.headers.get("content-type") || "").toLowerCase();
|
||||
@@ -400,14 +458,15 @@
|
||||
}
|
||||
}
|
||||
const fd = new FormData(form);
|
||||
const keepSub = maybeKeepSettingsSubTabAfterForm(form);
|
||||
return fetch(form.action, {
|
||||
method: form.method || "POST",
|
||||
body: fd,
|
||||
credentials: "same-origin",
|
||||
redirect: "manual",
|
||||
})
|
||||
.then(() => reloadCurrentTab())
|
||||
.catch(() => reloadCurrentTab());
|
||||
.then(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)))
|
||||
.catch(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)));
|
||||
}
|
||||
|
||||
function patchApplyListWindow() {
|
||||
@@ -466,14 +525,15 @@
|
||||
if (CUSTOM_SUBMIT_FORM_IDS.has(form.id)) return;
|
||||
ev.preventDefault();
|
||||
const fd = new FormData(form);
|
||||
const keepSub = maybeKeepSettingsSubTabAfterForm(form);
|
||||
fetch(form.action, {
|
||||
method: form.method || "POST",
|
||||
body: fd,
|
||||
credentials: "same-origin",
|
||||
redirect: "manual",
|
||||
})
|
||||
.then(() => reloadCurrentTab())
|
||||
.catch(() => reloadCurrentTab());
|
||||
.then(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)))
|
||||
.catch(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)));
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 实例手机壳: ≤720px 底栏 +「更多」,与 embed soft-nav 同步.
|
||||
*/
|
||||
(function (global) {
|
||||
const PRIMARY = { trade: 1, key_monitor: 1, options: 1 };
|
||||
const MQ = "(max-width: 720px)";
|
||||
|
||||
function isEmbedShell() {
|
||||
return document.body && document.body.getAttribute("data-embed-shell") === "1";
|
||||
}
|
||||
|
||||
function isMobileLayout() {
|
||||
return window.matchMedia(MQ).matches;
|
||||
}
|
||||
|
||||
function syncPhoneClass() {
|
||||
if (!document.body) return;
|
||||
document.body.classList.toggle("inst-phone", isMobileLayout());
|
||||
}
|
||||
|
||||
function currentTab() {
|
||||
if (global.InstanceEmbed && typeof global.InstanceEmbed.getTab === "function") {
|
||||
return global.InstanceEmbed.getTab();
|
||||
}
|
||||
try {
|
||||
const t = new URLSearchParams(location.search).get("tab");
|
||||
if (t) return t;
|
||||
} catch (_) {}
|
||||
return (document.body && document.body.getAttribute("data-page")) || "trade";
|
||||
}
|
||||
|
||||
function closeMore() {
|
||||
document.body.classList.remove("inst-mobile-more-open");
|
||||
const more = document.getElementById("inst-mobile-more");
|
||||
const btn = document.getElementById("inst-m-tab-more");
|
||||
if (more) more.setAttribute("aria-hidden", "true");
|
||||
if (btn) btn.setAttribute("aria-expanded", "false");
|
||||
syncTabActive(currentTab());
|
||||
}
|
||||
|
||||
function openMore() {
|
||||
if (!isMobileLayout()) return;
|
||||
document.body.classList.add("inst-mobile-more-open");
|
||||
const more = document.getElementById("inst-mobile-more");
|
||||
const btn = document.getElementById("inst-m-tab-more");
|
||||
if (more) more.setAttribute("aria-hidden", "false");
|
||||
if (btn) btn.setAttribute("aria-expanded", "true");
|
||||
syncTabActive(currentTab());
|
||||
}
|
||||
|
||||
function toggleMore() {
|
||||
if (document.body.classList.contains("inst-mobile-more-open")) closeMore();
|
||||
else openMore();
|
||||
}
|
||||
|
||||
function syncTabActive(tab) {
|
||||
const page = tab || currentTab();
|
||||
const primary = !!PRIMARY[page];
|
||||
const moreOpen = document.body.classList.contains("inst-mobile-more-open");
|
||||
document.querySelectorAll("#inst-mobile-tabbar .inst-m-tab").forEach((el) => {
|
||||
const t = el.getAttribute("data-embed-tab") || "";
|
||||
let on = false;
|
||||
if (t === "more") on = moreOpen || !primary;
|
||||
else on = !moreOpen && t === page;
|
||||
el.classList.toggle("active", on);
|
||||
});
|
||||
document.querySelectorAll("#inst-mobile-more .inst-mobile-more-nav [data-embed-tab]").forEach((a) => {
|
||||
a.classList.toggle("active", a.getAttribute("data-embed-tab") === page);
|
||||
});
|
||||
}
|
||||
|
||||
/** embed 切页时关闭「更多」并同步高亮 */
|
||||
function onTabChange(tab) {
|
||||
document.body.classList.remove("inst-mobile-more-open");
|
||||
const more = document.getElementById("inst-mobile-more");
|
||||
const btn = document.getElementById("inst-m-tab-more");
|
||||
if (more) more.setAttribute("aria-hidden", "true");
|
||||
if (btn) btn.setAttribute("aria-expanded", "false");
|
||||
syncTabActive(tab);
|
||||
}
|
||||
|
||||
function goTab(tab) {
|
||||
if (!tab || tab === "more") return;
|
||||
closeMore();
|
||||
if (global.InstanceEmbed && typeof global.InstanceEmbed.loadTab === "function") {
|
||||
if (tab === currentTab()) {
|
||||
syncTabActive(tab);
|
||||
return;
|
||||
}
|
||||
void global.InstanceEmbed.loadTab(tab);
|
||||
return;
|
||||
}
|
||||
const pathMap = {
|
||||
dashboard: "/dashboard",
|
||||
key_monitor: "/key_monitor",
|
||||
trade: "/trade",
|
||||
strategy: "/strategy",
|
||||
strategy_records: "/strategy/records",
|
||||
options: "/options",
|
||||
options_review: "/options/review",
|
||||
hedge_plan: "/hedge-plan",
|
||||
records: "/records",
|
||||
stats: "/stats",
|
||||
risk_policy: "/risk_policy",
|
||||
system_guide: "/system_guide",
|
||||
env_config: "/env_config",
|
||||
settings: "/settings",
|
||||
};
|
||||
location.href = pathMap[tab] || "/trade";
|
||||
}
|
||||
|
||||
function bindChrome() {
|
||||
const moreBtn = document.getElementById("inst-m-tab-more");
|
||||
const backdrop = document.getElementById("inst-mobile-more-backdrop");
|
||||
const closeBtn = document.getElementById("inst-mobile-more-close");
|
||||
if (moreBtn) {
|
||||
moreBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
toggleMore();
|
||||
});
|
||||
}
|
||||
if (backdrop) backdrop.addEventListener("click", closeMore);
|
||||
if (closeBtn) closeBtn.addEventListener("click", closeMore);
|
||||
document.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Escape" && document.body.classList.contains("inst-mobile-more-open")) {
|
||||
closeMore();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll("#inst-mobile-tabbar .inst-m-tab[data-embed-tab]").forEach((el) => {
|
||||
if (el.getAttribute("data-embed-tab") === "more") return;
|
||||
el.addEventListener("click", (ev) => {
|
||||
if (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
|
||||
ev.preventDefault();
|
||||
goTab(el.getAttribute("data-embed-tab"));
|
||||
});
|
||||
});
|
||||
document.querySelectorAll("#inst-mobile-more .inst-mobile-more-nav [data-embed-tab]").forEach((a) => {
|
||||
a.addEventListener("click", (ev) => {
|
||||
if (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
|
||||
ev.preventDefault();
|
||||
goTab(a.getAttribute("data-embed-tab"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
if (!isEmbedShell()) return;
|
||||
if (!document.getElementById("inst-mobile-tabbar")) return;
|
||||
syncPhoneClass();
|
||||
bindChrome();
|
||||
syncTabActive(currentTab());
|
||||
let resizeTimer = null;
|
||||
window.addEventListener("resize", () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
const was = document.body.classList.contains("inst-phone");
|
||||
syncPhoneClass();
|
||||
if (!isMobileLayout()) closeMore();
|
||||
else if (!was) syncTabActive(currentTab());
|
||||
}, 120);
|
||||
});
|
||||
}
|
||||
|
||||
global.InstanceMobileNav = {
|
||||
syncTabActive,
|
||||
onTabChange,
|
||||
closeMore,
|
||||
isMobileLayout,
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -22,8 +22,37 @@
|
||||
.card h2{font-size:1rem;margin-bottom:10px;color:#d4d9ff}
|
||||
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
|
||||
.form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
|
||||
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
|
||||
/* 实盘下单监控:分层布局 */
|
||||
.order-monitor-form{display:flex;flex-direction:column;gap:10px;margin-bottom:4px}
|
||||
.order-monitor-form .om-row{display:flex;flex-wrap:wrap;align-items:flex-end;gap:8px}
|
||||
.order-monitor-form .om-row-policy > input:not([type=checkbox]):not([type=radio]),
|
||||
.order-monitor-form .om-row-policy > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
|
||||
.order-monitor-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
|
||||
.order-monitor-form .om-field{display:flex;flex-direction:column;gap:4px;min-width:7.5rem}
|
||||
.order-monitor-form .om-field-lab{font-size:.72rem;color:#9aa3c7;line-height:1;letter-spacing:.02em}
|
||||
.order-monitor-form .om-field input{width:9.5rem;max-width:160px;box-sizing:border-box}
|
||||
.order-monitor-form .om-live-meta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding-bottom:2px;margin-left:auto}
|
||||
.order-monitor-form .om-row-opts{align-items:center;gap:12px;padding-top:2px}
|
||||
.order-monitor-form .om-check{display:inline-flex;align-items:center;gap:5px;font-size:.82rem;color:#cfd3ef;cursor:pointer;user-select:none}
|
||||
.order-monitor-form .om-time-close{display:inline-flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef}
|
||||
.order-monitor-form .om-time-close select{width:auto;min-width:4.2rem;max-width:5.5rem;padding:6px 8px}
|
||||
.order-monitor-form .om-row-action{padding-top:2px;display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px}
|
||||
.order-monitor-form .om-submit{min-width:11rem;padding:10px 18px;font-weight:600}
|
||||
.order-monitor-form .om-submit.is-blocked,
|
||||
.order-monitor-form .om-submit:disabled{
|
||||
opacity:.45;
|
||||
cursor:not-allowed;
|
||||
filter:grayscale(.35);
|
||||
pointer-events:none;
|
||||
}
|
||||
.order-monitor-form .om-open-block-note{
|
||||
color:var(--danger,#ff7b7b);
|
||||
font-size:13px;
|
||||
line-height:1.4;
|
||||
max-width:min(28rem,100%);
|
||||
}
|
||||
.order-plan-preview{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin:4px 0 10px;padding:10px 12px;background:#151a28;border:1px solid #2a3150;border-radius:8px;font-size:.85rem}
|
||||
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
|
||||
.order-preview-risk{color:#ff6b6b}
|
||||
.order-preview-risk strong{color:#ff8f8f;font-weight:600}
|
||||
.order-preview-profit{color:#4cd97f}
|
||||
@@ -196,6 +225,13 @@
|
||||
.inst-stats-details>summary{cursor:pointer;font-size:.84rem;color:#9aa3bf;padding:8px 0;user-select:none;list-style-position:inside}
|
||||
.inst-stats-details>summary::-webkit-details-marker{color:#6d7689}
|
||||
.inst-stats-details[open]>summary{margin-bottom:6px;color:#cfd3ef}
|
||||
.inst-stats-month-table-wrap{overflow:auto;-webkit-overflow-scrolling:touch}
|
||||
.inst-stats-month-table{width:100%;border-collapse:collapse;font-size:.8rem;font-variant-numeric:tabular-nums}
|
||||
.inst-stats-month-table th,.inst-stats-month-table td{padding:8px 10px;text-align:right;border-bottom:1px solid #2a3348;white-space:nowrap}
|
||||
.inst-stats-month-table th:first-child,.inst-stats-month-table td:first-child{text-align:left}
|
||||
.inst-stats-month-table th{color:#8892b0;font-weight:600;font-size:.72rem}
|
||||
.inst-stats-month-table td{color:#e8ecf4}
|
||||
.inst-stats-month-table tbody tr:last-child td{border-bottom:none}
|
||||
@media (max-width:640px){.inst-stats-kpis{grid-template-columns:1fr}.inst-stats-risk-grid{grid-template-columns:1fr}}
|
||||
.key-history{margin-top:12px;padding-top:10px;border-top:1px solid #2a3150}
|
||||
.key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px}
|
||||
|
||||
@@ -20,7 +20,11 @@
|
||||
}
|
||||
|
||||
/** 默认关闭的导航开关:缺失时按 false,不能用 !== false */
|
||||
const NAV_DEFAULT_OFF = { show_nav_dashboard: true, show_nav_system_guide: true };
|
||||
const NAV_DEFAULT_OFF = {
|
||||
show_nav_dashboard: true,
|
||||
show_nav_account_ledger: true,
|
||||
show_nav_system_guide: true,
|
||||
};
|
||||
|
||||
function navPrefShow(display, key) {
|
||||
if (!key) return true;
|
||||
@@ -31,6 +35,9 @@
|
||||
function applyDisplayToNav(display) {
|
||||
const map = {
|
||||
dashboard: "show_nav_dashboard",
|
||||
account_ledger: "show_nav_account_ledger",
|
||||
key_monitor: "show_nav_key_monitor",
|
||||
trade: "show_nav_trade",
|
||||
strategy: "show_nav_strategy",
|
||||
strategy_records: "show_nav_strategy_records",
|
||||
records: "show_nav_records",
|
||||
@@ -44,14 +51,19 @@
|
||||
system_guide: "show_nav_system_guide",
|
||||
env_config: "show_nav_env_config",
|
||||
};
|
||||
document.querySelectorAll(".embed-top-nav [data-embed-tab], .top-nav a[href^='/']").forEach((a) => {
|
||||
const tab = a.getAttribute("data-embed-tab") || (a.getAttribute("href") || "").replace(/^\//, "").split("?")[0];
|
||||
const key = map[tab];
|
||||
if (!key) return;
|
||||
const show = navPrefShow(display, key);
|
||||
a.classList.toggle("nav-hidden", !show);
|
||||
a.style.display = show ? "" : "none";
|
||||
});
|
||||
document
|
||||
.querySelectorAll(
|
||||
".embed-top-nav [data-embed-tab], .top-nav a[href^='/'], #inst-mobile-tabbar [data-embed-tab], #inst-mobile-more [data-embed-tab]"
|
||||
)
|
||||
.forEach((a) => {
|
||||
const tab = a.getAttribute("data-embed-tab") || (a.getAttribute("href") || "").replace(/^\//, "").split("?")[0];
|
||||
if (tab === "more") return;
|
||||
const key = map[tab];
|
||||
if (!key) return;
|
||||
const show = navPrefShow(display, key);
|
||||
a.classList.toggle("nav-hidden", !show);
|
||||
a.style.display = show ? "" : "none";
|
||||
});
|
||||
global.__INSTANCE_DISPLAY__ = display;
|
||||
}
|
||||
|
||||
@@ -59,6 +71,9 @@
|
||||
const d = DISPLAY();
|
||||
const map = {
|
||||
dashboard: "show_nav_dashboard",
|
||||
account_ledger: "show_nav_account_ledger",
|
||||
key_monitor: "show_nav_key_monitor",
|
||||
trade: "show_nav_trade",
|
||||
strategy: "show_nav_strategy",
|
||||
strategy_records: "show_nav_strategy_records",
|
||||
records: "show_nav_records",
|
||||
@@ -187,6 +202,7 @@
|
||||
function renderEnvFieldRow(field) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "env-field-row" + (field.restart_required ? " env-field-row--restart" : "");
|
||||
row.dataset.envKey = field.key;
|
||||
const label = document.createElement("label");
|
||||
label.className = "env-field-label";
|
||||
label.htmlFor = "env-f-" + field.key;
|
||||
@@ -279,6 +295,10 @@
|
||||
input.dataset.envKey = field.key;
|
||||
input.className = "env-field-input";
|
||||
row.appendChild(input);
|
||||
if (field.hidden) {
|
||||
row.hidden = true;
|
||||
row.style.display = "none";
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -302,7 +322,9 @@
|
||||
const panelsWrap = document.createElement("div");
|
||||
panelsWrap.className = "env-config-panels";
|
||||
panelsWrap.id = "env-config-grid";
|
||||
let modeSectionIdx = 0;
|
||||
groups.forEach((group, idx) => {
|
||||
if ((group.title || "").indexOf("期权/对冲模式") >= 0) modeSectionIdx = idx;
|
||||
const label = document.createElement("label");
|
||||
label.className = "env-tab-btn";
|
||||
label.htmlFor = "env-sec-" + idx;
|
||||
@@ -326,9 +348,100 @@
|
||||
});
|
||||
body.appendChild(tabBar);
|
||||
body.appendChild(panelsWrap);
|
||||
body.dataset.envModeSectionIdx = String(modeSectionIdx);
|
||||
bindTradeModeAutoRefresh(body);
|
||||
bindCompoundBudgetVisibility(body);
|
||||
bindMarginModeGateVisibility(body);
|
||||
return body;
|
||||
}
|
||||
|
||||
function envFieldRowByKey(body, key) {
|
||||
if (!body || !key) return null;
|
||||
const byRow = body.querySelector('.env-field-row[data-env-key="' + key + '"]');
|
||||
if (byRow) return byRow;
|
||||
const input = body.querySelector('.env-field-input[data-env-key="' + key + '"]');
|
||||
return input ? input.closest(".env-field-row") : null;
|
||||
}
|
||||
|
||||
function setEnvRowHidden(row, hidden) {
|
||||
if (!row) return;
|
||||
row.hidden = !!hidden;
|
||||
row.style.display = hidden ? "none" : "";
|
||||
}
|
||||
|
||||
function syncCompoundBudgetVisibility(body) {
|
||||
if (!body) return;
|
||||
const compoundSel = body.querySelector(
|
||||
'.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]'
|
||||
);
|
||||
const budgetRow = envFieldRowByKey(body, "OKX_OPTIONS_TRADE_BUDGET_USDC");
|
||||
if (!budgetRow) return;
|
||||
const compoundOn = !compoundSel || String(compoundSel.value || "").toLowerCase() === "true";
|
||||
setEnvRowHidden(budgetRow, compoundOn);
|
||||
}
|
||||
|
||||
function syncMarginModeGateVisibility(body) {
|
||||
if (!body) return;
|
||||
const modeSel = body.querySelector(
|
||||
'.env-field-input[data-env-key="OKX_OPTIONS_MARGIN_MODE"]'
|
||||
);
|
||||
const mode = String((modeSel && modeSel.value) || "coin").toLowerCase();
|
||||
const coinMode = mode !== "usdc";
|
||||
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC"), coinMode);
|
||||
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN"), !coinMode);
|
||||
}
|
||||
|
||||
function bindCompoundBudgetVisibility(body) {
|
||||
if (!body) return;
|
||||
syncCompoundBudgetVisibility(body);
|
||||
const compoundSel = body.querySelector(
|
||||
'.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]'
|
||||
);
|
||||
if (!compoundSel || compoundSel.dataset.compoundBudgetBound === "1") return;
|
||||
compoundSel.dataset.compoundBudgetBound = "1";
|
||||
compoundSel.addEventListener("change", () => syncCompoundBudgetVisibility(body));
|
||||
}
|
||||
|
||||
function bindMarginModeGateVisibility(body) {
|
||||
if (!body) return;
|
||||
syncMarginModeGateVisibility(body);
|
||||
const modeSel = body.querySelector(
|
||||
'.env-field-input[data-env-key="OKX_OPTIONS_MARGIN_MODE"]'
|
||||
);
|
||||
if (!modeSel || modeSel.dataset.marginGateBound === "1") return;
|
||||
modeSel.dataset.marginGateBound = "1";
|
||||
modeSel.addEventListener("change", () => syncMarginModeGateVisibility(body));
|
||||
}
|
||||
|
||||
function bindTradeModeAutoRefresh(body) {
|
||||
const modeSel = body.querySelector('.env-field-input[data-env-key="OKX_TRADE_MODE"]');
|
||||
if (!modeSel || modeSel.dataset.modeRefreshBound === "1") return;
|
||||
modeSel.dataset.modeRefreshBound = "1";
|
||||
modeSel.addEventListener("change", async () => {
|
||||
const status = document.getElementById("env-config-status");
|
||||
const nextMode = modeSel.value;
|
||||
setStatus(status, "切换交易模式并刷新配置…");
|
||||
try {
|
||||
await fetchJson("/api/settings/env", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ values: { OKX_TRADE_MODE: nextMode } }),
|
||||
});
|
||||
await loadEnvConfig(true);
|
||||
const page = envConfigRoot() || document.querySelector(".env-config-page");
|
||||
const newBody = page && page.querySelector("#env-config-body");
|
||||
const idx = newBody && newBody.dataset.envModeSectionIdx;
|
||||
if (idx != null) {
|
||||
const radio = document.getElementById("env-sec-" + idx);
|
||||
if (radio) radio.checked = true;
|
||||
}
|
||||
setStatus(status, "交易模式已切换为当前选项,配置区已刷新");
|
||||
} catch (e) {
|
||||
setStatus(status, e.message || "切换失败", true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadEnvConfig(force) {
|
||||
const root = envConfigRoot();
|
||||
const body = root && root.querySelector("#env-config-body");
|
||||
@@ -386,10 +499,10 @@
|
||||
setStatus(status, "已保存,正在重启实例…");
|
||||
await restartInstance();
|
||||
setStatus(status, "保存并重启完成");
|
||||
await loadEnvConfig();
|
||||
await loadEnvConfig(true);
|
||||
} else {
|
||||
setStatus(status, "已保存(即时生效项已应用)");
|
||||
await loadEnvConfig();
|
||||
await loadEnvConfig(true);
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(status, e.message || "保存失败", true);
|
||||
@@ -492,6 +605,12 @@
|
||||
bindEvents();
|
||||
loadDisplayPrefsForm(false);
|
||||
loadEnvConfig(false);
|
||||
const root = envConfigRoot();
|
||||
const body = root && root.querySelector("#env-config-body");
|
||||
if (body) {
|
||||
bindTradeModeAutoRefresh(body);
|
||||
bindCompoundBudgetVisibility(body);
|
||||
}
|
||||
if (global.__INSTANCE_DISPLAY__) applyDisplayToNav(global.__INSTANCE_DISPLAY__);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var PERIODS = ["day", "week", "month"];
|
||||
var PERIODS = ["day", "week", "month", "all"];
|
||||
|
||||
function statsSegmentSelect() {
|
||||
return document.getElementById("stats-segment-select");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 实盘下单监控:开仓按钮灰显 + 旁注(强制清仓/冷静期/日冻结等).
|
||||
*/
|
||||
(function (global) {
|
||||
function apply(data) {
|
||||
const d = data || {};
|
||||
const btn =
|
||||
document.getElementById("om-submit-btn") ||
|
||||
document.querySelector("#add-order-form button.om-submit");
|
||||
const noteEl = document.getElementById("om-open-block-note");
|
||||
if (!btn && !noteEl) return;
|
||||
|
||||
const canTrade = d.can_trade !== false;
|
||||
let note = (d.open_block_note || "").trim();
|
||||
const fc = d.force_close || {};
|
||||
const rs = d.risk_status || {};
|
||||
if (!note && fc.enabled && fc.executing) {
|
||||
const grace = fc.grace_minutes != null ? fc.grace_minutes : 5;
|
||||
note =
|
||||
"强制清仓窗口内(北京时间 " +
|
||||
(fc.hour_label || "--:--") +
|
||||
" 起 " +
|
||||
grace +
|
||||
" 分钟),暂不可开仓";
|
||||
}
|
||||
if (!note && rs.can_trade === false && rs.reason) {
|
||||
note = String(rs.reason);
|
||||
}
|
||||
if (!note && !canTrade) {
|
||||
note = "当前不可开仓";
|
||||
}
|
||||
|
||||
if (btn) {
|
||||
btn.disabled = !canTrade;
|
||||
btn.classList.toggle("is-blocked", !canTrade);
|
||||
btn.setAttribute("aria-disabled", canTrade ? "false" : "true");
|
||||
if (!canTrade) {
|
||||
btn.title = note || "当前不可开仓";
|
||||
} else {
|
||||
btn.removeAttribute("title");
|
||||
}
|
||||
}
|
||||
if (noteEl) {
|
||||
if (!canTrade && note) {
|
||||
noteEl.hidden = false;
|
||||
noteEl.textContent = note;
|
||||
} else {
|
||||
noteEl.hidden = true;
|
||||
noteEl.textContent = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
global.OpenSubmitGate = { apply: apply };
|
||||
})(window);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,61 @@
|
||||
return Number(v).toFixed(2);
|
||||
}
|
||||
|
||||
function posPremiumCcy(p) {
|
||||
const ccy = String((p && p.premium_ccy) || "").trim().toUpperCase();
|
||||
if (ccy) return ccy;
|
||||
const mode = String((p && p.margin_mode) || "").toLowerCase();
|
||||
const inst = String((p && p.inst_id) || "");
|
||||
if (mode === "coin" || (inst.indexOf("-USD-") >= 0 && inst.indexOf("_UM") < 0)) {
|
||||
return (inst.split("-")[0] || "ETH").toUpperCase() || "ETH";
|
||||
}
|
||||
return "USDC";
|
||||
}
|
||||
|
||||
function fmtPremiumAmt(v, ccy) {
|
||||
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
|
||||
const n = Number(v);
|
||||
const unit = String(ccy || "USDC").toUpperCase();
|
||||
if (unit === "ETH" || unit === "BTC") {
|
||||
let s = n.toFixed(8).replace(/\.?0+$/, "");
|
||||
return s || "0";
|
||||
}
|
||||
return fmtUsdc(n);
|
||||
}
|
||||
|
||||
function spotPxOf(p) {
|
||||
const n = Number(p && (p.idx_px != null ? p.idx_px : p.idxPx != null ? p.idxPx : p.index_px));
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
function fmtCoinUsdtDual(coinAmt, spotPx, ccy, signed) {
|
||||
if (coinAmt === null || coinAmt === undefined || Number.isNaN(Number(coinAmt))) return "—";
|
||||
const n = Number(coinAmt);
|
||||
const unit = String(ccy || "ETH").toUpperCase();
|
||||
if (unit !== "ETH" && unit !== "BTC") {
|
||||
const sign = signed && n > 0 ? "+" : "";
|
||||
return sign + fmtUsdc(n) + "U";
|
||||
}
|
||||
const absCoin = Math.abs(n).toFixed(8).replace(/\.?0+$/, "") || "0";
|
||||
const coinSign = n < 0 ? "-" : signed && n > 0 ? "+" : "";
|
||||
const coinTxt = coinSign + absCoin + " " + unit;
|
||||
const px = Number(spotPx);
|
||||
if (!Number.isFinite(px) || !(px > 0)) return coinTxt;
|
||||
const u = n * px;
|
||||
const absU = Math.abs(u).toFixed(2);
|
||||
const uSign = u < 0 ? "-" : signed && u > 0 ? "+" : "";
|
||||
return coinTxt + " / " + uSign + absU + "U";
|
||||
}
|
||||
|
||||
function fmtNetPnlDual(net, p) {
|
||||
const ccy = posPremiumCcy(p);
|
||||
if (ccy === "USDC") {
|
||||
if (net == null || Number.isNaN(Number(net))) return "—";
|
||||
return fmtUsdc(Number(net)) + "U";
|
||||
}
|
||||
return fmtCoinUsdtDual(net, spotPxOf(p), ccy, true);
|
||||
}
|
||||
|
||||
function optTypeLabel(t) {
|
||||
return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call";
|
||||
}
|
||||
@@ -96,22 +151,34 @@
|
||||
}
|
||||
const gate = preview.close_gate || {};
|
||||
if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) {
|
||||
return "目标门控: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟");
|
||||
return "目标门控: " + (preview.close_gate_msg || gate.msg || "门控未过(见 env 目标平仓门控)");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function netPnlFromPos(p) {
|
||||
const preview = (p && p.close_preview) || {};
|
||||
if (preview.bid_invalid) {
|
||||
const upl = p && p.upl != null ? Number(p.upl) : NaN;
|
||||
return Number.isFinite(upl) ? upl : null;
|
||||
}
|
||||
if (preview.estimated_pnl != null && !Number.isNaN(Number(preview.estimated_pnl))) {
|
||||
return Number(preview.estimated_pnl);
|
||||
}
|
||||
const covered = Number(preview.covered_sheets);
|
||||
const recv = Number(preview.total_received);
|
||||
const prem = Number(p && p.premium_paid);
|
||||
if (preview.total_received != null && !Number.isNaN(recv) && !Number.isNaN(prem)) {
|
||||
if (
|
||||
preview.total_received != null &&
|
||||
Number.isFinite(covered) &&
|
||||
covered > 0 &&
|
||||
!Number.isNaN(recv) &&
|
||||
!Number.isNaN(prem)
|
||||
) {
|
||||
return recv - prem;
|
||||
}
|
||||
return null;
|
||||
const upl = p && p.upl != null ? Number(p.upl) : NaN;
|
||||
return Number.isFinite(upl) ? upl : null;
|
||||
}
|
||||
|
||||
function netRoiFromPos(p, net) {
|
||||
@@ -124,9 +191,10 @@
|
||||
return (net / prem) * 100;
|
||||
}
|
||||
|
||||
function fmtClosePreview(preview, premiumPaid, hub) {
|
||||
function fmtClosePreview(preview, premiumPaid, hub, p) {
|
||||
if (!preview || preview.total_received == null) return "—";
|
||||
const recvTxt = fmtUsdc(preview.total_received);
|
||||
const ccy = posPremiumCcy(p);
|
||||
const recvTxt = fmtPremiumAmt(preview.total_received, ccy);
|
||||
let cls = "";
|
||||
const prem = Number(premiumPaid);
|
||||
const recv = Number(preview.total_received);
|
||||
@@ -134,7 +202,7 @@
|
||||
if (recv > prem) cls = " " + pnlCls(1, hub);
|
||||
else if (recv < prem) cls = " " + pnlCls(-1, hub);
|
||||
}
|
||||
return '<span class="opt-close-value' + cls + '">' + recvTxt + " USDC</span>";
|
||||
return '<span class="opt-close-value' + cls + '">' + recvTxt + " " + ccy + "</span>";
|
||||
}
|
||||
|
||||
function expiryCdHtml(expMs) {
|
||||
@@ -147,15 +215,20 @@
|
||||
opts = opts || {};
|
||||
const hub = !!opts.hub;
|
||||
const readOnly = !!opts.readOnly;
|
||||
const net = netPnlFromPos(p);
|
||||
const roi = netRoiFromPos(p, net);
|
||||
const uplCls = pnlCls(net, hub);
|
||||
const hidePnl = !!opts.hidePnl;
|
||||
const net = hidePnl ? null : netPnlFromPos(p);
|
||||
const roi = hidePnl ? null : netRoiFromPos(p, net);
|
||||
const uplCls = hidePnl ? "" : pnlCls(net, hub);
|
||||
const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long";
|
||||
const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time;
|
||||
const expAttr = expMs != null && expMs !== "" ? String(expMs) : "";
|
||||
const closePreview = p.close_preview || {};
|
||||
const tickSz = p.tick_sz;
|
||||
const premTxt = fmtDisplay(p.premium_paid_fmt, p.premium_paid != null ? fmtUsdc(p.premium_paid) : null);
|
||||
const premCcy = posPremiumCcy(p);
|
||||
const premTxt = fmtDisplay(
|
||||
p.premium_paid_fmt,
|
||||
p.premium_paid != null ? fmtPremiumAmt(p.premium_paid, premCcy) : null
|
||||
);
|
||||
const avgTxt = p.avg_px != null ? fmtOptionPx(p.avg_px, tickSz) : fmtDisplay(p.avg_px_fmt);
|
||||
const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt);
|
||||
let headActions = "";
|
||||
@@ -166,6 +239,12 @@
|
||||
'<button type="button" class="btn-primary opt-close-btn" data-inst="' + (p.inst_id || "") + '" data-sheets="' + closeSheets + '">买一平仓</button>' +
|
||||
"</div>";
|
||||
}
|
||||
const pnlCells = hidePnl
|
||||
? ""
|
||||
: '<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
|
||||
(net == null ? "—" : fmtNetPnlDual(net, p)) + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
|
||||
(roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>";
|
||||
return (
|
||||
'<div class="pos-card-head">' +
|
||||
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + "</strong>" +
|
||||
@@ -183,21 +262,18 @@
|
||||
: "") +
|
||||
"</div>" +
|
||||
'<div class="pos-grid">' +
|
||||
'<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + premTxt + " USDC</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + premTxt + " " + premCcy + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">到期平衡</span><span class="pos-value">' + fmt(p.expiry_be_px, 0) + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">平掉回本</span><span class="pos-value">' + fmt(p.close_be_px, 0) + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
|
||||
(closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
|
||||
(closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>" +
|
||||
pnlCells +
|
||||
'<div class="pos-cell opt-pos-cell--depth"><span class="pos-label">买盘深度</span><span class="pos-value opt-bid-plain">' + fmtCloseLevels(closePreview, tickSz) + "</span></div>" +
|
||||
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' +
|
||||
(closePreview.bid_invalid
|
||||
? '<span class="muted">暂无有效买盘</span>'
|
||||
: fmtClosePreview(closePreview, p.premium_paid, hub)) + "</span></div>" +
|
||||
: fmtClosePreview(closePreview, hidePnl ? null : p.premium_paid, hub, p)) + "</span></div>" +
|
||||
"</div>" +
|
||||
(function () {
|
||||
const hint = closeGateHint(closePreview);
|
||||
@@ -210,6 +286,7 @@
|
||||
const strike = Number(p.strike);
|
||||
const tgt = Number(p.target_index);
|
||||
const prem = Number(p.premium_paid);
|
||||
const idx = Number(p.idx_px);
|
||||
let profit = null;
|
||||
let value = null;
|
||||
if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) {
|
||||
@@ -217,19 +294,29 @@
|
||||
const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null;
|
||||
if (intrinsic != null) {
|
||||
value = Math.round(intrinsic * eth * 100) / 100;
|
||||
if (Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100;
|
||||
if (!hidePnl && Number.isFinite(prem)) {
|
||||
let premUsd = prem;
|
||||
if (premCcy !== "USDC" && Number.isFinite(idx) && idx > 0) premUsd = prem * idx;
|
||||
profit = Math.round((value - premUsd) * 100) / 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC");
|
||||
const valueUnit = premCcy !== "USDC" ? " U(估)" : " USDC";
|
||||
const profitTxt = profit == null
|
||||
? "—"
|
||||
: ((profit > 0 ? "+" : "") + fmtUsdc(profit) + (premCcy !== "USDC" ? " U(估)" : " USDC"));
|
||||
const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : "";
|
||||
const hedgeTarget = p.hedge_plan_target || null;
|
||||
const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
|
||||
const profitSpan = hidePnl
|
||||
? ""
|
||||
: '<span class="pos-value' + profitCls + '">预估盈利 ' + profitTxt + "</span>";
|
||||
return (
|
||||
'<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' +
|
||||
'<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" +
|
||||
'<span class="pos-value">目标 ' + fmt(p.target_index, 1) + "</span>" +
|
||||
'<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "</span>" +
|
||||
'<span class="pos-value' + profitCls + '">预估盈利 ' + profitTxt + "</span>" +
|
||||
'<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + valueUnit) + "</span>" +
|
||||
profitSpan +
|
||||
'<span class="muted opt-target-row-hint">' +
|
||||
(managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") +
|
||||
"</span></div>"
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
if (v == null || v === "") return "—";
|
||||
var n = Number(v);
|
||||
if (Number.isNaN(n)) return "—";
|
||||
return (n >= 0 ? "+" : "") + n.toFixed(2);
|
||||
return (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
|
||||
}
|
||||
|
||||
function fmtHold(sec) {
|
||||
@@ -76,8 +76,9 @@
|
||||
target_win_leg: "期期平盈利腿",
|
||||
target_up_win_leg: "期期上破·平盈利腿",
|
||||
target_down_win_leg: "期期下破·平盈利腿",
|
||||
oo_rest_closing: "期期全平·清残腿中",
|
||||
oo_rest_closed: "期期全平·两腿已平",
|
||||
profit_rr_win_leg: "期期盈亏比达标·平盈利腿",
|
||||
oo_rest_closing: "期期残值平·清亏损腿中",
|
||||
oo_rest_closed: "期期残值平·两腿已平",
|
||||
orphaned_after_tp: "止盈后持有至到期",
|
||||
orphaned_option_expiry: "残腿到期",
|
||||
hold_to_expiry: "持有至到期",
|
||||
@@ -248,8 +249,22 @@
|
||||
return "持平";
|
||||
}
|
||||
|
||||
function tradeModeFromDom() {
|
||||
var tabs = document.querySelector(".or-tabs");
|
||||
return (tabs && tabs.getAttribute("data-okx-trade-mode")) || "options";
|
||||
}
|
||||
|
||||
function defaultSourceForMode(mode) {
|
||||
if (mode === "options_options") return "options_options";
|
||||
if (mode === "perp_options") return "perp_options";
|
||||
return "option_spot";
|
||||
}
|
||||
|
||||
function setActiveTab(source) {
|
||||
activeSource = source || "option_spot";
|
||||
var mode = tradeModeFromDom();
|
||||
var allowed = defaultSourceForMode(mode);
|
||||
activeSource = source || allowed;
|
||||
if (activeSource !== allowed) activeSource = allowed;
|
||||
tradesPage = 0;
|
||||
reviewedPage = 0;
|
||||
document.querySelectorAll(".or-tab").forEach(function (btn) {
|
||||
@@ -1312,7 +1327,7 @@
|
||||
hideJournalForm();
|
||||
hideDetail();
|
||||
hardenSearchAutofill();
|
||||
setActiveTab("option_spot");
|
||||
setActiveTab(defaultSourceForMode(tradeModeFromDom()));
|
||||
}
|
||||
|
||||
function hardenSearchAutofill() {
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
const SWAP_BTNS = ["opt-set-swap-btn", "opt-set-swap-all-btn"];
|
||||
const INT_BTNS = ["opt-set-int-btn", "opt-set-int-all-btn"];
|
||||
const CROSS_BTNS = ["opt-set-cross-btn", "opt-set-cross-all-btn"];
|
||||
|
||||
async function apiJson(url, opts) {
|
||||
const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {}));
|
||||
@@ -269,80 +268,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function submitCrossTransfer(amount) {
|
||||
setButtonsBusy(CROSS_BTNS, true, "划转中…");
|
||||
setMsg("opt-set-cross-msg", "划转中…", false);
|
||||
try {
|
||||
const d = await apiJson("/api/options/cross-transfer", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ccy: document.getElementById("opt-set-cross-ccy").value,
|
||||
amount: amount,
|
||||
from_account: document.getElementById("opt-set-cross-from").value,
|
||||
to_account: document.getElementById("opt-set-cross-to").value,
|
||||
direction: document.getElementById("opt-set-cross-dir").value,
|
||||
}),
|
||||
});
|
||||
if (d.ok) {
|
||||
setMsg("opt-set-cross-msg", "划转成功", false);
|
||||
refreshFundsAfterMutation();
|
||||
} else {
|
||||
setMsg("opt-set-cross-msg", "划转失败:" + (d.msg || "未知错误"), true);
|
||||
}
|
||||
return d;
|
||||
} catch (e) {
|
||||
setMsg("opt-set-cross-msg", "划转失败:" + (e.message || "网络错误"), true);
|
||||
return { ok: false };
|
||||
} finally {
|
||||
setButtonsBusy(CROSS_BTNS, false);
|
||||
}
|
||||
}
|
||||
|
||||
const crossBtn = document.getElementById("opt-set-cross-btn");
|
||||
if (crossBtn) {
|
||||
crossBtn.addEventListener("click", async function () {
|
||||
const amount = parseFloat(document.getElementById("opt-set-cross-amount").value);
|
||||
if (!amount || amount <= 0) {
|
||||
setMsg("opt-set-cross-msg", "请输入有效数量", true);
|
||||
return;
|
||||
}
|
||||
await submitCrossTransfer(amount);
|
||||
});
|
||||
}
|
||||
|
||||
const crossAllBtn = document.getElementById("opt-set-cross-all-btn");
|
||||
if (crossAllBtn) {
|
||||
crossAllBtn.addEventListener("click", async function () {
|
||||
try {
|
||||
const ccy = document.getElementById("opt-set-cross-ccy").value;
|
||||
const from = document.getElementById("opt-set-cross-from").value;
|
||||
const to = document.getElementById("opt-set-cross-to").value;
|
||||
const direction = document.getElementById("opt-set-cross-dir").value;
|
||||
const scope = direction === "sub_to_main" ? "sub" : "main";
|
||||
const sideLabel = direction === "sub_to_main" ? "子账户" : "主账户";
|
||||
const amount = await resolveMaxAmount(from, ccy, scope);
|
||||
if (!amount) {
|
||||
setMsg("opt-set-cross-msg", sideLabel + "划出账户可用余额不足", true);
|
||||
return;
|
||||
}
|
||||
const msg =
|
||||
"确认全部划转?\n\n" +
|
||||
"方向:" + (direction === "main_to_sub" ? "主 → 子" : "子 → 主") + "\n" +
|
||||
"币种:" + ccy + "\n" +
|
||||
"划出:" + sideLabel + " · " + accountLabel(from) + "\n" +
|
||||
"划入:" + (direction === "main_to_sub" ? "子账户" : "主账户") + " · " + accountLabel(to) + "\n" +
|
||||
"金额:" + fmtAmt(amount, ccy) + "\n\n" +
|
||||
"将划转该账户全部可用余额。";
|
||||
if (!confirmOk(msg)) return;
|
||||
document.getElementById("opt-set-cross-amount").value = String(amount);
|
||||
await submitCrossTransfer(amount);
|
||||
} catch (e) {
|
||||
setMsg("opt-set-cross-msg", "划转失败:" + (e.message || "余额拉取失败"), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function hardenAmountAutofill(ids) {
|
||||
ids.forEach(function (id) {
|
||||
const el = document.getElementById(id);
|
||||
@@ -366,7 +291,7 @@
|
||||
}
|
||||
|
||||
// 全部划转/兑换前去掉 readonly,避免写不进数量
|
||||
["opt-set-swap-all-btn", "opt-set-int-all-btn", "opt-set-cross-all-btn"].forEach(function (btnId) {
|
||||
["opt-set-swap-all-btn", "opt-set-int-all-btn"].forEach(function (btnId) {
|
||||
const btn = document.getElementById(btnId);
|
||||
if (!btn) return;
|
||||
btn.addEventListener(
|
||||
@@ -375,7 +300,6 @@
|
||||
const map = {
|
||||
"opt-set-swap-all-btn": "opt-set-swap-amount",
|
||||
"opt-set-int-all-btn": "opt-set-int-amount",
|
||||
"opt-set-cross-all-btn": "opt-set-cross-amount",
|
||||
};
|
||||
const input = document.getElementById(map[btnId]);
|
||||
if (input) input.removeAttribute("readonly");
|
||||
@@ -384,5 +308,5 @@
|
||||
);
|
||||
});
|
||||
|
||||
hardenAmountAutofill(["opt-set-swap-amount", "opt-set-int-amount", "opt-set-cross-amount"]);
|
||||
hardenAmountAutofill(["opt-set-swap-amount", "opt-set-int-amount"]);
|
||||
})();
|
||||
|
||||
Vendored
+59
-1
@@ -58,6 +58,7 @@ HOT_RELOAD_EXACT = frozenset({
|
||||
"RISK_COOLING_HOURS_MANUAL",
|
||||
"RISK_COOLING_HOURS_MANUAL_JOURNAL",
|
||||
"RISK_MANUAL_CLOSE_DAILY_LIMIT",
|
||||
"RISK_DAILY_LOSS_LIMIT",
|
||||
"RISK_MOOD_ISSUES_DAILY_FREEZE",
|
||||
"KEY_AUTO_ORDER_ENABLED",
|
||||
"TRADE_DIRECTION_RESTRICT_ENABLED",
|
||||
@@ -69,9 +70,13 @@ HOT_RELOAD_EXACT = frozenset({
|
||||
"MONITOR_POLL_SECONDS",
|
||||
"AUTO_TRANSFER_ENABLED",
|
||||
"AUTO_TRANSFER_AMOUNT",
|
||||
"AUTO_TRANSFER_FROM",
|
||||
"AUTO_TRANSFER_TO",
|
||||
"AUTO_TRANSFER_BJ_HOUR",
|
||||
"TRANSFER_CCY",
|
||||
"FORCE_CLOSE_ENABLED",
|
||||
"FORCE_CLOSE_BJ_HOUR",
|
||||
"FORCE_CLOSE_GRACE_MINUTES",
|
||||
"BTC_LEVERAGE",
|
||||
"ALT_LEVERAGE",
|
||||
"DAILY_START_CAPITAL",
|
||||
@@ -85,8 +90,25 @@ HOT_RELOAD_EXACT = frozenset({
|
||||
"HEDGE_PLAN_ENABLED",
|
||||
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
|
||||
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
|
||||
"OKX_SHOW_PERP_FUNDS",
|
||||
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
|
||||
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
|
||||
"OKX_OPTIONS_MAX_DTE_DAYS",
|
||||
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
|
||||
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
|
||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
|
||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
|
||||
"OKX_OPTIONS_TRADE_BUDGET_USDC",
|
||||
"OKX_OPTIONS_BUDGET_BUFFER",
|
||||
"OKX_OPTIONS_COIN_COMPOUND",
|
||||
"OKX_OPTIONS_COIN_BUDGET_USDT",
|
||||
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
|
||||
"OKX_OPTIONS_COIN_MAX_USDT",
|
||||
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
|
||||
"OKX_TRADE_MODE",
|
||||
"MAX_ACTIVE_HEDGE_PLANS",
|
||||
"HEDGE_PLAN_LIVE_ORDER",
|
||||
"HEDGE_PLAN_OPTION_PRIMARY",
|
||||
"HEDGE_PLAN_OPEN_ORDER",
|
||||
"HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS",
|
||||
"HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS",
|
||||
@@ -125,16 +147,45 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
|
||||
("long_only", "仅做多"),
|
||||
("short_only", "仅做空"),
|
||||
),
|
||||
"AUTO_TRANSFER_FROM": (
|
||||
("funding", "funding 资金账户"),
|
||||
("swap", "swap 交易账户"),
|
||||
("spot", "spot 现货"),
|
||||
),
|
||||
"AUTO_TRANSFER_TO": (
|
||||
("swap", "swap 交易账户"),
|
||||
("funding", "funding 资金账户"),
|
||||
("spot", "spot 现货"),
|
||||
),
|
||||
"TRANSFER_CCY": (("USDT", "USDT"),),
|
||||
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": (
|
||||
("budget", "预算金额"),
|
||||
("sheets", "张数"),
|
||||
),
|
||||
"OKX_TRADE_MODE": (
|
||||
("options", "单独期权"),
|
||||
("perp_options", "永期对冲"),
|
||||
("options_options", "期期对冲"),
|
||||
),
|
||||
"OKX_OPTIONS_MARGIN_MODE": (
|
||||
("coin", "币本位(USDT买币桥)"),
|
||||
("usdc", "USDC(USDⓈ权利金)"),
|
||||
),
|
||||
"OKX_OPTIONS_CLOSE_GATE_MODE": (
|
||||
("premium", "权利金×倍数"),
|
||||
("net_pnl", "净盈亏(U)阈值"),
|
||||
),
|
||||
"HEDGE_PLAN_OPTION_PRIMARY": (
|
||||
("true", "以期权为主"),
|
||||
("false", "保险模式"),
|
||||
),
|
||||
}
|
||||
|
||||
_SELECT_ALIASES: dict[str, dict[str, str]] = {
|
||||
"OKX_TD_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"},
|
||||
"BINANCE_MARGIN_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"},
|
||||
"GATE_TD_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"},
|
||||
"TRANSFER_CCY": {"usdt": "USDT"},
|
||||
}
|
||||
|
||||
|
||||
@@ -158,10 +209,13 @@ def normalize_select_value(key: str, value: Optional[str]) -> str:
|
||||
if low in aliases:
|
||||
return aliases[low]
|
||||
allowed = {v for v, _ in (SELECT_OPTIONS.get(key) or ())}
|
||||
allowed_by_lower = {v.lower(): v for v in allowed}
|
||||
if low in allowed:
|
||||
return low
|
||||
if raw in allowed:
|
||||
return raw
|
||||
if low in allowed_by_lower:
|
||||
return allowed_by_lower[low]
|
||||
return raw
|
||||
|
||||
|
||||
@@ -187,7 +241,11 @@ def _field_type(key: str, value: str) -> str:
|
||||
low = (value or "").strip().lower()
|
||||
if low in ("true", "false"):
|
||||
return "bool"
|
||||
if key.endswith("_ENABLED") or key.startswith("RISK_MOOD_"):
|
||||
if key.endswith("_ENABLED") or key.startswith("RISK_MOOD_") or key in (
|
||||
"OKX_SHOW_PERP_FUNDS",
|
||||
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
|
||||
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
|
||||
):
|
||||
return "bool"
|
||||
try:
|
||||
if "." in low:
|
||||
|
||||
Vendored
+321
-71
@@ -19,27 +19,25 @@ from lib.env.env_schema import (
|
||||
# 各所「交易所与实盘」字段(顺序即页面顺序)
|
||||
_EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
|
||||
"okx": [
|
||||
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
|
||||
("OKX_API_KEY", "API Key", "永续子账户"),
|
||||
("OKX_API_SECRET", "API Secret", "永续子账户"),
|
||||
("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"),
|
||||
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"),
|
||||
("OKX_TD_MODE", "保证金模式", ""),
|
||||
("OKX_POS_MODE", "持仓模式", ""),
|
||||
("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"),
|
||||
("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
|
||||
(
|
||||
"OKX_SHOW_PERP_FUNDS",
|
||||
"显示永续资金",
|
||||
"默认开启;关闭后顶栏隐藏 USDT 资金账户与交易账户,总资金仅计期权 USDC 侧",
|
||||
),
|
||||
],
|
||||
"binance": [
|
||||
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
|
||||
("BINANCE_API_KEY", "API Key", "永续子账户"),
|
||||
("BINANCE_API_SECRET", "API Secret", "永续子账户"),
|
||||
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"),
|
||||
("BINANCE_MARGIN_MODE", "保证金模式", ""),
|
||||
("BINANCE_POSITION_MODE", "持仓模式", ""),
|
||||
("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
|
||||
],
|
||||
"gate": [
|
||||
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
|
||||
("GATE_API_KEY", "API Key", "永续子账户"),
|
||||
("GATE_API_SECRET", "API Secret", "永续子账户"),
|
||||
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"),
|
||||
("GATE_TD_MODE", "保证金模式", ""),
|
||||
("GATE_POS_MODE", "持仓模式", ""),
|
||||
("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
|
||||
@@ -78,6 +76,7 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
|
||||
("KEY_AUTO_MIN_PLANNED_RR", "关键位最低盈亏比", "自动单计划 RR 须严格大于该值,默认 1.5"),
|
||||
("FORCE_CLOSE_ENABLED", "强制清仓开关", ""),
|
||||
("FORCE_CLOSE_BJ_HOUR", "强制清仓整点(北京)", ""),
|
||||
("FORCE_CLOSE_GRACE_MINUTES", "强制清仓窗口(分钟)", "默认 5;整点起该分钟内执行并禁止开仓"),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -94,6 +93,7 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
|
||||
("RISK_COOLING_HOURS_MANUAL", "手动平仓冷静(小时)", ""),
|
||||
("RISK_COOLING_HOURS_MANUAL_JOURNAL", "复盘情绪冷静(小时)", ""),
|
||||
("RISK_MANUAL_CLOSE_DAILY_LIMIT", "日手动平仓次数上限", ""),
|
||||
("RISK_DAILY_LOSS_LIMIT", "日亏损次数上限", "默认2;达限当日冻结开仓;0=不因亏损次数冻结"),
|
||||
("RISK_MOOD_ISSUES_DAILY_FREEZE", "情绪标签日冻结", ""),
|
||||
],
|
||||
},
|
||||
@@ -102,10 +102,10 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
|
||||
"fields": [
|
||||
("AUTO_TRANSFER_ENABLED", "启用自动划转", ""),
|
||||
("AUTO_TRANSFER_AMOUNT", "目标余额(U)", "交易账户目标 USDT"),
|
||||
("AUTO_TRANSFER_FROM", "划出账户", "funding 或 swap"),
|
||||
("AUTO_TRANSFER_TO", "划入账户", "swap 或 funding"),
|
||||
("AUTO_TRANSFER_FROM", "划出账户", "余额不足时从此账户划入交易账户"),
|
||||
("AUTO_TRANSFER_TO", "划入账户", "目标余额所在账户,一般为 swap"),
|
||||
("AUTO_TRANSFER_BJ_HOUR", "执行整点(北京时间)", ""),
|
||||
("TRANSFER_CCY", "划转币种", "默认 USDT"),
|
||||
("TRANSFER_CCY", "划转币种", ""),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -118,75 +118,212 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
|
||||
},
|
||||
]
|
||||
|
||||
_MODE_SECTION: dict[str, Any] = {
|
||||
"title": "期权/对冲模式",
|
||||
"exchanges": frozenset({"okx"}),
|
||||
"fields": [
|
||||
(
|
||||
"OKX_TRADE_MODE",
|
||||
"交易模式",
|
||||
"三选一:单独期权 / 永期对冲 / 期期对冲.选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权",
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
_OPTIONS_SECTION: dict[str, Any] = {
|
||||
"title": "期权账户",
|
||||
"exchanges": frozenset({"okx"}),
|
||||
"fields": [
|
||||
("OKX_OPTIONS_ENABLED", "启用期权模块", ""),
|
||||
("OKX_OPTIONS_API_KEY", "期权 API Key", "主账户,与永续子账户分离"),
|
||||
("OKX_OPTIONS_API_SECRET", "期权 API Secret", ""),
|
||||
("OKX_OPTIONS_API_PASSPHRASE", "期权 API Passphrase", ""),
|
||||
("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"),
|
||||
("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
|
||||
("OKX_OPTIONS_TRADE_BUDGET_USDC", "单笔预算(USDC)", ""),
|
||||
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95"),
|
||||
(
|
||||
"OKX_OPTIONS_MARGIN_MODE",
|
||||
"单笔期权本位",
|
||||
"usdc=USDⓈ权利金;coin=币本位+USDT买币桥(默认)。有持仓/半成品桥时勿切换;改后需重启",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_TRADE_BUDGET_USDC",
|
||||
"单笔预算(USDC)",
|
||||
"仅 USDC 模式且全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限",
|
||||
),
|
||||
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;USDC 打满/全仓复利与币本位复利共用"),
|
||||
(
|
||||
"OKX_OPTIONS_COIN_COMPOUND",
|
||||
"币本位按交易户USDT复利",
|
||||
"默认 true;预算=交易账户USDT×缓冲;关闭则用下方固定 USDT 预算×缓冲",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_COIN_BUDGET_USDT",
|
||||
"币本位固定预算(USDT)",
|
||||
"仅币本位且复利关闭时生效",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
|
||||
"币本位单笔上限开关",
|
||||
"默认 false=靠人工转走控规模;true 时预算不超过下方 N U",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_COIN_MAX_USDT",
|
||||
"币本位单笔上限(USDT)",
|
||||
"仅上限开关开启时生效",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
|
||||
"币本位现货买入缓冲",
|
||||
"相对权利金倍数,默认 1.10(=多买10%);也可写 0.10 表示+10%。按最大可开张数×卖一权利金×本缓冲买币,不全额兑换",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
|
||||
"全仓复利开关",
|
||||
"默认 true;仅 USDC 模式。开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
|
||||
"全仓复利上限开关",
|
||||
"仅全仓复利开启时有意义;默认 false=不设上限用期权户全部可用;true 时按下方上限封顶",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
|
||||
"全仓复利上限(USDC)",
|
||||
"仅「全仓复利」且「上限开关」都开启时生效;例如 300",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
|
||||
"期权持仓上限(笔)",
|
||||
"仅「单独期权」模式生效;默认 0=不限制;按交易所期权合约笔数计数,同合约加仓不占新笔数",
|
||||
),
|
||||
("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"),
|
||||
(
|
||||
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
|
||||
"期权链展示天数",
|
||||
"默认 14;下拉到期日只出现该天数内的合约(含明天)",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_MAX_DTE_DAYS",
|
||||
"开仓最大剩余天数",
|
||||
"默认 2;单独开期权时拒绝更远到期(与链展示天数独立)",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
|
||||
"链上仅显示有卖一",
|
||||
"默认 true;开启后隐藏无卖一深度或深度不足1张的合约(含标记价估算行)",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_CLOSE_GATE_MODE",
|
||||
"目标平仓门控模式",
|
||||
"premium=可回收(U)≥权利金(U)×倍数;net_pnl=净盈亏(U)大于阈值。币本位按指数换算为U",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
|
||||
"门控权利金倍数(全局)",
|
||||
"可选;填写则覆盖下方分本位默认值",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
|
||||
"门控权利金倍数(币本位)",
|
||||
"默认 1.05;premium 模式下 recyclable(U)≥premium(U)×本值",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC",
|
||||
"门控权利金倍数(USDC)",
|
||||
"默认 2;premium 模式下 recyclable(U)≥premium(U)×本值",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_U",
|
||||
"门控净盈亏下限(U)",
|
||||
"net_pnl 模式;净盈亏(估)须大于本值,如 0 或 1",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_CLOSE_HOLD_SECONDS",
|
||||
"门控持续秒数",
|
||||
"达标后须持续本秒数才通过,默认 120",
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
# 对冲公共字段(不含已由 OKX_TRADE_MODE 取代的 ENABLED/SHOW/MUTUAL)
|
||||
_HEDGE_COMMON_FIELDS: list[tuple[str, str, str]] = [
|
||||
("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动"),
|
||||
(
|
||||
"MAX_ACTIVE_HEDGE_PLANS",
|
||||
"对冲组数上限",
|
||||
"默认 1;同时进行中的对冲计划组数(opening/active/partial),可改",
|
||||
),
|
||||
("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"),
|
||||
(
|
||||
"HEDGE_PLAN_BUDGET_BUFFER",
|
||||
"对冲预算缓冲比例",
|
||||
"默认 0.95;仅对冲计划;与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立",
|
||||
),
|
||||
(
|
||||
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
|
||||
"半腿失败改手动补开",
|
||||
"默认 true;开启时半腿失败不自动平,计划挂 partial,页面可补开;并强制关闭下方自动平",
|
||||
),
|
||||
(
|
||||
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
|
||||
"半腿失败时自动平期权",
|
||||
"默认 true;若上方「半腿失败改手动补开」开启则本项强制无效",
|
||||
),
|
||||
]
|
||||
|
||||
_HEDGE_PO_FIELDS: list[tuple[str, str, str]] = [
|
||||
(
|
||||
"HEDGE_PLAN_OPTION_PRIMARY",
|
||||
"永期模式(以期权为主/保险)",
|
||||
"默认 true=以期权为主;false=保险模式;页面标题前显示标识,不可在页内切换",
|
||||
),
|
||||
("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"),
|
||||
("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"),
|
||||
("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"),
|
||||
(
|
||||
"HEDGE_PLAN_ITM_MAX_DIST_USD",
|
||||
"永期实值最大深度(U)",
|
||||
"默认空=沿用 OKX_OPTIONS_ITM_MAX_DIST_USD(常 30);0=不限制",
|
||||
),
|
||||
(
|
||||
"HEDGE_PLAN_MIN_OPTION_HOURS",
|
||||
"对冲期权最低剩余小时",
|
||||
"默认 8;测算/启动时若传 hours_to_expiry 则校验",
|
||||
),
|
||||
(
|
||||
"HEDGE_PLAN_MIN_OPTION_LEVERAGE",
|
||||
"对冲期权最低杠杆(S/ask)",
|
||||
"默认 0=不启用;>0 时拒绝杠杆过低的保险腿",
|
||||
),
|
||||
]
|
||||
|
||||
_HEDGE_OO_FIELDS: list[tuple[str, str, str]] = [
|
||||
("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"),
|
||||
(
|
||||
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
|
||||
"期期平仓模式(方案C)",
|
||||
"默认 true;开启后页面可选「到期平/全平」;关闭则固定到期平",
|
||||
),
|
||||
(
|
||||
"HEDGE_PLAN_OO_BIAS_SPLIT_BY",
|
||||
"期期做多做空拆分口径",
|
||||
"默认预算金额;budget=按权利金预算分两腿;sheets=先算同张数再按比例拆",
|
||||
),
|
||||
(
|
||||
"HEDGE_PLAN_OO_BIAS_RATIO",
|
||||
"期期做多做空主腿占比",
|
||||
"默认 0.7(即 7:3);做多主腿=Call,做空主腿=Put;须在 0~1 之间",
|
||||
),
|
||||
]
|
||||
|
||||
# 兼容旧测试/全量字段列表(写 env 时仍允许这些键,但 UI 按模式过滤)
|
||||
_HEDGE_PLAN_SECTION: dict[str, Any] = {
|
||||
"title": "对冲计划",
|
||||
"exchanges": frozenset({"okx"}),
|
||||
"fields": [
|
||||
("HEDGE_PLAN_ENABLED", "启用对冲计划", "关闭则隐藏导航且不可开仓"),
|
||||
("HEDGE_PLAN_SHOW_PERP_OPTIONS", "显示永期对冲", "默认 true;关闭后隐藏永期 Tab,不可测算/开仓"),
|
||||
("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "显示期期对冲", "默认 true;关闭后隐藏期期 Tab,不可测算/开仓"),
|
||||
("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动永期"),
|
||||
("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"),
|
||||
("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"),
|
||||
("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"),
|
||||
("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"),
|
||||
(
|
||||
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
|
||||
"期期平仓模式(方案C)",
|
||||
"默认 true;开启后页面可选「到期平/全平」(盈利腿平后另一腿);关闭则固定到期平",
|
||||
),
|
||||
(
|
||||
"HEDGE_PLAN_OO_BIAS_SPLIT_BY",
|
||||
"期期做多做空拆分口径",
|
||||
"默认预算金额;budget=按权利金预算按比例分两腿;sheets=先算同张数总张数(2n)再按比例拆",
|
||||
),
|
||||
(
|
||||
"HEDGE_PLAN_OO_BIAS_RATIO",
|
||||
"期期做多做空主腿占比",
|
||||
"默认 0.7(即 7:3);做多主腿=Call,做空主腿=Put;须在 0~1 之间",
|
||||
),
|
||||
(
|
||||
"HEDGE_PLAN_BUDGET_BUFFER",
|
||||
"对冲预算缓冲比例",
|
||||
"默认 0.95;仅对冲计划(期期可用预算=交易户×本比例);与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立",
|
||||
),
|
||||
(
|
||||
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE",
|
||||
"对冲与期权互斥门控",
|
||||
"默认 true;开启时:有对冲计划则不可单独开期权,有单独期权则不可启动对冲;关闭后两边可同时开",
|
||||
),
|
||||
(
|
||||
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
|
||||
"半腿失败改手动补开",
|
||||
"默认 true;开启时半腿失败不自动平,计划挂 partial,页面可补开永续/腿B;并强制关闭下方自动平",
|
||||
),
|
||||
("MAX_ACTIVE_HEDGE_PLANS", "最大同时活跃计划数", "建议 1"),
|
||||
("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"),
|
||||
(
|
||||
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
|
||||
"半腿失败时自动平期权",
|
||||
"默认 true;若上方「半腿失败改手动补开」开启则本项强制无效(不会自动平)",
|
||||
),
|
||||
("HEDGE_PLAN_ENABLED", "启用对冲计划", "已由「交易模式」取代,一般无需再改"),
|
||||
("HEDGE_PLAN_SHOW_PERP_OPTIONS", "显示永期对冲", "已由「交易模式」取代"),
|
||||
("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "显示期期对冲", "已由「交易模式」取代"),
|
||||
("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", "对冲与期权互斥门控", "已由「交易模式」三选一取代"),
|
||||
*_HEDGE_COMMON_FIELDS,
|
||||
*_HEDGE_PO_FIELDS,
|
||||
*_HEDGE_OO_FIELDS,
|
||||
],
|
||||
}
|
||||
|
||||
@@ -197,22 +334,53 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
|
||||
"RISK_COOLING_HOURS_MANUAL": "4",
|
||||
"RISK_COOLING_HOURS_MANUAL_JOURNAL": "1",
|
||||
"RISK_MANUAL_CLOSE_DAILY_LIMIT": "2",
|
||||
"RISK_DAILY_LOSS_LIMIT": "2",
|
||||
"RISK_MOOD_ISSUES_DAILY_FREEZE": "true",
|
||||
"AUTO_TRANSFER_FROM": "funding",
|
||||
"AUTO_TRANSFER_TO": "swap",
|
||||
"TRANSFER_CCY": "USDT",
|
||||
"HEDGE_PLAN_SHOW_PERP_OPTIONS": "true",
|
||||
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true",
|
||||
"OKX_SHOW_PERP_FUNDS": "true",
|
||||
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true",
|
||||
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS": "14",
|
||||
"OKX_OPTIONS_MAX_DTE_DAYS": "2",
|
||||
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "0",
|
||||
"OKX_TRADE_MODE": "options",
|
||||
"MAX_ACTIVE_HEDGE_PLANS": "1",
|
||||
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true",
|
||||
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget",
|
||||
"HEDGE_PLAN_OO_BIAS_RATIO": "0.7",
|
||||
"HEDGE_PLAN_BUDGET_BUFFER": "0.95",
|
||||
"HEDGE_PLAN_OPTION_PRIMARY": "true",
|
||||
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true",
|
||||
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "true",
|
||||
"OKX_OPTIONS_CLOSE_GATE_MODE": "premium",
|
||||
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN": "1.05",
|
||||
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC": "2",
|
||||
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_U": "0",
|
||||
"OKX_OPTIONS_CLOSE_HOLD_SECONDS": "120",
|
||||
}
|
||||
|
||||
|
||||
def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str:
|
||||
if key == "OKX_TRADE_MODE":
|
||||
# 展示值必须与运行时 get_okx_trade_mode() 一致,避免未写入时默认 options 静默改模式
|
||||
file_val = str(file_values.get(key) or "").strip() if key in file_values else ""
|
||||
if file_val:
|
||||
from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode
|
||||
|
||||
return normalize_okx_trade_mode(file_val) or file_val
|
||||
try:
|
||||
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
|
||||
|
||||
return get_okx_trade_mode()
|
||||
except Exception:
|
||||
pass
|
||||
if key in file_values:
|
||||
return file_values[key]
|
||||
file_val = str(file_values.get(key) or "").strip()
|
||||
if file_val:
|
||||
return file_val
|
||||
runtime = os.getenv(key)
|
||||
if runtime is not None and str(runtime).strip() != "":
|
||||
return str(runtime).strip()
|
||||
@@ -280,24 +448,69 @@ def _build_field(
|
||||
return out
|
||||
|
||||
|
||||
def ui_sections_for_exchange(exchange_key: str) -> list[dict[str, Any]]:
|
||||
def _okx_mode_for_env_ui() -> str:
|
||||
try:
|
||||
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
|
||||
|
||||
return get_okx_trade_mode()
|
||||
except Exception:
|
||||
return "options"
|
||||
|
||||
|
||||
def _options_fields_for_mode(mode: str) -> list[tuple[str, str, str]]:
|
||||
fields = list(_OPTIONS_SECTION["fields"])
|
||||
if mode != "options":
|
||||
fields = [f for f in fields if f[0] != "OKX_OPTIONS_MAX_ACTIVE_POSITIONS"]
|
||||
return fields
|
||||
|
||||
|
||||
def _hedge_fields_for_mode(mode: str) -> list[tuple[str, str, str]]:
|
||||
if mode == "perp_options":
|
||||
return [*_HEDGE_COMMON_FIELDS, *_HEDGE_PO_FIELDS]
|
||||
if mode == "options_options":
|
||||
return [*_HEDGE_COMMON_FIELDS, *_HEDGE_OO_FIELDS]
|
||||
return []
|
||||
|
||||
|
||||
def ui_sections_for_exchange(
|
||||
exchange_key: str,
|
||||
*,
|
||||
mode: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
ex = (exchange_key or "").strip().lower()
|
||||
sections: list[dict[str, Any]] = []
|
||||
live_fields = _EXCHANGE_LIVE_FIELDS.get(ex, _EXCHANGE_LIVE_FIELDS["okx"])
|
||||
sections.append({"title": "交易所与实盘", "fields": live_fields})
|
||||
sections.extend(_SHARED_SECTIONS)
|
||||
if ex in _OPTIONS_SECTION.get("exchanges", frozenset()):
|
||||
sections.append(_OPTIONS_SECTION)
|
||||
if ex in _HEDGE_PLAN_SECTION.get("exchanges", frozenset()):
|
||||
sections.append(_HEDGE_PLAN_SECTION)
|
||||
if ex in _MODE_SECTION.get("exchanges", frozenset()):
|
||||
from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode
|
||||
|
||||
m = normalize_okx_trade_mode(mode) if mode else ""
|
||||
if not m:
|
||||
m = _okx_mode_for_env_ui()
|
||||
sections.append(_MODE_SECTION)
|
||||
sections.append({"title": "期权账户", "fields": _options_fields_for_mode(m)})
|
||||
hedge_fields = _hedge_fields_for_mode(m)
|
||||
if hedge_fields:
|
||||
title = "对冲计划·永期" if m == "perp_options" else "对冲计划·期期"
|
||||
sections.append({"title": title, "fields": hedge_fields})
|
||||
return sections
|
||||
|
||||
|
||||
def ui_allowed_keys(exchange_key: str) -> frozenset[str]:
|
||||
"""可写键=当前模式可见字段 + 模式切换键 + 遗留对冲开关(兼容旧脚本写入)."""
|
||||
keys: set[str] = set()
|
||||
for sec in ui_sections_for_exchange(exchange_key):
|
||||
for item in sec["fields"]:
|
||||
keys.add(item[0])
|
||||
ex = (exchange_key or "").strip().lower()
|
||||
if ex == "okx":
|
||||
keys.add("OKX_TRADE_MODE")
|
||||
# 允许写入遗留键,避免旧自动化/手改失败;页面不再展示
|
||||
for item in _HEDGE_PLAN_SECTION["fields"]:
|
||||
keys.add(item[0])
|
||||
for item in _OPTIONS_SECTION["fields"]:
|
||||
keys.add(item[0])
|
||||
return frozenset(keys)
|
||||
|
||||
|
||||
@@ -310,11 +523,14 @@ def build_env_ui_payload(
|
||||
env_lines = read_env_lines(env_path)
|
||||
values = env_get_all(env_lines)
|
||||
groups: list[dict[str, Any]] = []
|
||||
for sec in ui_sections_for_exchange(exchange_key):
|
||||
for sec in ui_sections_for_exchange(
|
||||
exchange_key, mode=values.get("OKX_TRADE_MODE") or ""
|
||||
):
|
||||
fields = [
|
||||
_build_field(key, label, note, schema, values)
|
||||
for key, label, note in sec["fields"]
|
||||
]
|
||||
fields = _mark_options_env_field_visibility(fields)
|
||||
groups.append({
|
||||
"title": sec["title"],
|
||||
"fields": fields,
|
||||
@@ -323,6 +539,40 @@ def build_env_ui_payload(
|
||||
return groups
|
||||
|
||||
|
||||
def _mark_options_env_field_visibility(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""按本位/全仓复利隐藏无关项(供 SSR/前端;切换开关仍可再显示)."""
|
||||
compound_on = True
|
||||
margin_mode = "coin"
|
||||
for f in fields:
|
||||
key = f.get("key")
|
||||
cur = str(f.get("current") or f.get("default") or "").strip()
|
||||
if key == "OKX_OPTIONS_COMPOUND_FULL_ENABLED":
|
||||
compound_on = _env_truthy(cur or "true")
|
||||
elif key == "OKX_OPTIONS_MARGIN_MODE":
|
||||
margin_mode = (cur or "coin").lower()
|
||||
if margin_mode not in ("coin", "usdc"):
|
||||
margin_mode = "coin"
|
||||
out: list[dict[str, Any]] = []
|
||||
for f in fields:
|
||||
item = dict(f)
|
||||
key = item.get("key")
|
||||
hide = False
|
||||
if key == "OKX_OPTIONS_TRADE_BUDGET_USDC" and compound_on:
|
||||
hide = True
|
||||
if key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC" and margin_mode == "coin":
|
||||
hide = True
|
||||
if key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN" and margin_mode == "usdc":
|
||||
hide = True
|
||||
if hide:
|
||||
item["hidden"] = True
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def _mark_compound_budget_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""兼容旧调用名;实际走 _mark_options_env_field_visibility."""
|
||||
return _mark_options_env_field_visibility(fields)
|
||||
|
||||
def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]:
|
||||
allowed = ui_allowed_keys(exchange_key)
|
||||
return {k: v for k, v in (updates or {}).items() if k in allowed}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""交易所 API 凭证规范化.
|
||||
|
||||
新机 .env 密钥应为空;示例占位符不得注入 ccxt,否则鉴权失败且
|
||||
(尤其 Gate)反复签名请求易触发 IP 封禁.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
_PLACEHOLDER_EXACT = frozenset(
|
||||
{
|
||||
"你的密钥",
|
||||
"your-api-key",
|
||||
"your_api_key",
|
||||
"your-api-secret",
|
||||
"your_api_secret",
|
||||
"todo",
|
||||
"xxx",
|
||||
"changeme",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def normalize_api_credential(value: Optional[str]) -> str:
|
||||
"""去空白;空/占位符一律视为未配置."""
|
||||
s = (value or "").strip().strip('"').strip("'")
|
||||
if not s:
|
||||
return ""
|
||||
upper = s.upper()
|
||||
if upper.startswith("REPLACE_WITH"):
|
||||
return ""
|
||||
if upper.startswith("CHANGE_TO"):
|
||||
return ""
|
||||
if s.lower() in _PLACEHOLDER_EXACT:
|
||||
return ""
|
||||
return s
|
||||
|
||||
|
||||
def credentials_configured(*parts: Optional[str]) -> bool:
|
||||
return all(bool(normalize_api_credential(p)) for p in parts)
|
||||
|
||||
|
||||
def is_exchange_auth_error(exc: BaseException) -> bool:
|
||||
"""鉴权/无效 Key 类错误(用于停掉后续签名请求,避免 Gate 封 IP)."""
|
||||
name = type(exc).__name__
|
||||
if name in ("AuthenticationError", "PermissionDenied", "InvalidNonce"):
|
||||
return True
|
||||
msg = str(exc)
|
||||
markers = (
|
||||
"Invalid Api-Key",
|
||||
"Invalid API-key",
|
||||
"Invalid API Key",
|
||||
"INVALID_KEY",
|
||||
"Invalid key",
|
||||
"API key is invalid",
|
||||
"api key not found",
|
||||
"Signature",
|
||||
"INVALID_SIGNATURE",
|
||||
"401",
|
||||
"-2008",
|
||||
"-2014",
|
||||
"-2015",
|
||||
"10003", # Gate: invalid key often
|
||||
"INVALID_KEY",
|
||||
)
|
||||
low = msg.lower()
|
||||
if "api" in low and ("key" in low or "sign" in low) and (
|
||||
"invalid" in low or "incorrect" in low or "not found" in low
|
||||
):
|
||||
return True
|
||||
return any(m in msg for m in markers)
|
||||
|
||||
|
||||
def strip_ccxt_credentials(exchange: Any) -> None:
|
||||
"""内存中清空密钥,后续只走公开接口,避免继续带坏钥签名."""
|
||||
try:
|
||||
exchange.apiKey = ""
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
exchange.secret = ""
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
exchange.password = ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def load_markets_public_fallback(exchange: Any, *, reload: bool = False) -> None:
|
||||
"""鉴权失败后去掉密钥再拉公开 markets(最多再请求一次)."""
|
||||
strip_ccxt_credentials(exchange)
|
||||
exchange.load_markets(reload=reload)
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Binance:交易账户 futures income;资金账户 deposits/withdrawals/transfers.USDT."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from lib.account_ledger.account_ledger_normalize import (
|
||||
ACCOUNT_FUNDING,
|
||||
ACCOUNT_TRADING,
|
||||
from_ccxt_ledger_entry,
|
||||
kind_from_raw,
|
||||
make_ref_id,
|
||||
normalize_row,
|
||||
)
|
||||
|
||||
|
||||
def _paginate_income(exchange, *, start_ms: int, end_ms: int, max_pages: int = 15) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
cursor = int(start_ms)
|
||||
end = int(end_ms)
|
||||
for _ in range(max_pages):
|
||||
try:
|
||||
if hasattr(exchange, "fapiPrivateGetIncome"):
|
||||
batch = exchange.fapiPrivateGetIncome(
|
||||
{"startTime": cursor, "endTime": end, "limit": 1000}
|
||||
)
|
||||
else:
|
||||
batch = exchange.fetch_ledger(
|
||||
"USDT", cursor, 1000, {"type": "swap", "until": end}
|
||||
)
|
||||
# already unified
|
||||
return batch or []
|
||||
except Exception:
|
||||
break
|
||||
if not batch:
|
||||
break
|
||||
out.extend(batch)
|
||||
if len(batch) < 1000:
|
||||
break
|
||||
last_t = batch[-1].get("time") or batch[-1].get("timestamp")
|
||||
try:
|
||||
last_i = int(float(last_t))
|
||||
except Exception:
|
||||
break
|
||||
if last_i >= end:
|
||||
break
|
||||
cursor = last_i + 1
|
||||
return out
|
||||
|
||||
|
||||
def _income_to_row(raw: dict) -> Optional[dict[str, Any]]:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
# raw fapi income
|
||||
if "income" in raw or "incomeType" in raw:
|
||||
amt = raw.get("income")
|
||||
ts = raw.get("time")
|
||||
ccy = raw.get("asset") or "USDT"
|
||||
raw_type = str(raw.get("incomeType") or "")
|
||||
ref = str(raw.get("tranId") or raw.get("tradeId") or "")
|
||||
return normalize_row(
|
||||
account=ACCOUNT_TRADING,
|
||||
ccy=str(ccy),
|
||||
amount=amt,
|
||||
ts_ms=ts,
|
||||
ref_id=ref or make_ref_id("trading", ccy, ts, amt, raw_type),
|
||||
raw_type=raw_type,
|
||||
symbol=str(raw.get("symbol") or ""),
|
||||
note=str(raw.get("info") or ""),
|
||||
kind=kind_from_raw(raw_type, float(amt) if amt is not None else None),
|
||||
)
|
||||
return from_ccxt_ledger_entry(raw, account=ACCOUNT_TRADING)
|
||||
|
||||
|
||||
def _dep_wd_to_row(entry: dict, *, kind: str) -> Optional[dict[str, Any]]:
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
info = entry.get("info") if isinstance(entry.get("info"), dict) else {}
|
||||
amount = entry.get("amount")
|
||||
ts = entry.get("timestamp") or info.get("insertTime") or info.get("applyTime")
|
||||
ccy = entry.get("currency") or info.get("coin") or "USDT"
|
||||
status = entry.get("status") or info.get("status") or ""
|
||||
ref = str(entry.get("id") or info.get("txId") or info.get("id") or "")
|
||||
amt = amount
|
||||
try:
|
||||
af = float(amount)
|
||||
if kind == "withdraw" and af > 0:
|
||||
af = -af
|
||||
amt = af
|
||||
except Exception:
|
||||
pass
|
||||
return normalize_row(
|
||||
account=ACCOUNT_FUNDING,
|
||||
ccy=str(ccy),
|
||||
amount=amt,
|
||||
ts_ms=ts,
|
||||
ref_id=ref or make_ref_id("funding", kind, ccy, ts, amount),
|
||||
raw_type=kind,
|
||||
note=str(status),
|
||||
kind=kind,
|
||||
)
|
||||
|
||||
|
||||
def _transfer_to_row(entry: dict) -> Optional[dict[str, Any]]:
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
info = entry.get("info") if isinstance(entry.get("info"), dict) else {}
|
||||
amount = entry.get("amount")
|
||||
ts = entry.get("timestamp") or info.get("timestamp")
|
||||
ccy = entry.get("currency") or info.get("asset") or "USDT"
|
||||
ref = str(entry.get("id") or info.get("tranId") or info.get("id") or "")
|
||||
frm = str(entry.get("fromAccount") or info.get("from") or "")
|
||||
to = str(entry.get("toAccount") or info.get("to") or "")
|
||||
try:
|
||||
amt = float(amount)
|
||||
except Exception:
|
||||
return None
|
||||
# 资金侧视角:从资金转出为负,转入为正(粗分)
|
||||
note = f"{frm}->{to}".strip("->")
|
||||
raw_type = "transfer"
|
||||
return normalize_row(
|
||||
account=ACCOUNT_FUNDING,
|
||||
ccy=str(ccy),
|
||||
amount=amt,
|
||||
ts_ms=ts,
|
||||
ref_id=ref or make_ref_id("funding", "transfer", ccy, ts, amt),
|
||||
raw_type=raw_type,
|
||||
note=note,
|
||||
kind=kind_from_raw("transfer", amt),
|
||||
)
|
||||
|
||||
|
||||
def fetch_binance_account_ledger(
|
||||
exchange,
|
||||
*,
|
||||
start_ms: int,
|
||||
end_ms: int,
|
||||
ensure_markets: Optional[Callable[[], None]] = None,
|
||||
) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
errors: list[str] = []
|
||||
rows: list[dict[str, Any]] = []
|
||||
if ensure_markets:
|
||||
try:
|
||||
ensure_markets()
|
||||
except Exception as e:
|
||||
errors.append(f"markets:{e}")
|
||||
|
||||
# 交易账户
|
||||
try:
|
||||
raw = _paginate_income(exchange, start_ms=start_ms, end_ms=end_ms)
|
||||
for e in raw:
|
||||
n = _income_to_row(e)
|
||||
if n and n["ccy"] == "USDT":
|
||||
rows.append(n)
|
||||
except Exception as e:
|
||||
errors.append(f"trading:{e}")
|
||||
|
||||
# 资金账户:充提 + 划转
|
||||
for label, fn, kind in (
|
||||
("deposits", "fetch_deposits", "deposit"),
|
||||
("withdrawals", "fetch_withdrawals", "withdraw"),
|
||||
):
|
||||
try:
|
||||
meth = getattr(exchange, fn, None)
|
||||
if not callable(meth):
|
||||
continue
|
||||
batch = meth("USDT", int(start_ms), 1000, {"until": int(end_ms)}) or []
|
||||
for e in batch:
|
||||
n = _dep_wd_to_row(e, kind=kind)
|
||||
if n:
|
||||
rows.append(n)
|
||||
except Exception as e:
|
||||
errors.append(f"{label}:{e}")
|
||||
|
||||
try:
|
||||
if hasattr(exchange, "fetch_transfers"):
|
||||
batch = (
|
||||
exchange.fetch_transfers("USDT", int(start_ms), 1000, {"until": int(end_ms)})
|
||||
or []
|
||||
)
|
||||
for e in batch:
|
||||
n = _transfer_to_row(e)
|
||||
if n:
|
||||
rows.append(n)
|
||||
except Exception as e:
|
||||
errors.append(f"transfers:{e}")
|
||||
|
||||
return rows, errors
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Gate:资金账户(spot account_book) + 交易账户(futures account_book),USDT."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from lib.account_ledger.account_ledger_normalize import (
|
||||
ACCOUNT_FUNDING,
|
||||
ACCOUNT_TRADING,
|
||||
from_ccxt_ledger_entry,
|
||||
kind_from_raw,
|
||||
make_ref_id,
|
||||
normalize_row,
|
||||
)
|
||||
|
||||
|
||||
def _sec(ms: int) -> int:
|
||||
return max(0, int(int(ms) // 1000))
|
||||
|
||||
|
||||
def _paginate_spot_book(exchange, *, start_ms: int, end_ms: int, max_pages: int = 10) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
# Gate spot account_book: from/to 为秒
|
||||
cursor = _sec(start_ms)
|
||||
end = _sec(end_ms)
|
||||
for _ in range(max_pages):
|
||||
try:
|
||||
batch = exchange.privateSpotGetAccountBook(
|
||||
{
|
||||
"currency": "USDT",
|
||||
"from": cursor,
|
||||
"to": end,
|
||||
"limit": 100,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
break
|
||||
if not batch:
|
||||
break
|
||||
if isinstance(batch, dict):
|
||||
batch = batch.get("data") or batch.get("result") or []
|
||||
if not isinstance(batch, list) or not batch:
|
||||
break
|
||||
out.extend(batch)
|
||||
if len(batch) < 100:
|
||||
break
|
||||
last_t = batch[-1].get("time") or batch[-1].get("create_time")
|
||||
try:
|
||||
last_i = int(float(last_t))
|
||||
except Exception:
|
||||
break
|
||||
# spot 返回秒
|
||||
if last_i > 1e12:
|
||||
last_i = last_i // 1000
|
||||
if last_i >= end:
|
||||
break
|
||||
cursor = last_i + 1
|
||||
return out
|
||||
|
||||
|
||||
def _paginate_swap_book(exchange, *, start_ms: int, end_ms: int, max_pages: int = 10) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
cursor = _sec(start_ms)
|
||||
end = _sec(end_ms)
|
||||
for _ in range(max_pages):
|
||||
try:
|
||||
batch = exchange.privateFuturesGetSettleAccountBook(
|
||||
{
|
||||
"settle": "usdt",
|
||||
"from": cursor,
|
||||
"to": end,
|
||||
"limit": 100,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
break
|
||||
if not batch:
|
||||
break
|
||||
if isinstance(batch, dict):
|
||||
batch = batch.get("data") or batch.get("result") or []
|
||||
if not isinstance(batch, list) or not batch:
|
||||
break
|
||||
out.extend(batch)
|
||||
if len(batch) < 100:
|
||||
break
|
||||
last_t = batch[-1].get("time")
|
||||
try:
|
||||
last_i = int(float(last_t))
|
||||
except Exception:
|
||||
break
|
||||
if last_i > 1e12:
|
||||
last_i = last_i // 1000
|
||||
if last_i >= end:
|
||||
break
|
||||
cursor = last_i + 1
|
||||
return out
|
||||
|
||||
|
||||
def _spot_row(raw: dict) -> Optional[dict[str, Any]]:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
amt = raw.get("change")
|
||||
ts = raw.get("time") or raw.get("create_time")
|
||||
# 秒 → 毫秒
|
||||
try:
|
||||
t = float(ts)
|
||||
if t < 1e12:
|
||||
t = t * 1000.0
|
||||
ts = t
|
||||
except Exception:
|
||||
pass
|
||||
raw_type = str(raw.get("type") or raw.get("change_type") or "")
|
||||
bal = raw.get("balance")
|
||||
ref = str(raw.get("id") or raw.get("txid") or "")
|
||||
return normalize_row(
|
||||
account=ACCOUNT_FUNDING,
|
||||
ccy="USDT",
|
||||
amount=amt,
|
||||
ts_ms=ts,
|
||||
ref_id=ref or make_ref_id("funding", raw_type, ts, amt),
|
||||
raw_type=raw_type,
|
||||
balance_after=bal,
|
||||
note=str(raw.get("text") or ""),
|
||||
kind=kind_from_raw(raw_type, float(amt) if amt is not None else None),
|
||||
)
|
||||
|
||||
|
||||
def _swap_row(raw: dict) -> Optional[dict[str, Any]]:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
# futures account_book: change, balance, type, text, time, contract...
|
||||
amt = raw.get("change")
|
||||
ts = raw.get("time")
|
||||
try:
|
||||
t = float(ts)
|
||||
if t < 1e12:
|
||||
t = t * 1000.0
|
||||
ts = t
|
||||
except Exception:
|
||||
pass
|
||||
raw_type = str(raw.get("type") or "")
|
||||
bal = raw.get("balance")
|
||||
ref = str(raw.get("id") or "")
|
||||
return normalize_row(
|
||||
account=ACCOUNT_TRADING,
|
||||
ccy="USDT",
|
||||
amount=amt,
|
||||
ts_ms=ts,
|
||||
ref_id=ref or make_ref_id("trading", raw_type, ts, amt, raw.get("contract")),
|
||||
raw_type=raw_type,
|
||||
balance_after=bal,
|
||||
symbol=str(raw.get("contract") or ""),
|
||||
note=str(raw.get("text") or ""),
|
||||
kind=kind_from_raw(raw_type, float(amt) if amt is not None else None),
|
||||
)
|
||||
|
||||
|
||||
def fetch_gate_account_ledger(
|
||||
exchange,
|
||||
*,
|
||||
start_ms: int,
|
||||
end_ms: int,
|
||||
ensure_markets: Optional[Callable[[], None]] = None,
|
||||
) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
errors: list[str] = []
|
||||
rows: list[dict[str, Any]] = []
|
||||
if ensure_markets:
|
||||
try:
|
||||
ensure_markets()
|
||||
except Exception as e:
|
||||
errors.append(f"markets:{e}")
|
||||
|
||||
try:
|
||||
for e in _paginate_spot_book(exchange, start_ms=start_ms, end_ms=end_ms):
|
||||
n = _spot_row(e)
|
||||
if n:
|
||||
rows.append(n)
|
||||
except Exception as e:
|
||||
errors.append(f"funding:{e}")
|
||||
# 回退 ccxt fetch_ledger
|
||||
try:
|
||||
batch = exchange.fetch_ledger(
|
||||
"USDT", int(start_ms), 100, {"type": "spot", "until": int(end_ms)}
|
||||
) or []
|
||||
for e in batch:
|
||||
n = from_ccxt_ledger_entry(e, account=ACCOUNT_FUNDING)
|
||||
if n:
|
||||
rows.append(n)
|
||||
except Exception as e2:
|
||||
errors.append(f"funding_fallback:{e2}")
|
||||
|
||||
try:
|
||||
for e in _paginate_swap_book(exchange, start_ms=start_ms, end_ms=end_ms):
|
||||
n = _swap_row(e)
|
||||
if n:
|
||||
rows.append(n)
|
||||
except Exception as e:
|
||||
errors.append(f"trading:{e}")
|
||||
try:
|
||||
batch = exchange.fetch_ledger(
|
||||
"USDT",
|
||||
int(start_ms),
|
||||
100,
|
||||
{"type": "swap", "settle": "usdt", "until": int(end_ms)},
|
||||
) or []
|
||||
for e in batch:
|
||||
n = from_ccxt_ledger_entry(e, account=ACCOUNT_TRADING)
|
||||
if n:
|
||||
rows.append(n)
|
||||
except Exception as e2:
|
||||
errors.append(f"trading_fallback:{e2}")
|
||||
|
||||
return rows, errors
|
||||
@@ -22,6 +22,7 @@ def execute_transfer_usdt(
|
||||
) -> tuple[bool, str, Any]:
|
||||
if amount <= 0:
|
||||
return False, "划转金额必须大于0", None
|
||||
ccy = (transfer_ccy or "USDT").strip().upper() or "USDT"
|
||||
ok_live, reason = ensure_live_ready()
|
||||
if not ok_live:
|
||||
return False, reason, None
|
||||
@@ -31,7 +32,7 @@ def execute_transfer_usdt(
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
resp = exchange.transfer(transfer_ccy, float(amount), from_account, to_account)
|
||||
resp = exchange.transfer(ccy, float(amount), from_account, to_account)
|
||||
return True, "划转成功", resp
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""OKX:资金账户 asset bills + 交易账户 account bills;USDT + USDC."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from lib.account_ledger.account_ledger_normalize import (
|
||||
ACCOUNT_FUNDING,
|
||||
ACCOUNT_TRADING,
|
||||
from_ccxt_ledger_entry,
|
||||
)
|
||||
|
||||
OKX_LEDGER_CCYS = ("USDT", "USDC")
|
||||
|
||||
|
||||
def _fetch_one(
|
||||
exchange,
|
||||
*,
|
||||
code: str,
|
||||
since: int,
|
||||
until: int,
|
||||
method: str,
|
||||
max_pages: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
after = None
|
||||
for _ in range(max_pages):
|
||||
params: dict[str, Any] = {"method": method, "until": int(until)}
|
||||
if after:
|
||||
params["after"] = after
|
||||
try:
|
||||
batch = exchange.fetch_ledger(code, int(since), 100, params) or []
|
||||
except Exception:
|
||||
# archive / bills 窗口差异:失败则停
|
||||
break
|
||||
if not batch:
|
||||
break
|
||||
out.extend(batch)
|
||||
if len(batch) < 100:
|
||||
break
|
||||
# OKX 翻页用 billId
|
||||
last = batch[-1]
|
||||
info = last.get("info") if isinstance(last.get("info"), dict) else {}
|
||||
bid = last.get("id") or info.get("billId")
|
||||
if not bid:
|
||||
break
|
||||
after = str(bid)
|
||||
return out
|
||||
|
||||
|
||||
def fetch_okx_account_ledger(
|
||||
exchange,
|
||||
*,
|
||||
start_ms: int,
|
||||
end_ms: int,
|
||||
ensure_markets: Optional[Callable[[], None]] = None,
|
||||
) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
errors: list[str] = []
|
||||
rows: list[dict[str, Any]] = []
|
||||
if ensure_markets:
|
||||
try:
|
||||
ensure_markets()
|
||||
except Exception as e:
|
||||
errors.append(f"markets:{e}")
|
||||
|
||||
for ccy in OKX_LEDGER_CCYS:
|
||||
# 资金账户
|
||||
try:
|
||||
raw = _fetch_one(
|
||||
exchange,
|
||||
code=ccy,
|
||||
since=start_ms,
|
||||
until=end_ms,
|
||||
method="privateGetAssetBills",
|
||||
)
|
||||
for e in raw:
|
||||
n = from_ccxt_ledger_entry(e, account=ACCOUNT_FUNDING)
|
||||
if n:
|
||||
rows.append(n)
|
||||
except Exception as e:
|
||||
errors.append(f"funding:{ccy}:{e}")
|
||||
|
||||
# 交易账户:近 3 月 archive + 近 7 日 bills(去重靠 upsert)
|
||||
for method in ("privateGetAccountBillsArchive", "privateGetAccountBills"):
|
||||
try:
|
||||
raw = _fetch_one(
|
||||
exchange,
|
||||
code=ccy,
|
||||
since=start_ms,
|
||||
until=end_ms,
|
||||
method=method,
|
||||
)
|
||||
for e in raw:
|
||||
n = from_ccxt_ledger_entry(e, account=ACCOUNT_TRADING)
|
||||
if n:
|
||||
rows.append(n)
|
||||
except Exception as e:
|
||||
errors.append(f"trading:{ccy}:{method}:{e}")
|
||||
|
||||
return rows, errors
|
||||
+321
-121
@@ -13,24 +13,19 @@ import ccxt
|
||||
from lib.options.options_pricing_lib import (
|
||||
expiry_breakeven_from_ask,
|
||||
idx_distance_to_be,
|
||||
intrinsic_px_per_unit,
|
||||
is_shallow_itm,
|
||||
option_moneyness,
|
||||
option_moneyness_label,
|
||||
strike_distance_to_be,
|
||||
)
|
||||
|
||||
_OKX_OPTION_ERR_ZH: dict[str, str] = {
|
||||
"51008": "资金账户 USDT 可用余额不足",
|
||||
"51008": "可用余额或保证金不足(币本位请确认交易账户 ETH/BTC 足够;USDC 模式请确认 USDC 足够)",
|
||||
"51018": "期权账户不能持有净空头头寸",
|
||||
"51019": "期权买入须使用逐仓模式(全仓模式下不能持有多头净头寸)",
|
||||
}
|
||||
|
||||
_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None}
|
||||
|
||||
|
||||
def invalidate_options_balance_cache() -> None:
|
||||
_OPTIONS_BALANCE_CACHE["updated_at"] = 0.0
|
||||
_OPTIONS_BALANCE_CACHE["data"] = None
|
||||
|
||||
|
||||
def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str:
|
||||
row: dict[str, Any] | None = None
|
||||
@@ -51,10 +46,25 @@ def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None)
|
||||
pass
|
||||
if row:
|
||||
code = str(row.get("sCode") or "")
|
||||
msg = str(row.get("sMsg") or "").strip()
|
||||
low = msg.lower()
|
||||
if code == "51008":
|
||||
# 勿写死「资金账户 USDT」:USDC 模式常因交易户 USDC 不足;币本位则是标的币不足
|
||||
if "usdc" in low:
|
||||
return "交易账户 USDC 可用余额不足"
|
||||
if "usdt" in low:
|
||||
return "USDT 可用余额不足"
|
||||
try:
|
||||
from lib.options.options_margin_mode_lib import is_coin_margin_mode
|
||||
|
||||
if is_coin_margin_mode():
|
||||
return "可用余额或保证金不足(币本位请确认交易账户 ETH/BTC 足够,或减少张数)"
|
||||
except Exception:
|
||||
pass
|
||||
return _OKX_OPTION_ERR_ZH["51008"]
|
||||
zh = _OKX_OPTION_ERR_ZH.get(code)
|
||||
if zh:
|
||||
return zh
|
||||
msg = str(row.get("sMsg") or "").strip()
|
||||
if msg:
|
||||
return msg
|
||||
if exc is not None:
|
||||
@@ -65,6 +75,28 @@ def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None)
|
||||
return "下单失败"
|
||||
|
||||
|
||||
_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None}
|
||||
|
||||
# public/instruments 全族缓存:合约列表变化慢,限频时用旧数据保活
|
||||
_OPTION_INSTRUMENTS_CACHE: dict[str, dict[str, Any]] = {}
|
||||
_OPTION_INSTRUMENTS_CACHE_LOCK = threading.Lock()
|
||||
_OPTION_INSTRUMENTS_CACHE_TTL = 90.0
|
||||
_OPTION_INSTRUMENTS_STALE_MAX = 600.0
|
||||
|
||||
|
||||
def invalidate_options_balance_cache() -> None:
|
||||
_OPTIONS_BALANCE_CACHE["updated_at"] = 0.0
|
||||
_OPTIONS_BALANCE_CACHE["data"] = None
|
||||
|
||||
|
||||
def invalidate_option_instruments_cache(inst_family: str | None = None) -> None:
|
||||
with _OPTION_INSTRUMENTS_CACHE_LOCK:
|
||||
if inst_family:
|
||||
_OPTION_INSTRUMENTS_CACHE.pop(str(inst_family), None)
|
||||
else:
|
||||
_OPTION_INSTRUMENTS_CACHE.clear()
|
||||
|
||||
|
||||
def td_mode_for_option_buy(configured: str | None = None) -> str:
|
||||
"""OKX 买入期权(多头)必须使用逐仓."""
|
||||
mode = (configured or "isolated").strip().lower()
|
||||
@@ -72,16 +104,22 @@ def td_mode_for_option_buy(configured: str | None = None) -> str:
|
||||
|
||||
|
||||
def create_options_exchange(
|
||||
api_key: str,
|
||||
api_secret: str,
|
||||
passphrase: str,
|
||||
api_key: str = "",
|
||||
api_secret: str = "",
|
||||
passphrase: str = "",
|
||||
proxies: dict[str, str] | None = None,
|
||||
) -> ccxt.okx:
|
||||
"""创建 option 客户端.未传密钥时读 OKX_API_*(与永续同源)."""
|
||||
import os
|
||||
|
||||
key = (api_key or os.getenv("OKX_API_KEY") or "").strip()
|
||||
secret = (api_secret or os.getenv("OKX_API_SECRET") or "").strip()
|
||||
password = (passphrase or os.getenv("OKX_API_PASSPHRASE") or "").strip()
|
||||
ex = ccxt.okx(
|
||||
{
|
||||
"apiKey": api_key,
|
||||
"secret": api_secret,
|
||||
"password": passphrase,
|
||||
"apiKey": key,
|
||||
"secret": secret,
|
||||
"password": password,
|
||||
"enableRateLimit": True,
|
||||
"options": {"defaultType": "option"},
|
||||
}
|
||||
@@ -135,6 +173,21 @@ def format_usdc_amount(v: float | None) -> str | None:
|
||||
return f"{float(v):.2f}"
|
||||
|
||||
|
||||
def format_premium_amount(v: float | None, *, ccy: str | None = "USDC") -> str | None:
|
||||
"""权利金/回收金额文案:USDC 2 位;币本位 ETH/BTC 最多 8 位去尾零."""
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
n = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
unit = (ccy or "USDC").strip().upper() or "USDC"
|
||||
if unit in ("ETH", "BTC"):
|
||||
txt = f"{n:.8f}".rstrip("0").rstrip(".")
|
||||
return txt or "0"
|
||||
return f"{n:.2f}"
|
||||
|
||||
|
||||
def is_option_full_close_history(raw: dict[str, Any]) -> bool:
|
||||
"""仅保留 OKX 历史仓位中的「全部平仓/强平/ADL 全平」记录,排除部分平仓."""
|
||||
close_type = str(raw.get("type") or "").strip()
|
||||
@@ -152,7 +205,10 @@ def option_history_row_key(
|
||||
pos_id = (pos_id or "").strip()
|
||||
if source == "live":
|
||||
return f"live:{inst_id}:{pos_id or close_ms or '0'}"
|
||||
# OKX 可能对同合约多次开平复用 posId,必须带上平仓时间区分
|
||||
if pos_id:
|
||||
if close_ms:
|
||||
return f"ex:{pos_id}:{int(close_ms)}"
|
||||
return f"ex:{pos_id}"
|
||||
return f"ex:{inst_id}:{close_ms or 0}"
|
||||
|
||||
@@ -197,15 +253,6 @@ def tick_sz_and_ct_mult(
|
||||
return tick_sz, ct_mult or 0.01
|
||||
|
||||
|
||||
def _intrinsic_px_per_unit(opt_type: str, strike: float, index_px: float) -> float | None:
|
||||
o = (opt_type or "").upper()
|
||||
if o == "C" and index_px > strike:
|
||||
return float(index_px) - float(strike)
|
||||
if o == "P" and index_px < strike:
|
||||
return float(strike) - float(index_px)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_chain_quote(
|
||||
*,
|
||||
ticker: dict[str, Any],
|
||||
@@ -213,6 +260,7 @@ def _resolve_chain_quote(
|
||||
opt_type: str,
|
||||
strike: float,
|
||||
index_px: float,
|
||||
inst_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""链列表报价:卖一缺失时用标记价/内在价值估算(深度实值常见无卖一)."""
|
||||
tick_sz = meta.get("tickSz")
|
||||
@@ -222,12 +270,13 @@ def _resolve_chain_quote(
|
||||
ask_sz = _safe_float(ticker.get("askSz"))
|
||||
bid_sz = _safe_float(ticker.get("bidSz"))
|
||||
ask_estimated = False
|
||||
iid = (inst_id or str(meta.get("instId") or "")).strip()
|
||||
|
||||
if ask is None and mark is not None and mark > 0:
|
||||
ask = round_option_px(mark, tick_sz, "buy")
|
||||
ask_estimated = True
|
||||
if ask is None:
|
||||
intrinsic = _intrinsic_px_per_unit(opt_type, strike, index_px)
|
||||
intrinsic = intrinsic_px_per_unit(opt_type, strike, index_px, inst_id=iid or None)
|
||||
if intrinsic is not None and intrinsic > 0:
|
||||
ask = round_option_px(intrinsic, tick_sz, "buy")
|
||||
ask_estimated = True
|
||||
@@ -235,7 +284,7 @@ def _resolve_chain_quote(
|
||||
if bid is None and mark is not None and mark > 0:
|
||||
bid = round_option_px(mark, tick_sz, "sell")
|
||||
if bid is None:
|
||||
intrinsic = _intrinsic_px_per_unit(opt_type, strike, index_px)
|
||||
intrinsic = intrinsic_px_per_unit(opt_type, strike, index_px, inst_id=iid or None)
|
||||
if intrinsic is not None and intrinsic > 0:
|
||||
bid = round_option_px(intrinsic, tick_sz, "sell")
|
||||
|
||||
@@ -398,25 +447,31 @@ def fetch_option_instrument_meta(ex: ccxt.okx, inst_id: str) -> dict[str, Any] |
|
||||
family = inst_family_from_inst_id(inst_id)
|
||||
if not family:
|
||||
return None
|
||||
# 优先从全族缓存取,避免每选一腿再打 instruments
|
||||
try:
|
||||
cached_rows = fetch_option_instruments(ex, family, allow_stale=True)
|
||||
for r in cached_rows:
|
||||
if isinstance(r, dict) and str(r.get("instId")) == inst_id:
|
||||
return r
|
||||
except Exception:
|
||||
pass
|
||||
last_err: BaseException | None = None
|
||||
for attempt in range(3):
|
||||
for attempt in range(2):
|
||||
try:
|
||||
rows = ex.public_get_public_instruments(
|
||||
{"instType": "OPTION", "instFamily": family, "instId": inst_id}
|
||||
).get("data") or []
|
||||
if rows and isinstance(rows[0], dict):
|
||||
return rows[0]
|
||||
rows = ex.public_get_public_instruments(
|
||||
{"instType": "OPTION", "instFamily": family}
|
||||
).get("data") or []
|
||||
rows = fetch_option_instruments(ex, family, allow_stale=True)
|
||||
for r in rows:
|
||||
if isinstance(r, dict) and str(r.get("instId")) == inst_id:
|
||||
return r
|
||||
return None
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if _is_okx_rate_limit(e) and attempt < 2:
|
||||
time.sleep(0.45 * (attempt + 1))
|
||||
if _is_okx_rate_limit(e) and attempt < 1:
|
||||
time.sleep(1.2)
|
||||
continue
|
||||
break
|
||||
if last_err is not None and _is_okx_rate_limit(last_err):
|
||||
@@ -471,8 +526,8 @@ def fetch_account_balances_by_type(
|
||||
ex: ccxt.okx,
|
||||
account_type: str,
|
||||
) -> tuple[dict[str, float | None], dict[str, float | None]]:
|
||||
out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None}
|
||||
avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None}
|
||||
out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
|
||||
avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
|
||||
try:
|
||||
bal = ex.fetch_balance(params={"type": account_type})
|
||||
for c in out:
|
||||
@@ -487,8 +542,8 @@ def fetch_funding_balances_via_asset_api(
|
||||
ex: ccxt.okx,
|
||||
) -> tuple[dict[str, float | None], dict[str, float | None]]:
|
||||
"""OKX 资金账户余额(GET /api/v5/asset/balances),比 ccxt fetch_balance 更准确."""
|
||||
out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None}
|
||||
avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None}
|
||||
out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
|
||||
avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
|
||||
try:
|
||||
resp = ex.private_get_asset_balances({})
|
||||
for row in (resp or {}).get("data") or []:
|
||||
@@ -571,24 +626,34 @@ def fetch_options_balances(
|
||||
funding = _merge_balance_maps(funding, asset_funding)
|
||||
funding_avail = _merge_balance_maps(funding_avail, asset_funding_avail)
|
||||
trading, trading_avail = fetch_account_balances_by_type(ex, "trading")
|
||||
if trading.get("USDC") is None:
|
||||
# OKX 统一账户:option 客户端拉 type=trading 常缺 USDT/币;用 swap 补齐缺失项
|
||||
if any(trading.get(c) is None for c in ("USDT", "USDC", "ETH", "BTC")):
|
||||
swap_bal, swap_avail = fetch_account_balances_by_type(ex, "swap")
|
||||
if swap_bal.get("USDC") is not None:
|
||||
trading["USDC"] = swap_bal["USDC"]
|
||||
if trading_avail.get("USDC") is None and swap_avail.get("USDC") is not None:
|
||||
trading_avail["USDC"] = swap_avail["USDC"]
|
||||
for ccy in ("USDT", "USDC", "USDG", "ETH", "BTC"):
|
||||
if trading.get(ccy) is None and swap_bal.get(ccy) is not None:
|
||||
trading[ccy] = swap_bal[ccy]
|
||||
if trading_avail.get(ccy) is None and swap_avail.get(ccy) is not None:
|
||||
trading_avail[ccy] = swap_avail[ccy]
|
||||
result = {
|
||||
"scope": "main",
|
||||
"funding_usdt": funding.get("USDT"),
|
||||
"funding_usdc": funding.get("USDC"),
|
||||
"funding_usdg": funding.get("USDG"),
|
||||
"funding_eth": funding.get("ETH"),
|
||||
"funding_btc": funding.get("BTC"),
|
||||
"funding_usdt_avail": funding_avail.get("USDT"),
|
||||
"funding_usdc_avail": funding_avail.get("USDC"),
|
||||
"funding_eth_avail": funding_avail.get("ETH"),
|
||||
"funding_btc_avail": funding_avail.get("BTC"),
|
||||
"trading_usdt": trading.get("USDT"),
|
||||
"trading_usdc": trading.get("USDC"),
|
||||
"trading_usdg": trading.get("USDG"),
|
||||
"trading_eth": trading.get("ETH"),
|
||||
"trading_btc": trading.get("BTC"),
|
||||
"trading_usdt_avail": trading_avail.get("USDT"),
|
||||
"trading_usdc_avail": trading_avail.get("USDC"),
|
||||
"trading_eth_avail": trading_avail.get("ETH"),
|
||||
"trading_btc_avail": trading_avail.get("BTC"),
|
||||
}
|
||||
_OPTIONS_BALANCE_CACHE["updated_at"] = now
|
||||
_OPTIONS_BALANCE_CACHE["data"] = result
|
||||
@@ -600,23 +665,67 @@ def options_header_balances(
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> tuple[float | None, float | None, float | None, float | None]:
|
||||
"""顶栏四格:交易 USDC/USDT,资金 USDC/USDT(单次拉取 + 缓存)."""
|
||||
"""顶栏期权两格用 USDC;顺带返回同账户 USDT(调用方勿再计入总资金,避免与永续栏重复).
|
||||
|
||||
返回:(trading_usdc, funding_usdc, funding_usdt, trading_usdt)
|
||||
"""
|
||||
pack = options_header_balance_pack(ex, force=force)
|
||||
return (
|
||||
pack.get("trading_usdc"),
|
||||
pack.get("funding_usdc"),
|
||||
pack.get("funding_usdt"),
|
||||
pack.get("trading_usdt"),
|
||||
)
|
||||
|
||||
|
||||
def options_header_balance_pack(
|
||||
ex: ccxt.okx,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""顶栏/快照用期权资金包(含币本位 ETH/BTC)."""
|
||||
import os
|
||||
|
||||
bal = fetch_options_balances(ex, force=force)
|
||||
|
||||
def _round(v: Any) -> float | None:
|
||||
def _round(v: Any, nd: int = 2) -> float | None:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return round(float(v), 2)
|
||||
return round(float(v), nd)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return (
|
||||
_round(bal.get("trading_usdc")),
|
||||
_round(bal.get("funding_usdc")),
|
||||
_round(bal.get("funding_usdt")),
|
||||
_round(bal.get("trading_usdt")),
|
||||
)
|
||||
def _round_coin(v: Any) -> float | None:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return round(float(v), 8)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
try:
|
||||
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
|
||||
|
||||
margin_mode = normalize_options_margin_mode()
|
||||
except Exception:
|
||||
margin_mode = "usdc"
|
||||
underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
|
||||
coin_key = "btc" if underly == "BTC" else "eth"
|
||||
return {
|
||||
"trading_usdc": _round(bal.get("trading_usdc")),
|
||||
"funding_usdc": _round(bal.get("funding_usdc")),
|
||||
"funding_usdt": _round(bal.get("funding_usdt")),
|
||||
"trading_usdt": _round(bal.get("trading_usdt")),
|
||||
"funding_eth": _round_coin(bal.get("funding_eth")),
|
||||
"trading_eth": _round_coin(bal.get("trading_eth")),
|
||||
"funding_btc": _round_coin(bal.get("funding_btc")),
|
||||
"trading_btc": _round_coin(bal.get("trading_btc")),
|
||||
"options_margin_mode": margin_mode,
|
||||
"options_underly": underly,
|
||||
"funding_coin": _round_coin(bal.get(f"funding_{coin_key}")),
|
||||
"trading_coin": _round_coin(bal.get(f"trading_{coin_key}")),
|
||||
}
|
||||
|
||||
|
||||
def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None:
|
||||
@@ -633,11 +742,42 @@ def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None:
|
||||
def fetch_option_instruments(
|
||||
ex: ccxt.okx,
|
||||
inst_family: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
allow_stale: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = ex.public_get_public_instruments(
|
||||
{"instType": "OPTION", "instFamily": inst_family}
|
||||
).get("data") or []
|
||||
return [r for r in rows if isinstance(r, dict) and r.get("state") == "live"]
|
||||
"""拉取 OPTION instruments;进程内缓存,50011 时回退旧列表."""
|
||||
family = str(inst_family or "").strip()
|
||||
if not family:
|
||||
return []
|
||||
now = time.time()
|
||||
with _OPTION_INSTRUMENTS_CACHE_LOCK:
|
||||
entry = _OPTION_INSTRUMENTS_CACHE.get(family)
|
||||
if (
|
||||
not force
|
||||
and entry is not None
|
||||
and entry.get("rows") is not None
|
||||
and now - float(entry.get("updated_at") or 0) < _OPTION_INSTRUMENTS_CACHE_TTL
|
||||
):
|
||||
return list(entry["rows"])
|
||||
|
||||
try:
|
||||
rows = ex.public_get_public_instruments(
|
||||
{"instType": "OPTION", "instFamily": family}
|
||||
).get("data") or []
|
||||
live = [r for r in rows if isinstance(r, dict) and r.get("state") == "live"]
|
||||
with _OPTION_INSTRUMENTS_CACHE_LOCK:
|
||||
_OPTION_INSTRUMENTS_CACHE[family] = {"updated_at": now, "rows": live}
|
||||
return list(live)
|
||||
except Exception as e:
|
||||
if allow_stale:
|
||||
with _OPTION_INSTRUMENTS_CACHE_LOCK:
|
||||
entry = _OPTION_INSTRUMENTS_CACHE.get(family)
|
||||
if entry is not None and entry.get("rows") is not None:
|
||||
age = now - float(entry.get("updated_at") or 0)
|
||||
if age <= _OPTION_INSTRUMENTS_STALE_MAX:
|
||||
return list(entry["rows"])
|
||||
raise
|
||||
|
||||
|
||||
def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]:
|
||||
@@ -662,31 +802,46 @@ def build_option_chain(
|
||||
itm_only: bool = True,
|
||||
itm_max_dist_usd: float = 30.0,
|
||||
index_px: float | None = None,
|
||||
margin_mode: str | None = None,
|
||||
inst_family: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
u = (underlying or "ETH").upper()
|
||||
family = f"{u}-USD_UM"
|
||||
if inst_family:
|
||||
family = str(inst_family).strip()
|
||||
else:
|
||||
try:
|
||||
from lib.options.options_margin_mode_lib import inst_family_for_underlying
|
||||
|
||||
family = inst_family_for_underlying(u, margin_mode=margin_mode)
|
||||
except Exception:
|
||||
family = f"{u}-USD_UM"
|
||||
uly = f"{u}-USD"
|
||||
idx = index_px if index_px is not None else fetch_index_price(ex, uly)
|
||||
chain_margin = "usdc" if "_UM" in family.upper() else "coin"
|
||||
now_ms = time.time() * 1000
|
||||
max_ms = now_ms + max_dte_days * 86400 * 1000
|
||||
instruments_err = ""
|
||||
instruments: list[dict[str, Any]] = []
|
||||
for attempt in range(2):
|
||||
try:
|
||||
instruments = fetch_option_instruments(ex, family)
|
||||
instruments_err = ""
|
||||
if instruments:
|
||||
break
|
||||
try:
|
||||
instruments = fetch_option_instruments(ex, family)
|
||||
if not instruments:
|
||||
# 空列表可能是瞬时空;短退避后强制再拉一次(非 50011)
|
||||
time.sleep(0.5)
|
||||
instruments = fetch_option_instruments(ex, family, force=True)
|
||||
if not instruments:
|
||||
instruments_err = "期权合约列表为空"
|
||||
except Exception as e:
|
||||
instruments = []
|
||||
instruments_err = str(e) or e.__class__.__name__
|
||||
if attempt == 0:
|
||||
time.sleep(0.35)
|
||||
continue
|
||||
break
|
||||
if attempt == 0 and not instruments:
|
||||
time.sleep(0.35)
|
||||
except Exception as e:
|
||||
instruments = []
|
||||
instruments_err = str(e) or e.__class__.__name__
|
||||
# 限频:再等一下用 stale/缓存,不要连打
|
||||
if _is_okx_rate_limit(e):
|
||||
time.sleep(1.5)
|
||||
try:
|
||||
instruments = fetch_option_instruments(ex, family, allow_stale=True)
|
||||
if instruments:
|
||||
instruments_err = ""
|
||||
except Exception as e2:
|
||||
instruments_err = str(e2) or e2.__class__.__name__
|
||||
tickers = fetch_option_tickers(ex, family)
|
||||
expiries: dict[str, list[dict[str, Any]]] = {}
|
||||
skipped_no_index = 0
|
||||
@@ -719,6 +874,7 @@ def build_option_chain(
|
||||
opt_type=opt_type,
|
||||
strike=strike,
|
||||
index_px=idx,
|
||||
inst_id=inst_id,
|
||||
)
|
||||
ask = q["ask"]
|
||||
bid = q["bid"]
|
||||
@@ -730,6 +886,8 @@ def build_option_chain(
|
||||
strike=strike,
|
||||
ask_px=ask,
|
||||
mark_px=mark,
|
||||
inst_id=inst_id,
|
||||
margin_mode=chain_margin,
|
||||
)
|
||||
mny = option_moneyness(opt_type=opt_type, strike=strike, index_px=idx)
|
||||
exp_key = str(exp_ms)
|
||||
@@ -746,7 +904,7 @@ def build_option_chain(
|
||||
"mark_px": mark,
|
||||
"ask_estimated": q["ask_estimated"],
|
||||
"expiry_be_px": expiry_be,
|
||||
"dist_expiry_be": idx_distance_to_be(idx, expiry_be),
|
||||
"dist_expiry_be": strike_distance_to_be(strike, expiry_be, opt_type=opt_type),
|
||||
"moneyness": mny,
|
||||
"moneyness_label": option_moneyness_label(mny),
|
||||
"ct_mult": _safe_float(meta.get("ctMult")) or 0.01,
|
||||
@@ -762,6 +920,8 @@ def build_option_chain(
|
||||
"underlying": u,
|
||||
"index_px": idx,
|
||||
"inst_family": family,
|
||||
"margin_mode": "usdc" if "_UM" in family.upper() else "coin",
|
||||
"premium_ccy": "USDC" if "_UM" in family.upper() else u,
|
||||
"expiries": exp_list,
|
||||
"instruments_count": len(instruments),
|
||||
}
|
||||
@@ -872,6 +1032,7 @@ def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]:
|
||||
strike=strike,
|
||||
ask_px=book_ask if can_open else None,
|
||||
mark_px=mark,
|
||||
inst_id=inst_id,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
@@ -890,7 +1051,7 @@ def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]:
|
||||
"open_block_msg": "" if can_open else open_block_msg,
|
||||
"index_px": idx,
|
||||
"expiry_be_px": expiry_be,
|
||||
"dist_expiry_be": idx_distance_to_be(idx, expiry_be),
|
||||
"dist_expiry_be": strike_distance_to_be(strike, expiry_be, opt_type=str(opt_type or "")),
|
||||
"ct_mult": _safe_float(meta.get("ctMult")) or 0.01,
|
||||
"min_sz": int(_safe_float(meta.get("minSz")) or 1),
|
||||
"tick_sz": tick_sz,
|
||||
@@ -1300,6 +1461,15 @@ def format_option_history_row(
|
||||
ctime = _safe_float(raw.get("cTime"))
|
||||
opt_type, strike = option_fields_from_inst_id(inst_id)
|
||||
uly = str(raw.get("uly") or inst_id.split("-")[0] or "").replace("-USD_UM", "").replace("-USD", "")
|
||||
try:
|
||||
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
|
||||
|
||||
row_mode = margin_mode_from_inst_id(inst_id) if inst_id else "usdc"
|
||||
premium_ccy = premium_ccy_for_mode(row_mode, uly or "ETH")
|
||||
except Exception:
|
||||
row_mode = "usdc"
|
||||
premium_ccy = "USDC"
|
||||
idx_px = _safe_float(raw.get("idxPx") or raw.get("idx_px"))
|
||||
if close_type in ("3", "4"):
|
||||
status_label = "强平"
|
||||
else:
|
||||
@@ -1321,12 +1491,17 @@ def format_option_history_row(
|
||||
"strike": strike,
|
||||
"sheets": sheets_i,
|
||||
"eth_amount": eth_amount,
|
||||
"ct_mult": ct_mult,
|
||||
"open_avg_px": open_avg,
|
||||
"open_avg_px_fmt": format_option_px(open_avg, tick_sz) if open_avg is not None else None,
|
||||
"close_avg_px": close_avg,
|
||||
"close_avg_px_fmt": format_option_px(close_avg, tick_sz) if close_avg is not None else None,
|
||||
"premium_paid": premium_paid,
|
||||
"premium_paid_fmt": format_usdc_amount(premium_paid),
|
||||
"premium_paid_fmt": format_premium_amount(premium_paid, ccy=premium_ccy),
|
||||
"premium_ccy": premium_ccy,
|
||||
"margin_mode": row_mode,
|
||||
"margin_mode_label": "币本位" if row_mode == "coin" else "USDC",
|
||||
"idx_px": idx_px,
|
||||
"realized_pnl": realized,
|
||||
"pnl_ratio_pct": round(pnl_ratio * 100, 2) if pnl_ratio is not None else None,
|
||||
"status": "closed",
|
||||
@@ -1349,6 +1524,7 @@ def format_live_option_history_row(
|
||||
inst_id = str(row.get("inst_id") or "").strip()
|
||||
pos_id = str((row.get("raw") or {}).get("posId") or "").strip() or None
|
||||
close_ms = open_ms
|
||||
premium_ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
|
||||
return {
|
||||
"source": "live",
|
||||
"history_key": option_history_row_key(
|
||||
@@ -1370,6 +1546,10 @@ def format_live_option_history_row(
|
||||
"close_avg_px_fmt": None,
|
||||
"premium_paid": row.get("premium_paid"),
|
||||
"premium_paid_fmt": row.get("premium_paid_fmt"),
|
||||
"premium_ccy": premium_ccy,
|
||||
"margin_mode": row.get("margin_mode"),
|
||||
"margin_mode_label": row.get("margin_mode_label"),
|
||||
"idx_px": row.get("idx_px"),
|
||||
"realized_pnl": row.get("upl"),
|
||||
"pnl_ratio_pct": row.get("upl_ratio_pct"),
|
||||
"status": "open",
|
||||
@@ -1387,28 +1567,69 @@ def resolve_option_close_from_history(
|
||||
hist_rows: list[dict[str, Any]],
|
||||
*,
|
||||
open_ms: int | None = None,
|
||||
close_ms: int | None = None,
|
||||
sheets: float | int | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""从 positions-history 中选取最近一条有效平仓/结算记录."""
|
||||
best: dict[str, Any] | None = None
|
||||
best_utime = -1
|
||||
"""从 positions-history 选取匹配的平仓记录.
|
||||
|
||||
同合约多次开平时,优先按开仓时间(cTime≈open_ms)对齐,再按平仓时间/张数;
|
||||
无锚点时取开仓后最晚一条(供刚平掉的持仓同步)。
|
||||
"""
|
||||
candidates: list[tuple[int, dict[str, Any]]] = []
|
||||
for row in hist_rows:
|
||||
u_ms = _safe_float(row.get("uTime"))
|
||||
if u_ms is None or u_ms <= 0:
|
||||
continue
|
||||
if open_ms is not None and u_ms < int(open_ms) - 60_000:
|
||||
u_i = int(u_ms)
|
||||
# 本地时间偶发与交易所差整时区时,放宽到 12h,主要靠 cTime/张数精配
|
||||
if open_ms is not None and u_i < int(open_ms) - 12 * 3600_000:
|
||||
continue
|
||||
if u_ms > best_utime:
|
||||
best = row
|
||||
best_utime = int(u_ms)
|
||||
if not best:
|
||||
candidates.append((u_i, row))
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
has_ctime = any(_safe_float(row.get("cTime")) is not None for _, row in candidates)
|
||||
want_sheets = _safe_float(sheets)
|
||||
|
||||
def _score(item: tuple[int, dict[str, Any]]) -> tuple:
|
||||
u_i, row = item
|
||||
c_ms = _safe_float(row.get("cTime"))
|
||||
parts: list[float] = []
|
||||
# 张数优先:同合约多笔时最稳,且不受本地/交易所时区偏差影响
|
||||
if want_sheets is not None:
|
||||
hist_sheets = _safe_float(row.get("closeTotalPos"))
|
||||
if hist_sheets is None:
|
||||
hist_sheets = _safe_float(row.get("openMaxPos"))
|
||||
parts.append(
|
||||
abs(float(hist_sheets) - float(want_sheets))
|
||||
if hist_sheets is not None
|
||||
else 1e12
|
||||
)
|
||||
if open_ms is not None and c_ms is not None:
|
||||
parts.append(float(abs(int(c_ms) - int(open_ms))))
|
||||
if close_ms is not None:
|
||||
parts.append(float(abs(u_i - int(close_ms))))
|
||||
if not parts:
|
||||
parts.append(float(-u_i))
|
||||
# 同距时偏向更晚平仓
|
||||
parts.append(float(-u_i))
|
||||
return tuple(parts)
|
||||
|
||||
if open_ms is None and close_ms is None and want_sheets is None:
|
||||
u_i, best = max(candidates, key=lambda item: item[0])
|
||||
elif open_ms is not None and close_ms is None and want_sheets is None and not has_ctime:
|
||||
# 兼容旧调用:只有 open_ms 时仍取最晚一条
|
||||
u_i, best = max(candidates, key=lambda item: item[0])
|
||||
else:
|
||||
u_i, best = min(candidates, key=_score)
|
||||
|
||||
realized = _safe_float(best.get("realizedPnl"))
|
||||
if realized is None:
|
||||
realized = _safe_float(best.get("pnl"))
|
||||
return {
|
||||
"close_quote": _safe_float(best.get("closeAvgPx")),
|
||||
"realized_pnl": realized,
|
||||
"close_ms": best_utime,
|
||||
"close_ms": u_i,
|
||||
"pos_id": str(best.get("posId") or "").strip() or None,
|
||||
}
|
||||
|
||||
@@ -1562,43 +1783,6 @@ def spot_market_swap_usdt_usdc(
|
||||
return {"ok": False, "msg": _okx_trade_error_message(e)}
|
||||
|
||||
|
||||
def transfer_main_sub_account(
|
||||
ex: ccxt.okx,
|
||||
*,
|
||||
ccy: str,
|
||||
amount: float,
|
||||
sub_acct: str,
|
||||
main_to_sub: bool,
|
||||
from_account: str = "funding",
|
||||
to_account: str = "funding",
|
||||
) -> dict[str, Any]:
|
||||
"""主账户与子账户之间划转(须主账户 API)."""
|
||||
if amount <= 0:
|
||||
return {"ok": False, "msg": "划转金额须大于 0"}
|
||||
sub = (sub_acct or "").strip()
|
||||
if not sub:
|
||||
return {"ok": False, "msg": "未配置子账户名称 OKX_SUB_ACCOUNT_NAME"}
|
||||
from_code = _OKX_ACCT_CODE.get((from_account or "funding").lower(), "6")
|
||||
to_code = _OKX_ACCT_CODE.get((to_account or "funding").lower(), "6")
|
||||
try:
|
||||
resp = ex.private_post_asset_transfer(
|
||||
{
|
||||
"type": "1" if main_to_sub else "2",
|
||||
"ccy": str(ccy).upper(),
|
||||
"amt": str(amount),
|
||||
"from": from_code,
|
||||
"to": to_code,
|
||||
"subAcct": sub,
|
||||
}
|
||||
)
|
||||
data = (resp or {}).get("data") or []
|
||||
if data and str(data[0].get("sCode", "0")) == "0":
|
||||
return {"ok": True, "data": data[0], "raw": resp}
|
||||
return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp}
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": _okx_trade_error_message(e)}
|
||||
|
||||
|
||||
def format_position_row(
|
||||
pos: dict[str, Any],
|
||||
ct_mult: float = 0.01,
|
||||
@@ -1609,6 +1793,7 @@ def format_position_row(
|
||||
close_breakeven_idx,
|
||||
expiry_breakeven_px,
|
||||
idx_distance_to_be,
|
||||
strike_distance_to_be,
|
||||
total_premium,
|
||||
)
|
||||
|
||||
@@ -1626,6 +1811,16 @@ def format_position_row(
|
||||
opt_type = parsed_type
|
||||
if strike is None:
|
||||
strike = parsed_strike
|
||||
try:
|
||||
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
|
||||
|
||||
row_mode = margin_mode_from_inst_id(inst_id) if inst_id else "usdc"
|
||||
underly = (inst_id.split("-")[0] if inst_id else "ETH") or "ETH"
|
||||
premium_ccy = premium_ccy_for_mode(row_mode, underly)
|
||||
except Exception:
|
||||
row_mode = "usdc"
|
||||
underly = (inst_id.split("-")[0] if inst_id else "ETH") or "ETH"
|
||||
premium_ccy = "USDC"
|
||||
eth_amount = round(abs(sheets) * ct_mult, 8)
|
||||
premium_paid = (
|
||||
round(total_premium(avg, eth_amount), 8) if avg is not None and eth_amount > 0 else None
|
||||
@@ -1636,6 +1831,8 @@ def format_position_row(
|
||||
strike=strike,
|
||||
avg_px=avg,
|
||||
be_px_api=_safe_float(pos.get("bePx")),
|
||||
inst_id=inst_id,
|
||||
margin_mode=row_mode,
|
||||
)
|
||||
close_be = close_breakeven_idx(
|
||||
opt_type=str(opt_type or ""),
|
||||
@@ -1655,11 +1852,14 @@ def format_position_row(
|
||||
"mark_px": mark,
|
||||
"avg_px_fmt": format_option_px(avg, tick_sz) if avg is not None else None,
|
||||
"mark_px_fmt": format_option_px(mark, tick_sz) if mark is not None else None,
|
||||
"premium_paid_fmt": format_usdc_amount(premium_paid),
|
||||
"premium_paid_fmt": format_premium_amount(premium_paid, ccy=premium_ccy),
|
||||
"tick_sz": tick_sz,
|
||||
"ct_mult": ct_mult,
|
||||
"idx_px": idx_px,
|
||||
"premium_paid": premium_paid,
|
||||
"margin_mode": row_mode,
|
||||
"premium_ccy": premium_ccy,
|
||||
"underlying": underly,
|
||||
"upl": upl,
|
||||
"upl_ratio_pct": round(upl_ratio * 100, 2) if upl_ratio is not None else None,
|
||||
"exp_time": exp_time_ms,
|
||||
@@ -1669,7 +1869,7 @@ def format_position_row(
|
||||
"avail_pos": _safe_float(pos.get("availPos")),
|
||||
"expiry_be_px": expiry_be,
|
||||
"close_be_px": close_be,
|
||||
"dist_expiry_be": idx_distance_to_be(idx_px, expiry_be),
|
||||
"dist_expiry_be": strike_distance_to_be(strike, expiry_be, opt_type=str(opt_type or "")),
|
||||
"dist_close_be": idx_distance_to_be(idx_px, close_be),
|
||||
"raw": pos,
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ def block_standalone_option_open_msg(conn: Any) -> Optional[str]:
|
||||
if count_active_plans(conn) > 0:
|
||||
return "存在进行中对冲计划,禁止单独开期权(可在 env「对冲与期权互斥门控」关闭)"
|
||||
except Exception:
|
||||
return None
|
||||
return "互斥门控校验失败,暂禁止单独开期权"
|
||||
return None
|
||||
|
||||
|
||||
@@ -77,10 +77,10 @@ def block_hedge_plan_start_msg(
|
||||
try:
|
||||
rows = fetch_positions(exchange) or []
|
||||
except Exception:
|
||||
return None
|
||||
return "获取期权持仓失败,暂禁止启动对冲计划"
|
||||
try:
|
||||
if has_standalone_option_position(conn, rows):
|
||||
return "存在单独期权持仓,禁止启动对冲计划(可在 env「对冲与期权互斥门控」关闭)"
|
||||
except Exception:
|
||||
return None
|
||||
return "互斥门控校验失败,暂禁止启动对冲计划"
|
||||
return None
|
||||
|
||||
@@ -58,6 +58,42 @@ def option_expiry_pnl(
|
||||
return value - float(premium_paid)
|
||||
|
||||
|
||||
def spot_from_expiry_intrinsic_profit(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: float,
|
||||
sheets: float,
|
||||
ct_mult: float,
|
||||
premium_paid: float,
|
||||
profit: float,
|
||||
) -> float | None:
|
||||
"""按到期实值反推现货价:使该腿到期盈亏 ≈ profit.
|
||||
|
||||
到期价值=实值×张数×乘数;盈亏=价值−权利金 → 实值/币=(profit+权利金)/(张数×乘数).
|
||||
Call: spot=K+实值/币; Put: spot=K−实值/币.
|
||||
"""
|
||||
try:
|
||||
k = float(strike)
|
||||
n = float(sheets or 0)
|
||||
ct = float(ct_mult or 0.01)
|
||||
prem = float(premium_paid or 0)
|
||||
pnl = float(profit)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
denom = n * ct
|
||||
if denom <= 0:
|
||||
return None
|
||||
need = (pnl + prem) / denom
|
||||
if need < 0:
|
||||
need = 0.0
|
||||
o = (opt_type or "").strip().upper()
|
||||
if o in ("C", "CALL"):
|
||||
return round(k + need, 2)
|
||||
if o in ("P", "PUT"):
|
||||
return round(k - need, 2)
|
||||
return None
|
||||
|
||||
|
||||
def suggest_contracts_from_notional(
|
||||
*,
|
||||
notional: float,
|
||||
@@ -447,11 +483,16 @@ def build_options_options_preview(
|
||||
target_price: float | None = None,
|
||||
target_price_up: float | None = None,
|
||||
target_price_down: float | None = None,
|
||||
profit_rr: float | None = None,
|
||||
index_px: float,
|
||||
leg_a: dict[str, Any],
|
||||
leg_b: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""期期情景:上破/下破目标价 / 到期现价 / 最大保费损耗."""
|
||||
"""期期情景:盈亏比达标 / 到期现价 / 最大保费损耗.
|
||||
|
||||
新口径优先 profit_rr(盈利金额/总权利金);若未传则兼容旧上/下破目标价.
|
||||
残值按亏损腿本合约权利金的 20% 计.
|
||||
"""
|
||||
|
||||
def _leg_pnl(leg: dict[str, Any], spot: float) -> float:
|
||||
return option_expiry_pnl(
|
||||
@@ -463,15 +504,127 @@ def build_options_options_preview(
|
||||
premium_paid=float(leg.get("premium_paid") or 0),
|
||||
)
|
||||
|
||||
prem_a = float(leg_a.get("premium_paid") or 0)
|
||||
prem_b = float(leg_b.get("premium_paid") or 0)
|
||||
prem = prem_a + prem_b
|
||||
rr = float(profit_rr) if profit_rr is not None else None
|
||||
|
||||
# 新:盈亏比情景(不依赖指数上下破价)
|
||||
if rr is not None and rr > 0:
|
||||
# 盈利腿达 RR:盈利金额 = rr × 总权利金;亏损腿按全亏 / 本合约残值20%回收
|
||||
win_profit = rr * prem
|
||||
a_at_a = win_profit
|
||||
b_at_a_full = -prem_b
|
||||
b_at_a_res = -prem_b * 0.8 # 本合约回收 20%
|
||||
b_at_b = win_profit
|
||||
a_at_b_full = -prem_a
|
||||
a_at_b_res = -prem_a * 0.8
|
||||
|
||||
spot_a = spot_from_expiry_intrinsic_profit(
|
||||
opt_type=str(leg_a.get("opt_type") or ""),
|
||||
strike=float(leg_a["strike"]),
|
||||
sheets=float(leg_a.get("sheets") or 0),
|
||||
ct_mult=float(leg_a.get("ct_mult") or 0.01),
|
||||
premium_paid=prem_a,
|
||||
profit=win_profit,
|
||||
)
|
||||
spot_b = spot_from_expiry_intrinsic_profit(
|
||||
opt_type=str(leg_b.get("opt_type") or ""),
|
||||
strike=float(leg_b["strike"]),
|
||||
sheets=float(leg_b.get("sheets") or 0),
|
||||
ct_mult=float(leg_b.get("ct_mult") or 0.01),
|
||||
premium_paid=prem_b,
|
||||
profit=win_profit,
|
||||
)
|
||||
|
||||
a_flat = _leg_pnl(leg_a, index_px)
|
||||
b_flat = _leg_pnl(leg_b, index_px)
|
||||
flat_total = a_flat + b_flat
|
||||
|
||||
return {
|
||||
"plan_type": "options_options",
|
||||
"premium_paid": round(prem, 6),
|
||||
"profit_rr": rr,
|
||||
"target_price": None,
|
||||
"target_price_up": None,
|
||||
"target_price_down": None,
|
||||
"winner_at_up": "a",
|
||||
"winner_at_down": "b",
|
||||
"winner_at_target": "a",
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "rr_leg_a_full",
|
||||
"label": f"腿A达盈亏比{rr:g}(亏腿全损)",
|
||||
"spot": spot_a,
|
||||
"leg_a_pnl": round(a_at_a, 4),
|
||||
"leg_b_pnl": round(b_at_a_full, 4),
|
||||
"total": round(a_at_a + b_at_a_full, 4),
|
||||
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
|
||||
},
|
||||
{
|
||||
"id": "rr_leg_b_full",
|
||||
"label": f"腿B达盈亏比{rr:g}(亏腿全损)",
|
||||
"spot": spot_b,
|
||||
"leg_a_pnl": round(a_at_b_full, 4),
|
||||
"leg_b_pnl": round(b_at_b, 4),
|
||||
"total": round(a_at_b_full + b_at_b, 4),
|
||||
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
|
||||
},
|
||||
{
|
||||
"id": "rr_leg_a_residual",
|
||||
"label": f"腿A达盈亏比{rr:g}(亏腿残值20%)",
|
||||
"spot": spot_a,
|
||||
"leg_a_pnl": round(a_at_a, 4),
|
||||
"leg_b_pnl": round(b_at_a_res, 4),
|
||||
"total": round(a_at_a + b_at_a_res, 4),
|
||||
"note": "现货同腿A达标反推;亏腿买一回收约本合约权利金20%",
|
||||
},
|
||||
{
|
||||
"id": "expiry_flat",
|
||||
"label": "到期·现价",
|
||||
"spot": index_px,
|
||||
"leg_a_pnl": round(a_flat, 4),
|
||||
"leg_b_pnl": round(b_flat, 4),
|
||||
"total": round(flat_total, 4),
|
||||
"note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值",
|
||||
},
|
||||
{
|
||||
"id": "max_premium_loss",
|
||||
"label": "最大保费损耗",
|
||||
"spot": None,
|
||||
"leg_a_pnl": round(-prem_a, 4),
|
||||
"leg_b_pnl": round(-prem_b, 4),
|
||||
"total": round(-prem, 4),
|
||||
"note": "双腿权利金全部损失",
|
||||
},
|
||||
],
|
||||
"summary": {
|
||||
"profit_rr": rr,
|
||||
"spot_at_rr_a": spot_a,
|
||||
"spot_at_rr_b": spot_b,
|
||||
"at_rr_a_full_total": round(a_at_a + b_at_a_full, 4),
|
||||
"at_rr_b_full_total": round(a_at_b_full + b_at_b, 4),
|
||||
"at_rr_a_residual_total": round(a_at_a + b_at_a_res, 4),
|
||||
"at_target_up_total": round(a_at_a + b_at_a_full, 4),
|
||||
"at_target_down_total": round(a_at_b_full + b_at_b, 4),
|
||||
"at_target_total": round(a_at_a + b_at_a_full, 4),
|
||||
"expiry_flat_total": round(flat_total, 4),
|
||||
"premium_paid": round(prem, 6),
|
||||
"expiry_is_loss": flat_total <= 0,
|
||||
"rr_risk_premium": round(prem, 6),
|
||||
"rr_at_up": round((a_at_a + b_at_a_full) / prem, 4) if prem > 0 else None,
|
||||
"rr_at_down": round((a_at_b_full + b_at_b) / prem, 4) if prem > 0 else None,
|
||||
},
|
||||
}
|
||||
|
||||
# 兼容旧单目标:若未传上下目标则用 target_price 填两边
|
||||
up = target_price_up if target_price_up is not None else target_price
|
||||
down = target_price_down if target_price_down is not None else target_price
|
||||
if up is None or down is None:
|
||||
raise ValueError("缺少上破/下破目标价")
|
||||
raise ValueError("缺少盈亏比或上破/下破目标价")
|
||||
up_f = float(up)
|
||||
down_f = float(down)
|
||||
|
||||
prem = float(leg_a.get("premium_paid") or 0) + float(leg_b.get("premium_paid") or 0)
|
||||
a_up = _leg_pnl(leg_a, up_f)
|
||||
b_up = _leg_pnl(leg_b, up_f)
|
||||
at_up = a_up + b_up
|
||||
@@ -528,8 +681,8 @@ def build_options_options_preview(
|
||||
"id": "max_premium_loss",
|
||||
"label": "最大保费损耗",
|
||||
"spot": None,
|
||||
"leg_a_pnl": round(-float(leg_a.get("premium_paid") or 0), 4),
|
||||
"leg_b_pnl": round(-float(leg_b.get("premium_paid") or 0), 4),
|
||||
"leg_a_pnl": round(-prem_a, 4),
|
||||
"leg_b_pnl": round(-prem_b, 4),
|
||||
"total": round(-prem, 4),
|
||||
"note": "双腿权利金全部损失",
|
||||
},
|
||||
|
||||
@@ -72,8 +72,22 @@ def init_hedge_plan_tables(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
_ensure_column(conn, "hedge_plans", "target_price_up", "REAL")
|
||||
_ensure_column(conn, "hedge_plans", "target_price_down", "REAL")
|
||||
# close_all=盈利腿平后清残腿;hold_expiry=残腿持有至到期(现状)
|
||||
# 期期出场:盈利金额/总权利金(默认2);有值则走盈亏比监控,旧单仍用上/下破价
|
||||
_ensure_column(conn, "hedge_plans", "profit_rr", "REAL")
|
||||
# close_all=残值平(本合约权利金≤20%且有买一);hold_expiry=残腿持有至到期
|
||||
_ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT")
|
||||
# 永期「以期权为主」
|
||||
_ensure_column(conn, "hedge_plans", "option_primary", "INTEGER")
|
||||
_ensure_column(conn, "hedge_plans", "option_target_points", "REAL")
|
||||
_ensure_column(conn, "hedge_plans", "perp_target_points", "REAL")
|
||||
_ensure_column(conn, "hedge_plans", "option_perp_ratio", "REAL")
|
||||
_ensure_column(conn, "hedge_plans", "premium_budget", "REAL")
|
||||
_ensure_column(conn, "hedge_plans", "strike_interval", "REAL")
|
||||
_ensure_column(conn, "hedge_plans", "min_option_hours", "REAL")
|
||||
_ensure_column(conn, "hedge_plans", "option_moneyness", "TEXT")
|
||||
_ensure_column(conn, "hedge_plans", "option_leverage", "REAL")
|
||||
_ensure_column(conn, "hedge_plans", "perp_direction", "TEXT")
|
||||
_ensure_column(conn, "hedge_plan_legs", "ct_mult", "REAL")
|
||||
|
||||
|
||||
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
|
||||
@@ -88,15 +102,19 @@ def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str)
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
|
||||
|
||||
|
||||
_ACTIVE_STATUSES = ("opening", "active", "partial", "watching")
|
||||
|
||||
|
||||
def count_active_plans(conn: sqlite3.Connection, plan_type: Optional[str] = None) -> int:
|
||||
statuses = ",".join(f"'{s}'" for s in _ACTIVE_STATUSES)
|
||||
if plan_type:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ('opening','active','partial') AND plan_type=?",
|
||||
f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses}) AND plan_type=?",
|
||||
(plan_type,),
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ('opening','active','partial')"
|
||||
f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses})"
|
||||
).fetchone()
|
||||
return int((row["c"] if row else 0) or 0)
|
||||
|
||||
@@ -189,7 +207,7 @@ def delete_plan(conn: sqlite3.Connection, plan_id: int) -> dict[str, Any]:
|
||||
if not plan:
|
||||
return {"ok": False, "msg": "计划不存在"}
|
||||
st = str(plan.get("status") or "")
|
||||
if st in ("opening", "active", "partial"):
|
||||
if st in ("opening", "active", "partial", "watching"):
|
||||
return {"ok": False, "msg": "进行中的计划不可删除,请先结束"}
|
||||
conn.execute("DELETE FROM hedge_plan_legs WHERE plan_id=?", (int(plan_id),))
|
||||
conn.execute("DELETE FROM hedge_plans WHERE id=?", (int(plan_id),))
|
||||
@@ -225,14 +243,30 @@ def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]])
|
||||
legs = get_plan_legs(conn, int(p["id"]))
|
||||
row = dict(p)
|
||||
row["legs"] = legs
|
||||
row["contracts_summary"] = legs_contract_summary(legs)
|
||||
summary = legs_contract_summary(legs)
|
||||
if str(p.get("status") or "") == "watching" and (not legs or summary == "—"):
|
||||
money = str(p.get("option_moneyness") or "otm")
|
||||
money_lab = {"itm": "实/平", "atm": "平值", "otm": "虚值"}.get(money, money)
|
||||
parts = [f"盯盘·{money_lab}"]
|
||||
try:
|
||||
if p.get("strike_interval") not in (None, ""):
|
||||
parts.append(f"间隔{float(p.get('strike_interval')):g}")
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
if p.get("option_leverage") not in (None, ""):
|
||||
parts.append(f"杠杆≥{float(p.get('option_leverage')):g}")
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
summary = "·".join(parts)
|
||||
row["contracts_summary"] = summary
|
||||
row["missing_leg"] = missing_leg_role(legs)
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
|
||||
"""返回由进行中「期期对冲」托管的期权目标位,仅供期权页只读展示。
|
||||
"""返回由进行中「期期对冲」托管的期权目标,仅供期权页只读展示。
|
||||
|
||||
这些目标由 hedge_plan_monitor_lib 执行,绝不能写入 options_target_monitors,
|
||||
否则两套监控会同时尝试平掉同一条期权腿。
|
||||
@@ -240,7 +274,7 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down,
|
||||
l.inst_id, l.opt_type
|
||||
p.profit_rr, l.inst_id, l.opt_type
|
||||
FROM hedge_plans p
|
||||
JOIN hedge_plan_legs l ON l.plan_id = p.id
|
||||
WHERE p.plan_type = 'options_options'
|
||||
@@ -256,9 +290,24 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s
|
||||
row = dict(raw)
|
||||
inst_id = str(row.get("inst_id") or "")
|
||||
opt_type = str(row.get("opt_type") or "").upper()
|
||||
if not inst_id or inst_id in out:
|
||||
continue
|
||||
profit_rr = _sf(row.get("profit_rr"))
|
||||
if profit_rr is not None and profit_rr > 0:
|
||||
out[inst_id] = {
|
||||
"plan_id": int(row["plan_id"]),
|
||||
"inst_id": inst_id,
|
||||
"underlying": row.get("underlying"),
|
||||
"opt_type": opt_type,
|
||||
"profit_rr": profit_rr,
|
||||
"target_index": None,
|
||||
"exit_mode": "profit_rr",
|
||||
"managed_by": "hedge_plan",
|
||||
}
|
||||
continue
|
||||
target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down")
|
||||
target_f = _sf(target)
|
||||
if not inst_id or target_f is None or target_f <= 0 or inst_id in out:
|
||||
if target_f is None or target_f <= 0:
|
||||
continue
|
||||
out[inst_id] = {
|
||||
"plan_id": int(row["plan_id"]),
|
||||
@@ -272,6 +321,26 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s
|
||||
return out
|
||||
|
||||
|
||||
def active_hedge_option_inst_ids(conn: sqlite3.Connection) -> set[str]:
|
||||
"""进行中对冲计划托管的期权合约,禁止单独期权页 close/target 拆组."""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT l.inst_id
|
||||
FROM hedge_plan_legs l
|
||||
JOIN hedge_plans p ON p.id = l.plan_id
|
||||
WHERE p.status IN ('opening', 'active', 'partial')
|
||||
AND l.status IN ('open', 'hold_to_expiry')
|
||||
AND l.inst_id IS NOT NULL
|
||||
AND TRIM(l.inst_id) != ''
|
||||
AND (
|
||||
l.leg_role LIKE 'option%'
|
||||
OR (l.opt_type IS NOT NULL AND TRIM(l.opt_type) != '')
|
||||
)
|
||||
"""
|
||||
).fetchall()
|
||||
return {str(r[0]).strip() for r in rows if r and r[0]}
|
||||
|
||||
|
||||
def _sf(v: Any) -> Optional[float]:
|
||||
try:
|
||||
if v is None or v == "":
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""对冲计划虚实值选约与校验.
|
||||
|
||||
永期(perp_options):期权腿仅允许实值或平值(禁虚值).
|
||||
期期(options_options):两腿仅允许平值或虚值(禁实值).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(name) or default)
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
|
||||
|
||||
def itm_max_dist_usd() -> float:
|
||||
"""过深实值上限(USD).优先对冲专用,否则回退期权页."""
|
||||
raw = (os.getenv("HEDGE_PLAN_ITM_MAX_DIST_USD") or "").strip()
|
||||
if raw:
|
||||
try:
|
||||
return max(0.0, float(raw))
|
||||
except ValueError:
|
||||
pass
|
||||
return max(0.0, _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0))
|
||||
|
||||
|
||||
def min_option_hours() -> float:
|
||||
return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_HOURS", 8.0))
|
||||
|
||||
|
||||
def min_option_leverage() -> float:
|
||||
"""指数/卖一 最低杠杆门槛;0=不启用."""
|
||||
return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_LEVERAGE", 0.0))
|
||||
|
||||
|
||||
def _sf(v: Any) -> Optional[float]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def normalize_opt_type(opt_type: Any, inst_id: str = "") -> str:
|
||||
o = str(opt_type or "").strip().upper()
|
||||
if o in ("C", "CALL"):
|
||||
return "C"
|
||||
if o in ("P", "PUT"):
|
||||
return "P"
|
||||
inst = str(inst_id or "").upper()
|
||||
if inst.endswith("-C") or inst.endswith("-CALL"):
|
||||
return "C"
|
||||
if inst.endswith("-P") or inst.endswith("-PUT"):
|
||||
return "P"
|
||||
return ""
|
||||
|
||||
|
||||
def classify_moneyness(*, opt_type: str, strike: float, index_px: float) -> str:
|
||||
"""itm / atm / otm / unknown.与 options_pricing_lib.option_moneyness 同口径."""
|
||||
from lib.options.options_pricing_lib import option_moneyness
|
||||
|
||||
return option_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
|
||||
|
||||
|
||||
def is_itm_or_atm(*, opt_type: str, strike: float, index_px: float) -> bool:
|
||||
"""Call: K<=S(+atm 带);Put: K>=S.用 classify 结果含 atm/itm."""
|
||||
m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
|
||||
if m in ("itm", "atm"):
|
||||
return True
|
||||
# 几何兜底(与 eth_hedge_sim 一致),避免 atm 带边界漏判
|
||||
o = normalize_opt_type(opt_type)
|
||||
k = float(strike)
|
||||
s = float(index_px)
|
||||
if o == "C":
|
||||
return k <= s + 1e-9
|
||||
if o == "P":
|
||||
return k >= s - 1e-9
|
||||
return False
|
||||
|
||||
|
||||
def is_atm_or_otm(*, opt_type: str, strike: float, index_px: float) -> bool:
|
||||
m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
|
||||
if m in ("atm", "otm"):
|
||||
return True
|
||||
o = normalize_opt_type(opt_type)
|
||||
k = float(strike)
|
||||
s = float(index_px)
|
||||
if o == "C":
|
||||
return k >= s - 1e-9 # 平值带内或虚值
|
||||
if o == "P":
|
||||
return k <= s + 1e-9
|
||||
return False
|
||||
|
||||
|
||||
def itm_depth_usd(*, opt_type: str, strike: float, index_px: float) -> float:
|
||||
o = normalize_opt_type(opt_type)
|
||||
k = float(strike)
|
||||
s = float(index_px)
|
||||
if o == "C" and k < s:
|
||||
return s - k
|
||||
if o == "P" and k > s:
|
||||
return k - s
|
||||
return 0.0
|
||||
|
||||
|
||||
def parse_strike_from_inst(inst_id: str) -> Optional[float]:
|
||||
"""从 OKX 合约名解析行权价: ETH-USD-260731-1800-P."""
|
||||
parts = str(inst_id or "").strip().upper().split("-")
|
||||
if len(parts) < 5:
|
||||
return None
|
||||
return _sf(parts[-2])
|
||||
|
||||
|
||||
def pick_itm_or_atm_contract(
|
||||
contracts: list[dict[str, Any]],
|
||||
*,
|
||||
opt_type: str,
|
||||
index_px: float,
|
||||
itm_max_dist: Optional[float] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""在合约列表中选距标的最近的实值/平值腿."""
|
||||
want = normalize_opt_type(opt_type)
|
||||
if not want or index_px <= 0:
|
||||
return None
|
||||
max_dist = itm_max_dist if itm_max_dist is not None else itm_max_dist_usd()
|
||||
cands: list[tuple[float, float, dict[str, Any]]] = []
|
||||
for c in contracts or []:
|
||||
if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want:
|
||||
continue
|
||||
k = _sf(c.get("strike"))
|
||||
if k is None:
|
||||
continue
|
||||
if not is_itm_or_atm(opt_type=want, strike=k, index_px=index_px):
|
||||
continue
|
||||
depth = itm_depth_usd(opt_type=want, strike=k, index_px=index_px)
|
||||
if max_dist > 0 and depth > max_dist:
|
||||
continue
|
||||
cands.append((abs(k - index_px), k, c))
|
||||
if not cands:
|
||||
return None
|
||||
cands.sort(key=lambda x: (x[0], x[1]))
|
||||
return cands[0][2]
|
||||
|
||||
|
||||
def pick_atm_or_otm_contract(
|
||||
contracts: list[dict[str, Any]],
|
||||
*,
|
||||
opt_type: str,
|
||||
index_px: float,
|
||||
prefer: str = "atm",
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""选平值或虚值腿.prefer=atm 取距标的最近;prefer=otm 取最近虚值(不含实值)."""
|
||||
want = normalize_opt_type(opt_type)
|
||||
if not want or index_px <= 0:
|
||||
return None
|
||||
prefer_l = (prefer or "atm").strip().lower()
|
||||
cands: list[tuple[float, float, dict[str, Any]]] = []
|
||||
for c in contracts or []:
|
||||
if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want:
|
||||
continue
|
||||
k = _sf(c.get("strike"))
|
||||
if k is None:
|
||||
continue
|
||||
if not is_atm_or_otm(opt_type=want, strike=k, index_px=index_px):
|
||||
continue
|
||||
m = classify_moneyness(opt_type=want, strike=k, index_px=index_px)
|
||||
if prefer_l == "otm" and m != "otm":
|
||||
continue
|
||||
if prefer_l == "atm" and m == "otm":
|
||||
# 仍可入选,但排序靠后(先 atm)
|
||||
cands.append((1_000_000 + abs(k - index_px), k, c))
|
||||
else:
|
||||
cands.append((abs(k - index_px), k, c))
|
||||
if not cands:
|
||||
return None
|
||||
cands.sort(key=lambda x: (x[0], x[1]))
|
||||
return cands[0][2]
|
||||
|
||||
|
||||
def recommend_oo_legs(
|
||||
contracts: list[dict[str, Any]],
|
||||
*,
|
||||
index_px: float,
|
||||
template: str = "atm_straddle",
|
||||
) -> Optional[tuple[dict[str, Any], dict[str, Any]]]:
|
||||
"""期期推荐两腿.atm_straddle=最近平值 Call+Put;double_otm=最近虚值 Call+Put."""
|
||||
tpl = (template or "atm_straddle").strip().lower()
|
||||
prefer = "otm" if tpl in ("double_otm", "otm_otm", "otm") else "atm"
|
||||
call = pick_atm_or_otm_contract(
|
||||
contracts, opt_type="C", index_px=index_px, prefer=prefer
|
||||
)
|
||||
put = pick_atm_or_otm_contract(
|
||||
contracts, opt_type="P", index_px=index_px, prefer=prefer
|
||||
)
|
||||
if not call or not put:
|
||||
return None
|
||||
if str(call.get("inst_id") or "") == str(put.get("inst_id") or ""):
|
||||
return None
|
||||
return call, put
|
||||
|
||||
|
||||
def validate_po_option_moneyness(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: Any,
|
||||
index_px: Any,
|
||||
ask: Any = None,
|
||||
hours_to_expiry: Any = None,
|
||||
) -> Optional[str]:
|
||||
"""永期保险腿校验;返回错误文案或 None."""
|
||||
o = normalize_opt_type(opt_type)
|
||||
k = _sf(strike)
|
||||
s = _sf(index_px)
|
||||
if o not in ("C", "P"):
|
||||
return "期权类型无效"
|
||||
if k is None or s is None or s <= 0:
|
||||
return "行权价或指数无效,无法校验虚实值"
|
||||
if not is_itm_or_atm(opt_type=o, strike=k, index_px=s):
|
||||
return "永期保险腿须为实值或平值,不可选虚值"
|
||||
max_dist = itm_max_dist_usd()
|
||||
depth = itm_depth_usd(opt_type=o, strike=k, index_px=s)
|
||||
if max_dist > 0 and depth > max_dist:
|
||||
return f"实值过深(距现价 {depth:.1f}U > {max_dist:.0f}U),请换更接近平值的档"
|
||||
min_h = min_option_hours()
|
||||
h = _sf(hours_to_expiry)
|
||||
if min_h > 0 and h is not None and h < min_h:
|
||||
return f"剩余到期约 {h:.1f}h,低于最低 {min_h:.0f}h"
|
||||
min_lev = min_option_leverage()
|
||||
a = _sf(ask)
|
||||
if min_lev > 0 and a is not None and a > 0:
|
||||
lev = s / a
|
||||
if lev < min_lev:
|
||||
return f"期权杠杆 S/ask≈{lev:.0f} 低于门槛 {min_lev:.0f}"
|
||||
return None
|
||||
|
||||
|
||||
def validate_oo_leg_moneyness(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: Any,
|
||||
index_px: Any,
|
||||
role: str = "腿",
|
||||
) -> Optional[str]:
|
||||
o = normalize_opt_type(opt_type)
|
||||
k = _sf(strike)
|
||||
s = _sf(index_px)
|
||||
if o not in ("C", "P"):
|
||||
return f"{role}期权类型无效"
|
||||
if k is None or s is None or s <= 0:
|
||||
return f"{role}行权价或指数无效,无法校验虚实值"
|
||||
m = classify_moneyness(opt_type=o, strike=k, index_px=s)
|
||||
if m == "itm":
|
||||
return f"{role}须为平值或虚值,不可选实值"
|
||||
if not is_atm_or_otm(opt_type=o, strike=k, index_px=s):
|
||||
return f"{role}须为平值或虚值"
|
||||
return None
|
||||
|
||||
|
||||
def validate_oo_legs_moneyness(
|
||||
leg_a: dict[str, Any],
|
||||
leg_b: dict[str, Any],
|
||||
*,
|
||||
index_px: Any,
|
||||
) -> Optional[str]:
|
||||
err = validate_oo_leg_moneyness(
|
||||
opt_type=leg_a.get("opt_type"),
|
||||
strike=leg_a.get("strike"),
|
||||
index_px=index_px,
|
||||
role="腿A",
|
||||
)
|
||||
if err:
|
||||
return err
|
||||
err = validate_oo_leg_moneyness(
|
||||
opt_type=leg_b.get("opt_type"),
|
||||
strike=leg_b.get("strike"),
|
||||
index_px=index_px,
|
||||
role="腿B",
|
||||
)
|
||||
if err:
|
||||
return err
|
||||
return None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -46,13 +46,22 @@ def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[
|
||||
]
|
||||
)
|
||||
else:
|
||||
lines.extend(
|
||||
[
|
||||
f"🎯 上破:{_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
|
||||
f"|下破:{_fmt(plan.get('target_price_down') or plan.get('target_price'))}",
|
||||
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
|
||||
]
|
||||
)
|
||||
rr = plan.get("profit_rr")
|
||||
if rr not in (None, ""):
|
||||
lines.extend(
|
||||
[
|
||||
f"🎯 盈亏比:{_fmt(rr)} (盈利金额/总权利金)",
|
||||
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
|
||||
]
|
||||
)
|
||||
else:
|
||||
lines.extend(
|
||||
[
|
||||
f"🎯 上破:{_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
|
||||
f"|下破:{_fmt(plan.get('target_price_down') or plan.get('target_price'))}",
|
||||
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
|
||||
]
|
||||
)
|
||||
if legs:
|
||||
for leg in legs:
|
||||
role = leg.get("leg_role") or ""
|
||||
@@ -81,8 +90,9 @@ def build_hedge_end_message(plan: dict[str, Any]) -> str:
|
||||
"target_win_leg": "期期已平盈利腿(中间态)",
|
||||
"target_up_win_leg": "期期上破·已平盈利腿",
|
||||
"target_down_win_leg": "期期下破·已平盈利腿",
|
||||
"oo_rest_closing": "期期全平·清残腿中",
|
||||
"oo_rest_closed": "期期全平·两腿已平",
|
||||
"profit_rr_win_leg": "期期盈亏比达标·已平盈利腿",
|
||||
"oo_rest_closing": "期期残值平·清亏损腿中",
|
||||
"oo_rest_closed": "期期残值平·两腿已平",
|
||||
"oo_expiry_loss": "期期到期无盈利·总亏损",
|
||||
"oo_expiry_win": "期期到期仍盈利",
|
||||
"expiry": "到期收口",
|
||||
@@ -152,25 +162,37 @@ def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> boo
|
||||
"target_win_leg",
|
||||
"target_up_win_leg",
|
||||
"target_down_win_leg",
|
||||
"profit_rr_win_leg",
|
||||
"oo_rest_closing",
|
||||
) and (plan.get("status") or "") != "closed":
|
||||
side = "上破" if "up" in str(plan.get("close_reason")) else (
|
||||
"下破" if "down" in str(plan.get("close_reason")) else "目标价"
|
||||
)
|
||||
cr = str(plan.get("close_reason") or "")
|
||||
if "profit_rr" in cr:
|
||||
side = "盈亏比达标"
|
||||
elif "up" in cr:
|
||||
side = "上破"
|
||||
elif "down" in cr:
|
||||
side = "下破"
|
||||
else:
|
||||
side = "目标"
|
||||
mode = (plan.get("oo_close_mode") or "").strip().lower()
|
||||
if mode in ("close_all", "全平"):
|
||||
rest_txt = "另一腿将全平(买一清残腿,无2×门控,失败重试)"
|
||||
if mode in ("close_all", "全平", "残值平"):
|
||||
rest_txt = "另一腿残值平(本合约权利金≤20%且有买一,失败重试)"
|
||||
else:
|
||||
rest_txt = "另一腿到期平(持有至到期结算)"
|
||||
rr = plan.get("profit_rr")
|
||||
if rr not in (None, ""):
|
||||
detail = f"盈亏比 {_fmt(rr)} (盈利金额/总权利金)"
|
||||
else:
|
||||
detail = (
|
||||
f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
|
||||
f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}"
|
||||
)
|
||||
notify_hedge(
|
||||
cfg,
|
||||
build_hedge_alert_message(
|
||||
title=f"期期{side}已平盈利腿 · {rest_txt}",
|
||||
plan_id=plan.get("id"),
|
||||
detail=(
|
||||
f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
|
||||
f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}"
|
||||
),
|
||||
detail=detail,
|
||||
),
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
"""永期「以期权为主」:定仓、方向映射、目标位与净利口径(纯函数为主)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
PREMIUM_EXEC_FACTOR = 0.95
|
||||
DEFAULT_MIN_HOURS = 36.0
|
||||
DEFAULT_STRIKE_INTERVAL = 15.0
|
||||
DEFAULT_PERP_LEVERAGE = 100
|
||||
DEFAULT_OPT_LEVERAGE_ITM_ATM = 100.0
|
||||
DEFAULT_OPT_LEVERAGE_OTM = 200.0
|
||||
DEFAULT_RATIO_ITM_ATM = 2.0
|
||||
DEFAULT_RATIO_OTM = 4.0
|
||||
OTM_LEV_FLOOR = 180.0
|
||||
|
||||
|
||||
def _sf(v: Any) -> Optional[float]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def is_option_primary(body_or_plan: dict[str, Any] | None) -> bool:
|
||||
if not body_or_plan:
|
||||
return False
|
||||
v = body_or_plan.get("option_primary")
|
||||
if v in (True, 1, "1", "true", "yes", "on"):
|
||||
return True
|
||||
try:
|
||||
return int(v or 0) == 1
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def fee_rate() -> float:
|
||||
try:
|
||||
return max(0.0, float(os.getenv("HEDGE_PLAN_FEE_RATE") or os.getenv("OKX_TAKER_FEE") or "0.0005"))
|
||||
except (TypeError, ValueError):
|
||||
return 0.0005
|
||||
|
||||
|
||||
def floor2(v: float) -> float:
|
||||
"""ETH 数量向下取两位小数."""
|
||||
if v <= 0:
|
||||
return 0.0
|
||||
return math.floor(float(v) * 100.0 + 1e-12) / 100.0
|
||||
|
||||
|
||||
def opt_type_for_view(direction: str) -> str:
|
||||
"""看法做多→Call,做空→Put."""
|
||||
return "P" if str(direction or "").strip().lower() == "short" else "C"
|
||||
|
||||
|
||||
def perp_direction_for_view(direction: str) -> str:
|
||||
"""看法做多→永续空,做空→永续多."""
|
||||
return "long" if str(direction or "").strip().lower() == "short" else "short"
|
||||
|
||||
|
||||
def default_opt_leverage(moneyness: str) -> float:
|
||||
m = (moneyness or "").strip().lower()
|
||||
return DEFAULT_OPT_LEVERAGE_OTM if m == "otm" else DEFAULT_OPT_LEVERAGE_ITM_ATM
|
||||
|
||||
|
||||
def default_ratio(moneyness: str) -> float:
|
||||
m = (moneyness or "").strip().lower()
|
||||
return DEFAULT_RATIO_OTM if m == "otm" else DEFAULT_RATIO_ITM_ATM
|
||||
|
||||
|
||||
def effective_min_opt_leverage(moneyness: str, configured: Any) -> float:
|
||||
cfg = _sf(configured)
|
||||
base = cfg if cfg is not None and cfg > 0 else default_opt_leverage(moneyness)
|
||||
if (moneyness or "").strip().lower() == "otm":
|
||||
return max(base, OTM_LEV_FLOOR)
|
||||
return base
|
||||
|
||||
|
||||
def hours_to_expiry_from_ms(exp_ms: Any, *, now_ms: Optional[float] = None) -> Optional[float]:
|
||||
exp = _sf(exp_ms)
|
||||
if exp is None or exp <= 0:
|
||||
return None
|
||||
# OKX exp 多为毫秒
|
||||
if exp < 1e12:
|
||||
exp *= 1000.0
|
||||
now = now_ms if now_ms is not None else __import__("time").time() * 1000.0
|
||||
return (exp - now) / 3600000.0
|
||||
|
||||
|
||||
def target_hit(*, view_side: str, index_px: float, strike: float, points: float) -> bool:
|
||||
"""相对 K 的点数目标:做多 index≥K+N;做空 index≤K−N.点数须 >0."""
|
||||
n = float(points or 0)
|
||||
k = float(strike)
|
||||
s = float(index_px)
|
||||
if n <= 0 or k <= 0 or s <= 0:
|
||||
return False
|
||||
side = str(view_side or "").strip().lower()
|
||||
if side == "short":
|
||||
return s <= (k - n)
|
||||
return s >= (k + n)
|
||||
|
||||
|
||||
def option_bid_liquidity_ok(bid: Any, bid_sz: Any, *, need_sheets: float = 0) -> tuple[bool, str]:
|
||||
b = _sf(bid)
|
||||
if b is None or b <= 0:
|
||||
return False, "暂无买一报价,无法平期权"
|
||||
sz = _sf(bid_sz)
|
||||
if sz is not None and sz <= 0:
|
||||
return False, "买一深度为 0,无法平期权"
|
||||
need = float(need_sheets or 0)
|
||||
if need > 0 and sz is not None and sz + 1e-12 < need:
|
||||
return False, f"买一深度不足(需 {need:g} 张,买一 {sz:g})"
|
||||
return True, ""
|
||||
|
||||
|
||||
def size_from_premium(
|
||||
*,
|
||||
premium_budget: float,
|
||||
ask: float,
|
||||
ct_mult: float,
|
||||
ratio: float,
|
||||
contract_size: float,
|
||||
exec_factor: float = PREMIUM_EXEC_FACTOR,
|
||||
) -> dict[str, Any]:
|
||||
"""权利金×0.95 → ETH 两位小数 → 期权张 → 永续跟比例."""
|
||||
budget = float(premium_budget or 0)
|
||||
a = float(ask or 0)
|
||||
ct = float(ct_mult or 0.01)
|
||||
r = float(ratio or 0)
|
||||
cs = float(contract_size or 0.01)
|
||||
usable = budget * float(exec_factor or PREMIUM_EXEC_FACTOR)
|
||||
if budget <= 0 or a <= 0 or ct <= 0 or r <= 0 or cs <= 0:
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": "定仓参数无效",
|
||||
"usable_premium": round(usable, 4),
|
||||
"eth_qty": 0.0,
|
||||
"sheets": 0.0,
|
||||
"perp_eth": 0.0,
|
||||
"contracts": 0.0,
|
||||
}
|
||||
# ask 为每 1 币权利金;ETH 数量 = usable / ask
|
||||
eth_qty = floor2(usable / a)
|
||||
if eth_qty <= 0:
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": "权利金不足以买入 0.01 ETH 名义期权",
|
||||
"usable_premium": round(usable, 4),
|
||||
"eth_qty": 0.0,
|
||||
"sheets": 0.0,
|
||||
"perp_eth": 0.0,
|
||||
"contracts": 0.0,
|
||||
}
|
||||
sheets = eth_qty / ct
|
||||
# 张数向下取整到整数张(OKX 期权常见整张)
|
||||
sheets_i = float(math.floor(sheets + 1e-12))
|
||||
if sheets_i <= 0:
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": "换算期权张数不足 1 张",
|
||||
"usable_premium": round(usable, 4),
|
||||
"eth_qty": eth_qty,
|
||||
"sheets": 0.0,
|
||||
"perp_eth": 0.0,
|
||||
"contracts": 0.0,
|
||||
}
|
||||
# 用整张回写 ETH,保持与下单一致
|
||||
eth_qty = round(sheets_i * ct, 2)
|
||||
perp_eth = eth_qty / r
|
||||
contracts = perp_eth / cs
|
||||
premium_est = a * sheets_i * ct
|
||||
return {
|
||||
"ok": True,
|
||||
"msg": "",
|
||||
"usable_premium": round(usable, 4),
|
||||
"eth_qty": eth_qty,
|
||||
"sheets": sheets_i,
|
||||
"perp_eth": round(perp_eth, 6),
|
||||
"contracts": contracts,
|
||||
"premium_est": round(premium_est, 4),
|
||||
"ratio": r,
|
||||
"exec_factor": float(exec_factor or PREMIUM_EXEC_FACTOR),
|
||||
}
|
||||
|
||||
|
||||
def estimate_combo_net_pnl(
|
||||
*,
|
||||
view_side: str,
|
||||
strike: float,
|
||||
index_px: float,
|
||||
ask_open: float,
|
||||
bid: float,
|
||||
sheets: float,
|
||||
ct_mult: float,
|
||||
perp_direction: str,
|
||||
perp_entry: float,
|
||||
perp_mark: float,
|
||||
contracts: float,
|
||||
contract_size: float,
|
||||
fee: Optional[float] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""组合净利(扣费);平仓/卖出手续费按买入费率估算."""
|
||||
fr = fee if fee is not None else fee_rate()
|
||||
ct = float(ct_mult or 0.01)
|
||||
sh = float(sheets or 0)
|
||||
a = float(ask_open or 0)
|
||||
b = float(bid or 0)
|
||||
premium = a * sh * ct
|
||||
opt_proceeds = b * sh * ct
|
||||
opt_open_fee = premium * fr
|
||||
opt_close_fee = opt_proceeds * fr # 卖出费用按买入费率
|
||||
opt_net = opt_proceeds - premium - opt_open_fee - opt_close_fee
|
||||
|
||||
coins = float(contracts or 0) * float(contract_size or 0.01)
|
||||
entry = float(perp_entry or 0)
|
||||
mark = float(perp_mark or 0)
|
||||
pd = str(perp_direction or "").strip().lower()
|
||||
if pd == "short":
|
||||
perp_gross = (entry - mark) * coins
|
||||
else:
|
||||
perp_gross = (mark - entry) * coins
|
||||
perp_notional_open = abs(entry * coins)
|
||||
perp_notional_close = abs(mark * coins)
|
||||
perp_open_fee = perp_notional_open * fr
|
||||
perp_close_fee = perp_notional_close * fr
|
||||
perp_net = perp_gross - perp_open_fee - perp_close_fee
|
||||
total = opt_net + perp_net
|
||||
return {
|
||||
"opt_net": round(opt_net, 4),
|
||||
"perp_net": round(perp_net, 4),
|
||||
"net": round(total, 4),
|
||||
"fee_rate": fr,
|
||||
"premium": round(premium, 4),
|
||||
"opt_proceeds": round(opt_proceeds, 4),
|
||||
}
|
||||
|
||||
|
||||
def validate_option_primary_moneyness(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: Any,
|
||||
index_px: Any,
|
||||
ask: Any = None,
|
||||
moneyness: str = "atm",
|
||||
strike_interval: Any = DEFAULT_STRIKE_INTERVAL,
|
||||
min_hours: Any = DEFAULT_MIN_HOURS,
|
||||
hours_to_expiry: Any = None,
|
||||
min_opt_leverage: Any = None,
|
||||
) -> Optional[str]:
|
||||
from lib.hedge_plan.hedge_plan_moneyness_lib import (
|
||||
classify_moneyness,
|
||||
is_atm_or_otm,
|
||||
is_itm_or_atm,
|
||||
normalize_opt_type,
|
||||
)
|
||||
|
||||
o = normalize_opt_type(opt_type)
|
||||
k = _sf(strike)
|
||||
s = _sf(index_px)
|
||||
if o not in ("C", "P"):
|
||||
return "期权类型无效"
|
||||
if k is None or s is None or s <= 0:
|
||||
return "行权价或指数无效"
|
||||
m_want = (moneyness or "atm").strip().lower()
|
||||
m_got = classify_moneyness(opt_type=o, strike=k, index_px=s)
|
||||
if m_want == "itm":
|
||||
if not is_itm_or_atm(opt_type=o, strike=k, index_px=s):
|
||||
return "所选须为实值或平值"
|
||||
elif m_want == "atm":
|
||||
# 平值:距指数在间隔内即可(不强制 classify==atm)
|
||||
pass
|
||||
elif m_want == "otm":
|
||||
if m_got == "itm":
|
||||
return "虚值模式不可选实值"
|
||||
if not is_atm_or_otm(opt_type=o, strike=k, index_px=s):
|
||||
return "虚值模式须选虚值或平值档"
|
||||
else:
|
||||
return "期权类型(实/平/虚)无效"
|
||||
|
||||
interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL)
|
||||
if interval > 0 and abs(k - s) > interval + 1e-9:
|
||||
return f"行权价偏离指数 {abs(k - s):.1f} > 间隔 {interval:.0f}"
|
||||
|
||||
min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS)
|
||||
h = _sf(hours_to_expiry)
|
||||
if min_h > 0 and h is not None and h < min_h:
|
||||
return f"剩余到期约 {h:.1f}h,低于最短 {min_h:.0f}h"
|
||||
|
||||
a = _sf(ask)
|
||||
min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else m_got or "atm", min_opt_leverage)
|
||||
if min_lev > 0 and a is not None and a > 0:
|
||||
lev = s / a
|
||||
if lev < min_lev:
|
||||
return f"期权杠杆 S/ask≈{lev:.0f} 低于门槛 {min_lev:.0f}"
|
||||
return None
|
||||
|
||||
|
||||
def validate_option_primary_watch(body: dict[str, Any]) -> Optional[str]:
|
||||
"""盯盘启动校验:只要参数,不要求已选具体合约."""
|
||||
need = (
|
||||
"direction",
|
||||
"exchange_symbol",
|
||||
"premium_budget",
|
||||
"option_target_points",
|
||||
"perp_target_points",
|
||||
"option_perp_ratio",
|
||||
"option_leverage",
|
||||
)
|
||||
for k in need:
|
||||
if body.get(k) in (None, ""):
|
||||
return f"缺少字段: {k}"
|
||||
try:
|
||||
if float(body["premium_budget"]) <= 0:
|
||||
return "权利金须大于 0"
|
||||
if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0:
|
||||
return "目标位点数须大于 0"
|
||||
if float(body["option_perp_ratio"]) <= 0:
|
||||
return "期权永续比例须大于 0"
|
||||
if float(body["option_leverage"]) <= 0:
|
||||
return "期权杠杆须大于 0"
|
||||
lev_perp = _sf(body.get("leverage"))
|
||||
if lev_perp is not None and lev_perp <= 0:
|
||||
return "永续杠杆须大于 0"
|
||||
except (TypeError, ValueError):
|
||||
return "数值字段无效"
|
||||
direction = str(body.get("direction") or "").strip().lower()
|
||||
if direction not in ("long", "short"):
|
||||
return "方向须为 long 或 short"
|
||||
moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
|
||||
if moneyness not in ("itm", "atm", "otm"):
|
||||
return "期权类型(实/平/虚)无效"
|
||||
return None
|
||||
|
||||
|
||||
def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]:
|
||||
need = (
|
||||
"direction",
|
||||
"contracts",
|
||||
"opt_inst_id",
|
||||
"sheets",
|
||||
"exchange_symbol",
|
||||
"premium_budget",
|
||||
"option_target_points",
|
||||
"perp_target_points",
|
||||
"option_perp_ratio",
|
||||
)
|
||||
for k in need:
|
||||
if body.get(k) in (None, ""):
|
||||
return f"缺少字段: {k}"
|
||||
try:
|
||||
if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0:
|
||||
return "张数必须大于 0"
|
||||
if float(body["premium_budget"]) <= 0:
|
||||
return "权利金须大于 0"
|
||||
if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0:
|
||||
return "目标位点数须大于 0"
|
||||
if float(body["option_perp_ratio"]) <= 0:
|
||||
return "期权永续比例须大于 0"
|
||||
except (TypeError, ValueError):
|
||||
return "数值字段无效"
|
||||
direction = str(body.get("direction") or "").strip().lower()
|
||||
if direction not in ("long", "short"):
|
||||
return "方向须为 long 或 short"
|
||||
opt_type = str(body.get("opt_type") or "").strip().upper()
|
||||
if not opt_type:
|
||||
inst = str(body.get("opt_inst_id") or "")
|
||||
if inst.upper().endswith("-P"):
|
||||
opt_type = "P"
|
||||
elif inst.upper().endswith("-C"):
|
||||
opt_type = "C"
|
||||
want = opt_type_for_view(direction)
|
||||
if opt_type != want:
|
||||
return f"以期权为主时做{'多' if direction == 'long' else '空'}须用 {'Call' if want == 'C' else 'Put'}"
|
||||
moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
|
||||
from lib.hedge_plan.hedge_plan_moneyness_lib import parse_strike_from_inst
|
||||
|
||||
strike = body.get("strike")
|
||||
if strike in (None, ""):
|
||||
strike = parse_strike_from_inst(str(body.get("opt_inst_id") or ""))
|
||||
index_px = body.get("index_px") or body.get("entry")
|
||||
return validate_option_primary_moneyness(
|
||||
opt_type=opt_type,
|
||||
strike=strike,
|
||||
index_px=index_px,
|
||||
ask=body.get("ask"),
|
||||
moneyness=moneyness,
|
||||
strike_interval=body.get("strike_interval", DEFAULT_STRIKE_INTERVAL),
|
||||
min_hours=body.get("min_option_hours", DEFAULT_MIN_HOURS),
|
||||
hours_to_expiry=body.get("hours_to_expiry"),
|
||||
min_opt_leverage=body.get("option_leverage") or body.get("min_opt_leverage"),
|
||||
)
|
||||
|
||||
|
||||
def pick_option_primary_candidate(
|
||||
chain: dict[str, Any],
|
||||
*,
|
||||
direction: str,
|
||||
moneyness: str = "otm",
|
||||
strike_interval: Any = DEFAULT_STRIKE_INTERVAL,
|
||||
min_hours: Any = DEFAULT_MIN_HOURS,
|
||||
min_opt_leverage: Any = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""从期权链挑最近达标合约(间隔+虚实值+杠杆门)."""
|
||||
from lib.hedge_plan.hedge_plan_moneyness_lib import classify_moneyness
|
||||
|
||||
want = opt_type_for_view(direction)
|
||||
m_want = (moneyness or "otm").strip().lower()
|
||||
interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL)
|
||||
min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS)
|
||||
try:
|
||||
idx = float(chain.get("index_px") or 0)
|
||||
except (TypeError, ValueError):
|
||||
idx = 0.0
|
||||
if idx <= 0:
|
||||
return None
|
||||
|
||||
best: Optional[dict[str, Any]] = None
|
||||
best_dist: Optional[float] = None
|
||||
for exp in chain.get("expiries") or []:
|
||||
h = hours_to_expiry_from_ms(exp.get("exp_time"))
|
||||
if min_h > 0 and h is not None and h < min_h:
|
||||
continue
|
||||
for c in exp.get("contracts") or []:
|
||||
if str(c.get("opt_type") or "").upper() != want:
|
||||
continue
|
||||
try:
|
||||
k = float(c.get("strike") or 0)
|
||||
ask = float(c.get("ask") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if k <= 0 or ask <= 0:
|
||||
continue
|
||||
if interval > 0 and abs(k - idx) > interval + 1e-9:
|
||||
continue
|
||||
m_got = classify_moneyness(opt_type=want, strike=k, index_px=idx)
|
||||
if m_want == "itm" and m_got not in ("itm", "atm"):
|
||||
continue
|
||||
if m_want == "atm" and m_got != "atm":
|
||||
continue
|
||||
if m_want == "otm" and m_got == "itm":
|
||||
continue
|
||||
min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else (m_got or "atm"), min_opt_leverage)
|
||||
if min_lev > 0 and idx / ask < min_lev - 1e-9:
|
||||
continue
|
||||
dist = abs(k - idx)
|
||||
if best is None or best_dist is None or dist < best_dist:
|
||||
best = {
|
||||
**dict(c),
|
||||
"hours_to_expiry": h,
|
||||
"exp_time": exp.get("exp_time"),
|
||||
"moneyness": m_got,
|
||||
"index_px": idx,
|
||||
"leverage": round(idx / ask, 1),
|
||||
}
|
||||
best_dist = dist
|
||||
return best
|
||||
|
||||
|
||||
def build_option_primary_preview(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""情景:期权目标 / 永续目标粗估净利."""
|
||||
view = str(body.get("direction") or "long").lower()
|
||||
strike = float(body["strike"])
|
||||
n = float(body.get("option_target_points") or 0)
|
||||
m = float(body.get("perp_target_points") or 0)
|
||||
ask = float(body.get("ask") or 0)
|
||||
sheets = float(body.get("sheets") or 0)
|
||||
ct = float(body.get("ct_mult") or 0.01)
|
||||
contracts = float(body.get("contracts") or 0)
|
||||
cs = float(body.get("contract_size") or 0.01)
|
||||
entry = float(body.get("entry") or body.get("index_px") or 0)
|
||||
perp_dir = perp_direction_for_view(view)
|
||||
# 粗估到点时期权卖价:按内在价值近似(下限 0)
|
||||
def intrinsic(spot: float) -> float:
|
||||
o = opt_type_for_view(view)
|
||||
if o == "C":
|
||||
return max(0.0, spot - strike)
|
||||
return max(0.0, strike - spot)
|
||||
|
||||
scenarios = []
|
||||
for label, pts, reason in (
|
||||
("期权目标", n, "opt_target_points"),
|
||||
("永续目标", m, "perp_target_points"),
|
||||
):
|
||||
spot = strike + pts if view != "short" else strike - pts
|
||||
bid_est = max(ask * 0.5, intrinsic(spot) * 0.85) # 保守估价
|
||||
net = estimate_combo_net_pnl(
|
||||
view_side=view,
|
||||
strike=strike,
|
||||
index_px=spot,
|
||||
ask_open=ask,
|
||||
bid=bid_est,
|
||||
sheets=sheets,
|
||||
ct_mult=ct,
|
||||
perp_direction=perp_dir,
|
||||
perp_entry=entry,
|
||||
perp_mark=spot,
|
||||
contracts=contracts,
|
||||
contract_size=cs,
|
||||
)
|
||||
scenarios.append(
|
||||
{
|
||||
"label": label,
|
||||
"reason": reason,
|
||||
"index": spot,
|
||||
"perp_pnl": net["perp_net"],
|
||||
"options_pnl": net["opt_net"],
|
||||
"total": net["net"],
|
||||
"note": "扣费净利估价;平仓费按买入费率",
|
||||
}
|
||||
)
|
||||
premium = ask * sheets * ct
|
||||
return {
|
||||
"plan_type": "perp_options",
|
||||
"option_primary": True,
|
||||
"summary": {
|
||||
"premium_paid": round(premium, 4),
|
||||
"usable_premium": round(float(body.get("premium_budget") or 0) * PREMIUM_EXEC_FACTOR, 4),
|
||||
"opt_target_total": scenarios[0]["total"] if scenarios else None,
|
||||
"perp_target_total": scenarios[1]["total"] if len(scenarios) > 1 else None,
|
||||
"perp_direction": perp_dir,
|
||||
"opt_type": opt_type_for_view(view),
|
||||
},
|
||||
"scenarios": scenarios,
|
||||
}
|
||||
@@ -37,7 +37,15 @@ def partial_auto_close_enabled() -> bool:
|
||||
|
||||
def build_po_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""永期下单路径清单(不交易)."""
|
||||
mode = open_order_mode()
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import (
|
||||
is_option_primary,
|
||||
perp_direction_for_view,
|
||||
)
|
||||
|
||||
opt_primary = is_option_primary(body)
|
||||
mode = "options_first" if opt_primary else open_order_mode()
|
||||
view = str(body.get("direction") or "long")
|
||||
perp_dir = perp_direction_for_view(view) if opt_primary else view
|
||||
opt = {
|
||||
"step": "options_buy_limit",
|
||||
"account": "options",
|
||||
@@ -50,11 +58,13 @@ def build_po_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"step": "perp_market_open",
|
||||
"account": "swap",
|
||||
"symbol": body.get("exchange_symbol"),
|
||||
"direction": body.get("direction") or "long",
|
||||
"direction": perp_dir,
|
||||
"contracts": float(body.get("contracts") or 0),
|
||||
"tp": body.get("tp"),
|
||||
"sl": body.get("sl"),
|
||||
"attach_tpsl": True,
|
||||
"tp": None if opt_primary else body.get("tp"),
|
||||
"sl": None if opt_primary else body.get("sl"),
|
||||
"attach_tpsl": False if opt_primary else True,
|
||||
"option_primary": opt_primary,
|
||||
"view_side": view,
|
||||
}
|
||||
return [opt, perp] if mode == "options_first" else [perp, opt]
|
||||
|
||||
@@ -125,11 +135,30 @@ def _buy_option(
|
||||
"ref_ask": q.get("ref_ask"),
|
||||
"can_open": False,
|
||||
}
|
||||
from lib.options.options_position_limit_lib import option_position_limit_block_msg
|
||||
|
||||
pos_limit_msg = option_position_limit_block_msg(
|
||||
ex,
|
||||
opening_inst_id=inst_id,
|
||||
fetch_positions=cfg.get("fetch_option_positions"),
|
||||
)
|
||||
if pos_limit_msg:
|
||||
return {"ok": False, "msg": pos_limit_msg, "quote": q, "can_open": False}
|
||||
sheets_i = max(1, int(round(float(sheets))))
|
||||
requested_sheets = sheets_i
|
||||
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets_i, ask_sz, min_sz=1)
|
||||
if capped is None:
|
||||
return {"ok": False, "msg": cap_msg or "卖一深度不足,无法买入", "quote": q}
|
||||
sheets_i = capped
|
||||
if int(capped) < requested_sheets:
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": f"卖一深度仅 {int(capped)} 张,不足请求 {requested_sheets} 张,拒绝缩量成交",
|
||||
"quote": q,
|
||||
"can_open": False,
|
||||
"requested_sheets": requested_sheets,
|
||||
"ask_sz": ask_sz,
|
||||
}
|
||||
sheets_i = int(capped)
|
||||
ct_mult = float(q.get("ct_mult") or 0.01)
|
||||
premium = float(ask) * sheets_i * ct_mult
|
||||
if dry_run:
|
||||
@@ -179,6 +208,14 @@ def _buy_option(
|
||||
cancel_on_timeout=True,
|
||||
)
|
||||
if not fill.get("ok"):
|
||||
filled_n = int(fill.get("filled_sheets") or 0)
|
||||
orphan_close = None
|
||||
if filled_n > 0 and not dry_run:
|
||||
# 部分成交后撤单:尝试立刻平掉已成交,避免孤儿多头
|
||||
try:
|
||||
orphan_close = _sell_option(cfg, inst_id=inst_id, sheets=float(filled_n))
|
||||
except Exception as e:
|
||||
orphan_close = {"ok": False, "msg": str(e)}
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": fill.get("msg") or "未完全成交,开仓失败",
|
||||
@@ -186,7 +223,8 @@ def _buy_option(
|
||||
"sheets": sheets_i,
|
||||
"ask": float(ask),
|
||||
"exchange_ord_id": ord_id,
|
||||
"filled_sheets": fill.get("filled_sheets"),
|
||||
"filled_sheets": filled_n,
|
||||
"orphan_close": orphan_close,
|
||||
"order": order,
|
||||
"fill": fill,
|
||||
"can_open": False,
|
||||
@@ -221,9 +259,10 @@ def _open_perp(
|
||||
direction: str,
|
||||
contracts: float,
|
||||
leverage: int,
|
||||
tp: float,
|
||||
sl: float,
|
||||
tp: Optional[float],
|
||||
sl: Optional[float],
|
||||
dry_run: bool,
|
||||
attach_tpsl: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if not symbol or contracts <= 0:
|
||||
return {"ok": False, "msg": "永续符号或张数无效"}
|
||||
@@ -237,6 +276,9 @@ def _open_perp(
|
||||
pass
|
||||
if amount <= 0:
|
||||
return {"ok": False, "msg": "张数经精度舍入后为 0"}
|
||||
use_tpsl = bool(attach_tpsl) and tp is not None and sl is not None
|
||||
tp_v = float(tp) if use_tpsl else None
|
||||
sl_v = float(sl) if use_tpsl else None
|
||||
if dry_run:
|
||||
return {
|
||||
"ok": True,
|
||||
@@ -245,8 +287,9 @@ def _open_perp(
|
||||
"direction": direction,
|
||||
"contracts": amount,
|
||||
"leverage": leverage,
|
||||
"tp": tp,
|
||||
"sl": sl,
|
||||
"tp": tp_v,
|
||||
"sl": sl_v,
|
||||
"attach_tpsl": use_tpsl,
|
||||
}
|
||||
ensure = cfg.get("ensure_okx_live_ready")
|
||||
if callable(ensure):
|
||||
@@ -257,7 +300,14 @@ def _open_perp(
|
||||
if not callable(place):
|
||||
return {"ok": False, "msg": "永续下单函数未注入"}
|
||||
try:
|
||||
order = place(symbol, direction, amount, leverage, stop_loss=sl, take_profit=tp)
|
||||
order = place(
|
||||
symbol,
|
||||
direction,
|
||||
amount,
|
||||
leverage,
|
||||
stop_loss=sl_v,
|
||||
take_profit=tp_v,
|
||||
)
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": f"永续开仓失败: {e}"}
|
||||
return {
|
||||
@@ -266,13 +316,60 @@ def _open_perp(
|
||||
"direction": direction,
|
||||
"contracts": amount,
|
||||
"leverage": leverage,
|
||||
"tp": tp,
|
||||
"sl": sl,
|
||||
"tp": tp_v,
|
||||
"sl": sl_v,
|
||||
"attach_tpsl": use_tpsl,
|
||||
"order": order,
|
||||
"exchange_ord_id": str((order or {}).get("id") or (order or {}).get("info", {}).get("ordId") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _close_perp(
|
||||
cfg: dict[str, Any],
|
||||
*,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
contracts: float,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""市价平永续(reduce-only);优先用注入的 close_exchange_order."""
|
||||
if not symbol:
|
||||
return {"ok": False, "msg": "永续符号无效"}
|
||||
if dry_run:
|
||||
return {
|
||||
"ok": True,
|
||||
"dry_run": True,
|
||||
"symbol": symbol,
|
||||
"direction": direction,
|
||||
"contracts": float(contracts or 0),
|
||||
}
|
||||
close_fn = cfg.get("close_exchange_order")
|
||||
if callable(close_fn):
|
||||
try:
|
||||
order = close_fn(
|
||||
{
|
||||
"exchange_symbol": symbol,
|
||||
"direction": direction,
|
||||
"order_amount": float(contracts or 0),
|
||||
"symbol": symbol,
|
||||
}
|
||||
)
|
||||
return {"ok": True, "symbol": symbol, "direction": direction, "order": order}
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": f"永续平仓失败: {e}"}
|
||||
# 回退:对向市价 reduce-only(若注入了 place + 支持)
|
||||
place = cfg.get("place_exchange_order")
|
||||
if not callable(place):
|
||||
return {"ok": False, "msg": "永续平仓函数未注入"}
|
||||
try:
|
||||
# 无 TP/SL 的对向单;依赖交易所 reduceOnly 由 place 实现不保证,优先 close_exchange_order
|
||||
side_dir = "short" if str(direction).lower() == "long" else "long"
|
||||
order = place(symbol, side_dir, float(contracts or 0), int(cfg.get("alt_leverage") or 5), None, None)
|
||||
return {"ok": True, "symbol": symbol, "direction": direction, "order": order, "note": "fallback_place"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": f"永续平仓失败: {e}"}
|
||||
|
||||
|
||||
def _sell_option(
|
||||
cfg: dict[str, Any],
|
||||
*,
|
||||
@@ -280,9 +377,17 @@ def _sell_option(
|
||||
sheets: float,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""平期权:走买一限价 + 验仓;仅 fully_closed/already_flat 视为成功.
|
||||
|
||||
对冲强平/目标平仓不启用 2× 回收门控(require_recycle_gate=False).
|
||||
"""
|
||||
from lib.exchange.okx_options_lib import fetch_option_book_depth, fetch_option_positions
|
||||
from lib.options.options_close_exec_lib import close_option_by_bid1
|
||||
|
||||
ex = cfg.get("exchange_options")
|
||||
quote_fn = cfg.get("quote_option_contract")
|
||||
place_fn = cfg.get("place_option_limit_order")
|
||||
if not inst_id:
|
||||
return {"ok": False, "msg": "缺少期权合约"}
|
||||
if not callable(quote_fn) or ex is None:
|
||||
return {"ok": False, "msg": "期权报价能力未就绪"}
|
||||
q = quote_fn(ex, inst_id)
|
||||
@@ -291,20 +396,77 @@ def _sell_option(
|
||||
return {"ok": False, "msg": "暂无买一价,无法平期权"}
|
||||
sheets_i = max(1, int(round(float(sheets))))
|
||||
if dry_run:
|
||||
return {"ok": True, "dry_run": True, "inst_id": inst_id, "sheets": sheets_i, "bid": float(bid)}
|
||||
if not callable(place_fn):
|
||||
return {
|
||||
"ok": True,
|
||||
"dry_run": True,
|
||||
"inst_id": inst_id,
|
||||
"sheets": sheets_i,
|
||||
"bid": float(bid),
|
||||
"fully_closed": True,
|
||||
}
|
||||
if not callable(cfg.get("place_option_limit_order")):
|
||||
return {"ok": False, "msg": "期权平仓未注入"}
|
||||
order = place_fn(
|
||||
close_cfg = dict(cfg)
|
||||
if not callable(close_cfg.get("fetch_option_positions")):
|
||||
close_cfg["fetch_option_positions"] = fetch_option_positions
|
||||
if not callable(close_cfg.get("fetch_option_book_depth")):
|
||||
close_cfg["fetch_option_book_depth"] = fetch_option_book_depth
|
||||
if "td_mode" not in close_cfg:
|
||||
close_cfg["td_mode"] = close_cfg.get("options_td_mode") or "isolated"
|
||||
result = close_option_by_bid1(
|
||||
close_cfg,
|
||||
ex,
|
||||
inst_id=inst_id,
|
||||
side="sell",
|
||||
inst_id,
|
||||
sheets=sheets_i,
|
||||
price=float(bid),
|
||||
td_mode="isolated",
|
||||
tick_sz=q.get("tick_sz"),
|
||||
reduce_only=True,
|
||||
require_recycle_gate=False,
|
||||
)
|
||||
return order if order.get("ok") else order
|
||||
out = dict(result or {})
|
||||
if out.get("already_flat"):
|
||||
# 二次验仓,避免一次空列表误判已平
|
||||
import time as _time
|
||||
|
||||
_time.sleep(0.35)
|
||||
try:
|
||||
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
|
||||
|
||||
invalidate_option_positions_cache()
|
||||
except Exception:
|
||||
pass
|
||||
rows2 = close_cfg["fetch_option_positions"](ex)
|
||||
if rows2 is None:
|
||||
return {"ok": False, "msg": "二次验仓失败,未确认是否已平", "fully_closed": False}
|
||||
still = next((p for p in rows2 if str(p.get("instId")) == inst_id), None)
|
||||
still_sz = 0.0
|
||||
if still is not None:
|
||||
try:
|
||||
still_sz = abs(float(still.get("availPos") or still.get("pos") or 0))
|
||||
except (TypeError, ValueError):
|
||||
still_sz = 0.0
|
||||
if still is not None and still_sz >= 1:
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": "二次验仓仍有持仓,拒绝 already_flat",
|
||||
"fully_closed": False,
|
||||
}
|
||||
out["ok"] = True
|
||||
out["fully_closed"] = True
|
||||
out.setdefault("bid", float(bid))
|
||||
return out
|
||||
if not out.get("ok"):
|
||||
out.setdefault("bid", float(bid))
|
||||
return out
|
||||
if not out.get("fully_closed"):
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": out.get("msg") or "期权尚未完全平仓,将下轮重试",
|
||||
"bid": out.get("locked_bid_px") or float(bid),
|
||||
"fully_closed": False,
|
||||
"partial": True,
|
||||
"close": out,
|
||||
}
|
||||
out["bid"] = out.get("locked_bid_px") or float(bid)
|
||||
out["fully_closed"] = True
|
||||
return out
|
||||
|
||||
|
||||
def _notify_partial(cfg: dict[str, Any], plan_type: str, msg: str, results: list[dict[str, Any]]) -> None:
|
||||
@@ -511,8 +673,9 @@ def refresh_oo_sizing_before_start(cfg: dict[str, Any], body: dict[str, Any]) ->
|
||||
|
||||
|
||||
def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""永期启动前再拉保险腿卖一(张数沿用页面值,不按预算重算)."""
|
||||
"""永期启动前再拉卖一;保险模式张数沿用页面;期权为主时按权利金×0.95重算定仓."""
|
||||
from lib.exchange.okx_options_lib import option_buy_liquidity_ok
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary, size_from_premium
|
||||
|
||||
inst = str(body.get("opt_inst_id") or "").strip()
|
||||
if not inst:
|
||||
@@ -535,6 +698,51 @@ def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, An
|
||||
body["ask_sz"] = q.get("ask_sz")
|
||||
if q.get("ct_mult") is not None:
|
||||
body["ct_mult"] = float(q.get("ct_mult") or 0.01)
|
||||
if is_option_primary(body):
|
||||
cs = float(body.get("contract_size") or 0.01)
|
||||
get_cs = cfg.get("get_contract_size")
|
||||
sym = str(body.get("exchange_symbol") or "")
|
||||
if callable(get_cs) and sym:
|
||||
try:
|
||||
cs = float(get_cs(sym) or cs)
|
||||
except Exception:
|
||||
pass
|
||||
sized = size_from_premium(
|
||||
premium_budget=float(body.get("premium_budget") or 0),
|
||||
ask=float(body["ask"]),
|
||||
ct_mult=float(body.get("ct_mult") or 0.01),
|
||||
ratio=float(body.get("option_perp_ratio") or 2),
|
||||
contract_size=cs,
|
||||
)
|
||||
if not sized.get("ok"):
|
||||
return {"ok": False, "msg": sized.get("msg") or "定仓失败", "quote": q, "sizing": sized}
|
||||
body["sheets"] = sized["sheets"]
|
||||
body["contracts"] = sized["contracts"]
|
||||
body["eth_qty"] = sized["eth_qty"]
|
||||
body["contract_size"] = cs
|
||||
# 深度不足则缩量
|
||||
ask_sz = float(q.get("ask_sz") or 0)
|
||||
if ask_sz > 0 and float(body["sheets"]) > ask_sz:
|
||||
body["sheets"] = float(int(ask_sz))
|
||||
if body["sheets"] <= 0:
|
||||
return {"ok": False, "msg": "卖一深度不足 1 张", "quote": q, "sizing": sized}
|
||||
eth = round(float(body["sheets"]) * float(body.get("ct_mult") or 0.01), 2)
|
||||
body["eth_qty"] = eth
|
||||
body["contracts"] = (eth / float(body.get("option_perp_ratio") or 2)) / cs
|
||||
return {
|
||||
"ok": True,
|
||||
"ask": float(q["ask"]),
|
||||
"ask_sz": q.get("ask_sz"),
|
||||
"sheets": body.get("sheets"),
|
||||
"contracts": body.get("contracts"),
|
||||
"eth_qty": body.get("eth_qty"),
|
||||
"sizing": sized,
|
||||
"quote": q,
|
||||
"msg": (
|
||||
f"期权为主定仓: 权利金×0.95→{body.get('eth_qty')}ETH / "
|
||||
f"{body.get('sheets')}张期权 / {float(body.get('contracts') or 0):.4f}张永续 @{q['ask']}"
|
||||
),
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"ask": float(q["ask"]),
|
||||
@@ -592,15 +800,32 @@ def execute_perp_options_start(
|
||||
)
|
||||
return {"ok": False, "msg": opt_res.get("msg") or "期权开仓失败", "path": path, "results": results}
|
||||
else:
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import (
|
||||
is_option_primary,
|
||||
perp_direction_for_view,
|
||||
)
|
||||
|
||||
opt_primary = is_option_primary(body)
|
||||
view = str(body.get("direction") or "long")
|
||||
perp_dir = str(step.get("direction") or (
|
||||
perp_direction_for_view(view) if opt_primary else view
|
||||
))
|
||||
attach = bool(step.get("attach_tpsl", not opt_primary))
|
||||
tp_v = None if not attach else body.get("tp")
|
||||
sl_v = None if not attach else body.get("sl")
|
||||
if attach:
|
||||
tp_v = float(body["tp"])
|
||||
sl_v = float(body["sl"])
|
||||
perp_res = _open_perp(
|
||||
cfg,
|
||||
symbol=str(body.get("exchange_symbol") or ""),
|
||||
direction=str(body.get("direction") or "long"),
|
||||
direction=perp_dir,
|
||||
contracts=float(body.get("contracts") or 0),
|
||||
leverage=int(body.get("leverage") or 10),
|
||||
tp=float(body["tp"]),
|
||||
sl=float(body["sl"]),
|
||||
leverage=int(body.get("leverage") or (100 if opt_primary else 10)),
|
||||
tp=tp_v,
|
||||
sl=sl_v,
|
||||
dry_run=dry_run,
|
||||
attach_tpsl=attach,
|
||||
)
|
||||
results.append({"step": step["step"], **perp_res})
|
||||
if not perp_res.get("ok"):
|
||||
@@ -681,7 +906,24 @@ def execute_options_options_start(
|
||||
results: list[dict[str, Any]] = []
|
||||
leg_a = body.get("leg_a") or {}
|
||||
leg_b = body.get("leg_b") or {}
|
||||
a_res = _buy_option(cfg, inst_id=str(leg_a.get("inst_id") or ""), sheets=float(leg_a.get("sheets") or 1), dry_run=dry_run)
|
||||
inst_a = str(leg_a.get("inst_id") or "")
|
||||
inst_b = str(leg_b.get("inst_id") or "")
|
||||
from lib.options.options_position_limit_lib import option_position_limit_block_msg
|
||||
|
||||
pos_limit_msg = option_position_limit_block_msg(
|
||||
cfg.get("exchange_options"),
|
||||
opening_inst_ids=[inst_a, inst_b],
|
||||
fetch_positions=cfg.get("fetch_option_positions"),
|
||||
)
|
||||
if pos_limit_msg:
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": pos_limit_msg,
|
||||
"path": path,
|
||||
"results": [],
|
||||
"refresh": refresh,
|
||||
}
|
||||
a_res = _buy_option(cfg, inst_id=inst_a, sheets=float(leg_a.get("sheets") or 1), dry_run=dry_run)
|
||||
results.append({"step": "options_buy_limit", "leg": "a", **a_res})
|
||||
if not a_res.get("ok"):
|
||||
return {
|
||||
@@ -691,7 +933,7 @@ def execute_options_options_start(
|
||||
"results": results,
|
||||
"refresh": refresh,
|
||||
}
|
||||
b_res = _buy_option(cfg, inst_id=str(leg_b.get("inst_id") or ""), sheets=float(leg_b.get("sheets") or 1), dry_run=dry_run)
|
||||
b_res = _buy_option(cfg, inst_id=inst_b, sheets=float(leg_b.get("sheets") or 1), dry_run=dry_run)
|
||||
results.append({"step": "options_buy_limit", "leg": "b", **b_res})
|
||||
if not b_res.get("ok"):
|
||||
if not dry_run and partial_auto_close_enabled():
|
||||
@@ -769,15 +1011,25 @@ def execute_complete_missing_leg(
|
||||
role = str(missing.get("leg_role") or "")
|
||||
results: list[dict[str, Any]] = []
|
||||
if role == "perp":
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import (
|
||||
is_option_primary,
|
||||
perp_direction_for_view,
|
||||
)
|
||||
|
||||
opt_primary = is_option_primary(start_body)
|
||||
view = str(start_body.get("direction") or "long")
|
||||
perp_dir = perp_direction_for_view(view) if opt_primary else view
|
||||
attach = not opt_primary
|
||||
res = _open_perp(
|
||||
cfg,
|
||||
symbol=str(start_body.get("exchange_symbol") or missing.get("symbol") or ""),
|
||||
direction=str(start_body.get("direction") or "long"),
|
||||
direction=perp_dir,
|
||||
contracts=float(start_body.get("contracts") or missing.get("size") or 0),
|
||||
leverage=int(start_body.get("leverage") or 10),
|
||||
tp=float(start_body["tp"]),
|
||||
sl=float(start_body["sl"]),
|
||||
leverage=int(start_body.get("leverage") or (100 if opt_primary else 10)),
|
||||
tp=None if not attach else float(start_body["tp"]),
|
||||
sl=None if not attach else float(start_body["sl"]),
|
||||
dry_run=dry_run,
|
||||
attach_tpsl=attach,
|
||||
)
|
||||
results.append({"step": "perp_market_open", "complete": True, **res})
|
||||
if not res.get("ok"):
|
||||
@@ -820,6 +1072,19 @@ def execute_complete_missing_leg(
|
||||
def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
|
||||
pt = (plan_type or "").strip().lower()
|
||||
if pt == "perp_options":
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import (
|
||||
is_option_primary,
|
||||
validate_option_primary_start,
|
||||
)
|
||||
|
||||
if is_option_primary(body):
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import validate_option_primary_watch
|
||||
|
||||
# 以期权为主默认盯盘启动(非现场开仓);显式 watch_entry=0 才走即开校验
|
||||
watch = body.get("watch_entry")
|
||||
if watch in (None, "", True, 1, "1", "true", "yes", "on"):
|
||||
return validate_option_primary_watch(body)
|
||||
return validate_option_primary_start(body)
|
||||
need = ("direction", "entry", "tp", "sl", "contracts", "opt_inst_id", "sheets", "exchange_symbol")
|
||||
for k in need:
|
||||
if body.get(k) in (None, ""):
|
||||
@@ -827,30 +1092,110 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
|
||||
try:
|
||||
if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0:
|
||||
return "张数必须大于 0"
|
||||
if float(body["tp"]) <= 0 or float(body["sl"]) <= 0:
|
||||
return "止盈/止损无效"
|
||||
entry = float(body["entry"])
|
||||
tp = float(body["tp"])
|
||||
sl = float(body["sl"])
|
||||
if tp <= 0 or sl <= 0 or entry <= 0:
|
||||
return "止盈/止损/入场无效"
|
||||
except (TypeError, ValueError):
|
||||
return "数值字段无效"
|
||||
direction = str(body.get("direction") or "").strip().lower()
|
||||
if direction not in ("long", "short"):
|
||||
return "方向须为 long 或 short"
|
||||
opt_type = str(body.get("opt_type") or "").strip().upper()
|
||||
if not opt_type:
|
||||
# 允许从合约名推断 ETH-USD-...-P / -C
|
||||
inst = str(body.get("opt_inst_id") or "")
|
||||
if inst.upper().endswith("-P"):
|
||||
opt_type = "P"
|
||||
elif inst.upper().endswith("-C"):
|
||||
opt_type = "C"
|
||||
if opt_type not in ("P", "C"):
|
||||
return "缺少期权类型(Put/Call)"
|
||||
if direction == "long" and opt_type != "P":
|
||||
return "做多永期对冲须用 Put"
|
||||
if direction == "short" and opt_type != "C":
|
||||
return "做空永期对冲须用 Call"
|
||||
if direction == "long" and not (sl < entry < tp):
|
||||
return "做多须满足 止损 < 入场 < 止盈"
|
||||
if direction == "short" and not (tp < entry < sl):
|
||||
return "做空须满足 止盈 < 入场 < 止损"
|
||||
from lib.hedge_plan.hedge_plan_moneyness_lib import (
|
||||
parse_strike_from_inst,
|
||||
validate_po_option_moneyness,
|
||||
)
|
||||
|
||||
strike = body.get("strike")
|
||||
if strike in (None, ""):
|
||||
strike = parse_strike_from_inst(str(body.get("opt_inst_id") or ""))
|
||||
index_px = body.get("index_px")
|
||||
if index_px in (None, ""):
|
||||
index_px = entry
|
||||
money_err = validate_po_option_moneyness(
|
||||
opt_type=opt_type,
|
||||
strike=strike,
|
||||
index_px=index_px,
|
||||
ask=body.get("ask"),
|
||||
hours_to_expiry=body.get("hours_to_expiry"),
|
||||
)
|
||||
if money_err:
|
||||
return money_err
|
||||
return None
|
||||
if pt == "options_options":
|
||||
a = body.get("leg_a") or {}
|
||||
b = body.get("leg_b") or {}
|
||||
if not a.get("inst_id") or not b.get("inst_id"):
|
||||
return "请选用两条期权腿"
|
||||
up = body.get("target_price_up")
|
||||
down = body.get("target_price_down")
|
||||
legacy = body.get("target_price")
|
||||
if up in (None, "") and legacy not in (None, ""):
|
||||
up = legacy
|
||||
if down in (None, "") and legacy not in (None, ""):
|
||||
down = legacy
|
||||
if up in (None, "") or down in (None, ""):
|
||||
return "请填写上破与下破目标价"
|
||||
try:
|
||||
if float(up) <= float(down):
|
||||
return "上破目标价必须大于下破目标价"
|
||||
except (TypeError, ValueError):
|
||||
return "目标价无效"
|
||||
rr_raw = body.get("profit_rr")
|
||||
if rr_raw not in (None, ""):
|
||||
try:
|
||||
rr = float(rr_raw)
|
||||
except (TypeError, ValueError):
|
||||
return "盈亏比无效"
|
||||
if rr <= 0:
|
||||
return "盈亏比须大于0"
|
||||
else:
|
||||
# 兼容旧上/下破
|
||||
up = body.get("target_price_up")
|
||||
down = body.get("target_price_down")
|
||||
legacy = body.get("target_price")
|
||||
if up in (None, "") and legacy not in (None, ""):
|
||||
up = legacy
|
||||
if down in (None, "") and legacy not in (None, ""):
|
||||
down = legacy
|
||||
if up in (None, "") or down in (None, ""):
|
||||
return "请填写盈亏比"
|
||||
try:
|
||||
if float(up) <= float(down):
|
||||
return "上破目标价必须大于下破目标价"
|
||||
except (TypeError, ValueError):
|
||||
return "目标价无效"
|
||||
from lib.hedge_plan.hedge_plan_moneyness_lib import (
|
||||
parse_strike_from_inst,
|
||||
validate_oo_legs_moneyness,
|
||||
)
|
||||
|
||||
def _leg_for_money(leg: dict) -> dict:
|
||||
strike = leg.get("strike")
|
||||
if strike in (None, ""):
|
||||
strike = parse_strike_from_inst(str(leg.get("inst_id") or ""))
|
||||
opt_type = leg.get("opt_type")
|
||||
if not opt_type:
|
||||
inst = str(leg.get("inst_id") or "").upper()
|
||||
if inst.endswith("-C"):
|
||||
opt_type = "C"
|
||||
elif inst.endswith("-P"):
|
||||
opt_type = "P"
|
||||
return {"opt_type": opt_type, "strike": strike}
|
||||
|
||||
index_px = body.get("index_px")
|
||||
money_err = validate_oo_legs_moneyness(
|
||||
_leg_for_money(a),
|
||||
_leg_for_money(b),
|
||||
index_px=index_px,
|
||||
)
|
||||
if money_err:
|
||||
return money_err
|
||||
return None
|
||||
return "未知计划类型"
|
||||
|
||||
@@ -955,7 +1300,7 @@ def execute_manual_end_plan(cfg: dict[str, Any], conn: Any, plan_id: int) -> dic
|
||||
if not plan:
|
||||
return {"ok": False, "msg": "计划不存在"}
|
||||
st = str(plan.get("status") or "")
|
||||
if st not in ("opening", "active", "partial"):
|
||||
if st not in ("opening", "active", "partial", "watching"):
|
||||
return {"ok": False, "msg": f"当前状态 {st or '—'} 不可结束"}
|
||||
|
||||
notes = reconcile_unfilled_option_legs(cfg, conn, int(plan_id))
|
||||
|
||||
@@ -79,6 +79,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
|
||||
"ensure_markets_loaded": getattr(app_module, "ensure_markets_loaded", None),
|
||||
"ensure_okx_live_ready": getattr(app_module, "ensure_okx_live_ready", None),
|
||||
"place_exchange_order": getattr(app_module, "place_exchange_order", None),
|
||||
"close_exchange_order": getattr(app_module, "close_exchange_order", None),
|
||||
"get_live_position_contracts": getattr(app_module, "get_live_position_contracts", None),
|
||||
"amount_to_precision": _amount_to_precision,
|
||||
"build_option_chain": build_option_chain,
|
||||
@@ -108,15 +109,21 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _hedge_enabled() -> bool:
|
||||
return _env_bool("HEDGE_PLAN_ENABLED", False)
|
||||
from lib.hedge_plan.okx_trade_mode_lib import hedge_module_enabled
|
||||
|
||||
return hedge_module_enabled()
|
||||
|
||||
|
||||
def _show_perp_options() -> bool:
|
||||
return _env_bool("HEDGE_PLAN_SHOW_PERP_OPTIONS", True)
|
||||
from lib.hedge_plan.okx_trade_mode_lib import show_perp_options
|
||||
|
||||
return show_perp_options()
|
||||
|
||||
|
||||
def _show_options_options() -> bool:
|
||||
return _env_bool("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", True)
|
||||
from lib.hedge_plan.okx_trade_mode_lib import show_options_options
|
||||
|
||||
return show_options_options()
|
||||
|
||||
|
||||
def _oo_close_mode_enabled() -> bool:
|
||||
@@ -180,13 +187,14 @@ def _gates_dict(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]:
|
||||
raw = fetch_option_positions(ex) if ex is not None else []
|
||||
has_standalone = has_standalone_option_position(conn, raw or [])
|
||||
except Exception:
|
||||
has_standalone = False
|
||||
has_standalone = True # fail-closed
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
active = 0
|
||||
has_standalone = False
|
||||
# fail-closed:探测失败视为不可开仓
|
||||
active = 10**9
|
||||
has_standalone = True
|
||||
return gate_status(
|
||||
hedge_enabled=_hedge_enabled(),
|
||||
sizing_mode=load_position_sizing_mode(),
|
||||
@@ -214,31 +222,45 @@ def _gates_public(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _maybe_start_monitor(cfg: dict[str, Any]) -> None:
|
||||
if not _hedge_enabled():
|
||||
return
|
||||
try:
|
||||
secs = float(os.getenv("HEDGE_PLAN_MONITOR_POLL_SECONDS") or "15")
|
||||
except ValueError:
|
||||
secs = 15.0
|
||||
secs = max(5.0, secs)
|
||||
# 始终启动监控线程:单独期权模式下仍需收口遗留 active/partial 计划
|
||||
with _hedge_start_lock():
|
||||
if cfg.get("hedge_monitor_thread") is not None:
|
||||
return
|
||||
try:
|
||||
secs = float(os.getenv("HEDGE_PLAN_MONITOR_POLL_SECONDS") or "15")
|
||||
except ValueError:
|
||||
secs = 15.0
|
||||
secs = max(5.0, secs)
|
||||
|
||||
def _loop() -> None:
|
||||
import time
|
||||
def _loop() -> None:
|
||||
import time
|
||||
|
||||
from lib.hedge_plan.hedge_plan_monitor_lib import tick_active_plans
|
||||
from lib.hedge_plan.hedge_plan_monitor_lib import tick_active_plans
|
||||
|
||||
while True:
|
||||
try:
|
||||
tick_active_plans(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(secs)
|
||||
while True:
|
||||
try:
|
||||
tick_active_plans(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(secs)
|
||||
|
||||
import threading
|
||||
import threading
|
||||
|
||||
t = threading.Thread(target=_loop, name="hedge-plan-monitor", daemon=True)
|
||||
t.start()
|
||||
cfg["hedge_monitor_thread"] = t
|
||||
t = threading.Thread(target=_loop, name="hedge-plan-monitor", daemon=True)
|
||||
t.start()
|
||||
cfg["hedge_monitor_thread"] = t
|
||||
|
||||
|
||||
_start_lock = None
|
||||
|
||||
|
||||
def _hedge_start_lock():
|
||||
global _start_lock
|
||||
if _start_lock is None:
|
||||
import threading
|
||||
|
||||
_start_lock = threading.Lock()
|
||||
return _start_lock
|
||||
|
||||
|
||||
def _start_body_json(body: dict[str, Any], missing_leg: Optional[str] = None) -> str:
|
||||
@@ -277,34 +299,57 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
|
||||
opt_ok = True
|
||||
perp_ok = True
|
||||
premium = float((opt or {}).get("premium") or 0) if opt_ok else 0.0
|
||||
plan_id = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "perp_options",
|
||||
"status": "partial" if is_partial else "active",
|
||||
"underlying": str(body.get("underlying") or "ETH").upper(),
|
||||
"direction": str(body.get("direction") or "long"),
|
||||
"entry_mark": float(body.get("entry") or 0),
|
||||
"tp": float(body.get("tp") or 0),
|
||||
"sl": float(body.get("sl") or 0),
|
||||
"sizing_mode_at_open": load_position_sizing_mode(),
|
||||
"perp_size": float((perp or {}).get("contracts") or body.get("contracts") or 0),
|
||||
"margin": body.get("margin"),
|
||||
"leverage": float(body.get("leverage") or 10),
|
||||
"premium_total": premium,
|
||||
"preview_json": _start_body_json(body, missing or None),
|
||||
"close_reason": "partial_fail" if is_partial else None,
|
||||
"opened_at": result.get("opened_at"),
|
||||
"note": (result.get("msg") or "")[:500] if is_partial else None,
|
||||
},
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import (
|
||||
is_option_primary,
|
||||
perp_direction_for_view,
|
||||
)
|
||||
|
||||
opt_primary = is_option_primary(body)
|
||||
view = str(body.get("direction") or "long")
|
||||
perp_dir = (
|
||||
str((perp or {}).get("direction") or "")
|
||||
or (perp_direction_for_view(view) if opt_primary else view)
|
||||
)
|
||||
plan_row = {
|
||||
"plan_type": "perp_options",
|
||||
"status": "partial" if is_partial else "active",
|
||||
"underlying": str(body.get("underlying") or "ETH").upper(),
|
||||
"direction": view,
|
||||
"entry_mark": float(body.get("entry") or 0),
|
||||
"tp": float(body.get("tp") or 0) if not opt_primary else 0,
|
||||
"sl": float(body.get("sl") or 0) if not opt_primary else 0,
|
||||
"sizing_mode_at_open": load_position_sizing_mode(),
|
||||
"perp_size": float((perp or {}).get("contracts") or body.get("contracts") or 0),
|
||||
"margin": body.get("margin"),
|
||||
"leverage": float(body.get("leverage") or (100 if opt_primary else 10)),
|
||||
"premium_total": premium,
|
||||
"preview_json": _start_body_json(body, missing or None),
|
||||
"close_reason": "partial_fail" if is_partial else None,
|
||||
"opened_at": result.get("opened_at"),
|
||||
"note": (result.get("msg") or "")[:500] if is_partial else None,
|
||||
"option_primary": 1 if opt_primary else 0,
|
||||
"perp_direction": perp_dir,
|
||||
}
|
||||
if opt_primary:
|
||||
plan_row.update(
|
||||
{
|
||||
"option_target_points": float(body.get("option_target_points") or 0),
|
||||
"perp_target_points": float(body.get("perp_target_points") or 0),
|
||||
"option_perp_ratio": float(body.get("option_perp_ratio") or 0),
|
||||
"premium_budget": float(body.get("premium_budget") or 0),
|
||||
"strike_interval": float(body.get("strike_interval") or 15),
|
||||
"min_option_hours": float(body.get("min_option_hours") or 36),
|
||||
"option_moneyness": str(body.get("moneyness") or body.get("option_moneyness") or ""),
|
||||
}
|
||||
)
|
||||
plan_id = insert_plan(conn, plan_row)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": plan_id,
|
||||
"leg_role": "perp",
|
||||
"symbol": str(body.get("exchange_symbol") or ""),
|
||||
"side": str(body.get("direction") or "long"),
|
||||
"side": perp_dir,
|
||||
"size": float((perp or {}).get("contracts") or body.get("contracts") or 0),
|
||||
"avg_open": float(body.get("entry") or 0) if perp_ok else None,
|
||||
"status": "open" if perp_ok else "pending",
|
||||
@@ -322,8 +367,9 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
|
||||
"strike": (opt or {}).get("strike") or body.get("strike"),
|
||||
"side": "buy",
|
||||
"size": float((opt or {}).get("sheets") or body.get("sheets") or 1),
|
||||
"avg_open": float((opt or {}).get("ask") or 0) if opt_ok else None,
|
||||
"avg_open": float((opt or {}).get("ask") or body.get("ask") or 0) if opt_ok else None,
|
||||
"premium": premium if opt_ok else 0,
|
||||
"ct_mult": float(body.get("ct_mult") or (opt or {}).get("ct_mult") or 0.01),
|
||||
"status": "open" if opt_ok else "pending",
|
||||
"exchange_ord_id": str((opt or {}).get("exchange_ord_id") or ""),
|
||||
"opened_at": result.get("opened_at") if opt_ok else None,
|
||||
@@ -341,6 +387,134 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
|
||||
conn.close()
|
||||
|
||||
|
||||
def _persist_po_watching(cfg: dict[str, Any], body: dict[str, Any]) -> int:
|
||||
"""以期权为主:只落库盯盘计划,不下单."""
|
||||
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, insert_plan
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view
|
||||
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_hedge_plan_tables(conn)
|
||||
view = str(body.get("direction") or "long")
|
||||
money = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
|
||||
plan_id = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "perp_options",
|
||||
"status": "watching",
|
||||
"underlying": str(body.get("underlying") or "ETH").upper(),
|
||||
"direction": view,
|
||||
"entry_mark": float(body.get("index_px") or body.get("entry") or 0) or None,
|
||||
"tp": 0,
|
||||
"sl": 0,
|
||||
"sizing_mode_at_open": None,
|
||||
"perp_size": None,
|
||||
"margin": None,
|
||||
"leverage": float(body.get("leverage") or 100),
|
||||
"premium_total": 0,
|
||||
"preview_json": _start_body_json(body),
|
||||
"close_reason": None,
|
||||
"opened_at": None,
|
||||
"note": "盯盘中:等待杠杆/间隔达标后自动开仓",
|
||||
"option_primary": 1,
|
||||
"perp_direction": perp_direction_for_view(view),
|
||||
"option_target_points": float(body.get("option_target_points") or 0),
|
||||
"perp_target_points": float(body.get("perp_target_points") or 0),
|
||||
"option_perp_ratio": float(body.get("option_perp_ratio") or 0),
|
||||
"premium_budget": float(body.get("premium_budget") or 0),
|
||||
"strike_interval": float(body.get("strike_interval") or 15),
|
||||
"min_option_hours": float(body.get("min_option_hours") or 36),
|
||||
"option_moneyness": money,
|
||||
"option_leverage": float(body.get("option_leverage") or 0),
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
return plan_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _activate_watching_po(
|
||||
cfg: dict[str, Any],
|
||||
conn: Any,
|
||||
plan_id: int,
|
||||
result: dict[str, Any],
|
||||
body: dict[str, Any],
|
||||
) -> None:
|
||||
"""盯盘命中后:写入腿并把 watching → active/partial."""
|
||||
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, insert_leg, update_plan
|
||||
from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view
|
||||
|
||||
is_partial = bool(result.get("partial"))
|
||||
missing = str(result.get("missing_leg") or "") if is_partial else ""
|
||||
opt = result.get("option") or {}
|
||||
perp = result.get("perp") or {}
|
||||
if is_partial:
|
||||
opt_ok = missing != "option_hedge" and bool(result.get("option"))
|
||||
perp_ok = missing != "perp" and bool(result.get("perp"))
|
||||
else:
|
||||
opt_ok = True
|
||||
perp_ok = True
|
||||
premium = float((opt or {}).get("premium") or 0) if opt_ok else 0.0
|
||||
view = str(body.get("direction") or "long")
|
||||
perp_dir = (
|
||||
str((perp or {}).get("direction") or "")
|
||||
or perp_direction_for_view(view)
|
||||
)
|
||||
update_plan(
|
||||
conn,
|
||||
int(plan_id),
|
||||
status="partial" if is_partial else "active",
|
||||
entry_mark=float(body.get("entry") or body.get("index_px") or 0) or None,
|
||||
perp_size=float((perp or {}).get("contracts") or body.get("contracts") or 0),
|
||||
leverage=float(body.get("leverage") or 100),
|
||||
premium_total=premium,
|
||||
preview_json=_start_body_json(body, missing or None),
|
||||
close_reason="partial_fail" if is_partial else None,
|
||||
opened_at=result.get("opened_at"),
|
||||
note=(result.get("msg") or "")[:500] if is_partial else "盯盘达标已开仓",
|
||||
perp_direction=perp_dir,
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": int(plan_id),
|
||||
"leg_role": "perp",
|
||||
"symbol": str(body.get("exchange_symbol") or ""),
|
||||
"side": perp_dir,
|
||||
"size": float((perp or {}).get("contracts") or body.get("contracts") or 0),
|
||||
"avg_open": float(body.get("entry") or 0) if perp_ok else None,
|
||||
"status": "open" if perp_ok else "pending",
|
||||
"exchange_ord_id": str((perp or {}).get("exchange_ord_id") or ""),
|
||||
"opened_at": result.get("opened_at") if perp_ok else None,
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": int(plan_id),
|
||||
"leg_role": "option_hedge",
|
||||
"inst_id": str((opt or {}).get("inst_id") or body.get("opt_inst_id") or ""),
|
||||
"opt_type": str((opt or {}).get("opt_type") or body.get("opt_type") or ""),
|
||||
"strike": (opt or {}).get("strike") or body.get("strike"),
|
||||
"side": "buy",
|
||||
"size": float((opt or {}).get("sheets") or body.get("sheets") or 1),
|
||||
"avg_open": float((opt or {}).get("ask") or body.get("ask") or 0) if opt_ok else None,
|
||||
"premium": premium if opt_ok else 0,
|
||||
"ct_mult": float(body.get("ct_mult") or (opt or {}).get("ct_mult") or 0.01),
|
||||
"status": "open" if opt_ok else "pending",
|
||||
"exchange_ord_id": str((opt or {}).get("exchange_ord_id") or ""),
|
||||
"opened_at": result.get("opened_at") if opt_ok else None,
|
||||
},
|
||||
)
|
||||
if not is_partial:
|
||||
plan = get_plan(conn, int(plan_id))
|
||||
legs = get_plan_legs(conn, int(plan_id))
|
||||
if plan:
|
||||
notify_plan_start(cfg, conn, plan, legs)
|
||||
|
||||
|
||||
def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int:
|
||||
from lib.hedge_plan.hedge_plan_db import (
|
||||
get_plan,
|
||||
@@ -363,27 +537,36 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
|
||||
premium = (float(a.get("premium") or 0) if a_ok else 0.0) + (
|
||||
float(b.get("premium") or 0) if b_ok else 0.0
|
||||
)
|
||||
rr_raw = body.get("profit_rr")
|
||||
try:
|
||||
profit_rr = float(rr_raw) if rr_raw not in (None, "") else 2.0
|
||||
except (TypeError, ValueError):
|
||||
profit_rr = 2.0
|
||||
if profit_rr <= 0:
|
||||
profit_rr = 2.0
|
||||
# 旧字段兼容:不再要求上/下破;有传则原样落库
|
||||
def _opt_float(key: str, *alts: str) -> float | None:
|
||||
for k in (key, *alts):
|
||||
v = body.get(k)
|
||||
if v not in (None, ""):
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
up_f = _opt_float("target_price_up", "target_price")
|
||||
down_f = _opt_float("target_price_down", "target_price")
|
||||
plan_id = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "options_options",
|
||||
"status": "partial" if is_partial else "active",
|
||||
"underlying": str(body.get("underlying") or "ETH").upper(),
|
||||
"target_price": float(
|
||||
body.get("target_price_up")
|
||||
or body.get("target_price")
|
||||
or 0
|
||||
),
|
||||
"target_price_up": float(
|
||||
body.get("target_price_up")
|
||||
or body.get("target_price")
|
||||
or 0
|
||||
),
|
||||
"target_price_down": float(
|
||||
body.get("target_price_down")
|
||||
or body.get("target_price")
|
||||
or 0
|
||||
),
|
||||
"target_price": up_f,
|
||||
"target_price_up": up_f,
|
||||
"target_price_down": down_f,
|
||||
"profit_rr": profit_rr,
|
||||
"sizing_mode_at_open": load_position_sizing_mode(),
|
||||
"premium_total": premium,
|
||||
"oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")),
|
||||
@@ -454,22 +637,43 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
direction = (request.args.get("direction") or "long").strip().lower()
|
||||
if direction not in ("long", "short"):
|
||||
direction = "long"
|
||||
option_primary = (request.args.get("option_primary") or "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
data, err = _fetch_perp_market(cfg, base)
|
||||
if err:
|
||||
return jsonify({"ok": False, "msg": err}), 400
|
||||
sizing_mode = load_position_sizing_mode()
|
||||
gates = _gates_dict(cfg, "perp_options")
|
||||
if option_primary:
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import (
|
||||
opt_type_for_view,
|
||||
perp_direction_for_view,
|
||||
)
|
||||
|
||||
suggested = opt_type_for_view(direction)
|
||||
perp_dir = perp_direction_for_view(direction)
|
||||
acct_note = "以期权为主:看法腿买期权,永续反向对冲"
|
||||
else:
|
||||
suggested = "P" if direction == "long" else "C"
|
||||
perp_dir = direction
|
||||
acct_note = "永续腿使用合约(交易)账户可用 USDT"
|
||||
out = {
|
||||
"ok": True,
|
||||
"base": base,
|
||||
"direction": direction,
|
||||
"suggested_opt_type": "P" if direction == "long" else "C",
|
||||
"option_primary": option_primary,
|
||||
"suggested_opt_type": suggested,
|
||||
"perp_direction": perp_dir,
|
||||
**data,
|
||||
"gates": gates,
|
||||
"sizing_mode": sizing_mode,
|
||||
"account_kind": "perp",
|
||||
"account_label": cfg.get("perp_account_label") or "合约账户",
|
||||
"account_note": "永续腿使用合约(交易)账户可用 USDT",
|
||||
"account_note": acct_note,
|
||||
}
|
||||
return jsonify(out)
|
||||
|
||||
@@ -482,29 +686,91 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
if ex is None:
|
||||
return jsonify({"ok": False, "msg": "期权交易所未初始化"}), 400
|
||||
u = (request.args.get("underlying") or cfg.get("default_underly") or "ETH").upper()
|
||||
# 热更新:链展示天数每次读 env
|
||||
chain_max_dte = float(
|
||||
os.getenv("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS")
|
||||
or os.getenv("OKX_OPTIONS_MAX_DTE_DAYS")
|
||||
or cfg.get("chain_max_dte")
|
||||
or 14
|
||||
)
|
||||
try:
|
||||
chain = cfg["build_option_chain"](
|
||||
ex,
|
||||
u,
|
||||
max_dte_days=float(cfg.get("chain_max_dte") or 14),
|
||||
max_dte_days=chain_max_dte,
|
||||
itm_only=False,
|
||||
itm_max_dist_usd=float(os.getenv("OKX_OPTIONS_ITM_MAX_DIST_USD") or "30"),
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": f"拉取期权链失败: {e}"}), 500
|
||||
# 可选:永期以期权为主时按最低剩余小时/行权间隔过滤(仅当请求显式带 option_primary)
|
||||
# 默认拉链不再带此过滤,避免期期看不到明天到期
|
||||
option_primary = (request.args.get("option_primary") or "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
min_hours = None
|
||||
strike_interval = None
|
||||
try:
|
||||
if request.args.get("min_hours") not in (None, ""):
|
||||
min_hours = float(request.args.get("min_hours"))
|
||||
except (TypeError, ValueError):
|
||||
min_hours = 36.0 if option_primary else None
|
||||
try:
|
||||
if request.args.get("strike_interval") not in (None, ""):
|
||||
strike_interval = float(request.args.get("strike_interval"))
|
||||
except (TypeError, ValueError):
|
||||
strike_interval = 15.0 if option_primary else None
|
||||
if option_primary and min_hours is None:
|
||||
min_hours = 36.0
|
||||
if option_primary and strike_interval is None:
|
||||
strike_interval = 15.0
|
||||
if min_hours is not None or strike_interval is not None:
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import hours_to_expiry_from_ms
|
||||
|
||||
idx = None
|
||||
try:
|
||||
idx = float(chain.get("index_px") or 0) or None
|
||||
except (TypeError, ValueError):
|
||||
idx = None
|
||||
filtered = []
|
||||
for exp in chain.get("expiries") or []:
|
||||
h = hours_to_expiry_from_ms(exp.get("exp_time"))
|
||||
if min_hours is not None and h is not None and h < min_hours:
|
||||
continue
|
||||
contracts = []
|
||||
for c in exp.get("contracts") or []:
|
||||
row = dict(c)
|
||||
row["hours_to_expiry"] = h
|
||||
if strike_interval is not None and idx and idx > 0:
|
||||
try:
|
||||
k = float(row.get("strike") or 0)
|
||||
except (TypeError, ValueError):
|
||||
k = 0.0
|
||||
if k > 0 and abs(k - idx) > strike_interval + 1e-9:
|
||||
continue
|
||||
contracts.append(row)
|
||||
if contracts:
|
||||
filtered.append({**exp, "contracts": contracts, "hours_to_expiry": h})
|
||||
chain = {**chain, "expiries": filtered}
|
||||
opt_acct = _options_account_snapshot(cfg)
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
**chain,
|
||||
"underlying": u,
|
||||
"chain_max_dte_days": cfg.get("chain_max_dte"),
|
||||
"chain_max_dte_days": chain_max_dte,
|
||||
"account_kind": "options",
|
||||
"account_label": cfg.get("options_account_label") or "期权账户",
|
||||
"account_note": "期权腿使用期权账户(交易 USDC)",
|
||||
"options_account": opt_acct,
|
||||
"trade_budget_usdc": cfg.get("trade_budget_usdc"),
|
||||
"budget_buffer": cfg.get("budget_buffer"),
|
||||
"option_primary": option_primary,
|
||||
"min_hours": min_hours,
|
||||
"strike_interval": strike_interval,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -560,37 +826,84 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
body = request.get_json(silent=True) or {}
|
||||
plan_type = (body.get("plan_type") or "perp_options").strip().lower()
|
||||
dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False)
|
||||
gates = _gates_dict(cfg, plan_type)
|
||||
if not dry_run and not gates.get("can_start"):
|
||||
try:
|
||||
from lib.options.options_margin_mode_lib import is_coin_margin_mode
|
||||
|
||||
if is_coin_margin_mode() and not dry_run:
|
||||
return jsonify(
|
||||
{
|
||||
"ok": False,
|
||||
"msg": "当前单笔期权为币本位模式,对冲计划仅支持 USDC 期权;请将 OKX_OPTIONS_MARGIN_MODE=usdc 并重启后再开对冲",
|
||||
}
|
||||
), 400
|
||||
except Exception as e:
|
||||
return jsonify(
|
||||
{"ok": False, "msg": "; ".join(gates.get("reasons") or ["不可开仓"]), "gates": gates}
|
||||
{"ok": False, "msg": f"期权本位校验失败,已拒绝开对冲: {e}"}
|
||||
), 400
|
||||
err = validate_start_body(plan_type, body)
|
||||
if err:
|
||||
return jsonify({"ok": False, "msg": err, "gates": gates}), 400
|
||||
# 补齐永续杠杆
|
||||
if plan_type == "perp_options" and not body.get("leverage"):
|
||||
base = str(body.get("underlying") or "ETH").upper()
|
||||
body["leverage"] = cfg.get("btc_leverage") if base == "BTC" else (cfg.get("btc_leverage") or 10)
|
||||
# ETH 也用 BTC 档 10x 按方案;ALT 为 alt_leverage 仅非 BTC/ETH
|
||||
if base in ("BTC", "ETH"):
|
||||
body["leverage"] = int(cfg.get("btc_leverage") or 10)
|
||||
if plan_type == "options_options":
|
||||
out = execute_options_options_start(
|
||||
cfg,
|
||||
body,
|
||||
dry_run=dry_run,
|
||||
persist=(None if dry_run else (lambda r, b: _persist_oo(cfg, r, b))),
|
||||
)
|
||||
else:
|
||||
out = execute_perp_options_start(
|
||||
cfg,
|
||||
body,
|
||||
dry_run=dry_run,
|
||||
persist=(None if dry_run else (lambda r, b: _persist_po(cfg, r, b))),
|
||||
)
|
||||
out["gates"] = gates
|
||||
return jsonify(out), (200 if out.get("ok") else 400)
|
||||
with _hedge_start_lock():
|
||||
gates = _gates_dict(cfg, plan_type)
|
||||
if not dry_run and not gates.get("can_start"):
|
||||
return jsonify(
|
||||
{"ok": False, "msg": "; ".join(gates.get("reasons") or ["不可开仓"]), "gates": gates}
|
||||
), 400
|
||||
err = validate_start_body(plan_type, body)
|
||||
if err:
|
||||
return jsonify({"ok": False, "msg": err, "gates": gates}), 400
|
||||
# 补齐永续杠杆(以期权为主默认 100;保险模式 BTC/ETH 用 btc_leverage)
|
||||
if plan_type == "perp_options" and not body.get("leverage"):
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary
|
||||
|
||||
if is_option_primary(body):
|
||||
body["leverage"] = 100
|
||||
else:
|
||||
base = str(body.get("underlying") or "ETH").upper()
|
||||
if base in ("BTC", "ETH"):
|
||||
body["leverage"] = int(cfg.get("btc_leverage") or 10)
|
||||
else:
|
||||
body["leverage"] = int(cfg.get("alt_leverage") or 5)
|
||||
# 以期权为主:策略启动=盯盘,不现场开仓
|
||||
if plan_type == "perp_options":
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary
|
||||
|
||||
watch = body.get("watch_entry")
|
||||
watch_on = watch in (None, "", True, 1, "1", "true", "yes", "on")
|
||||
if is_option_primary(body) and watch_on:
|
||||
if dry_run:
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"dry_run": True,
|
||||
"watching": True,
|
||||
"msg": "dry_run:将创建盯盘计划(不落库)",
|
||||
"gates": gates,
|
||||
}
|
||||
)
|
||||
plan_id = _persist_po_watching(cfg, body)
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"watching": True,
|
||||
"plan_id": plan_id,
|
||||
"msg": "已启动盯盘,杠杆/间隔达标后自动开仓",
|
||||
"gates": gates,
|
||||
}
|
||||
)
|
||||
if plan_type == "options_options":
|
||||
out = execute_options_options_start(
|
||||
cfg,
|
||||
body,
|
||||
dry_run=dry_run,
|
||||
persist=(None if dry_run else (lambda r, b: _persist_oo(cfg, r, b))),
|
||||
)
|
||||
else:
|
||||
out = execute_perp_options_start(
|
||||
cfg,
|
||||
body,
|
||||
dry_run=dry_run,
|
||||
persist=(None if dry_run else (lambda r, b: _persist_po(cfg, r, b))),
|
||||
)
|
||||
out["gates"] = gates
|
||||
return jsonify(out), (200 if out.get("ok") else 400)
|
||||
|
||||
@app.route("/api/hedge-plan/<int:plan_id>/end", methods=["POST"])
|
||||
@lr
|
||||
@@ -628,12 +941,19 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False)
|
||||
if not dry_run and not _hedge_enabled():
|
||||
return jsonify({"ok": False, "msg": "当前交易模式为单独期权,不可补开对冲腿"}), 400
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_hedge_plan_tables(conn)
|
||||
plan = get_plan(conn, plan_id)
|
||||
if not plan:
|
||||
return jsonify({"ok": False, "msg": "计划不存在"}), 404
|
||||
pt = str(plan.get("plan_type") or "")
|
||||
if pt == "perp_options" and not _show_perp_options():
|
||||
return jsonify({"ok": False, "msg": "当前模式非永期对冲,不可补开"}), 400
|
||||
if pt == "options_options" and not _show_options_options():
|
||||
return jsonify({"ok": False, "msg": "当前模式非期期对冲,不可补开"}), 400
|
||||
if str(plan.get("status") or "") != "partial":
|
||||
return jsonify({"ok": False, "msg": "仅半腿待补(partial)计划可补开"}), 400
|
||||
legs = get_plan_legs(conn, plan_id)
|
||||
@@ -770,17 +1090,19 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
try:
|
||||
init_hedge_plan_tables(conn)
|
||||
rows = []
|
||||
for status in ("opening", "active", "partial"):
|
||||
for status in ("watching", "opening", "active", "partial"):
|
||||
rows.extend(list_plans(conn, status=status, limit=80))
|
||||
rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True)
|
||||
for row in rows:
|
||||
if str(row.get("status") or "") == "watching":
|
||||
continue
|
||||
try:
|
||||
reconcile_unfilled_option_legs(cfg, conn, int(row["id"]))
|
||||
except Exception:
|
||||
pass
|
||||
# 校正后可能 status 变化,重新拉一遍
|
||||
rows = []
|
||||
for status in ("opening", "active", "partial"):
|
||||
for status in ("watching", "opening", "active", "partial"):
|
||||
rows.extend(list_plans(conn, status=status, limit=80))
|
||||
rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True)
|
||||
plans = attach_legs_to_plans(conn, rows)
|
||||
@@ -862,6 +1184,37 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
def _preview_po(body: dict[str, Any]) -> dict[str, Any]:
|
||||
from lib.hedge_plan.hedge_plan_moneyness_lib import validate_po_option_moneyness
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import (
|
||||
build_option_primary_preview,
|
||||
is_option_primary,
|
||||
size_from_premium,
|
||||
validate_option_primary_start,
|
||||
)
|
||||
|
||||
if is_option_primary(body):
|
||||
err = validate_option_primary_start(body)
|
||||
if err:
|
||||
raise ValueError(err)
|
||||
sized = size_from_premium(
|
||||
premium_budget=float(body.get("premium_budget") or 0),
|
||||
ask=float(body.get("ask") or 0),
|
||||
ct_mult=float(body.get("ct_mult") or 0.01),
|
||||
ratio=float(body.get("option_perp_ratio") or 2),
|
||||
contract_size=float(body.get("contract_size") or 0.01),
|
||||
)
|
||||
if not sized.get("ok"):
|
||||
raise ValueError(sized.get("msg") or "定仓失败")
|
||||
body = dict(body)
|
||||
body["sheets"] = sized["sheets"]
|
||||
body["contracts"] = sized["contracts"]
|
||||
body["eth_qty"] = sized["eth_qty"]
|
||||
if not body.get("entry"):
|
||||
body["entry"] = body.get("index_px") or 0
|
||||
out = build_option_primary_preview(body)
|
||||
out["sizing"] = sized
|
||||
return out
|
||||
|
||||
direction = str(body.get("direction") or "long").lower()
|
||||
entry = float(body["entry"])
|
||||
tp = float(body["tp"])
|
||||
@@ -879,6 +1232,16 @@ def _preview_po(body: dict[str, Any]) -> dict[str, Any]:
|
||||
raise ValueError("缺少权利金或卖一价")
|
||||
premium = option_premium_total(ask=float(ask), sheets=sheets, ct_mult=ct_mult)
|
||||
index_px = body.get("index_px")
|
||||
idx_for_money = float(index_px) if index_px is not None else entry
|
||||
money_err = validate_po_option_moneyness(
|
||||
opt_type=opt_type,
|
||||
strike=strike,
|
||||
index_px=idx_for_money,
|
||||
ask=ask,
|
||||
hours_to_expiry=body.get("hours_to_expiry"),
|
||||
)
|
||||
if money_err:
|
||||
raise ValueError(money_err)
|
||||
return build_perp_options_preview(
|
||||
direction=direction,
|
||||
entry=entry,
|
||||
@@ -896,6 +1259,14 @@ def _preview_po(body: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
|
||||
from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness
|
||||
|
||||
rr_raw = body.get("profit_rr")
|
||||
profit_rr = None
|
||||
if rr_raw not in (None, ""):
|
||||
profit_rr = float(rr_raw)
|
||||
if profit_rr <= 0:
|
||||
raise ValueError("盈亏比须大于0")
|
||||
up = body.get("target_price_up")
|
||||
down = body.get("target_price_down")
|
||||
legacy = body.get("target_price")
|
||||
@@ -903,13 +1274,19 @@ def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
|
||||
up = legacy
|
||||
if down in (None, "") and legacy not in (None, ""):
|
||||
down = legacy
|
||||
if up in (None, "") or down in (None, ""):
|
||||
raise ValueError("请填写上破与下破目标价")
|
||||
up_f = float(up)
|
||||
down_f = float(down)
|
||||
if up_f <= down_f:
|
||||
if profit_rr is None and (up in (None, "") or down in (None, "")):
|
||||
raise ValueError("请填写盈亏比")
|
||||
up_f = float(up) if up not in (None, "") else None
|
||||
down_f = float(down) if down not in (None, "") else None
|
||||
if profit_rr is None and up_f is not None and down_f is not None and up_f <= down_f:
|
||||
raise ValueError("上破目标价必须大于下破目标价")
|
||||
index_px = float(body.get("index_px") or ((up_f + down_f) / 2))
|
||||
index_px = body.get("index_px")
|
||||
if index_px in (None, ""):
|
||||
if up_f is not None and down_f is not None:
|
||||
index_px = (up_f + down_f) / 2
|
||||
else:
|
||||
raise ValueError("缺少指数价格")
|
||||
index_px = float(index_px)
|
||||
leg_a = body.get("leg_a") or {}
|
||||
leg_b = body.get("leg_b") or {}
|
||||
for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)):
|
||||
@@ -923,7 +1300,11 @@ def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
|
||||
)
|
||||
if leg.get("premium_paid") is None:
|
||||
raise ValueError(f"缺少 {name} 权利金")
|
||||
money_err = validate_oo_legs_moneyness(leg_a, leg_b, index_px=index_px)
|
||||
if money_err:
|
||||
raise ValueError(money_err)
|
||||
return build_options_options_preview(
|
||||
profit_rr=profit_rr,
|
||||
target_price_up=up_f,
|
||||
target_price_down=down_f,
|
||||
index_px=index_px,
|
||||
|
||||
@@ -109,7 +109,11 @@ def resolve_option_leg_realized_pnl(
|
||||
except Exception:
|
||||
rows = None
|
||||
if rows:
|
||||
info = resolve_option_close_from_history(rows, open_ms=open_ms)
|
||||
close_ms = _parse_opened_ms(leg.get("closed_at"))
|
||||
sheets = _sf(leg.get("size")) or _sf(leg.get("sheets"))
|
||||
info = resolve_option_close_from_history(
|
||||
rows, open_ms=open_ms, close_ms=close_ms, sheets=sheets
|
||||
)
|
||||
pnl = _sf((info or {}).get("realized_pnl")) if info else None
|
||||
if pnl is not None:
|
||||
return round(float(pnl), 4), "exchange"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""OKX 期权/对冲三选一模式(env: OKX_TRADE_MODE).
|
||||
|
||||
options → 仅单独期权(隐藏对冲导航与对冲 env 配置)
|
||||
perp_options → 仅永期对冲(不可单独开期权;对冲组数上限 MAX_ACTIVE_HEDGE_PLANS)
|
||||
options_options → 仅期期对冲(同上)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
MODE_OPTIONS = "options"
|
||||
MODE_PERP = "perp_options"
|
||||
MODE_OO = "options_options"
|
||||
VALID_MODES = frozenset({MODE_OPTIONS, MODE_PERP, MODE_OO})
|
||||
|
||||
_ALIASES = {
|
||||
"option": MODE_OPTIONS,
|
||||
"standalone": MODE_OPTIONS,
|
||||
"期权": MODE_OPTIONS,
|
||||
"单独期权": MODE_OPTIONS,
|
||||
"po": MODE_PERP,
|
||||
"perp": MODE_PERP,
|
||||
"永期": MODE_PERP,
|
||||
"永期对冲": MODE_PERP,
|
||||
"oo": MODE_OO,
|
||||
"期期": MODE_OO,
|
||||
"期期对冲": MODE_OO,
|
||||
}
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return default
|
||||
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def normalize_okx_trade_mode(raw: Optional[str]) -> str:
|
||||
s = str(raw or "").strip().lower()
|
||||
if s in VALID_MODES:
|
||||
return s
|
||||
if s in _ALIASES:
|
||||
return _ALIASES[s]
|
||||
return ""
|
||||
|
||||
|
||||
def legacy_infer_okx_trade_mode() -> str:
|
||||
"""未配置 OKX_TRADE_MODE 时,按旧开关推断,避免已有部署行为突变."""
|
||||
if not _env_bool("HEDGE_PLAN_ENABLED", False):
|
||||
return MODE_OPTIONS
|
||||
show_po = _env_bool("HEDGE_PLAN_SHOW_PERP_OPTIONS", True)
|
||||
show_oo = _env_bool("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", True)
|
||||
if show_po and not show_oo:
|
||||
return MODE_PERP
|
||||
if show_oo and not show_po:
|
||||
return MODE_OO
|
||||
if show_po:
|
||||
return MODE_PERP
|
||||
if show_oo:
|
||||
return MODE_OO
|
||||
return MODE_OPTIONS
|
||||
|
||||
|
||||
def get_okx_trade_mode() -> str:
|
||||
m = normalize_okx_trade_mode(os.getenv("OKX_TRADE_MODE"))
|
||||
if m:
|
||||
return m
|
||||
return legacy_infer_okx_trade_mode()
|
||||
|
||||
|
||||
def hedge_module_enabled() -> bool:
|
||||
return get_okx_trade_mode() in (MODE_PERP, MODE_OO)
|
||||
|
||||
|
||||
def show_perp_options() -> bool:
|
||||
return get_okx_trade_mode() == MODE_PERP
|
||||
|
||||
|
||||
def show_options_options() -> bool:
|
||||
return get_okx_trade_mode() == MODE_OO
|
||||
|
||||
|
||||
def standalone_options_open_allowed() -> bool:
|
||||
return get_okx_trade_mode() == MODE_OPTIONS
|
||||
|
||||
|
||||
def mode_label(mode: Optional[str] = None) -> str:
|
||||
m = mode or get_okx_trade_mode()
|
||||
return {
|
||||
MODE_OPTIONS: "单独期权",
|
||||
MODE_PERP: "永期对冲",
|
||||
MODE_OO: "期期对冲",
|
||||
}.get(m, m or "—")
|
||||
|
||||
|
||||
def block_standalone_open_by_mode_msg() -> Optional[str]:
|
||||
if standalone_options_open_allowed():
|
||||
return None
|
||||
return (
|
||||
f"当前交易模式为「{mode_label()}」,不可单独开期权;"
|
||||
"请在 env「交易模式」切换为「单独期权」"
|
||||
)
|
||||
@@ -2,9 +2,10 @@
|
||||
data-default-underly="{{ options_default_underly | default('ETH') }}"
|
||||
data-hedge-enabled="{{ '1' if hedge_plan_enabled else '0' }}"
|
||||
data-options-enabled="{{ '1' if options_enabled else '0' }}"
|
||||
data-show-perp="{{ '1' if hedge_plan_show_perp_options | default(true) else '0' }}"
|
||||
data-show-oo="{{ '1' if hedge_plan_show_options_options | default(true) else '0' }}"
|
||||
data-oo-close-mode-enabled="{{ '1' if hedge_plan_oo_close_mode_enabled | default(true) else '0' }}"
|
||||
data-show-perp="{{ '1' if hedge_plan_show_perp_options else '0' }}"
|
||||
data-show-oo="{{ '1' if hedge_plan_show_options_options else '0' }}"
|
||||
data-oo-close-mode-enabled="{{ '1' if hedge_plan_oo_close_mode_enabled else '0' }}"
|
||||
data-option-primary="{{ '1' if hedge_plan_option_primary|default(true) else '0' }}"
|
||||
data-budget-buffer="{{ hedge_plan_budget_buffer | default(0.95) }}"
|
||||
data-sizing-mode="{{ position_sizing_mode | default('risk') }}"
|
||||
data-is-full-margin="{{ '1' if position_sizing_mode == 'full_margin' else '0' }}">
|
||||
@@ -14,8 +15,8 @@
|
||||
{% if not options_enabled %}
|
||||
<div class="flash" style="margin-bottom:12px">期权模块未启用,无法拉期权链.请先配置期权账户.</div>
|
||||
{% endif %}
|
||||
{% if hedge_plan_enabled and not (hedge_plan_show_perp_options | default(true)) and not (hedge_plan_show_options_options | default(true)) %}
|
||||
<div class="flash" style="margin-bottom:12px">永期与期期 Tab 均已隐藏:可在 <code>env配置 → 对冲计划</code> 打开显示开关;进行中/历史仍可查看.</div>
|
||||
{% if hedge_plan_enabled and not hedge_plan_show_perp_options and not hedge_plan_show_options_options %}
|
||||
<div class="flash" style="margin-bottom:12px">永期与期期 Tab 均已隐藏:请在 env「期权/对冲模式」切换交易模式;进行中/历史仍可查看.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card hp-head-card">
|
||||
@@ -26,10 +27,10 @@
|
||||
<button type="button" class="btn-secondary" id="hp-refresh" title="刷新永续行情与期权链">刷新行情</button>
|
||||
</div>
|
||||
<div class="hp-tabs" role="tablist" aria-label="对冲计划分类">
|
||||
{% if hedge_plan_show_perp_options | default(true) %}
|
||||
{% if hedge_plan_show_perp_options %}
|
||||
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="perp_options">永期对冲</button>
|
||||
{% endif %}
|
||||
{% if hedge_plan_show_options_options | default(true) %}
|
||||
{% if hedge_plan_show_options_options %}
|
||||
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="options_options">期期对冲</button>
|
||||
{% endif %}
|
||||
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="active">进行中的计划</button>
|
||||
@@ -43,13 +44,19 @@
|
||||
<div id="hp-tab-perp_options" class="hp-tab-panel" role="tabpanel">
|
||||
<div class="options-dual-grid" id="hp-po-layout">
|
||||
<div class="card hp-po-perp-card">
|
||||
<h2>永续 · <span id="hp-perp-uly-label">ETH</span> <span class="muted hp-acct-tag" id="hp-perp-acct-tag">合约账户</span></h2>
|
||||
<h2>
|
||||
<span id="hp-po-mode-badge" class="hp-po-mode-badge">以期权为主</span>
|
||||
<span id="hp-po-card-title">执行参数</span>
|
||||
· <span id="hp-perp-uly-label">ETH</span>
|
||||
<span class="muted hp-acct-tag" id="hp-perp-acct-tag">合约账户</span>
|
||||
</h2>
|
||||
<details class="tip-collapse hp-rule-collapse">
|
||||
<summary class="tip-collapse-summary">规则说明</summary>
|
||||
<div class="tip-collapse-body rule-tip">
|
||||
<p><strong>账户</strong>:永续腿走<strong>合约账户</strong>(USDT);保险期权走<strong>期权账户</strong>(USDC)。两账户分开下单、资金不互通。</p>
|
||||
<p><strong>下单</strong>:先「计算」再「启动」。启动瞬间会再拉卖一并以 IOC 等完全成交;半腿失败可补开或「结束计划」(不平仓)。永期开仓需全仓计仓 + 对冲实盘门禁。</p>
|
||||
<p><strong>板块</strong>:左填永续开仓/止盈止损与张数;右选保险腿(做多配 Put、做空配 Call)。止盈后保险腿默认可持有;止损会联动平期权。</p>
|
||||
<p><strong>账户</strong>:永续腿走<strong>合约账户</strong>(USDT);期权腿走<strong>期权账户</strong>(USDC)。两账户分开下单、资金不互通。</p>
|
||||
<p><strong>模式</strong>:在 env <code>HEDGE_PLAN_OPTION_PRIMARY</code> 切换(true=以期权为主 / false=保险模式);标题前标识当前模式。</p>
|
||||
<p><strong>保险模式</strong>:做多配 Put、做空配 Call;左填开仓/止盈止损;仅实值/平值;交易所 TP/SL 出场。</p>
|
||||
<p><strong>以期权为主</strong>:填参后点「策略启动」进入<strong>盯盘</strong>(非现场开仓);杠杆/间隔达标后自动先开期权再市价永续。右侧列表仅展示达标候选。</p>
|
||||
</div>
|
||||
</details>
|
||||
<div class="form-row hp-uly-row">
|
||||
@@ -58,13 +65,11 @@
|
||||
</div>
|
||||
<div class="hp-po-top">
|
||||
<div class="hp-oo-seg hp-po-dir-seg" role="group" aria-label="方向">
|
||||
<button type="button" class="btn-secondary hp-po-dir is-selected" data-dir="long" title="做多永续"><span class="hp-oo-check" aria-hidden="true">✓</span>做多</button>
|
||||
<button type="button" class="btn-secondary hp-po-dir" data-dir="short" title="做空永续"><span class="hp-oo-check" aria-hidden="true">✓</span>做空</button>
|
||||
<button type="button" class="btn-secondary hp-po-dir is-selected" data-dir="long" title="做多=买Call+永续空"><span class="hp-oo-check" aria-hidden="true">✓</span>做多</button>
|
||||
<button type="button" class="btn-secondary hp-po-dir" data-dir="short" title="做空=买Put+永续多"><span class="hp-oo-check" aria-hidden="true">✓</span>做空</button>
|
||||
</div>
|
||||
<span id="hp-po-mark" class="hp-po-mark" aria-live="polite">标记 —</span>
|
||||
</div>
|
||||
<p id="hp-perp-quote" class="muted hp-po-meta">加载中…</p>
|
||||
<div class="hp-po-fields">
|
||||
<div class="hp-po-fields hidden" id="hp-po-fields-insurance" hidden>
|
||||
<label class="hp-po-field">
|
||||
<span class="hp-po-field-lab">开仓价 <em>USDT</em></span>
|
||||
<input type="number" step="any" id="hp-entry" placeholder="入场价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
@@ -82,45 +87,118 @@
|
||||
<input type="number" step="any" id="hp-sl" placeholder="保护价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
</div>
|
||||
<div id="hp-po-fields-option-primary">
|
||||
<section class="hp-po-section" aria-labelledby="hp-po-sec-capital">
|
||||
<h3 id="hp-po-sec-capital" class="hp-po-section-title">资金与杠杆配置</h3>
|
||||
<div class="hp-po-fields hp-po-fields--section hp-po-fields--capital">
|
||||
<label class="hp-po-field">
|
||||
<span class="hp-po-field-lab">权利金 <em>USDC</em></span>
|
||||
<input type="number" step="any" id="hp-premium-budget" placeholder="预算(执行×0.95)" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
<label class="hp-po-field">
|
||||
<span class="hp-po-field-lab">永续杠杆</span>
|
||||
<input type="number" step="1" id="hp-perp-leverage" value="100" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
<label class="hp-po-field">
|
||||
<span class="hp-po-field-lab">期权杠杆 <em>启动校验</em></span>
|
||||
<input type="number" step="1" id="hp-opt-leverage" value="100" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="hp-po-section" aria-labelledby="hp-po-sec-select">
|
||||
<h3 id="hp-po-sec-select" class="hp-po-section-title">选约条件</h3>
|
||||
<div class="hp-po-fields hp-po-fields--section hp-po-fields--select">
|
||||
<label class="hp-po-field">
|
||||
<span class="hp-po-field-lab">到期时间 <em>最短h</em></span>
|
||||
<input type="number" step="1" id="hp-min-hours" value="36" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
<label class="hp-po-field">
|
||||
<span class="hp-po-field-lab">期权间隔 <em>点</em></span>
|
||||
<input type="number" step="any" id="hp-strike-interval" value="15" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
<label class="hp-po-field hp-po-field--type">
|
||||
<span class="hp-po-field-lab">类型</span>
|
||||
<select id="hp-money-select" aria-label="虚实值类型">
|
||||
<option value="otm" selected>虚值</option>
|
||||
<option value="itm">实值/平值</option>
|
||||
<option value="atm">仅平值</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="hp-po-field">
|
||||
<span class="hp-po-field-lab">比例 <em>期权:永续</em></span>
|
||||
<input type="number" step="any" id="hp-opt-perp-ratio" value="2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="hp-po-section" aria-labelledby="hp-po-sec-exit">
|
||||
<h3 id="hp-po-sec-exit" class="hp-po-section-title">出场条件</h3>
|
||||
<div class="hp-po-fields hp-po-fields--section">
|
||||
<label class="hp-po-field">
|
||||
<span class="hp-po-field-lab">期权目标位 <em>相对K</em></span>
|
||||
<input type="number" step="any" id="hp-opt-target-pts" placeholder="点数" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
<label class="hp-po-field">
|
||||
<span class="hp-po-field-lab">永续目标位 <em>相对K</em></span>
|
||||
<input type="number" step="any" id="hp-perp-target-pts" placeholder="点数" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="hp-po-summary">
|
||||
<div id="hp-perp-pnl-line" class="hp-po-pnl"></div>
|
||||
<div id="hp-sizing-line" class="muted hp-po-sizing"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card hp-opt-card">
|
||||
<h2>期权 · <span id="hp-opt-type-label">Put</span> <span class="muted hp-acct-tag" id="hp-opt-acct-tag">期权账户</span></h2>
|
||||
<div class="form-row hp-opt-toolbar hp-po-opt-toolbar">
|
||||
<select id="hp-exp-select"><option value="">选择到期日</option></select>
|
||||
<button type="button" class="btn-secondary hp-money-btn active" data-money="all">全部</button>
|
||||
<button type="button" class="btn-secondary hp-money-btn" data-money="itm">实值</button>
|
||||
<button type="button" class="btn-secondary hp-money-btn" data-money="otm">虚值</button>
|
||||
<button type="button" class="btn-secondary" id="hp-load-chain">刷新链</button>
|
||||
<span id="hp-index-line" class="hp-po-index" aria-live="polite">指数 —</span>
|
||||
<div class="card hp-po-right-card">
|
||||
<div class="hp-po-right-stack">
|
||||
<div class="hp-po-inner-card hp-po-perp-quote-card">
|
||||
<h2>永续行情 <span class="muted hp-acct-tag">合约账户</span></h2>
|
||||
<div class="hp-po-quote-head">
|
||||
<span id="hp-po-mark" class="hp-po-mark" aria-live="polite">标记 —</span>
|
||||
</div>
|
||||
<p id="hp-perp-quote" class="muted hp-po-meta">加载中…</p>
|
||||
<p id="hp-po-perp-quote-right" class="muted hp-po-meta" hidden></p>
|
||||
</div>
|
||||
<div class="hp-po-inner-card hp-opt-card">
|
||||
<h2>期权 · <span id="hp-opt-type-label">Call</span> <span class="muted hp-acct-tag" id="hp-opt-acct-tag">期权账户</span></h2>
|
||||
<div class="form-row hp-opt-toolbar hp-po-opt-toolbar">
|
||||
<select id="hp-exp-select"><option value="">选择到期日</option></select>
|
||||
<span class="hp-po-ins-money" id="hp-po-ins-money" hidden>
|
||||
<button type="button" class="btn-secondary hp-money-btn active" data-money="itm" title="实值+平值">实值/平值</button>
|
||||
<button type="button" class="btn-secondary hp-money-btn" data-money="atm" title="仅平值">仅平值</button>
|
||||
</span>
|
||||
<button type="button" class="btn-secondary" id="hp-recommend-opt" title="按当前类型自动匹配最近合约">自动匹配</button>
|
||||
<button type="button" class="btn-secondary" id="hp-load-chain">刷新链</button>
|
||||
<span id="hp-index-line" class="hp-po-index" aria-live="polite">指数 —</span>
|
||||
</div>
|
||||
<div class="options-strike-table-wrap hp-strike-table-wrap--6">
|
||||
<table class="options-strike-table" id="hp-strike-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>行权价</th>
|
||||
<th>实虚值</th>
|
||||
<th title="指数÷卖一">杠杆</th>
|
||||
<th>卖一/张</th>
|
||||
<th>买一/张</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="hp-strike-tbody">
|
||||
<tr><td colspan="6" class="muted">请刷新期权链</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="form-row hp-pick-row">
|
||||
<label>已选 <code id="hp-sel-inst">—</code></label>
|
||||
<label>张数 <span class="hp-unit">期权张</span> <input type="number" step="1" min="1" id="hp-sheets" value="1" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
|
||||
<span class="muted" id="hp-premium-line"></span>
|
||||
</div>
|
||||
<div id="hp-opt-bal-line" class="muted hp-po-meta hp-opt-bal-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="options-strike-table-wrap hp-strike-table-wrap--5">
|
||||
<table class="options-strike-table" id="hp-strike-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>行权价</th>
|
||||
<th>实虚值</th>
|
||||
<th>卖一/张</th>
|
||||
<th>买一/张</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="hp-strike-tbody">
|
||||
<tr><td colspan="5" class="muted">请刷新期权链</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="form-row hp-pick-row">
|
||||
<label>已选 <code id="hp-sel-inst">—</code></label>
|
||||
<label>张数 <span class="hp-unit">期权张</span> <input type="number" step="1" min="1" id="hp-sheets" value="1" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
|
||||
<span class="muted" id="hp-premium-line"></span>
|
||||
</div>
|
||||
<div id="hp-opt-bal-line" class="muted hp-po-meta hp-opt-bal-line"></div>
|
||||
<div class="form-row hp-action-row">
|
||||
<button type="button" class="primary" id="hp-preview-btn">计算</button>
|
||||
<div class="form-row hp-action-row hp-po-action-row">
|
||||
<span id="hp-po-strategy-status" class="hp-po-strategy-status" aria-live="polite"></span>
|
||||
<button type="button" class="primary" id="hp-preview-btn" title="以期权为主=盯盘启动">策略启动</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,7 +213,7 @@
|
||||
<div class="tip-collapse-body rule-tip">
|
||||
<p><strong>账户</strong>:两腿都在<strong>期权账户</strong>。可用预算 = min(交易 USDC × 对冲缓冲 <strong id="hp-oo-buf-ratio">{{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「对冲预算缓冲比例」改。</p>
|
||||
<p><strong>下单</strong>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p>
|
||||
<p><strong>板块</strong>:左填上破/下破与张数模式(同张数/做多/做空);右 T 型选腿。「全平」= 盈利腿平后清另一腿;「到期平」= 另一腿持有至到期。</p>
|
||||
<p><strong>板块</strong>:左填<strong>盈亏比</strong>(盈利金额÷总权利金,默认2)与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。出场:盈利腿达盈亏比即平;亏损腿「残值平」=本合约权利金跌至20%且有买一时平,「到期平」=持有至到期。</p>
|
||||
</div>
|
||||
</details>
|
||||
<div class="form-row hp-uly-row">
|
||||
@@ -143,8 +221,7 @@
|
||||
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
|
||||
</div>
|
||||
<div class="form-row hp-target-row hp-oo-target-row">
|
||||
<label>上破目标 <input type="number" step="any" id="hp-target-up" placeholder="向上突破" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
|
||||
<label>下破目标 <input type="number" step="any" id="hp-target-down" placeholder="向下突破" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
|
||||
<label title="盈利金额 / 总权利金">盈亏比 <input type="number" step="0.1" min="0.1" id="hp-profit-rr" value="2" placeholder="默认2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
|
||||
<span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span>
|
||||
</div>
|
||||
<div class="hp-oo-controls">
|
||||
@@ -159,7 +236,7 @@
|
||||
<div class="hp-oo-ctrl" id="hp-oo-close-mode-row">
|
||||
<span class="hp-oo-ctrl-lab" title="仅控制盈利腿平掉后的另一腿">平仓</span>
|
||||
<div class="hp-oo-seg" role="group" aria-label="平仓模式">
|
||||
<button type="button" class="btn-secondary hp-oo-close-mode is-selected" data-oo-close="close_all" title="盈利腿平后立刻买一清另一腿(无2×,失败重试)"><span class="hp-oo-check" aria-hidden="true">✓</span>全平</button>
|
||||
<button type="button" class="btn-secondary hp-oo-close-mode is-selected" data-oo-close="close_all" title="盈利腿平后:亏损腿本合约权利金跌至20%且有买一时平掉(失败重试)"><span class="hp-oo-check" aria-hidden="true">✓</span>残值平</button>
|
||||
<button type="button" class="btn-secondary hp-oo-close-mode" data-oo-close="hold_expiry" title="盈利腿平后另一腿持有至到期"><span class="hp-oo-check" aria-hidden="true">✓</span>到期平</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,24 +262,30 @@
|
||||
<h2>期权 T 型报价</h2>
|
||||
<div class="form-row">
|
||||
<select id="hp-oo-exp-select"><option value="">选择到期日</option></select>
|
||||
<button type="button" class="btn-secondary hp-oo-money-btn is-selected active" data-oo-money="atm_otm" title="平值+虚值" aria-pressed="true"><span class="hp-oo-check" aria-hidden="true">✓</span>平/虚</button>
|
||||
<button type="button" class="btn-secondary hp-oo-money-btn" data-oo-money="atm" title="仅平值" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>仅平值</button>
|
||||
<button type="button" class="btn-secondary hp-oo-money-btn" data-oo-money="otm" title="仅虚值" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>仅虚值</button>
|
||||
<button type="button" class="btn-secondary hp-oo-recommend-btn" id="hp-oo-recommend-atm" data-oo-rec="atm_straddle" title="最近平值 Call+Put" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>推荐跨式</button>
|
||||
<button type="button" class="btn-secondary hp-oo-recommend-btn" id="hp-oo-recommend-otm" data-oo-rec="double_otm" title="最近虚值 Call+Put" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>推荐双虚</button>
|
||||
<button type="button" class="btn-secondary" id="hp-oo-load-chain">刷新链</button>
|
||||
<button type="button" class="btn-secondary hp-oo-expand-btn" id="hp-oo-expand-all" title="展开该到期全部平值/虚值行权价;若当前为「仅平值」会自动切到「平/虚」" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>显示全部</button>
|
||||
</div>
|
||||
<div class="options-strike-table-wrap options-strike-table-wrap--t">
|
||||
<div class="options-strike-table-wrap hp-oo-table-wrap" id="hp-oo-table-wrap">
|
||||
<table class="options-strike-table options-strike-table--t" id="hp-oo-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="3" class="opt-t-head-call">Call</th>
|
||||
<th colspan="4" class="opt-t-head-call">Call</th>
|
||||
<th class="opt-t-head-mid">行权</th>
|
||||
<th colspan="3" class="opt-t-head-put">Put</th>
|
||||
<th colspan="4" class="opt-t-head-put">Put</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>卖一/张</th><th>实虚值</th><th>选用</th>
|
||||
<th>卖一/张</th><th title="行权价÷卖一">杠杆</th><th>实虚值</th><th>选用</th>
|
||||
<th>K</th>
|
||||
<th>实虚值</th><th>卖一/张</th><th>选用</th>
|
||||
<th>实虚值</th><th>卖一/张</th><th title="行权价÷卖一">杠杆</th><th>选用</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="hp-oo-tbody">
|
||||
<tr><td colspan="7" class="muted">请刷新期权链</td></tr>
|
||||
<tr><td colspan="9" class="muted">请刷新期权链</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -322,4 +405,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/hedge_plan.js?v=33"></script>
|
||||
<script src="/static/hedge_plan.js?v=46"></script>
|
||||
|
||||
@@ -0,0 +1,888 @@
|
||||
"""中控振幅统计:OKX 指数(可降级永续)按时段切窗,点数口径.
|
||||
|
||||
仅只读行情;不触及下单链路.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import statistics
|
||||
import time
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Callable, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
|
||||
APP_TZ = ZoneInfo("Asia/Shanghai")
|
||||
END_HOUR = 16
|
||||
EXCHANGE = "okx"
|
||||
TIMEFRAME = "1H"
|
||||
|
||||
SYMBOLS: dict[str, dict[str, str]] = {
|
||||
"eth": {
|
||||
"label": "ETH",
|
||||
"index_inst": "ETH-USD",
|
||||
"swap_inst": "ETH-USDT-SWAP",
|
||||
},
|
||||
"btc": {
|
||||
"label": "BTC",
|
||||
"index_inst": "BTC-USD",
|
||||
"swap_inst": "BTC-USDT-SWAP",
|
||||
},
|
||||
}
|
||||
|
||||
PERIOD_DAYS: dict[str, int] = {
|
||||
"1m": 30,
|
||||
"2m": 60,
|
||||
"3m": 90,
|
||||
"6m": 180,
|
||||
"1y": 365,
|
||||
}
|
||||
|
||||
OKX_INDEX_CANDLES = "https://www.okx.com/api/v5/market/index-candles"
|
||||
OKX_HISTORY_INDEX_CANDLES = "https://www.okx.com/api/v5/market/history-index-candles"
|
||||
OKX_SWAP_CANDLES = "https://www.okx.com/api/v5/market/candles"
|
||||
OKX_HISTORY_SWAP_CANDLES = "https://www.okx.com/api/v5/market/history-candles"
|
||||
|
||||
|
||||
def normalize_symbol(raw: str) -> str:
|
||||
s = (raw or "").strip().lower()
|
||||
if s in ("eth", "ethereum"):
|
||||
return "eth"
|
||||
if s in ("btc", "bitcoin"):
|
||||
return "btc"
|
||||
raise ValueError("symbol 仅支持 eth / btc")
|
||||
|
||||
|
||||
def resolve_sample_days(period: str, custom_days: Any = None) -> int:
|
||||
p = (period or "2m").strip().lower()
|
||||
if p == "custom":
|
||||
try:
|
||||
n = int(custom_days)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("自定义天数无效") from None
|
||||
return max(7, min(400, n))
|
||||
if p not in PERIOD_DAYS:
|
||||
raise ValueError("周期无效")
|
||||
return PERIOD_DAYS[p]
|
||||
|
||||
|
||||
def window_bounds_for_settlement(
|
||||
settlement: date,
|
||||
start_hour: int,
|
||||
*,
|
||||
span_days: int = 1,
|
||||
) -> tuple[datetime, datetime]:
|
||||
"""返回 [start, end) 的本地时刻;end 为结算日 16:00.
|
||||
|
||||
span_days=1: 与现口径相同(如 26日16:00→27日16:00)
|
||||
span_days=2: 再往前推 1 天(如 25日16:00→27日16:00)
|
||||
"""
|
||||
if not (0 <= int(start_hour) <= 23):
|
||||
raise ValueError("起点须为 0-23 整点")
|
||||
span = max(1, int(span_days or 1))
|
||||
end = datetime(settlement.year, settlement.month, settlement.day, END_HOUR, 0, 0, tzinfo=APP_TZ)
|
||||
sh = int(start_hour)
|
||||
if sh >= END_HOUR:
|
||||
prev = settlement - timedelta(days=1)
|
||||
start = datetime(prev.year, prev.month, prev.day, sh, 0, 0, tzinfo=APP_TZ)
|
||||
else:
|
||||
start = datetime(settlement.year, settlement.month, settlement.day, sh, 0, 0, tzinfo=APP_TZ)
|
||||
if span > 1:
|
||||
start = start - timedelta(days=span - 1)
|
||||
return start, end
|
||||
|
||||
|
||||
def list_settlement_dates(*, sample_days: int, now: Optional[datetime] = None) -> list[date]:
|
||||
"""最近 sample_days 个已收窗结算日(不含进行中的今天未到 16:00)."""
|
||||
now = now or datetime.now(APP_TZ)
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=APP_TZ)
|
||||
else:
|
||||
now = now.astimezone(APP_TZ)
|
||||
today = now.date()
|
||||
today_end = datetime(today.year, today.month, today.day, END_HOUR, 0, 0, tzinfo=APP_TZ)
|
||||
latest = today if now >= today_end else today - timedelta(days=1)
|
||||
return [latest - timedelta(days=i) for i in range(int(sample_days))]
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> Optional[float]:
|
||||
try:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def bars_to_map(bars: list[dict[str, Any]]) -> dict[int, dict[str, float]]:
|
||||
"""open_time_ms -> {o,h,l,c}."""
|
||||
m: dict[int, dict[str, float]] = {}
|
||||
for b in bars or []:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
ts = b.get("ts")
|
||||
if ts is None:
|
||||
ts = b.get("open_time_ms")
|
||||
try:
|
||||
ts_i = int(ts)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
o = _safe_float(b.get("o") if "o" in b else b.get("open"))
|
||||
h = _safe_float(b.get("h") if "h" in b else b.get("high"))
|
||||
l = _safe_float(b.get("l") if "l" in b else b.get("low"))
|
||||
c = _safe_float(b.get("c") if "c" in b else b.get("close"))
|
||||
if None in (o, h, l, c):
|
||||
continue
|
||||
m[ts_i] = {"o": float(o), "h": float(h), "l": float(l), "c": float(c)}
|
||||
return m
|
||||
|
||||
|
||||
def _ohlc_window_metrics(
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
bar_map: dict[int, dict[str, float]],
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""在 [start, end) 上算开高低收与开→高/开→低/振幅/涨跌."""
|
||||
start_ms = int(start.timestamp() * 1000)
|
||||
# 1H 棒覆盖 [T, T+1h);窗终点 16:00 用 15:00 棒的 close
|
||||
last_bar_ms = int((end - timedelta(hours=1)).timestamp() * 1000)
|
||||
if start_ms not in bar_map or last_bar_ms not in bar_map:
|
||||
return None
|
||||
opens = bar_map[start_ms]["o"]
|
||||
close = bar_map[last_bar_ms]["c"]
|
||||
hi = bar_map[start_ms]["h"]
|
||||
lo = bar_map[start_ms]["l"]
|
||||
t = start_ms
|
||||
while t <= last_bar_ms:
|
||||
b = bar_map.get(t)
|
||||
if b:
|
||||
hi = max(hi, b["h"])
|
||||
lo = min(lo, b["l"])
|
||||
t += 3600 * 1000
|
||||
up = hi - opens
|
||||
down = opens - lo
|
||||
amp = hi - lo
|
||||
change = close - opens
|
||||
return {
|
||||
"window_start": start.strftime("%Y-%m-%d %H:%M"),
|
||||
"window_end": end.strftime("%Y-%m-%d %H:%M"),
|
||||
"open": round(opens, 4),
|
||||
"high": round(hi, 4),
|
||||
"low": round(lo, 4),
|
||||
"close": round(close, 4),
|
||||
"up_points": round(up, 4),
|
||||
"down_points": round(down, 4),
|
||||
"amplitude": round(amp, 4),
|
||||
"change": round(change, 4),
|
||||
}
|
||||
|
||||
|
||||
def compute_day_row(
|
||||
settlement: date,
|
||||
start_hour: int,
|
||||
bar_map: dict[int, dict[str, float]],
|
||||
) -> Optional[dict[str, Any]]:
|
||||
start, end = window_bounds_for_settlement(settlement, start_hour, span_days=1)
|
||||
m1 = _ohlc_window_metrics(start, end, bar_map)
|
||||
if m1 is None:
|
||||
return None
|
||||
start2, end2 = window_bounds_for_settlement(settlement, start_hour, span_days=2)
|
||||
m2 = _ohlc_window_metrics(start2, end2, bar_map)
|
||||
wd = settlement.weekday() # Mon=0 … Sun=6
|
||||
is_we = wd >= 5
|
||||
row: dict[str, Any] = {
|
||||
"settlement_day": settlement.isoformat(),
|
||||
"weekday": wd,
|
||||
"weekday_label": "六" if wd == 5 else ("日" if wd == 6 else ""),
|
||||
"is_weekend": is_we,
|
||||
**m1,
|
||||
}
|
||||
if m2 is None:
|
||||
row.update(
|
||||
{
|
||||
"window2_start": start2.strftime("%Y-%m-%d %H:%M"),
|
||||
"window2_end": end2.strftime("%Y-%m-%d %H:%M"),
|
||||
"open_2d": None,
|
||||
"high_2d": None,
|
||||
"low_2d": None,
|
||||
"close_2d": None,
|
||||
"up_points_2d": None,
|
||||
"down_points_2d": None,
|
||||
"amplitude_2d": None,
|
||||
"change_2d": None,
|
||||
}
|
||||
)
|
||||
else:
|
||||
row.update(
|
||||
{
|
||||
"window2_start": m2["window_start"],
|
||||
"window2_end": m2["window_end"],
|
||||
"open_2d": m2["open"],
|
||||
"high_2d": m2["high"],
|
||||
"low_2d": m2["low"],
|
||||
"close_2d": m2["close"],
|
||||
"up_points_2d": m2["up_points"],
|
||||
"down_points_2d": m2["down_points"],
|
||||
"amplitude_2d": m2["amplitude"],
|
||||
"change_2d": m2["change"],
|
||||
}
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def normalize_move_points(raw: Any) -> Optional[float]:
|
||||
"""对照波动点数.空/≤0 表示不做点数达标对照."""
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
try:
|
||||
v = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("波动点数须为数字") from None
|
||||
if v <= 0:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
def normalize_weekend_filter(raw: Any) -> str:
|
||||
"""all | exclude | only;默认全部."""
|
||||
s = (str(raw) if raw is not None else "all").strip().lower()
|
||||
if s in ("", "all", "全部"):
|
||||
return "all"
|
||||
if s in ("exclude", "exclude_weekend", "no_weekend", "排除周末"):
|
||||
return "exclude"
|
||||
if s in ("only", "weekend_only", "only_weekend", "仅周末"):
|
||||
return "only"
|
||||
raise ValueError("周末筛选须为 all / exclude / only")
|
||||
|
||||
|
||||
def filter_weekend_rows(rows: list[dict[str, Any]], weekend_filter: Any = "all") -> list[dict[str, Any]]:
|
||||
mode = normalize_weekend_filter(weekend_filter)
|
||||
if mode == "all":
|
||||
return list(rows or [])
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows or []:
|
||||
is_we = bool(r.get("is_weekend"))
|
||||
if "is_weekend" not in r and r.get("settlement_day"):
|
||||
try:
|
||||
is_we = date.fromisoformat(str(r["settlement_day"])).weekday() >= 5
|
||||
except ValueError:
|
||||
is_we = False
|
||||
if mode == "exclude" and is_we:
|
||||
continue
|
||||
if mode == "only" and not is_we:
|
||||
continue
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
def _ensure_weekend_flags(item: dict[str, Any]) -> None:
|
||||
if "is_weekend" in item:
|
||||
return
|
||||
if not item.get("settlement_day"):
|
||||
item.setdefault("weekday_label", "")
|
||||
item.setdefault("is_weekend", False)
|
||||
return
|
||||
try:
|
||||
wd = date.fromisoformat(str(item["settlement_day"])).weekday()
|
||||
item["weekday"] = wd
|
||||
item["weekday_label"] = "六" if wd == 5 else ("日" if wd == 6 else "")
|
||||
item["is_weekend"] = wd >= 5
|
||||
except ValueError:
|
||||
item.setdefault("weekday_label", "")
|
||||
item.setdefault("is_weekend", False)
|
||||
|
||||
|
||||
def enrich_rows(
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
move_points: Any = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""为日表附加周末标注,以及相对波动点数的两边达标."""
|
||||
mp = normalize_move_points(move_points)
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows or []:
|
||||
item = dict(r)
|
||||
_ensure_weekend_flags(item)
|
||||
up = float(item.get("up_points") or 0)
|
||||
down = float(item.get("down_points") or 0)
|
||||
amp = float(item.get("amplitude") or 0)
|
||||
hit_up = bool(mp is not None and up >= mp)
|
||||
hit_down = bool(mp is not None and down >= mp)
|
||||
amp_hit = bool(mp is not None and amp >= mp)
|
||||
amp2 = item.get("amplitude_2d")
|
||||
amp2_v = float(amp2) if amp2 is not None and amp2 != "" else None
|
||||
amp_hit_2d = bool(mp is not None and amp2_v is not None and amp2_v >= mp)
|
||||
item["move_points"] = mp
|
||||
item["hit_up"] = hit_up
|
||||
item["hit_down"] = hit_down
|
||||
item["hit_either"] = hit_up or hit_down
|
||||
item["hit_both"] = hit_up and hit_down
|
||||
item["amp_hit"] = amp_hit
|
||||
item["amp_hit_2d"] = amp_hit_2d
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
# 兼容旧调用名
|
||||
def enrich_rows_pnl(rows: list[dict[str, Any]], **kwargs: Any) -> list[dict[str, Any]]:
|
||||
return enrich_rows(rows, move_points=kwargs.get("move_points"))
|
||||
|
||||
|
||||
def move_points_stats(rows: list[dict[str, Any]], move_points: float) -> dict[str, Any]:
|
||||
"""波动点数达标汇总:开→高/开→低两边."""
|
||||
mp = float(move_points)
|
||||
if mp <= 0:
|
||||
raise ValueError("波动点数须 > 0")
|
||||
work = enrich_rows(rows, move_points=mp)
|
||||
n = len(work)
|
||||
empty = {
|
||||
"move_points": round(mp, 4),
|
||||
"sample_count": n,
|
||||
"up_hit_days": 0,
|
||||
"up_hit_ratio": None,
|
||||
"down_hit_days": 0,
|
||||
"down_hit_ratio": None,
|
||||
"either_hit_days": 0,
|
||||
"either_hit_ratio": None,
|
||||
"both_hit_days": 0,
|
||||
"both_hit_ratio": None,
|
||||
"amp_hit_days": 0,
|
||||
"amp_hit_ratio": None,
|
||||
"amp_2d_hit_days": 0,
|
||||
"amp_2d_hit_ratio": None,
|
||||
"abs_change_hit_days": 0,
|
||||
"abs_change_hit_ratio": None,
|
||||
}
|
||||
if n <= 0:
|
||||
return empty
|
||||
up_hit = sum(1 for r in work if r.get("hit_up"))
|
||||
down_hit = sum(1 for r in work if r.get("hit_down"))
|
||||
either = sum(1 for r in work if r.get("hit_either"))
|
||||
both = sum(1 for r in work if r.get("hit_both"))
|
||||
amp_hit = sum(1 for r in work if r.get("amp_hit"))
|
||||
amp2_rows = [r for r in work if r.get("amplitude_2d") is not None]
|
||||
amp2_hit = sum(1 for r in work if r.get("amp_hit_2d"))
|
||||
n2 = len(amp2_rows)
|
||||
abs_hit = sum(1 for r in work if abs(float(r.get("change") or 0)) >= mp)
|
||||
empty.update(
|
||||
{
|
||||
"up_hit_days": up_hit,
|
||||
"up_hit_ratio": round(up_hit / n, 4),
|
||||
"down_hit_days": down_hit,
|
||||
"down_hit_ratio": round(down_hit / n, 4),
|
||||
"either_hit_days": either,
|
||||
"either_hit_ratio": round(either / n, 4),
|
||||
"both_hit_days": both,
|
||||
"both_hit_ratio": round(both / n, 4),
|
||||
"amp_hit_days": amp_hit,
|
||||
"amp_hit_ratio": round(amp_hit / n, 4),
|
||||
"amp_2d_hit_days": amp2_hit,
|
||||
"amp_2d_hit_ratio": round(amp2_hit / n2, 4) if n2 else None,
|
||||
"abs_change_hit_days": abs_hit,
|
||||
"abs_change_hit_ratio": round(abs_hit / n, 4),
|
||||
}
|
||||
)
|
||||
return empty
|
||||
|
||||
|
||||
def summarize_rows(
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
move_points: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
mp = normalize_move_points(move_points)
|
||||
empty_2d = {
|
||||
"max_amplitude_2d": None,
|
||||
"max_amplitude_2d_day": None,
|
||||
"avg_amplitude_2d": None,
|
||||
"median_amplitude_2d": None,
|
||||
}
|
||||
if not rows:
|
||||
out = {
|
||||
"sample_count": 0,
|
||||
"max_amplitude": None,
|
||||
"max_amplitude_day": None,
|
||||
"avg_amplitude": None,
|
||||
"median_amplitude": None,
|
||||
"max_up_points": None,
|
||||
"avg_up_points": None,
|
||||
"max_down_points": None,
|
||||
"avg_down_points": None,
|
||||
"up_day_ratio": None,
|
||||
"down_day_ratio": None,
|
||||
**empty_2d,
|
||||
"move_points_stats": None,
|
||||
}
|
||||
if mp is not None:
|
||||
out["move_points_stats"] = move_points_stats([], mp)
|
||||
return out
|
||||
amps = [float(r["amplitude"]) for r in rows]
|
||||
ups = [float(r["up_points"]) for r in rows]
|
||||
downs = [float(r["down_points"]) for r in rows]
|
||||
max_amp = max(amps)
|
||||
max_amp_day = next(r["settlement_day"] for r in rows if float(r["amplitude"]) == max_amp)
|
||||
up_days = sum(1 for r in rows if float(r["change"]) > 0)
|
||||
down_days = sum(1 for r in rows if float(r["change"]) < 0)
|
||||
n = len(rows)
|
||||
amps2 = [float(r["amplitude_2d"]) for r in rows if r.get("amplitude_2d") is not None]
|
||||
out: dict[str, Any] = {
|
||||
"sample_count": n,
|
||||
"max_amplitude": round(max_amp, 4),
|
||||
"max_amplitude_day": max_amp_day,
|
||||
"avg_amplitude": round(statistics.fmean(amps), 4),
|
||||
"median_amplitude": round(statistics.median(amps), 4),
|
||||
"max_up_points": round(max(ups), 4),
|
||||
"avg_up_points": round(statistics.fmean(ups), 4),
|
||||
"max_down_points": round(max(downs), 4),
|
||||
"avg_down_points": round(statistics.fmean(downs), 4),
|
||||
"up_day_ratio": round(up_days / n, 4),
|
||||
"down_day_ratio": round(down_days / n, 4),
|
||||
**empty_2d,
|
||||
"move_points_stats": None,
|
||||
}
|
||||
if amps2:
|
||||
max_a2 = max(amps2)
|
||||
out["max_amplitude_2d"] = round(max_a2, 4)
|
||||
out["max_amplitude_2d_day"] = next(
|
||||
r["settlement_day"] for r in rows if r.get("amplitude_2d") is not None and float(r["amplitude_2d"]) == max_a2
|
||||
)
|
||||
out["avg_amplitude_2d"] = round(statistics.fmean(amps2), 4)
|
||||
out["median_amplitude_2d"] = round(statistics.median(amps2), 4)
|
||||
if mp is not None:
|
||||
out["move_points_stats"] = move_points_stats(rows, mp)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_okx_candle_row(row: list) -> Optional[dict[str, Any]]:
|
||||
if not row or len(row) < 5:
|
||||
return None
|
||||
try:
|
||||
ts = int(row[0])
|
||||
o, h, l, c = float(row[1]), float(row[2]), float(row[3]), float(row[4])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return None
|
||||
return {"ts": ts, "o": o, "h": h, "l": l, "c": c}
|
||||
|
||||
|
||||
def _okx_get_json(
|
||||
client: httpx.Client,
|
||||
url: str,
|
||||
params: dict[str, str],
|
||||
*,
|
||||
retries: int = 8,
|
||||
) -> dict[str, Any]:
|
||||
"""GET OKX 公共行情;遇 429 指数退避重试."""
|
||||
last_err: Optional[BaseException] = None
|
||||
for attempt in range(max(1, int(retries))):
|
||||
try:
|
||||
r = client.get(url, params=params)
|
||||
if r.status_code == 429:
|
||||
wait = min(12.0, 0.7 * (2**attempt))
|
||||
time.sleep(wait)
|
||||
last_err = httpx.HTTPStatusError(
|
||||
f"429 Too Many Requests for url '{r.url}'",
|
||||
request=r.request,
|
||||
response=r,
|
||||
)
|
||||
continue
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
if not isinstance(body, dict):
|
||||
raise RuntimeError("OKX 返回非对象 JSON")
|
||||
return body
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code if exc.response is not None else None
|
||||
if status == 429 and attempt + 1 < retries:
|
||||
wait = min(12.0, 0.7 * (2**attempt))
|
||||
time.sleep(wait)
|
||||
last_err = exc
|
||||
continue
|
||||
raise
|
||||
except httpx.TransportError as exc:
|
||||
if attempt + 1 < retries:
|
||||
time.sleep(min(8.0, 0.5 * (2**attempt)))
|
||||
last_err = exc
|
||||
continue
|
||||
raise
|
||||
if last_err is not None:
|
||||
raise last_err
|
||||
raise RuntimeError("OKX 请求失败")
|
||||
|
||||
|
||||
def fetch_okx_candles(
|
||||
*,
|
||||
url: str,
|
||||
inst_id: str,
|
||||
since_ms: int,
|
||||
until_ms: int,
|
||||
bar: str = "1H",
|
||||
client: Optional[httpx.Client] = None,
|
||||
timeout: float = 30.0,
|
||||
history_url: Optional[str] = None,
|
||||
max_pages: int = 200,
|
||||
page_pause_sec: float = 0.12,
|
||||
history_page_pause_sec: float = 0.22,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""拉取 [since_ms, until_ms] 覆盖的 K 线(含边界).
|
||||
|
||||
OKX 近期接口约仅 1440 根;更早需 history_* 端点续拉.
|
||||
分页带间隔,429 自动退避重试.
|
||||
"""
|
||||
own = client is None
|
||||
client = client or httpx.Client(
|
||||
timeout=timeout,
|
||||
trust_env=False,
|
||||
headers={"User-Agent": "crypto_monitor-amp-stats/1.0"},
|
||||
)
|
||||
try:
|
||||
out: dict[int, dict[str, Any]] = {}
|
||||
after: Optional[str] = None
|
||||
active_url = url
|
||||
switched_history = False
|
||||
for page_i in range(max(20, int(max_pages))):
|
||||
if page_i > 0:
|
||||
pause = history_page_pause_sec if switched_history or "history" in active_url else page_pause_sec
|
||||
if pause > 0:
|
||||
time.sleep(pause)
|
||||
params: dict[str, str] = {"instId": inst_id, "bar": bar, "limit": "100"}
|
||||
if after:
|
||||
params["after"] = after
|
||||
body = _okx_get_json(client, active_url, params)
|
||||
if str(body.get("code") or "") not in ("0", "0.0", ""):
|
||||
raise RuntimeError(body.get("msg") or f"OKX error {body.get('code')}")
|
||||
data = body.get("data") or []
|
||||
if not data:
|
||||
# 近期接口到头 → 切历史端点再试
|
||||
if history_url and not switched_history and after is not None:
|
||||
active_url = history_url
|
||||
switched_history = True
|
||||
time.sleep(max(history_page_pause_sec, 0.35))
|
||||
continue
|
||||
break
|
||||
oldest_ts = None
|
||||
for row in data:
|
||||
parsed = _parse_okx_candle_row(row)
|
||||
if not parsed:
|
||||
continue
|
||||
ts = int(parsed["ts"])
|
||||
oldest_ts = ts if oldest_ts is None else min(oldest_ts, ts)
|
||||
if ts < since_ms - 3600 * 1000:
|
||||
continue
|
||||
if ts > until_ms + 3600 * 1000:
|
||||
continue
|
||||
out[ts] = parsed
|
||||
if oldest_ts is None:
|
||||
break
|
||||
if oldest_ts <= since_ms:
|
||||
break
|
||||
# 无新进度时避免死循环
|
||||
if after is not None and str(oldest_ts) == after:
|
||||
if history_url and not switched_history:
|
||||
active_url = history_url
|
||||
switched_history = True
|
||||
time.sleep(max(history_page_pause_sec, 0.35))
|
||||
continue
|
||||
break
|
||||
after = str(oldest_ts)
|
||||
# 近期接口返回变少且仍未覆盖 since → 切历史
|
||||
if (
|
||||
history_url
|
||||
and not switched_history
|
||||
and len(data) < 100
|
||||
and oldest_ts > since_ms
|
||||
):
|
||||
active_url = history_url
|
||||
switched_history = True
|
||||
time.sleep(max(history_page_pause_sec, 0.35))
|
||||
return [out[k] for k in sorted(out.keys())]
|
||||
finally:
|
||||
if own:
|
||||
client.close()
|
||||
|
||||
|
||||
def fetch_symbol_bars(
|
||||
symbol: str,
|
||||
*,
|
||||
since_ms: int,
|
||||
until_ms: int,
|
||||
fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None,
|
||||
) -> tuple[list[dict[str, Any]], str, str]:
|
||||
"""返回 (bars, price_source_label, inst_id)."""
|
||||
key = normalize_symbol(symbol)
|
||||
meta = SYMBOLS[key]
|
||||
if fetch_fn:
|
||||
bars = fetch_fn(inst_id=meta["index_inst"], since_ms=since_ms, until_ms=until_ms)
|
||||
return bars, f"okx_index:{meta['index_inst']}", meta["index_inst"]
|
||||
|
||||
index_err: Optional[BaseException] = None
|
||||
try:
|
||||
bars = fetch_okx_candles(
|
||||
url=OKX_INDEX_CANDLES,
|
||||
history_url=OKX_HISTORY_INDEX_CANDLES,
|
||||
inst_id=meta["index_inst"],
|
||||
since_ms=since_ms,
|
||||
until_ms=until_ms,
|
||||
)
|
||||
if bars:
|
||||
return bars, f"okx_index:{meta['index_inst']}", meta["index_inst"]
|
||||
except Exception as exc:
|
||||
index_err = exc
|
||||
# 指数侧已触发限频时先冷却,再降级永续,避免连环 429
|
||||
time.sleep(1.2)
|
||||
|
||||
try:
|
||||
bars = fetch_okx_candles(
|
||||
url=OKX_SWAP_CANDLES,
|
||||
history_url=OKX_HISTORY_SWAP_CANDLES,
|
||||
inst_id=meta["swap_inst"],
|
||||
since_ms=since_ms,
|
||||
until_ms=until_ms,
|
||||
)
|
||||
except Exception as exc:
|
||||
detail = f"index={index_err}; swap={exc}" if index_err else str(exc)
|
||||
raise RuntimeError(f"OKX K线拉取失败({detail})") from exc
|
||||
if not bars:
|
||||
detail = f"index={index_err}" if index_err else "empty"
|
||||
raise RuntimeError(f"OKX 指数与永续 K 线均无数据({detail})")
|
||||
return bars, f"okx_swap:{meta['swap_inst']}", meta["swap_inst"]
|
||||
|
||||
|
||||
def compute_amp_stats(
|
||||
*,
|
||||
symbol: str = "eth",
|
||||
start_hour: int = 16,
|
||||
period: str = "2m",
|
||||
custom_days: Any = None,
|
||||
move_points: Any = None,
|
||||
weekend_filter: Any = "all",
|
||||
now: Optional[datetime] = None,
|
||||
fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None,
|
||||
) -> dict[str, Any]:
|
||||
key = normalize_symbol(symbol)
|
||||
sh = int(start_hour)
|
||||
if sh < 0 or sh > 23:
|
||||
raise ValueError("起点须为 0-23 整点")
|
||||
mp = normalize_move_points(move_points)
|
||||
we_mode = normalize_weekend_filter(weekend_filter)
|
||||
sample_days = resolve_sample_days(period, custom_days)
|
||||
settlements = list_settlement_dates(sample_days=sample_days, now=now)
|
||||
if not settlements:
|
||||
raise RuntimeError("无可用结算日")
|
||||
# 最远窗起点(含两日振幅,多拉 1 天)
|
||||
oldest = settlements[-1]
|
||||
newest = settlements[0]
|
||||
start0, _ = window_bounds_for_settlement(oldest, sh, span_days=2)
|
||||
_, end1 = window_bounds_for_settlement(newest, sh, span_days=1)
|
||||
since_ms = int(start0.timestamp() * 1000)
|
||||
until_ms = int(end1.timestamp() * 1000)
|
||||
bars, price_source, inst_id = fetch_symbol_bars(
|
||||
key, since_ms=since_ms, until_ms=until_ms, fetch_fn=fetch_fn
|
||||
)
|
||||
bar_map = bars_to_map(bars)
|
||||
rows_all: list[dict[str, Any]] = []
|
||||
missing: list[str] = []
|
||||
for d in settlements:
|
||||
row = compute_day_row(d, sh, bar_map)
|
||||
if row is None:
|
||||
missing.append(d.isoformat())
|
||||
continue
|
||||
rows_all.append(row)
|
||||
return build_amp_result(
|
||||
rows_all=rows_all,
|
||||
symbol_key=key,
|
||||
start_hour=sh,
|
||||
period=period,
|
||||
sample_days=sample_days,
|
||||
move_points=mp,
|
||||
weekend_filter=we_mode,
|
||||
price_source=price_source,
|
||||
inst_id=inst_id,
|
||||
missing=missing,
|
||||
)
|
||||
|
||||
|
||||
def build_amp_result(
|
||||
*,
|
||||
rows_all: list[dict[str, Any]],
|
||||
symbol_key: str,
|
||||
start_hour: int,
|
||||
period: str,
|
||||
sample_days: int,
|
||||
move_points: Any = None,
|
||||
weekend_filter: Any = "all",
|
||||
price_source: str = "",
|
||||
inst_id: str = "",
|
||||
missing: Optional[list[str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
mp = normalize_move_points(move_points)
|
||||
we_mode = normalize_weekend_filter(weekend_filter)
|
||||
filtered = filter_weekend_rows(rows_all, we_mode)
|
||||
rows = enrich_rows(filtered, move_points=mp)
|
||||
summary = summarize_rows(rows, move_points=mp)
|
||||
if period == "custom" or str(period).startswith("custom:"):
|
||||
period_label = period if str(period).startswith("custom:") else f"custom:{sample_days}"
|
||||
else:
|
||||
period_label = str(period)
|
||||
miss = missing or []
|
||||
return {
|
||||
"ok": True,
|
||||
"exchange": EXCHANGE,
|
||||
"symbol": symbol_key,
|
||||
"symbol_label": SYMBOLS[symbol_key]["label"],
|
||||
"start_hour": start_hour,
|
||||
"end_hour": END_HOUR,
|
||||
"period": period_label,
|
||||
"sample_days_requested": sample_days,
|
||||
"move_points": mp,
|
||||
"weekend_filter": we_mode,
|
||||
"timeframe": TIMEFRAME,
|
||||
"price_source": price_source,
|
||||
"inst_id": inst_id,
|
||||
"timezone": "Asia/Shanghai",
|
||||
"rows_all": rows_all,
|
||||
"rows": rows,
|
||||
"summary": summary,
|
||||
"missing_days": miss[:30],
|
||||
"missing_count": len(miss),
|
||||
}
|
||||
|
||||
|
||||
def reframe_amp_stats(
|
||||
*,
|
||||
rows_all: list[dict[str, Any]],
|
||||
symbol: str = "eth",
|
||||
start_hour: int = 16,
|
||||
period: str = "2m",
|
||||
sample_days: int = 60,
|
||||
move_points: Any = None,
|
||||
weekend_filter: Any = "all",
|
||||
price_source: str = "",
|
||||
inst_id: str = "",
|
||||
missing: Optional[list[str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""已有日表上改周末/波动点数,不拉 K 线."""
|
||||
key = normalize_symbol(symbol)
|
||||
return build_amp_result(
|
||||
rows_all=list(rows_all or []),
|
||||
symbol_key=key,
|
||||
start_hour=int(start_hour),
|
||||
period=period,
|
||||
sample_days=int(sample_days or 60),
|
||||
move_points=move_points,
|
||||
weekend_filter=weekend_filter,
|
||||
price_source=price_source,
|
||||
inst_id=inst_id,
|
||||
missing=missing,
|
||||
)
|
||||
|
||||
|
||||
def rows_page(rows: list[dict[str, Any]], *, page: int = 1, page_size: int = 20) -> dict[str, Any]:
|
||||
page = max(1, int(page or 1))
|
||||
page_size = max(5, min(100, int(page_size or 20)))
|
||||
total = len(rows)
|
||||
start = (page - 1) * page_size
|
||||
chunk = rows[start : start + page_size]
|
||||
return {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
"total_pages": max(1, (total + page_size - 1) // page_size) if total else 1,
|
||||
"rows": chunk,
|
||||
}
|
||||
|
||||
|
||||
def build_export_csv(payload: dict[str, Any]) -> str:
|
||||
buf = io.StringIO()
|
||||
# Excel 友好 BOM
|
||||
buf.write("\ufeff")
|
||||
w = csv.writer(buf)
|
||||
s = payload.get("summary") or {}
|
||||
w.writerow(["【统计摘要】"])
|
||||
w.writerow(["交易所", payload.get("exchange")])
|
||||
w.writerow(["标的", payload.get("symbol_label")])
|
||||
w.writerow(["价源", payload.get("price_source")])
|
||||
w.writerow(["起点整点", f"{payload.get('start_hour')}:00"])
|
||||
w.writerow(["终点", f"{payload.get('end_hour')}:00"])
|
||||
w.writerow(["周期", payload.get("period")])
|
||||
w.writerow(["周末筛选", payload.get("weekend_filter")])
|
||||
w.writerow(["样本数", s.get("sample_count")])
|
||||
w.writerow(["最大振幅", s.get("max_amplitude"), "日期", s.get("max_amplitude_day")])
|
||||
w.writerow(["振幅均值", s.get("avg_amplitude"), "中位数", s.get("median_amplitude")])
|
||||
w.writerow(["两日最大振幅", s.get("max_amplitude_2d"), "日期", s.get("max_amplitude_2d_day")])
|
||||
w.writerow(["两日振幅均值", s.get("avg_amplitude_2d"), "中位数", s.get("median_amplitude_2d")])
|
||||
w.writerow(["开→高最大", s.get("max_up_points"), "均值", s.get("avg_up_points")])
|
||||
w.writerow(["开→低最大", s.get("max_down_points"), "均值", s.get("avg_down_points")])
|
||||
w.writerow(["上涨窗占比", s.get("up_day_ratio"), "下跌窗占比", s.get("down_day_ratio")])
|
||||
mp = s.get("move_points_stats") or {}
|
||||
if mp:
|
||||
w.writerow([])
|
||||
w.writerow(["【波动点数·振幅占比】", mp.get("move_points")])
|
||||
w.writerow(["振幅≥点数天数", mp.get("amp_hit_days"), "占比", mp.get("amp_hit_ratio")])
|
||||
w.writerow(["两日振幅≥点数天数", mp.get("amp_2d_hit_days"), "占比", mp.get("amp_2d_hit_ratio")])
|
||||
w.writerow(["开→高≥点数天数", mp.get("up_hit_days"), "占比", mp.get("up_hit_ratio")])
|
||||
w.writerow(["开→低≥点数天数", mp.get("down_hit_days"), "占比", mp.get("down_hit_ratio")])
|
||||
w.writerow(["|涨跌|≥点数天数", mp.get("abs_change_hit_days"), "占比", mp.get("abs_change_hit_ratio")])
|
||||
w.writerow([])
|
||||
w.writerow(["【日表明细】"])
|
||||
w.writerow(
|
||||
[
|
||||
"结算日",
|
||||
"星期",
|
||||
"周末",
|
||||
"窗起点",
|
||||
"窗终点",
|
||||
"开盘",
|
||||
"最高",
|
||||
"最低",
|
||||
"收盘",
|
||||
"开→高",
|
||||
"开→低",
|
||||
"振幅",
|
||||
"涨跌值",
|
||||
"两日窗起点",
|
||||
"两日窗终点",
|
||||
"两日振幅",
|
||||
"两日开→高",
|
||||
"两日开→低",
|
||||
"对照点数",
|
||||
"振幅达标",
|
||||
"两日振幅达标",
|
||||
]
|
||||
)
|
||||
for r in payload.get("rows") or []:
|
||||
w.writerow(
|
||||
[
|
||||
r.get("settlement_day"),
|
||||
r.get("weekday_label") or "",
|
||||
"是" if r.get("is_weekend") else "否",
|
||||
r.get("window_start"),
|
||||
r.get("window_end"),
|
||||
r.get("open"),
|
||||
r.get("high"),
|
||||
r.get("low"),
|
||||
r.get("close"),
|
||||
r.get("up_points"),
|
||||
r.get("down_points"),
|
||||
r.get("amplitude"),
|
||||
r.get("change"),
|
||||
r.get("window2_start"),
|
||||
r.get("window2_end"),
|
||||
r.get("amplitude_2d"),
|
||||
r.get("up_points_2d"),
|
||||
r.get("down_points_2d"),
|
||||
r.get("move_points") if r.get("move_points") is not None else "",
|
||||
"是" if r.get("amp_hit") else ("否" if r.get("move_points") is not None else ""),
|
||||
"是" if r.get("amp_hit_2d") else ("否" if r.get("move_points") is not None and r.get("amplitude_2d") is not None else ""),
|
||||
]
|
||||
)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def export_filename(payload: dict[str, Any]) -> str:
|
||||
sym = (payload.get("symbol") or "eth").lower()
|
||||
sh = int(payload.get("start_hour") or 16)
|
||||
period = str(payload.get("period") or "2m").replace(":", "")
|
||||
day = datetime.now(APP_TZ).strftime("%Y%m%d")
|
||||
return f"okx_{sym}_amp_{sh}to16_{period}_{day}.csv"
|
||||
@@ -71,10 +71,12 @@ def install_instance_theme_static(app) -> None:
|
||||
"strategy_roll.js": "application/javascript; charset=utf-8",
|
||||
"instance_page.css": "text/css; charset=utf-8",
|
||||
"instance_embed.js": "application/javascript; charset=utf-8",
|
||||
"instance_mobile_nav.js": "application/javascript; charset=utf-8",
|
||||
"instance_stats.js": "application/javascript; charset=utf-8",
|
||||
"instance_live.js": "application/javascript; charset=utf-8",
|
||||
"instance_settings_prefs.js": "application/javascript; charset=utf-8",
|
||||
"instance_dashboard.js": "application/javascript; charset=utf-8",
|
||||
"account_ledger.js": "application/javascript; charset=utf-8",
|
||||
"options_expiry_countdown.js": "application/javascript; charset=utf-8",
|
||||
"options_panel.js": "application/javascript; charset=utf-8",
|
||||
"order_entry_model.js": "application/javascript; charset=utf-8",
|
||||
@@ -672,6 +674,72 @@ def register_hub_routes(app):
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/hub/options/review/archive")
|
||||
@_hub_auth_required
|
||||
def api_hub_options_review_archive():
|
||||
"""中控期权档案:近 N 天已平仓复盘记录(默认排除对冲腿)."""
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from lib.options.options_review_lib import (
|
||||
compute_review_stats,
|
||||
ensure_local_review_synced,
|
||||
list_review_trades,
|
||||
)
|
||||
|
||||
c = _ctx()
|
||||
get_db = c.get("get_db")
|
||||
if not get_db:
|
||||
return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500
|
||||
try:
|
||||
days = int(request.args.get("days") or "365")
|
||||
except ValueError:
|
||||
days = 365
|
||||
days = max(1, min(days, 3650))
|
||||
try:
|
||||
limit = int(request.args.get("limit") or "2000")
|
||||
except ValueError:
|
||||
limit = 2000
|
||||
limit = max(1, min(limit, 5000))
|
||||
include_hedge_legs = str(request.args.get("include_hedge_legs") or "").strip() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
tz = ZoneInfo("Asia/Shanghai")
|
||||
closed_from = (datetime.now(tz) - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
cfg = (current_app.extensions or {}).get("options_cfg") or {}
|
||||
ex = cfg.get("exchange_options")
|
||||
conn = get_db()
|
||||
try:
|
||||
ensure_local_review_synced(conn, ex=ex, backfill_exchange_pnl=bool(ex))
|
||||
trades = list_review_trades(
|
||||
conn,
|
||||
include_hedge_legs=include_hedge_legs,
|
||||
closed_from=closed_from,
|
||||
limit=limit,
|
||||
offset=0,
|
||||
)
|
||||
stats = compute_review_stats(
|
||||
conn,
|
||||
include_hedge_legs=include_hedge_legs,
|
||||
closed_from=closed_from,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"days": days,
|
||||
"limit": limit,
|
||||
"product": "options",
|
||||
"trades": trades,
|
||||
"stats": stats,
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/hub/trades/today")
|
||||
@_hub_auth_required
|
||||
def api_hub_trades_today():
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
"""中控策略对比:同风险额下 合约 / 单期权 / 期期7:3 情景测算(纯函数)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def _f(v: Any) -> Optional[float]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def default_contract_size(base: str) -> float:
|
||||
"""OKX 线性永续常用面值(币/张);与计算器缺省一致."""
|
||||
b = (base or "ETH").strip().upper()
|
||||
return 0.01
|
||||
|
||||
|
||||
def default_ct_mult(base: str) -> float:
|
||||
return 0.01
|
||||
|
||||
|
||||
def floor_sheets(n: float, step: float = 1.0) -> float:
|
||||
if n is None or not math.isfinite(n) or n <= 0:
|
||||
return 0.0
|
||||
s = float(step) if step and step > 0 else 1.0
|
||||
return math.floor(n / s + 1e-12) * s
|
||||
|
||||
|
||||
def option_unit_cost(*, ask: float, ct_mult: float) -> float:
|
||||
return float(ask) * float(ct_mult or 0.01)
|
||||
|
||||
|
||||
def option_intrinsic_value(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: float,
|
||||
spot: float,
|
||||
sheets: float,
|
||||
ct_mult: float,
|
||||
) -> float:
|
||||
o = (opt_type or "").strip().upper()
|
||||
k = float(strike)
|
||||
s = float(spot)
|
||||
if o == "C":
|
||||
intrinsic = max(0.0, s - k)
|
||||
elif o == "P":
|
||||
intrinsic = max(0.0, k - s)
|
||||
else:
|
||||
intrinsic = 0.0
|
||||
return intrinsic * float(sheets) * float(ct_mult or 0.01)
|
||||
|
||||
|
||||
def option_pnl_at_spot(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: float,
|
||||
spot: float,
|
||||
sheets: float,
|
||||
ct_mult: float,
|
||||
premium_paid: float,
|
||||
) -> float:
|
||||
return option_intrinsic_value(
|
||||
opt_type=opt_type,
|
||||
strike=strike,
|
||||
spot=spot,
|
||||
sheets=sheets,
|
||||
ct_mult=ct_mult,
|
||||
) - float(premium_paid)
|
||||
|
||||
|
||||
def perp_pnl(
|
||||
*,
|
||||
direction: str,
|
||||
entry: float,
|
||||
exit_px: float,
|
||||
contracts: float,
|
||||
contract_size: float,
|
||||
) -> float:
|
||||
coins = float(contracts) * float(contract_size or 0.01)
|
||||
d = (direction or "long").strip().lower()
|
||||
if d == "short":
|
||||
return (float(entry) - float(exit_px)) * coins
|
||||
return (float(exit_px) - float(entry)) * coins
|
||||
|
||||
|
||||
def _validate_common(inp: dict[str, Any]) -> Optional[str]:
|
||||
base = str(inp.get("base") or "ETH").strip().upper()
|
||||
if base not in ("ETH", "BTC"):
|
||||
return "标的仅支持 ETH / BTC"
|
||||
direction = str(inp.get("direction") or "long").strip().lower()
|
||||
if direction not in ("long", "short"):
|
||||
return "方向须为 long / short"
|
||||
s0 = _f(inp.get("entry"))
|
||||
sl = _f(inp.get("sl"))
|
||||
tp = _f(inp.get("tp"))
|
||||
risk = _f(inp.get("risk_u"))
|
||||
if s0 is None or s0 <= 0:
|
||||
return "请填写有效入场价"
|
||||
if sl is None or sl <= 0:
|
||||
return "请填写有效止损价"
|
||||
if tp is None or tp <= 0:
|
||||
return "请填写有效止盈价"
|
||||
if risk is None or risk <= 0:
|
||||
return "请填写有效风险额 R"
|
||||
if direction == "long" and not (sl < s0 < tp):
|
||||
return "做多须满足 止损 < 入场 < 止盈"
|
||||
if direction == "short" and not (tp < s0 < sl):
|
||||
return "做空须满足 止盈 < 入场 < 止损"
|
||||
return None
|
||||
|
||||
|
||||
def _calc_perp(inp: dict[str, Any], *, contract_size: float) -> dict[str, Any]:
|
||||
direction = str(inp.get("direction") or "long").strip().lower()
|
||||
s0 = float(inp["entry"])
|
||||
sl = float(inp["sl"])
|
||||
tp = float(inp["tp"])
|
||||
risk = float(inp["risk_u"])
|
||||
per_sheet_sl = abs(s0 - sl) * contract_size
|
||||
sheets = floor_sheets(risk / per_sheet_sl) if per_sheet_sl > 0 else 0.0
|
||||
actual_sl_loss = abs(perp_pnl(
|
||||
direction=direction, entry=s0, exit_px=sl, contracts=sheets, contract_size=contract_size
|
||||
))
|
||||
tp_pnl = perp_pnl(
|
||||
direction=direction, entry=s0, exit_px=tp, contracts=sheets, contract_size=contract_size
|
||||
)
|
||||
# 路径 C:本单已止损 −actual;踏空未拿到 = 原止盈盈利
|
||||
path_a = round(tp_pnl, 4)
|
||||
path_b = round(-actual_sl_loss if sheets > 0 else -risk, 4)
|
||||
path_c_realized = path_b
|
||||
path_c_missed = path_a
|
||||
return {
|
||||
"kind": "perp",
|
||||
"sheets": sheets,
|
||||
"contract_size": contract_size,
|
||||
"per_sheet_sl_u": round(per_sheet_sl, 6),
|
||||
"risk_used_u": round(actual_sl_loss, 4),
|
||||
"path_a_tp": path_a,
|
||||
"path_b_sl": path_b,
|
||||
"path_c_realized": path_c_realized,
|
||||
"path_c_missed": path_c_missed,
|
||||
"path_c_note": "本单已止损;踏空未拿到原止盈空间",
|
||||
"worst_u": path_b,
|
||||
}
|
||||
|
||||
|
||||
def _calc_single_option(inp: dict[str, Any], *, ct_mult: float) -> dict[str, Any]:
|
||||
direction = str(inp.get("direction") or "long").strip().lower()
|
||||
risk = float(inp["risk_u"])
|
||||
tp = float(inp.get("tp_opt") if inp.get("tp_opt") not in (None, "") else inp["tp"])
|
||||
sl = float(inp["sl"])
|
||||
opt = inp.get("option") if isinstance(inp.get("option"), dict) else {}
|
||||
default_type = "C" if direction == "long" else "P"
|
||||
opt_type = str(opt.get("opt_type") or default_type).strip().upper()
|
||||
if opt_type not in ("C", "P"):
|
||||
opt_type = default_type
|
||||
strike = _f(opt.get("strike"))
|
||||
ask = _f(opt.get("ask"))
|
||||
if strike is None or strike <= 0:
|
||||
return {"ok": False, "msg": "请填写单期权行权价"}
|
||||
if ask is None or ask <= 0:
|
||||
return {"ok": False, "msg": "请填写单期权卖一价"}
|
||||
unit = option_unit_cost(ask=ask, ct_mult=ct_mult)
|
||||
sheets = floor_sheets(risk / unit) if unit > 0 else 0.0
|
||||
premium = option_unit_cost(ask=ask, ct_mult=ct_mult) * sheets if sheets else 0.0
|
||||
# 若张数为 0
|
||||
path_a = option_pnl_at_spot(
|
||||
opt_type=opt_type, strike=strike, spot=tp, sheets=sheets, ct_mult=ct_mult, premium_paid=premium
|
||||
)
|
||||
path_b_at_sl = option_pnl_at_spot(
|
||||
opt_type=opt_type, strike=strike, spot=sl, sheets=sheets, ct_mult=ct_mult, premium_paid=premium
|
||||
)
|
||||
path_b_worst = -premium
|
||||
# 踏空路径:合约被洗后标的仍到 TP,期权仍持有 → 同止盈
|
||||
path_c = path_a
|
||||
return {
|
||||
"ok": True,
|
||||
"kind": "option",
|
||||
"opt_type": opt_type,
|
||||
"strike": strike,
|
||||
"ask": ask,
|
||||
"ct_mult": ct_mult,
|
||||
"sheets": sheets,
|
||||
"unit_cost_u": round(unit, 6),
|
||||
"premium_u": round(premium, 4),
|
||||
"path_a_tp": round(path_a, 4),
|
||||
"path_b_sl": round(path_b_at_sl, 4),
|
||||
"path_b_worst": round(path_b_worst, 4),
|
||||
"path_c_hold_to_tp": round(path_c, 4),
|
||||
"path_c_note": "合约踏空路径下期权仍持有至目标价(内在近似)",
|
||||
"worst_u": round(path_b_worst, 4),
|
||||
}
|
||||
|
||||
|
||||
def _calc_hedge(inp: dict[str, Any], *, ct_mult: float) -> dict[str, Any]:
|
||||
direction = str(inp.get("direction") or "long").strip().lower()
|
||||
risk = float(inp["risk_u"])
|
||||
tp = float(inp.get("tp_hedge") if inp.get("tp_hedge") not in (None, "") else inp["tp"])
|
||||
sl = float(inp["sl"])
|
||||
hedge = inp.get("hedge") if isinstance(inp.get("hedge"), dict) else {}
|
||||
main_default = "C" if direction == "long" else "P"
|
||||
side_default = "P" if direction == "long" else "C"
|
||||
main = hedge.get("main") if isinstance(hedge.get("main"), dict) else {}
|
||||
side = hedge.get("side") if isinstance(hedge.get("side"), dict) else {}
|
||||
main_type = str(main.get("opt_type") or main_default).strip().upper()
|
||||
side_type = str(side.get("opt_type") or side_default).strip().upper()
|
||||
if main_type not in ("C", "P"):
|
||||
main_type = main_default
|
||||
if side_type not in ("C", "P"):
|
||||
side_type = side_default
|
||||
main_k = _f(main.get("strike"))
|
||||
main_ask = _f(main.get("ask"))
|
||||
side_k = _f(side.get("strike"))
|
||||
side_ask = _f(side.get("ask"))
|
||||
if None in (main_k, main_ask, side_k, side_ask) or min(
|
||||
main_k or 0, main_ask or 0, side_k or 0, side_ask or 0
|
||||
) <= 0:
|
||||
return {"ok": False, "msg": "请填写期期对冲两腿的行权价与卖一"}
|
||||
main_budget = 0.7 * risk
|
||||
side_budget = 0.3 * risk
|
||||
main_unit = option_unit_cost(ask=float(main_ask), ct_mult=ct_mult)
|
||||
side_unit = option_unit_cost(ask=float(side_ask), ct_mult=ct_mult)
|
||||
main_sheets = floor_sheets(main_budget / main_unit) if main_unit > 0 else 0.0
|
||||
side_sheets = floor_sheets(side_budget / side_unit) if side_unit > 0 else 0.0
|
||||
main_prem = main_unit * main_sheets
|
||||
side_prem = side_unit * side_sheets
|
||||
premium = main_prem + side_prem
|
||||
|
||||
def combo_at(spot: float) -> float:
|
||||
a = option_pnl_at_spot(
|
||||
opt_type=main_type,
|
||||
strike=float(main_k),
|
||||
spot=spot,
|
||||
sheets=main_sheets,
|
||||
ct_mult=ct_mult,
|
||||
premium_paid=main_prem,
|
||||
)
|
||||
b = option_pnl_at_spot(
|
||||
opt_type=side_type,
|
||||
strike=float(side_k),
|
||||
spot=spot,
|
||||
sheets=side_sheets,
|
||||
ct_mult=ct_mult,
|
||||
premium_paid=side_prem,
|
||||
)
|
||||
return a + b
|
||||
|
||||
path_a = combo_at(tp)
|
||||
path_b_at_sl = combo_at(sl)
|
||||
path_b_worst = -premium
|
||||
path_c = path_a
|
||||
return {
|
||||
"ok": True,
|
||||
"kind": "hedge",
|
||||
"ratio": "7:3",
|
||||
"ct_mult": ct_mult,
|
||||
"main": {
|
||||
"opt_type": main_type,
|
||||
"strike": main_k,
|
||||
"ask": main_ask,
|
||||
"sheets": main_sheets,
|
||||
"premium_u": round(main_prem, 4),
|
||||
"budget_u": round(main_budget, 4),
|
||||
},
|
||||
"side": {
|
||||
"opt_type": side_type,
|
||||
"strike": side_k,
|
||||
"ask": side_ask,
|
||||
"sheets": side_sheets,
|
||||
"premium_u": round(side_prem, 4),
|
||||
"budget_u": round(side_budget, 4),
|
||||
},
|
||||
"premium_u": round(premium, 4),
|
||||
"path_a_tp": round(path_a, 4),
|
||||
"path_b_sl": round(path_b_at_sl, 4),
|
||||
"path_b_worst": round(path_b_worst, 4),
|
||||
"path_c_hold_to_tp": round(path_c, 4),
|
||||
"path_c_note": "合约踏空路径下对冲组合仍持有至目标价(内在近似)",
|
||||
"worst_u": round(path_b_worst, 4),
|
||||
}
|
||||
|
||||
|
||||
def recommend(perp: dict[str, Any], opt: dict[str, Any], hedge: dict[str, Any], risk: float) -> dict[str, Any]:
|
||||
"""可解释规则推荐."""
|
||||
candidates: list[tuple[str, float, dict[str, Any]]] = []
|
||||
if perp and perp.get("sheets", 0) > 0:
|
||||
candidates.append(("合约", float(perp.get("path_a_tp") or 0), perp))
|
||||
if opt and opt.get("ok") and opt.get("sheets", 0) > 0:
|
||||
candidates.append(("单期权", float(opt.get("path_a_tp") or 0), opt))
|
||||
if hedge and hedge.get("ok") and (hedge.get("premium_u") or 0) > 0:
|
||||
candidates.append(("期期对冲", float(hedge.get("path_a_tp") or 0), hedge))
|
||||
if not candidates:
|
||||
return {
|
||||
"choice": "—",
|
||||
"reason": "输入不足,无法推荐",
|
||||
"bullets": ["请检查风险额与卖一/止损距是否过小导致张数为 0"],
|
||||
}
|
||||
|
||||
best_name, best_a, _ = max(candidates, key=lambda x: x[1])
|
||||
perp_a = float(perp.get("path_a_tp") or 0) if perp else 0.0
|
||||
opt_a = float(opt.get("path_a_tp") or 0) if opt and opt.get("ok") else 0.0
|
||||
hedge_a = float(hedge.get("path_a_tp") or 0) if hedge and hedge.get("ok") else 0.0
|
||||
|
||||
# 踏空:合约 C 实现为亏损,期权/对冲 C 仍接近 A
|
||||
perp_miss = float(perp.get("path_c_missed") or 0) if perp else 0.0
|
||||
opt_c = float(opt.get("path_c_hold_to_tp") or 0) if opt and opt.get("ok") else None
|
||||
hedge_c = float(hedge.get("path_c_hold_to_tp") or 0) if hedge and hedge.get("ok") else None
|
||||
anti_whipsaw = False
|
||||
if perp_miss > 0 and (
|
||||
(opt_c is not None and opt_c > 0) or (hedge_c is not None and hedge_c > 0)
|
||||
):
|
||||
anti_whipsaw = True
|
||||
|
||||
# 合约止盈明显更高(>= 另两者 1.15 倍)且用户能接受踏空 → 推合约
|
||||
others_max = max(opt_a, hedge_a, 0.0)
|
||||
choice = best_name
|
||||
if perp_a > 0 and perp_a >= others_max * 1.15 and perp_a >= best_a * 0.99:
|
||||
choice = "合约"
|
||||
if anti_whipsaw:
|
||||
reason = "合约止盈赔付更高,但震荡易洗时存在踏空;能接受洗盘再走可选合约"
|
||||
else:
|
||||
reason = "同风险下合约干净止盈赔付最高"
|
||||
elif anti_whipsaw and (opt_a > 0 or hedge_a > 0):
|
||||
# 抗踏空优先期权类;期期与单腿接近时推期期
|
||||
if hedge_a > 0 and (opt_a <= 0 or hedge_a >= opt_a * 0.85):
|
||||
choice = "期期对冲"
|
||||
reason = "震荡易洗时期权类更抗踏空;期期 7:3 兼顾方向与保护"
|
||||
else:
|
||||
choice = "单期权"
|
||||
reason = "震荡易洗时单期权仍可持有到目标,抗踏空优于合约"
|
||||
else:
|
||||
reason = f"同风险下「{best_name}」干净止盈赔付最高"
|
||||
|
||||
bullets = [
|
||||
f"止盈对比:合约 {perp_a:.2f}U / 单期权 {opt_a:.2f}U / 期期 {hedge_a:.2f}U(风险 R={risk:.2f}U)",
|
||||
(
|
||||
"止损与踏空:合约打止损即结束并可能踏空;"
|
||||
"期权/对冲最坏约亏满权利金,踏空路径下常仍持有至目标"
|
||||
if anti_whipsaw
|
||||
else "止损与踏空:三者最坏接近 −R;关注合约是否易被洗后错过止盈"
|
||||
),
|
||||
f"选用建议:{reason}",
|
||||
]
|
||||
return {"choice": choice, "reason": reason, "bullets": bullets}
|
||||
|
||||
|
||||
def run_compare(inp: dict[str, Any]) -> dict[str, Any]:
|
||||
err = _validate_common(inp)
|
||||
if err:
|
||||
return {"ok": False, "msg": err}
|
||||
base = str(inp.get("base") or "ETH").strip().upper()
|
||||
risk = float(inp["risk_u"])
|
||||
cs = _f(inp.get("contract_size")) or default_contract_size(base)
|
||||
ct = _f(inp.get("ct_mult")) or default_ct_mult(base)
|
||||
perp = _calc_perp(inp, contract_size=float(cs))
|
||||
opt = _calc_single_option(inp, ct_mult=float(ct))
|
||||
hedge = _calc_hedge(inp, ct_mult=float(ct))
|
||||
rec = recommend(
|
||||
perp,
|
||||
opt if opt.get("ok") else {"ok": False},
|
||||
hedge if hedge.get("ok") else {"ok": False},
|
||||
risk,
|
||||
)
|
||||
warnings: list[str] = []
|
||||
if perp.get("sheets", 0) <= 0:
|
||||
warnings.append("合约张数为 0:止损距过大或 R 过小")
|
||||
if isinstance(opt, dict) and opt.get("ok") and opt.get("sheets", 0) <= 0:
|
||||
warnings.append("单期权张数为 0:卖一过高或 R 过小")
|
||||
if isinstance(hedge, dict) and hedge.get("ok") and hedge.get("premium_u", 0) <= 0:
|
||||
warnings.append("期期对冲未开出张数:卖一过高或 R 过小")
|
||||
if isinstance(opt, dict) and not opt.get("ok"):
|
||||
warnings.append(str(opt.get("msg") or "单期权输入不完整"))
|
||||
if isinstance(hedge, dict) and not hedge.get("ok"):
|
||||
warnings.append(str(hedge.get("msg") or "期期对冲输入不完整"))
|
||||
return {
|
||||
"ok": True,
|
||||
"base": base,
|
||||
"direction": str(inp.get("direction") or "long").strip().lower(),
|
||||
"entry": float(inp["entry"]),
|
||||
"sl": float(inp["sl"]),
|
||||
"tp": float(inp["tp"]),
|
||||
"risk_u": risk,
|
||||
"contract_size": float(cs),
|
||||
"ct_mult": float(ct),
|
||||
"perp": perp,
|
||||
"option": opt,
|
||||
"hedge": hedge,
|
||||
"recommend": rec,
|
||||
"warnings": warnings,
|
||||
"notes": [
|
||||
"期权止盈按标的到价的内在价值近似,非盘口卖出价",
|
||||
"到期小盈/小亏未纳入主表与推荐",
|
||||
"仅本地测算,不下单",
|
||||
],
|
||||
}
|
||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from lib.hub.hub_trades_lib import current_trading_day
|
||||
from lib.hub.hub_options_funds_lib import merge_board_row_balances
|
||||
from lib.hub.hub_options_funds_lib import merge_board_row_balances, repair_double_counted_fund_entry
|
||||
|
||||
from lib.paths import manual_trading_hub_dir
|
||||
|
||||
@@ -275,7 +275,7 @@ def _series_from_history(
|
||||
total = 0.0
|
||||
n = 0
|
||||
for key in account_keys:
|
||||
ac = ac_map.get(key) or {}
|
||||
ac = repair_double_counted_fund_entry(ac_map.get(key) or {})
|
||||
t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt"))
|
||||
if t is None:
|
||||
t = _safe_float(ac.get("total_usdt"))
|
||||
@@ -291,7 +291,8 @@ def _series_from_history(
|
||||
def _account_series(history: dict[str, dict], key: str) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for day in sorted(history.keys()):
|
||||
ac = (history.get(day) or {}).get("accounts", {}).get(key) or {}
|
||||
raw = (history.get(day) or {}).get("accounts", {}).get(key) or {}
|
||||
ac = repair_double_counted_fund_entry(raw)
|
||||
t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt"))
|
||||
if t is None:
|
||||
t = _safe_float(ac.get("total_usdt"))
|
||||
|
||||
@@ -7,6 +7,7 @@ from lib.hub.hub_options_funds_lib import (
|
||||
options_float_pnl_usdt,
|
||||
options_open_position_count as count_options_positions,
|
||||
)
|
||||
from lib.hub.hub_position_metrics import is_option_like_position
|
||||
|
||||
|
||||
def _coerce_float(value: Any) -> float | None:
|
||||
@@ -27,6 +28,27 @@ def position_unrealized_pnl(pos: dict[str, Any]) -> float:
|
||||
|
||||
|
||||
def _open_positions(agent: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
if not isinstance(agent, dict):
|
||||
return []
|
||||
positions = agent.get("positions")
|
||||
if not isinstance(positions, list):
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for p in positions:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
if is_option_like_position(p):
|
||||
continue
|
||||
try:
|
||||
c = abs(float(p.get("contracts") or 0))
|
||||
except (TypeError, ValueError):
|
||||
c = 0.0
|
||||
if c > 1e-12:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def _raw_open_positions(agent: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
if not isinstance(agent, dict):
|
||||
return []
|
||||
positions = agent.get("positions")
|
||||
@@ -79,8 +101,11 @@ def aggregate_monitor_board_totals(
|
||||
ag = row.get("agent") if isinstance(row.get("agent"), dict) else {}
|
||||
open_pos = _open_positions(ag)
|
||||
open_position_count += len(open_pos)
|
||||
raw_pos = _raw_open_positions(ag)
|
||||
contaminated = any(is_option_like_position(p) for p in raw_pos)
|
||||
agent_upnl = _coerce_float(ag.get("total_unrealized_pnl"))
|
||||
if agent_upnl is not None:
|
||||
# 子代理若把期权混进永续合计,改按过滤后腿求和;期权浮盈由下方 options 段计入
|
||||
if agent_upnl is not None and not contaminated:
|
||||
float_pnl_u += agent_upnl
|
||||
else:
|
||||
float_pnl_u += sum(position_unrealized_pnl(p) for p in open_pos)
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
"""中控期权档案:同步 OKX options_review_trades 到 hub_symbol_archive.db."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lib.hub.hub_symbol_archive_lib import (
|
||||
TRADING_DAY_RESET_HOUR,
|
||||
_connect,
|
||||
default_db_path,
|
||||
init_db as init_perp_archive_db,
|
||||
ms_to_trading_day,
|
||||
parse_wall_clock_ms,
|
||||
resolve_period_bounds,
|
||||
trading_day_bounds_ms,
|
||||
)
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def init_options_archive_db(db_path: Path | None = None) -> None:
|
||||
"""确保期权缓存表存在(与永续共用同一 SQLite)."""
|
||||
init_perp_archive_db(db_path)
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS archive_options_trade_cache (
|
||||
exchange_key TEXT NOT NULL,
|
||||
history_key TEXT NOT NULL,
|
||||
source_type TEXT,
|
||||
underlying TEXT,
|
||||
opened_at TEXT,
|
||||
closed_at TEXT,
|
||||
opened_at_ms INTEGER,
|
||||
closed_at_ms INTEGER,
|
||||
hold_seconds INTEGER,
|
||||
realized_pnl_total REAL,
|
||||
status_raw TEXT,
|
||||
pos_id TEXT,
|
||||
inst_id TEXT,
|
||||
opt_type TEXT,
|
||||
strike REAL,
|
||||
exp_time TEXT,
|
||||
sheets INTEGER,
|
||||
open_avg REAL,
|
||||
close_avg REAL,
|
||||
premium_paid REAL,
|
||||
realized_pnl REAL,
|
||||
hedge_plan_id INTEGER,
|
||||
plan_close_reason TEXT,
|
||||
realized_pnl_perp REAL,
|
||||
realized_pnl_options REAL,
|
||||
premium_total REAL,
|
||||
direction TEXT,
|
||||
tp REAL,
|
||||
sl REAL,
|
||||
target_price REAL,
|
||||
target_price_up REAL,
|
||||
target_price_down REAL,
|
||||
legs_json TEXT,
|
||||
linked_hedge_plan_id INTEGER,
|
||||
excluded_as_hedge_leg INTEGER DEFAULT 0,
|
||||
strategy_tag TEXT,
|
||||
result_tag TEXT,
|
||||
reviewed INTEGER DEFAULT 0,
|
||||
source_label TEXT,
|
||||
payload_json TEXT NOT NULL,
|
||||
synced_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (exchange_key, history_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_options_closed
|
||||
ON archive_options_trade_cache (exchange_key, closed_at_ms)
|
||||
"""
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def purge_stale_options_trades_cache(
|
||||
exchange_key: str,
|
||||
active_history_keys: list[str],
|
||||
*,
|
||||
db_path: Path | None = None,
|
||||
) -> int:
|
||||
init_options_archive_db(db_path)
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
if not ex_k:
|
||||
return 0
|
||||
active = {str(k).strip() for k in (active_history_keys or []) if str(k).strip()}
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT history_key FROM archive_options_trade_cache WHERE exchange_key=?",
|
||||
(ex_k,),
|
||||
).fetchall()
|
||||
stale = [r["history_key"] for r in rows if r["history_key"] not in active]
|
||||
removed = 0
|
||||
for hk in stale:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM archive_options_trade_cache WHERE exchange_key=? AND history_key=?",
|
||||
(ex_k, hk),
|
||||
)
|
||||
removed += int(cur.rowcount or 0)
|
||||
return removed
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _optional_float(raw: Any) -> float | None:
|
||||
if raw in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _optional_int(raw: Any) -> int | None:
|
||||
if raw in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def upsert_options_trades_cache(
|
||||
exchange_key: str,
|
||||
trades: list[dict[str, Any]],
|
||||
*,
|
||||
db_path: Path | None = None,
|
||||
prune_missing: bool = True,
|
||||
) -> dict[str, int]:
|
||||
init_options_archive_db(db_path)
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
if not ex_k:
|
||||
return {"upserted": 0, "removed": 0}
|
||||
now = _now_ms()
|
||||
n = 0
|
||||
active_keys: list[str] = []
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
for t in trades or []:
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
hk = str(t.get("history_key") or "").strip()
|
||||
if not hk:
|
||||
continue
|
||||
if int(t.get("excluded_as_hedge_leg") or 0):
|
||||
continue
|
||||
active_keys.append(hk)
|
||||
opened_at = t.get("opened_at")
|
||||
closed_at = t.get("closed_at")
|
||||
opened_ms = t.get("opened_at_ms") or parse_wall_clock_ms(opened_at)
|
||||
closed_ms = t.get("closed_at_ms") or parse_wall_clock_ms(closed_at)
|
||||
entry = t.get("entry") if isinstance(t.get("entry"), dict) else {}
|
||||
strategy_tag = t.get("strategy_tag") or (entry or {}).get("strategy_tag")
|
||||
result_tag = t.get("result_tag") or (entry or {}).get("result_tag")
|
||||
reviewed = 1 if t.get("reviewed") or entry else 0
|
||||
row = dict(t)
|
||||
row["exchange_key"] = ex_k
|
||||
payload = json.dumps(row, ensure_ascii=False, default=str)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO archive_options_trade_cache (
|
||||
exchange_key, history_key, source_type, underlying,
|
||||
opened_at, closed_at, opened_at_ms, closed_at_ms, hold_seconds,
|
||||
realized_pnl_total, status_raw,
|
||||
pos_id, inst_id, opt_type, strike, exp_time, sheets,
|
||||
open_avg, close_avg, premium_paid, realized_pnl,
|
||||
hedge_plan_id, plan_close_reason, realized_pnl_perp, realized_pnl_options,
|
||||
premium_total, direction, tp, sl, target_price, target_price_up, target_price_down,
|
||||
legs_json, linked_hedge_plan_id, excluded_as_hedge_leg,
|
||||
strategy_tag, result_tag, reviewed, source_label,
|
||||
payload_json, synced_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(exchange_key, history_key) DO UPDATE SET
|
||||
source_type=excluded.source_type,
|
||||
underlying=excluded.underlying,
|
||||
opened_at=excluded.opened_at,
|
||||
closed_at=excluded.closed_at,
|
||||
opened_at_ms=excluded.opened_at_ms,
|
||||
closed_at_ms=excluded.closed_at_ms,
|
||||
hold_seconds=excluded.hold_seconds,
|
||||
realized_pnl_total=excluded.realized_pnl_total,
|
||||
status_raw=excluded.status_raw,
|
||||
pos_id=excluded.pos_id,
|
||||
inst_id=excluded.inst_id,
|
||||
opt_type=excluded.opt_type,
|
||||
strike=excluded.strike,
|
||||
exp_time=excluded.exp_time,
|
||||
sheets=excluded.sheets,
|
||||
open_avg=excluded.open_avg,
|
||||
close_avg=excluded.close_avg,
|
||||
premium_paid=excluded.premium_paid,
|
||||
realized_pnl=excluded.realized_pnl,
|
||||
hedge_plan_id=excluded.hedge_plan_id,
|
||||
plan_close_reason=excluded.plan_close_reason,
|
||||
realized_pnl_perp=excluded.realized_pnl_perp,
|
||||
realized_pnl_options=excluded.realized_pnl_options,
|
||||
premium_total=excluded.premium_total,
|
||||
direction=excluded.direction,
|
||||
tp=excluded.tp,
|
||||
sl=excluded.sl,
|
||||
target_price=excluded.target_price,
|
||||
target_price_up=excluded.target_price_up,
|
||||
target_price_down=excluded.target_price_down,
|
||||
legs_json=excluded.legs_json,
|
||||
linked_hedge_plan_id=excluded.linked_hedge_plan_id,
|
||||
excluded_as_hedge_leg=excluded.excluded_as_hedge_leg,
|
||||
strategy_tag=excluded.strategy_tag,
|
||||
result_tag=excluded.result_tag,
|
||||
reviewed=excluded.reviewed,
|
||||
source_label=excluded.source_label,
|
||||
payload_json=excluded.payload_json,
|
||||
synced_at=excluded.synced_at
|
||||
""",
|
||||
(
|
||||
ex_k,
|
||||
hk,
|
||||
t.get("source_type"),
|
||||
t.get("underlying"),
|
||||
opened_at,
|
||||
closed_at,
|
||||
int(opened_ms) if opened_ms else None,
|
||||
int(closed_ms) if closed_ms else None,
|
||||
_optional_int(t.get("hold_seconds")),
|
||||
float(t.get("realized_pnl_total") or t.get("realized_pnl") or 0),
|
||||
t.get("status_raw"),
|
||||
t.get("pos_id"),
|
||||
t.get("inst_id"),
|
||||
t.get("opt_type"),
|
||||
_optional_float(t.get("strike")),
|
||||
t.get("exp_time"),
|
||||
_optional_int(t.get("sheets")),
|
||||
_optional_float(t.get("open_avg")),
|
||||
_optional_float(t.get("close_avg")),
|
||||
_optional_float(t.get("premium_paid")),
|
||||
_optional_float(t.get("realized_pnl")),
|
||||
_optional_int(t.get("hedge_plan_id")),
|
||||
t.get("plan_close_reason"),
|
||||
_optional_float(t.get("realized_pnl_perp")),
|
||||
_optional_float(t.get("realized_pnl_options")),
|
||||
_optional_float(t.get("premium_total")),
|
||||
t.get("direction"),
|
||||
_optional_float(t.get("tp")),
|
||||
_optional_float(t.get("sl")),
|
||||
_optional_float(t.get("target_price")),
|
||||
_optional_float(t.get("target_price_up")),
|
||||
_optional_float(t.get("target_price_down")),
|
||||
t.get("legs_json")
|
||||
if isinstance(t.get("legs_json"), str)
|
||||
else (json.dumps(t.get("legs"), ensure_ascii=False) if t.get("legs") else None),
|
||||
_optional_int(t.get("linked_hedge_plan_id")),
|
||||
int(t.get("excluded_as_hedge_leg") or 0),
|
||||
strategy_tag,
|
||||
result_tag,
|
||||
reviewed,
|
||||
t.get("source_label"),
|
||||
payload,
|
||||
now,
|
||||
),
|
||||
)
|
||||
n += 1
|
||||
finally:
|
||||
conn.close()
|
||||
removed = 0
|
||||
if prune_missing:
|
||||
removed = purge_stale_options_trades_cache(ex_k, active_keys, db_path=db_path)
|
||||
return {"upserted": n, "removed": removed}
|
||||
|
||||
|
||||
def _options_row_to_dict(row: Any) -> dict[str, Any]:
|
||||
out: dict[str, Any] = dict(row)
|
||||
payload = {}
|
||||
raw = out.get("payload_json")
|
||||
if raw:
|
||||
try:
|
||||
payload = json.loads(raw) if isinstance(raw, str) else {}
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
payload = {}
|
||||
if isinstance(payload, dict):
|
||||
for k, v in payload.items():
|
||||
if k not in out or out.get(k) in (None, ""):
|
||||
out[k] = v
|
||||
pnl = float(out.get("realized_pnl_total") or out.get("realized_pnl") or 0)
|
||||
out["realized_pnl_total"] = pnl
|
||||
out["pnl_amount"] = pnl # 复用永续统计/日历字段名
|
||||
hold_sec = out.get("hold_seconds")
|
||||
if hold_sec is not None:
|
||||
try:
|
||||
out["hold_minutes"] = round(float(hold_sec) / 60.0, 2)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if not out.get("opened_at_ms") and out.get("opened_at"):
|
||||
ms = parse_wall_clock_ms(out.get("opened_at"))
|
||||
if ms:
|
||||
out["opened_at_ms"] = int(ms)
|
||||
if not out.get("closed_at_ms") and out.get("closed_at"):
|
||||
ms = parse_wall_clock_ms(out.get("closed_at"))
|
||||
if ms:
|
||||
out["closed_at_ms"] = int(ms)
|
||||
out["trade_id"] = out.get("history_key")
|
||||
out["id"] = out.get("history_key")
|
||||
out["symbol"] = out.get("inst_id") or out.get("underlying") or ""
|
||||
return out
|
||||
|
||||
|
||||
def _empty_options_stats() -> dict[str, Any]:
|
||||
return {
|
||||
"open_count": 0,
|
||||
"sick_count": 0,
|
||||
"sick_pct": 0.0,
|
||||
"pnl_total": 0.0,
|
||||
"pnl_ex_sick": 0.0,
|
||||
"win_count": 0,
|
||||
"loss_count": 0,
|
||||
"avg_win": 0.0,
|
||||
"avg_loss": 0.0,
|
||||
"max_win": 0.0,
|
||||
"max_loss": 0.0,
|
||||
"win_rate": 0.0,
|
||||
"profit_loss_ratio": 0.0,
|
||||
"turnover_total": 0.0,
|
||||
"commission_total": 0.0,
|
||||
"premium_total": 0.0,
|
||||
"by_exchange": {},
|
||||
"by_source_type": {},
|
||||
}
|
||||
|
||||
|
||||
def _compute_options_period_stats(trade_rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
st = _empty_options_stats()
|
||||
wins: list[float] = []
|
||||
losses: list[float] = []
|
||||
by_ex: dict[str, dict[str, Any]] = {}
|
||||
by_src: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def bucket() -> dict[str, Any]:
|
||||
return {
|
||||
"open_count": 0,
|
||||
"pnl_total": 0.0,
|
||||
"win_count": 0,
|
||||
"loss_count": 0,
|
||||
"premium_total": 0.0,
|
||||
}
|
||||
|
||||
for td in trade_rows:
|
||||
pnl = float(td.get("pnl_amount") or td.get("realized_pnl_total") or 0)
|
||||
ex = str(td.get("exchange_key") or "okx")
|
||||
src = str(td.get("source_type") or td.get("source_label") or "?")
|
||||
prem = float(td.get("premium_total") or td.get("premium_paid") or 0)
|
||||
st["open_count"] += 1
|
||||
st["pnl_total"] += pnl
|
||||
st["premium_total"] += prem
|
||||
if pnl > 0.0001:
|
||||
st["win_count"] += 1
|
||||
wins.append(pnl)
|
||||
elif pnl < -0.0001:
|
||||
st["loss_count"] += 1
|
||||
losses.append(pnl)
|
||||
if ex not in by_ex:
|
||||
by_ex[ex] = bucket()
|
||||
by_ex[ex]["open_count"] += 1
|
||||
by_ex[ex]["pnl_total"] += pnl
|
||||
by_ex[ex]["premium_total"] += prem
|
||||
if pnl > 0.0001:
|
||||
by_ex[ex]["win_count"] += 1
|
||||
elif pnl < -0.0001:
|
||||
by_ex[ex]["loss_count"] += 1
|
||||
if src not in by_src:
|
||||
by_src[src] = bucket()
|
||||
by_src[src]["open_count"] += 1
|
||||
by_src[src]["pnl_total"] += pnl
|
||||
|
||||
total = int(st["open_count"] or 0)
|
||||
st["pnl_ex_sick"] = round(float(st["pnl_total"]), 4)
|
||||
st["pnl_total"] = round(float(st["pnl_total"]), 4)
|
||||
st["premium_total"] = round(float(st["premium_total"]), 4)
|
||||
st["avg_win"] = round(sum(wins) / len(wins), 4) if wins else 0.0
|
||||
st["avg_loss"] = round(sum(losses) / len(losses), 4) if losses else 0.0
|
||||
st["max_win"] = round(max(wins), 4) if wins else 0.0
|
||||
st["max_loss"] = round(min(losses), 4) if losses else 0.0
|
||||
st["win_rate"] = round(st["win_count"] / total * 100, 1) if total else 0.0
|
||||
if wins and losses and abs(st["avg_loss"]) > 1e-9:
|
||||
st["profit_loss_ratio"] = round(abs(st["avg_win"] / st["avg_loss"]), 2)
|
||||
for ex, b in by_ex.items():
|
||||
b["pnl_total"] = round(float(b["pnl_total"]), 4)
|
||||
b["premium_total"] = round(float(b["premium_total"]), 4)
|
||||
b["sick_count"] = 0
|
||||
b["sick_pct"] = 0.0
|
||||
b["pnl_ex_sick"] = b["pnl_total"]
|
||||
b["avg_win"] = 0.0
|
||||
b["avg_loss"] = 0.0
|
||||
b["max_win"] = 0.0
|
||||
b["max_loss"] = 0.0
|
||||
b["win_rate"] = (
|
||||
round(b["win_count"] / b["open_count"] * 100, 1) if b["open_count"] else 0.0
|
||||
)
|
||||
b["profit_loss_ratio"] = 0.0
|
||||
b["turnover_total"] = 0.0
|
||||
b["commission_total"] = 0.0
|
||||
for src, b in by_src.items():
|
||||
b["pnl_total"] = round(float(b["pnl_total"]), 4)
|
||||
st["by_exchange"] = by_ex
|
||||
st["by_source_type"] = by_src
|
||||
return st
|
||||
|
||||
|
||||
def list_daily_options_trades(
|
||||
trading_day: str = "",
|
||||
*,
|
||||
period: str = "",
|
||||
date_from: str = "",
|
||||
date_to: str = "",
|
||||
exchange_key: str = "",
|
||||
filter_profit: bool = False,
|
||||
filter_loss: bool = False,
|
||||
search: str = "",
|
||||
source_type: str = "",
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
init_options_archive_db(db_path)
|
||||
p = (period or "today").strip().lower() or "today"
|
||||
start_ms, end_ms, df, dt, period_label = resolve_period_bounds(
|
||||
period=p,
|
||||
trading_day=trading_day,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
ex_filter = (exchange_key or "").strip().lower()
|
||||
src_filter = (source_type or "").strip().lower()
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
params: list[Any] = [start_ms, end_ms]
|
||||
where = "closed_at_ms IS NOT NULL AND closed_at_ms >= ? AND closed_at_ms < ?"
|
||||
where += " AND COALESCE(excluded_as_hedge_leg,0)=0"
|
||||
if ex_filter:
|
||||
where += " AND exchange_key=?"
|
||||
params.append(ex_filter)
|
||||
if src_filter:
|
||||
where += " AND LOWER(COALESCE(source_type,''))=?"
|
||||
params.append(src_filter)
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT * FROM archive_options_trade_cache
|
||||
WHERE {where}
|
||||
ORDER BY closed_at_ms DESC, history_key DESC
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
trades: list[dict[str, Any]] = []
|
||||
q = (search or "").strip().lower()
|
||||
for r in rows:
|
||||
td = _options_row_to_dict(r)
|
||||
pnl = float(td.get("pnl_amount") or 0)
|
||||
if filter_profit and pnl <= 0.0001:
|
||||
continue
|
||||
if filter_loss and pnl >= -0.0001:
|
||||
continue
|
||||
if q:
|
||||
blob = " ".join(
|
||||
str(td.get(k) or "")
|
||||
for k in (
|
||||
"underlying",
|
||||
"inst_id",
|
||||
"exchange_key",
|
||||
"source_type",
|
||||
"source_label",
|
||||
"opt_type",
|
||||
"strategy_tag",
|
||||
"result_tag",
|
||||
"direction",
|
||||
)
|
||||
).lower()
|
||||
if q not in blob:
|
||||
continue
|
||||
trades.append(td)
|
||||
return {
|
||||
"period": p,
|
||||
"period_label": period_label,
|
||||
"trading_day": dt,
|
||||
"date_from": df,
|
||||
"date_to": dt,
|
||||
"product": "options",
|
||||
"trades": trades,
|
||||
"stats": _compute_options_period_stats(trades),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_archive_options_calendar(
|
||||
year: int,
|
||||
month: int,
|
||||
*,
|
||||
exchange_key: str = "",
|
||||
db_path: Path | None = None,
|
||||
reset_hour: int = TRADING_DAY_RESET_HOUR,
|
||||
) -> dict[str, Any]:
|
||||
init_options_archive_db(db_path)
|
||||
y = int(year)
|
||||
m = int(month)
|
||||
if m < 1 or m > 12:
|
||||
raise ValueError("month 无效")
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
first = f"{y:04d}-{m:02d}-01"
|
||||
if m == 12:
|
||||
next_first = datetime(y + 1, 1, 1)
|
||||
else:
|
||||
next_first = datetime(y, m + 1, 1)
|
||||
last = (next_first - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
start_ms, _ = trading_day_bounds_ms(first, reset_hour=reset_hour)
|
||||
_, end_ms = trading_day_bounds_ms(last, reset_hour=reset_hour)
|
||||
ex_filter = (exchange_key or "").strip().lower()
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
params: list[Any] = [start_ms, end_ms]
|
||||
where = (
|
||||
"closed_at_ms IS NOT NULL AND closed_at_ms >= ? AND closed_at_ms < ?"
|
||||
" AND COALESCE(excluded_as_hedge_leg,0)=0"
|
||||
)
|
||||
if ex_filter:
|
||||
where += " AND exchange_key=?"
|
||||
params.append(ex_filter)
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM archive_options_trade_cache WHERE {where}",
|
||||
params,
|
||||
).fetchall()
|
||||
days: dict[str, dict[str, Any]] = {}
|
||||
for r in rows:
|
||||
td = _options_row_to_dict(r)
|
||||
closed_ms = td.get("closed_at_ms") or parse_wall_clock_ms(td.get("closed_at"))
|
||||
if not closed_ms:
|
||||
continue
|
||||
day = ms_to_trading_day(int(closed_ms), reset_hour=reset_hour)
|
||||
if not day or day < first or day > last:
|
||||
continue
|
||||
bucket = days.setdefault(
|
||||
day,
|
||||
{
|
||||
"trading_day": day,
|
||||
"open_count": 0,
|
||||
"sick_count": 0,
|
||||
"pnl_total": 0.0,
|
||||
"turnover_total": 0.0,
|
||||
"commission_total": 0.0,
|
||||
"has_sick": False,
|
||||
},
|
||||
)
|
||||
bucket["open_count"] += 1
|
||||
bucket["pnl_total"] += float(td.get("pnl_amount") or 0)
|
||||
for d in days.values():
|
||||
d["pnl_total"] = round(float(d["pnl_total"]), 4)
|
||||
month_pnl = sum(float(d["pnl_total"]) for d in days.values())
|
||||
month_count = sum(int(d["open_count"]) for d in days.values())
|
||||
return {
|
||||
"year": y,
|
||||
"month": m,
|
||||
"date_from": first,
|
||||
"date_to": last,
|
||||
"product": "options",
|
||||
"days": days,
|
||||
"month_pnl_total": round(month_pnl, 4),
|
||||
"month_open_count": month_count,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def sync_options_exchange_archive(
|
||||
exchange_key: str,
|
||||
trades: list[dict[str, Any]],
|
||||
*,
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""仅缓存期权交易,不做 K 线."""
|
||||
r = upsert_options_trades_cache(
|
||||
exchange_key, trades, db_path=db_path, prune_missing=True
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"exchange_key": (exchange_key or "").strip().lower(),
|
||||
"product": "options",
|
||||
"trades_upserted": r.get("upserted", 0),
|
||||
"trades_removed": r.get("removed", 0),
|
||||
"trade_count": len(trades or []),
|
||||
}
|
||||
@@ -36,30 +36,127 @@ def _sum_optional(*values: Any) -> Optional[float]:
|
||||
|
||||
|
||||
def options_balances_usdt_equiv(options_snap: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""从期权 snapshot 提取资金户/交易户 USDT 等价余额."""
|
||||
"""从期权 snapshot 提取资金户/交易户 USDT 等价余额.
|
||||
|
||||
- funding_usdt / trading_usdt: USDT+USDC(+USDG) 全账户(勿与永续 USDT 再加总)
|
||||
- funding_usdc_equiv / trading_usdc_equiv: 仅非 USDT 稳定币,可安全加到永续 USDT 上
|
||||
"""
|
||||
snap = options_snap if isinstance(options_snap, dict) else {}
|
||||
if snap.get("enabled") is False:
|
||||
return {"ok": False, "funding_usdt": None, "trading_usdt": None}
|
||||
return {
|
||||
"ok": False,
|
||||
"funding_usdt": None,
|
||||
"trading_usdt": None,
|
||||
"funding_usdc_equiv": None,
|
||||
"trading_usdc_equiv": None,
|
||||
}
|
||||
if snap.get("ok") is False:
|
||||
return {"ok": False, "funding_usdt": None, "trading_usdt": None}
|
||||
return {
|
||||
"ok": False,
|
||||
"funding_usdt": None,
|
||||
"trading_usdt": None,
|
||||
"funding_usdc_equiv": None,
|
||||
"trading_usdc_equiv": None,
|
||||
}
|
||||
bal = snap.get("balances") if isinstance(snap.get("balances"), dict) else snap
|
||||
funding = _sum_optional(bal.get("funding_usdt"), bal.get("funding_usdc"))
|
||||
trading = _sum_optional(bal.get("trading_usdt"), bal.get("trading_usdc"))
|
||||
funding_usdc_equiv = _sum_optional(bal.get("funding_usdc"), bal.get("funding_usdg"))
|
||||
trading_usdc_equiv = _sum_optional(bal.get("trading_usdc"), bal.get("trading_usdg"))
|
||||
funding = _sum_optional(bal.get("funding_usdt"), funding_usdc_equiv)
|
||||
trading = _sum_optional(bal.get("trading_usdt"), trading_usdc_equiv)
|
||||
ok = funding is not None and trading is not None
|
||||
return {"ok": ok, "funding_usdt": funding, "trading_usdt": trading}
|
||||
return {
|
||||
"ok": ok,
|
||||
"funding_usdt": funding,
|
||||
"trading_usdt": trading,
|
||||
"funding_usdc_equiv": funding_usdc_equiv,
|
||||
"trading_usdc_equiv": trading_usdc_equiv,
|
||||
}
|
||||
|
||||
|
||||
def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[float]:
|
||||
"""期权浮盈合计(USDT).币本位按指数换算,勿把 ETH/BTC 数量当 U."""
|
||||
snap = options_snap if isinstance(options_snap, dict) else {}
|
||||
if snap.get("enabled") is False or snap.get("ok") is False:
|
||||
return None
|
||||
upl = snap.get("upl_total_usdc")
|
||||
if upl is None:
|
||||
|
||||
def _safe(v: Any) -> float | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _index_px() -> float | None:
|
||||
px = _safe(snap.get("options_index_px") or snap.get("index_px"))
|
||||
if px is not None and px > 0:
|
||||
return px
|
||||
for p in snap.get("positions") or []:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
px = _safe(p.get("idx_px") or p.get("idxPx"))
|
||||
if px is not None and px > 0:
|
||||
return px
|
||||
return None
|
||||
|
||||
def _row_is_coin(p: dict[str, Any]) -> bool:
|
||||
ccy = str(p.get("premium_ccy") or "").strip().upper()
|
||||
if ccy in ("ETH", "BTC"):
|
||||
return True
|
||||
if str(p.get("margin_mode") or "").strip().lower() == "coin":
|
||||
return True
|
||||
mid = str(p.get("inst_id") or "")
|
||||
return "-USD-" in mid.upper() and "_UM" not in mid.upper()
|
||||
|
||||
mode = str(snap.get("options_margin_mode") or snap.get("margin_mode") or "").strip().lower()
|
||||
snap_coin = mode == "coin"
|
||||
idx = _index_px()
|
||||
|
||||
try:
|
||||
return round(float(upl), 4)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
from lib.options.options_positions_lib import display_pnl_from_option_row
|
||||
|
||||
total_u = 0.0
|
||||
found = False
|
||||
missing_fx = False
|
||||
for p in snap.get("positions") or []:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
pnl = display_pnl_from_option_row(p)
|
||||
if pnl is None:
|
||||
continue
|
||||
found = True
|
||||
if snap_coin or _row_is_coin(p):
|
||||
px = _safe(p.get("idx_px") or p.get("idxPx"))
|
||||
if px is None or px <= 0:
|
||||
px = idx
|
||||
if px is None or px <= 0:
|
||||
missing_fx = True
|
||||
continue
|
||||
total_u += float(pnl) * float(px)
|
||||
else:
|
||||
total_u += float(pnl)
|
||||
if found and not missing_fx:
|
||||
return round(total_u, 4)
|
||||
if found and missing_fx and abs(total_u) > 1e-12:
|
||||
# 部分腿已换算成功时仍返回可得合计
|
||||
return round(total_u, 4)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
upl = snap.get("upl_total_usdc")
|
||||
if upl is not None:
|
||||
try:
|
||||
raw = float(upl)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if snap_coin or any(
|
||||
isinstance(p, dict) and _row_is_coin(p) for p in (snap.get("positions") or [])
|
||||
):
|
||||
if idx is None or idx <= 0:
|
||||
return None
|
||||
return round(raw * float(idx), 4)
|
||||
return round(raw, 4)
|
||||
return None
|
||||
|
||||
|
||||
def options_open_position_count(options_snap: dict[str, Any] | None) -> int:
|
||||
@@ -80,19 +177,45 @@ def merge_perp_options_balances(
|
||||
perpetual_trading_usdt: Any,
|
||||
options_snap: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""永续 + 期权余额合并为中控 USDT 统计口径."""
|
||||
"""永续 USDT + 期权非 USDT 稳定币合并为中控总资金(避免 OKX 同账户 USDT 双计).
|
||||
|
||||
与实例顶栏 total_funds_usdt(..., options_usdc, None, None) 口径一致:
|
||||
期权 snapshot 里的 USDT 与永续资金/交易户是同一钱包,只把 USDC/USDG 加上.
|
||||
"""
|
||||
opt = options_balances_usdt_equiv(options_snap)
|
||||
funding = _sum_optional(perpetual_funding_usdt, opt.get("funding_usdt"))
|
||||
trading = _sum_optional(perpetual_trading_usdt, opt.get("trading_usdt"))
|
||||
# 展示用期权户:优先非 USDT 稳定币;若仅有 USDT 则仍给出全量以便辨识
|
||||
opt_fund_disp = opt.get("funding_usdc_equiv")
|
||||
opt_trade_disp = opt.get("trading_usdc_equiv")
|
||||
if opt_fund_disp is None and opt_trade_disp is None and opt.get("ok"):
|
||||
opt_fund_disp = opt.get("funding_usdt")
|
||||
opt_trade_disp = opt.get("trading_usdt")
|
||||
|
||||
if opt.get("ok"):
|
||||
if perpetual_funding_usdt is None and perpetual_trading_usdt is None:
|
||||
# 永续账户未取到时,期权 snapshot 已含同账户 USDT+USDC,直接用全量
|
||||
funding = opt.get("funding_usdt")
|
||||
trading = opt.get("trading_usdt")
|
||||
else:
|
||||
funding = _sum_optional(perpetual_funding_usdt, opt.get("funding_usdc_equiv"))
|
||||
trading = _sum_optional(perpetual_trading_usdt, opt.get("trading_usdc_equiv"))
|
||||
else:
|
||||
funding = _safe_float(perpetual_funding_usdt)
|
||||
trading = _safe_float(perpetual_trading_usdt)
|
||||
|
||||
total = _account_total_usdt(funding, trading)
|
||||
# 任一侧齐全即可展示;永续缺一侧但有期权 USDC 时仍尽量给出合计
|
||||
if total is None:
|
||||
total = _sum_optional(funding, trading)
|
||||
perp_total = _account_total_usdt(perpetual_funding_usdt, perpetual_trading_usdt)
|
||||
opt_total = _account_total_usdt(opt.get("funding_usdt"), opt.get("trading_usdt"))
|
||||
data_ok = total is not None
|
||||
return {
|
||||
"perpetual_funding_usdt": _safe_float(perpetual_funding_usdt),
|
||||
"perpetual_trading_usdt": _safe_float(perpetual_trading_usdt),
|
||||
"options_funding_usdt": opt.get("funding_usdt"),
|
||||
"options_trading_usdt": opt.get("trading_usdt"),
|
||||
"options_funding_usdt": opt_fund_disp,
|
||||
"options_trading_usdt": opt_trade_disp,
|
||||
"options_funding_full_usdt": opt.get("funding_usdt"),
|
||||
"options_trading_full_usdt": opt.get("trading_usdt"),
|
||||
"options_ok": bool(opt.get("ok")),
|
||||
"funding_usdt": funding,
|
||||
"trading_usdt": trading,
|
||||
@@ -103,6 +226,33 @@ def merge_perp_options_balances(
|
||||
}
|
||||
|
||||
|
||||
def repair_double_counted_fund_entry(ac: dict[str, Any]) -> dict[str, Any]:
|
||||
"""识别并修复历史快照中「永续 USDT + 期权(USDT+USDC)」的双计.
|
||||
|
||||
旧口径 options_* 存的是 USDT+USDC 全量,且 funding≈2×期权资金户 USDT 部分.
|
||||
新口径 options_* 多为纯 USDC,不会误伤.
|
||||
"""
|
||||
if not isinstance(ac, dict):
|
||||
return {}
|
||||
out = dict(ac)
|
||||
ofu = _safe_float(ac.get("options_funding_usdt"))
|
||||
otu = _safe_float(ac.get("options_trading_usdt"))
|
||||
fu = _safe_float(ac.get("funding_usdt"))
|
||||
tu = _safe_float(ac.get("trading_usdt"))
|
||||
if ofu is None or otu is None or fu is None or tu is None:
|
||||
return out
|
||||
if ofu < 1.0:
|
||||
return out
|
||||
ratio = fu / ofu if ofu > 0 else 0.0
|
||||
# 经典双计:合并资金户 ≈ 2 × 期权资金户(同钱包 USDT 加了两遍)
|
||||
if 1.8 <= ratio <= 2.25:
|
||||
out["funding_usdt"] = ofu
|
||||
out["trading_usdt"] = otu
|
||||
out["total_usdt"] = round(ofu + otu, 4)
|
||||
out["repaired_double_count"] = True
|
||||
return out
|
||||
|
||||
|
||||
def merge_board_row_balances(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""监控板行 → 含期权的资金统计."""
|
||||
caps = row.get("capabilities") or []
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""中控永期对冲计算器:永续 1 币 + 按目标盈利反推期权仓位/波动点数(纯函数)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from lib.trade.trade_fee_lib import estimate_roundtrip_fee_usdt, taker_fee_rate
|
||||
|
||||
DEFAULT_CT_MULT = 0.01
|
||||
PERP_COINS = 1.0
|
||||
|
||||
|
||||
def _f(v: Any) -> Optional[float]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_base_common(
|
||||
*,
|
||||
base: str,
|
||||
spot: Any,
|
||||
capital_usdt: Any,
|
||||
target_profit_u: Any,
|
||||
perp_leverage: Any,
|
||||
option_leverage: Any,
|
||||
ct_mult: Any,
|
||||
) -> Tuple[Optional[dict[str, float]], Optional[str]]:
|
||||
b = (base or "ETH").strip().upper()
|
||||
if b not in ("ETH", "BTC"):
|
||||
return None, "币种仅支持 BTC / ETH"
|
||||
s = _f(spot)
|
||||
capital = _f(capital_usdt)
|
||||
target = _f(target_profit_u)
|
||||
p_lev = _f(perp_leverage)
|
||||
o_lev = _f(option_leverage)
|
||||
ct = _f(ct_mult)
|
||||
if s is None or capital is None or target is None or p_lev is None or o_lev is None:
|
||||
return None, "参数格式错误"
|
||||
if ct is None or ct <= 0:
|
||||
ct = DEFAULT_CT_MULT
|
||||
if s <= 0 or capital <= 0 or p_lev <= 0 or o_lev <= 0:
|
||||
return None, "现价、资金、杠杆须大于 0"
|
||||
if target < 0:
|
||||
return None, "目标盈利不能为负"
|
||||
prem_per_coin = s / o_lev
|
||||
if prem_per_coin <= 0:
|
||||
return None, "单币权利金无效"
|
||||
margin = (s * PERP_COINS) / p_lev
|
||||
return {
|
||||
"base_ok": 1.0,
|
||||
"spot": s,
|
||||
"capital": capital,
|
||||
"target": target,
|
||||
"p_lev": p_lev,
|
||||
"o_lev": o_lev,
|
||||
"ct": ct,
|
||||
"prem_per_coin": prem_per_coin,
|
||||
"margin": margin,
|
||||
"fee_rate": taker_fee_rate(),
|
||||
}, None
|
||||
|
||||
|
||||
def _move_for_perp_correct(
|
||||
*,
|
||||
spot: float,
|
||||
target: float,
|
||||
premium: float,
|
||||
fee_rate: float,
|
||||
perp_coins: float = 1.0,
|
||||
) -> float:
|
||||
"""净利 = qty*move − premium − fee(move,qty) = target → 解 move.
|
||||
|
||||
fee = (2*spot + move) * qty * fee_rate
|
||||
qty*move*(1-fee_rate) = target + premium + 2*spot*qty*fee_rate
|
||||
"""
|
||||
qty = float(perp_coins)
|
||||
if qty <= 0:
|
||||
return 0.0
|
||||
denom = qty * (1.0 - float(fee_rate))
|
||||
if denom <= 0:
|
||||
return 0.0
|
||||
return (float(target) + float(premium) + 2.0 * float(spot) * qty * float(fee_rate)) / denom
|
||||
|
||||
|
||||
def _case_sideways(
|
||||
*,
|
||||
spot: float,
|
||||
premium_total: float,
|
||||
perp_coins: float = 1.0,
|
||||
) -> dict[str, Any]:
|
||||
"""横盘/到期无方向:永续≈0,期权权利金全亏,另计永续开平同价手续费.
|
||||
|
||||
最大亏损(正数) = 权利金总额 + 开平手续费(exit=entry)
|
||||
组合净利 = −最大亏损
|
||||
"""
|
||||
qty = float(perp_coins) if float(perp_coins) > 0 else PERP_COINS
|
||||
fee_flat = estimate_roundtrip_fee_usdt(spot, spot, qty=qty, contract_size=1.0)
|
||||
prem = float(premium_total)
|
||||
max_loss = prem + float(fee_flat)
|
||||
return {
|
||||
"label": "横盘",
|
||||
"perp_pnl_u": 0.0,
|
||||
"premium_u": round(prem, 8),
|
||||
"fee_u": round(float(fee_flat), 8),
|
||||
"max_loss_u": round(max_loss, 8),
|
||||
"net_u": round(-max_loss, 8),
|
||||
}
|
||||
|
||||
|
||||
def calc_perp_options_hedge(
|
||||
*,
|
||||
base: str = "ETH",
|
||||
spot: float,
|
||||
capital_usdt: float,
|
||||
target_profit_u: float,
|
||||
move_mode: str = "points",
|
||||
move_value: float,
|
||||
perp_leverage: float,
|
||||
option_leverage: float,
|
||||
ct_mult: float = DEFAULT_CT_MULT,
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
"""由波动反推期权开仓币数/张数(calc_mode=size)."""
|
||||
common, err = _parse_base_common(
|
||||
base=base,
|
||||
spot=spot,
|
||||
capital_usdt=capital_usdt,
|
||||
target_profit_u=target_profit_u,
|
||||
perp_leverage=perp_leverage,
|
||||
option_leverage=option_leverage,
|
||||
ct_mult=ct_mult,
|
||||
)
|
||||
if err or not common:
|
||||
return None, err
|
||||
|
||||
s = common["spot"]
|
||||
capital = common["capital"]
|
||||
target = common["target"]
|
||||
p_lev = common["p_lev"]
|
||||
o_lev = common["o_lev"]
|
||||
ct = common["ct"]
|
||||
prem_per_coin = common["prem_per_coin"]
|
||||
margin = common["margin"]
|
||||
fee_rate = common["fee_rate"]
|
||||
b = (base or "ETH").strip().upper()
|
||||
|
||||
move = _f(move_value)
|
||||
mode = (move_mode or "points").strip().lower()
|
||||
if mode not in ("points", "pct", "percent", "rate"):
|
||||
return None, "波动模式须为 points 或 pct"
|
||||
if mode in ("percent", "rate"):
|
||||
mode = "pct"
|
||||
if move is None:
|
||||
return None, "参数格式错误"
|
||||
if move <= 0:
|
||||
return None, "现价、资金、波动、杠杆须大于 0"
|
||||
|
||||
if mode == "pct":
|
||||
move_points = s * (move / 100.0)
|
||||
else:
|
||||
move_points = move
|
||||
if move_points <= 0:
|
||||
return None, "波动对应价格变动须大于 0"
|
||||
|
||||
exit_px = s + move_points
|
||||
perp_gross = move_points * PERP_COINS
|
||||
fee = estimate_roundtrip_fee_usdt(s, exit_px, qty=PERP_COINS, contract_size=1.0)
|
||||
|
||||
premium_budget = perp_gross - target - fee
|
||||
if premium_budget <= 0:
|
||||
return None, "波动收益不足以覆盖目标盈利+手续费,无法开期权"
|
||||
|
||||
opt_coins = premium_budget / prem_per_coin
|
||||
opt_sheets = opt_coins / ct
|
||||
premium_total = opt_coins * prem_per_coin
|
||||
|
||||
case_a_net = perp_gross - premium_total - fee
|
||||
opt_intrinsic = opt_coins * move_points
|
||||
opt_net = opt_intrinsic - premium_total
|
||||
perp_loss = -perp_gross
|
||||
portfolio_net = opt_net + perp_loss
|
||||
|
||||
return {
|
||||
"calc_mode": "size",
|
||||
"base": b,
|
||||
"spot": round(s, 8),
|
||||
"capital_usdt": round(capital, 8),
|
||||
"target_profit_u": round(target, 8),
|
||||
"move_mode": mode,
|
||||
"move_value": round(move, 8),
|
||||
"move_points": round(move_points, 8),
|
||||
"exit_price": round(exit_px, 8),
|
||||
"perp_coins": PERP_COINS,
|
||||
"perp_leverage": round(p_lev, 8),
|
||||
"option_leverage": round(o_lev, 8),
|
||||
"ct_mult": ct,
|
||||
"prem_per_coin": round(prem_per_coin, 8),
|
||||
"perp_gross_u": round(perp_gross, 8),
|
||||
"perp_fee_u": round(fee, 8),
|
||||
"fee_rate": fee_rate,
|
||||
"premium_budget_u": round(premium_budget, 8),
|
||||
"opt_coins": round(opt_coins, 8),
|
||||
"opt_sheets": round(opt_sheets, 8),
|
||||
"premium_total_u": round(premium_total, 8),
|
||||
"perp_margin_u": round(margin, 8),
|
||||
"capital_ok": bool(capital >= margin),
|
||||
"case_a": {
|
||||
"label": "永续方向对",
|
||||
"perp_pnl_u": round(perp_gross, 8),
|
||||
"premium_u": round(premium_total, 8),
|
||||
"fee_u": round(fee, 8),
|
||||
"net_u": round(case_a_net, 8),
|
||||
},
|
||||
"case_b": {
|
||||
"label": "期权方向对",
|
||||
"opt_intrinsic_u": round(opt_intrinsic, 8),
|
||||
"premium_u": round(premium_total, 8),
|
||||
"opt_net_u": round(opt_net, 8),
|
||||
"perp_pnl_u": round(perp_loss, 8),
|
||||
"portfolio_net_u": round(portfolio_net, 8),
|
||||
},
|
||||
"case_sideways": _case_sideways(spot=s, premium_total=premium_total),
|
||||
}, None
|
||||
|
||||
|
||||
def calc_perp_options_points(
|
||||
*,
|
||||
base: str = "ETH",
|
||||
spot: float,
|
||||
capital_usdt: float,
|
||||
target_profit_u: float,
|
||||
perp_leverage: float,
|
||||
option_leverage: float,
|
||||
ratio_perp: float = 1.0,
|
||||
ratio_opt: float = 2.0,
|
||||
ct_mult: float = DEFAULT_CT_MULT,
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
"""按永续/期权币数 + 目标盈利,反推两套情景所需波动点数.
|
||||
|
||||
永续币数 = ratio_perp, 期权币数 = ratio_opt(按绝对币数,不再归一到 1 币).
|
||||
例 2:4 → 永续 2 币 + 期权 4 币;1:2 → 永续 1 币 + 期权 2 币.
|
||||
|
||||
A 永续方向对: qty*move − premium − fee(move,qty) = 目标盈利
|
||||
B 期权方向对:
|
||||
- 期权净利达目标: opt_coins*move − premium = 目标
|
||||
- 组合净利达目标: move*(opt_coins − perp_coins) − premium = 目标
|
||||
"""
|
||||
common, err = _parse_base_common(
|
||||
base=base,
|
||||
spot=spot,
|
||||
capital_usdt=capital_usdt,
|
||||
target_profit_u=target_profit_u,
|
||||
perp_leverage=perp_leverage,
|
||||
option_leverage=option_leverage,
|
||||
ct_mult=ct_mult,
|
||||
)
|
||||
if err or not common:
|
||||
return None, err
|
||||
|
||||
rp = _f(ratio_perp)
|
||||
ro = _f(ratio_opt)
|
||||
if rp is None or ro is None or rp <= 0 or ro <= 0:
|
||||
return None, "永续/期权币数须大于 0"
|
||||
|
||||
s = common["spot"]
|
||||
capital = common["capital"]
|
||||
target = common["target"]
|
||||
p_lev = common["p_lev"]
|
||||
o_lev = common["o_lev"]
|
||||
ct = common["ct"]
|
||||
prem_per_coin = common["prem_per_coin"]
|
||||
fee_rate = common["fee_rate"]
|
||||
b = (base or "ETH").strip().upper()
|
||||
|
||||
perp_coins = rp
|
||||
opt_coins = ro
|
||||
premium_total = opt_coins * prem_per_coin
|
||||
opt_sheets = opt_coins / ct
|
||||
margin = (s * perp_coins) / p_lev
|
||||
|
||||
move_a = _move_for_perp_correct(
|
||||
spot=s,
|
||||
target=target,
|
||||
premium=premium_total,
|
||||
fee_rate=fee_rate,
|
||||
perp_coins=perp_coins,
|
||||
)
|
||||
if move_a <= 0:
|
||||
return None, "无法解出永续方向对所需点数"
|
||||
|
||||
fee_a = estimate_roundtrip_fee_usdt(s, s + move_a, qty=perp_coins, contract_size=1.0)
|
||||
net_a = move_a * perp_coins - premium_total - fee_a
|
||||
|
||||
# 期权净利 = 目标
|
||||
move_b_opt = (target + premium_total) / opt_coins
|
||||
opt_net_at_b_opt = opt_coins * move_b_opt - premium_total
|
||||
portfolio_at_b_opt = opt_net_at_b_opt - move_b_opt * perp_coins
|
||||
|
||||
# 组合净利 = 目标
|
||||
edge = opt_coins - perp_coins
|
||||
if edge <= 0:
|
||||
move_b_port = None
|
||||
port_err = "期权币数须大于永续币数,组合才能在方向对时赚到目标盈利"
|
||||
else:
|
||||
move_b_port = (target + premium_total) / edge
|
||||
port_err = None
|
||||
if move_b_port is not None:
|
||||
opt_net_at_b_port = opt_coins * move_b_port - premium_total
|
||||
portfolio_at_b_port = opt_net_at_b_port - move_b_port * perp_coins
|
||||
else:
|
||||
opt_net_at_b_port = None
|
||||
portfolio_at_b_port = None
|
||||
|
||||
return {
|
||||
"calc_mode": "points",
|
||||
"base": b,
|
||||
"spot": round(s, 8),
|
||||
"capital_usdt": round(capital, 8),
|
||||
"target_profit_u": round(target, 8),
|
||||
"ratio_perp": round(rp, 8),
|
||||
"ratio_opt": round(ro, 8),
|
||||
"ratio_label": f"{_fmt_ratio(rp)}:{_fmt_ratio(ro)}",
|
||||
"perp_coins": round(perp_coins, 8),
|
||||
"opt_coins": round(opt_coins, 8),
|
||||
"opt_sheets": round(opt_sheets, 8),
|
||||
"perp_leverage": round(p_lev, 8),
|
||||
"option_leverage": round(o_lev, 8),
|
||||
"ct_mult": ct,
|
||||
"prem_per_coin": round(prem_per_coin, 8),
|
||||
"premium_total_u": round(premium_total, 8),
|
||||
"fee_rate": fee_rate,
|
||||
"perp_margin_u": round(margin, 8),
|
||||
"capital_ok": bool(capital >= margin),
|
||||
"case_a": {
|
||||
"label": "永续方向对",
|
||||
"move_points": round(move_a, 8),
|
||||
"move_pct": round(move_a / s * 100.0, 8),
|
||||
"perp_pnl_u": round(move_a * perp_coins, 8),
|
||||
"premium_u": round(premium_total, 8),
|
||||
"fee_u": round(fee_a, 8),
|
||||
"net_u": round(net_a, 8),
|
||||
},
|
||||
"case_b": {
|
||||
"label": "期权方向对",
|
||||
"move_points_opt_net": round(move_b_opt, 8),
|
||||
"move_pct_opt_net": round(move_b_opt / s * 100.0, 8),
|
||||
"opt_net_u": round(opt_net_at_b_opt, 8),
|
||||
"portfolio_net_at_opt_target_u": round(portfolio_at_b_opt, 8),
|
||||
"move_points_portfolio": None if move_b_port is None else round(move_b_port, 8),
|
||||
"move_pct_portfolio": None
|
||||
if move_b_port is None
|
||||
else round(move_b_port / s * 100.0, 8),
|
||||
"opt_net_at_portfolio_target_u": None
|
||||
if opt_net_at_b_port is None
|
||||
else round(opt_net_at_b_port, 8),
|
||||
"portfolio_net_u": None if portfolio_at_b_port is None else round(portfolio_at_b_port, 8),
|
||||
"portfolio_error": port_err,
|
||||
"premium_u": round(premium_total, 8),
|
||||
},
|
||||
"case_sideways": _case_sideways(
|
||||
spot=s, premium_total=premium_total, perp_coins=perp_coins
|
||||
),
|
||||
}, None
|
||||
|
||||
|
||||
def _fmt_ratio(v: float) -> str:
|
||||
if abs(v - round(v)) < 1e-9:
|
||||
return str(int(round(v)))
|
||||
s = f"{v:.4f}".rstrip("0").rstrip(".")
|
||||
return s
|
||||
|
||||
|
||||
def calc_perp_options(
|
||||
*,
|
||||
calc_mode: str = "size",
|
||||
**kwargs: Any,
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
"""统一入口:size=由波动推仓位;points=由比例推点数."""
|
||||
mode = (calc_mode or "size").strip().lower()
|
||||
if mode in ("points", "ratio", "move"):
|
||||
return calc_perp_options_points(**kwargs)
|
||||
# size mode: ignore ratio kwargs if present
|
||||
kwargs.pop("ratio_perp", None)
|
||||
kwargs.pop("ratio_opt", None)
|
||||
return calc_perp_options_hedge(**kwargs)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""中控后台轮询等待:防止 request_refresh 连锁打满 CPU."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
|
||||
async def wait_poll_interval(
|
||||
*,
|
||||
refresh: asyncio.Event,
|
||||
stop: asyncio.Event,
|
||||
interval_sec: float,
|
||||
started_at: float,
|
||||
min_early_wake_sec: float | None = None,
|
||||
) -> None:
|
||||
"""距 started_at 至少间隔 interval_sec 再进入下一轮.
|
||||
|
||||
期间若收到 refresh:仅当已过 min_early_wake_sec 才提前结束(兼顾手动刷新与防抖).
|
||||
"""
|
||||
interval = max(0.05, float(interval_sec))
|
||||
min_early = (
|
||||
float(min_early_wake_sec)
|
||||
if min_early_wake_sec is not None
|
||||
else min(2.0, interval * 0.4)
|
||||
)
|
||||
while not stop.is_set():
|
||||
left = interval - (time.monotonic() - started_at)
|
||||
if left <= 0:
|
||||
return
|
||||
refresh.clear()
|
||||
stop_task = asyncio.create_task(stop.wait())
|
||||
refresh_task = asyncio.create_task(refresh.wait())
|
||||
done, pending = await asyncio.wait(
|
||||
{stop_task, refresh_task},
|
||||
timeout=left,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
if stop.is_set():
|
||||
return
|
||||
if not done:
|
||||
return
|
||||
if refresh_task in done and (time.monotonic() - started_at) >= min_early:
|
||||
return
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@@ -23,6 +24,45 @@ def _coerce_float(*values: Any) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
# OKX ccxt: ETH/USD:USD-260806-1875-C ; instId: ETH-USD-260806-1875-C
|
||||
_OPTION_SYM_RE = re.compile(
|
||||
r"(?:^|[/:])[A-Z0-9]+(?:-USD)?(?::USD)?-\d{6}-\d+-(?:C|P|CALL|PUT)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def is_option_like_position(pos: dict[str, Any] | None) -> bool:
|
||||
"""识别期权仓(子代理/中控浮盈合计须排除,避免按永续线性公式误算)."""
|
||||
if not isinstance(pos, dict):
|
||||
return False
|
||||
info = pos.get("info") if isinstance(pos.get("info"), dict) else {}
|
||||
inst_type = str(
|
||||
info.get("instType")
|
||||
or info.get("inst_type")
|
||||
or pos.get("type")
|
||||
or ""
|
||||
).upper()
|
||||
if inst_type in ("OPTION", "OPT"):
|
||||
return True
|
||||
sym = str(
|
||||
pos.get("symbol")
|
||||
or info.get("instId")
|
||||
or info.get("instrument_name")
|
||||
or info.get("contract")
|
||||
or ""
|
||||
).strip()
|
||||
if not sym:
|
||||
return False
|
||||
if _OPTION_SYM_RE.search(sym.replace(" ", "")):
|
||||
return True
|
||||
su = sym.upper()
|
||||
if su.endswith("-C") or su.endswith("-P") or su.endswith("-CALL") or su.endswith("-PUT"):
|
||||
# 永续多为 BTC/USDT:USDT;期权常带到期日段
|
||||
if re.search(r"-\d{6}-\d+-(?:C|P|CALL|PUT)$", su):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
CONTRACTS_QTY_DECIMALS = 2
|
||||
|
||||
|
||||
|
||||
@@ -10,9 +10,32 @@ from typing import Any
|
||||
|
||||
from lib.paths import REPO_ROOT
|
||||
|
||||
STRATEGY_EXCHANGES: tuple[str, ...] = ("binance", "okx", "gate")
|
||||
STRATEGY_EXCHANGES: tuple[str, ...] = (
|
||||
"playbook_v2",
|
||||
"playbook",
|
||||
"behavior",
|
||||
"binance",
|
||||
"okx",
|
||||
"gate",
|
||||
)
|
||||
|
||||
STRATEGY_META: dict[str, dict[str, str]] = {
|
||||
"playbook_v2": {
|
||||
"label": "执行手册v2",
|
||||
"title": "交易执行手册 v2(期权 / 合约 · 无对冲)",
|
||||
"md_rel": "docs/交易执行手册-v2-期权与合约.md",
|
||||
},
|
||||
"playbook": {
|
||||
"label": "执行手册v1",
|
||||
"title": "交易执行手册 v1(期权为主 · Gate 为辅 · 含对冲)",
|
||||
# 相对仓库根;其余条目用 md_file 相对 docs/strategy
|
||||
"md_rel": "docs/交易执行手册-期权与Gate.md",
|
||||
},
|
||||
"behavior": {
|
||||
"label": "行为准则",
|
||||
"title": "交易行为准则(开单三检)",
|
||||
"md_rel": "docs/交易行为准则-开单三检.md",
|
||||
},
|
||||
"binance": {
|
||||
"label": "币安",
|
||||
"title": "币安·山寨多头趋势",
|
||||
@@ -43,6 +66,9 @@ def _md_path(exchange_key: str) -> Path:
|
||||
meta = STRATEGY_META.get((exchange_key or "").strip().lower())
|
||||
if not meta:
|
||||
raise KeyError(exchange_key)
|
||||
md_rel = (meta.get("md_rel") or "").strip()
|
||||
if md_rel:
|
||||
return REPO_ROOT / md_rel
|
||||
return _strategy_dir() / meta["md_file"]
|
||||
|
||||
|
||||
@@ -210,11 +236,30 @@ def load_strategy_payload(exchange_key: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def strategy_meta_payload() -> dict[str, Any]:
|
||||
tabs = [
|
||||
{"key": k, "label": STRATEGY_META[k]["label"], "title": STRATEGY_META[k]["title"]}
|
||||
for k in STRATEGY_EXCHANGES
|
||||
]
|
||||
_STRATEGY_TAB_DISPLAY_PREF: dict[str, str] = {
|
||||
"playbook_v2": "show_strategy_playbook_v2",
|
||||
"playbook": "show_strategy_playbook",
|
||||
"behavior": "show_strategy_behavior",
|
||||
"binance": "show_strategy_binance",
|
||||
"okx": "show_strategy_okx",
|
||||
"gate": "show_strategy_gate",
|
||||
}
|
||||
|
||||
|
||||
def strategy_meta_payload(display: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
prefs = display if isinstance(display, dict) else {}
|
||||
tabs = []
|
||||
for k in STRATEGY_EXCHANGES:
|
||||
pref_key = _STRATEGY_TAB_DISPLAY_PREF.get(k)
|
||||
if pref_key and prefs.get(pref_key) is False:
|
||||
continue
|
||||
tabs.append(
|
||||
{
|
||||
"key": k,
|
||||
"label": STRATEGY_META[k]["label"],
|
||||
"title": STRATEGY_META[k]["title"],
|
||||
}
|
||||
)
|
||||
return {"ok": True, "exchanges": tabs}
|
||||
|
||||
|
||||
|
||||
@@ -121,20 +121,40 @@ def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]:
|
||||
return default
|
||||
|
||||
|
||||
def _format_profit_exit_mult(mult: Any) -> str:
|
||||
try:
|
||||
n = float(mult)
|
||||
except (TypeError, ValueError):
|
||||
return "1倍"
|
||||
if n <= 0:
|
||||
return "1倍"
|
||||
if abs(n - round(n)) < 1e-9:
|
||||
return f"{int(round(n))}倍"
|
||||
return f"{n:g}倍"
|
||||
|
||||
|
||||
def _format_options_target(p: dict[str, Any]) -> str:
|
||||
hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None
|
||||
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
|
||||
if hedge:
|
||||
rr = _safe_float(hedge.get("profit_rr"))
|
||||
pid = hedge.get("plan_id")
|
||||
if rr is not None and rr > 0:
|
||||
return f"对冲#{pid} 盈亏比 {rr:g}" if pid is not None else f"盈亏比 {rr:g}"
|
||||
ot = str(hedge.get("opt_type") or opt_type).upper()
|
||||
side = "Put ≤" if ot == "P" else "Call ≥"
|
||||
tgt = _safe_float(hedge.get("target_index"))
|
||||
pid = hedge.get("plan_id")
|
||||
if tgt is not None:
|
||||
return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}"
|
||||
parts: list[str] = []
|
||||
tgt = _safe_float(p.get("target_index"))
|
||||
if tgt is not None and tgt > 0:
|
||||
side = "Put ≤" if opt_type == "P" else "Call ≥"
|
||||
return f"{side} {tgt:g}"
|
||||
parts.append(f"{side} {tgt:g}")
|
||||
if p.get("profit_exit_enabled"):
|
||||
parts.append(_format_profit_exit_mult(p.get("profit_exit_mult")))
|
||||
if parts:
|
||||
return " · ".join(parts)
|
||||
return "—"
|
||||
|
||||
|
||||
@@ -142,12 +162,12 @@ def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
|
||||
inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-"
|
||||
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
|
||||
label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT")
|
||||
# 看板期权列固定用净盈亏(买一回收−权利金);残档买一则空.
|
||||
# 看板期权列:优先买一净盈亏,残档回退交易所 upl
|
||||
pnl = None
|
||||
try:
|
||||
from lib.options.options_positions_lib import net_pnl_from_display_row
|
||||
from lib.options.options_positions_lib import display_pnl_from_option_row
|
||||
|
||||
pnl = net_pnl_from_display_row(p)
|
||||
pnl = display_pnl_from_option_row(p)
|
||||
except Exception:
|
||||
pnl = None
|
||||
pos = _safe_float(p.get("pos"))
|
||||
@@ -350,42 +370,151 @@ def collect_options_items(
|
||||
raw = fetch_options_positions() or []
|
||||
except Exception:
|
||||
return []
|
||||
pe_map: dict[str, dict[str, Any]] = {}
|
||||
tgt_map: dict[str, dict[str, Any]] = {}
|
||||
hedge_map: dict[str, dict[str, Any]] = {}
|
||||
if conn is not None:
|
||||
try:
|
||||
from lib.options.options_profit_exit_lib import profit_exit_by_inst
|
||||
from lib.options.options_target_lib import targets_by_inst
|
||||
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
|
||||
|
||||
pe_map = profit_exit_by_inst(conn)
|
||||
tgt_map = targets_by_inst(conn)
|
||||
hedge_map = active_options_targets_by_inst(conn)
|
||||
except Exception:
|
||||
pe_map, tgt_map, hedge_map = {}, {}, {}
|
||||
out: list[dict[str, Any]] = []
|
||||
for p in raw:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
out.append(_format_options_item(p, conn=conn))
|
||||
row = dict(p)
|
||||
inst = str(row.get("inst_id") or row.get("instId") or "").strip()
|
||||
mon = tgt_map.get(inst)
|
||||
if mon:
|
||||
row["target_index"] = mon.get("target_index")
|
||||
pe = pe_map.get(inst)
|
||||
if pe:
|
||||
row["profit_exit_enabled"] = pe.get("profit_exit_enabled")
|
||||
row["profit_exit_mult"] = pe.get("profit_exit_mult")
|
||||
hedge = hedge_map.get(inst)
|
||||
if hedge:
|
||||
row["hedge_plan_target"] = hedge
|
||||
if not mon:
|
||||
row["target_index"] = hedge.get("target_index")
|
||||
out.append(_format_options_item(row, conn=conn))
|
||||
return out
|
||||
|
||||
|
||||
def _swap_symbol_candidates(row: dict[str, Any]) -> list[str]:
|
||||
"""优先永续 symbol(含 settle),避免用现货 BTC/USDT 查到 contractSize=1."""
|
||||
raw: list[str] = []
|
||||
for key in ("symbol", "exchange_symbol", "price_symbol"):
|
||||
s = str(row.get(key) or "").strip()
|
||||
if s and s not in raw:
|
||||
raw.append(s)
|
||||
swapish: list[str] = []
|
||||
others: list[str] = []
|
||||
for s in raw:
|
||||
if ":" in s:
|
||||
swapish.append(s)
|
||||
continue
|
||||
others.append(s)
|
||||
if "/" in s:
|
||||
base, quote = s.split("/", 1)
|
||||
q = quote.split(":")[0].strip()
|
||||
if base and q:
|
||||
swapish.append(f"{base}/{q}:{q}")
|
||||
out: list[str] = []
|
||||
for s in swapish + others:
|
||||
if s and s not in out:
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_contract_size(
|
||||
row_or_sym: Any,
|
||||
*,
|
||||
get_contract_size: Optional[Callable[[str], Any]] = None,
|
||||
) -> float:
|
||||
if not callable(get_contract_size):
|
||||
return 1.0
|
||||
if isinstance(row_or_sym, dict):
|
||||
candidates = _swap_symbol_candidates(row_or_sym)
|
||||
else:
|
||||
sym = str(row_or_sym or "").strip()
|
||||
candidates = _swap_symbol_candidates({"symbol": sym}) if sym else []
|
||||
for sym in candidates:
|
||||
try:
|
||||
cs = float(get_contract_size(sym) or 0)
|
||||
if cs > 0:
|
||||
return cs
|
||||
except Exception:
|
||||
continue
|
||||
return 1.0
|
||||
|
||||
|
||||
def _fill_order_pnl_fields(row: dict[str, Any], *, mark: Optional[float], contract_size: float) -> None:
|
||||
"""按线性 U 本位补看板「盈利金额 / 浮盈」."""
|
||||
direction = str(row.get("direction") or "long").lower()
|
||||
entry = _safe_float(row.get("entry"))
|
||||
contracts = _safe_float(row.get("contracts"))
|
||||
tp = _safe_float(row.get("take_profit"))
|
||||
if entry is None or contracts is None or contracts <= 0:
|
||||
return
|
||||
cs = float(contract_size) if contract_size and contract_size > 0 else 1.0
|
||||
if mark is not None:
|
||||
try:
|
||||
from lib.hub.hub_position_metrics import estimate_linear_swap_upnl_usdt
|
||||
|
||||
upnl = estimate_linear_swap_upnl_usdt(direction, entry, mark, contracts, cs)
|
||||
if upnl is not None:
|
||||
row["float_pnl"] = upnl
|
||||
except Exception:
|
||||
pass
|
||||
if tp is not None and tp > 0:
|
||||
try:
|
||||
from lib.strategy.strategy_trend_lib import calc_tp_profit_usdt
|
||||
|
||||
profit = calc_tp_profit_usdt(direction, entry, tp, contracts, cs)
|
||||
if profit is not None:
|
||||
row["tp_profit"] = round(float(profit), 2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def enrich_order_items_with_marks(
|
||||
items: list[dict[str, Any]],
|
||||
*,
|
||||
get_price: Optional[Callable[[str], Any]] = None,
|
||||
get_contract_size: Optional[Callable[[str], Any]] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""后台聚合时补标记价(不打全量 fetch_positions;浮盈仍由实盘页口径负责)."""
|
||||
if not items or not callable(get_price):
|
||||
"""后台聚合时补标记价,并按张数×合约面值估算盈利金额/浮盈."""
|
||||
if not items:
|
||||
return items
|
||||
if not callable(get_price) and not callable(get_contract_size):
|
||||
return items
|
||||
out: list[dict[str, Any]] = []
|
||||
for it in items:
|
||||
row = dict(it)
|
||||
sym = str(row.get("price_symbol") or row.get("symbol") or "").strip()
|
||||
if not sym:
|
||||
out.append(row)
|
||||
continue
|
||||
try:
|
||||
px = get_price(sym)
|
||||
except Exception:
|
||||
px = None
|
||||
mark = _safe_float(px)
|
||||
if mark is None and ":" in sym:
|
||||
try:
|
||||
px = get_price(sym.split(":", 1)[0])
|
||||
except Exception:
|
||||
px = None
|
||||
mark = _safe_float(px)
|
||||
if mark is not None:
|
||||
row["mark_price"] = mark
|
||||
# 标记价:先试 price_symbol,再试永续候选
|
||||
mark = _safe_float(row.get("mark_price"))
|
||||
if callable(get_price):
|
||||
ordered: list[str] = []
|
||||
for s in [str(row.get("price_symbol") or "").strip()] + _swap_symbol_candidates(row):
|
||||
if s and s not in ordered:
|
||||
ordered.append(s)
|
||||
for sym in ordered:
|
||||
try:
|
||||
px = get_price(sym)
|
||||
except Exception:
|
||||
px = None
|
||||
mark = _safe_float(px)
|
||||
if mark is not None:
|
||||
row["mark_price"] = mark
|
||||
break
|
||||
cs = _resolve_contract_size(row, get_contract_size=get_contract_size)
|
||||
_fill_order_pnl_fields(row, mark=mark, contract_size=cs)
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
@@ -402,7 +531,8 @@ def build_instance_dashboard_payload(
|
||||
rolls = collect_rolls(conn)
|
||||
strategy_items = trends + rolls
|
||||
options_items = collect_options_items(fetch_options_positions, conn=conn)
|
||||
hedge_items = collect_hedge_plans(conn) if hedge_enabled else []
|
||||
hedge_items = collect_hedge_plans(conn) # 始终展示进行中计划,与当前交易模式无关
|
||||
# hedge_enabled 仅影响「新建」入口,不隐藏已有仓
|
||||
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
||||
return {
|
||||
"ok": True,
|
||||
|
||||
@@ -12,19 +12,27 @@ def register_instance_dashboard_routes(
|
||||
login_required: Callable,
|
||||
get_db: Callable,
|
||||
fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None,
|
||||
hedge_enabled: bool = False,
|
||||
hedge_enabled: bool | Callable[[], bool] = False,
|
||||
enrich_orders: Optional[Callable[[list[dict[str, Any]]], list[dict[str, Any]]]] = None,
|
||||
) -> None:
|
||||
from lib.instance.instance_dashboard_cache import instance_dashboard_store
|
||||
from lib.instance.instance_dashboard_lib import build_instance_dashboard_payload
|
||||
|
||||
def _hedge_on() -> bool:
|
||||
if callable(hedge_enabled):
|
||||
try:
|
||||
return bool(hedge_enabled())
|
||||
except Exception:
|
||||
return False
|
||||
return bool(hedge_enabled)
|
||||
|
||||
def _build() -> dict[str, Any]:
|
||||
conn = get_db()
|
||||
try:
|
||||
payload = build_instance_dashboard_payload(
|
||||
conn,
|
||||
fetch_options_positions=fetch_options_positions,
|
||||
hedge_enabled=bool(hedge_enabled),
|
||||
hedge_enabled=_hedge_on(),
|
||||
)
|
||||
if callable(enrich_orders) and payload.get("ok") and isinstance(payload.get("orders"), dict):
|
||||
items = list(payload["orders"].get("items") or [])
|
||||
|
||||
@@ -9,6 +9,9 @@ DISPLAY_RUNTIME_PREFIX = "display."
|
||||
|
||||
DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
|
||||
"show_nav_dashboard": False,
|
||||
"show_nav_account_ledger": False,
|
||||
"show_nav_key_monitor": True,
|
||||
"show_nav_trade": True,
|
||||
"show_nav_strategy": True,
|
||||
"show_nav_strategy_records": True,
|
||||
"show_nav_records": True,
|
||||
@@ -28,6 +31,9 @@ DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
|
||||
|
||||
DISPLAY_LABELS: dict[str, str] = {
|
||||
"show_nav_dashboard": "数据看板",
|
||||
"show_nav_account_ledger": "账户流水",
|
||||
"show_nav_key_monitor": "关键位监控",
|
||||
"show_nav_trade": "实盘下单",
|
||||
"show_nav_strategy": "策略交易",
|
||||
"show_nav_strategy_records": "策略交易记录",
|
||||
"show_nav_records": "交易记录与复盘",
|
||||
@@ -47,6 +53,9 @@ DISPLAY_LABELS: dict[str, str] = {
|
||||
|
||||
NAV_TAB_ALLOWED: dict[str, str] = {
|
||||
"dashboard": "show_nav_dashboard",
|
||||
"account_ledger": "show_nav_account_ledger",
|
||||
"key_monitor": "show_nav_key_monitor",
|
||||
"trade": "show_nav_trade",
|
||||
"strategy": "show_nav_strategy",
|
||||
"strategy_records": "show_nav_strategy_records",
|
||||
"records": "show_nav_records",
|
||||
@@ -110,6 +119,9 @@ def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool:
|
||||
def display_meta_for_ui() -> list[dict[str, Any]]:
|
||||
nav_keys = [
|
||||
"show_nav_dashboard",
|
||||
"show_nav_account_ledger",
|
||||
"show_nav_key_monitor",
|
||||
"show_nav_trade",
|
||||
"show_nav_strategy",
|
||||
"show_nav_strategy_records",
|
||||
"show_nav_records",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user