Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fad68f7b1 | |||
| 14a7adae1f | |||
| 24bb8532c4 | |||
| c514a75026 | |||
| 5c3969674a | |||
| 3b56e15fb1 | |||
| 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 |
@@ -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,31 @@ 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
|
||||
# 交易模式三选一(热更):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 +134,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 +157,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 +264,8 @@ AUTO_TRANSFER_BJ_HOUR=8
|
||||
FORCE_CLOSE_BJ_HOUR=0
|
||||
# 是否启用强制清仓(默认关闭,true 才会在整点执行)
|
||||
FORCE_CLOSE_ENABLED=false
|
||||
# 强制清仓执行窗口(分钟,默认 5;该窗口内禁止开仓)
|
||||
FORCE_CLOSE_GRACE_MINUTES=5
|
||||
|
||||
# 推送与AI超时(秒)
|
||||
WECHAT_TIMEOUT_SECONDS=10
|
||||
|
||||
+144
-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,15 +6860,18 @@ 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"),
|
||||
@@ -7001,19 +7062,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 +7125,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 +7149,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 +9183,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 +9197,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
|
||||
|
||||
+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,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
-3
@@ -6,10 +6,12 @@
|
||||
|
||||
| 标签 | 指向提交 | 说明 |
|
||||
|------|----------|------|
|
||||
| `snapshot/20260727` | `f53f281` | 2026-07-27:实例手机壳(下单/关键位/期权)、著作权声明、托管合同(一用户一机)、服务说明与报价说明 |
|
||||
| `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 执行手册文档等 |
|
||||
@@ -32,7 +34,7 @@
|
||||
git tag -l 'snapshot/*'
|
||||
|
||||
# 检出快照(只读查看,勿在此分支直接开发)
|
||||
git checkout snapshot/20260727
|
||||
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 |
|
||||
| 本系统能力 | 开平仓、止损、关键位 | **仅买方** 开平仓,无组合单 |
|
||||
|
||||
+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;
|
||||
|
||||
+1096
-142
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",
|
||||
@@ -311,7 +317,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 +343,40 @@
|
||||
});
|
||||
body.appendChild(tabBar);
|
||||
body.appendChild(panelsWrap);
|
||||
body.dataset.envModeSectionIdx = String(modeSectionIdx);
|
||||
bindTradeModeAutoRefresh(body);
|
||||
return 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 +434,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 +540,9 @@
|
||||
bindEvents();
|
||||
loadDisplayPrefsForm(false);
|
||||
loadEnvConfig(false);
|
||||
const root = envConfigRoot();
|
||||
const body = root && root.querySelector("#env-config-body");
|
||||
if (body) bindTradeModeAutoRefresh(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;
|
||||
}
|
||||
@@ -3122,6 +3138,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 +3470,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 +3508,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 +3786,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 +3798,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 +3845,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 +3892,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 +3933,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 +4111,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);
|
||||
@@ -5680,6 +5930,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);
|
||||
+517
-116
@@ -7,6 +7,10 @@
|
||||
root.setAttribute("data-options-booted", "1");
|
||||
|
||||
const panelCache = (window.__optionsPanelCache = window.__optionsPanelCache || {});
|
||||
if (!panelCache.quoteWatcherId) {
|
||||
panelCache.quoteWatcherId =
|
||||
"w" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
const state = {
|
||||
underlying: root.dataset.defaultUnderly || "ETH",
|
||||
@@ -37,9 +41,20 @@
|
||||
let selectSeq = 0;
|
||||
let refreshAllTimer = null;
|
||||
let pendingRefreshTimer = null;
|
||||
let chainSoftTimer = null;
|
||||
let lastChainSoftAt = 0;
|
||||
let chainQuotedAt = 0;
|
||||
let quoteLiveEs = null;
|
||||
let quoteLiveReconnectTimer = null;
|
||||
let quoteLiveOk = false;
|
||||
let quoteLiveWsOk = false;
|
||||
let lastOrderQuoteLiveAt = 0;
|
||||
let pendingTtlSeconds = 600;
|
||||
const POSITIONS_STALE_MS = 45000;
|
||||
const PENDING_POLL_MS = 8000;
|
||||
/** SSE/WS 断开时的 REST 兜底;连上后停用 */
|
||||
const CHAIN_SOFT_POLL_MS = 30000;
|
||||
const ORDER_QUOTE_LIVE_MIN_MS = 800;
|
||||
const orderPanelHome = (function () {
|
||||
const host = document.getElementById("opt-order-panel-host");
|
||||
return host ? host.parentElement : null;
|
||||
@@ -321,7 +336,7 @@
|
||||
[
|
||||
"opt-sheets-amount",
|
||||
"opt-eth-amount",
|
||||
"opt-target-idx",
|
||||
"opt-profit-rr",
|
||||
].forEach(function (id) {
|
||||
harden(document.getElementById(id));
|
||||
});
|
||||
@@ -341,7 +356,7 @@
|
||||
}
|
||||
|
||||
function strikeTableColspan() {
|
||||
return state.chainView === "t" ? 9 : 8;
|
||||
return 9;
|
||||
}
|
||||
|
||||
function syncChainViewUI() {
|
||||
@@ -352,7 +367,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 +487,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);
|
||||
@@ -589,6 +647,15 @@
|
||||
if (el) el.textContent = fmt(buf, 2);
|
||||
}
|
||||
|
||||
function fmtChainQuotedAt() {
|
||||
if (!chainQuotedAt) return "";
|
||||
const d = new Date(chainQuotedAt);
|
||||
const pad = function (n) {
|
||||
return n < 10 ? "0" + n : String(n);
|
||||
};
|
||||
return pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
|
||||
}
|
||||
|
||||
function renderIndexLine() {
|
||||
const idx = state.chain && state.chain.index_px;
|
||||
const dte = state.chain && state.chain.chain_max_dte_days;
|
||||
@@ -602,12 +669,234 @@
|
||||
const line = document.getElementById("opt-index-line");
|
||||
if (line) {
|
||||
const liqHint = askLiqFilterOn() ? "仅显示卖一深度≥1张" : "显示全部卖一(含估算~)";
|
||||
let liveHint = "";
|
||||
if (quoteLiveOk && quoteLiveWsOk) {
|
||||
liveHint = chainQuotedAt
|
||||
? " · WS实时 " + fmtChainQuotedAt()
|
||||
: " · WS实时";
|
||||
} else if (quoteLiveOk) {
|
||||
liveHint = " · 推送已连,等待 OKX WS…";
|
||||
} else if (chainQuotedAt) {
|
||||
liveHint = " · 链报价 " + fmtChainQuotedAt() + "(REST兜底)";
|
||||
}
|
||||
line.textContent =
|
||||
"指数 " + state.underlying + " ≈ " + fmt(idx, 2) +
|
||||
" · 默认最近一期 · " + liqHint + " · 实值含平值 · 虚值=价外";
|
||||
" · 默认最近一期 · " + liqHint + " · 实值含平值 · 虚值=价外" + liveHint;
|
||||
}
|
||||
}
|
||||
|
||||
function findChainContract(instId) {
|
||||
if (!state.chain || !instId) return null;
|
||||
const exps = state.chain.expiries || [];
|
||||
for (let i = 0; i < exps.length; i++) {
|
||||
const contracts = exps[i].contracts || [];
|
||||
for (let j = 0; j < contracts.length; j++) {
|
||||
if (String(contracts[j].inst_id) === String(instId)) return contracts[j];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function currentExpiryContracts() {
|
||||
if (!state.chain) return [];
|
||||
const expMs = (document.getElementById("opt-exp-select") || {}).value;
|
||||
const exp = (state.chain.expiries || []).find(function (e) {
|
||||
return String(e.exp_time) === String(expMs);
|
||||
});
|
||||
return (exp && exp.contracts) || [];
|
||||
}
|
||||
|
||||
async function watchCurrentExpiryQuotes() {
|
||||
if (!state.chain) return;
|
||||
const expMs = (document.getElementById("opt-exp-select") || {}).value;
|
||||
const contracts = currentExpiryContracts().map(function (c) {
|
||||
return {
|
||||
inst_id: c.inst_id,
|
||||
opt_type: c.opt_type,
|
||||
strike: c.strike,
|
||||
tick_sz: c.tick_sz,
|
||||
};
|
||||
});
|
||||
if (!contracts.length) return;
|
||||
try {
|
||||
await apiJson("/api/options/quotes/watch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
underlying: state.underlying,
|
||||
exp_time: expMs,
|
||||
contracts: contracts,
|
||||
index_inst_id: state.underlying + "-USD",
|
||||
watcher_id: panelCache.quoteWatcherId,
|
||||
}),
|
||||
});
|
||||
} catch (_) {
|
||||
/* ignore watch errors; soft poll fallback remains */
|
||||
}
|
||||
}
|
||||
|
||||
function patchListRowDom(instId, c) {
|
||||
const tr = document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-inst="' + instId + '"]');
|
||||
if (!tr || !c) return;
|
||||
const indexPx = state.chain && state.chain.index_px;
|
||||
const tds = tr.children;
|
||||
if (tds.length < 8) return;
|
||||
tds[3].textContent = "";
|
||||
tds[3].className = "opt-px-sz";
|
||||
tds[3].innerHTML = fmtPxSz(c.ask, c.ask_sz, c.ask_estimated);
|
||||
tds[4].className = "opt-chain-lev";
|
||||
tds[4].textContent = fmtChainLeverage(calcAskLeverage(indexPx, c.ask));
|
||||
tds[5].className = "opt-px-sz";
|
||||
tds[5].innerHTML = fmtPxSz(c.bid, c.bid_sz);
|
||||
tds[6].textContent = c.expiry_be_px != null ? fmt(c.expiry_be_px, 0) : "—";
|
||||
tds[7].className = distBeClass(c.dist_expiry_be);
|
||||
tds[7].textContent = fmtDist(c.dist_expiry_be);
|
||||
}
|
||||
|
||||
function patchTRowDom(instId, c) {
|
||||
if (!c) return;
|
||||
const callTr = document.querySelector(
|
||||
'#opt-strike-tbody tr.opt-strike-row-t[data-call-inst="' + instId + '"]'
|
||||
);
|
||||
const putTr = document.querySelector(
|
||||
'#opt-strike-tbody tr.opt-strike-row-t[data-put-inst="' + instId + '"]'
|
||||
);
|
||||
const tr = callTr || putTr;
|
||||
if (!tr) return;
|
||||
const callInst = tr.getAttribute("data-call-inst");
|
||||
const putInst = tr.getAttribute("data-put-inst");
|
||||
const call = callInst ? findChainContract(callInst) : null;
|
||||
const put = putInst ? findChainContract(putInst) : null;
|
||||
const callOk = call && (!askLiqFilterOn() || hasAskLiquidity(call)) ? call : null;
|
||||
const putOk = put && (!askLiqFilterOn() || hasAskLiquidity(put)) ? put : null;
|
||||
const tds = tr.children;
|
||||
if (tds.length < 9) return;
|
||||
tds[0].innerHTML = callOk ? fmtPxSz(callOk.ask, callOk.ask_sz, callOk.ask_estimated) : "—";
|
||||
tds[7].innerHTML = putOk ? fmtPxSz(putOk.ask, putOk.ask_sz, putOk.ask_estimated) : "—";
|
||||
const combined = straddleAskPerUnit(callOk && callOk.ask, putOk && putOk.ask);
|
||||
tds[4].innerHTML = formatStraddlePremiumCell(callOk && callOk.ask, putOk && putOk.ask);
|
||||
tds[5].innerHTML = formatStraddleBand(tr.getAttribute("data-strike"), combined);
|
||||
}
|
||||
|
||||
function applyLiveQuotes(payload) {
|
||||
if (!payload || !state.chain) return;
|
||||
const uly = String(state.underlying || "").toUpperCase();
|
||||
if (payload.indexes && payload.indexes[uly] != null && Number.isFinite(Number(payload.indexes[uly]))) {
|
||||
state.chain.index_px = Number(payload.indexes[uly]);
|
||||
} else if (payload.underlying && String(payload.underlying).toUpperCase() === uly) {
|
||||
if (payload.index_px != null && Number.isFinite(Number(payload.index_px))) {
|
||||
state.chain.index_px = Number(payload.index_px);
|
||||
}
|
||||
} else if (payload.underlying && String(payload.underlying).toUpperCase() !== uly) {
|
||||
// 别的标的推送:仍可 patch 本页已有合约
|
||||
}
|
||||
const quotes = payload.quotes || [];
|
||||
quotes.forEach(function (q) {
|
||||
const instId = q && q.inst_id;
|
||||
if (!instId) return;
|
||||
if (q.underlying && String(q.underlying).toUpperCase() !== uly) return;
|
||||
const c = findChainContract(instId);
|
||||
if (!c) return;
|
||||
if (q.ask !== undefined) c.ask = q.ask;
|
||||
if (q.bid !== undefined) c.bid = q.bid;
|
||||
if (q.ask_sz !== undefined) c.ask_sz = q.ask_sz;
|
||||
if (q.bid_sz !== undefined) c.bid_sz = q.bid_sz;
|
||||
if (q.mark_px !== undefined) c.mark_px = q.mark_px;
|
||||
if (q.ask_estimated !== undefined) c.ask_estimated = !!q.ask_estimated;
|
||||
if (q.expiry_be_px !== undefined) c.expiry_be_px = q.expiry_be_px;
|
||||
if (q.dist_expiry_be !== undefined) c.dist_expiry_be = q.dist_expiry_be;
|
||||
if (state.chainView === "t") patchTRowDom(instId, c);
|
||||
else patchListRowDom(instId, c);
|
||||
});
|
||||
if (payload.ts) chainQuotedAt = Number(payload.ts) || Date.now();
|
||||
else if (quotes.length || payload.index_px != null) chainQuotedAt = Date.now();
|
||||
quoteLiveWsOk = payload.ws_ok !== false;
|
||||
renderIndexLine();
|
||||
if (state.selectedInst && quotes.some(function (q) { return q && q.inst_id === state.selectedInst; })) {
|
||||
const now = Date.now();
|
||||
if (now - lastOrderQuoteLiveAt >= ORDER_QUOTE_LIVE_MIN_MS) {
|
||||
lastOrderQuoteLiveAt = now;
|
||||
void selectContract(state.selectedInst, null, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopQuoteLiveStream() {
|
||||
if (quoteLiveReconnectTimer) {
|
||||
clearTimeout(quoteLiveReconnectTimer);
|
||||
quoteLiveReconnectTimer = null;
|
||||
}
|
||||
if (quoteLiveEs) {
|
||||
try { quoteLiveEs.close(); } catch (_) {}
|
||||
quoteLiveEs = null;
|
||||
}
|
||||
quoteLiveOk = false;
|
||||
quoteLiveWsOk = false;
|
||||
}
|
||||
|
||||
function startQuoteLiveStream() {
|
||||
if (quoteLiveEs) return;
|
||||
if (typeof EventSource === "undefined") return;
|
||||
try {
|
||||
quoteLiveEs = new EventSource("/api/options/quotes/stream");
|
||||
} catch (_) {
|
||||
quoteLiveOk = false;
|
||||
return;
|
||||
}
|
||||
quoteLiveEs.addEventListener("quotes", function (ev) {
|
||||
try {
|
||||
const data = JSON.parse(ev.data || "{}");
|
||||
quoteLiveOk = true;
|
||||
if (data.reason === "connect") {
|
||||
quoteLiveWsOk = !!data.ws_ok;
|
||||
renderIndexLine();
|
||||
return;
|
||||
}
|
||||
applyLiveQuotes(data);
|
||||
} catch (_) {}
|
||||
});
|
||||
quoteLiveEs.onopen = function () {
|
||||
quoteLiveOk = true;
|
||||
renderIndexLine();
|
||||
void watchCurrentExpiryQuotes();
|
||||
};
|
||||
quoteLiveEs.onerror = function () {
|
||||
quoteLiveOk = false;
|
||||
quoteLiveWsOk = false;
|
||||
renderIndexLine();
|
||||
stopQuoteLiveStream();
|
||||
quoteLiveReconnectTimer = setTimeout(function () {
|
||||
quoteLiveReconnectTimer = null;
|
||||
startQuoteLiveStream();
|
||||
}, 8000);
|
||||
};
|
||||
}
|
||||
|
||||
function softRefreshChainThrottled(force) {
|
||||
if (document.hidden) return;
|
||||
if (!document.getElementById("options-root")) return;
|
||||
// WS 推送正常时不靠 REST 刷卖一,避免 50011;仅结构兜底可 force
|
||||
if (!force && quoteLiveOk && quoteLiveWsOk) return;
|
||||
const now = Date.now();
|
||||
if (!force && now - lastChainSoftAt < CHAIN_SOFT_POLL_MS) return;
|
||||
lastChainSoftAt = now;
|
||||
void loadChain({ soft: true });
|
||||
}
|
||||
|
||||
function startChainSoftPoll() {
|
||||
if (chainSoftTimer) return;
|
||||
chainSoftTimer = setInterval(function () {
|
||||
if (!document.getElementById("options-root")) {
|
||||
if (chainSoftTimer) {
|
||||
clearInterval(chainSoftTimer);
|
||||
chainSoftTimer = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
softRefreshChainThrottled(false);
|
||||
}, CHAIN_SOFT_POLL_MS);
|
||||
}
|
||||
|
||||
function pickNearestExpiry(exps) {
|
||||
if (!exps || !exps.length) return "";
|
||||
const now = Date.now();
|
||||
@@ -710,15 +999,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) {
|
||||
@@ -817,6 +1118,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);
|
||||
@@ -825,11 +1140,10 @@
|
||||
}
|
||||
|
||||
function updateOrderEstimates() {
|
||||
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 targetEl = document.getElementById("opt-target-idx");
|
||||
const levEl = document.getElementById("opt-order-leverage");
|
||||
const rrEl = document.getElementById("opt-profit-rr");
|
||||
const q = state.orderQuote;
|
||||
if (!q || !q.ok || !q.can_open) {
|
||||
if (levEl) levEl.textContent = "—";
|
||||
@@ -838,7 +1152,6 @@
|
||||
profitEl.textContent = "—";
|
||||
profitEl.className = "v";
|
||||
}
|
||||
if (targetLevEl) targetLevEl.textContent = "—";
|
||||
return;
|
||||
}
|
||||
const sz = q.sizing || {};
|
||||
@@ -847,30 +1160,19 @@
|
||||
const lev = calcContractLeverage(q.index_px, ethAmount, premium);
|
||||
if (levEl) levEl.textContent = fmtLeverage(lev);
|
||||
|
||||
if (valueEl && profitEl && targetEl) {
|
||||
const targetRaw = targetEl.value;
|
||||
if (targetRaw === "" || targetRaw == null) {
|
||||
if (valueEl && profitEl && rrEl) {
|
||||
const rrRaw = rrEl.value;
|
||||
const rr = rrRaw === "" || rrRaw == null ? NaN : Number(rrRaw);
|
||||
if (!Number.isFinite(rr) || rr <= 0 || !(Number(premium) > 0)) {
|
||||
valueEl.textContent = "—";
|
||||
profitEl.textContent = "—";
|
||||
profitEl.className = "v";
|
||||
if (targetLevEl) targetLevEl.textContent = "—";
|
||||
} else {
|
||||
const value = estimateExpiryValue(q.opt_type, q.strike, Number(targetRaw), ethAmount);
|
||||
const profit = estimateExpiryProfit(q.opt_type, q.strike, Number(targetRaw), ethAmount, premium);
|
||||
if (value == null || Number.isNaN(value)) {
|
||||
valueEl.textContent = "—";
|
||||
} else {
|
||||
valueEl.textContent = fmtUsdc(value) + " USDC";
|
||||
}
|
||||
if (profit == null || Number.isNaN(profit)) {
|
||||
profitEl.textContent = "—";
|
||||
profitEl.className = "v";
|
||||
} else {
|
||||
profitEl.textContent = fmtUsdcSigned(profit);
|
||||
profitEl.className = "v " + pnlCls(profit);
|
||||
}
|
||||
const targetLev = calcContractLeverage(Number(targetRaw), ethAmount, premium);
|
||||
if (targetLevEl) targetLevEl.textContent = fmtLeverage(targetLev);
|
||||
const targetProfit = Number(premium) * rr;
|
||||
const needRecycle = Number(premium) + targetProfit;
|
||||
valueEl.textContent = fmtUsdc(needRecycle) + " USDC";
|
||||
profitEl.textContent = fmtUsdcSigned(targetProfit);
|
||||
profitEl.className = "v " + pnlCls(targetProfit);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -936,7 +1238,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 +1249,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 +1278,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,7 +1348,7 @@
|
||||
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);
|
||||
@@ -1131,8 +1450,14 @@
|
||||
const uly = state.underlying;
|
||||
const seq = ++chainLoadSeq;
|
||||
const btn = document.getElementById("opt-load-chain");
|
||||
if (btn && !soft) btn.disabled = true;
|
||||
if (!soft) {
|
||||
const hadChain = chainHasExpiries(state.chain) && state.chain.underlying === uly;
|
||||
if (btn && !soft) {
|
||||
btn.disabled = true;
|
||||
if (!btn.dataset.origText) btn.dataset.origText = btn.textContent || "刷新链";
|
||||
btn.textContent = "刷新中…";
|
||||
}
|
||||
// 已有链时不先清空表格,避免「白屏等很久」的体感
|
||||
if (!soft && !hadChain) {
|
||||
setExpirySelectStatus("加载到期日中…");
|
||||
const tbody = document.getElementById("opt-strike-tbody");
|
||||
if (tbody) {
|
||||
@@ -1143,16 +1468,34 @@
|
||||
try {
|
||||
let d = null;
|
||||
let lastMsg = "";
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const expMs = (document.getElementById("opt-exp-select") || {}).value || "";
|
||||
// WS 已热时走 fast,跳过最慢的整家族 REST tickers
|
||||
const useFast = soft || quoteLiveWsOk || hadChain;
|
||||
const maxAttempts = soft ? 2 : 3;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (seq !== chainLoadSeq) return;
|
||||
d = await apiJson("/api/options/chain?underlying=" + encodeURIComponent(uly));
|
||||
let url =
|
||||
"/api/options/chain?underlying=" +
|
||||
encodeURIComponent(uly) +
|
||||
(useFast ? "&fast=1" : "");
|
||||
if (expMs) url += "&exp_time=" + encodeURIComponent(expMs);
|
||||
d = await apiJson(url);
|
||||
if (seq !== chainLoadSeq) return;
|
||||
if (d && d.ok && chainHasExpiries(d)) break;
|
||||
lastMsg = (d && (d.msg || d.chain_error)) || "暂无到期日";
|
||||
const rateLimited =
|
||||
!!(d && d.rate_limited) ||
|
||||
/50011|Too Many Requests|过于频繁/i.test(String(lastMsg || ""));
|
||||
d = null;
|
||||
if (attempt === 0) {
|
||||
if (!soft) setExpirySelectStatus("重试加载到期日…");
|
||||
await new Promise(function (resolve) { setTimeout(resolve, 400); });
|
||||
if (attempt < maxAttempts - 1) {
|
||||
if (!soft && !hadChain) {
|
||||
setExpirySelectStatus(
|
||||
rateLimited ? "OKX 限频,稍后重试…" : "重试加载到期日…"
|
||||
);
|
||||
}
|
||||
await new Promise(function (resolve) {
|
||||
setTimeout(resolve, rateLimited ? 1200 * (attempt + 1) : 300);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (seq !== chainLoadSeq) return;
|
||||
@@ -1161,28 +1504,37 @@
|
||||
if (!soft) {
|
||||
renderExpiries();
|
||||
renderStrikes();
|
||||
void watchCurrentExpiryQuotes();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (soft) return;
|
||||
setExpirySelectStatus("选择到期日");
|
||||
const tbody = document.getElementById("opt-strike-tbody");
|
||||
const friendly =
|
||||
/50011|Too Many Requests|过于频繁/i.test(String(lastMsg || ""))
|
||||
? "OKX 请求过于频繁,请稍后再点「刷新链」"
|
||||
: lastMsg || "暂无到期日,请点「刷新链」";
|
||||
if (tbody) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="' + strikeTableColspan() + '" class="muted">' +
|
||||
(lastMsg || "暂无到期日,请点「刷新链」") +
|
||||
'<tr><td colspan="' +
|
||||
strikeTableColspan() +
|
||||
'" class="muted">' +
|
||||
friendly +
|
||||
"</td></tr>";
|
||||
}
|
||||
alert(lastMsg || "加载到期日失败,请点「刷新链」重试");
|
||||
alert(friendly);
|
||||
return;
|
||||
}
|
||||
const keepExp = soft ? (document.getElementById("opt-exp-select") || {}).value : "";
|
||||
const keepExp = soft || hadChain ? (document.getElementById("opt-exp-select") || {}).value : "";
|
||||
state.chain = d;
|
||||
panelCache.chain = d;
|
||||
panelCache.underlying = uly;
|
||||
panelCache.optType = state.optType;
|
||||
chainQuotedAt = Date.now();
|
||||
lastChainSoftAt = chainQuotedAt;
|
||||
syncAskLiqFilterFromChain(d);
|
||||
if (!soft) {
|
||||
if (!soft && !hadChain) {
|
||||
state.selectedInst = null;
|
||||
resetMoneyFilterToAll();
|
||||
state.strikeExpandAll = false;
|
||||
@@ -1192,16 +1544,19 @@
|
||||
}
|
||||
updateUnderlyingLabel();
|
||||
renderExpiries();
|
||||
if (soft && keepExp) {
|
||||
if (keepExp) {
|
||||
const sel = document.getElementById("opt-exp-select");
|
||||
if (sel && Array.from(sel.options).some(function (o) { return o.value === keepExp; })) {
|
||||
sel.value = keepExp;
|
||||
}
|
||||
}
|
||||
// soft 时保留 selectedInst;renderStrikes 会先 park 再按 prevSelected 静默重挂下单面板
|
||||
// soft/已有链时保留 selectedInst;renderStrikes 会先 park 再按 prevSelected 静默重挂下单面板
|
||||
renderStrikes();
|
||||
void watchCurrentExpiryQuotes();
|
||||
startQuoteLiveStream();
|
||||
} catch (e) {
|
||||
if (seq !== chainLoadSeq || soft) return;
|
||||
if (hadChain) return;
|
||||
setExpirySelectStatus("选择到期日");
|
||||
const tbody = document.getElementById("opt-strike-tbody");
|
||||
if (tbody) {
|
||||
@@ -1211,7 +1566,10 @@
|
||||
"</td></tr>";
|
||||
}
|
||||
} finally {
|
||||
if (seq === chainLoadSeq && btn) btn.disabled = false;
|
||||
if (seq === chainLoadSeq && btn) {
|
||||
btn.disabled = false;
|
||||
if (btn.dataset.origText) btn.textContent = btn.dataset.origText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1243,15 +1601,13 @@
|
||||
} else if (mode === "sheets") {
|
||||
body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10);
|
||||
}
|
||||
const tgtRaw = (document.getElementById("opt-target-idx").value || "").trim();
|
||||
if (tgtRaw !== "") {
|
||||
const tgt = parseFloat(tgtRaw);
|
||||
if (!Number.isFinite(tgt) || tgt <= 0) {
|
||||
alert("目标位无效");
|
||||
return false;
|
||||
}
|
||||
body.target_index = tgt;
|
||||
const rrRaw = (document.getElementById("opt-profit-rr").value || "").trim();
|
||||
const rr = rrRaw === "" ? 2 : parseFloat(rrRaw);
|
||||
if (!Number.isFinite(rr) || rr <= 0) {
|
||||
alert("盈亏比无效");
|
||||
return false;
|
||||
}
|
||||
body.profit_rr = rr;
|
||||
const d = await apiJson("/api/options/open", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -1342,15 +1698,17 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
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 "";
|
||||
function formatRrEstimateHtml(rr, premiumPaid) {
|
||||
const r = Number(rr);
|
||||
const prem = Number(premiumPaid);
|
||||
if (!Number.isFinite(r) || r <= 0 || !Number.isFinite(prem) || prem <= 0) return "";
|
||||
const profit = Math.round(prem * r * 100) / 100;
|
||||
const need = Math.round((prem + profit) * 100) / 100;
|
||||
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(profit) + '">' +
|
||||
fmtUsdcSigned(profit) + "</span></span>";
|
||||
html += '<span class="opt-target-est-item"><span class="k">需回收</span><span class="v">' +
|
||||
fmtUsdc(need) + " USDC</span></span>";
|
||||
html += "</span>";
|
||||
return html;
|
||||
}
|
||||
@@ -1358,47 +1716,73 @@
|
||||
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 ≥";
|
||||
if (hedgeTarget && hedgeTarget.managed_by === "hedge_plan") {
|
||||
const rr = hedgeTarget.oo_profit_rr != null ? Number(hedgeTarget.oo_profit_rr) : null;
|
||||
const armedTxt =
|
||||
rr != null && Number.isFinite(rr) && rr > 0
|
||||
? "盈亏比 ×" + fmt(rr, 2)
|
||||
: hedgeTarget.target_index != null
|
||||
? "目标 " + fmt(hedgeTarget.target_index, 1)
|
||||
: "托管中";
|
||||
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) +
|
||||
armedTxt +
|
||||
"</span>" +
|
||||
'<span class="muted opt-target-row-hint">进行中 · 由对冲计划监控,到位后仅平盈利腿</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;
|
||||
const ethAmt = posEthAmount(p);
|
||||
const rrArmed =
|
||||
p.profit_rr != null && p.profit_rr !== ""
|
||||
? Number(p.profit_rr)
|
||||
: p.target_monitor && p.target_monitor.profit_rr != null
|
||||
? Number(p.target_monitor.profit_rr)
|
||||
: null;
|
||||
const armed = rrArmed != null && Number.isFinite(rrArmed) && rrArmed > 0;
|
||||
const prem = p.premium_paid;
|
||||
const draft =
|
||||
state.targetDraftByInst[inst] != null
|
||||
? String(state.targetDraftByInst[inst])
|
||||
: armed
|
||||
? String(rrArmed)
|
||||
: "2";
|
||||
const estHtml = armed
|
||||
? formatTargetEstimateHtml(p.opt_type, p.strike, tgt, ethAmt, prem)
|
||||
? formatRrEstimateHtml(rrArmed, prem)
|
||||
: '<span class="opt-target-est opt-target-est--idle"></span>';
|
||||
return (
|
||||
'<div class="opt-target-row" data-inst="' + inst + '"' +
|
||||
' data-opt-type="' + (p.opt_type || "") + '"' +
|
||||
' data-strike="' + (p.strike != null ? p.strike : "") + '"' +
|
||||
' data-eth="' + (ethAmt != null ? ethAmt : "") + '"' +
|
||||
' data-prem="' + (prem != null ? prem : "") + '"' +
|
||||
' data-armed-target="' + (armed ? tgt : "") + '">' +
|
||||
'<div class="opt-target-row" data-inst="' +
|
||||
inst +
|
||||
'"' +
|
||||
' data-prem="' +
|
||||
(prem != null ? prem : "") +
|
||||
'"' +
|
||||
' data-armed-rr="' +
|
||||
(armed ? rrArmed : "") +
|
||||
'">' +
|
||||
'<span class="opt-target-row-label">委托</span>' +
|
||||
'<input type="number" class="opt-pos-target-input" data-inst="' + inst + '" step="0.1" min="0" placeholder="监控目标指数" value="' +
|
||||
(state.targetDraftByInst[inst] != null ? String(state.targetDraftByInst[inst]) : "") + '">' +
|
||||
'<button type="button" class="btn-secondary opt-target-set-btn" data-inst="' + inst + '">设定</button>' +
|
||||
'<button type="button" class="btn-secondary opt-target-cancel-btn" data-inst="' + inst + '"' + (armed ? "" : " disabled") + ">取消</button>" +
|
||||
(armed
|
||||
? '<span class="opt-target-armed">目标 ' + fmt(tgt, 1) + "</span>"
|
||||
: "") +
|
||||
'<input type="number" class="opt-pos-target-input" data-inst="' +
|
||||
inst +
|
||||
'" step="0.1" min="0.1" placeholder="盈亏比" value="' +
|
||||
draft +
|
||||
'">' +
|
||||
'<button type="button" class="btn-secondary opt-target-set-btn" data-inst="' +
|
||||
inst +
|
||||
'">设定</button>' +
|
||||
'<button type="button" class="btn-secondary opt-target-cancel-btn" data-inst="' +
|
||||
inst +
|
||||
'"' +
|
||||
(armed ? "" : " disabled") +
|
||||
">取消</button>" +
|
||||
(armed ? '<span class="opt-target-armed">盈亏比 ×' + fmt(rrArmed, 2) + "</span>" : "") +
|
||||
estHtml +
|
||||
'<span class="muted opt-target-row-hint">' +
|
||||
(armed ? "监控中 · 到位按买一限价平" : "输入后设定 · 到位按买一限价平 · 到期即止损") +
|
||||
(armed
|
||||
? "监控中 · 买一浮盈达盈亏比后全平"
|
||||
: "默认2 · 买一浮盈达盈亏比×权利金后全平 · 不达标等到期") +
|
||||
"</span>" +
|
||||
"</div>"
|
||||
);
|
||||
@@ -1410,20 +1794,14 @@
|
||||
if (!est) return;
|
||||
const inp = row.querySelector(".opt-pos-target-input");
|
||||
const typed = inp ? String(inp.value || "").trim() : "";
|
||||
const armed = row.getAttribute("data-armed-target") || "";
|
||||
const targetRaw = typed !== "" ? typed : armed;
|
||||
if (targetRaw === "") {
|
||||
const armed = row.getAttribute("data-armed-rr") || "";
|
||||
const rrRaw = typed !== "" ? typed : armed;
|
||||
if (rrRaw === "") {
|
||||
est.className = "opt-target-est opt-target-est--idle";
|
||||
est.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const html = formatTargetEstimateHtml(
|
||||
row.getAttribute("data-opt-type"),
|
||||
row.getAttribute("data-strike"),
|
||||
targetRaw,
|
||||
row.getAttribute("data-eth"),
|
||||
row.getAttribute("data-prem")
|
||||
);
|
||||
const html = formatRrEstimateHtml(rrRaw, row.getAttribute("data-prem"));
|
||||
if (!html) {
|
||||
est.className = "opt-target-est opt-target-est--idle";
|
||||
est.innerHTML = "";
|
||||
@@ -1555,9 +1933,9 @@
|
||||
const row = card ? card.querySelector(".opt-target-row") : null;
|
||||
const inp = card ? card.querySelector(".opt-pos-target-input") : null;
|
||||
const raw = inp ? String(inp.value || "").trim() : "";
|
||||
const tgt = parseFloat(raw);
|
||||
if (!Number.isFinite(tgt) || tgt <= 0) {
|
||||
alert("请输入有效目标指数价");
|
||||
const rr = raw === "" ? 2 : parseFloat(raw);
|
||||
if (!Number.isFinite(rr) || rr <= 0) {
|
||||
alert("请输入有效盈亏比(相对权利金,默认2)");
|
||||
return;
|
||||
}
|
||||
if (btn) btn.disabled = true;
|
||||
@@ -1565,17 +1943,16 @@
|
||||
const d = await apiJson("/api/options/target", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ inst_id: inst, target_index: tgt }),
|
||||
body: JSON.stringify({ inst_id: inst, profit_rr: rr }),
|
||||
});
|
||||
if (!d.ok) {
|
||||
alert(d.msg || "设定失败");
|
||||
return;
|
||||
}
|
||||
delete state.targetDraftByInst[inst];
|
||||
if (inp) inp.value = "";
|
||||
if (inp) inp.value = String(rr);
|
||||
if (row) {
|
||||
row.setAttribute("data-armed-target", String(tgt));
|
||||
updatePosTargetEstimate(row);
|
||||
row.setAttribute("data-armed-rr", String(rr));
|
||||
}
|
||||
await refreshAllPositions();
|
||||
} finally {
|
||||
@@ -1615,12 +1992,22 @@
|
||||
}
|
||||
box.hidden = false;
|
||||
host.innerHTML = rows.map(function (t) {
|
||||
const side = (t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥";
|
||||
const managed = t.managed_by === "hedge_plan";
|
||||
let rule;
|
||||
if (t.profit_rr != null && Number(t.profit_rr) > 0) {
|
||||
rule = "盈亏比 ×" + fmt(t.profit_rr, 2);
|
||||
} else if (t.oo_profit_rr != null && Number(t.oo_profit_rr) > 0) {
|
||||
rule = "盈亏比 ×" + fmt(t.oo_profit_rr, 2);
|
||||
} else if (t.target_index != null && Number(t.target_index) > 0) {
|
||||
const side = (t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥";
|
||||
rule = side + " " + fmt(t.target_index, 1);
|
||||
} else {
|
||||
rule = "委托中";
|
||||
}
|
||||
return (
|
||||
'<div class="opt-target-mon-item' + (managed ? " opt-target-mon-item--managed" : "") + '">' +
|
||||
'<code class="opt-target-mon-inst" title="' + (t.inst_id || "") + '">' + (t.inst_id || "") + "</code>" +
|
||||
'<span class="opt-target-mon-rule">' + side + " " + fmt(t.target_index, 1) + "</span>" +
|
||||
'<span class="opt-target-mon-rule">' + rule + "</span>" +
|
||||
(managed
|
||||
? '<span class="opt-target-mon-managed">对冲计划 #' + (t.plan_id || "") + " · 进行中</span>"
|
||||
: '<button type="button" class="btn-secondary opt-target-mon-cancel" data-inst="' + (t.inst_id || "") + '">取消</button>') +
|
||||
@@ -1801,20 +2188,23 @@
|
||||
paintPositions(list);
|
||||
const fromPos = list.reduce(function (targets, p) {
|
||||
if (!p) return targets;
|
||||
if (p.target_index != null) {
|
||||
if (p.profit_rr != null || p.target_index != null) {
|
||||
targets.push({
|
||||
id: p.target_monitor_id,
|
||||
inst_id: p.inst_id,
|
||||
opt_type: p.opt_type,
|
||||
target_index: p.target_index,
|
||||
profit_rr: p.profit_rr,
|
||||
});
|
||||
}
|
||||
const hedgeTarget = p.hedge_plan_target;
|
||||
if (hedgeTarget && hedgeTarget.target_index != null) {
|
||||
if (hedgeTarget) {
|
||||
targets.push({
|
||||
inst_id: p.inst_id,
|
||||
opt_type: p.opt_type || hedgeTarget.opt_type,
|
||||
target_index: hedgeTarget.target_index,
|
||||
oo_profit_rr: hedgeTarget.oo_profit_rr,
|
||||
profit_rr: hedgeTarget.oo_profit_rr,
|
||||
plan_id: hedgeTarget.plan_id,
|
||||
managed_by: hedgeTarget.managed_by,
|
||||
});
|
||||
@@ -2076,6 +2466,7 @@
|
||||
const expandCb = document.getElementById("opt-strike-expand-all");
|
||||
if (expandCb) expandCb.checked = false;
|
||||
renderStrikes();
|
||||
void watchCurrentExpiryQuotes();
|
||||
}
|
||||
|
||||
function bootOptionsPanel() {
|
||||
@@ -2086,6 +2477,8 @@
|
||||
updateUnderlyingLabel();
|
||||
refreshPendingOrders();
|
||||
startPendingOrdersPoll();
|
||||
startChainSoftPoll();
|
||||
startQuoteLiveStream();
|
||||
const hasCache =
|
||||
chainHasExpiries(panelCache.chain) &&
|
||||
panelCache.underlying === state.underlying &&
|
||||
@@ -2095,8 +2488,9 @@
|
||||
renderExpiries();
|
||||
renderStrikes();
|
||||
refreshAllPositions();
|
||||
// 后台静默刷新,避免缓存过期后到期日变空
|
||||
loadChain({ soft: true });
|
||||
void watchCurrentExpiryQuotes();
|
||||
// 后台静默刷新结构;卖一优先走 WS
|
||||
softRefreshChainThrottled(true);
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(function () {
|
||||
@@ -2133,6 +2527,11 @@
|
||||
if (expandAllCb) {
|
||||
expandAllCb.addEventListener("change", function () {
|
||||
state.strikeExpandAll = !!expandAllCb.checked;
|
||||
// 实值/虚值筛选下档位本来就少,展开几乎不变;勾选时切回「全部」才有意义
|
||||
if (state.strikeExpandAll && state.moneyFilter !== "all") {
|
||||
state.moneyFilter = "all";
|
||||
syncMoneyFilterButtons();
|
||||
}
|
||||
renderStrikes();
|
||||
});
|
||||
}
|
||||
@@ -2195,17 +2594,17 @@
|
||||
}
|
||||
bindOrderDialogChrome();
|
||||
|
||||
["opt-sheets-amount", "opt-eth-amount", "opt-target-idx"].forEach(function (id) {
|
||||
["opt-sheets-amount", "opt-eth-amount", "opt-profit-rr"].forEach(function (id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.addEventListener("change", function () {
|
||||
if (id === "opt-target-idx") {
|
||||
if (id === "opt-profit-rr") {
|
||||
updateEstimatedProfit();
|
||||
return;
|
||||
}
|
||||
if (state.selectedInst) selectContract(state.selectedInst, null, true);
|
||||
});
|
||||
if (id === "opt-target-idx") {
|
||||
if (id === "opt-profit-rr") {
|
||||
el.addEventListener("input", updateEstimatedProfit);
|
||||
}
|
||||
});
|
||||
@@ -2215,6 +2614,8 @@
|
||||
window.OptionsPanelLive = {
|
||||
refreshSoft: function () {
|
||||
refreshAllPositions();
|
||||
// 有 WS 实时报价时不再 REST 刷链;断开时才兜底
|
||||
softRefreshChainThrottled(false);
|
||||
},
|
||||
refreshChain: loadChain,
|
||||
};
|
||||
|
||||
@@ -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>" +
|
||||
@@ -207,38 +219,42 @@
|
||||
const hint = closeGateHint(closePreview);
|
||||
return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : "";
|
||||
})() +
|
||||
(p.target_index != null
|
||||
(p.profit_rr != null || p.target_index != null
|
||||
? (function () {
|
||||
const eth = p.eth_amount != null ? Number(p.eth_amount)
|
||||
: (Number(p.pos) > 0 ? Number(p.pos) * Number(p.ct_mult || 0.01) : null);
|
||||
const strike = Number(p.strike);
|
||||
const tgt = Number(p.target_index);
|
||||
const hedgeTarget = p.hedge_plan_target || null;
|
||||
const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
|
||||
const rr =
|
||||
managed && hedgeTarget.oo_profit_rr != null
|
||||
? Number(hedgeTarget.oo_profit_rr)
|
||||
: p.profit_rr != null
|
||||
? Number(p.profit_rr)
|
||||
: null;
|
||||
const prem = Number(p.premium_paid);
|
||||
let profit = null;
|
||||
let value = null;
|
||||
if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) {
|
||||
const o = String(p.opt_type || "").toUpperCase();
|
||||
const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null;
|
||||
if (intrinsic != null) {
|
||||
value = Math.round(intrinsic * eth * 100) / 100;
|
||||
if (!hidePnl && Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100;
|
||||
}
|
||||
let need = null;
|
||||
if (rr != null && Number.isFinite(rr) && rr > 0 && Number.isFinite(prem) && prem > 0) {
|
||||
profit = Math.round(prem * rr * 100) / 100;
|
||||
need = Math.round((prem + profit) * 100) / 100;
|
||||
}
|
||||
const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC");
|
||||
const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : "";
|
||||
const hedgeTarget = p.hedge_plan_target || null;
|
||||
const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
|
||||
const profitSpan = hidePnl
|
||||
? ""
|
||||
: '<span class="pos-value' + profitCls + '">预估盈利 ' + profitTxt + "</span>";
|
||||
: '<span class="pos-value' + profitCls + '">目标盈利 ' + profitTxt + "</span>";
|
||||
const ruleTxt =
|
||||
rr != null && Number.isFinite(rr) && rr > 0
|
||||
? "盈亏比 ×" + fmt(rr, 2)
|
||||
: p.target_index != null
|
||||
? "目标 " + fmt(p.target_index, 1)
|
||||
: "委托中";
|
||||
return (
|
||||
'<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' +
|
||||
'<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" +
|
||||
'<span class="pos-value">目标 ' + fmt(p.target_index, 1) + "</span>" +
|
||||
'<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "</span>" +
|
||||
'<span class="pos-value">' + ruleTxt + "</span>" +
|
||||
(need != null ? '<span class="pos-value">需回收 ' + fmtUsdc(need) + " USDC</span>" : "") +
|
||||
profitSpan +
|
||||
'<span class="muted opt-target-row-hint">' +
|
||||
(managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") +
|
||||
(managed ? "进行中 · 由对冲计划监控" : "监控中 · 买一浮盈达盈亏比后全平") +
|
||||
"</span></div>"
|
||||
);
|
||||
})()
|
||||
|
||||
@@ -76,6 +76,8 @@
|
||||
target_win_leg: "期期平盈利腿",
|
||||
target_up_win_leg: "期期上破·平盈利腿",
|
||||
target_down_win_leg: "期期下破·平盈利腿",
|
||||
oo_rr_target: "期期盈亏比达标",
|
||||
oo_rr_closing: "期期盈亏比平仓中",
|
||||
oo_rest_closing: "期期全平·清残腿中",
|
||||
oo_rest_closed: "期期全平·两腿已平",
|
||||
orphaned_after_tp: "止盈后持有至到期",
|
||||
@@ -248,8 +250,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 +1328,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
+22
-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,15 @@ 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_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 +152,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 +223,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
+188
-57
@@ -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,42 @@ _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_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 +169,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 +269,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 +371,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,7 +446,9 @@ 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"]
|
||||
|
||||
@@ -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
|
||||
+202
-83
@@ -25,6 +25,14 @@ _OKX_OPTION_ERR_ZH: dict[str, str] = {
|
||||
}
|
||||
|
||||
_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None}
|
||||
# 期权合约列表变化慢;短缓存+限频退避,避免 50011 拖垮期权链
|
||||
_INSTRUMENTS_CACHE: dict[str, dict[str, Any]] = {}
|
||||
_INSTRUMENTS_CACHE_LOCK = threading.Lock()
|
||||
_INSTRUMENTS_CACHE_TTL_SEC = 90.0
|
||||
_INSTRUMENTS_STALE_SEC = 600.0
|
||||
_TICKERS_CACHE: dict[str, dict[str, Any]] = {}
|
||||
_TICKERS_CACHE_LOCK = threading.Lock()
|
||||
_TICKERS_CACHE_TTL_SEC = 8.0
|
||||
|
||||
|
||||
def invalidate_options_balance_cache() -> None:
|
||||
@@ -32,6 +40,14 @@ def invalidate_options_balance_cache() -> None:
|
||||
_OPTIONS_BALANCE_CACHE["data"] = None
|
||||
|
||||
|
||||
def invalidate_option_instruments_cache(inst_family: str | None = None) -> None:
|
||||
with _INSTRUMENTS_CACHE_LOCK:
|
||||
if inst_family:
|
||||
_INSTRUMENTS_CACHE.pop(str(inst_family), None)
|
||||
else:
|
||||
_INSTRUMENTS_CACHE.clear()
|
||||
|
||||
|
||||
def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str:
|
||||
row: dict[str, Any] | None = None
|
||||
if isinstance(resp, dict):
|
||||
@@ -72,16 +88,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 +174,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}"
|
||||
|
||||
@@ -600,7 +625,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,24 +661,105 @@ def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None:
|
||||
def fetch_option_instruments(
|
||||
ex: ccxt.okx,
|
||||
inst_family: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> 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"]
|
||||
"""拉取 live 期权合约列表;短 TTL 缓存,遇 50011 退避重试并可回退过期缓存."""
|
||||
family = (inst_family or "").strip()
|
||||
if not family:
|
||||
return []
|
||||
now = time.time()
|
||||
with _INSTRUMENTS_CACHE_LOCK:
|
||||
cached = _INSTRUMENTS_CACHE.get(family)
|
||||
if (
|
||||
not force
|
||||
and cached
|
||||
and now - float(cached.get("updated_at") or 0) < _INSTRUMENTS_CACHE_TTL_SEC
|
||||
and isinstance(cached.get("rows"), list)
|
||||
and cached["rows"]
|
||||
):
|
||||
return list(cached["rows"])
|
||||
|
||||
last_err: BaseException | None = None
|
||||
rows: list[dict[str, Any]] = []
|
||||
for attempt in range(4):
|
||||
try:
|
||||
raw = ex.public_get_public_instruments(
|
||||
{"instType": "OPTION", "instFamily": family}
|
||||
).get("data") or []
|
||||
rows = [r for r in raw if isinstance(r, dict) and r.get("state") == "live"]
|
||||
last_err = None
|
||||
break
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if _is_okx_rate_limit(e) and attempt < 3:
|
||||
time.sleep(0.8 * (2**attempt))
|
||||
continue
|
||||
break
|
||||
|
||||
if rows:
|
||||
with _INSTRUMENTS_CACHE_LOCK:
|
||||
_INSTRUMENTS_CACHE[family] = {"updated_at": time.time(), "rows": list(rows)}
|
||||
return rows
|
||||
|
||||
# 限频/短暂失败:优先用未过期太久的缓存,避免整页「拉取失败」
|
||||
if cached and isinstance(cached.get("rows"), list) and cached["rows"]:
|
||||
age = now - float(cached.get("updated_at") or 0)
|
||||
if age < _INSTRUMENTS_STALE_SEC and (
|
||||
last_err is None or _is_okx_rate_limit(last_err) or not rows
|
||||
):
|
||||
return list(cached["rows"])
|
||||
|
||||
if last_err is not None:
|
||||
raise last_err
|
||||
return []
|
||||
|
||||
|
||||
def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]:
|
||||
def fetch_option_tickers(
|
||||
ex: ccxt.okx,
|
||||
inst_family: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
family = (inst_family or "").strip()
|
||||
if not family:
|
||||
return {}
|
||||
now = time.time()
|
||||
with _TICKERS_CACHE_LOCK:
|
||||
cached = _TICKERS_CACHE.get(family)
|
||||
if (
|
||||
not force
|
||||
and cached
|
||||
and now - float(cached.get("updated_at") or 0) < _TICKERS_CACHE_TTL_SEC
|
||||
and isinstance(cached.get("rows"), dict)
|
||||
and cached["rows"]
|
||||
):
|
||||
return dict(cached["rows"])
|
||||
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
try:
|
||||
rows = ex.public_get_market_tickers(
|
||||
{"instType": "OPTION", "instFamily": inst_family}
|
||||
).get("data") or []
|
||||
for r in rows:
|
||||
if isinstance(r, dict) and r.get("instId"):
|
||||
out[str(r["instId"])] = r
|
||||
except Exception:
|
||||
pass
|
||||
last_err: BaseException | None = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
rows = ex.public_get_market_tickers(
|
||||
{"instType": "OPTION", "instFamily": family}
|
||||
).get("data") or []
|
||||
for r in rows:
|
||||
if isinstance(r, dict) and r.get("instId"):
|
||||
out[str(r["instId"])] = r
|
||||
if out:
|
||||
with _TICKERS_CACHE_LOCK:
|
||||
_TICKERS_CACHE[family] = {"updated_at": time.time(), "rows": dict(out)}
|
||||
return out
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if _is_okx_rate_limit(e) and attempt < 2:
|
||||
time.sleep(0.6 * (attempt + 1))
|
||||
continue
|
||||
break
|
||||
if cached and isinstance(cached.get("rows"), dict) and cached["rows"]:
|
||||
return dict(cached["rows"])
|
||||
if last_err is not None and _is_okx_rate_limit(last_err):
|
||||
return out
|
||||
return out
|
||||
|
||||
|
||||
@@ -662,6 +771,9 @@ def build_option_chain(
|
||||
itm_only: bool = True,
|
||||
itm_max_dist_usd: float = 30.0,
|
||||
index_px: float | None = None,
|
||||
tickers_override: dict[str, dict[str, Any]] | None = None,
|
||||
fetch_tickers: bool = True,
|
||||
force_tickers: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
u = (underlying or "ETH").upper()
|
||||
family = f"{u}-USD_UM"
|
||||
@@ -671,23 +783,24 @@ 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
|
||||
rate_limited = False
|
||||
try:
|
||||
instruments = fetch_option_instruments(ex, family)
|
||||
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)
|
||||
tickers = fetch_option_tickers(ex, family)
|
||||
except Exception as e:
|
||||
instruments = []
|
||||
instruments_err = str(e) or e.__class__.__name__
|
||||
rate_limited = _is_okx_rate_limit(e)
|
||||
if rate_limited:
|
||||
instruments_err = "OKX 请求过于频繁(50011),请稍后点「刷新链」重试"
|
||||
tickers: dict[str, dict[str, Any]] = {}
|
||||
if fetch_tickers:
|
||||
tickers = fetch_option_tickers(ex, family, force=force_tickers)
|
||||
if tickers_override:
|
||||
for iid, row in tickers_override.items():
|
||||
if isinstance(row, dict) and iid:
|
||||
tickers[str(iid)] = {**(tickers.get(str(iid)) or {}), **row}
|
||||
expiries: dict[str, list[dict[str, Any]]] = {}
|
||||
skipped_no_index = 0
|
||||
for meta in instruments:
|
||||
@@ -765,6 +878,8 @@ def build_option_chain(
|
||||
"expiries": exp_list,
|
||||
"instruments_count": len(instruments),
|
||||
}
|
||||
if rate_limited:
|
||||
out["rate_limited"] = True
|
||||
if not exp_list:
|
||||
if instruments_err:
|
||||
out["chain_error"] = f"拉取期权合约失败: {instruments_err}"
|
||||
@@ -1387,28 +1502,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 +1718,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,
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""OKX 公共 WebSocket(同步线程):订阅 tickers / index-tickers,自动重连."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OKX_PUBLIC_WS_URL = "wss://ws.okx.com:8443/ws/v5/public"
|
||||
_SUBSCRIBE_CHUNK = 40
|
||||
_APP_PING_SEC = 20.0
|
||||
|
||||
|
||||
class OkxPublicWs:
|
||||
"""单连接公共 WS;set_subscriptions 全量对齐目标频道."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
on_data: Callable[[dict[str, Any]], None],
|
||||
url: str = OKX_PUBLIC_WS_URL,
|
||||
name: str = "okx-public-ws",
|
||||
) -> None:
|
||||
self._on_data = on_data
|
||||
self._url = url
|
||||
self._name = name
|
||||
self._lock = threading.RLock()
|
||||
self._desired: dict[str, dict[str, str]] = {}
|
||||
self._active: set[str] = set()
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._ws: Any = None
|
||||
self._connected = False
|
||||
self._last_msg_at = 0.0
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def last_msg_at(self) -> float:
|
||||
return self._last_msg_at
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._run_loop, name=self._name, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
ws = self._ws
|
||||
if ws is not None:
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=3.0)
|
||||
|
||||
def set_subscriptions(self, args: list[dict[str, str]]) -> None:
|
||||
desired: dict[str, dict[str, str]] = {}
|
||||
for raw in args:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
channel = str(raw.get("channel") or "").strip()
|
||||
inst_id = str(raw.get("instId") or "").strip()
|
||||
if not channel or not inst_id:
|
||||
continue
|
||||
key = f"{channel}:{inst_id}"
|
||||
desired[key] = {"channel": channel, "instId": inst_id}
|
||||
with self._lock:
|
||||
self._desired = desired
|
||||
ws = self._ws
|
||||
connected = self._connected
|
||||
active = set(self._active)
|
||||
if connected and ws is not None:
|
||||
self._sync_subs(ws, active, desired)
|
||||
|
||||
def _sync_subs(
|
||||
self,
|
||||
ws: Any,
|
||||
active: set[str],
|
||||
desired: dict[str, dict[str, str]],
|
||||
) -> None:
|
||||
unsub_args: list[dict[str, str]] = []
|
||||
for key in active - set(desired.keys()):
|
||||
channel, _, inst_id = key.partition(":")
|
||||
if channel and inst_id:
|
||||
unsub_args.append({"channel": channel, "instId": inst_id})
|
||||
sub_args = [desired[k] for k in (set(desired.keys()) - active)]
|
||||
if unsub_args:
|
||||
self._send_op(ws, "unsubscribe", unsub_args)
|
||||
if sub_args:
|
||||
self._send_op(ws, "subscribe", sub_args)
|
||||
with self._lock:
|
||||
self._active = set(desired.keys())
|
||||
|
||||
def _send_op(self, ws: Any, op: str, args: list[dict[str, str]]) -> None:
|
||||
for i in range(0, len(args), _SUBSCRIBE_CHUNK):
|
||||
chunk = args[i : i + _SUBSCRIBE_CHUNK]
|
||||
try:
|
||||
ws.send(json.dumps({"op": op, "args": chunk}, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
logger.warning("%s %s failed: %s", self._name, op, e)
|
||||
return
|
||||
if i + _SUBSCRIBE_CHUNK < len(args):
|
||||
time.sleep(0.08)
|
||||
|
||||
def _run_loop(self) -> None:
|
||||
try:
|
||||
import websocket
|
||||
except ImportError:
|
||||
logger.error("%s: websocket-client not installed", self._name)
|
||||
return
|
||||
backoff = 1.0
|
||||
while not self._stop.is_set():
|
||||
opened = False
|
||||
try:
|
||||
self._connected = False
|
||||
with self._lock:
|
||||
self._active.clear()
|
||||
|
||||
def on_open(ws: Any) -> None:
|
||||
nonlocal opened
|
||||
opened = True
|
||||
self._connected = True
|
||||
self._last_msg_at = time.time()
|
||||
with self._lock:
|
||||
desired = dict(self._desired)
|
||||
self._sync_subs(ws, set(), desired)
|
||||
|
||||
def on_message(_ws: Any, message: str) -> None:
|
||||
self._last_msg_at = time.time()
|
||||
if message == "pong":
|
||||
return
|
||||
try:
|
||||
payload = json.loads(message)
|
||||
except Exception:
|
||||
return
|
||||
if not isinstance(payload, dict):
|
||||
return
|
||||
if payload.get("event") in ("subscribe", "unsubscribe", "error"):
|
||||
if payload.get("event") == "error":
|
||||
logger.warning("%s event error: %s", self._name, payload)
|
||||
return
|
||||
if payload.get("arg") and payload.get("data") is not None:
|
||||
try:
|
||||
self._on_data(payload)
|
||||
except Exception:
|
||||
logger.exception("%s on_data failed", self._name)
|
||||
|
||||
def on_error(_ws: Any, error: Any) -> None:
|
||||
logger.warning("%s error: %s", self._name, error)
|
||||
|
||||
def on_close(_ws: Any, *_args: Any) -> None:
|
||||
self._connected = False
|
||||
|
||||
self._ws = websocket.WebSocketApp(
|
||||
self._url,
|
||||
on_open=on_open,
|
||||
on_message=on_message,
|
||||
on_error=on_error,
|
||||
on_close=on_close,
|
||||
)
|
||||
ping_stop = threading.Event()
|
||||
|
||||
def ping_loop() -> None:
|
||||
while not self._stop.is_set() and not ping_stop.is_set():
|
||||
ws = self._ws
|
||||
if ws is not None and self._connected:
|
||||
try:
|
||||
ws.send("ping")
|
||||
except Exception:
|
||||
pass
|
||||
if ping_stop.wait(_APP_PING_SEC):
|
||||
break
|
||||
|
||||
ping_thread = threading.Thread(
|
||||
target=ping_loop, name=f"{self._name}-ping", daemon=True
|
||||
)
|
||||
ping_thread.start()
|
||||
self._ws.run_forever(ping_interval=0)
|
||||
ping_stop.set()
|
||||
except Exception as e:
|
||||
logger.warning("%s run failed: %s", self._name, e)
|
||||
finally:
|
||||
self._connected = False
|
||||
self._ws = None
|
||||
if self._stop.is_set():
|
||||
break
|
||||
time.sleep(backoff)
|
||||
backoff = 1.0 if opened else min(30.0, backoff * 1.7)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -444,6 +444,7 @@ def _hedge_ratio(opt_pnl: float, perp_pnl: float) -> Optional[float]:
|
||||
|
||||
def build_options_options_preview(
|
||||
*,
|
||||
profit_rr: float | None = None,
|
||||
target_price: float | None = None,
|
||||
target_price_up: float | None = None,
|
||||
target_price_down: float | None = None,
|
||||
@@ -451,7 +452,11 @@ def build_options_options_preview(
|
||||
leg_a: dict[str, Any],
|
||||
leg_b: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""期期情景:上破/下破目标价 / 到期现价 / 最大保费损耗."""
|
||||
"""期期情景:盈亏比达标 / 到期现价 / 最大保费损耗.
|
||||
|
||||
profit_rr=2 表示目标盈利=2×权利金;中途不达标则等到期.
|
||||
仍接受旧上破/下破参数仅作兼容测算.
|
||||
"""
|
||||
|
||||
def _leg_pnl(leg: dict[str, Any], spot: float) -> float:
|
||||
return option_expiry_pnl(
|
||||
@@ -463,15 +468,73 @@ def build_options_options_preview(
|
||||
premium_paid=float(leg.get("premium_paid") or 0),
|
||||
)
|
||||
|
||||
# 兼容旧单目标:若未传上下目标则用 target_price 填两边
|
||||
prem = float(leg_a.get("premium_paid") or 0) + float(leg_b.get("premium_paid") or 0)
|
||||
a_flat = _leg_pnl(leg_a, index_px)
|
||||
b_flat = _leg_pnl(leg_b, index_px)
|
||||
flat_total = a_flat + b_flat
|
||||
|
||||
rr = None
|
||||
if profit_rr not in (None, ""):
|
||||
try:
|
||||
rr = float(profit_rr)
|
||||
except (TypeError, ValueError):
|
||||
rr = None
|
||||
if rr is not None and rr > 0:
|
||||
target_pnl = rr * prem
|
||||
return {
|
||||
"plan_type": "options_options",
|
||||
"premium_paid": round(prem, 6),
|
||||
"oo_profit_rr": round(rr, 4),
|
||||
"target_profit": round(target_pnl, 4),
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "rr_target",
|
||||
"label": f"盈亏比×{rr:g}",
|
||||
"spot": None,
|
||||
"leg_a_pnl": None,
|
||||
"leg_b_pnl": None,
|
||||
"total": round(target_pnl, 4),
|
||||
"note": f"两腿合计浮盈≥{rr:g}×权利金({round(prem, 4)})时全平;不达标等到期",
|
||||
},
|
||||
{
|
||||
"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": "中途未达盈亏比则持有至到期结算",
|
||||
},
|
||||
{
|
||||
"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),
|
||||
"total": round(-prem, 4),
|
||||
"note": "双腿权利金全部损失",
|
||||
},
|
||||
],
|
||||
"summary": {
|
||||
"oo_profit_rr": round(rr, 4),
|
||||
"target_profit": round(target_pnl, 4),
|
||||
"at_target_total": round(target_pnl, 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_target": round(rr, 4),
|
||||
},
|
||||
}
|
||||
|
||||
# 兼容旧上破/下破测算
|
||||
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("请填写盈亏比(相对权利金,默认2)")
|
||||
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
|
||||
@@ -482,15 +545,10 @@ def build_options_options_preview(
|
||||
at_dn = a_dn + b_dn
|
||||
win_dn = "a" if a_dn >= b_dn else "b"
|
||||
|
||||
a_flat = _leg_pnl(leg_a, index_px)
|
||||
b_flat = _leg_pnl(leg_b, index_px)
|
||||
flat_total = a_flat + b_flat
|
||||
expiry_loss = flat_total if flat_total <= 0 else flat_total
|
||||
|
||||
return {
|
||||
"plan_type": "options_options",
|
||||
"premium_paid": round(prem, 6),
|
||||
"target_price": up_f, # 兼容旧字段,取上破
|
||||
"target_price": up_f,
|
||||
"target_price_up": up_f,
|
||||
"target_price_down": down_f,
|
||||
"winner_at_up": win_up,
|
||||
@@ -538,10 +596,9 @@ def build_options_options_preview(
|
||||
"at_target_up_total": round(at_up, 4),
|
||||
"at_target_down_total": round(at_dn, 4),
|
||||
"at_target_total": round(at_up, 4),
|
||||
"expiry_flat_total": round(expiry_loss, 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(at_up / prem, 4) if prem > 0 else None,
|
||||
"rr_at_down": round(at_dn / prem, 4) if prem > 0 else None,
|
||||
|
||||
@@ -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")
|
||||
# 期期:目标盈亏比=目标盈利/权利金(如 2=盈利 2 倍权利金);不达标则等到期
|
||||
_ensure_column(conn, "hedge_plans", "oo_profit_rr", "REAL")
|
||||
# close_all=盈利腿平后清残腿;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,7 +243,23 @@ 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
|
||||
@@ -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.oo_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'
|
||||
@@ -255,10 +289,36 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s
|
||||
for raw in rows:
|
||||
row = dict(raw)
|
||||
inst_id = str(row.get("inst_id") or "")
|
||||
if not inst_id or inst_id in out:
|
||||
continue
|
||||
opt_type = str(row.get("opt_type") or "").upper()
|
||||
rr = _sf(row.get("oo_profit_rr"))
|
||||
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:
|
||||
# 盈亏比模式无指数目标价;旧上破/下破计划仍透出 target_index 只读展示
|
||||
if rr is not None and rr > 0:
|
||||
out[inst_id] = {
|
||||
"plan_id": int(row["plan_id"]),
|
||||
"inst_id": inst_id,
|
||||
"underlying": row.get("underlying"),
|
||||
"opt_type": opt_type,
|
||||
"target_index": None,
|
||||
"oo_profit_rr": rr,
|
||||
"plan_type": "options_options",
|
||||
"managed_by": "hedge_plan",
|
||||
}
|
||||
continue
|
||||
if target_f is None or target_f <= 0:
|
||||
# 无目标价也标记托管,避免期权页误拆组
|
||||
out[inst_id] = {
|
||||
"plan_id": int(row["plan_id"]),
|
||||
"inst_id": inst_id,
|
||||
"underlying": row.get("underlying"),
|
||||
"opt_type": opt_type,
|
||||
"target_index": None,
|
||||
"plan_type": "options_options",
|
||||
"managed_by": "hedge_plan",
|
||||
}
|
||||
continue
|
||||
out[inst_id] = {
|
||||
"plan_id": int(row["plan_id"]),
|
||||
@@ -272,6 +332,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
|
||||
@@ -66,8 +66,63 @@ def _index_px(cfg: dict[str, Any], underlying: str) -> Optional[float]:
|
||||
return None
|
||||
|
||||
|
||||
def _plan_open_grace_sec() -> float:
|
||||
try:
|
||||
return max(0.0, float(os.getenv("HEDGE_PLAN_OPEN_GRACE_SEC") or "90"))
|
||||
except (TypeError, ValueError):
|
||||
return 90.0
|
||||
|
||||
|
||||
def _within_open_grace(plan: dict[str, Any]) -> bool:
|
||||
"""开仓后宽限期:仓位尚未同步到交易所时禁止按「已平」收口."""
|
||||
grace = _plan_open_grace_sec()
|
||||
if grace <= 0:
|
||||
return False
|
||||
raw = str(plan.get("opened_at") or plan.get("created_at") or "").strip()
|
||||
if not raw:
|
||||
return True
|
||||
try:
|
||||
# "YYYY-MM-DD HH:MM:SS" 本地墙钟
|
||||
opened = datetime.strptime(raw[:19], "%Y-%m-%d %H:%M:%S")
|
||||
age = (datetime.now() - opened).total_seconds()
|
||||
return age < grace
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def _classify_po_flat_reason(
|
||||
*,
|
||||
direction: str,
|
||||
entry: float,
|
||||
mark: Optional[float],
|
||||
tp: Optional[float],
|
||||
sl: Optional[float],
|
||||
) -> str:
|
||||
"""永续已平时分类 TP/SL.歧义时偏 SL(触发强平期权),避免误判 TP 跳过强平."""
|
||||
d = (direction or "long").lower()
|
||||
if mark is None or not entry:
|
||||
return "perp_flat_unknown"
|
||||
if sl is not None:
|
||||
if d == "long" and mark <= sl:
|
||||
return "perp_sl"
|
||||
if d == "short" and mark >= sl:
|
||||
return "perp_sl"
|
||||
if tp is not None:
|
||||
if d == "long" and mark >= tp:
|
||||
return "perp_tp"
|
||||
if d == "short" and mark <= tp:
|
||||
return "perp_tp"
|
||||
if sl is not None and tp is not None:
|
||||
return "perp_sl" if abs(mark - sl) <= abs(mark - tp) else "perp_tp"
|
||||
if sl is not None:
|
||||
return "perp_sl"
|
||||
if tp is not None:
|
||||
return "perp_tp"
|
||||
return "perp_flat_unknown"
|
||||
|
||||
|
||||
def tick_active_plans(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
"""扫描 active 计划 + 止盈后遗留期权到期收口.返回处理摘要."""
|
||||
"""扫描 active/partial 计划 + 止盈后遗留期权到期收口.返回处理摘要."""
|
||||
get_db = cfg.get("get_db")
|
||||
if not callable(get_db):
|
||||
return {"ok": False, "msg": "get_db missing"}
|
||||
@@ -78,8 +133,16 @@ def tick_active_plans(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables
|
||||
|
||||
init_hedge_plan_tables(conn)
|
||||
plans = list_plans(conn, status="active", limit=40)
|
||||
plans = list_plans(conn, status="watching", limit=20)
|
||||
plans.extend(list_plans(conn, status="active", limit=40))
|
||||
# partial:裸永续/半腿也需侦测永续 TP/SL
|
||||
plans.extend(list_plans(conn, status="partial", limit=20))
|
||||
seen: set[int] = set()
|
||||
for plan in plans:
|
||||
pid = int(plan.get("id") or 0)
|
||||
if pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
r = _tick_one(cfg, conn, plan)
|
||||
if r:
|
||||
acted.append(r)
|
||||
@@ -162,7 +225,22 @@ def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[
|
||||
pt = plan.get("plan_type")
|
||||
legs = get_plan_legs(conn, int(plan["id"]))
|
||||
if pt == "perp_options":
|
||||
# 先判断期权是否已过期且永续仍在(罕见);主路径仍是永续平仓侦测
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary
|
||||
|
||||
if is_option_primary(plan):
|
||||
if str(plan.get("status") or "") == "watching":
|
||||
return _tick_po_option_primary_watching(cfg, conn, plan)
|
||||
# 期权为主:半平重试 → 到期 → 目标位分叉
|
||||
r = _tick_po_option_primary_pending(cfg, conn, plan, legs)
|
||||
if r:
|
||||
return r
|
||||
r = _tick_po_option_primary_expiry(cfg, conn, plan, legs)
|
||||
if r:
|
||||
return r
|
||||
r = _tick_po_option_primary_both_expired(cfg, conn, plan, legs)
|
||||
if r:
|
||||
return r
|
||||
return _tick_po_option_primary(cfg, conn, plan, legs)
|
||||
r = _tick_po(cfg, conn, plan, legs)
|
||||
return r
|
||||
if pt == "options_options":
|
||||
@@ -176,18 +254,550 @@ def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[
|
||||
return None
|
||||
|
||||
|
||||
def _tick_po_option_primary_watching(
|
||||
cfg: dict[str, Any], conn: Any, plan: dict[str, Any]
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""盯盘:链上出现杠杆/间隔达标合约后自动开仓."""
|
||||
import json
|
||||
import os
|
||||
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import (
|
||||
pick_option_primary_candidate,
|
||||
size_from_premium,
|
||||
)
|
||||
from lib.hedge_plan.hedge_plan_orders_lib import execute_perp_options_start
|
||||
from lib.hedge_plan.hedge_plan_register import _activate_watching_po
|
||||
|
||||
build_chain = cfg.get("build_option_chain")
|
||||
ex = cfg.get("exchange_options")
|
||||
if not callable(build_chain) or ex is None:
|
||||
return None
|
||||
body0: dict[str, Any] = {}
|
||||
try:
|
||||
raw = plan.get("preview_json") or ""
|
||||
blob = json.loads(raw) if raw else {}
|
||||
body0 = dict(blob.get("start_body") or blob or {})
|
||||
except Exception:
|
||||
body0 = {}
|
||||
uly = str(plan.get("underlying") or body0.get("underlying") or "ETH").upper()
|
||||
direction = str(plan.get("direction") or body0.get("direction") or "long").lower()
|
||||
money = str(plan.get("option_moneyness") or body0.get("moneyness") or "otm").lower()
|
||||
interval = plan.get("strike_interval")
|
||||
if interval in (None, ""):
|
||||
interval = body0.get("strike_interval", 15)
|
||||
min_h = plan.get("min_option_hours")
|
||||
if min_h in (None, ""):
|
||||
min_h = body0.get("min_option_hours", 36)
|
||||
opt_lev = plan.get("option_leverage")
|
||||
if opt_lev in (None, ""):
|
||||
opt_lev = body0.get("option_leverage")
|
||||
try:
|
||||
chain = build_chain(
|
||||
ex,
|
||||
uly,
|
||||
max_dte_days=float(cfg.get("chain_max_dte") or 14),
|
||||
itm_only=False,
|
||||
itm_max_dist_usd=float(os.getenv("OKX_OPTIONS_ITM_MAX_DIST_USD") or "30"),
|
||||
)
|
||||
except Exception as e:
|
||||
update_plan(conn, int(plan["id"]), note=f"盯盘拉链失败: {e}"[:500])
|
||||
return None
|
||||
cand = pick_option_primary_candidate(
|
||||
chain,
|
||||
direction=direction,
|
||||
moneyness=money,
|
||||
strike_interval=interval,
|
||||
min_hours=min_h,
|
||||
min_opt_leverage=opt_lev,
|
||||
)
|
||||
if not cand:
|
||||
return None
|
||||
ask = float(cand.get("ask") or 0)
|
||||
ct = float(cand.get("ct_mult") or body0.get("ct_mult") or 0.01)
|
||||
sized = size_from_premium(
|
||||
premium_budget=float(plan.get("premium_budget") or body0.get("premium_budget") or 0),
|
||||
ask=ask,
|
||||
ct_mult=ct,
|
||||
ratio=float(plan.get("option_perp_ratio") or body0.get("option_perp_ratio") or 2),
|
||||
contract_size=float(body0.get("contract_size") or 0.01),
|
||||
)
|
||||
if not sized.get("ok"):
|
||||
update_plan(conn, int(plan["id"]), note=f"盯盘定仓失败: {sized.get('msg')}"[:500])
|
||||
return None
|
||||
idx = float(cand.get("index_px") or chain.get("index_px") or 0)
|
||||
body = dict(body0)
|
||||
body.update(
|
||||
{
|
||||
"plan_type": "perp_options",
|
||||
"option_primary": True,
|
||||
"watch_entry": 0,
|
||||
"underlying": uly,
|
||||
"direction": direction,
|
||||
"moneyness": money,
|
||||
"opt_inst_id": cand.get("inst_id"),
|
||||
"opt_type": cand.get("opt_type"),
|
||||
"strike": cand.get("strike"),
|
||||
"ask": ask,
|
||||
"ct_mult": ct,
|
||||
"sheets": sized["sheets"],
|
||||
"contracts": sized["contracts"],
|
||||
"eth_qty": sized.get("eth_qty"),
|
||||
"index_px": idx,
|
||||
"entry": idx,
|
||||
"hours_to_expiry": cand.get("hours_to_expiry"),
|
||||
"strike_interval": interval,
|
||||
"min_option_hours": min_h,
|
||||
"option_leverage": opt_lev,
|
||||
"option_perp_ratio": plan.get("option_perp_ratio") or body0.get("option_perp_ratio"),
|
||||
"option_target_points": plan.get("option_target_points") or body0.get("option_target_points"),
|
||||
"perp_target_points": plan.get("perp_target_points") or body0.get("perp_target_points"),
|
||||
"premium_budget": plan.get("premium_budget") or body0.get("premium_budget"),
|
||||
"leverage": plan.get("leverage") or body0.get("leverage") or 100,
|
||||
"exchange_symbol": body0.get("exchange_symbol") or f"{uly}-USDT-SWAP",
|
||||
"contract_size": body0.get("contract_size") or 0.01,
|
||||
}
|
||||
)
|
||||
dry = str(os.getenv("HEDGE_PLAN_DRY_RUN") or "").strip().lower() in ("1", "true", "yes", "on")
|
||||
out = execute_perp_options_start(cfg, body, dry_run=dry, persist=None)
|
||||
if not out.get("ok"):
|
||||
update_plan(conn, int(plan["id"]), note=f"盯盘开仓未成: {out.get('msg')}"[:500])
|
||||
return {"plan_id": plan["id"], "watching_open": False, "msg": out.get("msg")}
|
||||
if dry:
|
||||
update_plan(conn, int(plan["id"]), note=f"dry_run命中 {cand.get('inst_id')}"[:500])
|
||||
return {"plan_id": plan["id"], "watching_open": True, "dry_run": True, "inst_id": cand.get("inst_id")}
|
||||
_activate_watching_po(cfg, conn, int(plan["id"]), out, body)
|
||||
return {
|
||||
"plan_id": plan["id"],
|
||||
"watching_open": True,
|
||||
"inst_id": cand.get("inst_id"),
|
||||
"leverage": cand.get("leverage"),
|
||||
}
|
||||
|
||||
|
||||
def _tick_po_option_primary_pending(
|
||||
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""期权已平、永续待平(opt_target_perp_pending)时只重试平永续."""
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view
|
||||
from lib.hedge_plan.hedge_plan_orders_lib import _close_perp
|
||||
|
||||
pending = str(plan.get("close_reason") or "")
|
||||
if pending not in ("opt_target_perp_pending", "opt_target_pending"):
|
||||
return None
|
||||
perp = next((x for x in legs if x.get("leg_role") == "perp"), None)
|
||||
opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None)
|
||||
if not perp or str(perp.get("status") or "") != "open":
|
||||
return None
|
||||
view = str(plan.get("direction") or "long").lower()
|
||||
perp_dir = str(plan.get("perp_direction") or perp.get("side") or perp_direction_for_view(view)).lower()
|
||||
symbol = str(perp.get("symbol") or "")
|
||||
contracts = float(perp.get("size") or plan.get("perp_size") or 0)
|
||||
|
||||
# 期权仍 open:继续走主路径,不在此强平
|
||||
if pending == "opt_target_pending" and opt and str(opt.get("status") or "") == "open":
|
||||
return None
|
||||
|
||||
# 期权已平或 already flat:只补平永续
|
||||
if opt and str(opt.get("status") or "") == "open":
|
||||
return None
|
||||
|
||||
perp_close = _close_perp(cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False)
|
||||
if not perp_close.get("ok"):
|
||||
notify_hedge(
|
||||
cfg,
|
||||
build_hedge_alert_message(
|
||||
title="期权已平·永续平仓重试失败",
|
||||
plan_id=plan.get("id"),
|
||||
detail=str(perp_close.get("msg") or perp_close),
|
||||
),
|
||||
)
|
||||
update_plan(conn, int(plan["id"]), close_reason="opt_target_perp_pending")
|
||||
return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close}
|
||||
|
||||
entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or 0
|
||||
mark = entry
|
||||
ex = cfg.get("exchange")
|
||||
if ex is not None and symbol:
|
||||
try:
|
||||
t = ex.fetch_ticker(symbol)
|
||||
mark = _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last")) or entry
|
||||
except Exception:
|
||||
pass
|
||||
cs = float(cfg.get("default_contract_size") or 0.01)
|
||||
get_cs = cfg.get("get_contract_size")
|
||||
if callable(get_cs) and symbol:
|
||||
try:
|
||||
cs = float(get_cs(symbol) or cs)
|
||||
except Exception:
|
||||
pass
|
||||
coins = contracts * cs
|
||||
if perp_dir == "short":
|
||||
perp_pnl = (float(entry or 0) - float(mark or 0)) * coins
|
||||
else:
|
||||
perp_pnl = (float(mark or 0) - float(entry or 0)) * coins
|
||||
opt_pnl = float(opt.get("realized_pnl") or 0) if opt else float(plan.get("realized_pnl_options") or 0)
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
||||
("closed", "opt_target_points", _now(), round(perp_pnl, 4), perp["id"]),
|
||||
)
|
||||
total = opt_pnl + perp_pnl
|
||||
update_plan(
|
||||
conn,
|
||||
int(plan["id"]),
|
||||
status="closed",
|
||||
close_reason="opt_target_points",
|
||||
realized_pnl_perp=round(perp_pnl, 4),
|
||||
realized_pnl_options=round(opt_pnl, 4),
|
||||
realized_pnl_total=round(total, 4),
|
||||
stats_bucket="opt_primary",
|
||||
closed_at=_now(),
|
||||
)
|
||||
_notify_end_reload(cfg, conn, int(plan["id"]))
|
||||
return {"plan_id": plan["id"], "close_reason": "opt_target_points", "total": total, "recovered": True}
|
||||
|
||||
|
||||
def _tick_po_option_primary_both_expired(
|
||||
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""两腿仍 open 但期权已到期:结算期权并市价平永续,避免裸奔."""
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view
|
||||
from lib.hedge_plan.hedge_plan_orders_lib import _close_perp
|
||||
|
||||
perp = next((x for x in legs if x.get("leg_role") == "perp"), None)
|
||||
opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None)
|
||||
if not perp or str(perp.get("status") or "") != "open":
|
||||
return None
|
||||
if not opt or str(opt.get("status") or "") != "open":
|
||||
return None
|
||||
if not leg_is_expired(opt):
|
||||
return None
|
||||
spot = _index_px(cfg, str(plan.get("underlying") or "ETH"))
|
||||
if spot is None:
|
||||
return None
|
||||
est = settle_option_leg_at_spot(opt, float(spot))
|
||||
opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est)
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
||||
("closed", "expiry", _now(), round(opt_pnl, 4), opt["id"]),
|
||||
)
|
||||
view = str(plan.get("direction") or "long").lower()
|
||||
perp_dir = str(plan.get("perp_direction") or perp.get("side") or perp_direction_for_view(view)).lower()
|
||||
symbol = str(perp.get("symbol") or "")
|
||||
contracts = float(perp.get("size") or plan.get("perp_size") or 0)
|
||||
perp_close = _close_perp(cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False)
|
||||
entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or float(spot)
|
||||
cs = float(cfg.get("default_contract_size") or 0.01)
|
||||
get_cs = cfg.get("get_contract_size")
|
||||
if callable(get_cs) and symbol:
|
||||
try:
|
||||
cs = float(get_cs(symbol) or cs)
|
||||
except Exception:
|
||||
pass
|
||||
coins = contracts * cs
|
||||
if perp_dir == "short":
|
||||
perp_pnl = (float(entry) - float(spot)) * coins
|
||||
else:
|
||||
perp_pnl = (float(spot) - float(entry)) * coins
|
||||
if not perp_close.get("ok"):
|
||||
notify_hedge(
|
||||
cfg,
|
||||
build_hedge_alert_message(
|
||||
title="期权到期后永续平仓失败(将重试)",
|
||||
plan_id=plan.get("id"),
|
||||
detail=str(perp_close.get("msg") or perp_close),
|
||||
),
|
||||
)
|
||||
update_plan(
|
||||
conn,
|
||||
int(plan["id"]),
|
||||
close_reason="opt_target_perp_pending",
|
||||
realized_pnl_options=round(opt_pnl, 4),
|
||||
note="期权已到期结算,永续待平",
|
||||
)
|
||||
return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close}
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
||||
("closed", "option_expired", _now(), round(perp_pnl, 4), perp["id"]),
|
||||
)
|
||||
total = opt_pnl + perp_pnl
|
||||
update_plan(
|
||||
conn,
|
||||
int(plan["id"]),
|
||||
status="closed",
|
||||
close_reason="option_expired",
|
||||
realized_pnl_perp=round(perp_pnl, 4),
|
||||
realized_pnl_options=round(opt_pnl, 4),
|
||||
realized_pnl_total=round(total, 4),
|
||||
stats_bucket="opt_primary",
|
||||
closed_at=_now(),
|
||||
)
|
||||
_notify_end_reload(cfg, conn, int(plan["id"]))
|
||||
return {"plan_id": plan["id"], "close_reason": "option_expired", "total": total}
|
||||
|
||||
|
||||
def _tick_po_option_primary_expiry(
|
||||
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""期权为主且永续已平、期权 hold_to_expiry → 到期结算后收口计划."""
|
||||
perp = next((x for x in legs if x.get("leg_role") == "perp"), None)
|
||||
opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None)
|
||||
if not opt or str(opt.get("status") or "") != "hold_to_expiry":
|
||||
return None
|
||||
if perp and str(perp.get("status") or "") == "open":
|
||||
return None
|
||||
if not leg_is_expired(opt):
|
||||
return None
|
||||
spot = _index_px(cfg, str(plan.get("underlying") or "ETH"))
|
||||
if spot is None:
|
||||
return None
|
||||
est = settle_option_leg_at_spot(opt, float(spot))
|
||||
opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est)
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
||||
("closed", "expiry", _now(), round(opt_pnl, 4), opt["id"]),
|
||||
)
|
||||
perp_pnl = float(perp.get("realized_pnl") or 0) if perp else float(plan.get("realized_pnl_perp") or 0)
|
||||
total = perp_pnl + opt_pnl
|
||||
update_plan(
|
||||
conn,
|
||||
int(plan["id"]),
|
||||
status="closed",
|
||||
close_reason="perp_target_points_expiry",
|
||||
realized_pnl_perp=round(perp_pnl, 4),
|
||||
realized_pnl_options=round(opt_pnl, 4),
|
||||
realized_pnl_total=round(total, 4),
|
||||
stats_bucket="opt_primary",
|
||||
closed_at=_now(),
|
||||
)
|
||||
_notify_end_reload(cfg, conn, int(plan["id"]))
|
||||
return {"plan_id": plan["id"], "close_reason": "perp_target_points_expiry", "total": total}
|
||||
|
||||
|
||||
def _tick_po_option_primary(
|
||||
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""以期权为主:触达目标位立即执行分叉平仓规则."""
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import (
|
||||
estimate_combo_net_pnl,
|
||||
option_bid_liquidity_ok,
|
||||
perp_direction_for_view,
|
||||
target_hit,
|
||||
)
|
||||
from lib.hedge_plan.hedge_plan_orders_lib import _close_perp
|
||||
|
||||
perp = next((x for x in legs if x.get("leg_role") == "perp"), None)
|
||||
opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None)
|
||||
if not perp or str(perp.get("status") or "") != "open":
|
||||
return None
|
||||
if not opt or str(opt.get("status") or "") != "open":
|
||||
return None
|
||||
if _within_open_grace(plan):
|
||||
return None
|
||||
|
||||
view = str(plan.get("direction") or "long").lower()
|
||||
perp_dir = str(plan.get("perp_direction") or perp.get("side") or perp_direction_for_view(view)).lower()
|
||||
strike = _sf(opt.get("strike"))
|
||||
n = _sf(plan.get("option_target_points"))
|
||||
m = _sf(plan.get("perp_target_points"))
|
||||
if strike is None or strike <= 0:
|
||||
return None
|
||||
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
|
||||
if idx is None:
|
||||
return None
|
||||
|
||||
hit_opt = bool(n is not None and target_hit(view_side=view, index_px=idx, strike=strike, points=float(n)))
|
||||
hit_perp = bool(m is not None and target_hit(view_side=view, index_px=idx, strike=strike, points=float(m)))
|
||||
if not hit_opt and not hit_perp:
|
||||
return None
|
||||
|
||||
symbol = str(perp.get("symbol") or "")
|
||||
mark = None
|
||||
ex = cfg.get("exchange")
|
||||
if ex is not None and symbol:
|
||||
try:
|
||||
t = ex.fetch_ticker(symbol)
|
||||
mark = _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last"))
|
||||
except Exception:
|
||||
mark = None
|
||||
mark = mark or idx
|
||||
entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or mark
|
||||
cs = float(cfg.get("default_contract_size") or 0.01)
|
||||
get_cs = cfg.get("get_contract_size")
|
||||
if callable(get_cs) and symbol:
|
||||
try:
|
||||
cs = float(get_cs(symbol) or cs)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
quote_fn = cfg.get("quote_option_contract")
|
||||
ex_opt = cfg.get("exchange_options")
|
||||
bid = None
|
||||
bid_sz = None
|
||||
if callable(quote_fn) and ex_opt is not None:
|
||||
try:
|
||||
q = quote_fn(ex_opt, str(opt.get("inst_id") or ""))
|
||||
if q.get("ok"):
|
||||
bid = _sf(q.get("bid"))
|
||||
bid_sz = _sf(q.get("bid_sz"))
|
||||
except Exception:
|
||||
bid = None
|
||||
|
||||
ask_open = _sf(opt.get("avg_open")) or 0.0
|
||||
sheets = float(opt.get("size") or 1)
|
||||
ct = float(opt.get("ct_mult") or 0.01)
|
||||
contracts = float(perp.get("size") or plan.get("perp_size") or 0)
|
||||
|
||||
# 优先期权目标;买一不足或净利≤0 时若永续目标已触达则改走永续目标
|
||||
if hit_opt:
|
||||
liq_ok, liq_msg = option_bid_liquidity_ok(bid, bid_sz, need_sheets=sheets)
|
||||
net = None
|
||||
if liq_ok:
|
||||
net = estimate_combo_net_pnl(
|
||||
view_side=view,
|
||||
strike=float(strike),
|
||||
index_px=float(idx),
|
||||
ask_open=float(ask_open),
|
||||
bid=float(bid or 0),
|
||||
sheets=sheets,
|
||||
ct_mult=ct,
|
||||
perp_direction=perp_dir,
|
||||
perp_entry=float(entry or 0),
|
||||
perp_mark=float(mark or 0),
|
||||
contracts=contracts,
|
||||
contract_size=cs,
|
||||
)
|
||||
can_opt_exit = bool(liq_ok and net is not None and float(net.get("net") or 0) > 0)
|
||||
if can_opt_exit:
|
||||
reason = "opt_target_points"
|
||||
close_r = _sell_option(cfg, inst_id=str(opt.get("inst_id") or ""), sheets=sheets)
|
||||
if close_r.get("already_flat"):
|
||||
opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=net["opt_net"])
|
||||
elif not close_r.get("ok") or not close_r.get("fully_closed", True):
|
||||
notify_hedge(
|
||||
cfg,
|
||||
build_hedge_alert_message(
|
||||
title="期权目标平仓失败(将重试)",
|
||||
plan_id=plan.get("id"),
|
||||
detail=str(close_r.get("msg") or close_r),
|
||||
),
|
||||
)
|
||||
update_plan(conn, int(plan["id"]), close_reason="opt_target_pending")
|
||||
return {"plan_id": plan["id"], "retry": True, "close": close_r}
|
||||
else:
|
||||
opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=net["opt_net"])
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
||||
("closed", reason, _now(), round(opt_pnl, 4), opt["id"]),
|
||||
)
|
||||
perp_close = _close_perp(
|
||||
cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False
|
||||
)
|
||||
if not perp_close.get("ok"):
|
||||
notify_hedge(
|
||||
cfg,
|
||||
build_hedge_alert_message(
|
||||
title="期权已平但永续平仓失败(将重试)",
|
||||
plan_id=plan.get("id"),
|
||||
detail=str(perp_close.get("msg") or perp_close),
|
||||
),
|
||||
)
|
||||
update_plan(conn, int(plan["id"]), close_reason="opt_target_perp_pending")
|
||||
return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close}
|
||||
perp_pnl = float(net.get("perp_net") or 0)
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
||||
("closed", reason, _now(), round(perp_pnl, 4), perp["id"]),
|
||||
)
|
||||
total = float(opt_pnl) + float(perp_pnl)
|
||||
update_plan(
|
||||
conn,
|
||||
int(plan["id"]),
|
||||
status="closed",
|
||||
close_reason=reason,
|
||||
realized_pnl_perp=round(perp_pnl, 4),
|
||||
realized_pnl_options=round(opt_pnl, 4),
|
||||
realized_pnl_total=round(total, 4),
|
||||
stats_bucket="opt_primary",
|
||||
closed_at=_now(),
|
||||
)
|
||||
_notify_end_reload(cfg, conn, int(plan["id"]))
|
||||
return {"plan_id": plan["id"], "close_reason": reason, "total": total, "net": net}
|
||||
if not hit_perp:
|
||||
return {
|
||||
"plan_id": plan["id"],
|
||||
"skip": True,
|
||||
"msg": (liq_msg if not liq_ok else "净利≤0,继续持有"),
|
||||
"net": net,
|
||||
}
|
||||
|
||||
if not hit_perp:
|
||||
return None
|
||||
|
||||
reason = "perp_target_points"
|
||||
# 永续目标:平永续,期权持有至到期
|
||||
perp_close = _close_perp(cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False)
|
||||
if not perp_close.get("ok"):
|
||||
notify_hedge(
|
||||
cfg,
|
||||
build_hedge_alert_message(
|
||||
title="永续目标平仓失败(将重试)",
|
||||
plan_id=plan.get("id"),
|
||||
detail=str(perp_close.get("msg") or perp_close),
|
||||
),
|
||||
)
|
||||
update_plan(conn, int(plan["id"]), close_reason="perp_target_pending")
|
||||
return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close}
|
||||
# 估永续已实现
|
||||
coins = contracts * cs
|
||||
if perp_dir == "short":
|
||||
perp_pnl = (float(entry or 0) - float(mark or 0)) * coins
|
||||
else:
|
||||
perp_pnl = (float(mark or 0) - float(entry or 0)) * coins
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
||||
("closed", reason, _now(), round(perp_pnl, 4), perp["id"]),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=? WHERE id=?",
|
||||
("hold_to_expiry", "hold_expiry_after_perp_target", opt["id"]),
|
||||
)
|
||||
update_plan(
|
||||
conn,
|
||||
int(plan["id"]),
|
||||
# 计划保持 active,等期权到期收口
|
||||
close_reason="perp_target_points",
|
||||
realized_pnl_perp=round(perp_pnl, 4),
|
||||
note="永续已按目标平仓,期权持有至到期",
|
||||
)
|
||||
notify_hedge(
|
||||
cfg,
|
||||
build_hedge_alert_message(
|
||||
title="永续目标已平·期权持有至到期",
|
||||
plan_id=plan.get("id"),
|
||||
detail=f"指数 {idx:.2f} · 永续盈亏约 {perp_pnl:.2f}",
|
||||
),
|
||||
)
|
||||
return {"plan_id": plan["id"], "close_reason": reason, "perp_pnl": perp_pnl, "opt_hold": True}
|
||||
|
||||
|
||||
def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]) -> Optional[dict[str, Any]]:
|
||||
perp = next((x for x in legs if x.get("leg_role") == "perp"), None)
|
||||
opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None)
|
||||
if not perp or perp.get("status") != "open":
|
||||
return None
|
||||
symbol = perp.get("symbol") or ""
|
||||
direction = (plan.get("direction") or "long").lower()
|
||||
direction = (plan.get("perp_direction") or plan.get("direction") or "long").lower()
|
||||
live = _perp_live_contracts(cfg, symbol, direction)
|
||||
# 仍有仓 → 未触达交易所 TP/SL
|
||||
if live is not None and live > 0:
|
||||
# API 失败 / 未注入 → 本轮跳过,绝不当「已平」
|
||||
if live is None:
|
||||
return None
|
||||
# 仓已平:用标记/最新粗判 TP or SL
|
||||
# 仍有仓 → 未触达交易所 TP/SL
|
||||
if live > 0:
|
||||
return None
|
||||
# 开仓后宽限期:仓位同步延迟可误读为 0
|
||||
if _within_open_grace(plan):
|
||||
return None
|
||||
|
||||
entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or 0
|
||||
tp = _sf(plan.get("tp"))
|
||||
sl = _sf(plan.get("sl"))
|
||||
@@ -199,17 +809,19 @@ def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[di
|
||||
mark = _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last"))
|
||||
except Exception:
|
||||
mark = None
|
||||
reason = "perp_tp"
|
||||
if mark is not None and sl is not None and entry:
|
||||
if direction == "long" and mark <= sl:
|
||||
reason = "perp_sl"
|
||||
elif direction == "short" and mark >= sl:
|
||||
reason = "perp_sl"
|
||||
elif tp is not None:
|
||||
if direction == "long" and mark >= tp:
|
||||
reason = "perp_tp"
|
||||
elif direction == "short" and mark <= tp:
|
||||
reason = "perp_tp"
|
||||
reason = _classify_po_flat_reason(
|
||||
direction=direction, entry=float(entry or 0), mark=mark, tp=tp, sl=sl
|
||||
)
|
||||
# 上一轮止损强平未完成:粘滞为 SL,避免 mark 反弹误判 TP 跳过强平
|
||||
pending_reason = str(plan.get("close_reason") or "")
|
||||
if pending_reason == "perp_sl_pending_opt":
|
||||
reason = "perp_sl"
|
||||
elif pending_reason == "perp_tp_pending_opt":
|
||||
reason = "perp_tp"
|
||||
# 未明确 TP/SL 时不收口,下轮再判
|
||||
if reason == "perp_flat_unknown":
|
||||
return None
|
||||
|
||||
premium = float(plan.get("premium_total") or 0)
|
||||
cs = float(cfg.get("default_contract_size") or 0.01)
|
||||
get_cs = cfg.get("get_contract_size")
|
||||
@@ -227,66 +839,107 @@ def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[di
|
||||
perp_pnl = (exit_px - entry) * coins
|
||||
|
||||
opt_pnl = -premium
|
||||
if reason == "perp_sl" and opt and _env_bool("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", True):
|
||||
close_r = _sell_option(
|
||||
cfg,
|
||||
inst_id=str(opt.get("inst_id") or ""),
|
||||
sheets=float(opt.get("size") or 1),
|
||||
)
|
||||
if not close_r.get("ok"):
|
||||
notify_hedge(
|
||||
if reason == "perp_sl" and opt and str(opt.get("status") or "") == "open":
|
||||
if _env_bool("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", True):
|
||||
close_r = _sell_option(
|
||||
cfg,
|
||||
build_hedge_alert_message(
|
||||
title="永续止损后期权强制平仓失败",
|
||||
plan_id=plan.get("id"),
|
||||
detail=str(close_r.get("msg") or close_r),
|
||||
),
|
||||
inst_id=str(opt.get("inst_id") or ""),
|
||||
sheets=float(opt.get("size") or 1),
|
||||
)
|
||||
if close_r.get("ok"):
|
||||
bid = _sf(close_r.get("bid"))
|
||||
ask_open = _sf(opt.get("avg_open"))
|
||||
if bid is not None and ask_open is not None:
|
||||
ct = float(opt.get("ct_mult") or 0.01)
|
||||
est = (bid - ask_open) * float(opt.get("size") or 1) * ct
|
||||
else:
|
||||
est = -premium
|
||||
opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est)
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
||||
("closed", reason, _now(), opt_pnl, opt["id"]),
|
||||
)
|
||||
elif reason == "perp_tp" and opt:
|
||||
if _env_bool("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", False):
|
||||
close_r = _sell_option(cfg, inst_id=str(opt.get("inst_id") or ""), sheets=float(opt.get("size") or 1))
|
||||
if not close_r.get("ok"):
|
||||
if close_r.get("already_flat"):
|
||||
opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=-premium)
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
||||
("closed", reason, _now(), opt_pnl, opt["id"]),
|
||||
)
|
||||
elif not close_r.get("ok") or not close_r.get("fully_closed", True):
|
||||
notify_hedge(
|
||||
cfg,
|
||||
build_hedge_alert_message(
|
||||
title="永续止盈后期权平仓失败",
|
||||
title="永续止损后期权强制平仓失败(将重试)",
|
||||
plan_id=plan.get("id"),
|
||||
detail=str(close_r.get("msg") or close_r),
|
||||
),
|
||||
)
|
||||
update_plan(conn, int(plan["id"]), close_reason="perp_sl_pending_opt")
|
||||
return {
|
||||
"plan_id": plan["id"],
|
||||
"msg": "止损后期权未平完",
|
||||
"close": close_r,
|
||||
"retry": True,
|
||||
}
|
||||
else:
|
||||
bid = _sf(close_r.get("bid") or close_r.get("locked_bid_px"))
|
||||
ask_open = _sf(opt.get("avg_open"))
|
||||
if bid is not None and ask_open is not None:
|
||||
ct = float(opt.get("ct_mult") or 0.01)
|
||||
est = (bid - ask_open) * float(opt.get("size") or 1) * ct
|
||||
else:
|
||||
est = -premium
|
||||
opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est)
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
||||
("closed", reason, _now(), opt_pnl, opt["id"]),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=? WHERE id=?",
|
||||
("closed", reason, _now(), opt["id"]),
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=? WHERE id=?",
|
||||
("hold_to_expiry", "orphaned_after_sl", opt["id"]),
|
||||
)
|
||||
opt_pnl = -premium
|
||||
elif reason == "perp_tp" and opt and str(opt.get("status") or "") == "open":
|
||||
if _env_bool("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", False):
|
||||
close_r = _sell_option(
|
||||
cfg, inst_id=str(opt.get("inst_id") or ""), sheets=float(opt.get("size") or 1)
|
||||
)
|
||||
if close_r.get("already_flat"):
|
||||
opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=-premium)
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
||||
("closed", reason, _now(), opt_pnl, opt["id"]),
|
||||
)
|
||||
elif not close_r.get("ok") or not close_r.get("fully_closed", True):
|
||||
notify_hedge(
|
||||
cfg,
|
||||
build_hedge_alert_message(
|
||||
title="永续止盈后期权平仓失败(将重试)",
|
||||
plan_id=plan.get("id"),
|
||||
detail=str(close_r.get("msg") or close_r),
|
||||
),
|
||||
)
|
||||
update_plan(conn, int(plan["id"]), close_reason="perp_tp_pending_opt")
|
||||
return {
|
||||
"plan_id": plan["id"],
|
||||
"msg": "止盈后期权未平完",
|
||||
"close": close_r,
|
||||
"retry": True,
|
||||
}
|
||||
else:
|
||||
bid = _sf(close_r.get("bid") or close_r.get("locked_bid_px"))
|
||||
ask_open = _sf(opt.get("avg_open"))
|
||||
if bid is not None and ask_open is not None:
|
||||
ct = float(opt.get("ct_mult") or 0.01)
|
||||
est = (bid - ask_open) * float(opt.get("size") or 1) * ct
|
||||
else:
|
||||
est = -premium
|
||||
opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est)
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
||||
("closed", reason, _now(), opt_pnl, opt["id"]),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=? WHERE id=?",
|
||||
("hold_to_expiry", "orphaned_after_tp", opt["id"]),
|
||||
)
|
||||
opt_pnl = -premium
|
||||
|
||||
if reason == "perp_tp":
|
||||
total = perp_pnl + opt_pnl
|
||||
else:
|
||||
total = opt_pnl + perp_pnl
|
||||
opt_pnl = -premium
|
||||
|
||||
total = perp_pnl + opt_pnl
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
||||
("closed", reason, _now(), perp_pnl, perp["id"]),
|
||||
)
|
||||
# partial → closed 也走同一收口
|
||||
update_plan(
|
||||
conn,
|
||||
int(plan["id"]),
|
||||
@@ -346,6 +999,8 @@ def _tick_oo_close_rest(
|
||||
"target_up_win_leg",
|
||||
"target_down_win_leg",
|
||||
"oo_rest_closing",
|
||||
"oo_rr_closing",
|
||||
"oo_rr_target",
|
||||
"",
|
||||
)
|
||||
if reason0 not in allowed_reasons and not (
|
||||
@@ -396,7 +1051,113 @@ def _tick_oo_close_rest(
|
||||
def _tick_oo_target(
|
||||
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""期期:触及上破或下破目标价时平盈利腿;按平仓模式处理另一腿."""
|
||||
"""期期止盈:优先盈亏比(浮盈≥rr×权利金则两腿全平);否则兼容旧上破/下破."""
|
||||
rr = _sf(plan.get("oo_profit_rr"))
|
||||
if rr is not None and rr > 0:
|
||||
return _tick_oo_rr_target(cfg, conn, plan, legs, rr=float(rr))
|
||||
return _tick_oo_price_target(cfg, conn, plan, legs)
|
||||
|
||||
|
||||
def _tick_oo_rr_target(
|
||||
cfg: dict[str, Any],
|
||||
conn: Any,
|
||||
plan: dict[str, Any],
|
||||
legs: list[dict[str, Any]],
|
||||
*,
|
||||
rr: float,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""浮盈(买一回收−权利金)≥盈亏比×总权利金 → 两腿全平;不达标则等到期."""
|
||||
open_legs = _oo_option_legs(legs, statuses=("open",))
|
||||
if len(open_legs) < 1:
|
||||
return None
|
||||
premium = float(plan.get("premium_total") or 0)
|
||||
if premium <= 0:
|
||||
premium = sum(float(x.get("premium") or 0) for x in open_legs)
|
||||
if premium <= 0:
|
||||
return None
|
||||
need = float(rr) * premium
|
||||
quote_fn = cfg.get("quote_option_contract")
|
||||
ex = cfg.get("exchange_options")
|
||||
if not callable(quote_fn) or ex is None:
|
||||
return None
|
||||
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
|
||||
total_pnl = 0.0
|
||||
missing_bid = 0
|
||||
for leg in open_legs:
|
||||
inst = str(leg.get("inst_id") or "")
|
||||
bid = None
|
||||
try:
|
||||
q = quote_fn(ex, inst) if inst else {}
|
||||
if isinstance(q, dict) and q.get("ok"):
|
||||
bid = _sf(q.get("bid"))
|
||||
except Exception:
|
||||
bid = None
|
||||
if bid is None or float(bid) <= 0:
|
||||
missing_bid += 1
|
||||
# 无买一时用内在价值兜底,避免短暂无盘口卡住;两腿都无买一则本轮跳过
|
||||
total_pnl += _estimate_leg_close_pnl(leg, idx, None)
|
||||
else:
|
||||
total_pnl += _estimate_leg_close_pnl(leg, idx, float(bid))
|
||||
if missing_bid >= len(open_legs):
|
||||
return None
|
||||
if total_pnl + 1e-9 < need:
|
||||
return None
|
||||
|
||||
acted = False
|
||||
for leg in list(open_legs):
|
||||
close_r = _sell_option(
|
||||
cfg, inst_id=str(leg.get("inst_id") or ""), sheets=float(leg.get("size") or 1)
|
||||
)
|
||||
if not close_r.get("ok"):
|
||||
notify_hedge(
|
||||
cfg,
|
||||
build_hedge_alert_message(
|
||||
title="期期盈亏比达标·平仓失败(将重试)",
|
||||
plan_id=plan.get("id"),
|
||||
detail=(
|
||||
f"目标 {rr:g}×权利金={need:.4f};估算浮盈 {total_pnl:.4f}; "
|
||||
f"{close_r.get('msg') or close_r}"
|
||||
),
|
||||
),
|
||||
)
|
||||
update_plan(conn, int(plan["id"]), close_reason="oo_rr_closing")
|
||||
return {
|
||||
"plan_id": plan["id"],
|
||||
"msg": "盈亏比达标但平仓失败",
|
||||
"close": close_r,
|
||||
"retry": True,
|
||||
"rr": rr,
|
||||
"need": need,
|
||||
"mtm": total_pnl,
|
||||
}
|
||||
bid = _sf(close_r.get("bid"))
|
||||
est = _estimate_leg_close_pnl(leg, idx, bid)
|
||||
pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est)
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
||||
("closed", "oo_rr_target", _now(), round(pnl, 4), leg["id"]),
|
||||
)
|
||||
acted = True
|
||||
|
||||
if not acted:
|
||||
return None
|
||||
legs2 = get_plan_legs(conn, int(plan["id"]))
|
||||
still_open = _oo_option_legs(legs2, statuses=("open", "hold_to_expiry"))
|
||||
if still_open:
|
||||
update_plan(conn, int(plan["id"]), close_reason="oo_rr_closing")
|
||||
return {
|
||||
"plan_id": plan["id"],
|
||||
"msg": "盈亏比达标·部分已平,继续重试",
|
||||
"remaining": len(still_open),
|
||||
"rr": rr,
|
||||
}
|
||||
return _finalize_oo_all_closed(cfg, conn, plan, legs2, reason="oo_rr_target")
|
||||
|
||||
|
||||
def _tick_oo_price_target(
|
||||
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""旧逻辑:触及上破或下破目标价时平盈利腿;按平仓模式处理另一腿."""
|
||||
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
|
||||
if idx is None:
|
||||
return None
|
||||
|
||||
@@ -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("oo_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,6 +90,8 @@ def build_hedge_end_message(plan: dict[str, Any]) -> str:
|
||||
"target_win_leg": "期期已平盈利腿(中间态)",
|
||||
"target_up_win_leg": "期期上破·已平盈利腿",
|
||||
"target_down_win_leg": "期期下破·已平盈利腿",
|
||||
"oo_rr_target": "期期盈亏比达标·两腿已平",
|
||||
"oo_rr_closing": "期期盈亏比达标·平仓中",
|
||||
"oo_rest_closing": "期期全平·清残腿中",
|
||||
"oo_rest_closed": "期期全平·两腿已平",
|
||||
"oo_expiry_loss": "期期到期无盈利·总亏损",
|
||||
@@ -153,7 +164,18 @@ def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> boo
|
||||
"target_up_win_leg",
|
||||
"target_down_win_leg",
|
||||
"oo_rest_closing",
|
||||
"oo_rr_closing",
|
||||
) and (plan.get("status") or "") != "closed":
|
||||
if "oo_rr" in str(plan.get("close_reason") or ""):
|
||||
notify_hedge(
|
||||
cfg,
|
||||
build_hedge_alert_message(
|
||||
title="期期盈亏比达标·平仓进行中",
|
||||
plan_id=plan.get("id"),
|
||||
detail=f"盈亏比 {_fmt(plan.get('oo_profit_rr'))}×权利金",
|
||||
),
|
||||
)
|
||||
return True
|
||||
side = "上破" if "up" in str(plan.get("close_reason")) else (
|
||||
"下破" if "down" in str(plan.get("close_reason")) else "目标价"
|
||||
)
|
||||
|
||||
@@ -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,111 @@ 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("oo_profit_rr")
|
||||
if rr_raw in (None, ""):
|
||||
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 "请填写盈亏比(相对权利金,默认2)"
|
||||
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 +1301,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,25 @@ 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("oo_profit_rr")
|
||||
if rr_raw in (None, ""):
|
||||
rr_raw = body.get("profit_rr")
|
||||
try:
|
||||
oo_rr = float(rr_raw) if rr_raw not in (None, "") else 2.0
|
||||
except (TypeError, ValueError):
|
||||
oo_rr = 2.0
|
||||
if oo_rr <= 0:
|
||||
oo_rr = 2.0
|
||||
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": None,
|
||||
"target_price_up": None,
|
||||
"target_price_down": None,
|
||||
"oo_profit_rr": oo_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 +626,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 +675,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 +815,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 +916,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 +1065,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 +1159,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 +1207,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,20 +1234,26 @@ def _preview_po(body: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
|
||||
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, ""):
|
||||
raise ValueError("请填写上破与下破目标价")
|
||||
up_f = float(up)
|
||||
down_f = float(down)
|
||||
if up_f <= down_f:
|
||||
raise ValueError("上破目标价必须大于下破目标价")
|
||||
index_px = float(body.get("index_px") or ((up_f + down_f) / 2))
|
||||
from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness
|
||||
|
||||
rr_raw = body.get("oo_profit_rr")
|
||||
if rr_raw in (None, ""):
|
||||
rr_raw = body.get("profit_rr")
|
||||
rr = None
|
||||
if rr_raw not in (None, ""):
|
||||
try:
|
||||
rr = float(rr_raw)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ValueError("盈亏比无效") from e
|
||||
if rr <= 0:
|
||||
raise ValueError("盈亏比须大于 0")
|
||||
|
||||
index_px = body.get("index_px")
|
||||
try:
|
||||
index_px_f = float(index_px) if index_px not in (None, "") else 0.0
|
||||
except (TypeError, ValueError):
|
||||
index_px_f = 0.0
|
||||
|
||||
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,10 +1267,38 @@ 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_f or None)
|
||||
if money_err:
|
||||
raise ValueError(money_err)
|
||||
|
||||
if rr is not None:
|
||||
return build_options_options_preview(
|
||||
profit_rr=rr,
|
||||
index_px=index_px_f,
|
||||
leg_a=leg_a,
|
||||
leg_b=leg_b,
|
||||
)
|
||||
|
||||
# 兼容旧上破/下破
|
||||
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, ""):
|
||||
raise ValueError("请填写盈亏比(相对权利金,默认2)")
|
||||
up_f = float(up)
|
||||
down_f = float(down)
|
||||
if up_f <= down_f:
|
||||
raise ValueError("上破目标价必须大于下破目标价")
|
||||
if index_px_f <= 0:
|
||||
index_px_f = (up_f + down_f) / 2
|
||||
return build_options_options_preview(
|
||||
target_price_up=up_f,
|
||||
target_price_down=down_f,
|
||||
index_px=index_px,
|
||||
index_px=index_px_f,
|
||||
leg_a=leg_a,
|
||||
leg_b=leg_b,
|
||||
)
|
||||
|
||||
@@ -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=盈利 2 倍权利金)与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。中途浮盈达盈亏比→两腿全平;不达标→等到期。「全平/到期平」仅兼容旧上破下破计划残腿处理。</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="目标盈利 = 盈亏比 × 两腿权利金合计;例 2=赚满 2 倍权利金后全平">盈亏比 <input type="number" step="0.1" min="0.1" id="hp-oo-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">
|
||||
@@ -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=47"></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 []
|
||||
|
||||
@@ -63,16 +63,51 @@ def _parse_base_common(
|
||||
}, None
|
||||
|
||||
|
||||
def _move_for_perp_correct(*, spot: float, target: float, premium: float, fee_rate: float) -> float:
|
||||
"""净利 = move − premium − fee(move) = target → 解 move.
|
||||
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) * fee_rate
|
||||
move*(1-fee_rate) = target + premium + 2*spot*fee_rate
|
||||
fee = (2*spot + move) * qty * fee_rate
|
||||
qty*move*(1-fee_rate) = target + premium + 2*spot*qty*fee_rate
|
||||
"""
|
||||
denom = 1.0 - float(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) * float(fee_rate)) / denom
|
||||
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(
|
||||
@@ -186,6 +221,7 @@ def calc_perp_options_hedge(
|
||||
"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
|
||||
|
||||
|
||||
@@ -201,12 +237,12 @@ def calc_perp_options_points(
|
||||
ratio_opt: float = 2.0,
|
||||
ct_mult: float = DEFAULT_CT_MULT,
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
"""按永续:期权比例 + 目标盈利,反推两套情景所需波动点数.
|
||||
"""按永续/期权币数 + 目标盈利,反推两套情景所需波动点数.
|
||||
|
||||
永续币数固定为 ratio 归一后的 1 币侧(perp_coins = PERP_COINS).
|
||||
期权币数 = PERP_COINS * (ratio_opt / ratio_perp),例 1:2 → 2 币.
|
||||
永续币数 = ratio_perp, 期权币数 = ratio_opt(按绝对币数,不再归一到 1 币).
|
||||
例 2:4 → 永续 2 币 + 期权 4 币;1:2 → 永续 1 币 + 期权 2 币.
|
||||
|
||||
A 永续方向对: move − premium − fee(move) = 目标盈利
|
||||
A 永续方向对: qty*move − premium − fee(move,qty) = 目标盈利
|
||||
B 期权方向对:
|
||||
- 期权净利达目标: opt_coins*move − premium = 目标
|
||||
- 组合净利达目标: move*(opt_coins − perp_coins) − premium = 目标
|
||||
@@ -226,7 +262,7 @@ def calc_perp_options_points(
|
||||
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"
|
||||
return None, "永续/期权币数须大于 0"
|
||||
|
||||
s = common["spot"]
|
||||
capital = common["capital"]
|
||||
@@ -235,28 +271,35 @@ def calc_perp_options_points(
|
||||
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()
|
||||
|
||||
opt_coins = PERP_COINS * (ro / rp)
|
||||
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)
|
||||
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
|
||||
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
|
||||
portfolio_at_b_opt = opt_net_at_b_opt - move_b_opt * perp_coins
|
||||
|
||||
# 组合净利 = 目标
|
||||
edge = opt_coins - PERP_COINS
|
||||
edge = opt_coins - perp_coins
|
||||
if edge <= 0:
|
||||
move_b_port = None
|
||||
port_err = "期权币数须大于永续币数,组合才能在方向对时赚到目标盈利"
|
||||
@@ -265,7 +308,7 @@ def calc_perp_options_points(
|
||||
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
|
||||
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
|
||||
@@ -279,7 +322,7 @@ def calc_perp_options_points(
|
||||
"ratio_perp": round(rp, 8),
|
||||
"ratio_opt": round(ro, 8),
|
||||
"ratio_label": f"{_fmt_ratio(rp)}:{_fmt_ratio(ro)}",
|
||||
"perp_coins": PERP_COINS,
|
||||
"perp_coins": round(perp_coins, 8),
|
||||
"opt_coins": round(opt_coins, 8),
|
||||
"opt_sheets": round(opt_sheets, 8),
|
||||
"perp_leverage": round(p_lev, 8),
|
||||
@@ -294,7 +337,7 @@ def calc_perp_options_points(
|
||||
"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),
|
||||
"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),
|
||||
@@ -316,6 +359,9 @@ def calc_perp_options_points(
|
||||
"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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -123,31 +123,41 @@ def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]:
|
||||
|
||||
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:
|
||||
ot = str(hedge.get("opt_type") or opt_type).upper()
|
||||
rr = _safe_float(hedge.get("oo_profit_rr") or 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 p.get("opt_type") or p.get("optType") or "").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}"
|
||||
mon = p.get("target_monitor") if isinstance(p.get("target_monitor"), dict) else None
|
||||
rr = _safe_float(p.get("profit_rr"))
|
||||
if rr is None and mon:
|
||||
rr = _safe_float(mon.get("profit_rr"))
|
||||
if rr is not None and rr > 0:
|
||||
return f"盈亏比×{rr:g}"
|
||||
tgt = _safe_float(p.get("target_index"))
|
||||
if tgt is None and mon:
|
||||
tgt = _safe_float(mon.get("target_index"))
|
||||
if tgt is not None and tgt > 0:
|
||||
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
|
||||
side = "Put ≤" if opt_type == "P" else "Call ≥"
|
||||
return f"{side} {tgt:g}"
|
||||
return "—"
|
||||
|
||||
|
||||
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"))
|
||||
@@ -358,34 +368,115 @@ def collect_options_items(
|
||||
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 +493,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=114">
|
||||
<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=19"></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 %}
|
||||
|
||||
@@ -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=114">
|
||||
|
||||
</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=19"></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:
|
||||
|
||||
@@ -72,11 +72,14 @@ def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict
|
||||
mon = tgt_map.get(str(row.get("inst_id") or ""))
|
||||
if mon:
|
||||
row["target_index"] = mon.get("target_index")
|
||||
row["profit_rr"] = mon.get("profit_rr")
|
||||
row["target_monitor_id"] = mon.get("id")
|
||||
row["target_monitor"] = mon
|
||||
hedge_target = hedge_target_map.get(str(row.get("inst_id") or ""))
|
||||
if hedge_target:
|
||||
row["hedge_plan_target"] = hedge_target
|
||||
if hedge_target.get("oo_profit_rr") is not None and row.get("profit_rr") is None:
|
||||
row["profit_rr"] = hedge_target.get("oo_profit_rr")
|
||||
if not mon:
|
||||
row["target_index"] = hedge_target.get("target_index")
|
||||
rows.append(row)
|
||||
|
||||
@@ -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)
|
||||
@@ -37,14 +38,15 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
mon = tgt_map.get(str(p.get("inst_id") or ""))
|
||||
if mon:
|
||||
p["target_index"] = mon.get("target_index")
|
||||
p["profit_rr"] = mon.get("profit_rr")
|
||||
p["target_monitor_id"] = mon.get("id")
|
||||
p["target_monitor"] = mon
|
||||
hedge_target = hedge_target_map.get(str(p.get("inst_id") or ""))
|
||||
if hedge_target:
|
||||
p["hedge_plan_target"] = hedge_target
|
||||
if not mon:
|
||||
# 中控卡片共用 target_index 只读展示;实际平仓仍由对冲计划监控处理。
|
||||
p["target_index"] = hedge_target.get("target_index")
|
||||
p["profit_rr"] = hedge_target.get("oo_profit_rr")
|
||||
try:
|
||||
from lib.instance.instance_dashboard_lib import (
|
||||
_format_options_target,
|
||||
@@ -65,17 +67,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 +95,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
|
||||
@@ -431,6 +455,7 @@ def options_monitor_loop(
|
||||
conn,
|
||||
positions,
|
||||
close_fn=target_close_fn,
|
||||
bid_fn=ticker_bid_fn,
|
||||
send_wechat=send_wechat,
|
||||
account_label=account_label,
|
||||
cfg={"send_wechat": send_wechat, "account_label": account_label},
|
||||
|
||||
@@ -55,6 +55,7 @@ def build_options_open_message(
|
||||
premium_paid: Any = None,
|
||||
open_quote: Any = None,
|
||||
target_index: Any = None,
|
||||
profit_rr: Any = None,
|
||||
signal_note: str = "",
|
||||
trade_id: Any = None,
|
||||
) -> str:
|
||||
@@ -73,7 +74,12 @@ def build_options_open_message(
|
||||
f"权利金:{_fmt(premium_paid)} USDC",
|
||||
]
|
||||
)
|
||||
if target_index is not None and str(target_index).strip() != "":
|
||||
if profit_rr is not None and str(profit_rr).strip() != "":
|
||||
try:
|
||||
lines.append(f"盈亏比:×{float(profit_rr):g}(达标全平;不达标等到期)")
|
||||
except (TypeError, ValueError):
|
||||
lines.append(f"盈亏比:{profit_rr}")
|
||||
elif target_index is not None and str(target_index).strip() != "":
|
||||
try:
|
||||
lines.append(f"目标指数:{float(target_index):g}")
|
||||
except (TypeError, ValueError):
|
||||
@@ -96,6 +102,7 @@ def build_options_close_message(
|
||||
realized_pnl: Any = None,
|
||||
close_quote: Any = None,
|
||||
target_index: Any = None,
|
||||
profit_rr: Any = None,
|
||||
trigger_idx: Any = None,
|
||||
trade_id: Any = None,
|
||||
) -> str:
|
||||
@@ -116,7 +123,12 @@ def build_options_close_message(
|
||||
f"实现盈亏:{_fmt(realized_pnl, 4)} USDC",
|
||||
]
|
||||
)
|
||||
if target_index is not None and str(target_index).strip() != "":
|
||||
if profit_rr is not None and str(profit_rr).strip() != "":
|
||||
try:
|
||||
lines.append(f"盈亏比:×{float(profit_rr):g}")
|
||||
except (TypeError, ValueError):
|
||||
lines.append(f"盈亏比:{profit_rr}")
|
||||
elif target_index is not None and str(target_index).strip() != "":
|
||||
try:
|
||||
lines.append(f"目标指数:{float(target_index):g}")
|
||||
except (TypeError, ValueError):
|
||||
@@ -141,6 +153,7 @@ def notify_options_open(
|
||||
premium_paid: Any = None,
|
||||
open_quote: Any = None,
|
||||
target_index: Any = None,
|
||||
profit_rr: Any = None,
|
||||
signal_note: str = "",
|
||||
) -> bool:
|
||||
ensure_options_notify_columns(conn) if conn is not None else None
|
||||
@@ -160,6 +173,7 @@ def notify_options_open(
|
||||
premium_paid=premium_paid,
|
||||
open_quote=open_quote,
|
||||
target_index=target_index,
|
||||
profit_rr=profit_rr,
|
||||
signal_note=signal_note,
|
||||
trade_id=trade_id,
|
||||
)
|
||||
@@ -196,6 +210,7 @@ def notify_options_close(
|
||||
realized_pnl: Any = None,
|
||||
close_quote: Any = None,
|
||||
target_index: Any = None,
|
||||
profit_rr: Any = None,
|
||||
trigger_idx: Any = None,
|
||||
force: bool = False,
|
||||
) -> bool:
|
||||
@@ -257,6 +272,7 @@ def notify_options_close(
|
||||
realized_pnl=total_pnl,
|
||||
close_quote=close_quote if close_quote is not None else head.get("close_quote"),
|
||||
target_index=target_index,
|
||||
profit_rr=profit_rr,
|
||||
trigger_idx=trigger_idx,
|
||||
trade_id=head.get("id") if len(rows) == 1 else None,
|
||||
)
|
||||
@@ -286,6 +302,7 @@ def notify_options_close(
|
||||
realized_pnl=realized_pnl,
|
||||
close_quote=close_quote,
|
||||
target_index=target_index,
|
||||
profit_rr=profit_rr,
|
||||
trigger_idx=trigger_idx,
|
||||
trade_id=trade_id,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""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}),请先平仓后再开"
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
"""期权链实时报价:OKX 公共 WS tickers → 内存缓存 → SSE 推前端."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from typing import Any, Callable
|
||||
|
||||
from lib.exchange.okx_public_ws_lib import OkxPublicWs
|
||||
from lib.options.options_pricing_lib import (
|
||||
expiry_breakeven_from_ask,
|
||||
idx_distance_to_be,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPTIONS_QUOTE_SSE_HEARTBEAT_SEC = float(os.getenv("OKX_OPTIONS_QUOTE_SSE_HEARTBEAT_SEC", "20"))
|
||||
OPTIONS_QUOTE_FLUSH_MS = float(os.getenv("OKX_OPTIONS_QUOTE_FLUSH_MS", "120"))
|
||||
# OKX 单连接约 240 频道;当前到期日合约 + 指数通常够用
|
||||
OPTIONS_QUOTE_MAX_INST = int(os.getenv("OKX_OPTIONS_QUOTE_MAX_INST", "220"))
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> float | None:
|
||||
try:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class OptionsQuoteLive:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._watchers: dict[str, dict[str, Any]] = {}
|
||||
self._meta: dict[str, dict[str, Any]] = {}
|
||||
self._tickers: dict[str, dict[str, Any]] = {}
|
||||
self._index_by_uly: dict[str, float] = {}
|
||||
self._index_insts: set[str] = set()
|
||||
self._dirty_inst: set[str] = set()
|
||||
self._dirty_index: set[str] = set()
|
||||
self._version = 0
|
||||
self._subscribers: list[queue.Queue[str | None]] = []
|
||||
self._stop = threading.Event()
|
||||
self._flush_thread: threading.Thread | None = None
|
||||
ws_url = (os.getenv("OKX_PUBLIC_WS_URL") or "").strip() or None
|
||||
self._ws = OkxPublicWs(
|
||||
on_data=self._on_ws_data,
|
||||
name="okx-options-quote-ws",
|
||||
**({"url": ws_url} if ws_url else {}),
|
||||
)
|
||||
self._started = False
|
||||
|
||||
def start(self) -> None:
|
||||
if self._started:
|
||||
return
|
||||
self._started = True
|
||||
self._stop.clear()
|
||||
self._ws.start()
|
||||
self._flush_thread = threading.Thread(
|
||||
target=self._flush_loop, name="options-quote-flush", daemon=True
|
||||
)
|
||||
self._flush_thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
self._ws.stop()
|
||||
self._broadcast(close=True)
|
||||
self._started = False
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
uly = ""
|
||||
exp = ""
|
||||
index_inst = ""
|
||||
if self._watchers:
|
||||
last = next(reversed(list(self._watchers.values())))
|
||||
uly = str(last.get("underlying") or "")
|
||||
exp = str(last.get("exp_time") or "")
|
||||
index_inst = str(last.get("index_inst") or "")
|
||||
return {
|
||||
"ok": True,
|
||||
"started": self._started,
|
||||
"ws_ok": self._ws.connected,
|
||||
"underlying": uly,
|
||||
"index_inst": index_inst,
|
||||
"index_px": self._index_by_uly.get(uly),
|
||||
"watch_exp": exp,
|
||||
"watch_count": len(self._meta),
|
||||
"watcher_count": len(self._watchers),
|
||||
"version": self._version,
|
||||
"last_msg_at": self._ws.last_msg_at,
|
||||
}
|
||||
|
||||
def watch(
|
||||
self,
|
||||
*,
|
||||
underlying: str,
|
||||
exp_time: str | int | None,
|
||||
contracts: list[dict[str, Any]],
|
||||
index_inst_id: str | None = None,
|
||||
watcher_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
u = (underlying or "ETH").upper()
|
||||
index_id = (index_inst_id or f"{u}-USD").strip()
|
||||
wid = (watcher_id or "default").strip() or "default"
|
||||
meta: dict[str, dict[str, Any]] = {}
|
||||
for c in contracts or []:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
inst_id = str(c.get("inst_id") or c.get("instId") or "").strip()
|
||||
if not inst_id:
|
||||
continue
|
||||
meta[inst_id] = {
|
||||
"inst_id": inst_id,
|
||||
"opt_type": str(c.get("opt_type") or c.get("optType") or "").upper(),
|
||||
"strike": _safe_float(c.get("strike")),
|
||||
"tick_sz": c.get("tick_sz") or c.get("tickSz"),
|
||||
"underlying": u,
|
||||
}
|
||||
if len(meta) >= max(1, OPTIONS_QUOTE_MAX_INST):
|
||||
break
|
||||
with self._lock:
|
||||
self._watchers[wid] = {
|
||||
"underlying": u,
|
||||
"exp_time": str(exp_time or ""),
|
||||
"index_inst": index_id,
|
||||
"meta": meta,
|
||||
}
|
||||
self._rebuild_subscriptions_locked()
|
||||
if not self._started:
|
||||
self.start()
|
||||
return self.status()
|
||||
|
||||
def _rebuild_subscriptions_locked(self) -> None:
|
||||
merged: dict[str, dict[str, Any]] = {}
|
||||
index_insts: set[str] = set()
|
||||
for w in self._watchers.values():
|
||||
index_insts.add(str(w.get("index_inst") or ""))
|
||||
for inst_id, m in (w.get("meta") or {}).items():
|
||||
if inst_id not in merged:
|
||||
merged[inst_id] = dict(m)
|
||||
if len(merged) >= max(1, OPTIONS_QUOTE_MAX_INST):
|
||||
break
|
||||
if len(merged) >= max(1, OPTIONS_QUOTE_MAX_INST):
|
||||
break
|
||||
index_insts = {x for x in index_insts if x}
|
||||
self._meta = merged
|
||||
self._index_insts = index_insts
|
||||
keep = set(merged.keys())
|
||||
for k in list(self._tickers.keys()):
|
||||
if k not in keep:
|
||||
self._tickers.pop(k, None)
|
||||
args = [{"channel": "tickers", "instId": iid} for iid in merged]
|
||||
for iid in sorted(index_insts):
|
||||
args.append({"channel": "index-tickers", "instId": iid})
|
||||
# 订阅可能分片 sleep,不能堵 Flask 请求线程
|
||||
threading.Thread(
|
||||
target=self._ws.set_subscriptions,
|
||||
args=(args,),
|
||||
name="okx-options-quote-sub",
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def as_okx_tickers(self, underlying: str | None = None) -> dict[str, dict[str, Any]]:
|
||||
"""转成 build_option_chain 可用的 OKX ticker 字段."""
|
||||
u = (underlying or "").upper()
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
with self._lock:
|
||||
for inst_id, q in self._tickers.items():
|
||||
if u and str(q.get("underlying") or "").upper() not in ("", u):
|
||||
continue
|
||||
row: dict[str, Any] = {"instId": inst_id}
|
||||
if q.get("ask") is not None and not q.get("ask_estimated"):
|
||||
row["askPx"] = q.get("ask")
|
||||
row["askSz"] = q.get("ask_sz")
|
||||
if q.get("bid") is not None:
|
||||
row["bidPx"] = q.get("bid")
|
||||
row["bidSz"] = q.get("bid_sz")
|
||||
if q.get("mark_px") is not None:
|
||||
row["markPx"] = q.get("mark_px")
|
||||
out[inst_id] = row
|
||||
return out
|
||||
|
||||
def index_px_for(self, underlying: str) -> float | None:
|
||||
u = (underlying or "").upper()
|
||||
with self._lock:
|
||||
return self._index_by_uly.get(u)
|
||||
|
||||
def is_ws_fresh(self, *, max_age_sec: float = 15.0) -> bool:
|
||||
if not self._ws.connected:
|
||||
return False
|
||||
last = float(self._ws.last_msg_at or 0)
|
||||
return last > 0 and (time.time() - last) <= max_age_sec
|
||||
|
||||
def schedule_seed_from_chain(
|
||||
self,
|
||||
chain: dict[str, Any],
|
||||
*,
|
||||
exp_time: str | int | None = None,
|
||||
watcher_id: str | None = None,
|
||||
) -> None:
|
||||
threading.Thread(
|
||||
target=self.seed_from_chain,
|
||||
kwargs={"chain": chain, "exp_time": exp_time, "watcher_id": watcher_id},
|
||||
name="options-quote-seed",
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def seed_from_chain(
|
||||
self,
|
||||
chain: dict[str, Any],
|
||||
*,
|
||||
exp_time: str | int | None = None,
|
||||
watcher_id: str | None = None,
|
||||
) -> None:
|
||||
"""REST 拉链后预填报价,并默认监视指定/最近到期."""
|
||||
if not isinstance(chain, dict):
|
||||
return
|
||||
u = str(chain.get("underlying") or "ETH").upper()
|
||||
index_px = _safe_float(chain.get("index_px"))
|
||||
expiries = chain.get("expiries") or []
|
||||
target = None
|
||||
if exp_time is not None and str(exp_time):
|
||||
for e in expiries:
|
||||
if str(e.get("exp_time")) == str(exp_time):
|
||||
target = e
|
||||
break
|
||||
if target is None and expiries:
|
||||
target = expiries[0]
|
||||
contracts = list((target or {}).get("contracts") or [])
|
||||
if index_px is not None:
|
||||
with self._lock:
|
||||
self._index_by_uly[u] = index_px
|
||||
self._dirty_index.add(u)
|
||||
for c in contracts:
|
||||
inst_id = str(c.get("inst_id") or "").strip()
|
||||
if not inst_id:
|
||||
continue
|
||||
patch = {
|
||||
"inst_id": inst_id,
|
||||
"ask": c.get("ask"),
|
||||
"bid": c.get("bid"),
|
||||
"ask_sz": c.get("ask_sz"),
|
||||
"bid_sz": c.get("bid_sz"),
|
||||
"mark_px": c.get("mark_px"),
|
||||
"ask_estimated": bool(c.get("ask_estimated")),
|
||||
"expiry_be_px": c.get("expiry_be_px"),
|
||||
"dist_expiry_be": c.get("dist_expiry_be"),
|
||||
"underlying": u,
|
||||
}
|
||||
with self._lock:
|
||||
self._tickers[inst_id] = patch
|
||||
self._dirty_inst.add(inst_id)
|
||||
self.watch(
|
||||
underlying=u,
|
||||
exp_time=(target or {}).get("exp_time"),
|
||||
contracts=contracts,
|
||||
index_inst_id=f"{u}-USD",
|
||||
watcher_id=watcher_id or f"seed:{u}",
|
||||
)
|
||||
|
||||
def _on_ws_data(self, payload: dict[str, Any]) -> None:
|
||||
arg = payload.get("arg") or {}
|
||||
channel = str(arg.get("channel") or "")
|
||||
rows = payload.get("data") or []
|
||||
if not isinstance(rows, list) or not rows:
|
||||
return
|
||||
if channel == "index-tickers":
|
||||
row = rows[0] if isinstance(rows[0], dict) else {}
|
||||
px = _safe_float(row.get("idxPx"))
|
||||
inst = str(row.get("instId") or arg.get("instId") or "")
|
||||
uly = inst.split("-")[0].upper() if inst else ""
|
||||
if px is None or not uly:
|
||||
return
|
||||
with self._lock:
|
||||
if self._index_by_uly.get(uly) == px:
|
||||
return
|
||||
self._index_by_uly[uly] = px
|
||||
self._dirty_index.add(uly)
|
||||
return
|
||||
if channel != "tickers":
|
||||
return
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
inst_id = str(row.get("instId") or arg.get("instId") or "").strip()
|
||||
if not inst_id:
|
||||
continue
|
||||
patch = self._ticker_to_patch(inst_id, row)
|
||||
with self._lock:
|
||||
prev = self._tickers.get(inst_id) or {}
|
||||
if (
|
||||
prev.get("ask") == patch.get("ask")
|
||||
and prev.get("bid") == patch.get("bid")
|
||||
and prev.get("ask_sz") == patch.get("ask_sz")
|
||||
and prev.get("bid_sz") == patch.get("bid_sz")
|
||||
and prev.get("mark_px") == patch.get("mark_px")
|
||||
):
|
||||
continue
|
||||
self._tickers[inst_id] = patch
|
||||
self._dirty_inst.add(inst_id)
|
||||
|
||||
def _ticker_to_patch(self, inst_id: str, row: dict[str, Any]) -> dict[str, Any]:
|
||||
ask = _safe_float(row.get("askPx"))
|
||||
bid = _safe_float(row.get("bidPx"))
|
||||
ask_sz = _safe_float(row.get("askSz"))
|
||||
bid_sz = _safe_float(row.get("bidSz"))
|
||||
mark = _safe_float(row.get("markPx"))
|
||||
ask_estimated = False
|
||||
with self._lock:
|
||||
meta = dict(self._meta.get(inst_id) or {})
|
||||
uly = str(meta.get("underlying") or inst_id.split("-")[0] or "").upper()
|
||||
index_px = self._index_by_uly.get(uly)
|
||||
if ask is None and mark is not None and mark > 0:
|
||||
ask = mark
|
||||
ask_estimated = True
|
||||
ask_sz = None
|
||||
if bid is None and mark is not None and mark > 0:
|
||||
bid = mark
|
||||
be = expiry_breakeven_from_ask(
|
||||
opt_type=str(meta.get("opt_type") or ""),
|
||||
strike=meta.get("strike"),
|
||||
ask_px=None if ask_estimated else ask,
|
||||
mark_px=mark,
|
||||
)
|
||||
dist = idx_distance_to_be(index_px, be)
|
||||
return {
|
||||
"inst_id": inst_id,
|
||||
"underlying": uly,
|
||||
"ask": ask,
|
||||
"bid": bid,
|
||||
"ask_sz": ask_sz,
|
||||
"bid_sz": bid_sz,
|
||||
"mark_px": mark,
|
||||
"ask_estimated": ask_estimated,
|
||||
"expiry_be_px": be,
|
||||
"dist_expiry_be": dist,
|
||||
}
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
interval = max(0.05, OPTIONS_QUOTE_FLUSH_MS / 1000.0)
|
||||
while not self._stop.is_set():
|
||||
if self._stop.wait(interval):
|
||||
break
|
||||
event = self._build_flush_event()
|
||||
if event is None:
|
||||
continue
|
||||
self._broadcast(event)
|
||||
|
||||
def _build_flush_event(self) -> str | None:
|
||||
with self._lock:
|
||||
if not self._dirty_inst and not self._dirty_index:
|
||||
return None
|
||||
dirty_uly = set(self._dirty_index)
|
||||
self._dirty_index.clear()
|
||||
quotes: list[dict[str, Any]] = []
|
||||
for inst_id in list(self._dirty_inst):
|
||||
q = self._tickers.get(inst_id)
|
||||
if q:
|
||||
quotes.append(dict(q))
|
||||
self._dirty_inst.clear()
|
||||
for uly in dirty_uly:
|
||||
index_px = self._index_by_uly.get(uly)
|
||||
if index_px is None:
|
||||
continue
|
||||
for inst_id, q in list(self._tickers.items()):
|
||||
if str(q.get("underlying") or "").upper() != uly:
|
||||
continue
|
||||
be = q.get("expiry_be_px")
|
||||
dist = idx_distance_to_be(index_px, be if be is not None else None)
|
||||
if q.get("dist_expiry_be") != dist:
|
||||
q2 = dict(q)
|
||||
q2["dist_expiry_be"] = dist
|
||||
self._tickers[inst_id] = q2
|
||||
quotes.append(q2)
|
||||
self._version += 1
|
||||
# 多标的时 index_px 取「最近一次 watch」的标的,前端仍以 payload.underlying 过滤
|
||||
uly = ""
|
||||
exp = ""
|
||||
if self._watchers:
|
||||
last = next(reversed(list(self._watchers.values())))
|
||||
uly = str(last.get("underlying") or "")
|
||||
exp = str(last.get("exp_time") or "")
|
||||
# 若本批只有单一 underlying 的 quotes/index,优先用它
|
||||
quote_ulys = {str(q.get("underlying") or "").upper() for q in quotes if q.get("underlying")}
|
||||
if len(dirty_uly) == 1:
|
||||
uly = next(iter(dirty_uly))
|
||||
elif len(quote_ulys) == 1:
|
||||
uly = next(iter(quote_ulys))
|
||||
payload = {
|
||||
"ok": True,
|
||||
"live": True,
|
||||
"ws_ok": self._ws.connected,
|
||||
"version": self._version,
|
||||
"underlying": uly,
|
||||
"watch_exp": exp,
|
||||
"index_px": self._index_by_uly.get(uly),
|
||||
"indexes": dict(self._index_by_uly),
|
||||
"quotes": quotes,
|
||||
"ts": int(time.time() * 1000),
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
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=64)
|
||||
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 self._format_event(
|
||||
{
|
||||
"ok": True,
|
||||
"reason": "connect",
|
||||
**self.status(),
|
||||
"quotes": [],
|
||||
"ts": int(time.time() * 1000),
|
||||
}
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
raw = q.get(timeout=OPTIONS_QUOTE_SSE_HEARTBEAT_SEC)
|
||||
except queue.Empty:
|
||||
yield ": heartbeat\n\n"
|
||||
continue
|
||||
if raw is None:
|
||||
break
|
||||
yield f"event: quotes\ndata: {raw}\n\n"
|
||||
finally:
|
||||
self._unsubscribe(q)
|
||||
|
||||
@staticmethod
|
||||
def _format_event(data: dict[str, Any]) -> str:
|
||||
return "event: quotes\ndata: " + json.dumps(data, ensure_ascii=False) + "\n\n"
|
||||
|
||||
|
||||
options_quote_live = OptionsQuoteLive()
|
||||
|
||||
|
||||
def start_options_quote_live() -> OptionsQuoteLive:
|
||||
options_quote_live.start()
|
||||
return options_quote_live
|
||||
|
||||
|
||||
def register_options_quote_live_routes(app: Any, login_required: Callable) -> None:
|
||||
from flask import Response, jsonify, request, stream_with_context
|
||||
|
||||
start_options_quote_live()
|
||||
|
||||
@app.route("/api/options/quotes/stream")
|
||||
@login_required
|
||||
def api_options_quotes_stream():
|
||||
return Response(
|
||||
stream_with_context(options_quote_live.iter_sse()),
|
||||
mimetype="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
@app.route("/api/options/quotes/watch", methods=["POST"])
|
||||
@login_required
|
||||
def api_options_quotes_watch():
|
||||
data = request.get_json(silent=True) or {}
|
||||
contracts = data.get("contracts") or []
|
||||
if not contracts and data.get("inst_ids"):
|
||||
contracts = [{"inst_id": x} for x in (data.get("inst_ids") or [])]
|
||||
st = options_quote_live.watch(
|
||||
underlying=str(data.get("underlying") or "ETH"),
|
||||
exp_time=data.get("exp_time"),
|
||||
contracts=contracts,
|
||||
index_inst_id=data.get("index_inst_id"),
|
||||
watcher_id=str(data.get("watcher_id") or "default"),
|
||||
)
|
||||
return jsonify({"ok": True, **st})
|
||||
|
||||
@app.route("/api/options/quotes/status")
|
||||
@login_required
|
||||
def api_options_quotes_status():
|
||||
return jsonify(options_quote_live.status())
|
||||
+314
-82
@@ -61,6 +61,14 @@ def install_options_trading(app: Flask, repo_root: str, app_module: Any) -> None
|
||||
register_options_routes(app, cfg)
|
||||
_register_options_hub_bridge(app, cfg)
|
||||
if enabled:
|
||||
try:
|
||||
from lib.options.options_quote_live_lib import register_options_quote_live_routes
|
||||
|
||||
register_options_quote_live_routes(app, cfg["login_required"])
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception("options quote live init failed: %s", e)
|
||||
_start_monitor_thread(app, cfg)
|
||||
|
||||
|
||||
@@ -92,12 +100,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),
|
||||
@@ -132,7 +138,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,
|
||||
}
|
||||
@@ -357,13 +362,7 @@ 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": cfg["trade_budget"]})
|
||||
|
||||
@app.route("/api/options/chain")
|
||||
@@ -373,13 +372,37 @@ 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))
|
||||
fast = (request.args.get("fast") or "").strip().lower() in ("1", "true", "yes")
|
||||
force_tickers = (request.args.get("force_tickers") or "").strip().lower() in ("1", "true", "yes")
|
||||
watch_exp = (request.args.get("exp_time") or "").strip() or None
|
||||
live_index = None
|
||||
live_tickers = None
|
||||
ws_fresh = False
|
||||
try:
|
||||
from lib.options.options_quote_live_lib import options_quote_live
|
||||
|
||||
ws_fresh = options_quote_live.is_ws_fresh()
|
||||
live_index = options_quote_live.index_px_for(u)
|
||||
live_tickers = options_quote_live.as_okx_tickers(u) or None
|
||||
except Exception:
|
||||
pass
|
||||
# fast: WS 已热则跳过整家族 REST tickers(最慢的一步),用 WS 缓存覆盖
|
||||
fetch_tickers = True
|
||||
if fast and ws_fresh and not force_tickers:
|
||||
fetch_tickers = False
|
||||
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"],
|
||||
index_px=live_index,
|
||||
tickers_override=live_tickers,
|
||||
fetch_tickers=fetch_tickers,
|
||||
force_tickers=force_tickers,
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"})
|
||||
@@ -388,26 +411,39 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
# 热更新:每次读 env,保存配置后刷新链即可生效
|
||||
ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True)
|
||||
budget_buffer = _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95)
|
||||
if expiries:
|
||||
try:
|
||||
from lib.options.options_quote_live_lib import options_quote_live
|
||||
|
||||
options_quote_live.schedule_seed_from_chain(chain, exp_time=watch_exp)
|
||||
except Exception:
|
||||
pass
|
||||
if not expiries:
|
||||
return jsonify(
|
||||
{
|
||||
"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"],
|
||||
"chain_fast": fast,
|
||||
"ws_fresh": ws_fresh,
|
||||
}
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"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"],
|
||||
"quote_live": True,
|
||||
"chain_fast": fast,
|
||||
"ws_fresh": ws_fresh,
|
||||
"tickers_fetched": fetch_tickers,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -456,6 +492,68 @@ 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,
|
||||
}
|
||||
)
|
||||
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,
|
||||
}
|
||||
)
|
||||
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:
|
||||
# 合约可报价,但不可开仓:返回参考标记价供展示
|
||||
@@ -478,6 +576,33 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
"budget_full_usdc": budget if mode == "budget_full" else None,
|
||||
}
|
||||
)
|
||||
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 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,
|
||||
}
|
||||
)
|
||||
sizing = calc_order_size(
|
||||
quote_per_unit=float(ask),
|
||||
ct_mult=float(ct_mult),
|
||||
@@ -539,6 +664,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,13 +682,24 @@ 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()
|
||||
signal_note = (data.get("signal_note") or "").strip()
|
||||
target_index = None
|
||||
profit_rr = None
|
||||
raw_rr = data.get("profit_rr")
|
||||
if raw_rr is None or str(raw_rr).strip() == "":
|
||||
raw_rr = data.get("oo_profit_rr")
|
||||
if raw_rr is not None and str(raw_rr).strip() != "":
|
||||
try:
|
||||
profit_rr = float(raw_rr)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"ok": False, "msg": "盈亏比无效"})
|
||||
if profit_rr <= 0:
|
||||
return jsonify({"ok": False, "msg": "盈亏比须大于 0"})
|
||||
raw_target = data.get("target_index")
|
||||
if raw_target is not None and str(raw_target).strip() != "":
|
||||
try:
|
||||
@@ -564,6 +708,9 @@ 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": "目标位无效"})
|
||||
# 未显式传目标时默认盈亏比 2
|
||||
if profit_rr is None and target_index is None:
|
||||
profit_rr = 2.0
|
||||
if not inst_id:
|
||||
return jsonify({"ok": False, "msg": "缺少 inst_id"})
|
||||
q = cfg["quote_option_contract"](ex, inst_id)
|
||||
@@ -582,6 +729,15 @@ 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 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 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
|
||||
@@ -620,16 +776,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 +793,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
|
||||
@@ -669,20 +870,21 @@ 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,
|
||||
),
|
||||
)
|
||||
trade_id = int(cur.lastrowid)
|
||||
if target_index is not None:
|
||||
if profit_rr is not None or target_index is not None:
|
||||
from lib.options.options_target_lib import upsert_target_monitor
|
||||
|
||||
target_mon = upsert_target_monitor(
|
||||
conn,
|
||||
inst_id=inst_id,
|
||||
target_index=target_index,
|
||||
profit_rr=profit_rr,
|
||||
underlying=u,
|
||||
opt_type=str(opt_type) if opt_type else None,
|
||||
trade_id=trade_id,
|
||||
@@ -708,8 +910,9 @@ 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,
|
||||
profit_rr=profit_rr,
|
||||
signal_note=signal_note,
|
||||
)
|
||||
finally:
|
||||
@@ -825,11 +1028,14 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
mon = tgt_map.get(inst)
|
||||
if mon:
|
||||
row["target_index"] = mon.get("target_index")
|
||||
row["profit_rr"] = mon.get("profit_rr")
|
||||
row["target_monitor_id"] = mon.get("id")
|
||||
row["target_monitor"] = mon
|
||||
hedge_target = hedge_target_map.get(inst)
|
||||
if hedge_target:
|
||||
row["hedge_plan_target"] = hedge_target
|
||||
if hedge_target.get("oo_profit_rr") is not None:
|
||||
row.setdefault("profit_rr", hedge_target.get("oo_profit_rr"))
|
||||
try:
|
||||
from lib.instance.instance_dashboard_lib import _resolve_options_source
|
||||
|
||||
@@ -868,11 +1074,47 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
if not inst_id:
|
||||
return jsonify({"ok": False, "msg": "缺少 inst_id"})
|
||||
try:
|
||||
target_index = float(data.get("target_index"))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"ok": False, "msg": "目标位无效"})
|
||||
if target_index <= 0:
|
||||
return jsonify({"ok": False, "msg": "目标位无效"})
|
||||
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}"})
|
||||
profit_rr = None
|
||||
target_index = None
|
||||
raw_rr = data.get("profit_rr")
|
||||
if raw_rr is None or str(raw_rr).strip() == "":
|
||||
raw_rr = data.get("oo_profit_rr")
|
||||
if raw_rr is not None and str(raw_rr).strip() != "":
|
||||
try:
|
||||
profit_rr = float(raw_rr)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"ok": False, "msg": "盈亏比无效"})
|
||||
if profit_rr <= 0:
|
||||
return jsonify({"ok": False, "msg": "盈亏比须大于 0"})
|
||||
raw_tgt = data.get("target_index")
|
||||
if raw_tgt is not None and str(raw_tgt).strip() != "":
|
||||
try:
|
||||
target_index = float(raw_tgt)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"ok": False, "msg": "目标位无效"})
|
||||
if target_index <= 0:
|
||||
return jsonify({"ok": False, "msg": "目标位无效"})
|
||||
if profit_rr is None and target_index is None:
|
||||
profit_rr = 2.0
|
||||
raw = cfg["fetch_option_positions"](ex)
|
||||
if raw is None:
|
||||
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
|
||||
@@ -901,6 +1143,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
conn,
|
||||
inst_id=inst_id,
|
||||
target_index=target_index,
|
||||
profit_rr=profit_rr,
|
||||
underlying=str(underlying) if underlying else None,
|
||||
opt_type=str(opt_type) if opt_type else None,
|
||||
trade_id=trade_id,
|
||||
@@ -943,6 +1186,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 +1344,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():
|
||||
@@ -1275,7 +1490,24 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
raw = cfg["fetch_option_positions"](ex)
|
||||
if raw is None:
|
||||
return []
|
||||
return [cfg["format_position_row"](p) for p in raw]
|
||||
rows = [cfg["format_position_row"](p) for p in raw]
|
||||
try:
|
||||
from lib.options.options_db import sum_open_premium_paid
|
||||
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
for row in rows:
|
||||
inst = str(row.get("inst_id") or "")
|
||||
if not inst:
|
||||
continue
|
||||
paid = sum_open_premium_paid(conn, inst)
|
||||
if paid is not None:
|
||||
row["premium_paid"] = paid
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return rows
|
||||
|
||||
def _sync(conn):
|
||||
from lib.exchange.okx_options_lib import fetch_option_position_history
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算)."""
|
||||
"""期权目标委托:盈亏比×权利金触发后按买一限价平仓(无止损,到期结算).
|
||||
|
||||
兼容旧「目标指数」委托:无 profit_rr 时仍按指数到位触发.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
from lib.options.options_db import init_options_tables
|
||||
from lib.options.options_db import init_options_tables, sum_open_premium_paid
|
||||
from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px
|
||||
|
||||
|
||||
@@ -18,6 +21,18 @@ def _safe_float(v: Any) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
|
||||
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
names: set[str] = set()
|
||||
for r in rows:
|
||||
try:
|
||||
names.add(str(r["name"]))
|
||||
except (TypeError, KeyError, IndexError):
|
||||
names.add(str(r[1]))
|
||||
if col not in names:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
|
||||
|
||||
|
||||
def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]:
|
||||
from lib.exchange.okx_options_lib import option_fields_from_inst_id
|
||||
|
||||
@@ -63,21 +78,44 @@ def ensure_target_tables(conn: sqlite3.Connection) -> None:
|
||||
ON options_target_monitors(status)
|
||||
"""
|
||||
)
|
||||
# 盈亏比=目标盈利/权利金;如 2=盈利 2 倍权利金.有值时优先生效,target_index 可置 0
|
||||
_ensure_column(conn, "options_target_monitors", "profit_rr", "REAL")
|
||||
|
||||
|
||||
def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool:
|
||||
"""Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓."""
|
||||
"""旧逻辑:Call 指数≥目标;Put 指数≤目标."""
|
||||
ot = (opt_type or "").strip().upper()
|
||||
if ot == "P":
|
||||
return index_px <= target_index
|
||||
return index_px >= target_index
|
||||
|
||||
|
||||
def profit_rr_hit(
|
||||
*,
|
||||
premium: float,
|
||||
bid: float | None,
|
||||
sheets: float,
|
||||
ct_mult: float,
|
||||
profit_rr: float,
|
||||
) -> bool:
|
||||
"""买一回收 − 权利金 ≥ 盈亏比 × 权利金."""
|
||||
if premium <= 0 or profit_rr <= 0:
|
||||
return False
|
||||
if bid is None or float(bid) <= 0:
|
||||
return False
|
||||
if sheets <= 0 or ct_mult <= 0:
|
||||
return False
|
||||
recycle = float(bid) * float(sheets) * float(ct_mult)
|
||||
pnl = recycle - float(premium)
|
||||
return pnl + 1e-9 >= float(profit_rr) * float(premium)
|
||||
|
||||
|
||||
def upsert_target_monitor(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
inst_id: str,
|
||||
target_index: float,
|
||||
target_index: float | None = None,
|
||||
profit_rr: float | None = None,
|
||||
underlying: str | None = None,
|
||||
opt_type: str | None = None,
|
||||
trade_id: int | None = None,
|
||||
@@ -87,9 +125,18 @@ def upsert_target_monitor(
|
||||
inst_id = (inst_id or "").strip()
|
||||
if not inst_id:
|
||||
return {"ok": False, "msg": "缺少 inst_id"}
|
||||
if target_index is None or float(target_index) <= 0:
|
||||
return {"ok": False, "msg": "目标位无效"}
|
||||
target_index = float(target_index)
|
||||
|
||||
rr = _safe_float(profit_rr)
|
||||
tgt = _safe_float(target_index)
|
||||
if rr is not None and rr > 0:
|
||||
tgt_store = float(tgt) if tgt is not None and tgt > 0 else 0.0
|
||||
rr_store = float(rr)
|
||||
elif tgt is not None and tgt > 0:
|
||||
tgt_store = float(tgt)
|
||||
rr_store = None
|
||||
else:
|
||||
return {"ok": False, "msg": "请填写盈亏比(相对权利金,默认2)"}
|
||||
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id FROM options_target_monitors
|
||||
@@ -104,6 +151,7 @@ def upsert_target_monitor(
|
||||
"""
|
||||
UPDATE options_target_monitors
|
||||
SET target_index = ?,
|
||||
profit_rr = ?,
|
||||
underlying = COALESCE(?, underlying),
|
||||
opt_type = COALESCE(?, opt_type),
|
||||
trade_id = COALESCE(?, trade_id),
|
||||
@@ -115,14 +163,13 @@ def upsert_target_monitor(
|
||||
triggered_at = NULL
|
||||
WHERE id = ?
|
||||
""",
|
||||
(target_index, underlying, opt_type, trade_id, sheets, int(row["id"])),
|
||||
(tgt_store, rr_store, underlying, opt_type, trade_id, sheets, int(row["id"])),
|
||||
)
|
||||
mon_id = int(row["id"])
|
||||
# 同一合约其他进行中的委托取消,避免双轨触发重复推送
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_target_monitors
|
||||
SET status = 'cancelled', message = '被新目标位覆盖'
|
||||
SET status = 'cancelled', message = '被新目标委托覆盖'
|
||||
WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing')
|
||||
""",
|
||||
(inst_id, mon_id),
|
||||
@@ -131,13 +178,21 @@ def upsert_target_monitor(
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO options_target_monitors
|
||||
(inst_id, underlying, opt_type, target_index, trade_id, sheets, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'active')
|
||||
(inst_id, underlying, opt_type, target_index, profit_rr, trade_id, sheets, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'active')
|
||||
""",
|
||||
(inst_id, underlying, opt_type, target_index, trade_id, sheets),
|
||||
(inst_id, underlying, opt_type, tgt_store, rr_store, trade_id, sheets),
|
||||
)
|
||||
mon_id = int(cur.lastrowid)
|
||||
return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index}
|
||||
out: dict[str, Any] = {
|
||||
"ok": True,
|
||||
"id": mon_id,
|
||||
"inst_id": inst_id,
|
||||
"target_index": tgt_store if tgt_store > 0 else None,
|
||||
}
|
||||
if rr_store is not None:
|
||||
out["profit_rr"] = rr_store
|
||||
return out
|
||||
|
||||
|
||||
def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int:
|
||||
@@ -166,12 +221,19 @@ def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = Non
|
||||
|
||||
|
||||
def _row_to_target(r: sqlite3.Row) -> dict[str, Any]:
|
||||
tgt = _safe_float(r["target_index"])
|
||||
rr = None
|
||||
try:
|
||||
rr = _safe_float(r["profit_rr"])
|
||||
except (KeyError, IndexError):
|
||||
rr = None
|
||||
return {
|
||||
"id": int(r["id"]),
|
||||
"inst_id": r["inst_id"],
|
||||
"underlying": r["underlying"],
|
||||
"opt_type": r["opt_type"],
|
||||
"target_index": _safe_float(r["target_index"]),
|
||||
"target_index": tgt if tgt is not None and tgt > 0 else None,
|
||||
"profit_rr": rr if rr is not None and rr > 0 else None,
|
||||
"trade_id": r["trade_id"],
|
||||
"sheets": r["sheets"],
|
||||
"status": r["status"],
|
||||
@@ -180,16 +242,16 @@ def _row_to_target(r: sqlite3.Row) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
_TARGET_SELECT = (
|
||||
"SELECT id, inst_id, underlying, opt_type, target_index, profit_rr, trade_id, sheets, "
|
||||
"status, message, created_at FROM options_target_monitors"
|
||||
)
|
||||
|
||||
|
||||
def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
ensure_target_tables(conn)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
|
||||
status, message, created_at
|
||||
FROM options_target_monitors
|
||||
WHERE status = 'active'
|
||||
ORDER BY id DESC
|
||||
"""
|
||||
f"{_TARGET_SELECT} WHERE status = 'active' ORDER BY id DESC"
|
||||
).fetchall()
|
||||
return [_row_to_target(r) for r in rows]
|
||||
|
||||
@@ -198,13 +260,7 @@ def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
"""已挂出平仓单、等待成交的目标(不再重复推送微信)."""
|
||||
ensure_target_tables(conn)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
|
||||
status, message, created_at
|
||||
FROM options_target_monitors
|
||||
WHERE status = 'closing'
|
||||
ORDER BY id DESC
|
||||
"""
|
||||
f"{_TARGET_SELECT} WHERE status = 'closing' ORDER BY id DESC"
|
||||
).fetchall()
|
||||
return [_row_to_target(r) for r in rows]
|
||||
|
||||
@@ -286,23 +342,23 @@ def close_option_by_bid_depth(
|
||||
inst_id,
|
||||
sheets=sheets,
|
||||
require_recycle_gate=True,
|
||||
signal_note="目标位平仓",
|
||||
signal_note="盈亏比平仓",
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _notify_target_close(
|
||||
cfg: dict[str, Any] | None,
|
||||
send_wechat: Callable[[str], None] | None,
|
||||
*,
|
||||
account_label: str,
|
||||
inst_id: str,
|
||||
target: float,
|
||||
idx: float,
|
||||
target: float | None,
|
||||
profit_rr: float | None,
|
||||
idx: float | None,
|
||||
result: dict[str, Any],
|
||||
conn: Any = None,
|
||||
) -> None:
|
||||
"""目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案."""
|
||||
"""目标平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案."""
|
||||
if result.get("fully_closed") or result.get("already_flat"):
|
||||
if cfg is not None:
|
||||
try:
|
||||
@@ -312,12 +368,13 @@ def _notify_target_close(
|
||||
cfg,
|
||||
conn,
|
||||
inst_id=inst_id,
|
||||
reason="目标位平仓",
|
||||
reason="盈亏比平仓" if profit_rr else "目标位平仓",
|
||||
sheets=result.get("submitted_sheets"),
|
||||
premium_received=result.get("premium_received"),
|
||||
close_quote=result.get("locked_bid_px") or result.get("bid"),
|
||||
target_index=target,
|
||||
trigger_idx=idx,
|
||||
profit_rr=profit_rr,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
@@ -325,14 +382,20 @@ def _notify_target_close(
|
||||
if not send_wechat:
|
||||
return
|
||||
try:
|
||||
if profit_rr is not None and profit_rr > 0:
|
||||
rule = f"盈亏比×{profit_rr:g}"
|
||||
elif target is not None:
|
||||
rule = f"目标指数:{target:g}"
|
||||
else:
|
||||
rule = "目标委托"
|
||||
send_wechat(
|
||||
"\n".join(
|
||||
[
|
||||
"【OKX期权·目标位平仓】",
|
||||
"【OKX期权·盈亏比平仓】" if profit_rr else "【OKX期权·目标位平仓】",
|
||||
f"账户:{account_label}",
|
||||
f"合约:{inst_id}",
|
||||
f"目标指数:{target:g}",
|
||||
f"触发指数:{idx:g}",
|
||||
rule,
|
||||
f"触发指数:{idx:g}" if idx is not None else "触发指数:—",
|
||||
f"提交张数:{result.get('submitted_sheets') or '—'}",
|
||||
f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else '—'} USDC",
|
||||
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
|
||||
@@ -354,18 +417,77 @@ def _result_fully_done(result: dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _monitor_should_close(
|
||||
conn: sqlite3.Connection,
|
||||
mon: dict[str, Any],
|
||||
pos: dict[str, Any],
|
||||
*,
|
||||
bid_fn: Callable[[str], float | None] | None,
|
||||
index_fn: Callable[[dict[str, Any]], float | None] | None,
|
||||
) -> tuple[bool, float | None]:
|
||||
"""返回 (是否触发, 当前指数)."""
|
||||
inst_id = str(mon.get("inst_id") or "")
|
||||
rr = _safe_float(mon.get("profit_rr"))
|
||||
if index_fn is not None:
|
||||
idx = index_fn(pos)
|
||||
else:
|
||||
idx = _safe_float(pos.get("idx_px") or pos.get("idxPx"))
|
||||
|
||||
if rr is not None and rr > 0:
|
||||
premium = sum_open_premium_paid(conn, inst_id)
|
||||
if premium is None or premium <= 0:
|
||||
premium = _safe_float(pos.get("premium_paid"))
|
||||
sheets = _safe_float(mon.get("sheets"))
|
||||
if sheets is None or sheets <= 0:
|
||||
sheets = _safe_float(pos.get("pos") or pos.get("avail_pos") or pos.get("availPos"))
|
||||
ct = _safe_float(pos.get("ct_mult") or pos.get("ctMult")) or 0.01
|
||||
bid = None
|
||||
if bid_fn is not None:
|
||||
try:
|
||||
bid = bid_fn(inst_id)
|
||||
except Exception:
|
||||
bid = None
|
||||
if bid is None:
|
||||
bid = _safe_float(pos.get("bid_px") or pos.get("bidPx") or pos.get("bid"))
|
||||
preview = pos.get("close_preview") if isinstance(pos.get("close_preview"), dict) else {}
|
||||
if bid is None:
|
||||
bid = _safe_float(preview.get("bid") or preview.get("best_bid"))
|
||||
if premium is None or sheets is None:
|
||||
return False, idx
|
||||
return (
|
||||
profit_rr_hit(
|
||||
premium=float(premium),
|
||||
bid=bid,
|
||||
sheets=float(sheets),
|
||||
ct_mult=float(ct),
|
||||
profit_rr=float(rr),
|
||||
),
|
||||
idx,
|
||||
)
|
||||
|
||||
target = _safe_float(mon.get("target_index"))
|
||||
if target is None or target <= 0 or idx is None:
|
||||
return False, idx
|
||||
opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
|
||||
return (
|
||||
target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target),
|
||||
idx,
|
||||
)
|
||||
|
||||
|
||||
def run_options_target_closes(
|
||||
conn: sqlite3.Connection,
|
||||
positions: list[dict[str, Any]],
|
||||
*,
|
||||
close_fn: Callable[[str], dict[str, Any]],
|
||||
index_fn: Callable[[dict[str, Any]], float | None] | None = None,
|
||||
bid_fn: Callable[[str], float | None] | None = None,
|
||||
send_wechat: Callable[[str], None] | None = None,
|
||||
account_label: str = "OKX期权",
|
||||
cfg: dict[str, Any] | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
扫描 active 目标委托;指数到位后限价平仓.
|
||||
扫描 active 目标委托;盈亏比达标(或旧指数到位)后限价平仓.
|
||||
状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送.
|
||||
未完全成交进入 closing,仅重试平仓不再推送.
|
||||
返回本次新触发(并推送)的条数.
|
||||
@@ -373,6 +495,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 +512,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)
|
||||
@@ -394,7 +534,7 @@ def run_options_target_closes(
|
||||
status="triggered",
|
||||
trigger_idx=idx,
|
||||
close_ord_id=result.get("close_ord_id"),
|
||||
message="目标位限价平仓完成",
|
||||
message="盈亏比限价平仓完成",
|
||||
)
|
||||
_commit_monitor(conn)
|
||||
continue
|
||||
@@ -411,23 +551,29 @@ def run_options_target_closes(
|
||||
triggered = 0
|
||||
for mon in list_active_targets(conn):
|
||||
inst_id = str(mon.get("inst_id") or "")
|
||||
target = _safe_float(mon.get("target_index"))
|
||||
if not inst_id or target is None:
|
||||
if not inst_id:
|
||||
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
|
||||
if index_fn is not None:
|
||||
idx = index_fn(pos)
|
||||
else:
|
||||
idx = _safe_float(pos.get("idx_px") or pos.get("idxPx"))
|
||||
if idx is None:
|
||||
continue
|
||||
opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
|
||||
if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target):
|
||||
should, idx = _monitor_should_close(
|
||||
conn, mon, pos, bid_fn=bid_fn, index_fn=index_fn
|
||||
)
|
||||
if not should:
|
||||
continue
|
||||
|
||||
result = close_fn(inst_id)
|
||||
rr = _safe_float(mon.get("profit_rr"))
|
||||
target = _safe_float(mon.get("target_index"))
|
||||
if result.get("already_flat"):
|
||||
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
|
||||
_commit_monitor(conn)
|
||||
@@ -445,15 +591,19 @@ def run_options_target_closes(
|
||||
|
||||
done = _result_fully_done(result)
|
||||
status = "triggered" if done else "closing"
|
||||
hit_msg = (
|
||||
"盈亏比达标限价平仓"
|
||||
if (rr is not None and rr > 0)
|
||||
else "目标位触发限价平仓"
|
||||
)
|
||||
mark_monitor(
|
||||
conn,
|
||||
int(mon["id"]),
|
||||
status=status,
|
||||
trigger_idx=idx,
|
||||
close_ord_id=result.get("close_ord_id"),
|
||||
message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交",
|
||||
message=hit_msg if done else "已挂买一限价,等待成交",
|
||||
)
|
||||
# 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信
|
||||
_commit_monitor(conn)
|
||||
triggered += 1
|
||||
_notify_target_close(
|
||||
@@ -462,6 +612,7 @@ def run_options_target_closes(
|
||||
account_label=account_label,
|
||||
inst_id=inst_id,
|
||||
target=target,
|
||||
profit_rr=rr,
|
||||
idx=idx,
|
||||
result=result,
|
||||
conn=conn,
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
data-trade-budget="{{ options_trade_budget | default(10) }}"
|
||||
data-ask-liq-filter="{% if options_chain_ask_liq_filter is defined %}{{ '1' if options_chain_ask_liq_filter else '0' }}{% else %}1{% endif %}">
|
||||
{% 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,7 +21,7 @@
|
||||
<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>平仓仅买一限价,详见说明文档。</li>
|
||||
</ul>
|
||||
@@ -40,7 +43,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 +57,7 @@
|
||||
<th>类型</th>
|
||||
<th>合约</th>
|
||||
<th>卖一/张</th>
|
||||
<th title="指数÷卖一(每1币)">杠杆</th>
|
||||
<th>买一/张</th>
|
||||
<th>到期平衡</th>
|
||||
<th>距平衡</th>
|
||||
@@ -77,7 +81,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,17 +107,15 @@
|
||||
</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-profit-rr" title="目标盈利=盈亏比×权利金;例2=赚满2倍权利金后全平">盈亏比</label>
|
||||
<input type="number" id="opt-profit-rr" class="opt-target-idx" step="0.1" min="0.1" value="2" placeholder="默认2"
|
||||
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 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-value" class="v" title="权利金+目标盈利">—</span>
|
||||
</div>
|
||||
<span class="muted opt-est-note">目标价=监控指数;到位后按买一限价平仓;无止损,到期即止损</span>
|
||||
<span class="muted opt-est-note">按买一浮盈达盈亏比×权利金后限价全平;不达标等到期;无止损</span>
|
||||
</div>
|
||||
<div class="form-row options-order-mode-row">
|
||||
<div class="opt-size-mode-bar">
|
||||
@@ -320,4 +322,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=59"></script>
|
||||
|
||||
@@ -185,10 +185,16 @@
|
||||
|
||||
{# Tab + 筛选:放在各内容卡片上方,全局作用于下方列表/统计 #}
|
||||
<div class="or-toolbar">
|
||||
<div class="or-tabs" role="tablist" aria-label="复盘分类">
|
||||
<div class="or-tabs" role="tablist" aria-label="复盘分类" data-okx-trade-mode="{{ okx_trade_mode|default('options') }}">
|
||||
{% if okx_trade_mode|default('options') == 'options' %}
|
||||
<button type="button" class="or-tab active" data-source="option_spot" role="tab">期权交易记录</button>
|
||||
<button type="button" class="or-tab" data-source="options_options" role="tab">期期对冲记录</button>
|
||||
<button type="button" class="or-tab" data-source="perp_options" role="tab">永期对冲记录</button>
|
||||
{% elif okx_trade_mode == 'options_options' %}
|
||||
<button type="button" class="or-tab active" data-source="options_options" role="tab">期期对冲记录</button>
|
||||
{% elif okx_trade_mode == 'perp_options' %}
|
||||
<button type="button" class="or-tab active" data-source="perp_options" role="tab">永期对冲记录</button>
|
||||
{% else %}
|
||||
<button type="button" class="or-tab active" data-source="option_spot" role="tab">期权交易记录</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="or-filters">
|
||||
<select id="or-filter-uly" autocomplete="off">
|
||||
@@ -208,9 +214,11 @@
|
||||
data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly>
|
||||
<input type="datetime-local" id="or-filter-from" title="平仓起" autocomplete="off">
|
||||
<input type="datetime-local" id="or-filter-to" title="平仓止" autocomplete="off">
|
||||
{% if okx_trade_mode|default('options') == 'options' %}
|
||||
<label class="muted">
|
||||
<input type="checkbox" id="or-include-hedge-legs"> 含已归属对冲的期权腿
|
||||
</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -408,4 +416,4 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="/static/options_review.js?v=22"></script>
|
||||
<script src="/static/options_review.js?v=24"></script>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
{# 期权设置脚本挂载点(卡片在 settings_panel 中拆分) #}
|
||||
<div id="options-settings-root" hidden
|
||||
data-sub-account="{{ instance_settings.options_sub_account | default('', true) }}"></div>
|
||||
<script src="/static/options_settings.js?v=9"></script>
|
||||
<div id="options-settings-root" hidden></div>
|
||||
<script src="/static/options_settings.js?v=10"></script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="options-settings-section">
|
||||
<p class="options-settings-hint">主账户资金账户:USDT ↔ USDC 现货市价单.</p>
|
||||
<p class="options-settings-hint">账户资金账户:USDT ↔ USDC 现货市价单.</p>
|
||||
<div class="form-row settings-transfer-form options-settings-row">
|
||||
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
|
||||
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="options-settings-section">
|
||||
<div class="options-settings-subtitle">主账户内</div>
|
||||
<div class="options-settings-subtitle">账户内划转</div>
|
||||
<div class="form-row settings-transfer-form options-settings-row">
|
||||
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
|
||||
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
|
||||
@@ -22,35 +22,3 @@
|
||||
</div>
|
||||
<div id="opt-set-int-msg" class="options-settings-msg muted"></div>
|
||||
</div>
|
||||
|
||||
<div class="options-settings-section">
|
||||
<div class="options-settings-subtitle">
|
||||
主子账户
|
||||
<span class="muted">({{ instance_settings.options_sub_account or '未配置' }})</span>
|
||||
</div>
|
||||
<div class="form-row settings-transfer-form options-settings-row">
|
||||
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
|
||||
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
|
||||
<select id="opt-set-cross-dir" aria-label="主子方向" autocomplete="off">
|
||||
<option value="main_to_sub" selected>主 → 子</option>
|
||||
<option value="sub_to_main">子 → 主</option>
|
||||
</select>
|
||||
<select id="opt-set-cross-ccy" aria-label="币种">
|
||||
<option value="USDT" selected>USDT</option>
|
||||
<option value="USDC">USDC</option>
|
||||
</select>
|
||||
<select id="opt-set-cross-from" aria-label="划出账户">
|
||||
<option value="funding" selected>from: 资金</option>
|
||||
<option value="trading">from: 交易</option>
|
||||
</select>
|
||||
<select id="opt-set-cross-to" aria-label="划入账户">
|
||||
<option value="trading" selected>to: 交易</option>
|
||||
<option value="funding">to: 资金</option>
|
||||
</select>
|
||||
<input type="number" id="opt-set-cross-amount" name="cm_opt_cross_amt" min="0.01" step="0.01" placeholder="数量"
|
||||
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
|
||||
<button type="button" class="btn-secondary btn-sm" id="opt-set-cross-all-btn">全部划转</button>
|
||||
<button type="button" class="btn-primary btn-sm" id="opt-set-cross-btn">划转</button>
|
||||
</div>
|
||||
<div id="opt-set-cross-msg" class="options-settings-msg muted"></div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user