Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -190,6 +190,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
|
||||
|
||||
@@ -257,6 +257,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,
|
||||
@@ -1843,8 +1844,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 +1887,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 +1925,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,
|
||||
}
|
||||
@@ -3264,6 +3307,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,
|
||||
@@ -3273,6 +3317,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
|
||||
@@ -3365,7 +3416,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)
|
||||
|
||||
|
||||
@@ -4261,29 +4312,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):
|
||||
@@ -6910,8 +6938,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()
|
||||
@@ -7283,14 +7313,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,
|
||||
@@ -7357,6 +7395,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,
|
||||
@@ -7544,14 +7583,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
|
||||
@@ -7577,6 +7624,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,
|
||||
@@ -9556,7 +9604,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
|
||||
@@ -9569,6 +9619,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
|
||||
|
||||
@@ -192,6 +192,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
|
||||
|
||||
+90
-42
@@ -260,6 +260,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,
|
||||
@@ -1841,45 +1842,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 +1923,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,
|
||||
}
|
||||
@@ -2934,6 +2971,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,
|
||||
@@ -2943,6 +2981,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
|
||||
@@ -3035,7 +3080,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)
|
||||
|
||||
|
||||
@@ -3892,29 +3937,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):
|
||||
@@ -6539,8 +6561,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()
|
||||
@@ -7061,14 +7085,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,
|
||||
@@ -7132,6 +7164,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,
|
||||
@@ -7340,14 +7373,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
|
||||
@@ -7376,6 +7417,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,
|
||||
@@ -9404,7 +9446,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
|
||||
@@ -9417,6 +9461,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
|
||||
|
||||
@@ -79,11 +79,12 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
|
||||
|
||||
# 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单)
|
||||
LIVE_TRADING_ENABLED=true
|
||||
# OKX API Key(实盘)
|
||||
# =============================================================================
|
||||
# OKX 账户 API(永续 + 期权共用同一套密钥;修改后须重启 PM2)
|
||||
# 旧键 OKX_OPTIONS_API_* 已废弃:若 OKX_API_* 为空,启动时会从 OPTIONS 键回填
|
||||
# =============================================================================
|
||||
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
|
||||
# 保证金模式:cross=全仓,isolated=逐仓
|
||||
OKX_TD_MODE=cross
|
||||
@@ -99,24 +100,35 @@ 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=账户·期权
|
||||
OKX_OPTIONS_TRADE_BUDGET_USDC=10
|
||||
OKX_OPTIONS_BUDGET_BUFFER=0.95
|
||||
# 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算
|
||||
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
|
||||
@@ -126,13 +138,15 @@ OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
|
||||
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 +161,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)
|
||||
@@ -253,6 +268,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
|
||||
|
||||
+155
-61
@@ -256,6 +256,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 +343,29 @@ def _resolve_app_tz():
|
||||
|
||||
APP_TZ = _resolve_app_tz()
|
||||
LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true"
|
||||
|
||||
|
||||
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 = os.getenv("OKX_API_KEY", "")
|
||||
OKX_API_SECRET = os.getenv("OKX_API_SECRET", "")
|
||||
OKX_API_PASSPHRASE = 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"
|
||||
@@ -466,7 +480,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,10 +500,10 @@ 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
|
||||
ACCOUNT_BALANCE_CACHE = {
|
||||
@@ -1842,8 +1856,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 +1899,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 +1937,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,
|
||||
}
|
||||
@@ -2673,6 +2729,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,
|
||||
@@ -2682,6 +2739,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
|
||||
@@ -2775,7 +2839,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
|
||||
@@ -3381,29 +3445,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):
|
||||
@@ -6366,8 +6407,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()
|
||||
@@ -6567,6 +6610,7 @@ def render_main_page(page="trade", embed_mode=None):
|
||||
minimal_stats_bundle,
|
||||
options_funding_label,
|
||||
profit_loss_ratio_from_trades,
|
||||
show_perp_funds_enabled,
|
||||
total_funds_usdt,
|
||||
trade_records_summary,
|
||||
)
|
||||
@@ -6666,14 +6710,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",
|
||||
@@ -6714,6 +6766,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,
|
||||
@@ -6725,12 +6782,12 @@ 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,
|
||||
@@ -6755,6 +6812,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,
|
||||
@@ -6802,18 +6860,32 @@ def render_main_page(page="trade", embed_mode=None):
|
||||
options_funding_label=options_funding_label,
|
||||
exchange_display=EXCHANGE_DISPLAY_NAME,
|
||||
options_enabled=OKX_OPTIONS_ENABLED,
|
||||
show_perp_funds=_show_perp_funds,
|
||||
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"
|
||||
@@ -7001,19 +7073,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
|
||||
@@ -7052,20 +7136,22 @@ def api_account_snapshot():
|
||||
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")
|
||||
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,
|
||||
"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,
|
||||
@@ -7074,6 +7160,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,
|
||||
@@ -9107,9 +9194,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(
|
||||
@@ -9118,9 +9208,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
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# OKX 单笔期权 · 币本位模式(USDT 桥 + 复利)— 开发方案
|
||||
|
||||
> 状态:**方案待实现**(按本文落地;改需求先改本文).
|
||||
> 范围:**仅 `crypto_monitor_okx` 单笔期权**;对冲计划(永期/期期)**不接币本位**.
|
||||
> 相关:[期权方案.md](./期权方案.md) · [期权用法.md](./期权用法.md) · [期权开平仓与监控说明.md](./期权开平仓与监控说明.md) · [position-sizing-mode.md](./position-sizing-mode.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 路径;币本位模式下对冲开仓保持不可用或明确提示未支持.
|
||||
|
||||
---
|
||||
|
||||
## 3. 不做(首版外)
|
||||
|
||||
- 对冲计划(永期/期期)币本位腿或双模式混开
|
||||
- 盘中按单笔切换本位(必须 env + 重启/无仓校验)
|
||||
- 按权利金精确计算后再买现货(明确不做;见 §5)
|
||||
- 自动把资金账户 USDT 划入交易账户(首版只读 **交易账户** 可用 USDT;不足则提示人工划转)
|
||||
- 市价平期权(继续沿用现有「买一限价、禁市价平」纪律,除非另改总则)
|
||||
- 多笔并行单笔期权仓(维持「一次一仓」)
|
||||
- 中控代下币本位期权
|
||||
|
||||
---
|
||||
|
||||
## 4. 模式开关与互斥
|
||||
|
||||
### 4.1 env(草案)
|
||||
|
||||
| 变量 | 含义 | 默认 |
|
||||
|------|------|------|
|
||||
| `OKX_OPTIONS_MARGIN_MODE` | `usdc` \| `coin` | `usdc` |
|
||||
| `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`(可改) |
|
||||
|
||||
说明:
|
||||
|
||||
- **主路径(复利开 + 上限关)**:`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、差值**.
|
||||
|
||||
---
|
||||
|
||||
## 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 | 预算预览、确认、半成品提示 |
|
||||
|
||||
现货下单可与现有账户兑换/划转能力并列,但 **桥必须可自动、可回滚**,与「人工 USDT→USDC」不同.
|
||||
|
||||
### 8.3 权限与账户
|
||||
|
||||
- API 需具备:交易账户现货市价、期权开平.
|
||||
- 预算只认 **交易账户 USDT**;资金账户有钱但交易户不足 → 明确提示先划转(首版不自动划).
|
||||
|
||||
### 8.4 测试
|
||||
|
||||
- 预算计算:复利开/关、上限开/关、余额边界.
|
||||
- 状态机:开仓失败回滚卖币;平仓后卖币失败 → 待卖回 → 重试成功.
|
||||
- 门禁:有仓切换拒绝;一次一仓.
|
||||
- 回归: `margin_mode=usdc` 时行为与现网一致;对冲仍仅 USDC.
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
1. `usdc` 模式:单笔期权行为与现网一致.
|
||||
2. `coin` 模式:一轮开平后交易户 USDT 变化符合「买币→期权→卖币」;无异常残留币(或残留时必有待卖回告警).
|
||||
3. 复利:人为把交易户从约 10U 做到约 20U 后,下一轮预览预算约为 `20×0.95`(上限关闭时).
|
||||
4. 上限开关默认关;开启后预算不超过 N.
|
||||
5. 有持仓或半成品时切换模式被拒绝.
|
||||
6. 币本位下对冲不能误开币本位腿.
|
||||
7. 开仓失败自动卖回 USDT,不留下无主现货.
|
||||
|
||||
---
|
||||
|
||||
## 10. 实现顺序建议
|
||||
|
||||
1. 模式 env + 有仓/半成品门禁 + UI 展示当前模式
|
||||
2. 现货桥(买/卖/回滚/待卖回) + 单测
|
||||
3. 币本位合约适配 + 卖一开/买一平接入编排
|
||||
4. 复利预算预览与开仓确认
|
||||
5. 上限开关
|
||||
6. 文档:`期权用法.md` 增补币本位章节;`更新文档.md` 记一笔
|
||||
|
||||
---
|
||||
|
||||
## 11. 决策摘要(已拍板)
|
||||
|
||||
| 决策 | 结论 |
|
||||
|------|------|
|
||||
| 对冲 | 暂不接币本位 |
|
||||
| 单笔模式 | env:`usdc` ↔ `coin` |
|
||||
| 有持仓切换 | **拒绝** |
|
||||
| 买币方式 | **先买满预算 USDT 对应的币,再开满期权**(不按权利金精算) |
|
||||
| 复利 | 交易账户 USDT × 0.95;人工转走控规模 |
|
||||
| 单笔不超过 N U | **独立开关,默认关闭** |
|
||||
| 动机 | 币本位流动性往往优于 USDC,利于成交 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 风险与说明
|
||||
|
||||
- 现货双边手续费与滑点会吃掉部分「名义预算」;小资金下占比更明显.
|
||||
- 持仓期间若账户内残留标的币,平仓卖回时含现货汇率盈亏,需与期权腿区分看待.
|
||||
- 流动性优势随到期、行权、标的变化,不保证每一张合约都厚于 USDC;开仓仍以当场卖一深度为准.
|
||||
- 本方案不改变「符合机会才做、不符合就等」的交易纪律;仅改单笔期权的资金路径与合约族.
|
||||
+6
-6
@@ -61,13 +61,14 @@ Binance / Gate 无期权模块时,第三列最后一格不显示或显示「本
|
||||
| 中文名 | 说明 | 重启 |
|
||||
|--------|------|------|
|
||||
| 开启实盘下单 | 关闭时仅走本地流程,不向交易所发单 | 需重启 |
|
||||
| API Key | 永续子账户 API Key | 需重启 |
|
||||
| API Secret | 永续子账户 Secret | 需重启 |
|
||||
| API Key | 账户 API Key(永续+期权共用) | 需重启 |
|
||||
| API Secret | 账户 API Secret | 需重启 |
|
||||
| API Passphrase | 仅 OKX 显示 | 需重启 |
|
||||
| 保证金模式 | 全仓 / 逐仓 | 需重启 |
|
||||
| 持仓模式 | 双向 / 单向净持仓等(按所) | 需重启 |
|
||||
| 仓位查询类型 | 仅 OKX:如 SWAP | 需重启 |
|
||||
| 账户备注 | 企业微信推送中显示的交易所备注 | 保存即生效 |
|
||||
| 显示永续资金 | 仅 OKX:关闭后顶栏隐藏 USDT 资金/交易账户,总资金仅计期权 USDC 侧 | 保存即生效 |
|
||||
|
||||
**本卡片不包含**:网页登录账号密码,是否关闭登录校验,中控通信密钥.
|
||||
|
||||
@@ -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;仅扫监控仓,不含交易所裸仓 |
|
||||
|
||||
---
|
||||
|
||||
@@ -169,8 +170,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
|
||||
|
||||
| 中文名 | 说明 |
|
||||
|--------|------|
|
||||
| 启用期权模块 | |
|
||||
| 期权 API Key / Secret / Passphrase | 主账户,与永续子账户分离 |
|
||||
| 启用期权模块 | 与永续共用 `OKX_API_*`;不再单独配置期权密钥 |
|
||||
| 期权账户备注 | |
|
||||
| 单笔预算(USDC) | |
|
||||
| 预算缓冲比例 | |
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
| 类型 | 账户 | 作用 |
|
||||
|------|------|------|
|
||||
| **永期对冲** | 永续子账户 + 期权主账户买方 | 全仓做方向,期权买保险 |
|
||||
| **期期对冲** | 仅期权主账户双买方 | 目标价兑现盈利腿,亏损腿到期 |
|
||||
| **永期对冲** | 同账户永续 + 期权买方 | 全仓做方向,期权买保险 |
|
||||
| **期期对冲** | 同账户双买方期权 | 目标价兑现盈利腿,亏损腿到期 |
|
||||
|
||||
### 永期结束与统计
|
||||
|
||||
|
||||
+5
-2
@@ -6,9 +6,12 @@
|
||||
|
||||
| 标签 | 指向提交 | 说明 |
|
||||
|------|----------|------|
|
||||
| `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/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 执行手册文档等 |
|
||||
@@ -31,7 +34,7 @@
|
||||
git tag -l 'snapshot/*'
|
||||
|
||||
# 检出快照(只读查看,勿在此分支直接开发)
|
||||
git checkout snapshot/20260726-2
|
||||
git checkout snapshot/20260728-2
|
||||
|
||||
# 回到主线
|
||||
git checkout main
|
||||
|
||||
+25
-23
@@ -3,7 +3,8 @@
|
||||
中控只读工具:按自定义整点起点、**固定北京时间 16:00 收窗**,统计 OKX 上 ETH/BTC 的历史「点数振幅」档案,辅助一天期期权判断空间。
|
||||
|
||||
> 开发方案见 [ETH时段振幅统计-开发方案.md](./ETH时段振幅统计-开发方案.md)。
|
||||
> **不改下单链路**;不算 IV / 权利金。
|
||||
> **不改下单链路**;不算 IV。
|
||||
> 买跨 / 永期对冲测算请用中控 **策略计算器**,本页不再做对照盈亏。
|
||||
|
||||
---
|
||||
|
||||
@@ -21,8 +22,9 @@
|
||||
2. 选择 **标的** ETH / BTC;数据源固定 **OKX**
|
||||
3. **起点整点**(00–23);终点固定 **16:00**
|
||||
4. **周期**:1 月 / 2 月 / 3 月 / 半年 / 1 年 / 自定义天数(默认 2 个月)
|
||||
5. 点 **计算** → 下方看汇总 + 分页日表
|
||||
6. 需要留存时点 **保存到历史**;**下载 CSV** 含摘要 + 全日明细
|
||||
5. 可选填 **波动点数**(如 `50`)→ 看振幅达标占比
|
||||
6. 点 **计算** → 下方看汇总 + 振幅占比 + 分页日表
|
||||
7. 需要留存时点 **保存到历史**;**下载 CSV** 含摘要 + 全日明细
|
||||
|
||||
**跨天例子**
|
||||
|
||||
@@ -42,14 +44,15 @@
|
||||
|
||||
| 字段 | 算法 |
|
||||
|------|------|
|
||||
| 开→高 | `H − O` |
|
||||
| 开→低 | `O − L` |
|
||||
| **振幅** | `H − L`(= 开→高 + 开→低) |
|
||||
| 涨跌值 | `C − O` |
|
||||
| 开→高 | `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`。
|
||||
@@ -57,22 +60,20 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD /
|
||||
|
||||
---
|
||||
|
||||
## 买跨对照(赌波动)
|
||||
## 波动点数 → 振幅占比
|
||||
|
||||
表单可填 **双边权利金(点)**,例如 `30`;旁边可填 **止盈点**(可空):
|
||||
表单可填 **波动点数**(如 `50`)。填写后下方 **振幅占比** 块显示:
|
||||
|
||||
| 汇总项 | 口径 |
|
||||
|--------|------|
|
||||
| 开→高超过权利金 | `H−O > 权利金` 的天数与占比 |
|
||||
| 开→低超过权利金 | `O−L > 权利金` 的天数与占比 |
|
||||
| \|涨跌\|超过权利金 | `\|C−O\| > 权利金` 的天数与占比 |
|
||||
| 有效波动 | 若设止盈且 `开→高≥止盈` 或 `开→低≥止盈` → 用止盈点;否则用 `\|C−O\|` |
|
||||
| 买跨收益 | `有效波动 − 权利金`(日表「收益」列同口径) |
|
||||
| 振幅≥点数 | 单日窗 `H−L ≥ 点数` 的天数与**占比**(主指标) |
|
||||
| 两日振幅≥点数 | 两日窗振幅 ≥ 点数 的天数与占比 |
|
||||
| 开→高≥点数 | `H−O ≥ 点数` 天数与占比 |
|
||||
| 开→低≥点数 | `O−L ≥ 点数` 天数与占比 |
|
||||
| \|涨跌\|≥点数 | `\|C−O\| ≥ 点数` 天数与占比 |
|
||||
|
||||
- 方向:**买跨**
|
||||
- 权利金越过:严格 **`>`**;止盈触达:**`≥`**
|
||||
- 止盈留空 / ≤0:有效波动一律按 `|涨跌|`
|
||||
- 已算出日表后,改权利金 / 止盈 / 周末筛选会**本地重算**(不重拉 K 线)
|
||||
日表保留 **开→高 / 开→低**、**振幅**、**两日振幅**(悬停可见两日窗起止),并标 **振幅达标**。
|
||||
改点数 / 周末筛选会在已有日表上**本地重算**(不重拉 K 线)。
|
||||
|
||||
### 周末
|
||||
|
||||
@@ -97,7 +98,7 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD /
|
||||
| `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` | 单元测试 |
|
||||
| `tests/test_amp_stats_lib.py` | 单测 |
|
||||
|
||||
---
|
||||
|
||||
@@ -106,6 +107,7 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD /
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-07-23 | 首版上线说明 |
|
||||
| 2026-07-23 | 买跨对照:可设双边权利金、越过占比与收盘盈亏 |
|
||||
| 2026-07-23 | 周末筛选/标注、止盈点(≥)、日表收益列 |
|
||||
| 2026-07-23 | 长周期续拉 history K 线;收益列红绿着色 |
|
||||
| 2026-07-23 | 买跨对照、周末筛选、止盈点 |
|
||||
| 2026-07-28 | 永期对冲对照(后已移除) |
|
||||
| 2026-07-28 | 去掉买跨/永期;改为波动点数→振幅占比 |
|
||||
| 2026-07-28 | 增加两日振幅(例 25日16:00→27日16:00) |
|
||||
|
||||
+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)
|
||||
|
||||
+23
-82
@@ -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** 足够支付本笔权利金
|
||||
|
||||
系统 **不会** 自动兑换或划转,避免误动资金.
|
||||
|
||||
@@ -85,82 +85,23 @@ OKX_OPTIONS_API_PASSPHRASE=...
|
||||
|
||||
## 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)「永期对冲对照」。
|
||||
+1
-1
@@ -84,7 +84,7 @@
|
||||
|
||||
### 用途
|
||||
|
||||
在 **子账户永续** 场景下,于 **资金账户(funding)** 与 **交易账户(swap)** 之间手动划转 USDT.
|
||||
在 **OKX 账户** 场景下,于 **资金账户(funding)** 与 **交易账户(swap)** 之间手动划转 USDT.
|
||||
|
||||
### 与 env 配置的关系
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
"""实例账户流水(交易所资金/交易账户账单)."""
|
||||
@@ -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",
|
||||
@@ -114,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();
|
||||
}
|
||||
@@ -284,7 +288,7 @@
|
||||
setSettingsSubTabInUrl("transfer");
|
||||
return "transfer";
|
||||
}
|
||||
if (path.indexOf("/api/options/transfer") >= 0 || path.indexOf("/api/options/cross-transfer") >= 0) {
|
||||
if (path.indexOf("/api/options/transfer") >= 0) {
|
||||
setSettingsSubTabInUrl("options_transfer");
|
||||
return "options_transfer";
|
||||
}
|
||||
@@ -294,6 +298,7 @@
|
||||
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();
|
||||
|
||||
@@ -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,7 @@
|
||||
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",
|
||||
@@ -66,6 +71,7 @@
|
||||
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",
|
||||
@@ -196,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;
|
||||
@@ -288,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;
|
||||
}
|
||||
|
||||
@@ -311,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;
|
||||
@@ -335,9 +348,72 @@
|
||||
});
|
||||
body.appendChild(tabBar);
|
||||
body.appendChild(panelsWrap);
|
||||
body.dataset.envModeSectionIdx = String(modeSectionIdx);
|
||||
bindTradeModeAutoRefresh(body);
|
||||
bindCompoundBudgetVisibility(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 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";
|
||||
budgetRow.hidden = compoundOn;
|
||||
budgetRow.style.display = compoundOn ? "none" : "";
|
||||
}
|
||||
|
||||
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 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");
|
||||
@@ -395,10 +471,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);
|
||||
@@ -501,6 +577,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");
|
||||
|
||||
@@ -859,6 +859,13 @@ html[data-theme="light"] .inst-stats-details > summary {
|
||||
html[data-theme="light"] .inst-stats-details[open] > summary {
|
||||
color: #0d4a7a !important;
|
||||
}
|
||||
html[data-theme="light"] .inst-stats-month-table th {
|
||||
color: #4a6078 !important;
|
||||
}
|
||||
html[data-theme="light"] .inst-stats-month-table td {
|
||||
color: #142232 !important;
|
||||
border-bottom-color: #d0dae4 !important;
|
||||
}
|
||||
|
||||
html[data-theme="light"] .key-history {
|
||||
border-top-color: #d0dae4 !important;
|
||||
@@ -1959,6 +1966,15 @@ html[data-theme="light"] .order-plan-preview {
|
||||
border-color: #b8c8d8 !important;
|
||||
}
|
||||
|
||||
html[data-theme="light"] .order-monitor-form .om-field-lab {
|
||||
color: #5a6a82;
|
||||
}
|
||||
|
||||
html[data-theme="light"] .order-monitor-form .om-check,
|
||||
html[data-theme="light"] .order-monitor-form .om-time-close {
|
||||
color: #3a4a62;
|
||||
}
|
||||
|
||||
html[data-theme="light"] .order-preview-rr {
|
||||
color: #4a6078 !important;
|
||||
}
|
||||
@@ -2478,6 +2494,11 @@ html[data-theme="light"] .journal-detail-img-thumb {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* display:flex 会盖掉 UA [hidden];全仓复利开时隐藏单笔预算等依赖此规则 */
|
||||
.env-field-row[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.env-field-row--restart .env-field-label {
|
||||
color: #d4c4a0;
|
||||
}
|
||||
@@ -3122,6 +3143,15 @@ html[data-theme="light"] .settings-side-export-label {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.opt-chain-lev {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
color: #b8c8ff;
|
||||
}
|
||||
html[data-theme="light"] .opt-chain-lev {
|
||||
color: #1a4a8a;
|
||||
}
|
||||
.opt-be-dist-up {
|
||||
color: #5ee89a;
|
||||
}
|
||||
@@ -3445,6 +3475,34 @@ html[data-theme="light"] .opt-be-dist-down {
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-mode-badge {
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: #c4b5fd;
|
||||
background: rgba(139, 92, 246, 0.22);
|
||||
border: 1px solid rgba(167, 139, 250, 0.35);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-mode-badge.is-insurance {
|
||||
color: #93c5fd;
|
||||
background: rgba(59, 130, 246, 0.18);
|
||||
border-color: rgba(96, 165, 250, 0.35);
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-section {
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-section-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 650;
|
||||
color: #c5cdd9;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -3455,6 +3513,121 @@ html[data-theme="light"] .opt-be-dist-down {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-fields--section {
|
||||
margin: 0;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-fields--capital {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-fields--select {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
align-items: end;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-field--type {
|
||||
min-width: 0;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-field--type select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-plan-watching {
|
||||
color: #fbbf24;
|
||||
font-weight: 650;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.hedge-plan-page-wrap .hp-po-fields--capital,
|
||||
.hedge-plan-page-wrap .hp-po-fields--select {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-field--type {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-type-seg {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-right-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-right-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-inner-card {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-inner-card > h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-action-row {
|
||||
margin-top: auto;
|
||||
padding-top: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-strategy-status {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-strategy-status.is-watching {
|
||||
color: #fbbf24;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-strategy-status.is-holding {
|
||||
color: #3dd68c;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-strategy-status.is-idle {
|
||||
color: #6b7388;
|
||||
font-weight: 500;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-quote-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-ins-money {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-ins-money[hidden],
|
||||
.hedge-plan-page-wrap .hp-po-ins-money.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
/* display:grid 会盖掉 [hidden];模式切换必须显式 none */
|
||||
.hedge-plan-page-wrap .hp-po-fields.hidden,
|
||||
.hedge-plan-page-wrap .hp-po-fields[hidden],
|
||||
.hedge-plan-page-wrap #hp-po-fields-option-primary.hidden,
|
||||
.hedge-plan-page-wrap #hp-po-fields-option-primary[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-strike-table-wrap--6 {
|
||||
max-height: 280px;
|
||||
min-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin-top: 4px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -3618,7 +3791,10 @@ html[data-theme="light"] .opt-be-dist-down {
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-uly-btn,
|
||||
.hedge-plan-page-wrap .hp-uly-btn-oo,
|
||||
.hedge-plan-page-wrap .hp-money-btn {
|
||||
.hedge-plan-page-wrap .hp-money-btn,
|
||||
.hedge-plan-page-wrap .hp-oo-money-btn,
|
||||
.hedge-plan-page-wrap .hp-oo-recommend-btn,
|
||||
.hedge-plan-page-wrap .hp-oo-expand-btn {
|
||||
min-width: 52px;
|
||||
font-weight: 600;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
@@ -3627,11 +3803,34 @@ html[data-theme="light"] .opt-be-dist-down {
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-uly-btn.active,
|
||||
.hedge-plan-page-wrap .hp-uly-btn-oo.active,
|
||||
.hedge-plan-page-wrap .hp-money-btn.active {
|
||||
color: #0b1220;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #9ec0ff 100%);
|
||||
border-color: #fff;
|
||||
box-shadow: 0 0 0 2px rgba(100, 160, 255, 0.5);
|
||||
.hedge-plan-page-wrap .hp-money-btn.active,
|
||||
.hedge-plan-page-wrap .hp-oo-money-btn.is-selected,
|
||||
.hedge-plan-page-wrap .hp-oo-money-btn.active,
|
||||
.hedge-plan-page-wrap .hp-oo-recommend-btn.is-selected,
|
||||
.hedge-plan-page-wrap .hp-oo-recommend-btn.active,
|
||||
.hedge-plan-page-wrap .hp-oo-expand-btn.is-selected,
|
||||
.hedge-plan-page-wrap .hp-oo-expand-btn.active {
|
||||
border-color: var(--accent, #00d4ff);
|
||||
color: var(--text, #fff);
|
||||
background: rgba(0, 212, 255, 0.16);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 212, 255, 0.35);
|
||||
font-weight: 700;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-oo-money-btn .hp-oo-check,
|
||||
.hedge-plan-page-wrap .hp-oo-recommend-btn .hp-oo-check,
|
||||
.hedge-plan-page-wrap .hp-oo-expand-btn .hp-oo-check {
|
||||
display: none;
|
||||
margin-right: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-oo-money-btn.is-selected .hp-oo-check,
|
||||
.hedge-plan-page-wrap .hp-oo-money-btn.active .hp-oo-check,
|
||||
.hedge-plan-page-wrap .hp-oo-recommend-btn.is-selected .hp-oo-check,
|
||||
.hedge-plan-page-wrap .hp-oo-recommend-btn.active .hp-oo-check,
|
||||
.hedge-plan-page-wrap .hp-oo-expand-btn.is-selected .hp-oo-check,
|
||||
.hedge-plan-page-wrap .hp-oo-expand-btn.active .hp-oo-check {
|
||||
display: inline;
|
||||
color: var(--accent, #00d4ff);
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-money-hint {
|
||||
font-size: 0.68rem;
|
||||
@@ -3651,20 +3850,36 @@ html[data-theme="light"] .opt-be-dist-down {
|
||||
min-height: 26px;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-opt-toolbar .btn-secondary,
|
||||
.hedge-plan-page-wrap .hp-opt-toolbar .hp-money-btn {
|
||||
.hedge-plan-page-wrap .hp-opt-toolbar .hp-money-btn,
|
||||
.hedge-plan-page-wrap .hp-oo-money-btn,
|
||||
.hedge-plan-page-wrap .hp-oo-recommend-btn,
|
||||
.hedge-plan-page-wrap .hp-oo-expand-btn {
|
||||
padding: 3px 8px;
|
||||
min-height: 26px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
/* 视口约 5 行数据 + 表头;超出表内滚动 */
|
||||
.hedge-plan-page-wrap .hp-strike-table-wrap--5,
|
||||
.hedge-plan-page-wrap .options-strike-table-wrap--t {
|
||||
/* 永期期权列表:视口约 5 行 + 表头,超出滚动 */
|
||||
.hedge-plan-page-wrap .hp-strike-table-wrap--5 {
|
||||
max-height: 248px;
|
||||
min-height: 248px;
|
||||
overflow-y: auto;
|
||||
margin-top: 4px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
/* 期期 T 型:默认 3+3 一页展示,无下滑框 */
|
||||
.hedge-plan-page-wrap .hp-oo-table-wrap {
|
||||
max-height: none;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
margin-top: 4px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.hedge-plan-page-wrap .opt-chain-lev,
|
||||
.hedge-plan-page-wrap .hp-oo-table-wrap .opt-chain-lev {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-opt-bal-line {
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -3682,6 +3897,25 @@ html[data-theme="light"] .opt-be-dist-down {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.hedge-plan-page-wrap #hp-po-layout {
|
||||
align-items: stretch;
|
||||
}
|
||||
.hedge-plan-page-wrap #hp-po-layout > .card {
|
||||
align-self: stretch;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-right-stack > .hp-po-perp-quote-card {
|
||||
height: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-right-stack > .hp-opt-card {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-po-right-stack > .hp-opt-card .hp-strike-table-wrap--6 {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-action-row {
|
||||
margin-top: 10px;
|
||||
gap: 8px;
|
||||
@@ -3704,6 +3938,20 @@ html[data-theme="light"] .opt-be-dist-down {
|
||||
.hedge-plan-page-wrap .opt-row-selected td {
|
||||
background: rgba(90, 140, 255, 0.18);
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-oo-pick.is-selected,
|
||||
.hedge-plan-page-wrap .hp-oo-pick.active {
|
||||
border-color: var(--accent, #00d4ff);
|
||||
color: var(--text, #fff);
|
||||
background: rgba(0, 212, 255, 0.22);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 212, 255, 0.45), 0 0 0 2px rgba(0, 212, 255, 0.2);
|
||||
font-weight: 700;
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-oo-side-selected {
|
||||
background: rgba(0, 212, 255, 0.12);
|
||||
}
|
||||
.hedge-plan-page-wrap tr.hp-oo-row-selected td.opt-t-strike {
|
||||
color: var(--accent, #00d4ff);
|
||||
}
|
||||
.hedge-plan-page-wrap .hp-tab-panel.hidden,
|
||||
.hedge-plan-page-wrap .hp-tab-panel[hidden] {
|
||||
display: none !important;
|
||||
@@ -3868,10 +4116,17 @@ html[data-theme="light"] .hedge-plan-page-wrap .hp-tab.active {
|
||||
}
|
||||
html[data-theme="light"] .hedge-plan-page-wrap .hp-uly-btn.active,
|
||||
html[data-theme="light"] .hedge-plan-page-wrap .hp-uly-btn-oo.active,
|
||||
html[data-theme="light"] .hedge-plan-page-wrap .hp-money-btn.active {
|
||||
html[data-theme="light"] .hedge-plan-page-wrap .hp-money-btn.active,
|
||||
html[data-theme="light"] .hedge-plan-page-wrap .hp-oo-money-btn.is-selected,
|
||||
html[data-theme="light"] .hedge-plan-page-wrap .hp-oo-money-btn.active,
|
||||
html[data-theme="light"] .hedge-plan-page-wrap .hp-oo-recommend-btn.is-selected,
|
||||
html[data-theme="light"] .hedge-plan-page-wrap .hp-oo-recommend-btn.active,
|
||||
html[data-theme="light"] .hedge-plan-page-wrap .hp-oo-expand-btn.is-selected,
|
||||
html[data-theme="light"] .hedge-plan-page-wrap .hp-oo-expand-btn.active {
|
||||
color: #0b1220;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #b9d2ff 100%);
|
||||
border-color: #2f6fed;
|
||||
box-shadow: 0 0 0 2px rgba(47, 111, 237, 0.25);
|
||||
}
|
||||
html[data-theme="light"] .hedge-plan-page-wrap .hp-placeholder {
|
||||
border-color: rgba(15, 23, 42, 0.18);
|
||||
@@ -4169,6 +4424,9 @@ html[data-theme="light"] .opt-pending-item {
|
||||
.opt-size-mode-chip {
|
||||
position: relative;
|
||||
}
|
||||
.opt-size-mode-chip[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.opt-size-mode-chip input[type="radio"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
@@ -4192,6 +4450,22 @@ html[data-theme="light"] .opt-pending-item {
|
||||
min-height: 32px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.options-estimate-row .opt-profit-exit-mult,
|
||||
.options-page-wrap .opt-pos-profit-exit-mult {
|
||||
width: 4.5rem;
|
||||
min-width: 0;
|
||||
font-size: 0.8rem;
|
||||
padding: 6px 8px;
|
||||
min-height: 32px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.options-page-wrap .opt-profit-exit-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.78rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.options-estimate-row .k {
|
||||
color: #8892b0;
|
||||
}
|
||||
@@ -5680,6 +5954,36 @@ html[data-theme="light"] .options-review-wrap .or-reviewed-table tbody tr:hover
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
body.inst-phone .order-monitor-form .om-row {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
body.inst-phone .order-monitor-form .om-row-policy > input:not([type="checkbox"]):not([type="radio"]),
|
||||
body.inst-phone .order-monitor-form .om-row-policy > select,
|
||||
body.inst-phone .order-monitor-form #sltp-mode,
|
||||
body.inst-phone .order-monitor-form .om-field,
|
||||
body.inst-phone .order-monitor-form .om-live-meta,
|
||||
body.inst-phone .order-monitor-form .om-check,
|
||||
body.inst-phone .order-monitor-form .om-time-close,
|
||||
body.inst-phone .order-monitor-form .order-entry-model-row,
|
||||
body.inst-phone .order-monitor-form .om-submit {
|
||||
flex: 1 1 100% !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
body.inst-phone .order-monitor-form .om-field input {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
body.inst-phone .order-monitor-form .om-live-meta {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
body.inst-phone .order-plan-preview {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -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);
|
||||
@@ -28,6 +28,8 @@
|
||||
posTab: "live",
|
||||
/** 未点设定前的目标输入草稿,避免持仓轮询重绘清空 */
|
||||
targetDraftByInst: {},
|
||||
/** 翻倍倍数草稿,避免轮询重绘把正在输入的值刷回 1 */
|
||||
profitExitDraftByInst: {},
|
||||
};
|
||||
|
||||
let lastGoodPositions = null;
|
||||
@@ -271,9 +273,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
function compoundFullEnabled() {
|
||||
// 缺省按关闭,避免热更关闭后仍误用全仓复利
|
||||
return !!(root && String(root.dataset.compoundFullEnabled || "0") === "1");
|
||||
}
|
||||
|
||||
function currentSizeMode() {
|
||||
const el = document.querySelector('input[name="opt-size-mode"]:checked');
|
||||
return el ? el.value : "sheets";
|
||||
const el = document.querySelector('input[name="opt-size-mode"]:checked:not(:disabled)');
|
||||
if (el) return el.value;
|
||||
const any = document.querySelector('input[name="opt-size-mode"]:checked');
|
||||
if (any && any.value === "compound_full" && !compoundFullEnabled()) return "sheets";
|
||||
if (any && any.value === "budget_full" && compoundFullEnabled()) return "compound_full";
|
||||
return "sheets";
|
||||
}
|
||||
|
||||
function applyCompoundModeUi(compoundOn) {
|
||||
if (root) root.dataset.compoundFullEnabled = compoundOn ? "1" : "0";
|
||||
updateSizeInputs();
|
||||
}
|
||||
|
||||
function syncCompoundFlagsFromPayload(d) {
|
||||
if (!d || typeof d !== "object") return;
|
||||
if (d.compound_full_enabled != null) {
|
||||
applyCompoundModeUi(!!d.compound_full_enabled);
|
||||
} else if (d.cfg && d.cfg.compound_full_enabled != null) {
|
||||
applyCompoundModeUi(!!d.cfg.compound_full_enabled);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSizeInputs() {
|
||||
@@ -281,18 +306,69 @@
|
||||
const sheetsEl = document.getElementById("opt-sheets-amount");
|
||||
const ethEl = document.getElementById("opt-eth-amount");
|
||||
const hint = document.getElementById("opt-budget-full-hint");
|
||||
const compoundHint = document.getElementById("opt-compound-full-hint");
|
||||
const budgetWrap = document.getElementById("opt-size-mode-budget-wrap");
|
||||
const compoundWrap = document.getElementById("opt-size-mode-compound-wrap");
|
||||
const capEl = document.getElementById("opt-budget-full-cap");
|
||||
if (sheetsEl) sheetsEl.style.display = mode === "sheets" ? "" : "none";
|
||||
if (ethEl) ethEl.style.display = mode === "eth_amount" ? "" : "none";
|
||||
if (hint) hint.style.display = mode === "budget_full" ? "" : "none";
|
||||
const compoundCapLine = document.getElementById("opt-compound-cap-line");
|
||||
const compoundOn = compoundFullEnabled();
|
||||
if (budgetWrap) {
|
||||
budgetWrap.hidden = !!compoundOn;
|
||||
budgetWrap.style.display = compoundOn ? "none" : "";
|
||||
const radio = budgetWrap.querySelector('input[name="opt-size-mode"]');
|
||||
if (radio) radio.disabled = !!compoundOn;
|
||||
}
|
||||
if (compoundWrap) {
|
||||
compoundWrap.hidden = !compoundOn;
|
||||
compoundWrap.style.display = compoundOn ? "" : "none";
|
||||
const radio = compoundWrap.querySelector('input[name="opt-size-mode"]');
|
||||
if (radio) radio.disabled = !compoundOn;
|
||||
}
|
||||
if (compoundOn && (mode === "budget_full" || mode === "compound_full")) {
|
||||
const compoundRadio = document.querySelector('input[name="opt-size-mode"][value="compound_full"]');
|
||||
if (compoundRadio) {
|
||||
compoundRadio.disabled = false;
|
||||
compoundRadio.checked = true;
|
||||
}
|
||||
} else if (!compoundOn) {
|
||||
const compoundRadio = document.querySelector('input[name="opt-size-mode"][value="compound_full"]');
|
||||
if (compoundRadio) {
|
||||
compoundRadio.checked = false;
|
||||
compoundRadio.disabled = true;
|
||||
}
|
||||
// currentSizeMode 会把残留 compound 映射成 sheets,须实际勾选,避免无选中无法开仓
|
||||
const checkedOk = document.querySelector('input[name="opt-size-mode"]:checked:not(:disabled)');
|
||||
if (!checkedOk) {
|
||||
const sheetsRadio = document.querySelector('input[name="opt-size-mode"][value="sheets"]');
|
||||
if (sheetsRadio) {
|
||||
sheetsRadio.disabled = false;
|
||||
sheetsRadio.checked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const modeNow = currentSizeMode();
|
||||
if (sheetsEl) sheetsEl.style.display = modeNow === "sheets" ? "" : "none";
|
||||
if (ethEl) ethEl.style.display = modeNow === "eth_amount" ? "" : "none";
|
||||
if (hint) hint.style.display = modeNow === "budget_full" && !compoundOn ? "" : "none";
|
||||
if (compoundHint) compoundHint.style.display = modeNow === "compound_full" && compoundOn ? "" : "none";
|
||||
if (capEl && root && root.dataset.tradeBudget) {
|
||||
const n = Number(root.dataset.tradeBudget);
|
||||
if (Number.isFinite(n) && n > 0) capEl.textContent = n.toFixed(2);
|
||||
}
|
||||
if (compoundCapLine && root) {
|
||||
const on = String(root.dataset.compoundCapEnabled || "") === "1";
|
||||
const cap = Number(root.dataset.compoundCapUsdc);
|
||||
if (on && Number.isFinite(cap) && cap > 0) {
|
||||
compoundCapLine.textContent = "全仓上限已开启:" + cap.toFixed(2) + "U";
|
||||
} else {
|
||||
compoundCapLine.textContent = "全仓上限关闭(env可开)";
|
||||
}
|
||||
}
|
||||
document.querySelectorAll(".opt-size-mode-chip").forEach(function (chip) {
|
||||
const radio = chip.querySelector('input[name="opt-size-mode"]');
|
||||
chip.classList.toggle("is-selected", !!(radio && radio.checked));
|
||||
chip.classList.toggle("active", !!(radio && radio.checked));
|
||||
const selected = !!(radio && radio.checked && !radio.disabled);
|
||||
chip.classList.toggle("is-selected", selected);
|
||||
chip.classList.toggle("active", selected);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -328,6 +404,7 @@
|
||||
}
|
||||
|
||||
function quoteUrl(instId) {
|
||||
updateSizeInputs();
|
||||
const mode = currentSizeMode();
|
||||
let url = "/api/options/quote?inst_id=" + encodeURIComponent(instId) + "&mode=" + mode;
|
||||
if (mode === "eth_amount") {
|
||||
@@ -341,7 +418,7 @@
|
||||
}
|
||||
|
||||
function strikeTableColspan() {
|
||||
return state.chainView === "t" ? 9 : 8;
|
||||
return 9;
|
||||
}
|
||||
|
||||
function syncChainViewUI() {
|
||||
@@ -352,7 +429,7 @@
|
||||
const typeGroup = document.getElementById("opt-type-btn-group");
|
||||
if (typeGroup) typeGroup.hidden = isT;
|
||||
const expandWrap = document.getElementById("opt-strike-expand-wrap");
|
||||
if (expandWrap) expandWrap.hidden = !isT;
|
||||
if (expandWrap) expandWrap.hidden = false;
|
||||
const headList = document.getElementById("opt-strike-head-list");
|
||||
const headT = document.getElementById("opt-strike-head-t");
|
||||
const headTCols = document.getElementById("opt-strike-head-t-cols");
|
||||
@@ -472,16 +549,59 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 默认窗口:平值 + 实值 3 档 + 虚值 3 档(T 型按行权价 ATM±3)
|
||||
const DEFAULT_ATM_SIDE = 3;
|
||||
|
||||
function sliceAtmWindow(rows, indexPx) {
|
||||
if (state.strikeExpandAll || !rows.length) return rows;
|
||||
const atmStrike = findAtmStrike(rows, indexPx);
|
||||
const idx = rows.findIndex(function (r) { return Number(r.strike) === Number(atmStrike); });
|
||||
if (idx < 0) return rows.slice(0, Math.min(rows.length, 11));
|
||||
const start = Math.max(0, idx - 5);
|
||||
const end = Math.min(rows.length, idx + 6);
|
||||
const side = DEFAULT_ATM_SIDE;
|
||||
if (idx < 0) return rows.slice(0, Math.min(rows.length, side * 2 + 1));
|
||||
const start = Math.max(0, idx - side);
|
||||
const end = Math.min(rows.length, idx + side + 1);
|
||||
return rows.slice(start, end);
|
||||
}
|
||||
|
||||
function sliceListByMoneyness(list) {
|
||||
if (state.strikeExpandAll || !list.length) return list;
|
||||
const sorted = list.slice().sort(function (a, b) {
|
||||
return Number(a.strike) - Number(b.strike);
|
||||
});
|
||||
const itm = [];
|
||||
const atm = [];
|
||||
const otm = [];
|
||||
sorted.forEach(function (c) {
|
||||
const m = String(c.moneyness || "").toLowerCase();
|
||||
if (m === "atm") atm.push(c);
|
||||
else if (m === "itm") itm.push(c);
|
||||
else if (m === "otm") otm.push(c);
|
||||
});
|
||||
if (!atm.length && !itm.length && !otm.length) {
|
||||
const indexPx = state.chain && state.chain.index_px;
|
||||
return sliceAtmWindow(
|
||||
sorted.map(function (c) { return { strike: c.strike, _c: c }; }),
|
||||
indexPx
|
||||
).map(function (r) { return r._c; });
|
||||
}
|
||||
const n = DEFAULT_ATM_SIDE;
|
||||
const isPut = String(state.optType || "").toUpperCase() === "P";
|
||||
// Call: ITM 在下方取靠近 ATM 的末 N;OTM 取前 N。Put 相反。
|
||||
const pickedItm = isPut ? itm.slice(0, n) : itm.slice(-n);
|
||||
const pickedOtm = isPut ? otm.slice(-n) : otm.slice(0, n);
|
||||
return pickedItm.concat(atm, pickedOtm).sort(function (a, b) {
|
||||
return Number(a.strike) - Number(b.strike);
|
||||
});
|
||||
}
|
||||
|
||||
function strikeWindowHintHtml(cols) {
|
||||
return (
|
||||
'<td colspan="' +
|
||||
cols +
|
||||
'" class="muted opt-strike-hint">默认显示平值 + 实值3档 + 虚值3档 · 勾选「展开全部」查看该到期全部行权价</td>'
|
||||
);
|
||||
}
|
||||
|
||||
function straddleAskPerUnit(callAsk, putAsk) {
|
||||
const c = Number(callAsk);
|
||||
const p = Number(putAsk);
|
||||
@@ -710,15 +830,27 @@
|
||||
|
||||
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) {
|
||||
@@ -801,6 +933,19 @@
|
||||
return Math.round((value - prem) * 100) / 100;
|
||||
}
|
||||
|
||||
/** 盈亏比 = 盈利金额 / 本合约权利金(目标位仅作到期实值参考). */
|
||||
function estimateProfitRr(profit, totalPremium) {
|
||||
const pnl = Number(profit);
|
||||
const prem = Number(totalPremium);
|
||||
if (!Number.isFinite(pnl) || !Number.isFinite(prem) || prem <= 0) return null;
|
||||
return Math.round((pnl / prem) * 100) / 100;
|
||||
}
|
||||
|
||||
function fmtProfitRr(v) {
|
||||
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
|
||||
return Number(v).toFixed(2);
|
||||
}
|
||||
|
||||
function calcContractLeverage(indexPx, ethAmount, totalPremium) {
|
||||
if (indexPx == null || ethAmount == null || totalPremium == null) return null;
|
||||
const idx = Number(indexPx);
|
||||
@@ -817,6 +962,20 @@
|
||||
return "约 " + Number(v).toFixed(1) + "×";
|
||||
}
|
||||
|
||||
/** 链上展示:指数 ÷ 卖一(每1币). */
|
||||
function calcAskLeverage(indexPx, askPx) {
|
||||
if (indexPx == null || askPx == null) return null;
|
||||
const idx = Number(indexPx);
|
||||
const ask = Number(askPx);
|
||||
if (!Number.isFinite(idx) || !Number.isFinite(ask) || ask <= 0) return null;
|
||||
return Math.round((idx / ask) * 10) / 10;
|
||||
}
|
||||
|
||||
function fmtChainLeverage(v) {
|
||||
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
|
||||
return Number(v).toFixed(1) + "×";
|
||||
}
|
||||
|
||||
function fmtUsdcSigned(v) {
|
||||
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
|
||||
const n = Number(v);
|
||||
@@ -828,7 +987,7 @@
|
||||
const levEl = document.getElementById("opt-order-leverage");
|
||||
const valueEl = document.getElementById("opt-est-value");
|
||||
const profitEl = document.getElementById("opt-est-profit");
|
||||
const targetLevEl = document.getElementById("opt-est-leverage");
|
||||
const rrEl = document.getElementById("opt-est-rr") || document.getElementById("opt-est-leverage");
|
||||
const targetEl = document.getElementById("opt-target-idx");
|
||||
const q = state.orderQuote;
|
||||
if (!q || !q.ok || !q.can_open) {
|
||||
@@ -838,7 +997,10 @@
|
||||
profitEl.textContent = "—";
|
||||
profitEl.className = "v";
|
||||
}
|
||||
if (targetLevEl) targetLevEl.textContent = "—";
|
||||
if (rrEl) {
|
||||
rrEl.textContent = "—";
|
||||
rrEl.className = "v";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const sz = q.sizing || {};
|
||||
@@ -853,10 +1015,14 @@
|
||||
valueEl.textContent = "—";
|
||||
profitEl.textContent = "—";
|
||||
profitEl.className = "v";
|
||||
if (targetLevEl) targetLevEl.textContent = "—";
|
||||
if (rrEl) {
|
||||
rrEl.textContent = "—";
|
||||
rrEl.className = "v";
|
||||
}
|
||||
} else {
|
||||
const value = estimateExpiryValue(q.opt_type, q.strike, Number(targetRaw), ethAmount);
|
||||
const profit = estimateExpiryProfit(q.opt_type, q.strike, Number(targetRaw), ethAmount, premium);
|
||||
const rr = estimateProfitRr(profit, premium);
|
||||
if (value == null || Number.isNaN(value)) {
|
||||
valueEl.textContent = "—";
|
||||
} else {
|
||||
@@ -869,8 +1035,15 @@
|
||||
profitEl.textContent = fmtUsdcSigned(profit);
|
||||
profitEl.className = "v " + pnlCls(profit);
|
||||
}
|
||||
const targetLev = calcContractLeverage(Number(targetRaw), ethAmount, premium);
|
||||
if (targetLevEl) targetLevEl.textContent = fmtLeverage(targetLev);
|
||||
if (rrEl) {
|
||||
if (rr == null || Number.isNaN(rr)) {
|
||||
rrEl.textContent = "—";
|
||||
rrEl.className = "v";
|
||||
} else {
|
||||
rrEl.textContent = fmtProfitRr(rr);
|
||||
rrEl.className = "v " + pnlCls(rr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -936,7 +1109,8 @@
|
||||
state.selectedInst = null;
|
||||
return;
|
||||
}
|
||||
const list = filterChainContracts(exp.contracts);
|
||||
let list = filterChainContracts(exp.contracts);
|
||||
list = sliceListByMoneyness(list);
|
||||
if (!list.length) {
|
||||
const label = moneyFilterLabel();
|
||||
const suffix = label ? label : optTypeLabel(state.optType);
|
||||
@@ -946,16 +1120,26 @@
|
||||
return;
|
||||
}
|
||||
let matchedSelected = false;
|
||||
const indexPx = state.chain && state.chain.index_px;
|
||||
const atmStrike = findAtmStrike(
|
||||
list.map(function (c) { return { strike: c.strike }; }),
|
||||
indexPx
|
||||
);
|
||||
list.forEach(function (c) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "opt-strike-row";
|
||||
tr.setAttribute("data-inst", c.inst_id);
|
||||
if (c.moneyness) tr.classList.add("opt-row-" + c.moneyness);
|
||||
if (atmStrike != null && Number(c.strike) === Number(atmStrike)) {
|
||||
tr.classList.add("opt-strike-row-atm");
|
||||
}
|
||||
const chainLev = calcAskLeverage(indexPx, c.ask);
|
||||
tr.innerHTML =
|
||||
"<td>" + c.strike + "</td>" +
|
||||
"<td>" + moneynessBadge(c) + "</td>" +
|
||||
"<td><code>" + c.inst_id + "</code></td>" +
|
||||
"<td class=\"opt-px-sz\">" + fmtPxSz(c.ask, c.ask_sz, c.ask_estimated) + "</td>" +
|
||||
'<td class="opt-chain-lev">' + fmtChainLeverage(chainLev) + "</td>" +
|
||||
"<td class=\"opt-px-sz\">" + fmtPxSz(c.bid, c.bid_sz) + "</td>" +
|
||||
"<td>" + (c.expiry_be_px != null ? fmt(c.expiry_be_px, 0) : "—") + "</td>" +
|
||||
'<td class="' + distBeClass(c.dist_expiry_be) + '">' + fmtDist(c.dist_expiry_be) + "</td>" +
|
||||
@@ -965,6 +1149,12 @@
|
||||
tbody.appendChild(tr);
|
||||
if (c.inst_id === prevSelected) matchedSelected = true;
|
||||
});
|
||||
if (!state.strikeExpandAll && list.length >= 1) {
|
||||
const hint = document.createElement("tr");
|
||||
hint.className = "opt-strike-hint-row";
|
||||
hint.innerHTML = strikeWindowHintHtml(cols);
|
||||
tbody.appendChild(hint);
|
||||
}
|
||||
finishStrikeRender(tbody, prevSelected, matchedSelected);
|
||||
}
|
||||
|
||||
@@ -1029,13 +1219,14 @@
|
||||
if (!state.strikeExpandAll && rows.length >= 1) {
|
||||
const hint = document.createElement("tr");
|
||||
hint.className = "opt-strike-hint-row";
|
||||
hint.innerHTML = '<td colspan="' + cols + '" class="muted opt-strike-hint">默认显示 ATM ±5 档 · 勾选「展开全部」查看该到期全部行权价</td>';
|
||||
hint.innerHTML = strikeWindowHintHtml(cols);
|
||||
tbody.appendChild(hint);
|
||||
}
|
||||
finishStrikeRender(tbody, prevSelected, matchedSelected);
|
||||
}
|
||||
|
||||
function fillOrderPanel(d) {
|
||||
syncCompoundFlagsFromPayload(d);
|
||||
state.orderQuote = d && d.ok ? d : null;
|
||||
const sz = d.sizing || {};
|
||||
const canOpen = !!(d && d.ok && d.can_open);
|
||||
@@ -1149,10 +1340,14 @@
|
||||
if (seq !== chainLoadSeq) return;
|
||||
if (d && d.ok && chainHasExpiries(d)) break;
|
||||
lastMsg = (d && (d.msg || d.chain_error)) || "暂无到期日";
|
||||
const rateLimited =
|
||||
/50011|Too Many Requests|RateLimit/i.test(String(lastMsg || ""));
|
||||
d = null;
|
||||
if (attempt === 0) {
|
||||
if (attempt === 0 && !rateLimited) {
|
||||
if (!soft) setExpirySelectStatus("重试加载到期日…");
|
||||
await new Promise(function (resolve) { setTimeout(resolve, 400); });
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (seq !== chainLoadSeq) return;
|
||||
@@ -1232,6 +1427,7 @@
|
||||
const btn = document.getElementById("opt-open-btn");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
updateSizeInputs();
|
||||
const mode = currentSizeMode();
|
||||
const body = {
|
||||
inst_id: state.selectedInst,
|
||||
@@ -1241,7 +1437,10 @@
|
||||
if (mode === "eth_amount") {
|
||||
body.eth_amount = parseFloat(document.getElementById("opt-eth-amount").value);
|
||||
} else if (mode === "sheets") {
|
||||
body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10);
|
||||
body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10) || 1;
|
||||
} else if (mode === "compound_full" && !compoundFullEnabled()) {
|
||||
body.mode = "sheets";
|
||||
body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10) || 1;
|
||||
}
|
||||
const tgtRaw = (document.getElementById("opt-target-idx").value || "").trim();
|
||||
if (tgtRaw !== "") {
|
||||
@@ -1252,6 +1451,17 @@
|
||||
}
|
||||
body.target_index = tgt;
|
||||
}
|
||||
const peEnabled = !!(document.getElementById("opt-profit-exit-enabled") || {}).checked;
|
||||
if (peEnabled) {
|
||||
const multRaw = (document.getElementById("opt-profit-exit-mult") || {}).value;
|
||||
const mult = parseFloat(multRaw);
|
||||
if (!Number.isFinite(mult) || mult <= 0) {
|
||||
alert("翻倍倍数无效");
|
||||
return false;
|
||||
}
|
||||
body.profit_exit_enabled = true;
|
||||
body.profit_exit_mult = mult;
|
||||
}
|
||||
const d = await apiJson("/api/options/open", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -1330,7 +1540,57 @@
|
||||
const hint = closeGateHint(closePreview);
|
||||
return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : "";
|
||||
})() +
|
||||
renderTargetDelegateRow(p)
|
||||
renderTargetDelegateRow(p) +
|
||||
renderProfitExitRow(p)
|
||||
);
|
||||
}
|
||||
|
||||
function formatProfitExitMultLabel(mult) {
|
||||
const n = Number(mult);
|
||||
if (!Number.isFinite(n) || n <= 0) return "1倍";
|
||||
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n)) + "倍";
|
||||
return fmt(n, 2) + "倍";
|
||||
}
|
||||
|
||||
function renderProfitExitRow(p) {
|
||||
const inst = p.inst_id || "";
|
||||
if (p.hedge_plan_target) {
|
||||
return "";
|
||||
}
|
||||
const enabled = !!p.profit_exit_enabled;
|
||||
const serverMult = p.profit_exit_mult != null && Number(p.profit_exit_mult) > 0
|
||||
? Number(p.profit_exit_mult)
|
||||
: 1;
|
||||
const draft = state.profitExitDraftByInst[inst];
|
||||
const multDisp = draft != null && String(draft).trim() !== ""
|
||||
? String(draft)
|
||||
: String(serverMult);
|
||||
const multNum = Number(multDisp);
|
||||
const multLabel = formatProfitExitMultLabel(
|
||||
Number.isFinite(multNum) && multNum > 0 ? multNum : serverMult
|
||||
);
|
||||
const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle"));
|
||||
const req = p.profit_exit_required_recycle;
|
||||
let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启";
|
||||
if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel;
|
||||
return (
|
||||
'<div class="opt-target-row opt-profit-exit-pos-row" data-inst="' + inst + '">' +
|
||||
'<span class="opt-target-row-label">翻倍</span>' +
|
||||
'<label class="opt-profit-exit-toggle"><input type="checkbox" class="opt-pos-profit-exit-enabled" data-inst="' +
|
||||
inst + '"' + (enabled ? " checked" : "") + "> 开启</label>" +
|
||||
'<input type="number" class="opt-pos-profit-exit-mult" data-inst="' + inst +
|
||||
'" min="0.1" step="0.1" value="' + multDisp +
|
||||
'" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true">' +
|
||||
'<button type="button" class="btn-secondary opt-profit-exit-save-btn" data-inst="' +
|
||||
inst + '" data-mode="' + (enabled ? "cancel" : "apply") + '">' +
|
||||
(enabled ? "取消" : "应用") + "</button>" +
|
||||
'<span class="opt-target-armed">' + statusTxt + "</span>" +
|
||||
'<span class="muted opt-target-row-hint">' +
|
||||
(enabled
|
||||
? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtUsdc(req)) : ""))
|
||||
: "开启后自选倍数;达标按买一限价平;可随时关闭") +
|
||||
"</span>" +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1345,12 +1605,15 @@
|
||||
function formatTargetEstimateHtml(optType, strike, targetIdx, ethAmount, premiumPaid) {
|
||||
const value = estimateExpiryValue(optType, strike, targetIdx, ethAmount);
|
||||
const profit = estimateExpiryProfit(optType, strike, targetIdx, ethAmount, premiumPaid);
|
||||
if (value == null && profit == null) return "";
|
||||
const rr = estimateProfitRr(profit, premiumPaid);
|
||||
if (value == null && profit == null && rr == null) return "";
|
||||
let html = '<span class="opt-target-est">';
|
||||
html += '<span class="opt-target-est-item"><span class="k">价值</span><span class="v">' +
|
||||
(value == null ? "—" : fmtUsdc(value) + " USDC") + "</span></span>";
|
||||
html += '<span class="opt-target-est-item"><span class="k">预估盈利</span><span class="v ' + pnlCls(profit) + '">' +
|
||||
(profit == null ? "—" : fmtUsdcSigned(profit)) + "</span></span>";
|
||||
html += '<span class="opt-target-est-item"><span class="k">盈亏比</span><span class="v ' + pnlCls(rr) + '">' +
|
||||
(rr == null ? "—" : fmtProfitRr(rr)) + "</span></span>";
|
||||
html += "</span>";
|
||||
return html;
|
||||
}
|
||||
@@ -1358,21 +1621,37 @@
|
||||
function renderTargetDelegateRow(p) {
|
||||
const inst = p.inst_id || "";
|
||||
const hedgeTarget = p.hedge_plan_target || null;
|
||||
if (hedgeTarget && Number(hedgeTarget.target_index) > 0) {
|
||||
const side = (p.opt_type || hedgeTarget.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥";
|
||||
return (
|
||||
'<div class="opt-target-row opt-target-row--managed">' +
|
||||
'<span class="opt-target-row-label">对冲计划</span>' +
|
||||
'<span class="opt-target-armed">计划 #' +
|
||||
hedgeTarget.plan_id +
|
||||
" · " +
|
||||
side +
|
||||
" " +
|
||||
fmt(hedgeTarget.target_index, 1) +
|
||||
"</span>" +
|
||||
'<span class="muted opt-target-row-hint">进行中 · 由对冲计划监控,到位后仅平盈利腿</span>' +
|
||||
"</div>"
|
||||
);
|
||||
if (hedgeTarget) {
|
||||
const rr = hedgeTarget.profit_rr != null ? Number(hedgeTarget.profit_rr) : null;
|
||||
if (rr != null && rr > 0) {
|
||||
return (
|
||||
'<div class="opt-target-row opt-target-row--managed">' +
|
||||
'<span class="opt-target-row-label">对冲计划</span>' +
|
||||
'<span class="opt-target-armed">计划 #' +
|
||||
hedgeTarget.plan_id +
|
||||
" · 盈亏比 " +
|
||||
fmt(rr, 2) +
|
||||
"</span>" +
|
||||
'<span class="muted opt-target-row-hint">进行中 · 盈利达总权利金×盈亏比仅平盈利腿;亏损腿按本合约残值平或到期平</span>' +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
if (Number(hedgeTarget.target_index) > 0) {
|
||||
const side = (p.opt_type || hedgeTarget.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥";
|
||||
return (
|
||||
'<div class="opt-target-row opt-target-row--managed">' +
|
||||
'<span class="opt-target-row-label">对冲计划</span>' +
|
||||
'<span class="opt-target-armed">计划 #' +
|
||||
hedgeTarget.plan_id +
|
||||
" · " +
|
||||
side +
|
||||
" " +
|
||||
fmt(hedgeTarget.target_index, 1) +
|
||||
"</span>" +
|
||||
'<span class="muted opt-target-row-hint">进行中 · 由对冲计划监控,到位后仅平盈利腿</span>' +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
}
|
||||
const tgt = p.target_index != null && p.target_index !== "" ? Number(p.target_index) : null;
|
||||
const armed = tgt != null && Number.isFinite(tgt) && tgt > 0;
|
||||
@@ -1398,7 +1677,7 @@
|
||||
: "") +
|
||||
estHtml +
|
||||
'<span class="muted opt-target-row-hint">' +
|
||||
(armed ? "监控中 · 到位按买一限价平" : "输入后设定 · 到位按买一限价平 · 到期即止损") +
|
||||
(armed ? "监控中 · 目标位参考 · 到位按买一限价平" : "目标位参考(到期实值估盈亏比) · 到位按买一限价平 · 到期即止损") +
|
||||
"</span>" +
|
||||
"</div>"
|
||||
);
|
||||
@@ -1513,6 +1792,37 @@
|
||||
cancelPositionTarget(btn.getAttribute("data-inst"), btn);
|
||||
});
|
||||
});
|
||||
container.querySelectorAll(".opt-profit-exit-save-btn").forEach(function (btn) {
|
||||
btn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
savePositionProfitExit(btn.getAttribute("data-inst"), btn);
|
||||
});
|
||||
});
|
||||
container.querySelectorAll(".opt-pos-profit-exit-enabled").forEach(function (cb) {
|
||||
cb.addEventListener("click", function (e) { e.stopPropagation(); });
|
||||
});
|
||||
container.querySelectorAll(".opt-pos-profit-exit-mult").forEach(function (inp) {
|
||||
// 倍数随时可改;「开启/应用」只控制是否监控,不再因未勾选而 disabled
|
||||
inp.disabled = false;
|
||||
inp.removeAttribute("readonly");
|
||||
inp.addEventListener("click", function (e) { e.stopPropagation(); });
|
||||
inp.addEventListener("mousedown", function (e) { e.stopPropagation(); });
|
||||
inp.addEventListener("focus", function (e) { e.stopPropagation(); });
|
||||
inp.addEventListener("input", function () {
|
||||
const id = inp.getAttribute("data-inst") || "";
|
||||
if (!id) return;
|
||||
const draft = String(inp.value || "");
|
||||
if (draft.trim() === "") delete state.profitExitDraftByInst[id];
|
||||
else state.profitExitDraftByInst[id] = draft;
|
||||
});
|
||||
inp.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
savePositionProfitExit(inp.getAttribute("data-inst"), null);
|
||||
}
|
||||
});
|
||||
});
|
||||
container.querySelectorAll(".opt-pos-target-input").forEach(function (inp) {
|
||||
inp.addEventListener("click", function (e) { e.stopPropagation(); });
|
||||
inp.addEventListener("input", function () {
|
||||
@@ -1603,6 +1913,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function savePositionProfitExit(inst, btn) {
|
||||
if (!inst) return;
|
||||
const card = document.querySelector('.opt-pos-card[data-inst="' + inst + '"]') ||
|
||||
document.querySelector('.opt-pos-accordion-item[data-inst="' + inst + '"]');
|
||||
const row = card ? card.querySelector(".opt-profit-exit-pos-row") : null;
|
||||
const enabledEl = row ? row.querySelector(".opt-pos-profit-exit-enabled") : null;
|
||||
const multEl = row ? row.querySelector(".opt-pos-profit-exit-mult") : null;
|
||||
const mode = btn && btn.getAttribute("data-mode");
|
||||
let enabled = !!(enabledEl && enabledEl.checked);
|
||||
if (mode === "cancel") enabled = false;
|
||||
if (mode === "apply") {
|
||||
enabled = true;
|
||||
if (enabledEl) enabledEl.checked = true;
|
||||
}
|
||||
let mult = 1;
|
||||
if (enabled) {
|
||||
mult = parseFloat(multEl ? multEl.value : "1");
|
||||
if (!Number.isFinite(mult) || mult <= 0) {
|
||||
alert("翻倍倍数无效");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const d = await apiJson("/api/options/profit-exit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ inst_id: inst, enabled: enabled, mult: mult }),
|
||||
});
|
||||
if (!d.ok) {
|
||||
alert(d.msg || "保存失败");
|
||||
return;
|
||||
}
|
||||
delete state.profitExitDraftByInst[inst];
|
||||
await refreshAllPositions();
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function paintTargetMonitors(list) {
|
||||
const box = document.getElementById("opt-target-monitors");
|
||||
const host = document.getElementById("opt-target-monitors-list");
|
||||
@@ -1750,6 +2100,14 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 正在输入翻倍倍数:同样跳过重绘,避免被默认 1 冲掉
|
||||
if (active && active.classList && active.classList.contains("opt-pos-profit-exit-mult")) {
|
||||
const focusInst = active.getAttribute("data-inst") || "";
|
||||
if (focusInst) {
|
||||
state.profitExitDraftByInst[focusInst] = String(active.value || "");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 未聚焦时也同步可见输入,防止漏掉 input 事件
|
||||
wrap.querySelectorAll(".opt-pos-target-input").forEach(function (inp) {
|
||||
const id = inp.getAttribute("data-inst") || "";
|
||||
@@ -1758,6 +2116,13 @@
|
||||
if (v.trim() === "") delete state.targetDraftByInst[id];
|
||||
else state.targetDraftByInst[id] = v;
|
||||
});
|
||||
wrap.querySelectorAll(".opt-pos-profit-exit-mult").forEach(function (inp) {
|
||||
const id = inp.getAttribute("data-inst") || "";
|
||||
if (!id) return;
|
||||
const v = String(inp.value || "");
|
||||
if (v.trim() === "") delete state.profitExitDraftByInst[id];
|
||||
else state.profitExitDraftByInst[id] = v;
|
||||
});
|
||||
wrap.innerHTML = "";
|
||||
if (!list.length) {
|
||||
if (empty) empty.style.display = "";
|
||||
@@ -1810,13 +2175,15 @@
|
||||
});
|
||||
}
|
||||
const hedgeTarget = p.hedge_plan_target;
|
||||
if (hedgeTarget && hedgeTarget.target_index != null) {
|
||||
if (hedgeTarget && (hedgeTarget.target_index != null || hedgeTarget.profit_rr != null)) {
|
||||
targets.push({
|
||||
inst_id: p.inst_id,
|
||||
opt_type: p.opt_type || hedgeTarget.opt_type,
|
||||
target_index: hedgeTarget.target_index,
|
||||
profit_rr: hedgeTarget.profit_rr,
|
||||
plan_id: hedgeTarget.plan_id,
|
||||
managed_by: hedgeTarget.managed_by,
|
||||
exit_mode: hedgeTarget.exit_mode,
|
||||
});
|
||||
}
|
||||
return targets;
|
||||
@@ -2081,6 +2448,15 @@
|
||||
function bootOptionsPanel() {
|
||||
applyBudgetBuffer(state.budgetBuffer);
|
||||
updateSizeInputs();
|
||||
void (async function syncLiveCompoundFlag() {
|
||||
try {
|
||||
const d = await apiJson("/api/options/balances");
|
||||
syncCompoundFlagsFromPayload(d);
|
||||
if (d && d.trade_budget != null && root) {
|
||||
root.dataset.tradeBudget = String(d.trade_budget);
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
syncMoneyFilterButtons();
|
||||
syncChainViewUI();
|
||||
updateUnderlyingLabel();
|
||||
@@ -2133,6 +2509,11 @@
|
||||
if (expandAllCb) {
|
||||
expandAllCb.addEventListener("change", function () {
|
||||
state.strikeExpandAll = !!expandAllCb.checked;
|
||||
// 实值/虚值筛选下档位本来就少,展开几乎不变;勾选时切回「全部」才有意义
|
||||
if (state.strikeExpandAll && state.moneyFilter !== "all") {
|
||||
state.moneyFilter = "all";
|
||||
syncMoneyFilterButtons();
|
||||
}
|
||||
renderStrikes();
|
||||
});
|
||||
}
|
||||
@@ -2169,6 +2550,18 @@
|
||||
bindOptionsPosTabs();
|
||||
hardenOrderAutofill();
|
||||
|
||||
(function bindProfitExitOpenControls() {
|
||||
const peCb = document.getElementById("opt-profit-exit-enabled");
|
||||
const peMult = document.getElementById("opt-profit-exit-mult");
|
||||
if (!peCb || !peMult) return;
|
||||
// 倍数始终可手输;勾选只决定开仓是否带上翻倍出场
|
||||
peMult.disabled = false;
|
||||
peMult.removeAttribute("readonly");
|
||||
peCb.addEventListener("change", function () {
|
||||
if (peCb.checked && (!peMult.value || Number(peMult.value) <= 0)) peMult.value = "1";
|
||||
});
|
||||
})();
|
||||
|
||||
document.querySelectorAll('input[name="opt-size-mode"]').forEach(function (r) {
|
||||
r.addEventListener("change", function () {
|
||||
updateSizeInputs();
|
||||
|
||||
@@ -103,15 +103,27 @@
|
||||
|
||||
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) {
|
||||
@@ -170,9 +182,9 @@
|
||||
const pnlCells = hidePnl
|
||||
? ""
|
||||
: '<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
|
||||
(closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "</span></div>" +
|
||||
(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>";
|
||||
(roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>";
|
||||
return (
|
||||
'<div class="pos-card-head">' +
|
||||
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + "</strong>" +
|
||||
|
||||
@@ -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
+27
-1
@@ -76,6 +76,7 @@ HOT_RELOAD_EXACT = frozenset({
|
||||
"TRANSFER_CCY",
|
||||
"FORCE_CLOSE_ENABLED",
|
||||
"FORCE_CLOSE_BJ_HOUR",
|
||||
"FORCE_CLOSE_GRACE_MINUTES",
|
||||
"BTC_LEVERAGE",
|
||||
"ALT_LEVERAGE",
|
||||
"DAILY_START_CAPITAL",
|
||||
@@ -89,8 +90,20 @@ 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_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",
|
||||
@@ -144,6 +157,15 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
|
||||
("budget", "预算金额"),
|
||||
("sheets", "张数"),
|
||||
),
|
||||
"OKX_TRADE_MODE": (
|
||||
("options", "单独期权"),
|
||||
("perp_options", "永期对冲"),
|
||||
("options_options", "期期对冲"),
|
||||
),
|
||||
"HEDGE_PLAN_OPTION_PRIMARY": (
|
||||
("true", "以期权为主"),
|
||||
("false", "保险模式"),
|
||||
),
|
||||
}
|
||||
|
||||
_SELECT_ALIASES: dict[str, dict[str, str]] = {
|
||||
@@ -206,7 +228,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
+230
-59
@@ -20,13 +20,18 @@ 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_KEY", "API Key", "账户 API(永续+期权共用)"),
|
||||
("OKX_API_SECRET", "API Secret", "账户 API(永续+期权共用)"),
|
||||
("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"),
|
||||
("OKX_TD_MODE", "保证金模式", ""),
|
||||
("OKX_POS_MODE", "持仓模式", ""),
|
||||
("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"),
|
||||
("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
|
||||
(
|
||||
"OKX_SHOW_PERP_FUNDS",
|
||||
"显示永续资金",
|
||||
"默认开启;关闭后顶栏隐藏 USDT 资金账户与交易账户,总资金仅计期权 USDC 侧",
|
||||
),
|
||||
],
|
||||
"binance": [
|
||||
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
|
||||
@@ -78,6 +83,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;整点起该分钟内执行并禁止开仓"),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -119,18 +125,61 @@ _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_TRADE_BUDGET_USDC",
|
||||
"单笔预算(USDC)",
|
||||
"仅全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限",
|
||||
),
|
||||
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;打满/全仓复利共用"),
|
||||
(
|
||||
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
|
||||
"全仓复利开关",
|
||||
"默认 true;开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利",
|
||||
),
|
||||
(
|
||||
"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",
|
||||
"链上仅显示有卖一",
|
||||
@@ -139,55 +188,89 @@ _OPTIONS_SECTION: dict[str, Any] = {
|
||||
],
|
||||
}
|
||||
|
||||
# 对冲公共字段(不含已由 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,
|
||||
],
|
||||
}
|
||||
|
||||
@@ -205,17 +288,37 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
|
||||
"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",
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
file_val = str(file_values.get(key) or "").strip()
|
||||
if file_val:
|
||||
@@ -287,24 +390,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)
|
||||
|
||||
|
||||
@@ -317,11 +465,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_compound_budget_hidden(fields)
|
||||
groups.append({
|
||||
"title": sec["title"],
|
||||
"fields": fields,
|
||||
@@ -330,6 +481,26 @@ def build_env_ui_payload(
|
||||
return groups
|
||||
|
||||
|
||||
def _mark_compound_budget_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""全仓复利开启时标记单笔预算为 hidden(供 SSR/前端隐藏;切换开关仍可再显示)."""
|
||||
compound_on = True
|
||||
for f in fields:
|
||||
if f.get("key") == "OKX_OPTIONS_COMPOUND_FULL_ENABLED":
|
||||
compound_on = _env_truthy(str(f.get("current") or f.get("default") or "true"))
|
||||
break
|
||||
if not compound_on:
|
||||
return fields
|
||||
out: list[dict[str, Any]] = []
|
||||
for f in fields:
|
||||
if f.get("key") == "OKX_OPTIONS_TRADE_BUDGET_USDC":
|
||||
item = dict(f)
|
||||
item["hidden"] = True
|
||||
out.append(item)
|
||||
else:
|
||||
out.append(f)
|
||||
return out
|
||||
|
||||
|
||||
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,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
|
||||
@@ -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
|
||||
+167
-87
@@ -19,18 +19,11 @@ from lib.options.options_pricing_lib import (
|
||||
)
|
||||
|
||||
_OKX_OPTION_ERR_ZH: dict[str, str] = {
|
||||
"51008": "资金账户 USDT 可用余额不足",
|
||||
"51008": "可用余额或保证金不足(期权买入请确认交易账户 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 +44,18 @@ 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 不足
|
||||
if "usdc" in low:
|
||||
return "交易账户 USDC 可用余额不足"
|
||||
if "usdt" in low:
|
||||
return "USDT 可用余额不足(期权请先兑成 USDC 并划入交易账户)"
|
||||
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 +66,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 +95,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"},
|
||||
}
|
||||
@@ -152,7 +181,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}"
|
||||
|
||||
@@ -398,25 +430,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):
|
||||
@@ -600,7 +638,10 @@ 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)
|
||||
"""
|
||||
bal = fetch_options_balances(ex, force=force)
|
||||
|
||||
def _round(v: Any) -> float | None:
|
||||
@@ -633,11 +674,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]]:
|
||||
@@ -671,22 +743,26 @@ def build_option_chain(
|
||||
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
|
||||
@@ -1387,28 +1463,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 +1679,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,
|
||||
|
||||
@@ -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,70 @@ 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"):
|
||||
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
|
||||
# 补齐永续杠杆
|
||||
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 +927,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 +1076,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 +1170,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 +1218,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 +1245,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 +1260,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 +1286,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>
|
||||
|
||||
+888
-833
File diff suppressed because it is too large
Load Diff
@@ -76,6 +76,7 @@ def install_instance_theme_static(app) -> None:
|
||||
"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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -36,17 +36,41 @@ 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]:
|
||||
@@ -54,11 +78,27 @@ def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[floa
|
||||
if snap.get("enabled") is False or snap.get("ok") is False:
|
||||
return None
|
||||
upl = snap.get("upl_total_usdc")
|
||||
if upl is None:
|
||||
return None
|
||||
if upl is not None:
|
||||
try:
|
||||
return round(float(upl), 4)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
# 快照偶发缺合计时,按持仓行回退汇总(与卡片展示一致)
|
||||
try:
|
||||
return round(float(upl), 4)
|
||||
except (TypeError, ValueError):
|
||||
from lib.options.options_positions_lib import display_pnl_from_option_row
|
||||
|
||||
total = 0.0
|
||||
found = 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
|
||||
total += float(pnl)
|
||||
return round(total, 4) if found else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@@ -80,19 +120,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 +169,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)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,7 @@ 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,
|
||||
@@ -30,6 +31,7 @@ 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": "策略交易",
|
||||
@@ -51,6 +53,7 @@ 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",
|
||||
@@ -116,6 +119,7 @@ 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",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
@@ -10,6 +11,20 @@ EMBED_STRATEGY_PAGES = frozenset({"strategy", "strategy_trend", "strategy_roll",
|
||||
_WIN_EPS = 1e-9
|
||||
|
||||
|
||||
def env_truthy(raw: str | None, default: bool = False) -> bool:
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return default
|
||||
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def show_perp_funds_enabled(*, exchange_key: str | None = None) -> bool:
|
||||
"""OKX:是否在顶栏显示永续资金账户/交易账户.其他所恒为 True."""
|
||||
ex = (exchange_key or "").strip().lower()
|
||||
if ex and ex != "okx":
|
||||
return True
|
||||
return env_truthy(os.getenv("OKX_SHOW_PERP_FUNDS"), default=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmbedRenderPlan:
|
||||
exchange_capitals: bool
|
||||
@@ -89,12 +104,14 @@ def options_funding_label(
|
||||
funding_usdc: float | None,
|
||||
funding_usdt: float | None = None,
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
if funding_usdc is not None and float(funding_usdc) > 0:
|
||||
parts.append(f"{float(funding_usdc):.2f} USDC")
|
||||
if funding_usdt is not None and float(funding_usdt) > 0:
|
||||
parts.append(f"{float(funding_usdt):.2f} USDT")
|
||||
return " · ".join(parts) if parts else "—"
|
||||
"""期权侧顶栏仅展示 USDC(USDT 归永续资金/交易账户).funding_usdt 参数保留兼容,忽略."""
|
||||
_ = funding_usdt
|
||||
if funding_usdc is None:
|
||||
return "—"
|
||||
try:
|
||||
return f"{float(funding_usdc):.2f} USDC"
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
|
||||
|
||||
def total_funds_usdt(
|
||||
|
||||
@@ -7,11 +7,12 @@ import os
|
||||
from typing import Callable
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit
|
||||
|
||||
from flask import Flask, Response, jsonify, redirect, request, session
|
||||
from flask import Flask, Response, jsonify, make_response, redirect, request, session
|
||||
from jinja2 import ChoiceLoader, FileSystemLoader
|
||||
|
||||
EMBED_TABS: tuple[str, ...] = (
|
||||
"dashboard",
|
||||
"account_ledger",
|
||||
"key_monitor",
|
||||
"trade",
|
||||
"strategy",
|
||||
@@ -31,6 +32,7 @@ PATH_TO_EMBED_TAB: dict[str, str] = {
|
||||
"/": "trade",
|
||||
"/trade": "trade",
|
||||
"/dashboard": "dashboard",
|
||||
"/account_ledger": "account_ledger",
|
||||
"/key_monitor": "key_monitor",
|
||||
"/strategy": "strategy",
|
||||
"/strategy/trend": "strategy",
|
||||
@@ -184,7 +186,10 @@ def register_embed_routes(
|
||||
if tab not in EMBED_TABS:
|
||||
tab = "trade"
|
||||
session["hub_embed_shell"] = True
|
||||
return render_main_page_fn(tab, embed_mode="shell")
|
||||
resp = make_response(render_main_page_fn(tab, embed_mode="shell"))
|
||||
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
resp.headers["Pragma"] = "no-cache"
|
||||
return resp
|
||||
|
||||
@login_required
|
||||
@app.route("/api/embed/page/<tab>")
|
||||
@@ -198,7 +203,10 @@ def register_embed_routes(
|
||||
html = render_main_page_fn(tab, embed_mode="fragment")
|
||||
if isinstance(html, Response):
|
||||
html = html.get_data(as_text=True)
|
||||
return jsonify({"ok": True, "page": tab, "html": html})
|
||||
resp = jsonify({"ok": True, "page": tab, "html": html})
|
||||
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
resp.headers["Pragma"] = "no-cache"
|
||||
return resp
|
||||
|
||||
|
||||
def pwa_app_name(exchange_key: str) -> str:
|
||||
|
||||
@@ -152,30 +152,29 @@ def build_instance_settings_view(
|
||||
"title": "整点强制清仓",
|
||||
"rows": [
|
||||
_row("强制清仓", _on_off(force_close_on)),
|
||||
_row("执行时刻", f"北京时间 {force_close_hour}:00 起 15 分钟内"),
|
||||
_row(
|
||||
"执行时刻",
|
||||
f"北京时间 {force_close_hour}:00 起 {_env_int('FORCE_CLOSE_GRACE_MINUTES', 5)} 分钟内",
|
||||
),
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
if (exchange_key or "").strip().lower() == "okx" and _env_bool("OKX_OPTIONS_ENABLED", False):
|
||||
opt_key = (os.getenv("OKX_OPTIONS_API_KEY") or "").strip()
|
||||
api_key = (os.getenv("OKX_API_KEY") or "").strip()
|
||||
sections.append(
|
||||
{
|
||||
"title": "期权设置",
|
||||
"rows": [
|
||||
_row("期权模块", "已启用"),
|
||||
_row(
|
||||
"期权 API",
|
||||
f"已配置(…{opt_key[-4:]})" if len(opt_key) >= 4 else "未配置",
|
||||
),
|
||||
_row(
|
||||
"子账户",
|
||||
(os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip() or "未配置 OKX_SUB_ACCOUNT_NAME",
|
||||
"主/子账户划转用",
|
||||
"账户 API",
|
||||
f"已配置(…{api_key[-4:]})" if len(api_key) >= 4 else "未配置 OKX_API_*",
|
||||
"永续与期权共用 OKX_API_*",
|
||||
),
|
||||
_row(
|
||||
"说明",
|
||||
"币种兑换与账户划转到右侧「期权设置」卡片操作",
|
||||
"币种兑换与账户内划转到右侧「期权设置」卡片操作",
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -196,7 +195,6 @@ def build_instance_settings_view(
|
||||
"show_transfer": (exchange_key or "").strip().lower() in ("gate", "binance", "okx"),
|
||||
"options_settings_enabled": (exchange_key or "").strip().lower() == "okx"
|
||||
and _env_bool("OKX_OPTIONS_ENABLED", False),
|
||||
"options_sub_account": (os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip(),
|
||||
"auto_transfer_enabled": auto_transfer_on,
|
||||
"auto_transfer_bj_hour": _env_int("AUTO_TRANSFER_BJ_HOUR", 8),
|
||||
"auto_transfer_amount": _env_float("AUTO_TRANSFER_AMOUNT", 30),
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="display-prefs-checks">
|
||||
{% for item in group.entries %}
|
||||
<label class="chk-label">
|
||||
<input type="checkbox" data-pref-key="{{ item.key }}"{% if item.key in ('show_nav_dashboard', 'show_nav_system_guide') %}{% if display.get(item.key) %} checked{% endif %}{% elif display.get(item.key, true) %} checked{% endif %}>
|
||||
<input type="checkbox" data-pref-key="{{ item.key }}"{% if item.key in ('show_nav_dashboard', 'show_nav_account_ledger', 'show_nav_system_guide') %}{% if display.get(item.key) %} checked{% endif %}{% elif display.get(item.key, true) %} checked{% endif %}>
|
||||
{{ item.label }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
|
||||
@@ -741,13 +741,10 @@ function paintExchangeTpslRow(orderId, tpsl){
|
||||
const tpText = document.getElementById(`ex-tp-text-${orderId}`);
|
||||
const slBtn = document.getElementById(`ex-sl-cancel-${orderId}`);
|
||||
const tpBtn = document.getElementById(`ex-tp-cancel-${orderId}`);
|
||||
const intraday = (document.body && document.body.getAttribute("data-intraday-discipline")) === "1";
|
||||
if(slText) slText.innerText = formatExTpslLine('sl', data.sl);
|
||||
if(tpText) tpText.innerText = formatExTpslLine('tp', data.tp);
|
||||
if(!intraday){
|
||||
if(slBtn) slBtn.disabled = !(data.sl && data.sl.order_id);
|
||||
if(tpBtn) tpBtn.disabled = !(data.tp && data.tp.order_id);
|
||||
}
|
||||
if(slBtn) slBtn.disabled = !(data.sl && data.sl.order_id);
|
||||
if(tpBtn) tpBtn.disabled = !(data.tp && data.tp.order_id);
|
||||
}
|
||||
function toggleTpslModalMode(){
|
||||
const mode = (document.getElementById('tpsl-modal-mode')||{}).value || 'price';
|
||||
@@ -1140,10 +1137,11 @@ function paintRealtimePnlFromSnapshot(data){
|
||||
}
|
||||
|
||||
function formatOptionsFundingLabel(usdc, usdt) {
|
||||
const parts = [];
|
||||
if (usdc !== null && usdc !== undefined && Number(usdc) > 0) parts.push(`${Number(usdc).toFixed(2)} USDC`);
|
||||
if (usdt !== null && usdt !== undefined && Number(usdt) > 0) parts.push(`${Number(usdt).toFixed(2)} USDT`);
|
||||
return parts.length ? parts.join(" · ") : "—";
|
||||
// 期权侧顶栏仅 USDC;usdt 参数忽略(USDT 在永续资金/交易账户)
|
||||
if (usdc === null || usdc === undefined || usdc === "") return "—";
|
||||
const n = Number(usdc);
|
||||
if (Number.isNaN(n)) return "—";
|
||||
return `${n.toFixed(2)} USDC`;
|
||||
}
|
||||
|
||||
function setFundsFieldText(field, text){
|
||||
@@ -1152,8 +1150,23 @@ function setFundsFieldText(field, text){
|
||||
el.innerText = text;
|
||||
});
|
||||
}
|
||||
function applyPerpFundsVisibility(show){
|
||||
const on = show !== false;
|
||||
document.querySelectorAll("[data-perp-funds='1']").forEach((el) => {
|
||||
el.style.display = on ? "" : "none";
|
||||
});
|
||||
}
|
||||
function accountSnapshotFundingMissing(data){
|
||||
if(!data || typeof data !== "object") return true;
|
||||
if(data.show_perp_funds === false){
|
||||
const hasTotal = data.total_funds != null && data.total_funds !== "";
|
||||
const hasOpt =
|
||||
data.options_funding_usdc != null ||
|
||||
data.options_funding_usdt != null ||
|
||||
data.options_trading_usdc != null ||
|
||||
data.options_trading_usdt != null;
|
||||
return !hasTotal && !hasOpt;
|
||||
}
|
||||
const hasFunding = data.funding_usdt != null && data.funding_usdt !== "";
|
||||
const hasTotal = data.total_funds != null && data.total_funds !== "";
|
||||
const hasTrading = data.current_capital != null && data.current_capital !== "";
|
||||
@@ -1162,6 +1175,9 @@ function accountSnapshotFundingMissing(data){
|
||||
let accountSnapshotRetryCount = 0;
|
||||
function applyAccountSnapshot(data){
|
||||
if(!data || typeof data !== "object") return;
|
||||
if(typeof data.show_perp_funds !== "undefined"){
|
||||
applyPerpFundsVisibility(data.show_perp_funds);
|
||||
}
|
||||
if(data.funding_usdt != null && data.funding_usdt !== ""){
|
||||
setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`);
|
||||
}
|
||||
@@ -1214,11 +1230,15 @@ function applyAccountSnapshot(data){
|
||||
if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){
|
||||
TimeCloseUI.paintForceCloseHeader(data.force_close);
|
||||
}
|
||||
if(window.OpenSubmitGate) OpenSubmitGate.apply(data);
|
||||
let canTradeText = "可开仓";
|
||||
if(!data.can_trade){
|
||||
const parts = [];
|
||||
if(data.open_block_note) parts.push(data.open_block_note);
|
||||
if(data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason){
|
||||
parts.push(data.risk_status.reason);
|
||||
if(!data.open_block_note || data.open_block_note.indexOf(data.risk_status.reason) < 0){
|
||||
parts.push(data.risk_status.reason);
|
||||
}
|
||||
}
|
||||
const ac = Number(data.active_count || 0);
|
||||
const max = Number(data.max_active_positions || {{ max_active_positions }});
|
||||
@@ -1226,9 +1246,8 @@ function applyAccountSnapshot(data){
|
||||
const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }});
|
||||
const opens = Number(data.opens_today);
|
||||
if(hard > 0 && !Number.isNaN(opens) && opens >= hard) parts.push(`本交易日开仓 ${opens}/${hard} 已达上限`);
|
||||
if(!parts.length) parts.push(`未到北京时间 {{ reset_hour }}:00`);
|
||||
else parts.push(`或未到北京时间 {{ reset_hour }}:00`);
|
||||
canTradeText = `不可开仓(${parts.join(";")})`;
|
||||
if(data.open_guard_blocks_now) parts.push(`未到北京时间 ${data.reset_hour||{{ reset_hour }}}:00`);
|
||||
canTradeText = parts.length ? `不可开仓(${parts.join(";")})` : "不可开仓";
|
||||
}
|
||||
const opensToday = Number(data.opens_today);
|
||||
const hardLim = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }});
|
||||
@@ -1279,6 +1298,12 @@ if(fullMarginEl){
|
||||
}
|
||||
|
||||
const sltpModeEl = document.getElementById("sltp-mode");
|
||||
function setOmFieldVisible(inputEl, show){
|
||||
if(!inputEl) return;
|
||||
inputEl.style.display = show ? "" : "none";
|
||||
const wrap = inputEl.closest(".om-field");
|
||||
if(wrap) wrap.style.display = show ? "" : "none";
|
||||
}
|
||||
function toggleSltpMode(){
|
||||
const mode = sltpModeEl ? sltpModeEl.value : "fixed_rr";
|
||||
const slEl = document.getElementById("order-sl");
|
||||
@@ -1289,14 +1314,14 @@ function toggleSltpMode(){
|
||||
if(!slEl || !tpEl || !slPctEl || !tpPctEl){ return; }
|
||||
const pct = mode === "pct";
|
||||
const fixed = mode === "fixed_rr";
|
||||
slEl.style.display = pct ? "none" : "";
|
||||
tpEl.style.display = (pct || fixed) ? "none" : "";
|
||||
if(fixedRrEl) fixedRrEl.style.display = fixed ? "" : "none";
|
||||
setOmFieldVisible(slEl, !pct);
|
||||
setOmFieldVisible(tpEl, !(pct || fixed));
|
||||
setOmFieldVisible(fixedRrEl, fixed);
|
||||
slEl.required = !pct;
|
||||
tpEl.required = !pct && !fixed;
|
||||
if(fixedRrEl) fixedRrEl.required = fixed;
|
||||
slPctEl.style.display = pct ? "" : "none";
|
||||
tpPctEl.style.display = pct ? "" : "none";
|
||||
setOmFieldVisible(slPctEl, pct);
|
||||
setOmFieldVisible(tpPctEl, pct);
|
||||
slPctEl.required = pct;
|
||||
tpPctEl.required = pct;
|
||||
refreshOrderTpPreview();
|
||||
|
||||
@@ -78,11 +78,49 @@
|
||||
<div class="stat-item"><div class="label">期内最大亏损日</div><div class="value">{% if s.worst_day %}{{ s.worst_day }}({{ funds_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}</div></div>
|
||||
</div>
|
||||
</details>
|
||||
{% if period_key == 'all' %}
|
||||
<div class="inst-stats-block inst-stats-monthly" style="margin-top:14px">
|
||||
<div class="inst-stats-block-title">按月统计</div>
|
||||
{% if s.monthly_rows %}
|
||||
<div class="inst-stats-month-table-wrap">
|
||||
<table class="inst-stats-month-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>月份</th>
|
||||
<th>开单</th>
|
||||
<th>平仓</th>
|
||||
<th>胜率</th>
|
||||
<th>净盈亏</th>
|
||||
<th>最大回撤</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in s.monthly_rows %}
|
||||
{% set m_net_cls = 'pos-pnl-profit' if m.net_pnl_u > 0 else ('pos-pnl-loss' if m.net_pnl_u < 0 else '') %}
|
||||
<tr>
|
||||
<td>{{ m.month_key }}</td>
|
||||
<td>{{ m.opens_count }}</td>
|
||||
<td>{{ m.closed_count }}</td>
|
||||
<td>{% if m.win_rate_pct is not none %}{{ m.win_rate_pct }}%{% else %}—{% endif %}</td>
|
||||
<td class="{{ m_net_cls }}">{% if m.net_pnl_u > 0 %}+{% endif %}{{ funds_fmt(m.net_pnl_u) }}</td>
|
||||
<td class="pos-pnl-loss">{{ funds_fmt(m.max_drawdown_u) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="inst-stats-empty" style="margin-top:0">暂无按月平仓数据</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
<div class="grid">
|
||||
{% if page == 'dashboard' %}
|
||||
{% include 'dashboard_panel.html' %}
|
||||
{% elif page == 'account_ledger' %}
|
||||
{% include 'account_ledger_panel.html' %}
|
||||
{% elif page == 'key_monitor' %}
|
||||
{% include 'key_monitor_panel.html' %}
|
||||
{% elif page == 'trade' %}
|
||||
@@ -97,50 +135,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include order_rule_tips_tpl %}
|
||||
<form id="add-order-form" action="/add_order" method="post" class="form-row" data-risk-percent="{{ risk_percent }}">
|
||||
{% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %}
|
||||
{{ trade_policy_symbol('symbol', 'order-symbol') }}
|
||||
{{ trade_policy_direction('direction', 'order-direction') }}
|
||||
<select id="sltp-mode" name="sltp_mode">
|
||||
<option value="fixed_rr" selected>止盈止损:固定盈亏比</option>
|
||||
<option value="price">止盈止损:价格模式</option>
|
||||
<option value="pct">止盈止损:百分比模式</option>
|
||||
</select>
|
||||
{% from 'order_entry_model_fields.html' import order_entry_type_fields with context %}
|
||||
{{ order_entry_type_fields() }}
|
||||
{% from 'order_leverage_fields.html' import order_leverage_fields with context %}
|
||||
{{ order_leverage_fields() }}
|
||||
{% if not intraday_discipline %}
|
||||
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
|
||||
<input type="checkbox" name="breakeven_enabled" value="1" checked> 启用移动保本(关闭则仅保留初始止损与交易所挂单)
|
||||
</label>
|
||||
<span id="order-time-close-wrap" class="order-time-close-wrap" style="display:inline-flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
|
||||
<label style="display:inline-flex;align-items:center;gap:4px;margin:0;cursor:pointer">
|
||||
<input type="checkbox" name="time_close_enabled" value="1" id="order-time-close-cb"> 时间平仓
|
||||
</label>
|
||||
<select name="time_close_hours" id="order-time-close-hours" title="持仓满该时长后自动平仓">
|
||||
<option value="1">1h</option>
|
||||
<option value="2">2h</option>
|
||||
<option value="4" selected>4h</option>
|
||||
</select>
|
||||
</span>
|
||||
{% else %}
|
||||
<input type="hidden" name="breakeven_enabled" value="0">
|
||||
{% endif %}
|
||||
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
|
||||
<input type="checkbox" name="order_chart" value="true"> 开仓后生成多周期K线图(各周期100根,含开平仓标记)
|
||||
</label>
|
||||
{% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %}
|
||||
{{ symbol_live_price_hint('order-symbol-live-price', 'order-symbol', 'order-direction') }}
|
||||
<span class="symbol-live-price-note">下单成交价以交易所成交回报为准</span>
|
||||
<input id="order-sl" name="sl" step="any" placeholder="止损价格" required>
|
||||
<input id="order-fixed-rr" name="fixed_rr" type="number" min="0.01" step="0.01" placeholder="盈亏比(默认1.5)" value="1.5" title="止盈距离=止损距离×盈亏比">
|
||||
<input id="order-tp" name="tgt" step="any" placeholder="止盈价格" style="display:none">
|
||||
<input id="order-sl-pct" name="sl_pct" type="number" min="0.01" step="0.01" placeholder="止损%" style="display:none">
|
||||
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
|
||||
<button type="submit">{{ open_position_button_label }}</button>
|
||||
</form>
|
||||
{% include 'order_plan_preview_bar.html' %}
|
||||
{% include 'order_monitor_open_form.html' %}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||
@@ -182,10 +177,8 @@
|
||||
<span class="pos-side-badge {{ 'pos-side-long' if o.direction == 'long' else 'pos-side-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span>
|
||||
</div>
|
||||
<div class="pos-head-actions">
|
||||
{% if not intraday_discipline %}
|
||||
<button type="button" class="pos-entrust-btn" onclick="openTpslEntrustModal({{ o.id }})">委托</button>
|
||||
<a href="/del_order/{{ o.id }}" class="pos-close-btn" onclick="return confirm('删除会触发手动平仓,继续?')">平仓</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pos-meta">
|
||||
@@ -245,15 +238,11 @@
|
||||
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
||||
<div class="pos-ex-order-row">
|
||||
<span class="pos-ex-order-main" id="ex-sl-text-{{ o.id }}">止损:加载中…</span>
|
||||
{% if not intraday_discipline %}
|
||||
<button type="button" class="pos-ex-cancel-btn" id="ex-sl-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'sl')">撤单</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="pos-ex-order-row">
|
||||
<span class="pos-ex-order-main" id="ex-tp-text-{{ o.id }}">止盈:加载中…</span>
|
||||
{% if not intraday_discipline %}
|
||||
<button type="button" class="pos-ex-cancel-btn" id="ex-tp-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'tp')">撤单</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,6 +295,7 @@
|
||||
{% if page == 'records' %}
|
||||
{% include 'records_panel.html' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if page == 'env_config' %}
|
||||
{% include 'env_config_panel.html' %}
|
||||
{% endif %}
|
||||
@@ -349,13 +339,14 @@
|
||||
<button type="button" class="stats-period-tab active" data-stats-period="day" role="tab" aria-selected="true" onclick="switchStatsPeriod('day')">日统计</button>
|
||||
<button type="button" class="stats-period-tab" data-stats-period="week" role="tab" aria-selected="false" onclick="switchStatsPeriod('week')">周统计</button>
|
||||
<button type="button" class="stats-period-tab" data-stats-period="month" role="tab" aria-selected="false" onclick="switchStatsPeriod('month')">月统计</button>
|
||||
<button type="button" class="stats-period-tab" data-stats-period="all" role="tab" aria-selected="false" onclick="switchStatsPeriod('all')">全部统计</button>
|
||||
</div>
|
||||
{{ period_stats_pane("day", seg.day) }}
|
||||
{{ period_stats_pane("week", seg.week) }}
|
||||
{{ period_stats_pane("month", seg.month) }}
|
||||
{{ period_stats_pane("all", seg.all) }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
<script src="/static/autofill_guard.js?v=1"></script>
|
||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
|
||||
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
||||
<link rel="stylesheet" href="/static/instance_page.css?v=11">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=108">
|
||||
<link rel="stylesheet" href="/static/instance_page.css?v=13">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=117">
|
||||
<script src="/static/account_risk_badge.js?v=4"></script>
|
||||
<script src="/static/open_submit_gate.js?v=1"></script>
|
||||
<meta name="theme-color" content="#0b0d14">
|
||||
<title>{{ pwa_app_name }}</title>
|
||||
</head>
|
||||
@@ -31,6 +32,7 @@
|
||||
</div>
|
||||
<nav class="top-nav embed-top-nav" aria-label="实例导航">
|
||||
<a href="/dashboard" data-embed-tab="dashboard" class="{% if initial_tab == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
|
||||
<a href="/account_ledger" data-embed-tab="account_ledger" class="{% if initial_tab == 'account_ledger' %}active{% endif %}"{% if not display.show_nav_account_ledger %} style="display:none"{% endif %}>账户流水</a>
|
||||
<a href="/key_monitor" data-embed-tab="key_monitor" class="{% if initial_tab == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a>
|
||||
<a href="/trade" data-embed-tab="trade" class="{% if initial_tab == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} style="display:none"{% endif %}>实盘下单</a>
|
||||
{% if not intraday_discipline and display.show_nav_strategy %}
|
||||
@@ -110,6 +112,7 @@
|
||||
<p class="inst-mobile-more-hint">次要页面 · 完整界面请用电脑</p>
|
||||
<nav class="inst-mobile-more-nav" aria-label="更多页面">
|
||||
<a href="/dashboard" data-embed-tab="dashboard"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
|
||||
<a href="/account_ledger" data-embed-tab="account_ledger"{% if not display.show_nav_account_ledger %} style="display:none"{% endif %}>账户流水</a>
|
||||
{% if display.show_nav_records %}
|
||||
<a href="/records" data-embed-tab="records">交易记录</a>
|
||||
{% endif %}
|
||||
@@ -158,17 +161,18 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
|
||||
<script src="/static/symbol_live_price.js?v=2"></script>
|
||||
<script src="/static/strategy_roll.js?v=6"></script>
|
||||
<script src="/static/key_monitor_form.js?v=2"></script>
|
||||
<script src="/static/instance_stats.js?v=4"></script>
|
||||
<script src="/static/instance_stats.js?v=5"></script>
|
||||
{% include 'embed_boot_scripts.html' %}
|
||||
<script src="/static/records_review_page.js?v=4"></script>
|
||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||
<script src="/static/instance_dashboard.js?v=5"></script>
|
||||
<script src="/static/account_ledger.js?v=1"></script>
|
||||
<script>
|
||||
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
|
||||
</script>
|
||||
<script src="/static/instance_settings_prefs.js?v=16"></script>
|
||||
<script src="/static/instance_settings_prefs.js?v=21"></script>
|
||||
<script src="/static/instance_live.js?v=6"></script>
|
||||
<script src="/static/instance_embed.js?v=29"></script>
|
||||
<script src="/static/instance_embed.js?v=31"></script>
|
||||
<script src="/static/instance_mobile_nav.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
</div>
|
||||
|
||||
{% if env_config_groups %}
|
||||
<div class="env-config-body card" data-env-ssr="1" id="env-config-body">
|
||||
{% set ns = namespace(mode_idx=0) %}
|
||||
{% for group in env_config_groups %}
|
||||
{% if '期权/对冲模式' in (group.title or '') %}{% set ns.mode_idx = loop.index0 %}{% endif %}
|
||||
{% endfor %}
|
||||
<div class="env-config-body card" data-env-ssr="1" id="env-config-body" data-env-mode-section-idx="{{ ns.mode_idx }}">
|
||||
{% for group in env_config_groups %}
|
||||
<input type="radio" name="env-section" id="env-sec-{{ loop.index0 }}" class="env-tab-radio"{% if loop.first %} checked{% endif %}>
|
||||
{% endfor %}
|
||||
@@ -33,7 +37,7 @@
|
||||
{% endif %}
|
||||
<div class="env-form-grid">
|
||||
{% for field in group.fields %}
|
||||
<div class="env-field-row{% if field.restart_required %} env-field-row--restart{% endif %}">
|
||||
<div class="env-field-row{% if field.restart_required %} env-field-row--restart{% endif %}" data-env-key="{{ field.key }}"{% if field.hidden %} hidden style="display:none"{% endif %}>
|
||||
<label class="env-field-label" for="env-f-{{ field.key }}">
|
||||
{{ field.label or field.key }}
|
||||
{% if field.restart_required %}<span class="env-restart-mark" title="需重启">*</span>{% endif %}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
|
||||
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
||||
<script src="/static/account_risk_badge.js?v=4"></script>
|
||||
<script src="/static/open_submit_gate.js?v=1"></script>
|
||||
|
||||
<meta name="theme-color" content="#0b0d14">
|
||||
<meta name="apple-mobile-web-app-title" content="{{ pwa_app_name }}">
|
||||
@@ -17,8 +18,8 @@
|
||||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/static/icons/manifest.webmanifest">
|
||||
<title>{{ pwa_app_name }}</title>
|
||||
<link rel="stylesheet" href="/static/instance_page.css?v=11">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=105">
|
||||
<link rel="stylesheet" href="/static/instance_page.css?v=13">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=117">
|
||||
|
||||
</head>
|
||||
<body
|
||||
@@ -110,6 +111,42 @@
|
||||
<div class="stat-item"><div class="label">期内最大亏损日</div><div class="value">{% if s.worst_day %}{{ s.worst_day }}({{ funds_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}</div></div>
|
||||
</div>
|
||||
</details>
|
||||
{% if period_key == 'all' %}
|
||||
<div class="inst-stats-block inst-stats-monthly" style="margin-top:14px">
|
||||
<div class="inst-stats-block-title">按月统计</div>
|
||||
{% if s.monthly_rows %}
|
||||
<div class="inst-stats-month-table-wrap">
|
||||
<table class="inst-stats-month-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>月份</th>
|
||||
<th>开单</th>
|
||||
<th>平仓</th>
|
||||
<th>胜率</th>
|
||||
<th>净盈亏</th>
|
||||
<th>最大回撤</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in s.monthly_rows %}
|
||||
{% set m_net_cls = 'pos-pnl-profit' if m.net_pnl_u > 0 else ('pos-pnl-loss' if m.net_pnl_u < 0 else '') %}
|
||||
<tr>
|
||||
<td>{{ m.month_key }}</td>
|
||||
<td>{{ m.opens_count }}</td>
|
||||
<td>{{ m.closed_count }}</td>
|
||||
<td>{% if m.win_rate_pct is not none %}{{ m.win_rate_pct }}%{% else %}—{% endif %}</td>
|
||||
<td class="{{ m_net_cls }}">{% if m.net_pnl_u > 0 %}+{% endif %}{{ funds_fmt(m.net_pnl_u) }}</td>
|
||||
<td class="pos-pnl-loss">{{ funds_fmt(m.max_drawdown_u) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="inst-stats-empty" style="margin-top:0">暂无按月平仓数据</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
<div class="container">
|
||||
@@ -118,6 +155,7 @@
|
||||
</div>
|
||||
<div class="top-nav">
|
||||
<a href="/dashboard" data-embed-tab="dashboard" class="{% if page == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
|
||||
<a href="/account_ledger" data-embed-tab="account_ledger" class="{% if page == 'account_ledger' %}active{% endif %}"{% if not display.show_nav_account_ledger %} style="display:none"{% endif %}>账户流水</a>
|
||||
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a>
|
||||
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} style="display:none"{% endif %}>实盘下单</a>
|
||||
{% if not intraday_discipline and display.show_nav_strategy %}
|
||||
@@ -160,6 +198,8 @@
|
||||
<div class="grid">
|
||||
{% if page == 'dashboard' %}
|
||||
{% include 'dashboard_panel.html' %}
|
||||
{% elif page == 'account_ledger' %}
|
||||
{% include 'account_ledger_panel.html' %}
|
||||
{% elif page == 'key_monitor' %}
|
||||
{% include 'key_monitor_panel.html' %}
|
||||
{% elif page == 'trade' %}
|
||||
@@ -174,50 +214,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include order_rule_tips_tpl %}
|
||||
<form id="add-order-form" action="/add_order" method="post" class="form-row" data-risk-percent="{{ risk_percent }}">
|
||||
{% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %}
|
||||
{{ trade_policy_symbol('symbol', 'order-symbol') }}
|
||||
{{ trade_policy_direction('direction', 'order-direction') }}
|
||||
<select id="sltp-mode" name="sltp_mode">
|
||||
<option value="fixed_rr" selected>止盈止损:固定盈亏比</option>
|
||||
<option value="price">止盈止损:价格模式</option>
|
||||
<option value="pct">止盈止损:百分比模式</option>
|
||||
</select>
|
||||
{% from 'order_entry_model_fields.html' import order_entry_type_fields with context %}
|
||||
{{ order_entry_type_fields() }}
|
||||
{% from 'order_leverage_fields.html' import order_leverage_fields with context %}
|
||||
{{ order_leverage_fields() }}
|
||||
{% if not intraday_discipline %}
|
||||
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
|
||||
<input type="checkbox" name="breakeven_enabled" value="1" checked> 启用移动保本(关闭则仅保留初始止损与交易所挂单)
|
||||
</label>
|
||||
<span id="order-time-close-wrap" class="order-time-close-wrap" style="display:inline-flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
|
||||
<label style="display:inline-flex;align-items:center;gap:4px;margin:0;cursor:pointer">
|
||||
<input type="checkbox" name="time_close_enabled" value="1" id="order-time-close-cb"> 时间平仓
|
||||
</label>
|
||||
<select name="time_close_hours" id="order-time-close-hours" title="持仓满该时长后自动平仓">
|
||||
<option value="1">1h</option>
|
||||
<option value="2">2h</option>
|
||||
<option value="4" selected>4h</option>
|
||||
</select>
|
||||
</span>
|
||||
{% else %}
|
||||
<input type="hidden" name="breakeven_enabled" value="0">
|
||||
{% endif %}
|
||||
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
|
||||
<input type="checkbox" name="order_chart" value="true"> 开仓后生成多周期K线图(各周期100根,含开平仓标记)
|
||||
</label>
|
||||
{% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %}
|
||||
{{ symbol_live_price_hint('order-symbol-live-price', 'order-symbol', 'order-direction') }}
|
||||
<span class="symbol-live-price-note">下单成交价以交易所成交回报为准</span>
|
||||
<input id="order-sl" name="sl" step="any" placeholder="止损价格" required>
|
||||
<input id="order-fixed-rr" name="fixed_rr" type="number" min="0.01" step="0.01" placeholder="盈亏比(默认1.5)" value="1.5" title="止盈距离=止损距离×盈亏比">
|
||||
<input id="order-tp" name="tgt" step="any" placeholder="止盈价格" style="display:none">
|
||||
<input id="order-sl-pct" name="sl_pct" type="number" min="0.01" step="0.01" placeholder="止损%" style="display:none">
|
||||
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
|
||||
<button type="submit">{{ open_position_button_label }}</button>
|
||||
</form>
|
||||
{% include 'order_plan_preview_bar.html' %}
|
||||
{% include 'order_monitor_open_form.html' %}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||
@@ -259,10 +256,8 @@
|
||||
<span class="pos-side-badge {{ 'pos-side-long' if o.direction == 'long' else 'pos-side-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span>
|
||||
</div>
|
||||
<div class="pos-head-actions">
|
||||
{% if not intraday_discipline %}
|
||||
<button type="button" class="pos-entrust-btn" onclick="openTpslEntrustModal({{ o.id }})">委托</button>
|
||||
<a href="/del_order/{{ o.id }}" class="pos-close-btn" onclick="return confirm('删除会触发手动平仓,继续?')">平仓</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pos-meta">
|
||||
@@ -322,15 +317,11 @@
|
||||
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
||||
<div class="pos-ex-order-row">
|
||||
<span class="pos-ex-order-main" id="ex-sl-text-{{ o.id }}">止损:加载中…</span>
|
||||
{% if not intraday_discipline %}
|
||||
<button type="button" class="pos-ex-cancel-btn" id="ex-sl-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'sl')">撤单</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="pos-ex-order-row">
|
||||
<span class="pos-ex-order-main" id="ex-tp-text-{{ o.id }}">止盈:加载中…</span>
|
||||
{% if not intraday_discipline %}
|
||||
<button type="button" class="pos-ex-cancel-btn" id="ex-tp-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'tp')">撤单</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -427,10 +418,12 @@
|
||||
<button type="button" class="stats-period-tab active" data-stats-period="day" role="tab" aria-selected="true" onclick="switchStatsPeriod('day')">日统计</button>
|
||||
<button type="button" class="stats-period-tab" data-stats-period="week" role="tab" aria-selected="false" onclick="switchStatsPeriod('week')">周统计</button>
|
||||
<button type="button" class="stats-period-tab" data-stats-period="month" role="tab" aria-selected="false" onclick="switchStatsPeriod('month')">月统计</button>
|
||||
<button type="button" class="stats-period-tab" data-stats-period="all" role="tab" aria-selected="false" onclick="switchStatsPeriod('all')">全部统计</button>
|
||||
</div>
|
||||
{{ period_stats_pane("day", seg.day) }}
|
||||
{{ period_stats_pane("week", seg.week) }}
|
||||
{{ period_stats_pane("month", seg.month) }}
|
||||
{{ period_stats_pane("all", seg.all) }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -472,7 +465,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
|
||||
<script src="/static/manual_order_rr_preview.js?v=5"></script>
|
||||
<script src="/static/symbol_live_price.js?v=2"></script>
|
||||
<script src="/static/strategy_roll.js?v=6"></script>
|
||||
<script src="/static/instance_stats.js?v=4"></script>
|
||||
<script src="/static/instance_stats.js?v=5"></script>
|
||||
<script>
|
||||
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
|
||||
const JOURNAL_ORDER_TYPE_OPTIONS = {{ order_type_options | tojson }};
|
||||
@@ -1195,13 +1188,10 @@ function paintExchangeTpslRow(orderId, tpsl){
|
||||
const tpText = document.getElementById(`ex-tp-text-${orderId}`);
|
||||
const slBtn = document.getElementById(`ex-sl-cancel-${orderId}`);
|
||||
const tpBtn = document.getElementById(`ex-tp-cancel-${orderId}`);
|
||||
const intraday = (document.body && document.body.getAttribute("data-intraday-discipline")) === "1";
|
||||
if(slText) slText.innerText = formatExTpslLine('sl', data.sl);
|
||||
if(tpText) tpText.innerText = formatExTpslLine('tp', data.tp);
|
||||
if(!intraday){
|
||||
if(slBtn) slBtn.disabled = !(data.sl && data.sl.order_id);
|
||||
if(tpBtn) tpBtn.disabled = !(data.tp && data.tp.order_id);
|
||||
}
|
||||
if(slBtn) slBtn.disabled = !(data.sl && data.sl.order_id);
|
||||
if(tpBtn) tpBtn.disabled = !(data.tp && data.tp.order_id);
|
||||
}
|
||||
function toggleTpslModalMode(){
|
||||
const mode = (document.getElementById('tpsl-modal-mode')||{}).value || 'price';
|
||||
@@ -1628,10 +1618,11 @@ function paintRealtimePnlFromSnapshot(data){
|
||||
}
|
||||
|
||||
function formatOptionsFundingLabel(usdc, usdt) {
|
||||
const parts = [];
|
||||
if (usdc !== null && usdc !== undefined && Number(usdc) > 0) parts.push(`${Number(usdc).toFixed(2)} USDC`);
|
||||
if (usdt !== null && usdt !== undefined && Number(usdt) > 0) parts.push(`${Number(usdt).toFixed(2)} USDT`);
|
||||
return parts.length ? parts.join(" · ") : "—";
|
||||
// 期权侧顶栏仅 USDC;usdt 参数忽略(USDT 在永续资金/交易账户)
|
||||
if(usdc == null || usdc === "") return "—";
|
||||
const n = Number(usdc);
|
||||
if(Number.isNaN(n)) return "—";
|
||||
return `${n.toFixed(2)} USDC`;
|
||||
}
|
||||
|
||||
function setFundsFieldText(field, text){
|
||||
@@ -1640,8 +1631,23 @@ function setFundsFieldText(field, text){
|
||||
el.innerText = text;
|
||||
});
|
||||
}
|
||||
function applyPerpFundsVisibility(show){
|
||||
const on = show !== false;
|
||||
document.querySelectorAll("[data-perp-funds='1']").forEach((el) => {
|
||||
el.style.display = on ? "" : "none";
|
||||
});
|
||||
}
|
||||
function accountSnapshotFundingMissing(data){
|
||||
if(!data || typeof data !== "object") return true;
|
||||
if(data.show_perp_funds === false){
|
||||
const hasTotal = data.total_funds != null && data.total_funds !== "";
|
||||
const hasOpt =
|
||||
data.options_funding_usdc != null ||
|
||||
data.options_funding_usdt != null ||
|
||||
data.options_trading_usdc != null ||
|
||||
data.options_trading_usdt != null;
|
||||
return !hasTotal && !hasOpt;
|
||||
}
|
||||
const hasFunding = data.funding_usdt != null && data.funding_usdt !== "";
|
||||
const hasTotal = data.total_funds != null && data.total_funds !== "";
|
||||
const hasTrading = data.current_capital != null && data.current_capital !== "";
|
||||
@@ -1650,6 +1656,9 @@ function accountSnapshotFundingMissing(data){
|
||||
let accountSnapshotRetryCount = 0;
|
||||
function applyAccountSnapshot(data){
|
||||
if(!data || typeof data !== "object") return;
|
||||
if(typeof data.show_perp_funds !== "undefined"){
|
||||
applyPerpFundsVisibility(data.show_perp_funds);
|
||||
}
|
||||
if(data.funding_usdt != null && data.funding_usdt !== ""){
|
||||
setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`);
|
||||
}
|
||||
@@ -1702,11 +1711,15 @@ function applyAccountSnapshot(data){
|
||||
if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){
|
||||
TimeCloseUI.paintForceCloseHeader(data.force_close);
|
||||
}
|
||||
if(window.OpenSubmitGate) OpenSubmitGate.apply(data);
|
||||
let canTradeText = "可开仓";
|
||||
if(!data.can_trade){
|
||||
const parts = [];
|
||||
if(data.open_block_note) parts.push(data.open_block_note);
|
||||
if(data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason){
|
||||
parts.push(data.risk_status.reason);
|
||||
if(!data.open_block_note || data.open_block_note.indexOf(data.risk_status.reason) < 0){
|
||||
parts.push(data.risk_status.reason);
|
||||
}
|
||||
}
|
||||
if((data.active_count||0) >= (data.max_active_positions||{{ max_active_positions }})) parts.push(`持仓 ${data.active_count}/${data.max_active_positions}`);
|
||||
const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }});
|
||||
@@ -1791,6 +1804,12 @@ if(fullMarginEl){
|
||||
}
|
||||
|
||||
const sltpModeEl = document.getElementById("sltp-mode");
|
||||
function setOmFieldVisible(inputEl, show){
|
||||
if(!inputEl) return;
|
||||
inputEl.style.display = show ? "" : "none";
|
||||
const wrap = inputEl.closest(".om-field");
|
||||
if(wrap) wrap.style.display = show ? "" : "none";
|
||||
}
|
||||
function toggleSltpMode(){
|
||||
const mode = sltpModeEl ? sltpModeEl.value : "fixed_rr";
|
||||
const slEl = document.getElementById("order-sl");
|
||||
@@ -1801,14 +1820,14 @@ function toggleSltpMode(){
|
||||
if(!slEl || !tpEl || !slPctEl || !tpPctEl){ return; }
|
||||
const pct = mode === "pct";
|
||||
const fixed = mode === "fixed_rr";
|
||||
slEl.style.display = pct ? "none" : "";
|
||||
tpEl.style.display = (pct || fixed) ? "none" : "";
|
||||
if(fixedRrEl) fixedRrEl.style.display = fixed ? "" : "none";
|
||||
setOmFieldVisible(slEl, !pct);
|
||||
setOmFieldVisible(tpEl, !(pct || fixed));
|
||||
setOmFieldVisible(fixedRrEl, fixed);
|
||||
slEl.required = !pct;
|
||||
tpEl.required = !pct && !fixed;
|
||||
if(fixedRrEl) fixedRrEl.required = fixed;
|
||||
slPctEl.style.display = pct ? "" : "none";
|
||||
tpPctEl.style.display = pct ? "" : "none";
|
||||
setOmFieldVisible(slPctEl, pct);
|
||||
setOmFieldVisible(tpPctEl, pct);
|
||||
slPctEl.required = pct;
|
||||
tpPctEl.required = pct;
|
||||
refreshOrderTpPreview();
|
||||
@@ -2012,6 +2031,7 @@ setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }})
|
||||
<script src="/static/records_review_page.js?v=4"></script>
|
||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||
<script src="/static/instance_dashboard.js?v=5"></script>
|
||||
<script src="/static/account_ledger.js?v=1"></script>
|
||||
<script>
|
||||
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
|
||||
{% if page == 'dashboard' %}
|
||||
@@ -2019,7 +2039,12 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (window.InstanceDashboard) InstanceDashboard.init(true);
|
||||
});
|
||||
{% endif %}
|
||||
{% if page == 'account_ledger' %}
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (window.AccountLedgerPage) AccountLedgerPage.boot();
|
||||
});
|
||||
{% endif %}
|
||||
</script>
|
||||
<script src="/static/instance_settings_prefs.js?v=15"></script>
|
||||
<script src="/static/instance_settings_prefs.js?v=21"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -38,11 +38,11 @@
|
||||
{% include 'instance_header_stats.html' %}
|
||||
</div>
|
||||
<div class="instance-header-phone-strip instance-phone-only" aria-label="手机资金摘要">
|
||||
<span class="inst-phone-chip">
|
||||
<span class="inst-phone-chip"{% if not (show_perp_funds|default(true)) %} style="display:none"{% endif %} data-perp-funds="1">
|
||||
<em>交易</em>
|
||||
<b data-funds-field="current-capital">{{ funds_fmt(current_capital) }}U</b>
|
||||
</span>
|
||||
<span class="inst-phone-chip">
|
||||
<span class="inst-phone-chip"{% if not (show_perp_funds|default(true)) %} style="display:none"{% endif %} data-perp-funds="1">
|
||||
<em>资金</em>
|
||||
<b data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</b>
|
||||
</span>
|
||||
|
||||
@@ -24,22 +24,22 @@
|
||||
<div class="label">总资金</div>
|
||||
<div class="value" id="total-funds" data-funds-field="total-funds">{% if total_funds is not none %}{{ funds_fmt(total_funds) }}U{% else %}—{% endif %}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item">
|
||||
<div class="stat-strip-item"{% if not (show_perp_funds|default(true)) %} style="display:none"{% endif %} data-perp-funds="1">
|
||||
<div class="label">资金账户</div>
|
||||
<div class="value" id="total-capital" data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item">
|
||||
<div class="stat-strip-item"{% if not (show_perp_funds|default(true)) %} style="display:none"{% endif %} data-perp-funds="1">
|
||||
<div class="label">交易账户</div>
|
||||
<div class="value" id="current-capital" data-funds-field="current-capital">{{ funds_fmt(current_capital) }}U</div>
|
||||
</div>
|
||||
{% if options_enabled %}
|
||||
<div class="stat-strip-item">
|
||||
<div class="label">期权资金账户</div>
|
||||
<div class="value" id="options-funding-usdc" data-funds-field="options-funding-usdc">{{ options_funding_label(options_funding_usdc, options_funding_usdt) }}</div>
|
||||
<div class="value" id="options-funding-usdc" data-funds-field="options-funding-usdc">{{ options_funding_label(options_funding_usdc) }}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item">
|
||||
<div class="label">期权交易账户</div>
|
||||
<div class="value" id="options-trading-usdc" data-funds-field="options-trading-usdc">{{ options_funding_label(options_trading_usdc, options_trading_usdt) }}</div>
|
||||
<div class="value" id="options-trading-usdc" data-funds-field="options-trading-usdc">{{ options_funding_label(options_trading_usdc) }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="stat-strip-item stat-strip-item--pnl">
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
{# 实盘下单监控 · 开仓表单(实例页与中控嵌入共用) #}
|
||||
<form id="add-order-form" action="/add_order" method="post" class="order-monitor-form" data-risk-percent="{{ risk_percent }}">
|
||||
<div class="om-row om-row-policy">
|
||||
{% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %}
|
||||
{{ trade_policy_symbol('symbol', 'order-symbol') }}
|
||||
{{ trade_policy_direction('direction', 'order-direction') }}
|
||||
<select id="sltp-mode" name="sltp_mode" title="止盈止损模式">
|
||||
<option value="fixed_rr" selected>止盈止损:固定盈亏比</option>
|
||||
<option value="price">止盈止损:价格模式</option>
|
||||
<option value="pct">止盈止损:百分比模式</option>
|
||||
</select>
|
||||
{% from 'order_entry_model_fields.html' import order_entry_type_fields with context %}
|
||||
{{ order_entry_type_fields() }}
|
||||
{% from 'order_leverage_fields.html' import order_leverage_fields with context %}
|
||||
{{ order_leverage_fields() }}
|
||||
</div>
|
||||
|
||||
<div class="om-row om-row-levels">
|
||||
<label class="om-field" id="om-field-sl">
|
||||
<span class="om-field-lab">止损价格</span>
|
||||
<input id="order-sl" name="sl" step="any" placeholder="必填" required>
|
||||
</label>
|
||||
<label class="om-field" id="om-field-rr">
|
||||
<span class="om-field-lab">盈亏比</span>
|
||||
<input id="order-fixed-rr" name="fixed_rr" type="number" min="0.01" step="0.01" placeholder="默认1.5" value="1.5" title="止盈距离=止损距离×盈亏比">
|
||||
</label>
|
||||
<label class="om-field" id="om-field-tp" style="display:none">
|
||||
<span class="om-field-lab">止盈价格</span>
|
||||
<input id="order-tp" name="tgt" step="any" placeholder="止盈价格">
|
||||
</label>
|
||||
<label class="om-field" id="om-field-sl-pct" style="display:none">
|
||||
<span class="om-field-lab">止损%</span>
|
||||
<input id="order-sl-pct" name="sl_pct" type="number" min="0.01" step="0.01" placeholder="止损%">
|
||||
</label>
|
||||
<label class="om-field" id="om-field-tp-pct" style="display:none">
|
||||
<span class="om-field-lab">止盈%</span>
|
||||
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%">
|
||||
</label>
|
||||
<div class="om-live-meta">
|
||||
{% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %}
|
||||
{{ symbol_live_price_hint('order-symbol-live-price', 'order-symbol', 'order-direction') }}
|
||||
<span class="symbol-live-price-note">成交价以交易所回报为准</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="om-row om-row-opts">
|
||||
{% if not intraday_discipline %}
|
||||
<label class="om-check">
|
||||
<input type="checkbox" name="breakeven_enabled" value="1" checked>
|
||||
<span>移动保本</span>
|
||||
</label>
|
||||
<span id="order-time-close-wrap" class="order-time-close-wrap om-time-close">
|
||||
<label class="om-check" style="margin:0">
|
||||
<input type="checkbox" name="time_close_enabled" value="1" id="order-time-close-cb">
|
||||
<span>时间平仓</span>
|
||||
</label>
|
||||
<select name="time_close_hours" id="order-time-close-hours" title="持仓满该时长后自动平仓">
|
||||
<option value="1">1h</option>
|
||||
<option value="2">2h</option>
|
||||
<option value="4" selected>4h</option>
|
||||
</select>
|
||||
</span>
|
||||
{% else %}
|
||||
<input type="hidden" name="breakeven_enabled" value="0">
|
||||
{% endif %}
|
||||
<label class="om-check" title="开仓后生成多周期K线图(各周期100根,含开平仓标记)">
|
||||
<input type="checkbox" name="order_chart" value="true">
|
||||
<span>开仓后生成多周期K线</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="om-row om-row-action">
|
||||
<button type="submit" class="om-submit{% if not can_trade %} is-blocked{% endif %}" id="om-submit-btn"{% if not can_trade %} disabled aria-disabled="true"{% endif %}>{{ open_position_button_label }}</button>
|
||||
<span id="om-open-block-note" class="om-open-block-note"{% if can_trade or not (open_block_note|default('')) %} hidden{% endif %}>{% if not can_trade and (open_block_note|default('')) %}{{ open_block_note }}{% endif %}</span>
|
||||
</div>
|
||||
</form>
|
||||
{% include 'order_plan_preview_bar.html' %}
|
||||
@@ -36,7 +36,7 @@
|
||||
{% include 'password_settings_panel.html' %}
|
||||
{% elif tab.key == 'transfer' %}
|
||||
<h2>永续资金划转</h2>
|
||||
<p class="settings-subcard-desc">子账户永续:资金账户与交易账户之间划转 USDT.</p>
|
||||
<p class="settings-subcard-desc">账户内:资金账户与交易账户之间划转 USDT.</p>
|
||||
{% include 'instance_transfer_panel.html' %}
|
||||
{% elif tab.key == 'export' %}
|
||||
<h2>数据导出</h2>
|
||||
|
||||
@@ -187,7 +187,7 @@ def close_option_by_bid1(
|
||||
max_levels=1,
|
||||
)
|
||||
if preview.get("bid_invalid") or preview.get("auto_close_blocked"):
|
||||
_cancel_sell_pending(ex, inst_id)
|
||||
# 不撤他人挂单:仅拒绝本轮下单
|
||||
update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid)
|
||||
return {
|
||||
"ok": False,
|
||||
@@ -248,8 +248,6 @@ def close_option_by_bid1(
|
||||
"auto_close_blocked": True,
|
||||
"close_gate": gate,
|
||||
}
|
||||
if gate.get("ready"):
|
||||
mark_close_gate_passed(inst_id)
|
||||
|
||||
locked_bid_px = level_px
|
||||
before_avail = avail
|
||||
@@ -272,6 +270,9 @@ def close_option_by_bid1(
|
||||
"locked_bid_px": locked_bid_px,
|
||||
"batch_sheets": level_sheets,
|
||||
}
|
||||
# 仅下单被接受后才记门控已通过,避免下单失败却跳过后续 2× 等待
|
||||
if require_recycle_gate and gate.get("ready"):
|
||||
mark_close_gate_passed(inst_id)
|
||||
|
||||
px = float(order.get("px", locked_bid_px))
|
||||
oid = str((order.get("data") or {}).get("ordId") or "")
|
||||
@@ -279,12 +280,20 @@ def close_option_by_bid1(
|
||||
time.sleep(0.6)
|
||||
invalidate_option_positions_cache()
|
||||
raw2 = cfg["fetch_option_positions"](ex)
|
||||
after_avail = 0
|
||||
if raw2 is not None:
|
||||
after_pos = next((p for p in raw2 if str(p.get("instId")) == inst_id), None)
|
||||
after_avail = _avail_sheets(after_pos) if after_pos else 0
|
||||
reduced = max(0, before_avail - after_avail) if raw2 is not None else 0
|
||||
remaining_pos = after_avail if raw2 is not None else max(0, before_avail - level_sheets)
|
||||
if raw2 is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": "下单后获取持仓失败,未确认是否成交",
|
||||
"stopped_reason": "position_fetch_failed",
|
||||
"locked_bid_px": locked_bid_px,
|
||||
"batch_sheets": level_sheets,
|
||||
"close_ord_id": oid or None,
|
||||
"fully_closed": False,
|
||||
}
|
||||
after_pos = next((p for p in raw2 if str(p.get("instId")) == inst_id), None)
|
||||
after_avail = _avail_sheets(after_pos) if after_pos else 0
|
||||
reduced = max(0, before_avail - after_avail)
|
||||
remaining_pos = after_avail
|
||||
fully_closed = remaining_pos < 1
|
||||
|
||||
if fully_closed:
|
||||
|
||||
@@ -98,6 +98,9 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
|
||||
for ddl in (
|
||||
"ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0",
|
||||
"ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0",
|
||||
"ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0",
|
||||
"ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0",
|
||||
"ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'",
|
||||
):
|
||||
try:
|
||||
conn.execute(ddl)
|
||||
|
||||
@@ -16,6 +16,7 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
if not ok:
|
||||
return {"ok": False, "enabled": True, "msg": reason or "期权 API 未配置"}
|
||||
try:
|
||||
from lib.options.options_position_limit_lib import options_max_active_positions
|
||||
from lib.options.options_positions_lib import build_display_option_positions
|
||||
|
||||
raw = cfg["fetch_option_positions"](ex)
|
||||
@@ -27,18 +28,36 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
|
||||
from lib.options.options_profit_exit_lib import profit_exit_by_inst
|
||||
from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst
|
||||
|
||||
target_monitors = list_active_targets(conn) + list_closing_targets(conn)
|
||||
tgt_map = targets_by_inst(conn)
|
||||
hedge_target_map = active_options_targets_by_inst(conn)
|
||||
profit_exit_map = profit_exit_by_inst(conn)
|
||||
target_monitors.extend(hedge_target_map.values())
|
||||
for pe in profit_exit_map.values():
|
||||
if pe.get("profit_exit_enabled"):
|
||||
target_monitors.append(
|
||||
{
|
||||
"inst_id": pe.get("inst_id"),
|
||||
"exit_mode": "profit_exit",
|
||||
"profit_exit_mult": pe.get("profit_exit_mult"),
|
||||
"profit_exit_enabled": True,
|
||||
}
|
||||
)
|
||||
for p in positions:
|
||||
mon = tgt_map.get(str(p.get("inst_id") or ""))
|
||||
if mon:
|
||||
p["target_index"] = mon.get("target_index")
|
||||
p["target_monitor_id"] = mon.get("id")
|
||||
p["target_monitor"] = mon
|
||||
pe = profit_exit_map.get(str(p.get("inst_id") or ""))
|
||||
if pe:
|
||||
p["profit_exit_enabled"] = pe.get("profit_exit_enabled")
|
||||
p["profit_exit_mult"] = pe.get("profit_exit_mult")
|
||||
p["profit_exit_state"] = pe.get("profit_exit_state")
|
||||
p["profit_exit_required_recycle"] = pe.get("required_recycle")
|
||||
hedge_target = hedge_target_map.get(str(p.get("inst_id") or ""))
|
||||
if hedge_target:
|
||||
p["hedge_plan_target"] = hedge_target
|
||||
@@ -65,17 +84,17 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
conn.close()
|
||||
except Exception:
|
||||
target_monitors = []
|
||||
from lib.options.options_positions_lib import net_pnl_from_display_row
|
||||
from lib.options.options_positions_lib import display_pnl_from_option_row
|
||||
|
||||
upl_total = 0.0
|
||||
has_upl = False
|
||||
for p in positions:
|
||||
# 与持仓卡「净盈亏」一致(买一回收−权利金);不用交易所标记价 upl
|
||||
net = net_pnl_from_display_row(p)
|
||||
if net is None:
|
||||
# 与持仓卡展示一致:优先买一净盈亏,残档回退交易所 upl
|
||||
pnl = display_pnl_from_option_row(p)
|
||||
if pnl is None:
|
||||
continue
|
||||
has_upl = True
|
||||
upl_total += float(net)
|
||||
upl_total += float(pnl)
|
||||
bal = cfg["fetch_options_balances"](ex)
|
||||
return {
|
||||
"ok": True,
|
||||
@@ -93,6 +112,7 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
"stats": {},
|
||||
"trade_budget": cfg.get("trade_budget"),
|
||||
"account_label": cfg.get("account_label") or "OKX期权",
|
||||
"max_active_positions": options_max_active_positions(),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "enabled": True, "msg": str(e)}
|
||||
|
||||
@@ -142,11 +142,13 @@ def _created_at_ms(created_at: Any) -> int | None:
|
||||
|
||||
def _group_key_for_closed_trade(row: Any) -> str:
|
||||
inst = str(row["inst_id"] or "").strip()
|
||||
ord_id = str(row["close_ord_id"] or "").strip() if "close_ord_id" in row.keys() else ""
|
||||
if ord_id:
|
||||
return f"{inst}|ord:{ord_id}"
|
||||
closed = str(row["closed_at"] or "").strip()
|
||||
return f"{inst}|close:{(closed[:16] if closed else '')}"
|
||||
close_prefix = closed[:16] if closed else ""
|
||||
ord_id = str(row["close_ord_id"] or "").strip() if "close_ord_id" in row.keys() else ""
|
||||
# 即使 close_ord_id/posId 相同,也要按平仓时间拆开(OKX 可能复用 posId)
|
||||
if ord_id:
|
||||
return f"{inst}|ord:{ord_id}|close:{close_prefix}"
|
||||
return f"{inst}|close:{close_prefix}"
|
||||
|
||||
|
||||
def backfill_closed_options_realized_pnl_from_history(
|
||||
@@ -170,7 +172,8 @@ def backfill_closed_options_realized_pnl_from_history(
|
||||
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, inst_id, sheets, premium_paid, realized_pnl, created_at, closed_at, close_ord_id
|
||||
SELECT id, inst_id, sheets, premium_paid, realized_pnl, close_quote,
|
||||
created_at, closed_at, close_ord_id
|
||||
FROM options_trades
|
||||
WHERE status = 'closed'
|
||||
ORDER BY id DESC
|
||||
@@ -193,13 +196,26 @@ def backfill_closed_options_realized_pnl_from_history(
|
||||
inst = str(group[0]["inst_id"] or "").strip()
|
||||
open_candidates = [_created_at_ms(r["created_at"]) for r in group]
|
||||
open_ms = min((x for x in open_candidates if x is not None), default=None)
|
||||
close_info = resolve_option_close_from_history(by_inst.get(inst) or [], open_ms=open_ms)
|
||||
close_candidates = [_created_at_ms(r["closed_at"]) for r in group]
|
||||
close_ms = max((x for x in close_candidates if x is not None), default=None)
|
||||
sheets_hint = None
|
||||
try:
|
||||
sheets_hint = sum(float(_safe_float(r["sheets"]) or 0.0) for r in group) or None
|
||||
except (TypeError, ValueError):
|
||||
sheets_hint = None
|
||||
close_info = resolve_option_close_from_history(
|
||||
by_inst.get(inst) or [],
|
||||
open_ms=open_ms,
|
||||
close_ms=close_ms,
|
||||
sheets=sheets_hint,
|
||||
)
|
||||
if not close_info:
|
||||
continue
|
||||
ex_pnl = _safe_float(close_info.get("realized_pnl"))
|
||||
if ex_pnl is None:
|
||||
continue
|
||||
close_quote = _safe_float(close_info.get("close_quote"))
|
||||
matched_pos = str(close_info.get("pos_id") or "").strip() or None
|
||||
total_paid = 0.0
|
||||
for r in group:
|
||||
total_paid += float(_safe_float(r["premium_paid"]) or 0.0)
|
||||
@@ -215,7 +231,14 @@ def backfill_closed_options_realized_pnl_from_history(
|
||||
share = round(float(ex_pnl) / len(group), 4)
|
||||
allocated += share
|
||||
local = _safe_float(r["realized_pnl"])
|
||||
if local is not None and abs(local - share) < 1e-6:
|
||||
local_close = _safe_float(r["close_quote"])
|
||||
local_ord = str(r["close_ord_id"] or "").strip()
|
||||
pnl_ok = local is not None and abs(local - share) < 1e-6
|
||||
quote_ok = close_quote is None or (
|
||||
local_close is not None and abs(local_close - float(close_quote)) < 1e-6
|
||||
)
|
||||
ord_ok = (not matched_pos) or (local_ord == matched_pos)
|
||||
if pnl_ok and quote_ok and ord_ok:
|
||||
continue
|
||||
prem_recv = round(paid + share, 4)
|
||||
conn.execute(
|
||||
@@ -223,10 +246,11 @@ def backfill_closed_options_realized_pnl_from_history(
|
||||
UPDATE options_trades
|
||||
SET realized_pnl = ?,
|
||||
premium_received = ?,
|
||||
close_quote = COALESCE(?, close_quote)
|
||||
close_quote = COALESCE(?, close_quote),
|
||||
close_ord_id = COALESCE(?, close_ord_id)
|
||||
WHERE id = ?
|
||||
""",
|
||||
(share, prem_recv, close_quote, int(r["id"])),
|
||||
(share, prem_recv, close_quote, matched_pos, int(r["id"])),
|
||||
)
|
||||
updated += 1
|
||||
return updated
|
||||
@@ -404,6 +428,8 @@ def options_monitor_loop(
|
||||
profit_ratio: float,
|
||||
sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None,
|
||||
target_close_fn: Callable[[str], dict[str, Any]] | None = None,
|
||||
profit_exit_close_fn: Callable[[str], dict[str, Any]] | None = None,
|
||||
profit_exit_cfg: dict[str, Any] | None = None,
|
||||
stale_pending_fn: Callable[[], dict[str, Any]] | None = None,
|
||||
stop_event: Any = None,
|
||||
) -> None:
|
||||
@@ -435,6 +461,21 @@ def options_monitor_loop(
|
||||
account_label=account_label,
|
||||
cfg={"send_wechat": send_wechat, "account_label": account_label},
|
||||
)
|
||||
if profit_exit_close_fn is not None:
|
||||
from lib.options.options_profit_exit_lib import run_options_profit_exits
|
||||
|
||||
pe_cfg = dict(profit_exit_cfg or {})
|
||||
pe_cfg.setdefault("send_wechat", send_wechat)
|
||||
pe_cfg.setdefault("account_label", account_label)
|
||||
run_options_profit_exits(
|
||||
conn,
|
||||
positions,
|
||||
close_fn=profit_exit_close_fn,
|
||||
send_wechat=send_wechat,
|
||||
account_label=account_label,
|
||||
cfg=pe_cfg,
|
||||
ex=pe_cfg.get("exchange_options"),
|
||||
)
|
||||
if sync_trades_fn is not None:
|
||||
sync_trades_fn(conn)
|
||||
conn.commit()
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""OKX 期权持仓笔数上限(env: OKX_OPTIONS_MAX_ACTIVE_POSITIONS)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Optional, Sequence
|
||||
|
||||
|
||||
def options_max_active_positions() -> int:
|
||||
"""同时持有的期权合约笔数上限;0=不限制.热更读 env."""
|
||||
raw = os.getenv("OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "0")
|
||||
try:
|
||||
v = int(float(str(raw).strip()))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return max(0, v)
|
||||
|
||||
|
||||
def count_live_option_positions(rows: Optional[list[dict[str, Any]]]) -> int:
|
||||
if not rows:
|
||||
return 0
|
||||
n = 0
|
||||
for r in rows:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
try:
|
||||
pos = float(r.get("pos") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if abs(pos) >= 1e-12:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def _inst_already_open(rows: list[dict[str, Any]], inst_id: str) -> bool:
|
||||
want = (inst_id or "").strip()
|
||||
if not want:
|
||||
return False
|
||||
for r in rows:
|
||||
if str(r.get("instId") or r.get("inst_id") or "").strip() == want:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_inst_ids(
|
||||
opening_inst_id: str = "",
|
||||
opening_inst_ids: Optional[Sequence[str]] = None,
|
||||
) -> list[str]:
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in list(opening_inst_ids or []) + ([opening_inst_id] if opening_inst_id else []):
|
||||
iid = str(raw or "").strip()
|
||||
if not iid or iid in seen:
|
||||
continue
|
||||
seen.add(iid)
|
||||
out.append(iid)
|
||||
return out
|
||||
|
||||
|
||||
def option_position_limit_block_msg(
|
||||
ex: Any,
|
||||
*,
|
||||
opening_inst_id: str = "",
|
||||
opening_inst_ids: Optional[Sequence[str]] = None,
|
||||
new_positions: Optional[int] = None,
|
||||
max_active: Optional[int] = None,
|
||||
fetch_positions=None,
|
||||
) -> Optional[str]:
|
||||
"""若禁止新开买期权则返回中文原因,否则 None.
|
||||
|
||||
- max_active<=0:不限制
|
||||
- opening_inst_ids:本次要开的合约;已在持仓中的不占新笔数
|
||||
- new_positions:显式指定还需新占几笔(默认按 opening_inst_ids 推算)
|
||||
- 期期两腿应一次传入两个 inst_id,在开仓前预检,避免上限=1 时开出半边仓
|
||||
- 拉持仓失败:拒绝开仓(避免绕过上限)
|
||||
"""
|
||||
try:
|
||||
from lib.hedge_plan.okx_trade_mode_lib import standalone_options_open_allowed
|
||||
|
||||
# 对冲模式用 MAX_ACTIVE_HEDGE_PLANS 管「组数」,不占用期权笔数上限
|
||||
if max_active is None and not standalone_options_open_allowed():
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
mx = options_max_active_positions() if max_active is None else int(max_active)
|
||||
if mx <= 0:
|
||||
return None
|
||||
fetch = fetch_positions
|
||||
if fetch is None:
|
||||
from lib.exchange.okx_options_lib import fetch_option_positions
|
||||
|
||||
fetch = fetch_option_positions
|
||||
try:
|
||||
rows = fetch(ex)
|
||||
except Exception:
|
||||
rows = None
|
||||
if rows is None:
|
||||
return f"无法获取期权持仓,暂不可开仓(上限 {mx} 笔)"
|
||||
active = count_live_option_positions(rows)
|
||||
ids = _normalize_inst_ids(opening_inst_id, opening_inst_ids)
|
||||
|
||||
if new_positions is None:
|
||||
if ids:
|
||||
already = sum(1 for i in ids if _inst_already_open(rows, i))
|
||||
need = max(0, len(ids) - already)
|
||||
else:
|
||||
need = 1
|
||||
else:
|
||||
need = max(0, int(new_positions))
|
||||
if need <= 1 and len(ids) == 1 and _inst_already_open(rows, ids[0]):
|
||||
return None
|
||||
|
||||
if need <= 0:
|
||||
return None
|
||||
if active + need <= mx:
|
||||
return None
|
||||
if need >= 2:
|
||||
return (
|
||||
f"期期对冲需新开 {need} 笔期权,当前已有 {active} 笔、上限 {mx};"
|
||||
f"请将 OKX_OPTIONS_MAX_ACTIVE_POSITIONS 设为 0(不限制)或不小于 {active + need},或先平仓"
|
||||
)
|
||||
return f"期权持仓已达上限({active}/{mx}),请先平仓后再开"
|
||||
|
||||
|
||||
def compound_full_single_position_block_msg(
|
||||
ex: Any,
|
||||
*,
|
||||
fetch_positions=None,
|
||||
) -> Optional[str]:
|
||||
"""全仓复利:账户内已有任意期权持仓则禁止再开(仅允许 1 笔)."""
|
||||
fetch = fetch_positions
|
||||
if fetch is None:
|
||||
from lib.exchange.okx_options_lib import fetch_option_positions
|
||||
|
||||
fetch = fetch_option_positions
|
||||
try:
|
||||
rows = fetch(ex)
|
||||
except Exception:
|
||||
rows = None
|
||||
if rows is None:
|
||||
return "无法获取期权持仓,全仓复利模式暂不可开仓"
|
||||
active = count_live_option_positions(rows)
|
||||
if active >= 1:
|
||||
return f"全仓复利模式仅允许同时持有 1 笔仓位(当前 {active} 笔),请先平仓"
|
||||
return None
|
||||
@@ -92,13 +92,26 @@ def net_pnl_from_display_row(row: dict[str, Any]) -> float | None:
|
||||
return float(net)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
# 仅当实际吃到买盘张数时,才用 total_received − 权利金(避免 bid 无效时 total_received=0 算出 −权利金假亏)
|
||||
try:
|
||||
covered = float(preview.get("covered_sheets") or 0)
|
||||
except (TypeError, ValueError):
|
||||
covered = 0.0
|
||||
recv = _safe_float(preview.get("total_received"))
|
||||
paid = _safe_float(row.get("premium_paid"))
|
||||
if recv is not None and paid is not None:
|
||||
if covered > 0 and recv is not None and paid is not None:
|
||||
return round(recv - paid, 4)
|
||||
return None
|
||||
|
||||
|
||||
def display_pnl_from_option_row(row: dict[str, Any]) -> float | None:
|
||||
"""展示用盈亏:优先买一净盈亏;残档/无买一时回退交易所标记浮盈 upl."""
|
||||
net = net_pnl_from_display_row(row)
|
||||
if net is not None:
|
||||
return net
|
||||
return _safe_float(row.get("upl"))
|
||||
|
||||
|
||||
def sum_options_net_pnl_usdc(
|
||||
cfg: dict[str, Any],
|
||||
ex: Any,
|
||||
@@ -106,7 +119,8 @@ def sum_options_net_pnl_usdc(
|
||||
) -> float | None:
|
||||
"""
|
||||
期权浮盈合计(USDC),与顶栏实时盈亏/中控口径对齐为「净盈亏」:
|
||||
各仓买一可回收 − 权利金之和.获取失败返回 None;无持仓返回 0.
|
||||
各仓买一可回收 − 权利金之和;残档则回退该仓交易所 upl.
|
||||
获取失败返回 None;无持仓返回 0.
|
||||
"""
|
||||
raw = raw_positions
|
||||
if raw is None:
|
||||
@@ -119,11 +133,11 @@ def sum_options_net_pnl_usdc(
|
||||
total = 0.0
|
||||
found = False
|
||||
for p in positions:
|
||||
net = net_pnl_from_display_row(p)
|
||||
if net is None:
|
||||
pnl = display_pnl_from_option_row(p)
|
||||
if pnl is None:
|
||||
continue
|
||||
found = True
|
||||
total += float(net)
|
||||
total += float(pnl)
|
||||
return round(total, 4) if found else (0.0 if not positions else None)
|
||||
|
||||
|
||||
|
||||
@@ -264,6 +264,25 @@ def resolve_budget_full_usdc(trading_usdc: float, trade_budget_usdc: float) -> f
|
||||
return min(float(trading_usdc), float(trade_budget_usdc))
|
||||
|
||||
|
||||
def resolve_compound_full_usdc(
|
||||
trading_usdc: float,
|
||||
*,
|
||||
cap_enabled: bool = False,
|
||||
cap_usdc: float | None = None,
|
||||
) -> float:
|
||||
"""全仓复利:默认用期权交易户全部可用;上限开关开启时再封顶."""
|
||||
bal = max(0.0, float(trading_usdc or 0))
|
||||
if not cap_enabled:
|
||||
return bal
|
||||
try:
|
||||
cap = float(cap_usdc) if cap_usdc is not None else 0.0
|
||||
except (TypeError, ValueError):
|
||||
cap = 0.0
|
||||
if cap <= 0:
|
||||
return bal
|
||||
return min(bal, cap)
|
||||
|
||||
|
||||
def calc_order_size(
|
||||
*,
|
||||
quote_per_unit: float,
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
"""单独期权翻倍出场:盈利达权利金×倍数后按买一限价平仓.
|
||||
|
||||
1 倍 = 盈利金额等于初始权利金 ⇒ 买一可回收 ≥ 权利金 × (1 + 倍数).
|
||||
与「目标位」并行;与仅微信提醒的 OKX_OPTIONS_PROFIT_ALERT_RATIO 独立.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any, Callable
|
||||
|
||||
from lib.options.options_db import init_options_tables, sum_open_premium_paid
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> float | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def ensure_profit_exit_columns(conn: sqlite3.Connection) -> None:
|
||||
init_options_tables(conn)
|
||||
for ddl in (
|
||||
"ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0",
|
||||
"ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0",
|
||||
"ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'",
|
||||
):
|
||||
try:
|
||||
conn.execute(ddl)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def normalize_profit_exit_mult(raw: Any, *, default: float = 1.0) -> float:
|
||||
try:
|
||||
mult = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
mult = float(default)
|
||||
if mult <= 0:
|
||||
mult = float(default)
|
||||
return round(mult, 4)
|
||||
|
||||
|
||||
def profit_exit_hit(
|
||||
*,
|
||||
premium_paid: float,
|
||||
recycle_usdc: float,
|
||||
mult: float,
|
||||
) -> bool:
|
||||
"""1倍:盈利=权利金 ⇒ recycle ≥ premium×(1+mult)."""
|
||||
prem = float(premium_paid or 0)
|
||||
recv = float(recycle_usdc or 0)
|
||||
m = float(mult or 0)
|
||||
if prem <= 0 or m <= 0 or recv <= 0:
|
||||
return False
|
||||
return recv + 1e-9 >= prem * (1.0 + m)
|
||||
|
||||
|
||||
def required_recycle_usdc(premium_paid: float, mult: float) -> float | None:
|
||||
prem = float(premium_paid or 0)
|
||||
m = float(mult or 0)
|
||||
if prem <= 0 or m <= 0:
|
||||
return None
|
||||
return round(prem * (1.0 + m), 4)
|
||||
|
||||
|
||||
def set_profit_exit(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
inst_id: str,
|
||||
enabled: bool,
|
||||
mult: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ensure_profit_exit_columns(conn)
|
||||
inst = (inst_id or "").strip()
|
||||
if not inst:
|
||||
return {"ok": False, "msg": "缺少 inst_id"}
|
||||
m = normalize_profit_exit_mult(mult if mult is not None else 1.0)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id FROM options_trades
|
||||
WHERE inst_id = ? AND status = 'open'
|
||||
""",
|
||||
(inst,),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return {"ok": False, "msg": "未找到该合约的本地开仓记录"}
|
||||
if enabled:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET profit_exit_enabled = 1,
|
||||
profit_exit_mult = ?,
|
||||
profit_exit_state = 'active'
|
||||
WHERE inst_id = ? AND status = 'open'
|
||||
""",
|
||||
(m, inst),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET profit_exit_enabled = 0,
|
||||
profit_exit_state = 'idle'
|
||||
WHERE inst_id = ? AND status = 'open'
|
||||
""",
|
||||
(inst,),
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"inst_id": inst,
|
||||
"profit_exit_enabled": bool(enabled),
|
||||
"profit_exit_mult": m if enabled else None,
|
||||
"updated": len(rows),
|
||||
}
|
||||
|
||||
|
||||
def profit_exit_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
|
||||
"""进行中(active/closing)的翻倍出场,按合约取最新一条规则."""
|
||||
ensure_profit_exit_columns(conn)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT inst_id, profit_exit_enabled, profit_exit_mult, profit_exit_state
|
||||
FROM options_trades
|
||||
WHERE status = 'open'
|
||||
AND (
|
||||
CAST(COALESCE(profit_exit_enabled, 0) AS INTEGER) = 1
|
||||
OR COALESCE(profit_exit_state, 'idle') IN ('active', 'closing')
|
||||
)
|
||||
ORDER BY id DESC
|
||||
"""
|
||||
).fetchall()
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for r in rows:
|
||||
inst = str(r["inst_id"] or "").strip()
|
||||
if not inst or inst in out:
|
||||
continue
|
||||
enabled = int(r["profit_exit_enabled"] or 0) == 1
|
||||
state = str(r["profit_exit_state"] or "idle")
|
||||
if not enabled and state not in ("active", "closing"):
|
||||
continue
|
||||
mult = normalize_profit_exit_mult(r["profit_exit_mult"], default=1.0)
|
||||
out[inst] = {
|
||||
"inst_id": inst,
|
||||
"profit_exit_enabled": enabled or state in ("active", "closing"),
|
||||
"profit_exit_mult": mult,
|
||||
"profit_exit_state": state if state in ("active", "closing") else ("active" if enabled else "idle"),
|
||||
"required_recycle": None,
|
||||
}
|
||||
for inst, info in out.items():
|
||||
prem = sum_open_premium_paid(conn, inst)
|
||||
if prem is not None:
|
||||
info["premium_paid"] = prem
|
||||
info["required_recycle"] = required_recycle_usdc(prem, float(info["profit_exit_mult"]))
|
||||
return out
|
||||
|
||||
|
||||
def _mark_state(conn: sqlite3.Connection, inst_id: str, state: str) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET profit_exit_state = ?
|
||||
WHERE inst_id = ? AND status = 'open'
|
||||
""",
|
||||
(state, inst_id),
|
||||
)
|
||||
|
||||
|
||||
def _commit(conn: sqlite3.Connection) -> None:
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _result_fully_done(result: dict[str, Any]) -> bool:
|
||||
if result.get("already_flat"):
|
||||
return True
|
||||
if result.get("fully_closed"):
|
||||
return True
|
||||
remaining = result.get("remaining_sheets")
|
||||
if remaining is not None and int(remaining) <= 0 and result.get("ok"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def close_option_by_bid_profit_exit(
|
||||
cfg: dict[str, Any],
|
||||
ex: Any,
|
||||
inst_id: str,
|
||||
*,
|
||||
sheets: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from lib.options.options_close_exec_lib import close_option_by_bid1
|
||||
|
||||
return close_option_by_bid1(
|
||||
cfg,
|
||||
ex,
|
||||
inst_id,
|
||||
sheets=sheets,
|
||||
require_recycle_gate=False,
|
||||
signal_note="翻倍出场",
|
||||
)
|
||||
|
||||
|
||||
def _estimate_recycle(
|
||||
cfg: dict[str, Any],
|
||||
ex: Any,
|
||||
pos: dict[str, Any],
|
||||
premium_paid: float | None,
|
||||
) -> float | None:
|
||||
from lib.options.options_positions_lib import attach_close_preview
|
||||
|
||||
row = dict(pos)
|
||||
attach_close_preview(cfg, ex, row, premium_paid=premium_paid)
|
||||
preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {}
|
||||
if preview.get("bid_invalid"):
|
||||
return None
|
||||
return _safe_float(preview.get("total_received"))
|
||||
|
||||
|
||||
def _notify_profit_exit_close(
|
||||
cfg: dict[str, Any] | None,
|
||||
send_wechat: Callable[[str], None] | None,
|
||||
*,
|
||||
account_label: str,
|
||||
inst_id: str,
|
||||
mult: float,
|
||||
premium_paid: float | None,
|
||||
recycle: float | None,
|
||||
result: dict[str, Any],
|
||||
conn: Any = None,
|
||||
) -> None:
|
||||
if result.get("fully_closed") or result.get("already_flat"):
|
||||
if cfg is not None:
|
||||
try:
|
||||
from lib.options.options_notify_lib import notify_options_close
|
||||
|
||||
notify_options_close(
|
||||
cfg,
|
||||
conn,
|
||||
inst_id=inst_id,
|
||||
reason=f"翻倍出场({mult:g}倍)",
|
||||
sheets=result.get("submitted_sheets"),
|
||||
premium_received=result.get("premium_received"),
|
||||
close_quote=result.get("locked_bid_px") or result.get("bid"),
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
if not send_wechat:
|
||||
return
|
||||
try:
|
||||
send_wechat(
|
||||
"\n".join(
|
||||
[
|
||||
"【OKX期权·翻倍出场】",
|
||||
f"账户:{account_label}",
|
||||
f"合约:{inst_id}",
|
||||
f"倍数:{mult:g}(1倍=盈利=权利金)",
|
||||
f"权利金:{premium_paid if premium_paid is not None else '—'}",
|
||||
f"可回收:{recycle if recycle is not None else '—'}",
|
||||
f"提交张数:{result.get('submitted_sheets') or '—'}",
|
||||
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
|
||||
]
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_options_profit_exits(
|
||||
conn: sqlite3.Connection,
|
||||
positions: list[dict[str, Any]],
|
||||
*,
|
||||
close_fn: Callable[[str], dict[str, Any]],
|
||||
recycle_fn: Callable[[dict[str, Any], float | None], float | None] | None = None,
|
||||
send_wechat: Callable[[str], None] | None = None,
|
||||
account_label: str = "OKX期权",
|
||||
cfg: dict[str, Any] | None = None,
|
||||
ex: Any = None,
|
||||
) -> int:
|
||||
"""扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数."""
|
||||
ensure_profit_exit_columns(conn)
|
||||
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
|
||||
hedge_managed: set[str] = set()
|
||||
try:
|
||||
from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables
|
||||
|
||||
init_hedge_plan_tables(conn)
|
||||
hedge_managed = active_hedge_option_inst_ids(conn)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
rules = profit_exit_by_inst(conn)
|
||||
triggered = 0
|
||||
|
||||
for inst_id, info in list(rules.items()):
|
||||
if not inst_id:
|
||||
continue
|
||||
if inst_id in hedge_managed:
|
||||
_mark_state(conn, inst_id, "idle")
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET profit_exit_enabled = 0, profit_exit_state = 'idle'
|
||||
WHERE inst_id = ? AND status = 'open'
|
||||
""",
|
||||
(inst_id,),
|
||||
)
|
||||
_commit(conn)
|
||||
continue
|
||||
pos = pos_by_inst.get(inst_id)
|
||||
if not pos:
|
||||
# 持仓已平:收尾
|
||||
_mark_state(conn, inst_id, "done")
|
||||
_commit(conn)
|
||||
continue
|
||||
|
||||
state = str(info.get("profit_exit_state") or "active")
|
||||
mult = normalize_profit_exit_mult(info.get("profit_exit_mult"), default=1.0)
|
||||
prem = sum_open_premium_paid(conn, inst_id)
|
||||
if prem is None or prem <= 0:
|
||||
continue
|
||||
|
||||
if state == "closing":
|
||||
result = close_fn(inst_id)
|
||||
if result.get("already_flat") or _result_fully_done(result):
|
||||
_mark_state(conn, inst_id, "done")
|
||||
_commit(conn)
|
||||
else:
|
||||
_mark_state(conn, inst_id, "closing")
|
||||
_commit(conn)
|
||||
continue
|
||||
|
||||
if not info.get("profit_exit_enabled"):
|
||||
continue
|
||||
|
||||
if recycle_fn is not None:
|
||||
recycle = recycle_fn(pos, prem)
|
||||
elif cfg is not None and ex is not None:
|
||||
recycle = _estimate_recycle(cfg, ex, pos, prem)
|
||||
else:
|
||||
continue
|
||||
if recycle is None:
|
||||
continue
|
||||
if not profit_exit_hit(premium_paid=prem, recycle_usdc=recycle, mult=mult):
|
||||
continue
|
||||
|
||||
result = close_fn(inst_id)
|
||||
if result.get("already_flat"):
|
||||
_mark_state(conn, inst_id, "done")
|
||||
_commit(conn)
|
||||
continue
|
||||
if not result.get("ok"):
|
||||
_mark_state(conn, inst_id, "active")
|
||||
_commit(conn)
|
||||
continue
|
||||
|
||||
done = _result_fully_done(result)
|
||||
_mark_state(conn, inst_id, "done" if done else "closing")
|
||||
_commit(conn)
|
||||
triggered += 1
|
||||
_notify_profit_exit_close(
|
||||
cfg,
|
||||
send_wechat,
|
||||
account_label=account_label,
|
||||
inst_id=inst_id,
|
||||
mult=mult,
|
||||
premium_paid=prem,
|
||||
recycle=recycle,
|
||||
result=result,
|
||||
conn=conn,
|
||||
)
|
||||
return triggered
|
||||
+505
-86
@@ -92,12 +92,10 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
|
||||
quote_option_contract,
|
||||
spot_market_swap_usdt_usdc,
|
||||
transfer_ccy,
|
||||
transfer_main_sub_account,
|
||||
)
|
||||
|
||||
return {
|
||||
"enabled": _env_bool("OKX_OPTIONS_ENABLED", False),
|
||||
"sub_account_name": (os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip(),
|
||||
"get_db": app_module.get_db,
|
||||
"login_required": app_module.login_required,
|
||||
"exchange_options": getattr(app_module, "exchange_options", None),
|
||||
@@ -105,6 +103,9 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
|
||||
"render_main_page": app_module.render_main_page,
|
||||
"trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0),
|
||||
"budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95),
|
||||
"compound_full_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True),
|
||||
"compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False),
|
||||
"compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0),
|
||||
"default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(),
|
||||
"max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0),
|
||||
"chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0),
|
||||
@@ -132,7 +133,6 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
|
||||
"execute_convert": execute_convert,
|
||||
"transfer_ccy": transfer_ccy,
|
||||
"spot_market_swap_usdt_usdc": spot_market_swap_usdt_usdc,
|
||||
"transfer_main_sub_account": transfer_main_sub_account,
|
||||
"options_api_ready": options_api_ready,
|
||||
"app_module": app_module,
|
||||
}
|
||||
@@ -177,6 +177,69 @@ def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]:
|
||||
return resolve_budget_full_usdc(trading, float(cap)), ""
|
||||
|
||||
|
||||
def _compound_full_enabled() -> bool:
|
||||
return _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True)
|
||||
|
||||
|
||||
def _budget_full_blocked_by_compound_msg() -> str | None:
|
||||
if _compound_full_enabled():
|
||||
return "全仓复利已开启,不可使用单笔预算/打满;请关闭全仓复利或改用全仓复利模式"
|
||||
return None
|
||||
|
||||
|
||||
def _size_mode_budget_cap(
|
||||
cfg: dict[str, Any], mode: str, budget_cap: float | None
|
||||
) -> float | None:
|
||||
"""全仓复利开启时禁用单笔预算封顶(sheets/eth 也不再受 trade_budget 限制)."""
|
||||
if mode in ("budget_full", "compound_full"):
|
||||
return budget_cap
|
||||
if mode in ("sheets", "eth_amount"):
|
||||
if _compound_full_enabled():
|
||||
return None
|
||||
return budget_cap
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_size_mode(mode: str) -> tuple[str, str | None]:
|
||||
"""全仓复利关闭时强制离开 compound_full,避免前端残留选中导致无法开仓."""
|
||||
m = (mode or "sheets").strip() or "sheets"
|
||||
if m == "compound_full" and not _compound_full_enabled():
|
||||
return "sheets", "全仓复利已关闭,已改用指定张数"
|
||||
if m == "budget_full" and _compound_full_enabled():
|
||||
return "compound_full", None
|
||||
return m, None
|
||||
|
||||
|
||||
def _compound_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]:
|
||||
"""全仓复利 = 期权交易户可用(可选上限封顶);再由 calc_order_size × budget_buffer."""
|
||||
if not _compound_full_enabled():
|
||||
return None, "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)"
|
||||
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
|
||||
from lib.options.options_pricing_lib import resolve_compound_full_usdc
|
||||
|
||||
raw = fetch_options_trading_usdc(ex)
|
||||
if raw is None or float(raw) <= 0:
|
||||
return None, "交易账户 USDC 可用余额不足"
|
||||
trading = float(raw)
|
||||
# 额度热更读 env(与模板启动值无关)
|
||||
cap_on = _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False)
|
||||
cap_v = _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0)
|
||||
if cap_on and cap_v <= 0:
|
||||
return None, "全仓上限无效(OKX_OPTIONS_COMPOUND_FULL_CAP_USDC)"
|
||||
return (
|
||||
resolve_compound_full_usdc(
|
||||
trading,
|
||||
cap_enabled=cap_on,
|
||||
cap_usdc=cap_v,
|
||||
),
|
||||
"",
|
||||
)
|
||||
|
||||
|
||||
def _is_budget_mode(mode: str) -> bool:
|
||||
return mode in ("budget_full", "compound_full")
|
||||
|
||||
|
||||
def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None:
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
@@ -357,14 +420,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
if ex is None:
|
||||
return jsonify({"ok": False, "msg": err})
|
||||
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
|
||||
scope = (request.args.get("scope") or "main").strip().lower()
|
||||
bal = cfg["fetch_options_balances"](
|
||||
ex,
|
||||
force=force,
|
||||
scope=scope,
|
||||
sub_acct=cfg.get("sub_account_name") or "",
|
||||
bal = cfg["fetch_options_balances"](ex, force=force, scope="main")
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
**bal,
|
||||
"trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10)),
|
||||
"compound_full_enabled": _compound_full_enabled(),
|
||||
"compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False),
|
||||
"compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0),
|
||||
}
|
||||
)
|
||||
return jsonify({"ok": True, **bal, "trade_budget": cfg["trade_budget"]})
|
||||
|
||||
@app.route("/api/options/chain")
|
||||
@lr
|
||||
@@ -373,11 +439,13 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
if ex is None:
|
||||
return jsonify({"ok": False, "msg": err})
|
||||
u = (request.args.get("underlying") or cfg["default_underly"]).upper()
|
||||
# 热更新:链展示天数每次读 env,保存后刷新链即可
|
||||
chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14))
|
||||
try:
|
||||
chain = cfg["build_option_chain"](
|
||||
ex,
|
||||
u,
|
||||
max_dte_days=cfg["chain_max_dte_days"],
|
||||
max_dte_days=chain_max_dte,
|
||||
itm_only=False,
|
||||
itm_max_dist_usd=cfg["itm_max_dist"],
|
||||
)
|
||||
@@ -394,7 +462,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
"ok": False,
|
||||
"msg": chain_err or "暂无到期日,请稍后点「刷新链」",
|
||||
**chain,
|
||||
"chain_max_dte_days": cfg["chain_max_dte_days"],
|
||||
"chain_max_dte_days": chain_max_dte,
|
||||
"ask_liq_filter_enabled": ask_liq_filter,
|
||||
"budget_buffer": budget_buffer,
|
||||
"trade_budget": cfg["trade_budget"],
|
||||
@@ -404,7 +472,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
{
|
||||
"ok": True,
|
||||
**chain,
|
||||
"chain_max_dte_days": cfg["chain_max_dte_days"],
|
||||
"chain_max_dte_days": chain_max_dte,
|
||||
"ask_liq_filter_enabled": ask_liq_filter,
|
||||
"budget_buffer": budget_buffer,
|
||||
"trade_budget": cfg["trade_budget"],
|
||||
@@ -426,7 +494,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
ask = q.get("ask")
|
||||
ct_mult = q.get("ct_mult") or 0.01
|
||||
min_sz = q.get("min_sz") or 1
|
||||
mode = (request.args.get("mode") or "budget_full").strip()
|
||||
mode = (request.args.get("mode") or "sheets").strip()
|
||||
sheet_count = None
|
||||
try:
|
||||
if request.args.get("sheets"):
|
||||
@@ -437,17 +505,45 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
paid = _open_premium_paid(cfg, inst_id)
|
||||
target = sheet_count if sheet_count is not None else 0
|
||||
return jsonify(_attach_close_preview(cfg, ex, {**q, "pos": target, "premium_paid": paid}, sheets=target, premium_paid=paid))
|
||||
mode, mode_note = _normalize_size_mode(mode)
|
||||
budget = cfg["trade_budget"]
|
||||
budget_cap = cfg["trade_budget"]
|
||||
available_usdc = None
|
||||
if mode == "budget_full":
|
||||
blocked = _budget_full_blocked_by_compound_msg()
|
||||
if blocked:
|
||||
return jsonify(
|
||||
{
|
||||
"ok": False,
|
||||
"msg": blocked,
|
||||
"compound_full_enabled": _compound_full_enabled(),
|
||||
}
|
||||
)
|
||||
budget, budget_err = _budget_full_usdc(cfg, ex)
|
||||
if budget is None:
|
||||
return jsonify({"ok": False, "msg": budget_err})
|
||||
return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": _compound_full_enabled()})
|
||||
budget_cap = budget
|
||||
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
|
||||
|
||||
available_usdc = fetch_options_trading_usdc(ex)
|
||||
elif mode == "compound_full":
|
||||
if not _compound_full_enabled():
|
||||
return jsonify(
|
||||
{
|
||||
"ok": False,
|
||||
"msg": "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)",
|
||||
"compound_full_enabled": False,
|
||||
}
|
||||
)
|
||||
budget, budget_err = _compound_full_usdc(cfg, ex)
|
||||
if budget is None:
|
||||
return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": True})
|
||||
budget_cap = budget
|
||||
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
|
||||
|
||||
available_usdc = fetch_options_trading_usdc(ex)
|
||||
elif mode in ("sheets", "eth_amount") and _compound_full_enabled():
|
||||
budget_cap = None
|
||||
eth_amount = None
|
||||
try:
|
||||
if request.args.get("eth_amount"):
|
||||
@@ -456,6 +552,70 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
pass
|
||||
ask = q.get("ask")
|
||||
ask_sz = q.get("ask_sz")
|
||||
try:
|
||||
from lib.hedge_plan.okx_trade_mode_lib import block_standalone_open_by_mode_msg
|
||||
|
||||
mode_block = block_standalone_open_by_mode_msg()
|
||||
if mode_block:
|
||||
return jsonify(
|
||||
{
|
||||
**q,
|
||||
"ok": True,
|
||||
"can_open": False,
|
||||
"msg": mode_block,
|
||||
"quote_per_unit": ask,
|
||||
"premium_per_sheet": None,
|
||||
"sizing": {
|
||||
"ok": False,
|
||||
"msg": mode_block,
|
||||
"sheets": 0,
|
||||
"eth_amount": 0.0,
|
||||
"total_premium": 0.0,
|
||||
},
|
||||
"available_usdc": available_usdc,
|
||||
"budget_full_usdc": budget if mode == "budget_full" else None,
|
||||
"compound_full_usdc": budget if mode == "compound_full" else None,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify(
|
||||
{
|
||||
"ok": False,
|
||||
"can_open": False,
|
||||
"msg": f"交易模式校验失败: {e}",
|
||||
}
|
||||
)
|
||||
try:
|
||||
from lib.hedge_plan.hedge_options_exclusive_lib import block_standalone_option_open_msg
|
||||
|
||||
conn_q = cfg["get_db"]()
|
||||
try:
|
||||
excl = block_standalone_option_open_msg(conn_q)
|
||||
finally:
|
||||
conn_q.close()
|
||||
if excl:
|
||||
return jsonify(
|
||||
{
|
||||
**q,
|
||||
"ok": True,
|
||||
"can_open": False,
|
||||
"msg": excl,
|
||||
"quote_per_unit": ask,
|
||||
"premium_per_sheet": None,
|
||||
"sizing": {
|
||||
"ok": False,
|
||||
"msg": excl,
|
||||
"sheets": 0,
|
||||
"eth_amount": 0.0,
|
||||
"total_premium": 0.0,
|
||||
},
|
||||
"available_usdc": available_usdc,
|
||||
"budget_full_usdc": budget if mode == "budget_full" else None,
|
||||
"compound_full_usdc": budget if mode == "compound_full" else None,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "can_open": False, "msg": f"互斥校验失败: {e}"})
|
||||
can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz)
|
||||
if not can_open:
|
||||
# 合约可报价,但不可开仓:返回参考标记价供展示
|
||||
@@ -476,17 +636,77 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
},
|
||||
"available_usdc": available_usdc,
|
||||
"budget_full_usdc": budget if mode == "budget_full" else None,
|
||||
"compound_full_usdc": budget if mode == "compound_full" else None,
|
||||
}
|
||||
)
|
||||
from lib.options.options_position_limit_lib import (
|
||||
compound_full_single_position_block_msg,
|
||||
option_position_limit_block_msg,
|
||||
)
|
||||
|
||||
if mode == "compound_full":
|
||||
compound_block = compound_full_single_position_block_msg(
|
||||
ex, fetch_positions=cfg.get("fetch_option_positions")
|
||||
)
|
||||
if compound_block:
|
||||
return jsonify(
|
||||
{
|
||||
**q,
|
||||
"ok": True,
|
||||
"can_open": False,
|
||||
"msg": compound_block,
|
||||
"quote_per_unit": ask,
|
||||
"premium_per_sheet": None,
|
||||
"sizing": {
|
||||
"ok": False,
|
||||
"msg": compound_block,
|
||||
"sheets": 0,
|
||||
"eth_amount": 0.0,
|
||||
"total_premium": 0.0,
|
||||
},
|
||||
"available_usdc": available_usdc,
|
||||
"budget_full_usdc": None,
|
||||
"compound_full_usdc": budget,
|
||||
}
|
||||
)
|
||||
|
||||
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 jsonify(
|
||||
{
|
||||
**q,
|
||||
"ok": True,
|
||||
"can_open": False,
|
||||
"msg": pos_limit_msg,
|
||||
"quote_per_unit": ask,
|
||||
"premium_per_sheet": None,
|
||||
"sizing": {
|
||||
"ok": False,
|
||||
"msg": pos_limit_msg,
|
||||
"sheets": 0,
|
||||
"eth_amount": 0.0,
|
||||
"total_premium": 0.0,
|
||||
},
|
||||
"available_usdc": available_usdc,
|
||||
"budget_full_usdc": budget if mode == "budget_full" else None,
|
||||
"compound_full_usdc": budget if mode == "compound_full" else None,
|
||||
}
|
||||
)
|
||||
sizing = calc_order_size(
|
||||
quote_per_unit=float(ask),
|
||||
ct_mult=float(ct_mult),
|
||||
min_sz=int(min_sz),
|
||||
budget_usdc=budget if mode == "budget_full" else None,
|
||||
budget_usdc=budget if _is_budget_mode(mode) else None,
|
||||
budget_buffer=cfg["budget_buffer"],
|
||||
eth_amount=eth_amount if mode == "eth_amount" else None,
|
||||
sheets=sheet_count if mode == "sheets" else None,
|
||||
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
|
||||
budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap)
|
||||
if mode in ("budget_full", "compound_full", "sheets", "eth_amount")
|
||||
else None,
|
||||
)
|
||||
if sizing.get("ok"):
|
||||
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(
|
||||
@@ -508,7 +728,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
ct_mult=float(ct_mult),
|
||||
min_sz=int(min_sz),
|
||||
sheets=capped,
|
||||
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
|
||||
budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap)
|
||||
if mode in ("budget_full", "compound_full", "sheets", "eth_amount")
|
||||
else None,
|
||||
)
|
||||
if sizing.get("ok"):
|
||||
sizing["ask_depth_capped"] = True
|
||||
@@ -530,6 +752,10 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
"sizing": sizing,
|
||||
"available_usdc": available_usdc,
|
||||
"budget_full_usdc": budget if mode == "budget_full" else None,
|
||||
"compound_full_usdc": budget if mode == "compound_full" else None,
|
||||
"mode": mode,
|
||||
"mode_note": mode_note,
|
||||
"compound_full_enabled": _compound_full_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -539,6 +765,14 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
ex, err = _require_options_ex(cfg)
|
||||
if ex is None:
|
||||
return jsonify({"ok": False, "msg": err})
|
||||
try:
|
||||
from lib.hedge_plan.okx_trade_mode_lib import block_standalone_open_by_mode_msg
|
||||
|
||||
mode_block = block_standalone_open_by_mode_msg()
|
||||
if mode_block:
|
||||
return jsonify({"ok": False, "msg": mode_block, "can_open": False})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": f"交易模式校验失败: {e}", "can_open": False})
|
||||
try:
|
||||
from lib.hedge_plan.hedge_options_exclusive_lib import block_standalone_option_open_msg
|
||||
|
||||
@@ -549,12 +783,16 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
conn_gate.close()
|
||||
if block_msg:
|
||||
return jsonify({"ok": False, "msg": block_msg})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": f"互斥校验失败: {e}"})
|
||||
data = request.get_json(silent=True) or {}
|
||||
inst_id = (data.get("inst_id") or "").strip()
|
||||
mode = (data.get("mode") or "budget_full").strip()
|
||||
mode = (data.get("mode") or "sheets").strip()
|
||||
mode, mode_note = _normalize_size_mode(mode)
|
||||
signal_note = (data.get("signal_note") or "").strip()
|
||||
if mode_note and mode == "sheets" and (data.get("mode") or "").strip() == "compound_full":
|
||||
# 前端残留全仓复利选中时,已自动改指定张数;继续开仓
|
||||
pass
|
||||
target_index = None
|
||||
raw_target = data.get("target_index")
|
||||
if raw_target is not None and str(raw_target).strip() != "":
|
||||
@@ -564,6 +802,12 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
return jsonify({"ok": False, "msg": "目标位无效"})
|
||||
if target_index <= 0:
|
||||
return jsonify({"ok": False, "msg": "目标位无效"})
|
||||
profit_exit_enabled = bool(data.get("profit_exit_enabled"))
|
||||
profit_exit_mult = 1.0
|
||||
if profit_exit_enabled:
|
||||
from lib.options.options_profit_exit_lib import normalize_profit_exit_mult
|
||||
|
||||
profit_exit_mult = normalize_profit_exit_mult(data.get("profit_exit_mult"), default=1.0)
|
||||
if not inst_id:
|
||||
return jsonify({"ok": False, "msg": "缺少 inst_id"})
|
||||
q = cfg["quote_option_contract"](ex, inst_id)
|
||||
@@ -582,6 +826,25 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
"ref_ask": q.get("ref_ask"),
|
||||
}
|
||||
)
|
||||
from lib.options.options_position_limit_lib import (
|
||||
compound_full_single_position_block_msg,
|
||||
option_position_limit_block_msg,
|
||||
)
|
||||
|
||||
if mode == "compound_full":
|
||||
compound_block = compound_full_single_position_block_msg(
|
||||
ex, fetch_positions=cfg.get("fetch_option_positions")
|
||||
)
|
||||
if compound_block:
|
||||
return jsonify({"ok": False, "msg": compound_block, "can_open": False})
|
||||
|
||||
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 jsonify({"ok": False, "msg": pos_limit_msg, "can_open": False})
|
||||
ct_mult = float(q.get("ct_mult") or 0.01)
|
||||
min_sz = int(q.get("min_sz") or 1)
|
||||
eth_amount = None
|
||||
@@ -595,23 +858,49 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
try:
|
||||
sheet_count = int(data.get("sheets"))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"ok": False, "msg": "张数无效"})
|
||||
sheet_count = None
|
||||
if sheet_count is None or int(sheet_count) < 1:
|
||||
# 全仓复利关闭后前端可能仍带着旧 mode 过来,归一后缺张数则默认 1
|
||||
if (data.get("mode") or "").strip() == "compound_full":
|
||||
sheet_count = 1
|
||||
else:
|
||||
return jsonify({"ok": False, "msg": "张数无效"})
|
||||
budget = cfg["trade_budget"]
|
||||
budget_cap = cfg["trade_budget"]
|
||||
if mode == "budget_full":
|
||||
blocked = _budget_full_blocked_by_compound_msg()
|
||||
if blocked:
|
||||
return jsonify({"ok": False, "msg": blocked, "compound_full_enabled": _compound_full_enabled()})
|
||||
budget, budget_err = _budget_full_usdc(cfg, ex)
|
||||
if budget is None:
|
||||
return jsonify({"ok": False, "msg": budget_err})
|
||||
budget_cap = budget
|
||||
elif mode == "compound_full":
|
||||
if not _compound_full_enabled():
|
||||
return jsonify(
|
||||
{
|
||||
"ok": False,
|
||||
"msg": "全仓复利未开启,请改用指定张数或先开启全仓复利",
|
||||
"compound_full_enabled": False,
|
||||
}
|
||||
)
|
||||
budget, budget_err = _compound_full_usdc(cfg, ex)
|
||||
if budget is None:
|
||||
return jsonify({"ok": False, "msg": budget_err})
|
||||
budget_cap = budget
|
||||
elif mode in ("sheets", "eth_amount") and _compound_full_enabled():
|
||||
budget_cap = None
|
||||
sizing = calc_order_size(
|
||||
quote_per_unit=float(ask),
|
||||
ct_mult=ct_mult,
|
||||
min_sz=min_sz,
|
||||
budget_usdc=budget if mode == "budget_full" else None,
|
||||
budget_usdc=budget if _is_budget_mode(mode) else None,
|
||||
budget_buffer=cfg["budget_buffer"],
|
||||
eth_amount=eth_amount,
|
||||
sheets=sheet_count,
|
||||
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
|
||||
budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap)
|
||||
if mode in ("budget_full", "compound_full", "sheets", "eth_amount")
|
||||
else None,
|
||||
)
|
||||
if not sizing.get("ok"):
|
||||
return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing})
|
||||
@@ -620,16 +909,14 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
if capped is None:
|
||||
return jsonify({"ok": False, "msg": cap_msg or "卖一深度不足,无法买入"})
|
||||
if capped < sheets:
|
||||
sizing = calc_order_size(
|
||||
quote_per_unit=float(ask),
|
||||
ct_mult=ct_mult,
|
||||
min_sz=min_sz,
|
||||
sheets=capped,
|
||||
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
|
||||
return jsonify(
|
||||
{
|
||||
"ok": False,
|
||||
"msg": f"卖一深度仅 {int(capped)} 张,不足请求 {int(sheets)} 张,拒绝缩量成交",
|
||||
"requested_sheets": int(sheets),
|
||||
"ask_sz": ask_sz,
|
||||
}
|
||||
)
|
||||
if not sizing.get("ok"):
|
||||
return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing})
|
||||
sheets = int(sizing["sheets"])
|
||||
tick_sz = q.get("tick_sz")
|
||||
order = cfg["place_option_limit_order"](
|
||||
ex,
|
||||
@@ -639,9 +926,56 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
price=float(ask),
|
||||
td_mode=td_mode_for_option_buy(cfg["td_mode"]),
|
||||
tick_sz=tick_sz,
|
||||
ord_type="ioc",
|
||||
)
|
||||
if not order.get("ok"):
|
||||
return jsonify(order)
|
||||
ord_id = str((order.get("data") or {}).get("ordId") or "").strip()
|
||||
if not ord_id:
|
||||
return jsonify({"ok": False, "msg": "下单成功但未返回订单号", "order": order})
|
||||
from lib.exchange.okx_options_lib import wait_option_order_full_fill
|
||||
|
||||
try:
|
||||
fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12"))
|
||||
except (TypeError, ValueError):
|
||||
fill_timeout = 12.0
|
||||
fill = wait_option_order_full_fill(
|
||||
ex,
|
||||
inst_id=inst_id,
|
||||
ord_id=ord_id,
|
||||
need_sheets=int(sheets),
|
||||
timeout_sec=fill_timeout,
|
||||
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:
|
||||
try:
|
||||
from lib.options.options_close_exec_lib import close_option_by_bid1
|
||||
|
||||
orphan_close = close_option_by_bid1(
|
||||
cfg, ex, inst_id, sheets=filled_n, require_recycle_gate=False
|
||||
)
|
||||
except Exception as e:
|
||||
orphan_close = {"ok": False, "msg": str(e)}
|
||||
return jsonify(
|
||||
{
|
||||
"ok": False,
|
||||
"msg": fill.get("msg") or "未完全成交,开仓失败",
|
||||
"filled_sheets": filled_n,
|
||||
"orphan_close": orphan_close,
|
||||
"fill": fill,
|
||||
"order": order,
|
||||
}
|
||||
)
|
||||
fill_px = float(fill.get("avg_px") or ask)
|
||||
filled_n = int(fill.get("filled_sheets") or sheets)
|
||||
sheets = filled_n
|
||||
sizing = dict(sizing)
|
||||
sizing["sheets"] = sheets
|
||||
sizing["eth_amount"] = round(sheets * ct_mult, 8)
|
||||
sizing["total_premium"] = round(fill_px * sheets * ct_mult, 4)
|
||||
conn = cfg["get_db"]()
|
||||
trade_id = None
|
||||
target_mon = None
|
||||
@@ -649,6 +983,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
open_opt_type = None
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
from lib.options.options_profit_exit_lib import ensure_profit_exit_columns
|
||||
|
||||
ensure_profit_exit_columns(conn)
|
||||
meta = q.get("meta") or {}
|
||||
u = str(meta.get("uly") or inst_id).split("-")[0]
|
||||
opt_type = meta.get("optType")
|
||||
@@ -658,8 +995,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||
open_quote, premium_paid, status, signal_note, exchange_ord_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)
|
||||
open_quote, premium_paid, status, signal_note, exchange_ord_id,
|
||||
profit_exit_enabled, profit_exit_mult, profit_exit_state)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
inst_id,
|
||||
@@ -669,10 +1007,13 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
str(q.get("exp_time") or ""),
|
||||
sheets,
|
||||
sizing["eth_amount"],
|
||||
float(ask),
|
||||
fill_px,
|
||||
sizing["total_premium"],
|
||||
signal_note,
|
||||
(order.get("data") or {}).get("ordId"),
|
||||
ord_id,
|
||||
1 if profit_exit_enabled else 0,
|
||||
profit_exit_mult if profit_exit_enabled else 1.0,
|
||||
"active" if profit_exit_enabled else "idle",
|
||||
),
|
||||
)
|
||||
trade_id = int(cur.lastrowid)
|
||||
@@ -688,6 +1029,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
trade_id=trade_id,
|
||||
sheets=sheets,
|
||||
)
|
||||
if profit_exit_enabled:
|
||||
pass # 列已由 init_options_tables / ensure 迁移
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -708,7 +1051,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
opt_type=open_opt_type,
|
||||
sheets=sheets,
|
||||
premium_paid=sizing.get("total_premium"),
|
||||
open_quote=float(ask) if ask is not None else None,
|
||||
open_quote=fill_px,
|
||||
target_index=target_index,
|
||||
signal_note=signal_note,
|
||||
)
|
||||
@@ -806,9 +1149,11 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
from lib.options.options_target_lib import targets_by_inst
|
||||
from lib.options.options_profit_exit_lib import profit_exit_by_inst
|
||||
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
|
||||
|
||||
tgt_map = targets_by_inst(conn)
|
||||
profit_exit_map = profit_exit_by_inst(conn)
|
||||
hedge_target_map = active_options_targets_by_inst(conn)
|
||||
rows = []
|
||||
for p in raw:
|
||||
@@ -827,6 +1172,12 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
row["target_index"] = mon.get("target_index")
|
||||
row["target_monitor_id"] = mon.get("id")
|
||||
row["target_monitor"] = mon
|
||||
pe = profit_exit_map.get(inst)
|
||||
if pe:
|
||||
row["profit_exit_enabled"] = pe.get("profit_exit_enabled")
|
||||
row["profit_exit_mult"] = pe.get("profit_exit_mult")
|
||||
row["profit_exit_state"] = pe.get("profit_exit_state")
|
||||
row["profit_exit_required_recycle"] = pe.get("required_recycle")
|
||||
hedge_target = hedge_target_map.get(inst)
|
||||
if hedge_target:
|
||||
row["hedge_plan_target"] = hedge_target
|
||||
@@ -867,6 +1218,26 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
inst_id = (data.get("inst_id") or "").strip()
|
||||
if not inst_id:
|
||||
return jsonify({"ok": False, "msg": "缺少 inst_id"})
|
||||
try:
|
||||
from lib.hedge_plan.hedge_plan_db import (
|
||||
active_hedge_option_inst_ids,
|
||||
init_hedge_plan_tables,
|
||||
)
|
||||
|
||||
conn_h = cfg["get_db"]()
|
||||
try:
|
||||
init_hedge_plan_tables(conn_h)
|
||||
if inst_id in active_hedge_option_inst_ids(conn_h):
|
||||
return jsonify(
|
||||
{
|
||||
"ok": False,
|
||||
"msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页设置目标",
|
||||
}
|
||||
)
|
||||
finally:
|
||||
conn_h.close()
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"})
|
||||
try:
|
||||
target_index = float(data.get("target_index"))
|
||||
except (TypeError, ValueError):
|
||||
@@ -933,6 +1304,62 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/api/options/profit-exit", methods=["POST"])
|
||||
@lr
|
||||
def api_options_profit_exit_set():
|
||||
ex, err = _require_options_ex(cfg)
|
||||
if ex is None:
|
||||
return jsonify({"ok": False, "msg": err})
|
||||
data = request.get_json(silent=True) or {}
|
||||
inst_id = (data.get("inst_id") or "").strip()
|
||||
if not inst_id:
|
||||
return jsonify({"ok": False, "msg": "缺少 inst_id"})
|
||||
try:
|
||||
from lib.hedge_plan.hedge_plan_db import (
|
||||
active_hedge_option_inst_ids,
|
||||
init_hedge_plan_tables,
|
||||
)
|
||||
|
||||
conn_h = cfg["get_db"]()
|
||||
try:
|
||||
init_hedge_plan_tables(conn_h)
|
||||
if inst_id in active_hedge_option_inst_ids(conn_h):
|
||||
return jsonify(
|
||||
{
|
||||
"ok": False,
|
||||
"msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页设置翻倍出场",
|
||||
}
|
||||
)
|
||||
finally:
|
||||
conn_h.close()
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"})
|
||||
enabled_raw = data.get("enabled")
|
||||
if enabled_raw is None:
|
||||
enabled_raw = data.get("profit_exit_enabled")
|
||||
enabled = bool(enabled_raw) and str(enabled_raw).strip().lower() not in (
|
||||
"0",
|
||||
"false",
|
||||
"off",
|
||||
"no",
|
||||
)
|
||||
from lib.options.options_profit_exit_lib import normalize_profit_exit_mult, set_profit_exit
|
||||
|
||||
mult = normalize_profit_exit_mult(data.get("mult", data.get("profit_exit_mult")), default=1.0)
|
||||
raw = cfg["fetch_option_positions"](ex)
|
||||
if raw is None:
|
||||
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
|
||||
if not _find_position(raw, inst_id):
|
||||
return jsonify({"ok": False, "msg": "未找到持仓"})
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
out = set_profit_exit(conn, inst_id=inst_id, enabled=enabled, mult=mult)
|
||||
if out.get("ok"):
|
||||
conn.commit()
|
||||
return jsonify(out)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/api/options/close", methods=["POST"])
|
||||
@lr
|
||||
def api_options_close():
|
||||
@@ -943,6 +1370,26 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
inst_id = (data.get("inst_id") or "").strip()
|
||||
if not inst_id:
|
||||
return jsonify({"ok": False, "msg": "缺少 inst_id"})
|
||||
try:
|
||||
from lib.hedge_plan.hedge_plan_db import (
|
||||
active_hedge_option_inst_ids,
|
||||
init_hedge_plan_tables,
|
||||
)
|
||||
|
||||
conn_h = cfg["get_db"]()
|
||||
try:
|
||||
init_hedge_plan_tables(conn_h)
|
||||
if inst_id in active_hedge_option_inst_ids(conn_h):
|
||||
return jsonify(
|
||||
{
|
||||
"ok": False,
|
||||
"msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页平仓",
|
||||
}
|
||||
)
|
||||
finally:
|
||||
conn_h.close()
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"})
|
||||
if data.get("market"):
|
||||
return jsonify({"ok": False, "msg": "已禁用市价平仓,仅支持买一限价"})
|
||||
sheets = data.get("sheets")
|
||||
@@ -1081,54 +1528,6 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
_mark_balances_stale(cfg)
|
||||
return jsonify(result)
|
||||
|
||||
@app.route("/api/options/cross-transfer", methods=["POST"])
|
||||
@lr
|
||||
def api_options_cross_transfer():
|
||||
ex, err = _require_options_ex(cfg)
|
||||
if ex is None:
|
||||
return jsonify({"ok": False, "msg": err})
|
||||
data = request.get_json(silent=True) or {}
|
||||
ccy = (data.get("ccy") or "USDT").upper()
|
||||
direction = (data.get("direction") or "sub_to_main").strip()
|
||||
from_account = (data.get("from_account") or data.get("account") or "funding").strip()
|
||||
to_account = (data.get("to_account") or data.get("account") or "funding").strip()
|
||||
try:
|
||||
amount = float(data.get("amount"))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"ok": False, "msg": "数量无效"})
|
||||
main_to_sub = direction == "main_to_sub"
|
||||
result = cfg["transfer_main_sub_account"](
|
||||
ex,
|
||||
ccy=ccy,
|
||||
amount=amount,
|
||||
sub_acct=cfg.get("sub_account_name") or "",
|
||||
main_to_sub=main_to_sub,
|
||||
from_account=from_account,
|
||||
to_account=to_account,
|
||||
)
|
||||
if result.get("ok"):
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_transfer_log (ccy, amount, from_account, to_account, status, message)
|
||||
VALUES (?, ?, ?, ?, 'ok', ?)
|
||||
""",
|
||||
(
|
||||
ccy,
|
||||
amount,
|
||||
("main" if main_to_sub else "sub") + ":" + from_account,
|
||||
("sub" if main_to_sub else "main") + ":" + to_account,
|
||||
"cross",
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
_mark_balances_stale(cfg)
|
||||
return jsonify(result)
|
||||
|
||||
@app.route("/api/options/history")
|
||||
@lr
|
||||
def api_options_history():
|
||||
@@ -1314,6 +1713,24 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
pass
|
||||
return result
|
||||
|
||||
def _profit_exit_close(inst_id: str) -> dict[str, Any]:
|
||||
from lib.options.options_profit_exit_lib import close_option_by_bid_profit_exit
|
||||
|
||||
ex = cfg.get("exchange_options")
|
||||
if ex is None:
|
||||
return {"ok": False, "msg": "期权 exchange 未就绪"}
|
||||
result = close_option_by_bid_profit_exit(cfg, ex, inst_id)
|
||||
if result.get("ok"):
|
||||
try:
|
||||
_sync_options_trades(cfg, force=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_mark_balances_stale(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
def _stale_pending() -> dict[str, Any]:
|
||||
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
|
||||
from lib.options.options_pending_lib import cancel_stale_close_pending_orders
|
||||
@@ -1364,6 +1781,8 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
"profit_ratio": cfg["profit_ratio"],
|
||||
"sync_trades_fn": _sync,
|
||||
"target_close_fn": _target_close,
|
||||
"profit_exit_close_fn": _profit_exit_close,
|
||||
"profit_exit_cfg": cfg,
|
||||
"stale_pending_fn": _stale_pending,
|
||||
},
|
||||
daemon=True,
|
||||
|
||||
@@ -129,6 +129,7 @@ def init_options_review_tables(conn: sqlite3.Connection) -> None:
|
||||
_ensure_column(conn, "options_review_trades", "excluded_as_hedge_leg", "INTEGER DEFAULT 0")
|
||||
_ensure_column(conn, "options_review_trades", "target_price_up", "REAL")
|
||||
_ensure_column(conn, "options_review_trades", "target_price_down", "REAL")
|
||||
_ensure_column(conn, "options_review_trades", "profit_rr", "REAL")
|
||||
|
||||
|
||||
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
|
||||
|
||||
@@ -450,6 +450,7 @@ def upsert_hedge_plan_row(
|
||||
"target_price": _safe_float(plan.get("target_price")),
|
||||
"target_price_up": _safe_float(plan.get("target_price_up")),
|
||||
"target_price_down": _safe_float(plan.get("target_price_down")),
|
||||
"profit_rr": _safe_float(plan.get("profit_rr")),
|
||||
"legs_json": _legs_json_from_plan(legs),
|
||||
}
|
||||
existing = conn.execute(
|
||||
|
||||
@@ -29,6 +29,28 @@ from lib.options.options_review_lib import (
|
||||
)
|
||||
|
||||
|
||||
def _review_source_for_mode(requested: str | None) -> str | None:
|
||||
"""按当前交易模式钳制复盘 source_type;不允许跨模式窥探."""
|
||||
try:
|
||||
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
|
||||
|
||||
mode = get_okx_trade_mode()
|
||||
except Exception:
|
||||
mode = "options"
|
||||
allowed = {
|
||||
"options": "option_spot",
|
||||
"perp_options": "perp_options",
|
||||
"options_options": "options_options",
|
||||
}.get(mode, "option_spot")
|
||||
req = (requested or "").strip()
|
||||
if not req:
|
||||
return allowed
|
||||
if req == allowed:
|
||||
return allowed
|
||||
# 显式 all=1 仍拒绝跨模式,除非管理员扩展;此处一律钳制
|
||||
return allowed
|
||||
|
||||
|
||||
def attach_options_review_templates(app: Flask, repo_root: str) -> None:
|
||||
tpl_dir = os.path.join(repo_root, "lib", "options", "templates")
|
||||
if not os.path.isdir(tpl_dir):
|
||||
@@ -138,7 +160,7 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
|
||||
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
|
||||
conn.commit()
|
||||
filt = dict(
|
||||
source_type=(request.args.get("source_type") or "").strip() or None,
|
||||
source_type=_review_source_for_mode(request.args.get("source_type")),
|
||||
underlying=(request.args.get("underlying") or "").strip() or None,
|
||||
opt_type=(request.args.get("opt_type") or "").strip() or None,
|
||||
strategy_tag=(request.args.get("strategy_tag") or "").strip() or None,
|
||||
@@ -272,7 +294,7 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
|
||||
conn.commit()
|
||||
stats = compute_review_stats(
|
||||
conn,
|
||||
source_type=(request.args.get("source_type") or "").strip() or None,
|
||||
source_type=_review_source_for_mode(request.args.get("source_type")),
|
||||
underlying=(request.args.get("underlying") or "").strip() or None,
|
||||
include_hedge_legs=(request.args.get("include_hedge_legs") or "").strip().lower()
|
||||
in ("1", "true", "yes"),
|
||||
|
||||
@@ -373,6 +373,15 @@ def run_options_target_closes(
|
||||
ensure_target_tables(conn)
|
||||
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
|
||||
live_ids = {k for k in pos_by_inst if k}
|
||||
hedge_managed: set[str] = set()
|
||||
try:
|
||||
from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables
|
||||
|
||||
init_hedge_plan_tables(conn)
|
||||
hedge_managed = active_hedge_option_inst_ids(conn)
|
||||
except Exception:
|
||||
# fail-closed:本轮不执行任何单独目标平仓,避免误平对冲腿
|
||||
return 0
|
||||
cancel_orphans_without_position(conn, live_inst_ids=live_ids)
|
||||
_commit_monitor(conn)
|
||||
|
||||
@@ -381,6 +390,15 @@ def run_options_target_closes(
|
||||
inst_id = str(mon.get("inst_id") or "")
|
||||
if not inst_id:
|
||||
continue
|
||||
if inst_id in hedge_managed:
|
||||
mark_monitor(
|
||||
conn,
|
||||
int(mon["id"]),
|
||||
status="expired",
|
||||
message="已移交对冲计划托管,跳过单独目标平仓",
|
||||
)
|
||||
_commit_monitor(conn)
|
||||
continue
|
||||
if inst_id not in pos_by_inst:
|
||||
mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平")
|
||||
_commit_monitor(conn)
|
||||
@@ -414,6 +432,15 @@ def run_options_target_closes(
|
||||
target = _safe_float(mon.get("target_index"))
|
||||
if not inst_id or target is None:
|
||||
continue
|
||||
if inst_id in hedge_managed:
|
||||
mark_monitor(
|
||||
conn,
|
||||
int(mon["id"]),
|
||||
status="expired",
|
||||
message="已移交对冲计划托管,跳过单独目标平仓",
|
||||
)
|
||||
_commit_monitor(conn)
|
||||
continue
|
||||
pos = pos_by_inst.get(inst_id)
|
||||
if not pos:
|
||||
continue
|
||||
|
||||
@@ -2,14 +2,21 @@
|
||||
data-default-underly="{{ options_default_underly | default('ETH') }}"
|
||||
data-budget-buffer="{{ options_budget_buffer | default(0.95) }}"
|
||||
data-trade-budget="{{ options_trade_budget | default(10) }}"
|
||||
data-compound-full-enabled="{% if options_compound_full_enabled %}1{% else %}0{% endif %}"
|
||||
data-compound-cap-enabled="{% if options_compound_full_cap_enabled %}1{% else %}0{% endif %}"
|
||||
data-compound-cap-usdc="{{ '%.2f'|format(options_compound_full_cap_usdc|default(300)|float) }}"
|
||||
data-ask-liq-filter="{% if options_chain_ask_liq_filter is defined %}{{ '1' if options_chain_ask_liq_filter else '0' }}{% else %}1{% endif %}">
|
||||
{% set compound_on = options_compound_full_enabled if options_compound_full_enabled is defined else true %}
|
||||
{% if not options_enabled %}
|
||||
<div class="flash" style="margin-bottom:12px">期权 API 未启用:请在 <code>crypto_monitor_okx/.env</code> 设置 <code>OKX_OPTIONS_ENABLED=true</code> 及主账户 <code>OKX_OPTIONS_API_*</code>,然后 <code>pm2 restart crypto_okx --update-env</code>.</div>
|
||||
<div class="flash" style="margin-bottom:12px">期权未启用:请在 <code>crypto_monitor_okx/.env</code> 设置 <code>OKX_OPTIONS_ENABLED=true</code> 及 <code>OKX_API_*</code>(永续与期权共用),然后 <code>pm2 restart crypto_okx --update-env</code>.</div>
|
||||
{% endif %}
|
||||
{% if options_enabled and options_open_allowed is defined and not options_open_allowed %}
|
||||
<div class="flash" style="margin-bottom:12px">当前交易模式为对冲(永期/期期),不可单独开期权;持仓可在此查看/平仓.切换请到 env「交易模式」.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="options-dual-grid">
|
||||
<div class="card options-order-card">
|
||||
<h2>期权下单</h2>
|
||||
<div class="card options-order-card"{% if options_open_allowed is defined and not options_open_allowed %} style="opacity:.72"{% endif %}>
|
||||
<h2>期权下单{% if options_open_allowed is defined and not options_open_allowed %} <small class="muted">(对冲模式已禁用开仓)</small>{% endif %}</h2>
|
||||
<details class="opt-close-rule opt-open-rule">
|
||||
<summary>开仓规则说明</summary>
|
||||
<div class="opt-close-rule-body">
|
||||
@@ -18,8 +25,10 @@
|
||||
<li><strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算。</li>
|
||||
<li>环境配置「链上仅显示有卖一」开启时,隐藏无真实卖一或深度不足 1 张的合约(估算价 <strong>~</strong> 亦不显示)。</li>
|
||||
<li><strong>开仓只认真实卖一价且卖一深度≥1</strong>;无深度时面板显示参考标记价并禁用买入。</li>
|
||||
<li>链展示近 <span id="opt-chain-dte">14</span> 日到期;<strong>T 型</strong>默认 ATM ±5 档,可展开全部。</li>
|
||||
<li>链展示近 <span id="opt-chain-dte">14</span> 日到期;列表与 T 型默认<strong>平值 + 实值3档 + 虚值3档</strong>,勾选「展开全部」看全部行权价(若当前为实值/虚值筛选会自动切回「全部」)。</li>
|
||||
<li>「按可用余额打满」可用额度 = min(交易户可用 USDC, 单笔预算 <strong id="opt-trade-budget">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</strong>),再 × 预算缓冲 <strong id="opt-budget-buf">{{ '%.2f'|format(options_budget_buffer|default(0.95)|float) }}</strong> 算张数(env 可改)。</li>
|
||||
<li>「全仓复利」用期权交易户<strong>全部可用</strong>×缓冲开仓(不受单笔预算限制);可选开启全仓上限;该模式下仅允许同时 1 笔持仓。</li>
|
||||
<li><strong>翻倍出场</strong>:开仓时可勾选;1倍=盈利等于权利金,买一可回收达标后限价平;持仓卡可改倍数或关闭。</li>
|
||||
<li>平仓仅买一限价,详见说明文档。</li>
|
||||
</ul>
|
||||
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
|
||||
@@ -40,7 +49,7 @@
|
||||
<button type="button" class="btn-secondary opt-money-btn active" data-money="all">全部</button>
|
||||
<button type="button" class="btn-secondary opt-money-btn" data-money="itm">实值</button>
|
||||
<button type="button" class="btn-secondary opt-money-btn" data-money="otm">虚值</button>
|
||||
<label id="opt-strike-expand-wrap" class="opt-strike-expand-label" hidden>
|
||||
<label id="opt-strike-expand-wrap" class="opt-strike-expand-label">
|
||||
<input type="checkbox" id="opt-strike-expand-all"> 展开全部
|
||||
</label>
|
||||
<button type="button" class="btn-secondary" id="opt-load-chain">刷新链</button>
|
||||
@@ -54,6 +63,7 @@
|
||||
<th>类型</th>
|
||||
<th>合约</th>
|
||||
<th>卖一/张</th>
|
||||
<th title="指数÷卖一(每1币)">杠杆</th>
|
||||
<th>买一/张</th>
|
||||
<th>到期平衡</th>
|
||||
<th>距平衡</th>
|
||||
@@ -77,7 +87,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="opt-strike-tbody">
|
||||
<tr><td colspan="8" class="muted">请选择到期日</td></tr>
|
||||
<tr><td colspan="9" class="muted">请选择到期日</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -103,30 +113,46 @@
|
||||
</div>
|
||||
<div class="options-estimate-row">
|
||||
<div class="opt-est-main">
|
||||
<label class="btn-secondary opt-order-chip" for="opt-target-idx">目标位(指数)</label>
|
||||
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="达价限价平仓"
|
||||
<label class="btn-secondary opt-order-chip" for="opt-target-idx" title="仅作到期实值估算参考">目标位(指数)</label>
|
||||
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="参考指数·到期实值"
|
||||
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
|
||||
<span class="k">预计价值</span>
|
||||
<span id="opt-est-value" class="v">—</span>
|
||||
<span class="k">盈利</span>
|
||||
<span id="opt-est-profit" class="v">—</span>
|
||||
<span class="k">目标杠杆</span>
|
||||
<span id="opt-est-leverage" class="v" title="目标位名义价值÷权利金">—</span>
|
||||
<span class="k">盈亏比</span>
|
||||
<span id="opt-est-rr" class="v" title="盈利金额÷本合约权利金">—</span>
|
||||
</div>
|
||||
<span class="muted opt-est-note">目标价=监控指数;到位后按买一限价平仓;无止损,到期即止损</span>
|
||||
<span class="muted opt-est-note">目标位仅参考(按到期实值估);盈亏比=盈利÷权利金;到位后按买一限价平;无止损,到期即止损</span>
|
||||
</div>
|
||||
<div class="options-estimate-row opt-profit-exit-row">
|
||||
<div class="opt-est-main">
|
||||
<label class="btn-secondary opt-order-chip" for="opt-profit-exit-enabled" title="开启后监控买一可回收;达标按买一限价平">
|
||||
<input type="checkbox" id="opt-profit-exit-enabled">
|
||||
<span>翻倍出场</span>
|
||||
</label>
|
||||
<label class="k" for="opt-profit-exit-mult">倍数</label>
|
||||
<input type="number" id="opt-profit-exit-mult" class="opt-profit-exit-mult" min="0.1" step="0.1" value="1"
|
||||
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
|
||||
</div>
|
||||
<span class="muted opt-est-note">1倍=盈利等于权利金(可回收≥2×权利金);可开可关,与目标位并行</span>
|
||||
</div>
|
||||
<div class="form-row options-order-mode-row">
|
||||
<div class="opt-size-mode-bar">
|
||||
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
|
||||
<input type="radio" name="opt-size-mode" value="sheets" checked>
|
||||
<input type="radio" name="opt-size-mode" value="sheets"{% if not compound_on %} checked{% endif %}>
|
||||
<span>指定张数</span>
|
||||
</label>
|
||||
<input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数"
|
||||
autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
|
||||
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
|
||||
<input type="radio" name="opt-size-mode" value="budget_full">
|
||||
<label class="btn-secondary opt-order-chip opt-size-mode-chip" id="opt-size-mode-budget-wrap"{% if compound_on %} hidden{% endif %}>
|
||||
<input type="radio" name="opt-size-mode" value="budget_full"{% if compound_on %} disabled{% endif %}>
|
||||
<span>按可用余额打满</span>
|
||||
</label>
|
||||
<label class="btn-secondary opt-order-chip opt-size-mode-chip" id="opt-size-mode-compound-wrap"{% if not compound_on %} hidden{% endif %}>
|
||||
<input type="radio" name="opt-size-mode" value="compound_full"{% if compound_on %} checked{% endif %}{% if not compound_on %} disabled{% endif %}>
|
||||
<span>全仓复利</span>
|
||||
</label>
|
||||
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
|
||||
<input type="radio" name="opt-size-mode" value="eth_amount" id="opt-size-mode-eth">
|
||||
<span>指定币数量</span>
|
||||
@@ -137,6 +163,9 @@
|
||||
<p class="muted opt-budget-full-hint" id="opt-budget-full-hint" style="display:none;margin:6px 0 0;font-size:.82rem;line-height:1.4">
|
||||
余额 > 单笔预算(<span id="opt-budget-full-cap">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</span>U)时按预算;余额不足时按余额;再乘预算缓冲算张数。
|
||||
</p>
|
||||
<p class="muted opt-compound-full-hint" id="opt-compound-full-hint" style="display:none;margin:6px 0 0;font-size:.82rem;line-height:1.4">
|
||||
用期权交易户全部可用×缓冲开仓;不受单笔预算限制。<span id="opt-compound-cap-line">全仓上限关闭</span>。仅允许同时持有 1 笔仓位。
|
||||
</p>
|
||||
<input type="text" id="opt-signal-note" name="opt_signal_note" class="opt-signal-note" placeholder="备注(关键位说明)"
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||
data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly>
|
||||
@@ -181,6 +210,7 @@
|
||||
<li>本轮只锁<strong>买一</strong>:张数 = min(持仓, 买一深度),限价 = 当场买一。</li>
|
||||
<li>买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」。</li>
|
||||
<li>手动平仓只验有效买一(非残档);目标触达后才平,2×权利金只是门控(到 2× 本身不会自动平)。</li>
|
||||
<li><strong>翻倍出场</strong>:开启后可自选倍数(默认1);1倍=盈利等于权利金,买一可回收达标即限价平;可随时关闭。</li>
|
||||
<li>全程 <code>reduceOnly</code> 限价卖,不吃买二及以下、不走市价。</li>
|
||||
</ul>
|
||||
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
|
||||
@@ -320,4 +350,4 @@
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||
<script src="/static/options_panel.js?v=50"></script>
|
||||
<script src="/static/options_panel.js?v=64"></script>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user