Compare commits
109 Commits
4573ccca9a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 54c1984ec7 | |||
| be7896cc25 | |||
| 394793b9d2 | |||
| 0b8f410fbe | |||
| 687a34474d | |||
| 9d9d0af31e | |||
| bfa3352122 | |||
| 55261b7812 | |||
| 5797d49d8a | |||
| 4742a0bb9d | |||
| 32079bb4c2 | |||
| 3b687d17eb | |||
| 4f784d09ac | |||
| 052dcf63bd | |||
| ac4cdceb39 | |||
| 14dbf25798 | |||
| 6b872b1f43 | |||
| 865567fbd3 | |||
| a0d57fc65e | |||
| 5b3448b52b | |||
| e51d7824a7 | |||
| 9cb63c368a | |||
| be8e4ce6c6 | |||
| 02255a3b02 | |||
| 6352fa6be3 | |||
| 5a887de6f4 | |||
| 7d03e8e93e | |||
| d467760d5c | |||
| 4aebe70611 | |||
| ee011800e1 | |||
| 5cf88818c1 | |||
| 448e88ec55 | |||
| 0a20ee7eec | |||
| cfc703ae5b | |||
| 2dadd93d91 | |||
| 924a385d6c | |||
| 61d79c4de1 | |||
| 6ffae02d30 | |||
| 9d1986d771 | |||
| 322060de31 | |||
| 3e8ecbf712 | |||
| 384d404bb3 | |||
| bced61b9d7 | |||
| 4ad335ca84 | |||
| 157d9ada21 | |||
| 813ebf0e4e | |||
| b18b2143b5 | |||
| f63f8810e6 | |||
| 7f8ae97a98 | |||
| e3559531d9 | |||
| 016c93faf2 | |||
| e03863d780 | |||
| 54ba412d1d | |||
| 65901c5577 | |||
| acc158f85d | |||
| ea5c6cddb4 | |||
| 0dedaa2b4d | |||
| bfbd6879d6 | |||
| d3d366d0ee | |||
| faa41eece1 | |||
| f4d7dec111 | |||
| b0ec291345 | |||
| f78ea1288e | |||
| 5e507d0b66 | |||
| d938bc6c59 | |||
| 253d353206 | |||
| 1ba0014fff | |||
| caf4996159 | |||
| 89909c64a3 | |||
| 21f86906da | |||
| c302c3e4ea | |||
| 8e810154ca | |||
| ed3709dddf | |||
| a837cfd14c | |||
| 091317276d | |||
| bd759c42d6 | |||
| c0f3606ecc | |||
| c05afbbedf | |||
| 073a382d41 | |||
| ce172a7cee | |||
| 9330e356fc | |||
| deb240d4eb | |||
| c73944581c | |||
| 9c778e0232 | |||
| ff8caf7f8d | |||
| f8e760961e | |||
| 97370926d6 | |||
| 0280b4f065 | |||
| f0a158686e | |||
| e470c5952f | |||
| 3d29b4f9d9 | |||
| d8dccb8606 | |||
| 6520234bd8 | |||
| be7f5d5072 | |||
| b6acbf4b2c | |||
| 850ffcd7d2 | |||
| e307eef690 | |||
| b77741ee21 | |||
| ca1e25888d | |||
| 6287ca9129 | |||
| 7fe7c2e918 | |||
| c1ee0dae25 | |||
| 58e940629a | |||
| f9257b64e4 | |||
| 869728ce10 | |||
| ad1c08a2cc | |||
| 467d160f4d | |||
| 28a23008f3 | |||
| 42c06c0f38 |
@@ -16,6 +16,9 @@
|
|||||||
**/.env.bak
|
**/.env.bak
|
||||||
**/.env.local
|
**/.env.local
|
||||||
manual_trading_hub/hub_settings.json
|
manual_trading_hub/hub_settings.json
|
||||||
|
manual_trading_hub/hub_backup_state.json
|
||||||
|
manual_trading_hub/hub_fund_history.json
|
||||||
|
manual_trading_hub/hub_supervisor_state.json
|
||||||
manual_trading_hub/hub_ai_summaries.json
|
manual_trading_hub/hub_ai_summaries.json
|
||||||
manual_trading_hub/hub_ai_chat.json
|
manual_trading_hub/hub_ai_chat.json
|
||||||
manual_trading_hub/hub_ai_fund_history.json
|
manual_trading_hub/hub_ai_fund_history.json
|
||||||
|
|||||||
@@ -53,8 +53,11 @@ bash deploy/setup_env.sh --install-system-deps
|
|||||||
| `crypto_monitor_gate_bot/` | Gate 机器人 / 趋势户 | [部署文档.md](./crypto_monitor_gate_bot/部署文档.md) |
|
| `crypto_monitor_gate_bot/` | Gate 机器人 / 趋势户 | [部署文档.md](./crypto_monitor_gate_bot/部署文档.md) |
|
||||||
| `crypto_monitor_okx/` | OKX 永续 | [部署文档.md](./crypto_monitor_okx/部署文档.md) |
|
| `crypto_monitor_okx/` | OKX 永续 | [部署文档.md](./crypto_monitor_okx/部署文档.md) |
|
||||||
| `manual_trading_hub/` | 中控 + 子代理 | [部署文档.md](./manual_trading_hub/部署文档.md) |
|
| `manual_trading_hub/` | 中控 + 子代理 | [部署文档.md](./manual_trading_hub/部署文档.md) |
|
||||||
| 根目录 `strategy_*.py` | 策略共用库 | [策略交易说明.md](./策略交易说明.md) |
|
| `lib/` | **共用模块**(策略、关键位、交易、中控库、AI、静态与模板) | **[docs/lib-structure.md](./docs/lib-structure.md)** |
|
||||||
| 根目录 `key_*_lib.py` | 关键位 / 止盈止损共用库 | [关键位止盈止损与移动保本更新说明.md](./关键位止盈止损与移动保本更新说明.md) |
|
| `brand/` | 各所共用图标与 manifest | — |
|
||||||
|
| `docs/`、`deploy/`、`scripts/`、`tests/` | 文档、环境、脚本、单元测试 | — |
|
||||||
|
|
||||||
|
共用代码 import 示例:`from lib.strategy.strategy_db import init_strategy_tables`(各所启动时仍将仓库根加入 `PYTHONPATH`)。详见 **[docs/lib-structure.md](./docs/lib-structure.md)**。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ APP_PORT=5001
|
|||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
|
|
||||||
# 登录账号
|
# 登录账号
|
||||||
APP_USERNAME=dekun
|
APP_USERNAME=admin
|
||||||
# 登录密码(请改成你自己的强密码)
|
# 登录密码(请改成你自己的强密码)
|
||||||
APP_PASSWORD=ChangeMe123!
|
APP_PASSWORD=admin123
|
||||||
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
||||||
APP_AUTH_DISABLED=true
|
APP_AUTH_DISABLED=true
|
||||||
# --- 多账户交易中控 manual_trading_hub ---
|
# --- 多账户交易中控 manual_trading_hub ---
|
||||||
@@ -127,6 +127,16 @@ DAILY_OPEN_ALERT_THRESHOLD=5
|
|||||||
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
|
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
|
||||||
DAILY_OPEN_HARD_LIMIT=0
|
DAILY_OPEN_HARD_LIMIT=0
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 账户冷静期 / 日冻结风控(手动平仓、外部平仓、复盘情绪标签)
|
||||||
|
# 详见 docs/account-risk-cooldown.md
|
||||||
|
# =============================================================================
|
||||||
|
# RISK_CONTROL_ENABLED=true
|
||||||
|
# RISK_COOLING_HOURS_MANUAL=4
|
||||||
|
# RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
||||||
|
# RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
||||||
|
# RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
||||||
|
|
||||||
# 资金与仓位刷新周期(秒)
|
# 资金与仓位刷新周期(秒)
|
||||||
BALANCE_REFRESH_SECONDS=60
|
BALANCE_REFRESH_SECONDS=60
|
||||||
# 前端价格快照轮询(秒)
|
# 前端价格快照轮询(秒)
|
||||||
|
|||||||
+819
-178
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<script src="/static/instance_theme.js?v=6"></script>
|
<script src="/static/instance_theme.js?v=46"></script>
|
||||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=1">
|
<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>
|
||||||
|
|
||||||
<meta name="theme-color" content="#0b0d14">
|
<meta name="theme-color" content="#0b0d14">
|
||||||
<meta name="apple-mobile-web-app-title" content="监控">
|
<meta name="apple-mobile-web-app-title" content="监控">
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
|
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
|
||||||
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
|
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
|
||||||
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
|
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
|
||||||
|
.header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center}
|
||||||
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
|
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
|
||||||
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
|
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
|
||||||
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
|
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
|
||||||
@@ -34,6 +37,12 @@
|
|||||||
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
|
.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}
|
.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}
|
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
|
||||||
|
.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}
|
||||||
|
.order-preview-risk{color:#ff6b6b}.order-preview-risk strong{color:#ff8f8f;font-weight:600}
|
||||||
|
.order-preview-profit{color:#4cd97f}.order-preview-profit strong{color:#6ee7a0;font-weight:600}
|
||||||
|
.order-preview-rr{color:#cfd3ef}.order-preview-rr strong{font-weight:600;color:#dbe4ff}
|
||||||
|
.order-preview-rr.order-preview-rr-low strong{color:#ff8f8f}
|
||||||
|
.order-preview-rr.order-preview-rr-ok strong{color:#8fc8ff}
|
||||||
.form-row > button,.form-row > label{flex:0 0 auto}
|
.form-row > button,.form-row > label{flex:0 0 auto}
|
||||||
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
||||||
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
|
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
|
||||||
@@ -234,10 +243,17 @@
|
|||||||
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
|
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
|
||||||
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=14">
|
<link rel="stylesheet" href="/static/instance_theme.css?v=48">
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body data-page="{{ page }}">
|
<body
|
||||||
|
data-page="{{ page }}"
|
||||||
|
data-risk-percent="{{ risk_percent }}"
|
||||||
|
data-position-sizing-mode="{{ position_sizing_mode }}"
|
||||||
|
data-btc-leverage="{{ btc_leverage }}"
|
||||||
|
data-alt-leverage="{{ alt_leverage }}"
|
||||||
|
data-full-margin-buffer="{{ full_margin_buffer_ratio }}"
|
||||||
|
>
|
||||||
{% macro period_stats(title, s) %}
|
{% macro period_stats(title, s) %}
|
||||||
<div class="stats-period-block">
|
<div class="stats-period-block">
|
||||||
<h3>{{ title }}</h3>
|
<h3>{{ title }}</h3>
|
||||||
@@ -262,6 +278,7 @@
|
|||||||
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div class="exchange-tag">{{ exchange_display }}</div>
|
<div class="exchange-tag">{{ exchange_display }}</div>
|
||||||
|
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}" data-status-label="{{ risk_status.status_label|default('正常') }}"{% if risk_status.freeze_until_ms %} data-freeze-until-ms="{{ risk_status.freeze_until_ms }}"{% endif %}>{{ risk_status.status_label|default('正常') }}</span>
|
||||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||||
@@ -356,7 +373,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<button type="submit">手动划转</button>
|
<button type="submit">手动划转</button>
|
||||||
</form>
|
</form>
|
||||||
<form id="add-order-form" action="/add_order" method="post" class="form-row">
|
<form id="add-order-form" action="/add_order" method="post" class="form-row" data-risk-percent="{{ risk_percent }}">
|
||||||
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
||||||
<select id="order-direction" name="direction" required>
|
<select id="order-direction" name="direction" required>
|
||||||
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
@@ -398,9 +415,23 @@
|
|||||||
<input id="order-tp-pct" name="tp_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>
|
<button type="submit">{{ open_position_button_label }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% include 'order_plan_preview_bar.html' %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="margin-bottom:8px">实时持仓</h2>
|
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||||
|
{% if not order and orphan_live_positions %}
|
||||||
|
{% set o = orphan_live_positions[0] %}
|
||||||
|
<div id="orphan-position-recover" class="orphan-recover-banner" style="display:block;margin-bottom:10px;padding:10px 12px;background:#2a2210;border:1px solid #6b5420;border-radius:6px;font-size:.9rem;color:#e8d5a8">
|
||||||
|
检测到交易所仍有 <strong>{{ o.symbol }}</strong> {{ '空' if o.direction == 'short' else '多' }}仓,但本地监控已中断(误同步时可能无交易记录)。
|
||||||
|
{% if o.recoverable_monitor_id %}
|
||||||
|
<button type="button" class="pos-entrust-btn" onclick="recoverLivePosition({{ o.recoverable_monitor_id }})">恢复监控{% if o.plan_stop_loss and o.plan_take_profit %}并挂止盈止损{% endif %}</button>
|
||||||
|
{% else %}
|
||||||
|
未找到可恢复的监控记录,需在服务器数据库处理。
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div id="orphan-position-recover" class="orphan-recover-banner" style="display:none;margin-bottom:10px;padding:10px 12px;background:#2a2210;border:1px solid #6b5420;border-radius:6px;font-size:.9rem;color:#e8d5a8"></div>
|
||||||
|
{% endif %}
|
||||||
<div class="panel-scroll pos-list pos-list-live">
|
<div class="panel-scroll pos-list pos-list-live">
|
||||||
{% for o in order %}
|
{% for o in order %}
|
||||||
<div class="pos-card" id="order-row-{{ o.id }}"
|
<div class="pos-card" id="order-row-{{ o.id }}"
|
||||||
@@ -431,6 +462,7 @@
|
|||||||
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
|
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
|
||||||
<span class="pos-meta-item">风格: {{ o.trade_style or 'trend' }}</span>
|
<span class="pos-meta-item">风格: {{ o.trade_style or 'trend' }}</span>
|
||||||
<span class="pos-meta-item">风险: {% if position_sizing_mode == 'full_margin' %}{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% else %}{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% endif %}</span>
|
<span class="pos-meta-item">风险: {% if position_sizing_mode == 'full_margin' %}{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% else %}{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% endif %}</span>
|
||||||
|
<span class="pos-meta-item" id="order-latest-risk-wrap-{{ o.id }}" style="display:none">最新风险: —</span>
|
||||||
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}">
|
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}">
|
||||||
{% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
|
{% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
|
||||||
</span>
|
</span>
|
||||||
@@ -453,6 +485,10 @@
|
|||||||
<span class="pos-label">盈亏比</span>
|
<span class="pos-label">盈亏比</span>
|
||||||
<span class="pos-value" id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %}</span>
|
<span class="pos-value" id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pos-cell">
|
||||||
|
<span class="pos-label">张数</span>
|
||||||
|
<span class="pos-value" id="order-contracts-{{ o.id }}">{% if o.order_amount is not none %}{{ '%g'|format(o.order_amount) }}{% else %}—{% endif %}</span>
|
||||||
|
</div>
|
||||||
<div class="pos-cell">
|
<div class="pos-cell">
|
||||||
<span class="pos-label">标记价</span>
|
<span class="pos-label">标记价</span>
|
||||||
<span class="pos-value" id="order-price-{{ o.id }}">-</span>
|
<span class="pos-value" id="order-price-{{ o.id }}">-</span>
|
||||||
@@ -467,6 +503,8 @@
|
|||||||
<span>计划基数: {{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
|
<span>计划基数: {{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
|
||||||
<span>杠杆: {{ o.leverage or '-' }}x</span>
|
<span>杠杆: {{ o.leverage or '-' }}x</span>
|
||||||
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
|
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
|
||||||
|
<span>开仓时间: {{ (o.opened_at or '-')[:16] }}</span>
|
||||||
|
<span>持仓时长: <span class="order-hold-duration" id="order-hold-duration-{{ o.id }}" data-order-opened-ms="{{ o.opened_at_ms or '' }}">—</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pos-ex-orders">
|
<div class="pos-ex-orders">
|
||||||
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
||||||
@@ -609,8 +647,7 @@
|
|||||||
<select name="type" required>
|
<select name="type" required>
|
||||||
<option value="箱体突破">箱体突破</option>
|
<option value="箱体突破">箱体突破</option>
|
||||||
<option value="收敛突破">收敛突破</option>
|
<option value="收敛突破">收敛突破</option>
|
||||||
<option value="关键阻力位">关键阻力位</option>
|
<option value="关键支撑阻力">关键支撑阻力</option>
|
||||||
<option value="关键支撑位">关键支撑位</option>
|
|
||||||
</select>
|
</select>
|
||||||
<select name="direction" required>
|
<select name="direction" required>
|
||||||
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
@@ -803,10 +840,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/instance_ui.js?v=1"></script>
|
<script src="/static/instance_ui.js?v=4"></script>
|
||||||
|
<script src="/static/instance_records_mobile.js?v=2"></script>
|
||||||
<script src="/static/time_close_ui.js?v=2"></script>
|
<script src="/static/time_close_ui.js?v=2"></script>
|
||||||
<script src="/static/ai_review_render.js?v=2"></script>
|
<script src="/static/ai_review_render.js?v=2"></script>
|
||||||
<script src="/static/form_submit_guard.js?v=2"></script>
|
<script src="/static/form_submit_guard.js?v=2"></script>
|
||||||
|
<script src="/static/manual_order_rr_preview.js?v=5"></script>
|
||||||
|
<script src="/static/strategy_roll.js?v=6"></script>
|
||||||
<script>
|
<script>
|
||||||
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
|
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
|
||||||
const JOURNAL_ENTRY_REASON_OTHER = {{ entry_reason_other_value | tojson }};
|
const JOURNAL_ENTRY_REASON_OTHER = {{ entry_reason_other_value | tojson }};
|
||||||
@@ -887,8 +927,10 @@ function setDetailBodyPlain(text){
|
|||||||
body.innerText = text || "";
|
body.innerText = text || "";
|
||||||
}
|
}
|
||||||
function setDetailBodyMarkdown(text){
|
function setDetailBodyMarkdown(text){
|
||||||
|
if(window.InstanceUI && InstanceUI.clearDetailActions) InstanceUI.clearDetailActions();
|
||||||
const body = document.getElementById("detailBody");
|
const body = document.getElementById("detailBody");
|
||||||
if(!body) return;
|
if(!body) return;
|
||||||
|
body.classList.remove("trade-record-detail-wrap", "journal-detail-meta");
|
||||||
if(window.AiReviewRender && AiReviewRender.setElementMarkdown){
|
if(window.AiReviewRender && AiReviewRender.setElementMarkdown){
|
||||||
body.classList.add("md-review");
|
body.classList.add("md-review");
|
||||||
AiReviewRender.setElementMarkdown(body, text || "");
|
AiReviewRender.setElementMarkdown(body, text || "");
|
||||||
@@ -1090,22 +1132,12 @@ function loadJournals(){
|
|||||||
const qs = listWindowQueryString();
|
const qs = listWindowQueryString();
|
||||||
fetch("/api/journals" + (qs ? "?" + qs : "")).then(r=>r.json()).then(data=>{
|
fetch("/api/journals" + (qs ? "?" + qs : "")).then(r=>r.json()).then(data=>{
|
||||||
Object.keys(journalCache).forEach(k=>delete journalCache[k]);
|
Object.keys(journalCache).forEach(k=>delete journalCache[k]);
|
||||||
let html="";
|
data.forEach(o=>{ journalCache[o.id] = o; });
|
||||||
data.forEach(o=>{
|
|
||||||
journalCache[o.id] = o;
|
|
||||||
const moodTags = (o.mood_issues || []).join(",") || "无";
|
|
||||||
html += `<div class="entry">
|
|
||||||
<div><strong>${o.coin||"-"} ${o.tf||"-"}</strong> | 盈亏:${o.pnl||"-"}U</div>
|
|
||||||
<div>开:${o.open_datetime||"-"} 平:${o.close_datetime||"-"} 持仓:${o.hold_duration||"-"}</div>
|
|
||||||
<div>心态标签:${moodTags}</div>
|
|
||||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:6px">
|
|
||||||
<button type="button" class="btn-del" style="border:none;cursor:pointer;background:#1f3a5a;color:#8fc8ff" onclick="openJournalDetail('${o.id}')">查看详情</button>
|
|
||||||
<button type="button" class="btn-del" onclick="deleteJournal('${o.id}')">删除</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
const box = document.getElementById("journal-list");
|
const box = document.getElementById("journal-list");
|
||||||
if(box){ box.innerHTML = html || "<div class='entry'>暂无数据</div>"; }
|
if(box){
|
||||||
|
const html = InstanceUI.renderJournalListHtml(data);
|
||||||
|
box.innerHTML = html || "<div class='journal-empty-msg'>暂无数据</div>";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1529,120 +1561,9 @@ if(journalForm){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncKeyMonitorFormFields(){
|
|
||||||
const typeEl = document.querySelector('#key-form [name="type"]');
|
|
||||||
const dirEl = document.getElementById("key-direction");
|
|
||||||
const modeEl = document.getElementById("key-sl-tp-mode");
|
|
||||||
const manualTp = document.getElementById("key-manual-tp");
|
|
||||||
const beWrap = document.getElementById("key-breakeven-wrap");
|
|
||||||
if(!typeEl) return;
|
|
||||||
const t = (typeEl.value || "").trim();
|
|
||||||
const autoTypes = new Set(["箱体突破","收敛突破"]);
|
|
||||||
const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]);
|
|
||||||
const fbTypes = new Set(["假突破"]);
|
|
||||||
const teTypes = new Set(["触价开仓"]);
|
|
||||||
const rsTypes = new Set(["关键阻力位","关键支撑位"]);
|
|
||||||
const showAuto = autoTypes.has(t);
|
|
||||||
const showFb = fbTypes.has(t);
|
|
||||||
const showTe = teTypes.has(t);
|
|
||||||
const showBe = showAuto || fibTypes.has(t) || showFb || showTe;
|
|
||||||
const showDir = !rsTypes.has(t);
|
|
||||||
const upperEl = document.getElementById("key-upper");
|
|
||||||
const lowerEl = document.getElementById("key-lower");
|
|
||||||
const fbPriceEl = document.getElementById("key-fb-price");
|
|
||||||
const teEntryEl = document.getElementById("key-trigger-entry");
|
|
||||||
const teSlEl = document.getElementById("key-trigger-sl");
|
|
||||||
const teTpEl = document.getElementById("key-trigger-tp");
|
|
||||||
if(dirEl){
|
|
||||||
dirEl.style.display = showDir ? "" : "none";
|
|
||||||
dirEl.required = showDir;
|
|
||||||
if(!showDir) dirEl.value = "";
|
|
||||||
}
|
|
||||||
if(modeEl) modeEl.style.display = showAuto ? "" : "none";
|
|
||||||
if(manualTp){
|
|
||||||
const trend = showAuto && modeEl && modeEl.value === "trend_manual";
|
|
||||||
manualTp.style.display = trend ? "" : "none";
|
|
||||||
manualTp.required = !!trend;
|
|
||||||
}
|
|
||||||
if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
|
|
||||||
if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
|
|
||||||
const hideBounds = showFb || showTe;
|
|
||||||
if(upperEl){
|
|
||||||
upperEl.style.display = hideBounds ? "none" : "";
|
|
||||||
upperEl.required = !hideBounds;
|
|
||||||
if(hideBounds) upperEl.value = "";
|
|
||||||
}
|
|
||||||
if(lowerEl){
|
|
||||||
lowerEl.style.display = hideBounds ? "none" : "";
|
|
||||||
lowerEl.required = !hideBounds;
|
|
||||||
if(hideBounds) lowerEl.value = "";
|
|
||||||
}
|
|
||||||
if(fbPriceEl){
|
|
||||||
fbPriceEl.style.display = showFb ? "" : "none";
|
|
||||||
fbPriceEl.required = showFb;
|
|
||||||
if(!showFb) fbPriceEl.value = "";
|
|
||||||
fbPriceEl.placeholder = (dirEl && dirEl.value === "short") ? "高点(阻力)" : ((dirEl && dirEl.value === "long") ? "低点(支撑)" : "做空填高点/做多填低点");
|
|
||||||
}
|
|
||||||
[teEntryEl, teSlEl, teTpEl].forEach((el)=>{
|
|
||||||
if(!el) return;
|
|
||||||
el.style.display = showTe ? "" : "none";
|
|
||||||
el.required = showTe;
|
|
||||||
if(!showTe) el.value = "";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const keyTypeSel = document.querySelector('#key-form [name="type"]');
|
|
||||||
const keyModeSel = document.getElementById("key-sl-tp-mode");
|
|
||||||
const keyDirSel = document.getElementById("key-direction");
|
|
||||||
if(keyTypeSel) keyTypeSel.addEventListener("change", syncKeyMonitorFormFields);
|
|
||||||
if(keyModeSel) keyModeSel.addEventListener("change", syncKeyMonitorFormFields);
|
|
||||||
if(keyDirSel) keyDirSel.addEventListener("change", syncKeyMonitorFormFields);
|
|
||||||
syncKeyMonitorFormFields();
|
|
||||||
if(window.TimeCloseUI){
|
if(window.TimeCloseUI){
|
||||||
TimeCloseUI.bindTimeCloseForm("key-time-close-cb", "key-time-close-hours", "key-time-close-wrap");
|
|
||||||
TimeCloseUI.bindTimeCloseForm("order-time-close-cb", "order-time-close-hours", "order-time-close-wrap");
|
TimeCloseUI.bindTimeCloseForm("order-time-close-cb", "order-time-close-hours", "order-time-close-wrap");
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyForm = document.getElementById("key-form");
|
|
||||||
if(keyForm){
|
|
||||||
keyForm.addEventListener("submit", (e)=>{
|
|
||||||
e.preventDefault();
|
|
||||||
if(window.FormSubmitGuard && FormSubmitGuard.isLocked(keyForm)) return;
|
|
||||||
const symbolEl = keyForm.querySelector('[name="symbol"]');
|
|
||||||
const symbol = (symbolEl ? symbolEl.value : "").trim();
|
|
||||||
if(!symbol){
|
|
||||||
alert("请先输入交易对");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const typeVal = (keyForm.querySelector('[name="type"]') || {}).value || "";
|
|
||||||
if(typeVal === "假突破"){
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
|
|
||||||
else keyForm.submit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.lock(keyForm, "校验排名中…");
|
|
||||||
fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`)
|
|
||||||
.then(r=>r.json().then(d=>({status:r.status, data:d})))
|
|
||||||
.then(({status,data})=>{
|
|
||||||
if(status >= 400 || !data.ok){
|
|
||||||
alert((data && data.msg) || "日成交量排名读取失败");
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rankMax = data.rank_max || 30;
|
|
||||||
if(!data.in_top30){
|
|
||||||
alert(`${data.symbol} 当前日成交量排名 ${data.rank}/${data.total},不在前${rankMax},已拦截。`);
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
|
|
||||||
else keyForm.submit();
|
|
||||||
})
|
|
||||||
.catch(()=>{
|
|
||||||
alert("日成交量排名检查失败,请稍后重试");
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
|
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if(document.getElementById("journal-list")) loadJournals();
|
if(document.getElementById("journal-list")) loadJournals();
|
||||||
@@ -1693,6 +1614,7 @@ function refreshOrderTpPreview(entryPx){
|
|||||||
const entry = entryPx != null && Number.isFinite(Number(entryPx)) ? Number(entryPx) : sl;
|
const entry = entryPx != null && Number.isFinite(Number(entryPx)) ? Number(entryPx) : sl;
|
||||||
const tp = calcTpFromFixedRr(direction, entry, sl, rr);
|
const tp = calcTpFromFixedRr(direction, entry, sl, rr);
|
||||||
preview.textContent = tp == null ? "预估止盈:—" : ("预估止盈:" + formatPriceForInput(tp));
|
preview.textContent = tp == null ? "预估止盈:—" : ("预估止盈:" + formatPriceForInput(tp));
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}
|
}
|
||||||
function calcClientRr(direction, entry, sl, tp){
|
function calcClientRr(direction, entry, sl, tp){
|
||||||
const e = Number(entry), s = Number(sl), t = Number(tp);
|
const e = Number(entry), s = Number(sl), t = Number(tp);
|
||||||
@@ -1779,6 +1701,13 @@ function submitTpslEntrust(){
|
|||||||
alert(data.msg || '已提交');
|
alert(data.msg || '已提交');
|
||||||
closeTpslEntrustModal();
|
closeTpslEntrustModal();
|
||||||
if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl);
|
if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl);
|
||||||
|
paintPlanTpslDisplay(orderId, data);
|
||||||
|
paintLatestRiskDisplay(orderId, data);
|
||||||
|
const rrEl = document.getElementById(`order-rr-${orderId}`);
|
||||||
|
if(rrEl){
|
||||||
|
const rr = data.display_rr_ratio != null && data.display_rr_ratio !== "" ? data.display_rr_ratio : data.planned_rr;
|
||||||
|
rrEl.innerText = formatRrRatio(rr);
|
||||||
|
}
|
||||||
refreshPriceSnapshotConditional();
|
refreshPriceSnapshotConditional();
|
||||||
}).catch(()=>alert('委托请求失败'));
|
}).catch(()=>alert('委托请求失败'));
|
||||||
}
|
}
|
||||||
@@ -1846,6 +1775,25 @@ function paintPlanTpslDisplay(orderId, snap){
|
|||||||
else if(tpDisp) card.setAttribute("data-plan-tp", tpDisp);
|
else if(tpDisp) card.setAttribute("data-plan-tp", tpDisp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function paintLatestRiskDisplay(orderId, snap){
|
||||||
|
const wrap = document.getElementById(`order-latest-risk-wrap-${orderId}`);
|
||||||
|
if(!wrap) return;
|
||||||
|
const v = snap && snap.latest_risk_amount;
|
||||||
|
const n = v != null && v !== "" ? Number(v) : NaN;
|
||||||
|
if(Number.isFinite(n)){
|
||||||
|
wrap.style.display = "inline-flex";
|
||||||
|
wrap.textContent = `最新风险: ${n.toFixed(2)}U`;
|
||||||
|
} else {
|
||||||
|
wrap.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function paintContractsDisplay(orderId, snap){
|
||||||
|
const el = document.getElementById(`order-contracts-${orderId}`);
|
||||||
|
if(!el || !snap) return;
|
||||||
|
const v = snap.contracts != null && snap.contracts !== "" ? snap.contracts : snap.order_amount;
|
||||||
|
const n = v != null && v !== "" ? Number(v) : NaN;
|
||||||
|
el.innerText = Number.isFinite(n) ? String(parseFloat(n.toFixed(4))) : "—";
|
||||||
|
}
|
||||||
|
|
||||||
function paintPriceTrend(el, key, value){
|
function paintPriceTrend(el, key, value){
|
||||||
if(!el) return;
|
if(!el) return;
|
||||||
@@ -1861,6 +1809,46 @@ function paintPriceTrend(el, key, value){
|
|||||||
lastPriceMap[key] = value;
|
lastPriceMap[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderOrphanRecoverBanner(orphans){
|
||||||
|
const el = document.getElementById("orphan-position-recover");
|
||||||
|
if(!el) return;
|
||||||
|
const liveCards = document.querySelectorAll(".pos-list-live .pos-card");
|
||||||
|
if(liveCards.length > 0 || !orphans || !orphans.length){
|
||||||
|
el.style.display = "none";
|
||||||
|
el.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const o = orphans[0];
|
||||||
|
const dir = o.direction === "short" ? "空" : "多";
|
||||||
|
const mid = o.recoverable_monitor_id;
|
||||||
|
let html = `检测到交易所仍有 <strong>${o.symbol}</strong> ${dir}仓,但本地监控已中断(误同步时可能无交易记录)。`;
|
||||||
|
if(mid){
|
||||||
|
const tpslHint = (o.plan_stop_loss && o.plan_take_profit) ? "并挂止盈止损" : "";
|
||||||
|
html += ` <button type="button" class="pos-entrust-btn" onclick="recoverLivePosition(${mid})">恢复监控${tpslHint}</button>`;
|
||||||
|
} else {
|
||||||
|
html += " 未找到可恢复的监控记录,需在服务器数据库处理。";
|
||||||
|
}
|
||||||
|
el.innerHTML = html;
|
||||||
|
el.style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recoverLivePosition(monitorId){
|
||||||
|
const withTpsl = confirm("确认恢复本地实时监控?若原计划有止盈止损,将尝试重新挂到交易所。");
|
||||||
|
if(!withTpsl) return;
|
||||||
|
try{
|
||||||
|
const res = await fetch("/api/recover_live_position", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify({monitor_id: monitorId, place_tpsl: true})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
alert(data.msg || (data.ok ? "已恢复" : "失败"));
|
||||||
|
if(data.ok) location.reload();
|
||||||
|
}catch(e){
|
||||||
|
alert("恢复失败:" + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function refreshPriceSnapshot(){
|
function refreshPriceSnapshot(){
|
||||||
fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{
|
fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{
|
||||||
const updatedEl = document.getElementById("price-last-updated");
|
const updatedEl = document.getElementById("price-last-updated");
|
||||||
@@ -1929,13 +1917,17 @@ function refreshPriceSnapshot(){
|
|||||||
}
|
}
|
||||||
const rrEl = document.getElementById(`order-rr-${o.id}`);
|
const rrEl = document.getElementById(`order-rr-${o.id}`);
|
||||||
if(rrEl){
|
if(rrEl){
|
||||||
rrEl.innerText = formatRrRatio(o.rr_ratio);
|
const rr = o.display_rr_ratio != null && o.display_rr_ratio !== "" ? o.display_rr_ratio : o.rr_ratio;
|
||||||
|
rrEl.innerText = formatRrRatio(rr);
|
||||||
}
|
}
|
||||||
|
paintLatestRiskDisplay(o.id, o);
|
||||||
|
paintContractsDisplay(o.id, o);
|
||||||
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
|
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
|
||||||
if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl);
|
if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl);
|
||||||
paintPlanTpslDisplay(o.id, o);
|
paintPlanTpslDisplay(o.id, o);
|
||||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
||||||
});
|
});
|
||||||
|
renderOrphanRecoverBanner(data.orphan_live_positions);
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1965,6 +1957,7 @@ function refreshOrderDefaults(){
|
|||||||
}
|
}
|
||||||
const px = data.last_price || data.price;
|
const px = data.last_price || data.price;
|
||||||
if(px) refreshOrderTpPreview(px);
|
if(px) refreshOrderTpPreview(px);
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1981,9 +1974,25 @@ function refreshAccountSnapshot(){
|
|||||||
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
||||||
latestAvailableUsdt = Number(data.available_trading_usdt);
|
latestAvailableUsdt = Number(data.available_trading_usdt);
|
||||||
}
|
}
|
||||||
|
if (data.risk_status) {
|
||||||
|
const badge = document.getElementById("account-risk-badge");
|
||||||
|
if (badge) {
|
||||||
|
if (window.AccountRiskBadge) {
|
||||||
|
AccountRiskBadge.applyToElement(badge, data.risk_status);
|
||||||
|
} else {
|
||||||
|
const st = data.risk_status.status || "normal";
|
||||||
|
badge.className = "risk-status-badge risk-status-" + st;
|
||||||
|
badge.innerText = data.risk_status.status_label || "正常";
|
||||||
|
badge.title = data.risk_status.reason || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let canTradeText = "可开仓";
|
let canTradeText = "可开仓";
|
||||||
if (!data.can_trade) {
|
if (!data.can_trade) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
if (data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason) {
|
||||||
|
parts.push(data.risk_status.reason);
|
||||||
|
}
|
||||||
const ac = Number(data.active_count || 0);
|
const ac = Number(data.active_count || 0);
|
||||||
const max = Number(data.max_active_positions || {{ max_active_positions }});
|
const max = Number(data.max_active_positions || {{ max_active_positions }});
|
||||||
if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
|
if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
|
||||||
@@ -2044,12 +2053,16 @@ function toggleSltpMode(){
|
|||||||
slPctEl.required = pct;
|
slPctEl.required = pct;
|
||||||
tpPctEl.required = pct;
|
tpPctEl.required = pct;
|
||||||
refreshOrderTpPreview();
|
refreshOrderTpPreview();
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}
|
}
|
||||||
if(sltpModeEl){
|
if(sltpModeEl){
|
||||||
sltpModeEl.addEventListener("change", toggleSltpMode);
|
sltpModeEl.addEventListener("change", toggleSltpMode);
|
||||||
loadFixedRrPref();
|
loadFixedRrPref();
|
||||||
toggleSltpMode();
|
toggleSltpMode();
|
||||||
}
|
}
|
||||||
|
if(window.ManualOrderRrPreview){
|
||||||
|
ManualOrderRrPreview.wire({ minRr: MANUAL_MIN_PLANNED_RR });
|
||||||
|
}
|
||||||
["order-sl","order-fixed-rr","order-direction"].forEach(function(id){
|
["order-sl","order-fixed-rr","order-direction"].forEach(function(id){
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if(el) el.addEventListener("input", function(){ refreshOrderTpPreview(); });
|
if(el) el.addEventListener("input", function(){ refreshOrderTpPreview(); });
|
||||||
@@ -2057,6 +2070,7 @@ if(sltpModeEl){
|
|||||||
});
|
});
|
||||||
|
|
||||||
refreshAccountSnapshot();
|
refreshAccountSnapshot();
|
||||||
|
if (window.AccountRiskBadge) AccountRiskBadge.startTicker();
|
||||||
const _journalFormEl = document.getElementById("journal-form");
|
const _journalFormEl = document.getElementById("journal-form");
|
||||||
if(_journalFormEl){
|
if(_journalFormEl){
|
||||||
_journalFormEl.addEventListener("submit", function(ev){
|
_journalFormEl.addEventListener("submit", function(ev){
|
||||||
@@ -2194,10 +2208,42 @@ function refreshPriceSnapshotConditional(){
|
|||||||
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
|
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
|
||||||
paintPlanTpslDisplay(o.id, o);
|
paintPlanTpslDisplay(o.id, o);
|
||||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
||||||
|
const holdEl = document.getElementById(`order-hold-duration-${o.id}`);
|
||||||
|
if(holdEl && o.opened_at_ms != null && o.opened_at_ms !== ""){
|
||||||
|
holdEl.setAttribute("data-order-opened-ms", String(o.opened_at_ms));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
tickOrderHoldDurations();
|
||||||
|
renderOrphanRecoverBanner(data.orphan_live_positions);
|
||||||
}
|
}
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
function formatLiveHoldDurationFromMs(openedMs, nowMs){
|
||||||
|
if(openedMs == null || openedMs === "" || !Number.isFinite(Number(openedMs))) return "—";
|
||||||
|
const ms = Number(openedMs);
|
||||||
|
const now = (nowMs != null) ? nowMs : Date.now();
|
||||||
|
let sec = Math.floor((now - ms) / 1000);
|
||||||
|
if(sec < 0) sec = 0;
|
||||||
|
if(sec <= 0) return "0分钟";
|
||||||
|
const d = Math.floor(sec / 86400); sec %= 86400;
|
||||||
|
const h = Math.floor(sec / 3600); sec %= 3600;
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const parts = [];
|
||||||
|
if(d) parts.push(`${d}天`);
|
||||||
|
if(h) parts.push(`${h}小时`);
|
||||||
|
if(m || !parts.length) parts.push(`${m}分钟`);
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
function tickOrderHoldDurations(){
|
||||||
|
const now = Date.now();
|
||||||
|
document.querySelectorAll(".order-hold-duration[data-order-opened-ms]").forEach(el=>{
|
||||||
|
const ms = Number(el.getAttribute("data-order-opened-ms"));
|
||||||
|
if(!Number.isFinite(ms) || ms <= 0) return;
|
||||||
|
el.textContent = formatLiveHoldDurationFromMs(ms, now);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setInterval(tickOrderHoldDurations, 1000);
|
||||||
|
tickOrderHoldDurations();
|
||||||
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -120,14 +120,14 @@
|
|||||||
<div class="flash">{{ messages[0] }}</div>
|
<div class="flash">{{ messages[0] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<form method="POST">
|
<form method="POST" autocomplete="off">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>账号</label>
|
<label>账号</label>
|
||||||
<input type="text" name="username" required placeholder="请输入账号">
|
<input type="text" name="username" required placeholder="请输入账号" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>密码</label>
|
<label>密码</label>
|
||||||
<input type="password" name="password" required placeholder="请输入密码">
|
<input type="password" name="password" required placeholder="请输入密码" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">登录</button>
|
<button type="submit">登录</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -66,10 +66,11 @@
|
|||||||
| **收敛突破** | 同上(自动开仓类)。 |
|
| **收敛突破** | 同上(自动开仓类)。 |
|
||||||
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
|
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
|
||||||
| **关键支撑位** | 同上(仅提醒)。 |
|
| **关键支撑位** | 同上(仅提醒)。 |
|
||||||
| **触价开仓** | **不挂交易所限价**;标记价触达计划入场价后 **下一轮询市价开仓**(RR 门槛同关键位 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h**;全仓杠杆模式可用。 |
|
| **回调触价开仓** | **不挂交易所限价**;标记价回调触达 E 后 **下一轮询市价开仓**(RR 门槛同 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h** |
|
||||||
|
| **突破触价开仓** | **不挂交易所限价**;标记价 **穿越 E 立即市价开仓**;先触 SL/TP 侧失效;有效期 **24h** |
|
||||||
|
|
||||||
3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
|
3. **方向**:做多 / 做空(回调/突破触价、箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
|
||||||
4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**。
|
4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价填 **入场 E / 止损 SL / 止盈 TP**。
|
||||||
|
|
||||||
**限制:**
|
**限制:**
|
||||||
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
|
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
|
|||||||
| **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` |
|
| **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` |
|
||||||
| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
|
| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
|
||||||
| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) |
|
| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) |
|
||||||
| **触价开仓** | **必选** 多/空 | **程序盯价 → 触 E 后市价** | 见下文 **§六** |
|
| **回调触价开仓** | **必选** 多/空 | **程序盯价 → 回调触 E 后市价** | 见下文 **§四** |
|
||||||
|
| **突破触价开仓** | **必选** 多/空 | **程序盯价 → 穿越 E 立即市价** | 见下文 **§四** |
|
||||||
|
|
||||||
**添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位)。
|
**添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位)。
|
||||||
|
|
||||||
@@ -111,6 +112,7 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
|
|||||||
|
|
||||||
| `close_reason` | 含义 |
|
| `close_reason` | 含义 |
|
||||||
|----------------|------|
|
|----------------|------|
|
||||||
|
| `box_opposite_break` | 标记价先突破反向边界(多:≤下沿;空:≥上沿) |
|
||||||
| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 |
|
| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 |
|
||||||
| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 |
|
| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 |
|
||||||
| `auto_opened` | RR 达标且市价开仓成功 |
|
| `auto_opened` | RR 达标且市价开仓成功 |
|
||||||
@@ -118,25 +120,31 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、触价开仓(程序触价,无交易所挂单)
|
## 四、回调 / 突破触价开仓(程序触价,无交易所挂单)
|
||||||
|
|
||||||
### 4.1 录入
|
### 4.1 录入
|
||||||
|
|
||||||
- 类型选 **触价开仓**;方向必选多/空。
|
- **回调触价开仓**:方向必选多/空;填写 **计划入场价 E**、**止损 SL**、**止盈 TP**(做多须 `SL < E < TP`)。
|
||||||
- 填写 **计划入场价 E**、**止损 SL**、**止盈 TP**(做多须 `SL < E < TP`)。
|
- **突破触价开仓**:同上;添加时当前价须在突破方向一侧(做多:价低于 E;做空:价高于 E)。
|
||||||
- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5,与箱体/斐波相同)。
|
- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5)。
|
||||||
- 可选移动保本、时间平仓;**全仓杠杆模式**下可用(页面隐藏箱体/收敛/斐波/假突破)。
|
- 可选移动保本、时间平仓;**全仓杠杆模式**下可用。
|
||||||
|
|
||||||
### 4.2 触发与结案
|
### 4.2 触发与结案
|
||||||
|
|
||||||
- 轮询标记价:做多 `标记价 ≤ E`、做空 `标记价 ≥ E` → **下一轮询市价开仓**,挂交易所 TP/SL,进下单监控。
|
| 类型 | 触发条件(标记价) |
|
||||||
- 未成交前标记价先触 **TP 侧** → `trigger_tp_invalidate`;**24h** 未触发 → `trigger_entry_expired`。
|
|------|-------------------|
|
||||||
|
| **回调触价** | 做多 `≤ E`;做空 `≥ E` → 下一轮询市价开仓 |
|
||||||
|
| **突破触价** | 做多**向上穿越** E;做空**向下穿越** E → **立即**市价开仓 |
|
||||||
|
|
||||||
|
- 未成交前标记价先触 **TP 侧** → `trigger_tp_invalidate`。
|
||||||
|
- **突破触价**另:未穿越 E 先触 **SL 侧** → `trigger_sl_invalidate`。
|
||||||
|
- **24h** 未触发 → `trigger_entry_expired`。
|
||||||
- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`。
|
- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`。
|
||||||
|
|
||||||
### 4.3 计仓与占位
|
### 4.3 计仓与占位
|
||||||
|
|
||||||
- **以损定仓**:按 E、SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x、其它 5x。
|
- **以损定仓**:按 E、SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x、其它 5x。
|
||||||
- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条。
|
- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条触价监控(含回调/突破)。
|
||||||
|
|
||||||
共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`。
|
共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`。
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ APP_PORT=5000
|
|||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
|
|
||||||
# 登录账号
|
# 登录账号
|
||||||
APP_USERNAME=dekun
|
APP_USERNAME=admin
|
||||||
# 登录密码(请改成你自己的强密码)
|
# 登录密码(请改成你自己的强密码)
|
||||||
APP_PASSWORD=ChangeMe123!
|
APP_PASSWORD=admin123
|
||||||
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
||||||
APP_AUTH_DISABLED=true
|
APP_AUTH_DISABLED=true
|
||||||
# --- 多账户交易中控 manual_trading_hub ---
|
# --- 多账户交易中控 manual_trading_hub ---
|
||||||
@@ -129,6 +129,16 @@ DAILY_OPEN_ALERT_THRESHOLD=5
|
|||||||
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
|
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
|
||||||
DAILY_OPEN_HARD_LIMIT=0
|
DAILY_OPEN_HARD_LIMIT=0
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 账户冷静期 / 日冻结风控(手动平仓、外部平仓、复盘情绪标签)
|
||||||
|
# 详见 docs/account-risk-cooldown.md
|
||||||
|
# =============================================================================
|
||||||
|
# RISK_CONTROL_ENABLED=true
|
||||||
|
# RISK_COOLING_HOURS_MANUAL=4
|
||||||
|
# RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
||||||
|
# RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
||||||
|
# RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
||||||
|
|
||||||
# 资金与仓位刷新周期(秒)
|
# 资金与仓位刷新周期(秒)
|
||||||
BALANCE_REFRESH_SECONDS=60
|
BALANCE_REFRESH_SECONDS=60
|
||||||
# 前端价格快照轮询(秒)
|
# 前端价格快照轮询(秒)
|
||||||
|
|||||||
+520
-167
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<script src="/static/instance_theme.js?v=6"></script>
|
<script src="/static/instance_theme.js?v=46"></script>
|
||||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=1">
|
<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>
|
||||||
|
|
||||||
<meta name="theme-color" content="#0b0d14">
|
<meta name="theme-color" content="#0b0d14">
|
||||||
<meta name="apple-mobile-web-app-title" content="监控">
|
<meta name="apple-mobile-web-app-title" content="监控">
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
|
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
|
||||||
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
|
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
|
||||||
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
|
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
|
||||||
|
.header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center}
|
||||||
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
|
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
|
||||||
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
|
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
|
||||||
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
|
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
|
||||||
@@ -34,6 +37,12 @@
|
|||||||
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
|
.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}
|
.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}
|
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
|
||||||
|
.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}
|
||||||
|
.order-preview-risk{color:#ff6b6b}.order-preview-risk strong{color:#ff8f8f;font-weight:600}
|
||||||
|
.order-preview-profit{color:#4cd97f}.order-preview-profit strong{color:#6ee7a0;font-weight:600}
|
||||||
|
.order-preview-rr{color:#cfd3ef}.order-preview-rr strong{font-weight:600;color:#dbe4ff}
|
||||||
|
.order-preview-rr.order-preview-rr-low strong{color:#ff8f8f}
|
||||||
|
.order-preview-rr.order-preview-rr-ok strong{color:#8fc8ff}
|
||||||
.form-row > button,.form-row > label{flex:0 0 auto}
|
.form-row > button,.form-row > label{flex:0 0 auto}
|
||||||
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
||||||
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
|
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
|
||||||
@@ -234,10 +243,17 @@
|
|||||||
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
|
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
|
||||||
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=14">
|
<link rel="stylesheet" href="/static/instance_theme.css?v=48">
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body data-page="{{ page }}">
|
<body
|
||||||
|
data-page="{{ page }}"
|
||||||
|
data-risk-percent="{{ risk_percent }}"
|
||||||
|
data-position-sizing-mode="{{ position_sizing_mode }}"
|
||||||
|
data-btc-leverage="{{ btc_leverage }}"
|
||||||
|
data-alt-leverage="{{ alt_leverage }}"
|
||||||
|
data-full-margin-buffer="{{ full_margin_buffer_ratio }}"
|
||||||
|
>
|
||||||
{% macro period_stats(title, s) %}
|
{% macro period_stats(title, s) %}
|
||||||
<div class="stats-period-block">
|
<div class="stats-period-block">
|
||||||
<h3>{{ title }}</h3>
|
<h3>{{ title }}</h3>
|
||||||
@@ -262,6 +278,7 @@
|
|||||||
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div class="exchange-tag">{{ exchange_display }}</div>
|
<div class="exchange-tag">{{ exchange_display }}</div>
|
||||||
|
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}" data-status-label="{{ risk_status.status_label|default('正常') }}"{% if risk_status.freeze_until_ms %} data-freeze-until-ms="{{ risk_status.freeze_until_ms }}"{% endif %}>{{ risk_status.status_label|default('正常') }}</span>
|
||||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||||
@@ -336,7 +353,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% include 'order_monitor_rule_tips_gate.html' %}
|
{% include 'order_monitor_rule_tips_gate.html' %}
|
||||||
<form id="add-order-form" action="/add_order" method="post" class="form-row">
|
<form id="add-order-form" action="/add_order" method="post" class="form-row" data-risk-percent="{{ risk_percent }}">
|
||||||
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
||||||
<select id="order-direction" name="direction" required>
|
<select id="order-direction" name="direction" required>
|
||||||
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
@@ -378,6 +395,7 @@
|
|||||||
<input id="order-tp-pct" name="tp_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>
|
<button type="submit">{{ open_position_button_label }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% include 'order_plan_preview_bar.html' %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="margin-bottom:8px">实时持仓</h2>
|
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||||
@@ -411,6 +429,7 @@
|
|||||||
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
|
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
|
||||||
<span class="pos-meta-item">风格: {{ o.trade_style or 'trend' }}</span>
|
<span class="pos-meta-item">风格: {{ o.trade_style or 'trend' }}</span>
|
||||||
<span class="pos-meta-item">风险: {% if position_sizing_mode == 'full_margin' %}{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% else %}{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% endif %}</span>
|
<span class="pos-meta-item">风险: {% if position_sizing_mode == 'full_margin' %}{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% else %}{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% endif %}</span>
|
||||||
|
<span class="pos-meta-item" id="order-latest-risk-wrap-{{ o.id }}" style="display:none">最新风险: —</span>
|
||||||
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}">
|
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}">
|
||||||
{% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
|
{% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
|
||||||
</span>
|
</span>
|
||||||
@@ -433,6 +452,10 @@
|
|||||||
<span class="pos-label">盈亏比</span>
|
<span class="pos-label">盈亏比</span>
|
||||||
<span class="pos-value" id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %}</span>
|
<span class="pos-value" id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pos-cell">
|
||||||
|
<span class="pos-label">张数</span>
|
||||||
|
<span class="pos-value" id="order-contracts-{{ o.id }}">{% if o.order_amount is not none %}{{ '%g'|format(o.order_amount) }}{% else %}—{% endif %}</span>
|
||||||
|
</div>
|
||||||
<div class="pos-cell">
|
<div class="pos-cell">
|
||||||
<span class="pos-label">标记价</span>
|
<span class="pos-label">标记价</span>
|
||||||
<span class="pos-value" id="order-price-{{ o.id }}">-</span>
|
<span class="pos-value" id="order-price-{{ o.id }}">-</span>
|
||||||
@@ -447,6 +470,8 @@
|
|||||||
<span>计划基数: {{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
|
<span>计划基数: {{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
|
||||||
<span>杠杆: {{ o.leverage or '-' }}x</span>
|
<span>杠杆: {{ o.leverage or '-' }}x</span>
|
||||||
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
|
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
|
||||||
|
<span>开仓时间: {{ (o.opened_at or '-')[:16] }}</span>
|
||||||
|
<span>持仓时长: <span class="order-hold-duration" id="order-hold-duration-{{ o.id }}" data-order-opened-ms="{{ o.opened_at_ms or '' }}">—</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pos-ex-orders">
|
<div class="pos-ex-orders">
|
||||||
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
||||||
@@ -589,8 +614,7 @@
|
|||||||
<select name="type" required>
|
<select name="type" required>
|
||||||
<option value="箱体突破">箱体突破</option>
|
<option value="箱体突破">箱体突破</option>
|
||||||
<option value="收敛突破">收敛突破</option>
|
<option value="收敛突破">收敛突破</option>
|
||||||
<option value="关键阻力位">关键阻力位</option>
|
<option value="关键支撑阻力">关键支撑阻力</option>
|
||||||
<option value="关键支撑位">关键支撑位</option>
|
|
||||||
</select>
|
</select>
|
||||||
<select name="direction" required>
|
<select name="direction" required>
|
||||||
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
@@ -783,10 +807,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/instance_ui.js?v=1"></script>
|
<script src="/static/instance_ui.js?v=4"></script>
|
||||||
|
<script src="/static/instance_records_mobile.js?v=2"></script>
|
||||||
<script src="/static/time_close_ui.js?v=2"></script>
|
<script src="/static/time_close_ui.js?v=2"></script>
|
||||||
<script src="/static/ai_review_render.js?v=2"></script>
|
<script src="/static/ai_review_render.js?v=2"></script>
|
||||||
<script src="/static/form_submit_guard.js?v=2"></script>
|
<script src="/static/form_submit_guard.js?v=2"></script>
|
||||||
|
<script src="/static/manual_order_rr_preview.js?v=5"></script>
|
||||||
|
<script src="/static/strategy_roll.js?v=6"></script>
|
||||||
<script>
|
<script>
|
||||||
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
|
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
|
||||||
const JOURNAL_ENTRY_REASON_OTHER = {{ entry_reason_other_value | tojson }};
|
const JOURNAL_ENTRY_REASON_OTHER = {{ entry_reason_other_value | tojson }};
|
||||||
@@ -867,8 +894,10 @@ function setDetailBodyPlain(text){
|
|||||||
body.innerText = text || "";
|
body.innerText = text || "";
|
||||||
}
|
}
|
||||||
function setDetailBodyMarkdown(text){
|
function setDetailBodyMarkdown(text){
|
||||||
|
if(window.InstanceUI && InstanceUI.clearDetailActions) InstanceUI.clearDetailActions();
|
||||||
const body = document.getElementById("detailBody");
|
const body = document.getElementById("detailBody");
|
||||||
if(!body) return;
|
if(!body) return;
|
||||||
|
body.classList.remove("trade-record-detail-wrap", "journal-detail-meta");
|
||||||
if(window.AiReviewRender && AiReviewRender.setElementMarkdown){
|
if(window.AiReviewRender && AiReviewRender.setElementMarkdown){
|
||||||
body.classList.add("md-review");
|
body.classList.add("md-review");
|
||||||
AiReviewRender.setElementMarkdown(body, text || "");
|
AiReviewRender.setElementMarkdown(body, text || "");
|
||||||
@@ -1070,22 +1099,12 @@ function loadJournals(){
|
|||||||
const qs = listWindowQueryString();
|
const qs = listWindowQueryString();
|
||||||
fetch("/api/journals" + (qs ? "?" + qs : "")).then(r=>r.json()).then(data=>{
|
fetch("/api/journals" + (qs ? "?" + qs : "")).then(r=>r.json()).then(data=>{
|
||||||
Object.keys(journalCache).forEach(k=>delete journalCache[k]);
|
Object.keys(journalCache).forEach(k=>delete journalCache[k]);
|
||||||
let html="";
|
data.forEach(o=>{ journalCache[o.id] = o; });
|
||||||
data.forEach(o=>{
|
|
||||||
journalCache[o.id] = o;
|
|
||||||
const moodTags = (o.mood_issues || []).join(",") || "无";
|
|
||||||
html += `<div class="entry">
|
|
||||||
<div><strong>${o.coin||"-"} ${o.tf||"-"}</strong> | 盈亏:${o.pnl||"-"}U</div>
|
|
||||||
<div>开:${o.open_datetime||"-"} 平:${o.close_datetime||"-"} 持仓:${o.hold_duration||"-"}</div>
|
|
||||||
<div>心态标签:${moodTags}</div>
|
|
||||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:6px">
|
|
||||||
<button type="button" class="btn-del" style="border:none;cursor:pointer;background:#1f3a5a;color:#8fc8ff" onclick="openJournalDetail('${o.id}')">查看详情</button>
|
|
||||||
<button type="button" class="btn-del" onclick="deleteJournal('${o.id}')">删除</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
const box = document.getElementById("journal-list");
|
const box = document.getElementById("journal-list");
|
||||||
if(box){ box.innerHTML = html || "<div class='entry'>暂无数据</div>"; }
|
if(box){
|
||||||
|
const html = InstanceUI.renderJournalListHtml(data);
|
||||||
|
box.innerHTML = html || "<div class='journal-empty-msg'>暂无数据</div>";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1509,120 +1528,9 @@ if(journalForm){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncKeyMonitorFormFields(){
|
|
||||||
const typeEl = document.querySelector('#key-form [name="type"]');
|
|
||||||
const dirEl = document.getElementById("key-direction");
|
|
||||||
const modeEl = document.getElementById("key-sl-tp-mode");
|
|
||||||
const manualTp = document.getElementById("key-manual-tp");
|
|
||||||
const beWrap = document.getElementById("key-breakeven-wrap");
|
|
||||||
if(!typeEl) return;
|
|
||||||
const t = (typeEl.value || "").trim();
|
|
||||||
const autoTypes = new Set(["箱体突破","收敛突破"]);
|
|
||||||
const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]);
|
|
||||||
const fbTypes = new Set(["假突破"]);
|
|
||||||
const teTypes = new Set(["触价开仓"]);
|
|
||||||
const rsTypes = new Set(["关键阻力位","关键支撑位"]);
|
|
||||||
const showAuto = autoTypes.has(t);
|
|
||||||
const showFb = fbTypes.has(t);
|
|
||||||
const showTe = teTypes.has(t);
|
|
||||||
const showBe = showAuto || fibTypes.has(t) || showFb || showTe;
|
|
||||||
const showDir = !rsTypes.has(t);
|
|
||||||
const upperEl = document.getElementById("key-upper");
|
|
||||||
const lowerEl = document.getElementById("key-lower");
|
|
||||||
const fbPriceEl = document.getElementById("key-fb-price");
|
|
||||||
const teEntryEl = document.getElementById("key-trigger-entry");
|
|
||||||
const teSlEl = document.getElementById("key-trigger-sl");
|
|
||||||
const teTpEl = document.getElementById("key-trigger-tp");
|
|
||||||
if(dirEl){
|
|
||||||
dirEl.style.display = showDir ? "" : "none";
|
|
||||||
dirEl.required = showDir;
|
|
||||||
if(!showDir) dirEl.value = "";
|
|
||||||
}
|
|
||||||
if(modeEl) modeEl.style.display = showAuto ? "" : "none";
|
|
||||||
if(manualTp){
|
|
||||||
const trend = showAuto && modeEl && modeEl.value === "trend_manual";
|
|
||||||
manualTp.style.display = trend ? "" : "none";
|
|
||||||
manualTp.required = !!trend;
|
|
||||||
}
|
|
||||||
if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
|
|
||||||
if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
|
|
||||||
const hideBounds = showFb || showTe;
|
|
||||||
if(upperEl){
|
|
||||||
upperEl.style.display = hideBounds ? "none" : "";
|
|
||||||
upperEl.required = !hideBounds;
|
|
||||||
if(hideBounds) upperEl.value = "";
|
|
||||||
}
|
|
||||||
if(lowerEl){
|
|
||||||
lowerEl.style.display = hideBounds ? "none" : "";
|
|
||||||
lowerEl.required = !hideBounds;
|
|
||||||
if(hideBounds) lowerEl.value = "";
|
|
||||||
}
|
|
||||||
if(fbPriceEl){
|
|
||||||
fbPriceEl.style.display = showFb ? "" : "none";
|
|
||||||
fbPriceEl.required = showFb;
|
|
||||||
if(!showFb) fbPriceEl.value = "";
|
|
||||||
fbPriceEl.placeholder = (dirEl && dirEl.value === "short") ? "高点(阻力)" : ((dirEl && dirEl.value === "long") ? "低点(支撑)" : "做空填高点/做多填低点");
|
|
||||||
}
|
|
||||||
[teEntryEl, teSlEl, teTpEl].forEach((el)=>{
|
|
||||||
if(!el) return;
|
|
||||||
el.style.display = showTe ? "" : "none";
|
|
||||||
el.required = showTe;
|
|
||||||
if(!showTe) el.value = "";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const keyTypeSel = document.querySelector('#key-form [name="type"]');
|
|
||||||
const keyModeSel = document.getElementById("key-sl-tp-mode");
|
|
||||||
const keyDirSel = document.getElementById("key-direction");
|
|
||||||
if(keyTypeSel) keyTypeSel.addEventListener("change", syncKeyMonitorFormFields);
|
|
||||||
if(keyModeSel) keyModeSel.addEventListener("change", syncKeyMonitorFormFields);
|
|
||||||
if(keyDirSel) keyDirSel.addEventListener("change", syncKeyMonitorFormFields);
|
|
||||||
syncKeyMonitorFormFields();
|
|
||||||
if(window.TimeCloseUI){
|
if(window.TimeCloseUI){
|
||||||
TimeCloseUI.bindTimeCloseForm("key-time-close-cb", "key-time-close-hours", "key-time-close-wrap");
|
|
||||||
TimeCloseUI.bindTimeCloseForm("order-time-close-cb", "order-time-close-hours", "order-time-close-wrap");
|
TimeCloseUI.bindTimeCloseForm("order-time-close-cb", "order-time-close-hours", "order-time-close-wrap");
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyForm = document.getElementById("key-form");
|
|
||||||
if(keyForm){
|
|
||||||
keyForm.addEventListener("submit", (e)=>{
|
|
||||||
e.preventDefault();
|
|
||||||
if(window.FormSubmitGuard && FormSubmitGuard.isLocked(keyForm)) return;
|
|
||||||
const symbolEl = keyForm.querySelector('[name="symbol"]');
|
|
||||||
const symbol = (symbolEl ? symbolEl.value : "").trim();
|
|
||||||
if(!symbol){
|
|
||||||
alert("请先输入交易对");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const typeVal = (keyForm.querySelector('[name="type"]') || {}).value || "";
|
|
||||||
if(typeVal === "假突破"){
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
|
|
||||||
else keyForm.submit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.lock(keyForm, "校验排名中…");
|
|
||||||
fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`)
|
|
||||||
.then(r=>r.json().then(d=>({status:r.status, data:d})))
|
|
||||||
.then(({status,data})=>{
|
|
||||||
if(status >= 400 || !data.ok){
|
|
||||||
alert((data && data.msg) || "日成交量排名读取失败");
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rankMax = data.rank_max || 30;
|
|
||||||
if(!data.in_top30){
|
|
||||||
alert(`${data.symbol} 当前日成交量排名 ${data.rank}/${data.total},不在前${rankMax},已拦截。`);
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
|
|
||||||
else keyForm.submit();
|
|
||||||
})
|
|
||||||
.catch(()=>{
|
|
||||||
alert("日成交量排名检查失败,请稍后重试");
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
|
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if(document.getElementById("journal-list")) loadJournals();
|
if(document.getElementById("journal-list")) loadJournals();
|
||||||
@@ -1673,6 +1581,7 @@ function refreshOrderTpPreview(entryPx){
|
|||||||
const entry = entryPx != null && Number.isFinite(Number(entryPx)) ? Number(entryPx) : sl;
|
const entry = entryPx != null && Number.isFinite(Number(entryPx)) ? Number(entryPx) : sl;
|
||||||
const tp = calcTpFromFixedRr(direction, entry, sl, rr);
|
const tp = calcTpFromFixedRr(direction, entry, sl, rr);
|
||||||
preview.textContent = tp == null ? "预估止盈:—" : ("预估止盈:" + formatPriceForInput(tp));
|
preview.textContent = tp == null ? "预估止盈:—" : ("预估止盈:" + formatPriceForInput(tp));
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}
|
}
|
||||||
function calcClientRr(direction, entry, sl, tp){
|
function calcClientRr(direction, entry, sl, tp){
|
||||||
const e = Number(entry), s = Number(sl), t = Number(tp);
|
const e = Number(entry), s = Number(sl), t = Number(tp);
|
||||||
@@ -1759,6 +1668,13 @@ function submitTpslEntrust(){
|
|||||||
alert(data.msg || '已提交');
|
alert(data.msg || '已提交');
|
||||||
closeTpslEntrustModal();
|
closeTpslEntrustModal();
|
||||||
if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl);
|
if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl);
|
||||||
|
paintPlanTpslDisplay(orderId, data);
|
||||||
|
paintLatestRiskDisplay(orderId, data);
|
||||||
|
const rrEl = document.getElementById(`order-rr-${orderId}`);
|
||||||
|
if(rrEl){
|
||||||
|
const rr = data.display_rr_ratio != null && data.display_rr_ratio !== "" ? data.display_rr_ratio : data.planned_rr;
|
||||||
|
rrEl.innerText = formatRrRatio(rr);
|
||||||
|
}
|
||||||
refreshPriceSnapshotConditional();
|
refreshPriceSnapshotConditional();
|
||||||
}).catch(()=>alert('委托请求失败'));
|
}).catch(()=>alert('委托请求失败'));
|
||||||
}
|
}
|
||||||
@@ -1826,6 +1742,25 @@ function paintPlanTpslDisplay(orderId, snap){
|
|||||||
else if(tpDisp) card.setAttribute("data-plan-tp", tpDisp);
|
else if(tpDisp) card.setAttribute("data-plan-tp", tpDisp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function paintLatestRiskDisplay(orderId, snap){
|
||||||
|
const wrap = document.getElementById(`order-latest-risk-wrap-${orderId}`);
|
||||||
|
if(!wrap) return;
|
||||||
|
const v = snap && snap.latest_risk_amount;
|
||||||
|
const n = v != null && v !== "" ? Number(v) : NaN;
|
||||||
|
if(Number.isFinite(n)){
|
||||||
|
wrap.style.display = "inline-flex";
|
||||||
|
wrap.textContent = `最新风险: ${n.toFixed(2)}U`;
|
||||||
|
} else {
|
||||||
|
wrap.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function paintContractsDisplay(orderId, snap){
|
||||||
|
const el = document.getElementById(`order-contracts-${orderId}`);
|
||||||
|
if(!el || !snap) return;
|
||||||
|
const v = snap.contracts != null && snap.contracts !== "" ? snap.contracts : snap.order_amount;
|
||||||
|
const n = v != null && v !== "" ? Number(v) : NaN;
|
||||||
|
el.innerText = Number.isFinite(n) ? String(parseFloat(n.toFixed(4))) : "—";
|
||||||
|
}
|
||||||
|
|
||||||
function paintPriceTrend(el, key, value){
|
function paintPriceTrend(el, key, value){
|
||||||
if(!el) return;
|
if(!el) return;
|
||||||
@@ -1909,8 +1844,11 @@ function refreshPriceSnapshot(){
|
|||||||
}
|
}
|
||||||
const rrEl = document.getElementById(`order-rr-${o.id}`);
|
const rrEl = document.getElementById(`order-rr-${o.id}`);
|
||||||
if(rrEl){
|
if(rrEl){
|
||||||
rrEl.innerText = formatRrRatio(o.rr_ratio);
|
const rr = o.display_rr_ratio != null && o.display_rr_ratio !== "" ? o.display_rr_ratio : o.rr_ratio;
|
||||||
|
rrEl.innerText = formatRrRatio(rr);
|
||||||
}
|
}
|
||||||
|
paintLatestRiskDisplay(o.id, o);
|
||||||
|
paintContractsDisplay(o.id, o);
|
||||||
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
|
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
|
||||||
if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl);
|
if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl);
|
||||||
paintPlanTpslDisplay(o.id, o);
|
paintPlanTpslDisplay(o.id, o);
|
||||||
@@ -1945,6 +1883,7 @@ function refreshOrderDefaults(){
|
|||||||
}
|
}
|
||||||
const px = data.last_price || data.price;
|
const px = data.last_price || data.price;
|
||||||
if(px) refreshOrderTpPreview(px);
|
if(px) refreshOrderTpPreview(px);
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1961,9 +1900,25 @@ function refreshAccountSnapshot(){
|
|||||||
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
||||||
latestAvailableUsdt = Number(data.available_trading_usdt);
|
latestAvailableUsdt = Number(data.available_trading_usdt);
|
||||||
}
|
}
|
||||||
|
if (data.risk_status) {
|
||||||
|
const badge = document.getElementById("account-risk-badge");
|
||||||
|
if (badge) {
|
||||||
|
if (window.AccountRiskBadge) {
|
||||||
|
AccountRiskBadge.applyToElement(badge, data.risk_status);
|
||||||
|
} else {
|
||||||
|
const st = data.risk_status.status || "normal";
|
||||||
|
badge.className = "risk-status-badge risk-status-" + st;
|
||||||
|
badge.innerText = data.risk_status.status_label || "正常";
|
||||||
|
badge.title = data.risk_status.reason || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let canTradeText = "可开仓";
|
let canTradeText = "可开仓";
|
||||||
if (!data.can_trade) {
|
if (!data.can_trade) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
if (data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason) {
|
||||||
|
parts.push(data.risk_status.reason);
|
||||||
|
}
|
||||||
const ac = Number(data.active_count || 0);
|
const ac = Number(data.active_count || 0);
|
||||||
const max = Number(data.max_active_positions || {{ max_active_positions }});
|
const max = Number(data.max_active_positions || {{ max_active_positions }});
|
||||||
if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
|
if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
|
||||||
@@ -2024,12 +1979,16 @@ function toggleSltpMode(){
|
|||||||
slPctEl.required = pct;
|
slPctEl.required = pct;
|
||||||
tpPctEl.required = pct;
|
tpPctEl.required = pct;
|
||||||
refreshOrderTpPreview();
|
refreshOrderTpPreview();
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}
|
}
|
||||||
if(sltpModeEl){
|
if(sltpModeEl){
|
||||||
sltpModeEl.addEventListener("change", toggleSltpMode);
|
sltpModeEl.addEventListener("change", toggleSltpMode);
|
||||||
loadFixedRrPref();
|
loadFixedRrPref();
|
||||||
toggleSltpMode();
|
toggleSltpMode();
|
||||||
}
|
}
|
||||||
|
if(window.ManualOrderRrPreview){
|
||||||
|
ManualOrderRrPreview.wire({ minRr: MANUAL_MIN_PLANNED_RR });
|
||||||
|
}
|
||||||
["order-sl","order-fixed-rr","order-direction"].forEach(function(id){
|
["order-sl","order-fixed-rr","order-direction"].forEach(function(id){
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if(el) el.addEventListener("input", function(){ refreshOrderTpPreview(); });
|
if(el) el.addEventListener("input", function(){ refreshOrderTpPreview(); });
|
||||||
@@ -2037,6 +1996,7 @@ if(sltpModeEl){
|
|||||||
});
|
});
|
||||||
|
|
||||||
refreshAccountSnapshot();
|
refreshAccountSnapshot();
|
||||||
|
if (window.AccountRiskBadge) AccountRiskBadge.startTicker();
|
||||||
const _journalFormEl = document.getElementById("journal-form");
|
const _journalFormEl = document.getElementById("journal-form");
|
||||||
if(_journalFormEl){
|
if(_journalFormEl){
|
||||||
_journalFormEl.addEventListener("submit", function(ev){
|
_journalFormEl.addEventListener("submit", function(ev){
|
||||||
@@ -2174,10 +2134,41 @@ function refreshPriceSnapshotConditional(){
|
|||||||
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
|
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
|
||||||
paintPlanTpslDisplay(o.id, o);
|
paintPlanTpslDisplay(o.id, o);
|
||||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
||||||
|
const holdEl = document.getElementById(`order-hold-duration-${o.id}`);
|
||||||
|
if(holdEl && o.opened_at_ms != null && o.opened_at_ms !== ""){
|
||||||
|
holdEl.setAttribute("data-order-opened-ms", String(o.opened_at_ms));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
tickOrderHoldDurations();
|
||||||
}
|
}
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
function formatLiveHoldDurationFromMs(openedMs, nowMs){
|
||||||
|
if(openedMs == null || openedMs === "" || !Number.isFinite(Number(openedMs))) return "—";
|
||||||
|
const ms = Number(openedMs);
|
||||||
|
const now = (nowMs != null) ? nowMs : Date.now();
|
||||||
|
let sec = Math.floor((now - ms) / 1000);
|
||||||
|
if(sec < 0) sec = 0;
|
||||||
|
if(sec <= 0) return "0分钟";
|
||||||
|
const d = Math.floor(sec / 86400); sec %= 86400;
|
||||||
|
const h = Math.floor(sec / 3600); sec %= 3600;
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const parts = [];
|
||||||
|
if(d) parts.push(`${d}天`);
|
||||||
|
if(h) parts.push(`${h}小时`);
|
||||||
|
if(m || !parts.length) parts.push(`${m}分钟`);
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
function tickOrderHoldDurations(){
|
||||||
|
const now = Date.now();
|
||||||
|
document.querySelectorAll(".order-hold-duration[data-order-opened-ms]").forEach(el=>{
|
||||||
|
const ms = Number(el.getAttribute("data-order-opened-ms"));
|
||||||
|
if(!Number.isFinite(ms) || ms <= 0) return;
|
||||||
|
el.textContent = formatLiveHoldDurationFromMs(ms, now);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setInterval(tickOrderHoldDurations, 1000);
|
||||||
|
tickOrderHoldDurations();
|
||||||
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -120,14 +120,14 @@
|
|||||||
<div class="flash">{{ messages[0] }}</div>
|
<div class="flash">{{ messages[0] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<form method="POST">
|
<form method="POST" autocomplete="off">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>账号</label>
|
<label>账号</label>
|
||||||
<input type="text" name="username" required placeholder="请输入账号">
|
<input type="text" name="username" required placeholder="请输入账号" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>密码</label>
|
<label>密码</label>
|
||||||
<input type="password" name="password" required placeholder="请输入密码">
|
<input type="password" name="password" required placeholder="请输入密码" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">登录</button>
|
<button type="submit">登录</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -65,7 +65,8 @@
|
|||||||
| **收敛突破** | 同上(自动开仓类)。 |
|
| **收敛突破** | 同上(自动开仓类)。 |
|
||||||
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
|
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
|
||||||
| **关键支撑位** | 同上(仅提醒)。 |
|
| **关键支撑位** | 同上(仅提醒)。 |
|
||||||
| **触价开仓** | **不挂交易所限价**;标记价触达计划入场价后 **下一轮询市价开仓**(RR 门槛同关键位 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h**;全仓杠杆模式可用。 |
|
| **回调触价开仓** | **不挂交易所限价**;标记价回调触达 E 后 **下一轮询市价开仓**(RR 门槛同 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h** |
|
||||||
|
| **突破触价开仓** | **不挂交易所限价**;标记价 **穿越 E 立即市价开仓**;先触 SL/TP 侧失效;有效期 **24h** |
|
||||||
|
|
||||||
3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
|
3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
|
||||||
4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**。
|
4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**。
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
|
|||||||
| **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` |
|
| **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` |
|
||||||
| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
|
| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
|
||||||
| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) |
|
| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) |
|
||||||
| **触价开仓** | **必选** 多/空 | **程序盯价 → 触 E 后市价** | 见下文 **§六** |
|
| **回调触价开仓** | **必选** 多/空 | **程序盯价 → 回调触 E 后市价** | 见下文 **§四** |
|
||||||
|
| **突破触价开仓** | **必选** 多/空 | **程序盯价 → 穿越 E 立即市价** | 见下文 **§四** |
|
||||||
|
|
||||||
**添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位)。
|
**添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位)。
|
||||||
|
|
||||||
@@ -111,6 +112,7 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
|
|||||||
|
|
||||||
| `close_reason` | 含义 |
|
| `close_reason` | 含义 |
|
||||||
|----------------|------|
|
|----------------|------|
|
||||||
|
| `box_opposite_break` | 标记价先突破反向边界(多:≤下沿;空:≥上沿) |
|
||||||
| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 |
|
| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 |
|
||||||
| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 |
|
| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 |
|
||||||
| `auto_opened` | RR 达标且市价开仓成功 |
|
| `auto_opened` | RR 达标且市价开仓成功 |
|
||||||
@@ -118,25 +120,31 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、触价开仓(程序触价,无交易所挂单)
|
## 四、回调 / 突破触价开仓(程序触价,无交易所挂单)
|
||||||
|
|
||||||
### 4.1 录入
|
### 4.1 录入
|
||||||
|
|
||||||
- 类型选 **触价开仓**;方向必选多/空。
|
- **回调触价开仓**:方向必选多/空;填写 **计划入场价 E**、**止损 SL**、**止盈 TP**(做多须 `SL < E < TP`)。
|
||||||
- 填写 **计划入场价 E**、**止损 SL**、**止盈 TP**(做多须 `SL < E < TP`)。
|
- **突破触价开仓**:同上;添加时当前价须在突破方向一侧(做多:价低于 E;做空:价高于 E)。
|
||||||
- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5,与箱体/斐波相同)。
|
- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5)。
|
||||||
- 可选移动保本、时间平仓;**全仓杠杆模式**下可用(页面隐藏箱体/收敛/斐波/假突破)。
|
- 可选移动保本、时间平仓;**全仓杠杆模式**下可用。
|
||||||
|
|
||||||
### 4.2 触发与结案
|
### 4.2 触发与结案
|
||||||
|
|
||||||
- 轮询标记价:做多 `标记价 ≤ E`、做空 `标记价 ≥ E` → **下一轮询市价开仓**,挂交易所 TP/SL,进下单监控。
|
| 类型 | 触发条件(标记价) |
|
||||||
- 未成交前标记价先触 **TP 侧** → `trigger_tp_invalidate`;**24h** 未触发 → `trigger_entry_expired`。
|
|------|-------------------|
|
||||||
|
| **回调触价** | 做多 `≤ E`;做空 `≥ E` → 下一轮询市价开仓 |
|
||||||
|
| **突破触价** | 做多**向上穿越** E;做空**向下穿越** E → **立即**市价开仓 |
|
||||||
|
|
||||||
|
- 未成交前标记价先触 **TP 侧** → `trigger_tp_invalidate`。
|
||||||
|
- **突破触价**另:未穿越 E 先触 **SL 侧** → `trigger_sl_invalidate`。
|
||||||
|
- **24h** 未触发 → `trigger_entry_expired`。
|
||||||
- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`。
|
- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`。
|
||||||
|
|
||||||
### 4.3 计仓与占位
|
### 4.3 计仓与占位
|
||||||
|
|
||||||
- **以损定仓**:按 E、SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x、其它 5x。
|
- **以损定仓**:按 E、SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x、其它 5x。
|
||||||
- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条。
|
- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条触价监控(含回调/突破)。
|
||||||
|
|
||||||
共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`。
|
共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`。
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ APP_PORT=5002
|
|||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
|
|
||||||
# 登录账号
|
# 登录账号
|
||||||
APP_USERNAME=dekun
|
APP_USERNAME=admin
|
||||||
# 登录密码(请改成你自己的强密码)
|
# 登录密码(请改成你自己的强密码)
|
||||||
APP_PASSWORD=ChangeMe123!
|
APP_PASSWORD=admin123
|
||||||
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
||||||
APP_AUTH_DISABLED=true
|
APP_AUTH_DISABLED=true
|
||||||
# --- 多账户交易中控 manual_trading_hub ---
|
# --- 多账户交易中控 manual_trading_hub ---
|
||||||
@@ -129,6 +129,16 @@ DAILY_OPEN_ALERT_THRESHOLD=5
|
|||||||
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
|
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
|
||||||
DAILY_OPEN_HARD_LIMIT=0
|
DAILY_OPEN_HARD_LIMIT=0
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 账户冷静期 / 日冻结风控(手动平仓、外部平仓、复盘情绪标签)
|
||||||
|
# 详见 docs/account-risk-cooldown.md
|
||||||
|
# =============================================================================
|
||||||
|
# RISK_CONTROL_ENABLED=true
|
||||||
|
# RISK_COOLING_HOURS_MANUAL=4
|
||||||
|
# RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
||||||
|
# RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
||||||
|
# RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
||||||
|
|
||||||
# 资金与仓位刷新周期(秒)
|
# 资金与仓位刷新周期(秒)
|
||||||
BALANCE_REFRESH_SECONDS=60
|
BALANCE_REFRESH_SECONDS=60
|
||||||
# 前端价格快照轮询(秒)
|
# 前端价格快照轮询(秒)
|
||||||
|
|||||||
+516
-167
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<script src="/static/instance_theme.js?v=6"></script>
|
<script src="/static/instance_theme.js?v=46"></script>
|
||||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=1">
|
<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>
|
||||||
|
|
||||||
<meta name="theme-color" content="#0b0d14">
|
<meta name="theme-color" content="#0b0d14">
|
||||||
<meta name="apple-mobile-web-app-title" content="监控">
|
<meta name="apple-mobile-web-app-title" content="监控">
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
|
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
|
||||||
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
|
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
|
||||||
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
|
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
|
||||||
|
.header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center}
|
||||||
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
|
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
|
||||||
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
|
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
|
||||||
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
|
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
|
||||||
@@ -34,6 +37,12 @@
|
|||||||
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
|
.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}
|
.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}
|
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
|
||||||
|
.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}
|
||||||
|
.order-preview-risk{color:#ff6b6b}.order-preview-risk strong{color:#ff8f8f;font-weight:600}
|
||||||
|
.order-preview-profit{color:#4cd97f}.order-preview-profit strong{color:#6ee7a0;font-weight:600}
|
||||||
|
.order-preview-rr{color:#cfd3ef}.order-preview-rr strong{font-weight:600;color:#dbe4ff}
|
||||||
|
.order-preview-rr.order-preview-rr-low strong{color:#ff8f8f}
|
||||||
|
.order-preview-rr.order-preview-rr-ok strong{color:#8fc8ff}
|
||||||
.form-row > button,.form-row > label{flex:0 0 auto}
|
.form-row > button,.form-row > label{flex:0 0 auto}
|
||||||
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
||||||
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
|
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
|
||||||
@@ -234,10 +243,17 @@
|
|||||||
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
|
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
|
||||||
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=14">
|
<link rel="stylesheet" href="/static/instance_theme.css?v=48">
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body data-page="{{ page }}">
|
<body
|
||||||
|
data-page="{{ page }}"
|
||||||
|
data-risk-percent="{{ risk_percent }}"
|
||||||
|
data-position-sizing-mode="{{ position_sizing_mode }}"
|
||||||
|
data-btc-leverage="{{ btc_leverage }}"
|
||||||
|
data-alt-leverage="{{ alt_leverage }}"
|
||||||
|
data-full-margin-buffer="{{ full_margin_buffer_ratio }}"
|
||||||
|
>
|
||||||
{% macro period_stats(title, s) %}
|
{% macro period_stats(title, s) %}
|
||||||
<div class="stats-period-block">
|
<div class="stats-period-block">
|
||||||
<h3>{{ title }}</h3>
|
<h3>{{ title }}</h3>
|
||||||
@@ -262,6 +278,7 @@
|
|||||||
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div class="exchange-tag">{{ exchange_display }}</div>
|
<div class="exchange-tag">{{ exchange_display }}</div>
|
||||||
|
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}" data-status-label="{{ risk_status.status_label|default('正常') }}"{% if risk_status.freeze_until_ms %} data-freeze-until-ms="{{ risk_status.freeze_until_ms }}"{% endif %}>{{ risk_status.status_label|default('正常') }}</span>
|
||||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||||
@@ -336,7 +353,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% include 'order_monitor_rule_tips_gate.html' %}
|
{% include 'order_monitor_rule_tips_gate.html' %}
|
||||||
<form id="add-order-form" action="/add_order" method="post" class="form-row">
|
<form id="add-order-form" action="/add_order" method="post" class="form-row" data-risk-percent="{{ risk_percent }}">
|
||||||
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
||||||
<select id="order-direction" name="direction" required>
|
<select id="order-direction" name="direction" required>
|
||||||
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
@@ -378,6 +395,7 @@
|
|||||||
<input id="order-tp-pct" name="tp_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>
|
<button type="submit">{{ open_position_button_label }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% include 'order_plan_preview_bar.html' %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="margin-bottom:8px">实时持仓</h2>
|
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||||
@@ -411,6 +429,7 @@
|
|||||||
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
|
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
|
||||||
<span class="pos-meta-item">风格: {{ o.trade_style or 'trend' }}</span>
|
<span class="pos-meta-item">风格: {{ o.trade_style or 'trend' }}</span>
|
||||||
<span class="pos-meta-item">风险: {% if position_sizing_mode == 'full_margin' %}{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% else %}{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% endif %}</span>
|
<span class="pos-meta-item">风险: {% if position_sizing_mode == 'full_margin' %}{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% else %}{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% endif %}</span>
|
||||||
|
<span class="pos-meta-item" id="order-latest-risk-wrap-{{ o.id }}" style="display:none">最新风险: —</span>
|
||||||
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}">
|
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}">
|
||||||
{% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
|
{% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
|
||||||
</span>
|
</span>
|
||||||
@@ -433,6 +452,10 @@
|
|||||||
<span class="pos-label">盈亏比</span>
|
<span class="pos-label">盈亏比</span>
|
||||||
<span class="pos-value" id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %}</span>
|
<span class="pos-value" id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pos-cell">
|
||||||
|
<span class="pos-label">张数</span>
|
||||||
|
<span class="pos-value" id="order-contracts-{{ o.id }}">{% if o.order_amount is not none %}{{ '%g'|format(o.order_amount) }}{% else %}—{% endif %}</span>
|
||||||
|
</div>
|
||||||
<div class="pos-cell">
|
<div class="pos-cell">
|
||||||
<span class="pos-label">标记价</span>
|
<span class="pos-label">标记价</span>
|
||||||
<span class="pos-value" id="order-price-{{ o.id }}">-</span>
|
<span class="pos-value" id="order-price-{{ o.id }}">-</span>
|
||||||
@@ -447,6 +470,8 @@
|
|||||||
<span>计划基数: {{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
|
<span>计划基数: {{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
|
||||||
<span>杠杆: {{ o.leverage or '-' }}x</span>
|
<span>杠杆: {{ o.leverage or '-' }}x</span>
|
||||||
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
|
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
|
||||||
|
<span>开仓时间: {{ (o.opened_at or '-')[:16] }}</span>
|
||||||
|
<span>持仓时长: <span class="order-hold-duration" id="order-hold-duration-{{ o.id }}" data-order-opened-ms="{{ o.opened_at_ms or '' }}">—</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pos-ex-orders">
|
<div class="pos-ex-orders">
|
||||||
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
||||||
@@ -589,8 +614,7 @@
|
|||||||
<select name="type" required>
|
<select name="type" required>
|
||||||
<option value="箱体突破">箱体突破</option>
|
<option value="箱体突破">箱体突破</option>
|
||||||
<option value="收敛突破">收敛突破</option>
|
<option value="收敛突破">收敛突破</option>
|
||||||
<option value="关键阻力位">关键阻力位</option>
|
<option value="关键支撑阻力">关键支撑阻力</option>
|
||||||
<option value="关键支撑位">关键支撑位</option>
|
|
||||||
</select>
|
</select>
|
||||||
<select name="direction" required>
|
<select name="direction" required>
|
||||||
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
@@ -783,10 +807,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/instance_ui.js?v=1"></script>
|
<script src="/static/instance_ui.js?v=4"></script>
|
||||||
|
<script src="/static/instance_records_mobile.js?v=2"></script>
|
||||||
<script src="/static/time_close_ui.js?v=2"></script>
|
<script src="/static/time_close_ui.js?v=2"></script>
|
||||||
<script src="/static/ai_review_render.js?v=2"></script>
|
<script src="/static/ai_review_render.js?v=2"></script>
|
||||||
<script src="/static/form_submit_guard.js?v=2"></script>
|
<script src="/static/form_submit_guard.js?v=2"></script>
|
||||||
|
<script src="/static/manual_order_rr_preview.js?v=5"></script>
|
||||||
|
<script src="/static/strategy_roll.js?v=6"></script>
|
||||||
<script>
|
<script>
|
||||||
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
|
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
|
||||||
const JOURNAL_ENTRY_REASON_OTHER = {{ entry_reason_other_value | tojson }};
|
const JOURNAL_ENTRY_REASON_OTHER = {{ entry_reason_other_value | tojson }};
|
||||||
@@ -867,8 +894,10 @@ function setDetailBodyPlain(text){
|
|||||||
body.innerText = text || "";
|
body.innerText = text || "";
|
||||||
}
|
}
|
||||||
function setDetailBodyMarkdown(text){
|
function setDetailBodyMarkdown(text){
|
||||||
|
if(window.InstanceUI && InstanceUI.clearDetailActions) InstanceUI.clearDetailActions();
|
||||||
const body = document.getElementById("detailBody");
|
const body = document.getElementById("detailBody");
|
||||||
if(!body) return;
|
if(!body) return;
|
||||||
|
body.classList.remove("trade-record-detail-wrap", "journal-detail-meta");
|
||||||
if(window.AiReviewRender && AiReviewRender.setElementMarkdown){
|
if(window.AiReviewRender && AiReviewRender.setElementMarkdown){
|
||||||
body.classList.add("md-review");
|
body.classList.add("md-review");
|
||||||
AiReviewRender.setElementMarkdown(body, text || "");
|
AiReviewRender.setElementMarkdown(body, text || "");
|
||||||
@@ -1070,22 +1099,12 @@ function loadJournals(){
|
|||||||
const qs = listWindowQueryString();
|
const qs = listWindowQueryString();
|
||||||
fetch("/api/journals" + (qs ? "?" + qs : "")).then(r=>r.json()).then(data=>{
|
fetch("/api/journals" + (qs ? "?" + qs : "")).then(r=>r.json()).then(data=>{
|
||||||
Object.keys(journalCache).forEach(k=>delete journalCache[k]);
|
Object.keys(journalCache).forEach(k=>delete journalCache[k]);
|
||||||
let html="";
|
data.forEach(o=>{ journalCache[o.id] = o; });
|
||||||
data.forEach(o=>{
|
|
||||||
journalCache[o.id] = o;
|
|
||||||
const moodTags = (o.mood_issues || []).join(",") || "无";
|
|
||||||
html += `<div class="entry">
|
|
||||||
<div><strong>${o.coin||"-"} ${o.tf||"-"}</strong> | 盈亏:${o.pnl||"-"}U</div>
|
|
||||||
<div>开:${o.open_datetime||"-"} 平:${o.close_datetime||"-"} 持仓:${o.hold_duration||"-"}</div>
|
|
||||||
<div>心态标签:${moodTags}</div>
|
|
||||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:6px">
|
|
||||||
<button type="button" class="btn-del" style="border:none;cursor:pointer;background:#1f3a5a;color:#8fc8ff" onclick="openJournalDetail('${o.id}')">查看详情</button>
|
|
||||||
<button type="button" class="btn-del" onclick="deleteJournal('${o.id}')">删除</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
const box = document.getElementById("journal-list");
|
const box = document.getElementById("journal-list");
|
||||||
if(box){ box.innerHTML = html || "<div class='entry'>暂无数据</div>"; }
|
if(box){
|
||||||
|
const html = InstanceUI.renderJournalListHtml(data);
|
||||||
|
box.innerHTML = html || "<div class='journal-empty-msg'>暂无数据</div>";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1509,120 +1528,9 @@ if(journalForm){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncKeyMonitorFormFields(){
|
|
||||||
const typeEl = document.querySelector('#key-form [name="type"]');
|
|
||||||
const dirEl = document.getElementById("key-direction");
|
|
||||||
const modeEl = document.getElementById("key-sl-tp-mode");
|
|
||||||
const manualTp = document.getElementById("key-manual-tp");
|
|
||||||
const beWrap = document.getElementById("key-breakeven-wrap");
|
|
||||||
if(!typeEl) return;
|
|
||||||
const t = (typeEl.value || "").trim();
|
|
||||||
const autoTypes = new Set(["箱体突破","收敛突破"]);
|
|
||||||
const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]);
|
|
||||||
const fbTypes = new Set(["假突破"]);
|
|
||||||
const teTypes = new Set(["触价开仓"]);
|
|
||||||
const rsTypes = new Set(["关键阻力位","关键支撑位"]);
|
|
||||||
const showAuto = autoTypes.has(t);
|
|
||||||
const showFb = fbTypes.has(t);
|
|
||||||
const showTe = teTypes.has(t);
|
|
||||||
const showBe = showAuto || fibTypes.has(t) || showFb || showTe;
|
|
||||||
const showDir = !rsTypes.has(t);
|
|
||||||
const upperEl = document.getElementById("key-upper");
|
|
||||||
const lowerEl = document.getElementById("key-lower");
|
|
||||||
const fbPriceEl = document.getElementById("key-fb-price");
|
|
||||||
const teEntryEl = document.getElementById("key-trigger-entry");
|
|
||||||
const teSlEl = document.getElementById("key-trigger-sl");
|
|
||||||
const teTpEl = document.getElementById("key-trigger-tp");
|
|
||||||
if(dirEl){
|
|
||||||
dirEl.style.display = showDir ? "" : "none";
|
|
||||||
dirEl.required = showDir;
|
|
||||||
if(!showDir) dirEl.value = "";
|
|
||||||
}
|
|
||||||
if(modeEl) modeEl.style.display = showAuto ? "" : "none";
|
|
||||||
if(manualTp){
|
|
||||||
const trend = showAuto && modeEl && modeEl.value === "trend_manual";
|
|
||||||
manualTp.style.display = trend ? "" : "none";
|
|
||||||
manualTp.required = !!trend;
|
|
||||||
}
|
|
||||||
if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
|
|
||||||
if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
|
|
||||||
const hideBounds = showFb || showTe;
|
|
||||||
if(upperEl){
|
|
||||||
upperEl.style.display = hideBounds ? "none" : "";
|
|
||||||
upperEl.required = !hideBounds;
|
|
||||||
if(hideBounds) upperEl.value = "";
|
|
||||||
}
|
|
||||||
if(lowerEl){
|
|
||||||
lowerEl.style.display = hideBounds ? "none" : "";
|
|
||||||
lowerEl.required = !hideBounds;
|
|
||||||
if(hideBounds) lowerEl.value = "";
|
|
||||||
}
|
|
||||||
if(fbPriceEl){
|
|
||||||
fbPriceEl.style.display = showFb ? "" : "none";
|
|
||||||
fbPriceEl.required = showFb;
|
|
||||||
if(!showFb) fbPriceEl.value = "";
|
|
||||||
fbPriceEl.placeholder = (dirEl && dirEl.value === "short") ? "高点(阻力)" : ((dirEl && dirEl.value === "long") ? "低点(支撑)" : "做空填高点/做多填低点");
|
|
||||||
}
|
|
||||||
[teEntryEl, teSlEl, teTpEl].forEach((el)=>{
|
|
||||||
if(!el) return;
|
|
||||||
el.style.display = showTe ? "" : "none";
|
|
||||||
el.required = showTe;
|
|
||||||
if(!showTe) el.value = "";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const keyTypeSel = document.querySelector('#key-form [name="type"]');
|
|
||||||
const keyModeSel = document.getElementById("key-sl-tp-mode");
|
|
||||||
const keyDirSel = document.getElementById("key-direction");
|
|
||||||
if(keyTypeSel) keyTypeSel.addEventListener("change", syncKeyMonitorFormFields);
|
|
||||||
if(keyModeSel) keyModeSel.addEventListener("change", syncKeyMonitorFormFields);
|
|
||||||
if(keyDirSel) keyDirSel.addEventListener("change", syncKeyMonitorFormFields);
|
|
||||||
syncKeyMonitorFormFields();
|
|
||||||
if(window.TimeCloseUI){
|
if(window.TimeCloseUI){
|
||||||
TimeCloseUI.bindTimeCloseForm("key-time-close-cb", "key-time-close-hours", "key-time-close-wrap");
|
|
||||||
TimeCloseUI.bindTimeCloseForm("order-time-close-cb", "order-time-close-hours", "order-time-close-wrap");
|
TimeCloseUI.bindTimeCloseForm("order-time-close-cb", "order-time-close-hours", "order-time-close-wrap");
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyForm = document.getElementById("key-form");
|
|
||||||
if(keyForm){
|
|
||||||
keyForm.addEventListener("submit", (e)=>{
|
|
||||||
e.preventDefault();
|
|
||||||
if(window.FormSubmitGuard && FormSubmitGuard.isLocked(keyForm)) return;
|
|
||||||
const symbolEl = keyForm.querySelector('[name="symbol"]');
|
|
||||||
const symbol = (symbolEl ? symbolEl.value : "").trim();
|
|
||||||
if(!symbol){
|
|
||||||
alert("请先输入交易对");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const typeVal = (keyForm.querySelector('[name="type"]') || {}).value || "";
|
|
||||||
if(typeVal === "假突破"){
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
|
|
||||||
else keyForm.submit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.lock(keyForm, "校验排名中…");
|
|
||||||
fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`)
|
|
||||||
.then(r=>r.json().then(d=>({status:r.status, data:d})))
|
|
||||||
.then(({status,data})=>{
|
|
||||||
if(status >= 400 || !data.ok){
|
|
||||||
alert((data && data.msg) || "日成交量排名读取失败");
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rankMax = data.rank_max || 30;
|
|
||||||
if(!data.in_top30){
|
|
||||||
alert(`${data.symbol} 当前日成交量排名 ${data.rank}/${data.total},不在前${rankMax},已拦截。`);
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
|
|
||||||
else keyForm.submit();
|
|
||||||
})
|
|
||||||
.catch(()=>{
|
|
||||||
alert("日成交量排名检查失败,请稍后重试");
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
|
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if(document.getElementById("journal-list")) loadJournals();
|
if(document.getElementById("journal-list")) loadJournals();
|
||||||
@@ -1673,6 +1581,7 @@ function refreshOrderTpPreview(entryPx){
|
|||||||
const entry = entryPx != null && Number.isFinite(Number(entryPx)) ? Number(entryPx) : sl;
|
const entry = entryPx != null && Number.isFinite(Number(entryPx)) ? Number(entryPx) : sl;
|
||||||
const tp = calcTpFromFixedRr(direction, entry, sl, rr);
|
const tp = calcTpFromFixedRr(direction, entry, sl, rr);
|
||||||
preview.textContent = tp == null ? "预估止盈:—" : ("预估止盈:" + formatPriceForInput(tp));
|
preview.textContent = tp == null ? "预估止盈:—" : ("预估止盈:" + formatPriceForInput(tp));
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}
|
}
|
||||||
function calcClientRr(direction, entry, sl, tp){
|
function calcClientRr(direction, entry, sl, tp){
|
||||||
const e = Number(entry), s = Number(sl), t = Number(tp);
|
const e = Number(entry), s = Number(sl), t = Number(tp);
|
||||||
@@ -1759,6 +1668,13 @@ function submitTpslEntrust(){
|
|||||||
alert(data.msg || '已提交');
|
alert(data.msg || '已提交');
|
||||||
closeTpslEntrustModal();
|
closeTpslEntrustModal();
|
||||||
if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl);
|
if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl);
|
||||||
|
paintPlanTpslDisplay(orderId, data);
|
||||||
|
paintLatestRiskDisplay(orderId, data);
|
||||||
|
const rrEl = document.getElementById(`order-rr-${orderId}`);
|
||||||
|
if(rrEl){
|
||||||
|
const rr = data.display_rr_ratio != null && data.display_rr_ratio !== "" ? data.display_rr_ratio : data.planned_rr;
|
||||||
|
rrEl.innerText = formatRrRatio(rr);
|
||||||
|
}
|
||||||
refreshPriceSnapshotConditional();
|
refreshPriceSnapshotConditional();
|
||||||
}).catch(()=>alert('委托请求失败'));
|
}).catch(()=>alert('委托请求失败'));
|
||||||
}
|
}
|
||||||
@@ -1826,6 +1742,25 @@ function paintPlanTpslDisplay(orderId, snap){
|
|||||||
else if(tpDisp) card.setAttribute("data-plan-tp", tpDisp);
|
else if(tpDisp) card.setAttribute("data-plan-tp", tpDisp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function paintLatestRiskDisplay(orderId, snap){
|
||||||
|
const wrap = document.getElementById(`order-latest-risk-wrap-${orderId}`);
|
||||||
|
if(!wrap) return;
|
||||||
|
const v = snap && snap.latest_risk_amount;
|
||||||
|
const n = v != null && v !== "" ? Number(v) : NaN;
|
||||||
|
if(Number.isFinite(n)){
|
||||||
|
wrap.style.display = "inline-flex";
|
||||||
|
wrap.textContent = `最新风险: ${n.toFixed(2)}U`;
|
||||||
|
} else {
|
||||||
|
wrap.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function paintContractsDisplay(orderId, snap){
|
||||||
|
const el = document.getElementById(`order-contracts-${orderId}`);
|
||||||
|
if(!el || !snap) return;
|
||||||
|
const v = snap.contracts != null && snap.contracts !== "" ? snap.contracts : snap.order_amount;
|
||||||
|
const n = v != null && v !== "" ? Number(v) : NaN;
|
||||||
|
el.innerText = Number.isFinite(n) ? String(parseFloat(n.toFixed(4))) : "—";
|
||||||
|
}
|
||||||
|
|
||||||
function paintPriceTrend(el, key, value){
|
function paintPriceTrend(el, key, value){
|
||||||
if(!el) return;
|
if(!el) return;
|
||||||
@@ -1909,8 +1844,11 @@ function refreshPriceSnapshot(){
|
|||||||
}
|
}
|
||||||
const rrEl = document.getElementById(`order-rr-${o.id}`);
|
const rrEl = document.getElementById(`order-rr-${o.id}`);
|
||||||
if(rrEl){
|
if(rrEl){
|
||||||
rrEl.innerText = formatRrRatio(o.rr_ratio);
|
const rr = o.display_rr_ratio != null && o.display_rr_ratio !== "" ? o.display_rr_ratio : o.rr_ratio;
|
||||||
|
rrEl.innerText = formatRrRatio(rr);
|
||||||
}
|
}
|
||||||
|
paintLatestRiskDisplay(o.id, o);
|
||||||
|
paintContractsDisplay(o.id, o);
|
||||||
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
|
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
|
||||||
if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl);
|
if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl);
|
||||||
paintPlanTpslDisplay(o.id, o);
|
paintPlanTpslDisplay(o.id, o);
|
||||||
@@ -1945,6 +1883,7 @@ function refreshOrderDefaults(){
|
|||||||
}
|
}
|
||||||
const px = data.last_price || data.price;
|
const px = data.last_price || data.price;
|
||||||
if(px) refreshOrderTpPreview(px);
|
if(px) refreshOrderTpPreview(px);
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1961,9 +1900,25 @@ function refreshAccountSnapshot(){
|
|||||||
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
||||||
latestAvailableUsdt = Number(data.available_trading_usdt);
|
latestAvailableUsdt = Number(data.available_trading_usdt);
|
||||||
}
|
}
|
||||||
|
if (data.risk_status) {
|
||||||
|
const badge = document.getElementById("account-risk-badge");
|
||||||
|
if (badge) {
|
||||||
|
if (window.AccountRiskBadge) {
|
||||||
|
AccountRiskBadge.applyToElement(badge, data.risk_status);
|
||||||
|
} else {
|
||||||
|
const st = data.risk_status.status || "normal";
|
||||||
|
badge.className = "risk-status-badge risk-status-" + st;
|
||||||
|
badge.innerText = data.risk_status.status_label || "正常";
|
||||||
|
badge.title = data.risk_status.reason || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let canTradeText = "可开仓";
|
let canTradeText = "可开仓";
|
||||||
if (!data.can_trade) {
|
if (!data.can_trade) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
if (data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason) {
|
||||||
|
parts.push(data.risk_status.reason);
|
||||||
|
}
|
||||||
const ac = Number(data.active_count || 0);
|
const ac = Number(data.active_count || 0);
|
||||||
const max = Number(data.max_active_positions || {{ max_active_positions }});
|
const max = Number(data.max_active_positions || {{ max_active_positions }});
|
||||||
if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
|
if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
|
||||||
@@ -2024,12 +1979,16 @@ function toggleSltpMode(){
|
|||||||
slPctEl.required = pct;
|
slPctEl.required = pct;
|
||||||
tpPctEl.required = pct;
|
tpPctEl.required = pct;
|
||||||
refreshOrderTpPreview();
|
refreshOrderTpPreview();
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}
|
}
|
||||||
if(sltpModeEl){
|
if(sltpModeEl){
|
||||||
sltpModeEl.addEventListener("change", toggleSltpMode);
|
sltpModeEl.addEventListener("change", toggleSltpMode);
|
||||||
loadFixedRrPref();
|
loadFixedRrPref();
|
||||||
toggleSltpMode();
|
toggleSltpMode();
|
||||||
}
|
}
|
||||||
|
if(window.ManualOrderRrPreview){
|
||||||
|
ManualOrderRrPreview.wire({ minRr: MANUAL_MIN_PLANNED_RR });
|
||||||
|
}
|
||||||
["order-sl","order-fixed-rr","order-direction"].forEach(function(id){
|
["order-sl","order-fixed-rr","order-direction"].forEach(function(id){
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if(el) el.addEventListener("input", function(){ refreshOrderTpPreview(); });
|
if(el) el.addEventListener("input", function(){ refreshOrderTpPreview(); });
|
||||||
@@ -2037,6 +1996,7 @@ if(sltpModeEl){
|
|||||||
});
|
});
|
||||||
|
|
||||||
refreshAccountSnapshot();
|
refreshAccountSnapshot();
|
||||||
|
if (window.AccountRiskBadge) AccountRiskBadge.startTicker();
|
||||||
const _journalFormEl = document.getElementById("journal-form");
|
const _journalFormEl = document.getElementById("journal-form");
|
||||||
if(_journalFormEl){
|
if(_journalFormEl){
|
||||||
_journalFormEl.addEventListener("submit", function(ev){
|
_journalFormEl.addEventListener("submit", function(ev){
|
||||||
@@ -2174,10 +2134,41 @@ function refreshPriceSnapshotConditional(){
|
|||||||
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
|
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
|
||||||
paintPlanTpslDisplay(o.id, o);
|
paintPlanTpslDisplay(o.id, o);
|
||||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
||||||
|
const holdEl = document.getElementById(`order-hold-duration-${o.id}`);
|
||||||
|
if(holdEl && o.opened_at_ms != null && o.opened_at_ms !== ""){
|
||||||
|
holdEl.setAttribute("data-order-opened-ms", String(o.opened_at_ms));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
tickOrderHoldDurations();
|
||||||
}
|
}
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
function formatLiveHoldDurationFromMs(openedMs, nowMs){
|
||||||
|
if(openedMs == null || openedMs === "" || !Number.isFinite(Number(openedMs))) return "—";
|
||||||
|
const ms = Number(openedMs);
|
||||||
|
const now = (nowMs != null) ? nowMs : Date.now();
|
||||||
|
let sec = Math.floor((now - ms) / 1000);
|
||||||
|
if(sec < 0) sec = 0;
|
||||||
|
if(sec <= 0) return "0分钟";
|
||||||
|
const d = Math.floor(sec / 86400); sec %= 86400;
|
||||||
|
const h = Math.floor(sec / 3600); sec %= 3600;
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const parts = [];
|
||||||
|
if(d) parts.push(`${d}天`);
|
||||||
|
if(h) parts.push(`${h}小时`);
|
||||||
|
if(m || !parts.length) parts.push(`${m}分钟`);
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
function tickOrderHoldDurations(){
|
||||||
|
const now = Date.now();
|
||||||
|
document.querySelectorAll(".order-hold-duration[data-order-opened-ms]").forEach(el=>{
|
||||||
|
const ms = Number(el.getAttribute("data-order-opened-ms"));
|
||||||
|
if(!Number.isFinite(ms) || ms <= 0) return;
|
||||||
|
el.textContent = formatLiveHoldDurationFromMs(ms, now);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setInterval(tickOrderHoldDurations, 1000);
|
||||||
|
tickOrderHoldDurations();
|
||||||
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -120,14 +120,14 @@
|
|||||||
<div class="flash">{{ messages[0] }}</div>
|
<div class="flash">{{ messages[0] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<form method="POST">
|
<form method="POST" autocomplete="off">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>账号</label>
|
<label>账号</label>
|
||||||
<input type="text" name="username" required placeholder="请输入账号">
|
<input type="text" name="username" required placeholder="请输入账号" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>密码</label>
|
<label>密码</label>
|
||||||
<input type="password" name="password" required placeholder="请输入密码">
|
<input type="password" name="password" required placeholder="请输入密码" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">登录</button>
|
<button type="submit">登录</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -65,7 +65,8 @@
|
|||||||
| **收敛突破** | 同上(自动开仓类)。 |
|
| **收敛突破** | 同上(自动开仓类)。 |
|
||||||
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
|
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
|
||||||
| **关键支撑位** | 同上(仅提醒)。 |
|
| **关键支撑位** | 同上(仅提醒)。 |
|
||||||
| **触价开仓** | **不挂交易所限价**;标记价触达计划入场价后 **下一轮询市价开仓**(RR 门槛同关键位 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h**;全仓杠杆模式可用。 |
|
| **回调触价开仓** | **不挂交易所限价**;标记价回调触达 E 后 **下一轮询市价开仓**(RR 门槛同 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h** |
|
||||||
|
| **突破触价开仓** | **不挂交易所限价**;标记价 **穿越 E 立即市价开仓**;先触 SL/TP 侧失效;有效期 **24h** |
|
||||||
|
|
||||||
3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
|
3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
|
||||||
4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**。
|
4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**。
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
|
|||||||
|
|
||||||
| `close_reason` | 含义 |
|
| `close_reason` | 含义 |
|
||||||
|----------------|------|
|
|----------------|------|
|
||||||
|
| `box_opposite_break` | 标记价先突破反向边界(多:≤下沿;空:≥上沿) |
|
||||||
| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 |
|
| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 |
|
||||||
| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 |
|
| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 |
|
||||||
| `auto_opened` | RR 达标且市价开仓成功 |
|
| `auto_opened` | RR 达标且市价开仓成功 |
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ APP_PORT=5004
|
|||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
|
|
||||||
# 登录账号
|
# 登录账号
|
||||||
APP_USERNAME=dekun
|
APP_USERNAME=admin
|
||||||
# 登录密码(请改成你自己的强密码)
|
# 登录密码(请改成你自己的强密码)
|
||||||
APP_PASSWORD=ChangeMe123!
|
APP_PASSWORD=admin123
|
||||||
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
||||||
APP_AUTH_DISABLED=true
|
APP_AUTH_DISABLED=true
|
||||||
# --- 多账户交易中控 manual_trading_hub ---
|
# --- 多账户交易中控 manual_trading_hub ---
|
||||||
@@ -167,6 +167,16 @@ DAILY_OPEN_ALERT_THRESHOLD=5
|
|||||||
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
|
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
|
||||||
DAILY_OPEN_HARD_LIMIT=0
|
DAILY_OPEN_HARD_LIMIT=0
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 账户冷静期 / 日冻结风控(手动平仓、外部平仓、复盘情绪标签)
|
||||||
|
# 详见 docs/account-risk-cooldown.md
|
||||||
|
# =============================================================================
|
||||||
|
# RISK_CONTROL_ENABLED=true
|
||||||
|
# RISK_COOLING_HOURS_MANUAL=4
|
||||||
|
# RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
||||||
|
# RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
||||||
|
# RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
||||||
|
|
||||||
KEY_CONFIRM_BREAKOUT_BAR=-2
|
KEY_CONFIRM_BREAKOUT_BAR=-2
|
||||||
KEY_CONFIRM_BAR=-1
|
KEY_CONFIRM_BAR=-1
|
||||||
KEY_VOLUME_MA_BARS=20
|
KEY_VOLUME_MA_BARS=20
|
||||||
|
|||||||
+569
-258
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<script src="/static/instance_theme.js?v=6"></script>
|
<script src="/static/instance_theme.js?v=46"></script>
|
||||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=1">
|
<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>
|
||||||
|
|
||||||
<meta name="theme-color" content="#0b0d14">
|
<meta name="theme-color" content="#0b0d14">
|
||||||
<meta name="apple-mobile-web-app-title" content="监控">
|
<meta name="apple-mobile-web-app-title" content="监控">
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
|
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
|
||||||
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
|
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
|
||||||
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
|
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
|
||||||
|
.header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center}
|
||||||
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
|
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
|
||||||
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
|
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
|
||||||
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
|
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
|
||||||
@@ -34,6 +37,12 @@
|
|||||||
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
|
.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}
|
.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}
|
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
|
||||||
|
.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}
|
||||||
|
.order-preview-risk{color:#ff6b6b}.order-preview-risk strong{color:#ff8f8f;font-weight:600}
|
||||||
|
.order-preview-profit{color:#4cd97f}.order-preview-profit strong{color:#6ee7a0;font-weight:600}
|
||||||
|
.order-preview-rr{color:#cfd3ef}.order-preview-rr strong{font-weight:600;color:#dbe4ff}
|
||||||
|
.order-preview-rr.order-preview-rr-low strong{color:#ff8f8f}
|
||||||
|
.order-preview-rr.order-preview-rr-ok strong{color:#8fc8ff}
|
||||||
.form-row > button,.form-row > label{flex:0 0 auto}
|
.form-row > button,.form-row > label{flex:0 0 auto}
|
||||||
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
||||||
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
|
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
|
||||||
@@ -234,10 +243,17 @@
|
|||||||
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
|
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
|
||||||
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=14">
|
<link rel="stylesheet" href="/static/instance_theme.css?v=48">
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body data-page="{{ page }}">
|
<body
|
||||||
|
data-page="{{ page }}"
|
||||||
|
data-risk-percent="{{ risk_percent }}"
|
||||||
|
data-position-sizing-mode="{{ position_sizing_mode }}"
|
||||||
|
data-btc-leverage="{{ btc_leverage }}"
|
||||||
|
data-alt-leverage="{{ alt_leverage }}"
|
||||||
|
data-full-margin-buffer="{{ full_margin_buffer_ratio }}"
|
||||||
|
>
|
||||||
{% macro period_stats(title, s) %}
|
{% macro period_stats(title, s) %}
|
||||||
<div class="stats-period-block">
|
<div class="stats-period-block">
|
||||||
<h3>{{ title }}</h3>
|
<h3>{{ title }}</h3>
|
||||||
@@ -262,6 +278,7 @@
|
|||||||
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div class="exchange-tag">{{ exchange_display }}</div>
|
<div class="exchange-tag">{{ exchange_display }}</div>
|
||||||
|
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}" data-status-label="{{ risk_status.status_label|default('正常') }}"{% if risk_status.freeze_until_ms %} data-freeze-until-ms="{{ risk_status.freeze_until_ms }}"{% endif %}>{{ risk_status.status_label|default('正常') }}</span>
|
||||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||||
@@ -365,7 +382,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<button type="submit">手动划转</button>
|
<button type="submit">手动划转</button>
|
||||||
</form>
|
</form>
|
||||||
<form id="add-order-form" action="/add_order" method="post" class="form-row">
|
<form id="add-order-form" action="/add_order" method="post" class="form-row" data-risk-percent="{{ risk_percent }}">
|
||||||
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
||||||
<select id="order-direction" name="direction" required>
|
<select id="order-direction" name="direction" required>
|
||||||
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
@@ -407,6 +424,7 @@
|
|||||||
<input id="order-tp-pct" name="tp_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>
|
<button type="submit">{{ open_position_button_label }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% include 'order_plan_preview_bar.html' %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="margin-bottom:8px">实时持仓</h2>
|
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||||
@@ -440,6 +458,7 @@
|
|||||||
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
|
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
|
||||||
<span class="pos-meta-item">风格: {{ o.trade_style or 'trend' }}</span>
|
<span class="pos-meta-item">风格: {{ o.trade_style or 'trend' }}</span>
|
||||||
<span class="pos-meta-item">风险: {% if position_sizing_mode == 'full_margin' %}{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% else %}{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% endif %}</span>
|
<span class="pos-meta-item">风险: {% if position_sizing_mode == 'full_margin' %}{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% else %}{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% endif %}</span>
|
||||||
|
<span class="pos-meta-item" id="order-latest-risk-wrap-{{ o.id }}" style="display:none">最新风险: —</span>
|
||||||
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}">
|
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}">
|
||||||
{% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
|
{% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
|
||||||
</span>
|
</span>
|
||||||
@@ -462,6 +481,10 @@
|
|||||||
<span class="pos-label">盈亏比</span>
|
<span class="pos-label">盈亏比</span>
|
||||||
<span class="pos-value" id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %}</span>
|
<span class="pos-value" id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pos-cell">
|
||||||
|
<span class="pos-label">张数</span>
|
||||||
|
<span class="pos-value" id="order-contracts-{{ o.id }}">{% if o.order_amount is not none %}{{ '%g'|format(o.order_amount) }}{% else %}—{% endif %}</span>
|
||||||
|
</div>
|
||||||
<div class="pos-cell">
|
<div class="pos-cell">
|
||||||
<span class="pos-label">标记价</span>
|
<span class="pos-label">标记价</span>
|
||||||
<span class="pos-value" id="order-price-{{ o.id }}">-</span>
|
<span class="pos-value" id="order-price-{{ o.id }}">-</span>
|
||||||
@@ -476,6 +499,8 @@
|
|||||||
<span>计划基数: {{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
|
<span>计划基数: {{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
|
||||||
<span>杠杆: {{ o.leverage or '-' }}x</span>
|
<span>杠杆: {{ o.leverage or '-' }}x</span>
|
||||||
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
|
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
|
||||||
|
<span>开仓时间: {{ (o.opened_at or '-')[:16] }}</span>
|
||||||
|
<span>持仓时长: <span class="order-hold-duration" id="order-hold-duration-{{ o.id }}" data-order-opened-ms="{{ o.opened_at_ms or '' }}">—</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pos-ex-orders">
|
<div class="pos-ex-orders">
|
||||||
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
||||||
@@ -618,8 +643,7 @@
|
|||||||
<select name="type" required>
|
<select name="type" required>
|
||||||
<option value="箱体突破">箱体突破</option>
|
<option value="箱体突破">箱体突破</option>
|
||||||
<option value="收敛突破">收敛突破</option>
|
<option value="收敛突破">收敛突破</option>
|
||||||
<option value="关键阻力位">关键阻力位</option>
|
<option value="关键支撑阻力">关键支撑阻力</option>
|
||||||
<option value="关键支撑位">关键支撑位</option>
|
|
||||||
</select>
|
</select>
|
||||||
<select name="direction" required>
|
<select name="direction" required>
|
||||||
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
@@ -812,10 +836,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/instance_ui.js?v=1"></script>
|
<script src="/static/instance_ui.js?v=4"></script>
|
||||||
|
<script src="/static/instance_records_mobile.js?v=2"></script>
|
||||||
<script src="/static/time_close_ui.js?v=2"></script>
|
<script src="/static/time_close_ui.js?v=2"></script>
|
||||||
<script src="/static/ai_review_render.js?v=2"></script>
|
<script src="/static/ai_review_render.js?v=2"></script>
|
||||||
<script src="/static/form_submit_guard.js?v=2"></script>
|
<script src="/static/form_submit_guard.js?v=2"></script>
|
||||||
|
<script src="/static/manual_order_rr_preview.js?v=5"></script>
|
||||||
|
<script src="/static/strategy_roll.js?v=6"></script>
|
||||||
<script>
|
<script>
|
||||||
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
|
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
|
||||||
const JOURNAL_ENTRY_REASON_OTHER = {{ entry_reason_other_value | tojson }};
|
const JOURNAL_ENTRY_REASON_OTHER = {{ entry_reason_other_value | tojson }};
|
||||||
@@ -896,8 +923,10 @@ function setDetailBodyPlain(text){
|
|||||||
body.innerText = text || "";
|
body.innerText = text || "";
|
||||||
}
|
}
|
||||||
function setDetailBodyMarkdown(text){
|
function setDetailBodyMarkdown(text){
|
||||||
|
if(window.InstanceUI && InstanceUI.clearDetailActions) InstanceUI.clearDetailActions();
|
||||||
const body = document.getElementById("detailBody");
|
const body = document.getElementById("detailBody");
|
||||||
if(!body) return;
|
if(!body) return;
|
||||||
|
body.classList.remove("trade-record-detail-wrap", "journal-detail-meta");
|
||||||
if(window.AiReviewRender && AiReviewRender.setElementMarkdown){
|
if(window.AiReviewRender && AiReviewRender.setElementMarkdown){
|
||||||
body.classList.add("md-review");
|
body.classList.add("md-review");
|
||||||
AiReviewRender.setElementMarkdown(body, text || "");
|
AiReviewRender.setElementMarkdown(body, text || "");
|
||||||
@@ -1099,22 +1128,12 @@ function loadJournals(){
|
|||||||
const qs = listWindowQueryString();
|
const qs = listWindowQueryString();
|
||||||
fetch("/api/journals" + (qs ? "?" + qs : "")).then(r=>r.json()).then(data=>{
|
fetch("/api/journals" + (qs ? "?" + qs : "")).then(r=>r.json()).then(data=>{
|
||||||
Object.keys(journalCache).forEach(k=>delete journalCache[k]);
|
Object.keys(journalCache).forEach(k=>delete journalCache[k]);
|
||||||
let html="";
|
data.forEach(o=>{ journalCache[o.id] = o; });
|
||||||
data.forEach(o=>{
|
|
||||||
journalCache[o.id] = o;
|
|
||||||
const moodTags = (o.mood_issues || []).join(",") || "无";
|
|
||||||
html += `<div class="entry">
|
|
||||||
<div><strong>${o.coin||"-"} ${o.tf||"-"}</strong> | 盈亏:${o.pnl||"-"}U</div>
|
|
||||||
<div>开:${o.open_datetime||"-"} 平:${o.close_datetime||"-"} 持仓:${o.hold_duration||"-"}</div>
|
|
||||||
<div>心态标签:${moodTags}</div>
|
|
||||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:6px">
|
|
||||||
<button type="button" class="btn-del" style="border:none;cursor:pointer;background:#1f3a5a;color:#8fc8ff" onclick="openJournalDetail('${o.id}')">查看详情</button>
|
|
||||||
<button type="button" class="btn-del" onclick="deleteJournal('${o.id}')">删除</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
const box = document.getElementById("journal-list");
|
const box = document.getElementById("journal-list");
|
||||||
if(box){ box.innerHTML = html || "<div class='entry'>暂无数据</div>"; }
|
if(box){
|
||||||
|
const html = InstanceUI.renderJournalListHtml(data);
|
||||||
|
box.innerHTML = html || "<div class='journal-empty-msg'>暂无数据</div>";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1538,121 +1557,9 @@ if(journalForm){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncKeyMonitorFormFields(){
|
|
||||||
const typeEl = document.querySelector('#key-form [name="type"]');
|
|
||||||
const dirEl = document.getElementById("key-direction");
|
|
||||||
const modeEl = document.getElementById("key-sl-tp-mode");
|
|
||||||
const manualTp = document.getElementById("key-manual-tp");
|
|
||||||
const beWrap = document.getElementById("key-breakeven-wrap");
|
|
||||||
if(!typeEl) return;
|
|
||||||
const t = (typeEl.value || "").trim();
|
|
||||||
const autoTypes = new Set(["箱体突破","收敛突破"]);
|
|
||||||
const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]);
|
|
||||||
const fbTypes = new Set(["假突破"]);
|
|
||||||
const teTypes = new Set(["触价开仓"]);
|
|
||||||
const rsTypes = new Set(["关键阻力位","关键支撑位"]);
|
|
||||||
const showAuto = autoTypes.has(t);
|
|
||||||
const showFb = fbTypes.has(t);
|
|
||||||
const showTe = teTypes.has(t);
|
|
||||||
const showBe = showAuto || fibTypes.has(t) || showFb || showTe;
|
|
||||||
const showDir = !rsTypes.has(t);
|
|
||||||
const upperEl = document.getElementById("key-upper");
|
|
||||||
const lowerEl = document.getElementById("key-lower");
|
|
||||||
const fbPriceEl = document.getElementById("key-fb-price");
|
|
||||||
const teEntryEl = document.getElementById("key-trigger-entry");
|
|
||||||
const teSlEl = document.getElementById("key-trigger-sl");
|
|
||||||
const teTpEl = document.getElementById("key-trigger-tp");
|
|
||||||
if(dirEl){
|
|
||||||
dirEl.style.display = showDir ? "" : "none";
|
|
||||||
dirEl.required = showDir;
|
|
||||||
if(!showDir) dirEl.value = "";
|
|
||||||
}
|
|
||||||
if(modeEl) modeEl.style.display = showAuto ? "" : "none";
|
|
||||||
if(manualTp){
|
|
||||||
const trend = showAuto && modeEl && modeEl.value === "trend_manual";
|
|
||||||
manualTp.style.display = trend ? "" : "none";
|
|
||||||
manualTp.required = !!trend;
|
|
||||||
}
|
|
||||||
if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
|
|
||||||
if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
|
|
||||||
const hideBounds = showFb || showTe;
|
|
||||||
if(upperEl){
|
|
||||||
upperEl.style.display = hideBounds ? "none" : "";
|
|
||||||
upperEl.required = !hideBounds;
|
|
||||||
if(hideBounds) upperEl.value = "";
|
|
||||||
}
|
|
||||||
if(lowerEl){
|
|
||||||
lowerEl.style.display = hideBounds ? "none" : "";
|
|
||||||
lowerEl.required = !hideBounds;
|
|
||||||
if(hideBounds) lowerEl.value = "";
|
|
||||||
}
|
|
||||||
if(fbPriceEl){
|
|
||||||
fbPriceEl.style.display = showFb ? "" : "none";
|
|
||||||
fbPriceEl.required = showFb;
|
|
||||||
if(!showFb) fbPriceEl.value = "";
|
|
||||||
fbPriceEl.placeholder = (dirEl && dirEl.value === "short") ? "高点(阻力)" : ((dirEl && dirEl.value === "long") ? "低点(支撑)" : "做空填高点/做多填低点");
|
|
||||||
}
|
|
||||||
[teEntryEl, teSlEl, teTpEl].forEach((el)=>{
|
|
||||||
if(!el) return;
|
|
||||||
el.style.display = showTe ? "" : "none";
|
|
||||||
el.required = showTe;
|
|
||||||
if(!showTe) el.value = "";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const keyTypeSel = document.querySelector('#key-form [name="type"]');
|
|
||||||
const keyModeSel = document.getElementById("key-sl-tp-mode");
|
|
||||||
const keyDirSel = document.getElementById("key-direction");
|
|
||||||
if(keyTypeSel) keyTypeSel.addEventListener("change", syncKeyMonitorFormFields);
|
|
||||||
if(keyModeSel) keyModeSel.addEventListener("change", syncKeyMonitorFormFields);
|
|
||||||
if(keyDirSel) keyDirSel.addEventListener("change", syncKeyMonitorFormFields);
|
|
||||||
syncKeyMonitorFormFields();
|
|
||||||
if(window.TimeCloseUI){
|
if(window.TimeCloseUI){
|
||||||
TimeCloseUI.bindTimeCloseForm("key-time-close-cb", "key-time-close-hours", "key-time-close-wrap");
|
|
||||||
TimeCloseUI.bindTimeCloseForm("order-time-close-cb", "order-time-close-hours", "order-time-close-wrap");
|
TimeCloseUI.bindTimeCloseForm("order-time-close-cb", "order-time-close-hours", "order-time-close-wrap");
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyForm = document.getElementById("key-form");
|
|
||||||
if(keyForm){
|
|
||||||
keyForm.addEventListener("submit", (e)=>{
|
|
||||||
e.preventDefault();
|
|
||||||
if(window.FormSubmitGuard && FormSubmitGuard.isLocked(keyForm)) return;
|
|
||||||
const symbolEl = keyForm.querySelector('[name="symbol"]');
|
|
||||||
const symbol = (symbolEl ? symbolEl.value : "").trim();
|
|
||||||
if(!symbol){
|
|
||||||
alert("请先输入交易对");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const typeVal = (keyForm.querySelector('[name="type"]') || {}).value || "";
|
|
||||||
if(typeVal === "假突破"){
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
|
|
||||||
else keyForm.submit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.lock(keyForm, "校验排名中…");
|
|
||||||
fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`)
|
|
||||||
.then(r=>r.json().then(d=>({status:r.status, data:d})))
|
|
||||||
.then(({status,data})=>{
|
|
||||||
if(status >= 400 || !data.ok){
|
|
||||||
alert((data && data.msg) || "日成交量排名读取失败");
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rankMax = data.rank_max || 30;
|
|
||||||
const inTop = data.in_top != null ? data.in_top : data.in_top30;
|
|
||||||
if(data.rank == null || !inTop){
|
|
||||||
alert(`${data.symbol} 当前24h成交额排名 ${data.rank == null ? "—" : data.rank}/${data.total},不在前${rankMax},已拦截。`);
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
|
|
||||||
else keyForm.submit();
|
|
||||||
})
|
|
||||||
.catch(()=>{
|
|
||||||
alert("日成交量排名检查失败,请稍后重试");
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
|
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if(document.getElementById("journal-list")) loadJournals();
|
if(document.getElementById("journal-list")) loadJournals();
|
||||||
@@ -1703,6 +1610,7 @@ function refreshOrderTpPreview(entryPx){
|
|||||||
const entry = entryPx != null && Number.isFinite(Number(entryPx)) ? Number(entryPx) : sl;
|
const entry = entryPx != null && Number.isFinite(Number(entryPx)) ? Number(entryPx) : sl;
|
||||||
const tp = calcTpFromFixedRr(direction, entry, sl, rr);
|
const tp = calcTpFromFixedRr(direction, entry, sl, rr);
|
||||||
preview.textContent = tp == null ? "预估止盈:—" : ("预估止盈:" + formatPriceForInput(tp));
|
preview.textContent = tp == null ? "预估止盈:—" : ("预估止盈:" + formatPriceForInput(tp));
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}
|
}
|
||||||
function calcClientRr(direction, entry, sl, tp){
|
function calcClientRr(direction, entry, sl, tp){
|
||||||
const e = Number(entry), s = Number(sl), t = Number(tp);
|
const e = Number(entry), s = Number(sl), t = Number(tp);
|
||||||
@@ -1789,6 +1697,13 @@ function submitTpslEntrust(){
|
|||||||
alert(data.msg || '已提交');
|
alert(data.msg || '已提交');
|
||||||
closeTpslEntrustModal();
|
closeTpslEntrustModal();
|
||||||
if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl);
|
if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl);
|
||||||
|
paintPlanTpslDisplay(orderId, data);
|
||||||
|
paintLatestRiskDisplay(orderId, data);
|
||||||
|
const rrEl = document.getElementById(`order-rr-${orderId}`);
|
||||||
|
if(rrEl){
|
||||||
|
const rr = data.display_rr_ratio != null && data.display_rr_ratio !== "" ? data.display_rr_ratio : data.planned_rr;
|
||||||
|
rrEl.innerText = formatRrRatio(rr);
|
||||||
|
}
|
||||||
refreshPriceSnapshotConditional();
|
refreshPriceSnapshotConditional();
|
||||||
}).catch(()=>alert('委托请求失败'));
|
}).catch(()=>alert('委托请求失败'));
|
||||||
}
|
}
|
||||||
@@ -1856,6 +1771,25 @@ function paintPlanTpslDisplay(orderId, snap){
|
|||||||
else if(tpDisp) card.setAttribute("data-plan-tp", tpDisp);
|
else if(tpDisp) card.setAttribute("data-plan-tp", tpDisp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function paintLatestRiskDisplay(orderId, snap){
|
||||||
|
const wrap = document.getElementById(`order-latest-risk-wrap-${orderId}`);
|
||||||
|
if(!wrap) return;
|
||||||
|
const v = snap && snap.latest_risk_amount;
|
||||||
|
const n = v != null && v !== "" ? Number(v) : NaN;
|
||||||
|
if(Number.isFinite(n)){
|
||||||
|
wrap.style.display = "inline-flex";
|
||||||
|
wrap.textContent = `最新风险: ${n.toFixed(2)}U`;
|
||||||
|
} else {
|
||||||
|
wrap.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function paintContractsDisplay(orderId, snap){
|
||||||
|
const el = document.getElementById(`order-contracts-${orderId}`);
|
||||||
|
if(!el || !snap) return;
|
||||||
|
const v = snap.contracts != null && snap.contracts !== "" ? snap.contracts : snap.order_amount;
|
||||||
|
const n = v != null && v !== "" ? Number(v) : NaN;
|
||||||
|
el.innerText = Number.isFinite(n) ? String(parseFloat(n.toFixed(4))) : "—";
|
||||||
|
}
|
||||||
|
|
||||||
function paintPriceTrend(el, key, value){
|
function paintPriceTrend(el, key, value){
|
||||||
if(!el) return;
|
if(!el) return;
|
||||||
@@ -1939,8 +1873,11 @@ function refreshPriceSnapshot(){
|
|||||||
}
|
}
|
||||||
const rrEl = document.getElementById(`order-rr-${o.id}`);
|
const rrEl = document.getElementById(`order-rr-${o.id}`);
|
||||||
if(rrEl){
|
if(rrEl){
|
||||||
rrEl.innerText = formatRrRatio(o.rr_ratio);
|
const rr = o.display_rr_ratio != null && o.display_rr_ratio !== "" ? o.display_rr_ratio : o.rr_ratio;
|
||||||
|
rrEl.innerText = formatRrRatio(rr);
|
||||||
}
|
}
|
||||||
|
paintLatestRiskDisplay(o.id, o);
|
||||||
|
paintContractsDisplay(o.id, o);
|
||||||
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
|
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
|
||||||
if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl);
|
if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl);
|
||||||
paintPlanTpslDisplay(o.id, o);
|
paintPlanTpslDisplay(o.id, o);
|
||||||
@@ -1975,6 +1912,7 @@ function refreshOrderDefaults(){
|
|||||||
}
|
}
|
||||||
const px = data.last_price || data.price;
|
const px = data.last_price || data.price;
|
||||||
if(px) refreshOrderTpPreview(px);
|
if(px) refreshOrderTpPreview(px);
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1991,9 +1929,25 @@ function refreshAccountSnapshot(){
|
|||||||
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
||||||
latestAvailableUsdt = Number(data.available_trading_usdt);
|
latestAvailableUsdt = Number(data.available_trading_usdt);
|
||||||
}
|
}
|
||||||
|
if (data.risk_status) {
|
||||||
|
const badge = document.getElementById("account-risk-badge");
|
||||||
|
if (badge) {
|
||||||
|
if (window.AccountRiskBadge) {
|
||||||
|
AccountRiskBadge.applyToElement(badge, data.risk_status);
|
||||||
|
} else {
|
||||||
|
const st = data.risk_status.status || "normal";
|
||||||
|
badge.className = "risk-status-badge risk-status-" + st;
|
||||||
|
badge.innerText = data.risk_status.status_label || "正常";
|
||||||
|
badge.title = data.risk_status.reason || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let canTradeText = "可开仓";
|
let canTradeText = "可开仓";
|
||||||
if(!data.can_trade){
|
if(!data.can_trade){
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
if (data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason) {
|
||||||
|
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}`);
|
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 }});
|
const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }});
|
||||||
const opens = Number(data.opens_today);
|
const opens = Number(data.opens_today);
|
||||||
@@ -2077,12 +2031,16 @@ function toggleSltpMode(){
|
|||||||
slPctEl.required = pct;
|
slPctEl.required = pct;
|
||||||
tpPctEl.required = pct;
|
tpPctEl.required = pct;
|
||||||
refreshOrderTpPreview();
|
refreshOrderTpPreview();
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}
|
}
|
||||||
if(sltpModeEl){
|
if(sltpModeEl){
|
||||||
sltpModeEl.addEventListener("change", toggleSltpMode);
|
sltpModeEl.addEventListener("change", toggleSltpMode);
|
||||||
loadFixedRrPref();
|
loadFixedRrPref();
|
||||||
toggleSltpMode();
|
toggleSltpMode();
|
||||||
}
|
}
|
||||||
|
if(window.ManualOrderRrPreview){
|
||||||
|
ManualOrderRrPreview.wire({ minRr: MANUAL_MIN_PLANNED_RR });
|
||||||
|
}
|
||||||
["order-sl","order-fixed-rr","order-direction"].forEach(function(id){
|
["order-sl","order-fixed-rr","order-direction"].forEach(function(id){
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if(el) el.addEventListener("input", function(){ refreshOrderTpPreview(); });
|
if(el) el.addEventListener("input", function(){ refreshOrderTpPreview(); });
|
||||||
@@ -2090,6 +2048,7 @@ if(sltpModeEl){
|
|||||||
});
|
});
|
||||||
|
|
||||||
refreshAccountSnapshot();
|
refreshAccountSnapshot();
|
||||||
|
if (window.AccountRiskBadge) AccountRiskBadge.startTicker();
|
||||||
const _journalFormEl = document.getElementById("journal-form");
|
const _journalFormEl = document.getElementById("journal-form");
|
||||||
if(_journalFormEl){
|
if(_journalFormEl){
|
||||||
_journalFormEl.addEventListener("submit", function(ev){
|
_journalFormEl.addEventListener("submit", function(ev){
|
||||||
@@ -2227,10 +2186,41 @@ function refreshPriceSnapshotConditional(){
|
|||||||
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
|
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
|
||||||
paintPlanTpslDisplay(o.id, o);
|
paintPlanTpslDisplay(o.id, o);
|
||||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
||||||
|
const holdEl = document.getElementById(`order-hold-duration-${o.id}`);
|
||||||
|
if(holdEl && o.opened_at_ms != null && o.opened_at_ms !== ""){
|
||||||
|
holdEl.setAttribute("data-order-opened-ms", String(o.opened_at_ms));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
tickOrderHoldDurations();
|
||||||
}
|
}
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
function formatLiveHoldDurationFromMs(openedMs, nowMs){
|
||||||
|
if(openedMs == null || openedMs === "" || !Number.isFinite(Number(openedMs))) return "—";
|
||||||
|
const ms = Number(openedMs);
|
||||||
|
const now = (nowMs != null) ? nowMs : Date.now();
|
||||||
|
let sec = Math.floor((now - ms) / 1000);
|
||||||
|
if(sec < 0) sec = 0;
|
||||||
|
if(sec <= 0) return "0分钟";
|
||||||
|
const d = Math.floor(sec / 86400); sec %= 86400;
|
||||||
|
const h = Math.floor(sec / 3600); sec %= 3600;
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const parts = [];
|
||||||
|
if(d) parts.push(`${d}天`);
|
||||||
|
if(h) parts.push(`${h}小时`);
|
||||||
|
if(m || !parts.length) parts.push(`${m}分钟`);
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
function tickOrderHoldDurations(){
|
||||||
|
const now = Date.now();
|
||||||
|
document.querySelectorAll(".order-hold-duration[data-order-opened-ms]").forEach(el=>{
|
||||||
|
const ms = Number(el.getAttribute("data-order-opened-ms"));
|
||||||
|
if(!Number.isFinite(ms) || ms <= 0) return;
|
||||||
|
el.textContent = formatLiveHoldDurationFromMs(ms, now);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setInterval(tickOrderHoldDurations, 1000);
|
||||||
|
tickOrderHoldDurations();
|
||||||
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -109,14 +109,14 @@
|
|||||||
<div class="flash">{{ messages[0] }}</div>
|
<div class="flash">{{ messages[0] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<form method="POST">
|
<form method="POST" autocomplete="off">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>账号</label>
|
<label>账号</label>
|
||||||
<input type="text" name="username" required placeholder="请输入账号">
|
<input type="text" name="username" required placeholder="请输入账号" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>密码</label>
|
<label>密码</label>
|
||||||
<input type="password" name="password" required placeholder="请输入密码">
|
<input type="password" name="password" required placeholder="请输入密码" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">登录</button>
|
<button type="submit">登录</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -65,7 +65,8 @@
|
|||||||
| **收敛突破** | 同上(自动开仓类)。 |
|
| **收敛突破** | 同上(自动开仓类)。 |
|
||||||
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
|
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
|
||||||
| **关键支撑位** | 同上(仅提醒)。 |
|
| **关键支撑位** | 同上(仅提醒)。 |
|
||||||
| **触价开仓** | **不挂交易所限价**;标记价触达计划入场价后 **下一轮询市价开仓**(RR 门槛同关键位 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h**;全仓杠杆模式可用。 |
|
| **回调触价开仓** | **不挂交易所限价**;标记价回调触达 E 后 **下一轮询市价开仓**(RR 门槛同 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h** |
|
||||||
|
| **突破触价开仓** | **不挂交易所限价**;标记价 **穿越 E 立即市价开仓**;先触 SL/TP 侧失效;有效期 **24h** |
|
||||||
|
|
||||||
3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
|
3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
|
||||||
4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**。
|
4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**。
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
|
|||||||
| **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` |
|
| **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` |
|
||||||
| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
|
| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
|
||||||
| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) |
|
| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) |
|
||||||
| **触价开仓** | **必选** 多/空 | **程序盯价 → 触 E 后市价** | 见下文 **§六** |
|
| **回调触价开仓** | **必选** 多/空 | **程序盯价 → 回调触 E 后市价** | 见下文 **§四** |
|
||||||
|
| **突破触价开仓** | **必选** 多/空 | **程序盯价 → 穿越 E 立即市价** | 见下文 **§四** |
|
||||||
|
|
||||||
**添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位)。
|
**添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位)。
|
||||||
|
|
||||||
@@ -111,6 +112,7 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
|
|||||||
|
|
||||||
| `close_reason` | 含义 |
|
| `close_reason` | 含义 |
|
||||||
|----------------|------|
|
|----------------|------|
|
||||||
|
| `box_opposite_break` | 标记价先突破反向边界(多:≤下沿;空:≥上沿) |
|
||||||
| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 |
|
| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 |
|
||||||
| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 |
|
| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 |
|
||||||
| `auto_opened` | RR 达标且市价开仓成功 |
|
| `auto_opened` | RR 达标且市价开仓成功 |
|
||||||
@@ -118,25 +120,31 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、触价开仓(程序触价,无交易所挂单)
|
## 四、回调 / 突破触价开仓(程序触价,无交易所挂单)
|
||||||
|
|
||||||
### 4.1 录入
|
### 4.1 录入
|
||||||
|
|
||||||
- 类型选 **触价开仓**;方向必选多/空。
|
- **回调触价开仓**:方向必选多/空;填写 **计划入场价 E**、**止损 SL**、**止盈 TP**(做多须 `SL < E < TP`)。
|
||||||
- 填写 **计划入场价 E**、**止损 SL**、**止盈 TP**(做多须 `SL < E < TP`)。
|
- **突破触价开仓**:同上;添加时当前价须在突破方向一侧(做多:价低于 E;做空:价高于 E)。
|
||||||
- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5,与箱体/斐波相同)。
|
- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5)。
|
||||||
- 可选移动保本、时间平仓;**全仓杠杆模式**下可用(页面隐藏箱体/收敛/斐波/假突破)。
|
- 可选移动保本、时间平仓;**全仓杠杆模式**下可用。
|
||||||
|
|
||||||
### 4.2 触发与结案
|
### 4.2 触发与结案
|
||||||
|
|
||||||
- 轮询标记价:做多 `标记价 ≤ E`、做空 `标记价 ≥ E` → **下一轮询市价开仓**,挂交易所 TP/SL,进下单监控。
|
| 类型 | 触发条件(标记价) |
|
||||||
- 未成交前标记价先触 **TP 侧** → `trigger_tp_invalidate`;**24h** 未触发 → `trigger_entry_expired`。
|
|------|-------------------|
|
||||||
|
| **回调触价** | 做多 `≤ E`;做空 `≥ E` → 下一轮询市价开仓 |
|
||||||
|
| **突破触价** | 做多**向上穿越** E;做空**向下穿越** E → **立即**市价开仓 |
|
||||||
|
|
||||||
|
- 未成交前标记价先触 **TP 侧** → `trigger_tp_invalidate`。
|
||||||
|
- **突破触价**另:未穿越 E 先触 **SL 侧** → `trigger_sl_invalidate`。
|
||||||
|
- **24h** 未触发 → `trigger_entry_expired`。
|
||||||
- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`。
|
- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`。
|
||||||
|
|
||||||
### 4.3 计仓与占位
|
### 4.3 计仓与占位
|
||||||
|
|
||||||
- **以损定仓**:按 E、SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x、其它 5x。
|
- **以损定仓**:按 E、SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x、其它 5x。
|
||||||
- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条。
|
- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条触价监控(含回调/突破)。
|
||||||
|
|
||||||
共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`。
|
共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# 账户冷静期 / 日冻结风控
|
||||||
|
|
||||||
|
四所实例(币安 / OKX / Gate / Gate 趋势)共用 `account_risk_lib.py`。
|
||||||
|
**仅用户主动平仓**计入风控;交易所止盈/止损、空仓同步、改保本/改委托等**不触发**冷静期。
|
||||||
|
|
||||||
|
## 状态展示
|
||||||
|
|
||||||
|
实例页顶、中控监控卡片账户名旁显示风控徽章:
|
||||||
|
|
||||||
|
| 状态 | 含义 | 倒计时 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 正常 | 可新开仓 | 无 |
|
||||||
|
| 1h冻结 | 冷静期中(通常为复盘后缩短的 1 小时) | 剩余时间,如 `1h冻结 · 52m 08s` |
|
||||||
|
| 4h冻结 | 冷静期中(默认 4 小时) | 剩余时间,如 `4h冻结 · 3h 12m` |
|
||||||
|
| 日冻结 | 当日禁止一切新开仓 | 至下一 **交易日切点**(`TRADING_DAY_RESET_HOUR`) |
|
||||||
|
|
||||||
|
- 倒计时每秒刷新;到期后徽章自动恢复为 **正常**(下次轮询/API 刷新会再次对齐服务端状态)。
|
||||||
|
- 鼠标悬停徽章可见完整说明(含解除时刻,如有)。
|
||||||
|
|
||||||
|
## 什么算「手动平仓」(计入风控)
|
||||||
|
|
||||||
|
以下操作通过 `close_source` 登记为 **用户主动平仓**:
|
||||||
|
|
||||||
|
| 来源标识 | 操作 |
|
||||||
|
|----------|------|
|
||||||
|
| `user_instance` | 实例页删单/手动平仓(`del_order`) |
|
||||||
|
| `user_hub` | 中控「平仓」「全平」「紧急全平」 |
|
||||||
|
| `user_trend_stop` | 趋势计划 **「结束计划」**(手动结束) |
|
||||||
|
|
||||||
|
**不算**手动平仓(不触发风控):
|
||||||
|
|
||||||
|
- 趋势 **「保本移交下单监控」**
|
||||||
|
- 中控/实例修改委托、挂止盈止损、移动保本
|
||||||
|
- 交易所止盈/止损/条件单成交
|
||||||
|
- 后台 `reconcile_external_closes` 空仓同步(即使记账为「外部平仓」)
|
||||||
|
- 监控轮询自动止盈/止损/保本
|
||||||
|
|
||||||
|
## 触发规则
|
||||||
|
|
||||||
|
| 事件 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| 第 1 次用户主动平仓 | 默认 **4h** 冷静期 |
|
||||||
|
| 第 2 次用户主动平仓(同一交易日) | **日冻结** |
|
||||||
|
| 复盘勾选任意情绪标签 | **日冻结** |
|
||||||
|
| 复盘:离场=手动平仓 且说明非空 | 将当前冷静期降为 **1h**(须处于 4h 档冷静期中) |
|
||||||
|
|
||||||
|
情绪标签:怕踏空、报复开仓、盈利飘了、拿不住单、扛单、重仓违规。
|
||||||
|
|
||||||
|
### 复盘缩短为 1h
|
||||||
|
|
||||||
|
任选一种方式,并填写说明:
|
||||||
|
|
||||||
|
| 方式 | 必填 |
|
||||||
|
|------|------|
|
||||||
|
| **复盘表单**提交 | 离场触发 = **手动平仓**;**离场补充** 非空(不是下方「备注」) |
|
||||||
|
| **核对修改**保存 | 结果 = **手动平仓**;**备注** 非空 |
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 中控全平 / 实例手动平仓后,只要在 4h 窗口内完成上述操作即可降为 1h。
|
||||||
|
- 复盘保存后会同步更新 `last_close_at_ms`,倒计时以 **最后一次手动平仓 + 当前档位数** 为准,不会继续读库内旧 4h 结束时间。
|
||||||
|
- 1h 窗口已结束后,即使库里残留旧 `cooloff_until_ms`,状态也会恢复 **正常**。
|
||||||
|
- 若超过「平仓 + 1h」才复盘,则从 **保存复盘时刻** 起再计 1h(不延长原 4h)。
|
||||||
|
- **止盈 / 保本止盈 / 止损** 等自动平仓不触发风控,也不会刷新冷静期。
|
||||||
|
- 代码更新后需 **重启对应实例** 并硬刷新页面。
|
||||||
|
|
||||||
|
### 倒计时与标签
|
||||||
|
|
||||||
|
- 结束时刻 = `last_close_at_ms + cooloff_hours`(`APP_TIMEZONE` 默认北京时间)
|
||||||
|
- 1h / 4h 标签按实际剩余时长判断,与倒计时一致
|
||||||
|
- 切交易日后,若冷静期已过期,自动清库内残留字段
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
```env
|
||||||
|
RISK_CONTROL_ENABLED=true
|
||||||
|
RISK_COOLING_HOURS_MANUAL=4
|
||||||
|
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
||||||
|
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
||||||
|
RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
||||||
|
TRADING_DAY_RESET_HOUR=8
|
||||||
|
APP_TIMEZONE=Asia/Shanghai
|
||||||
|
```
|
||||||
|
|
||||||
|
`RISK_COOLING_HOURS_EXTERNAL` 已废弃(外部平仓不再触发风控)。
|
||||||
|
|
||||||
|
## API 与 `risk_status` 字段
|
||||||
|
|
||||||
|
| 接口 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `GET /api/account_snapshot` | 实例页轮询,含 `risk_status` |
|
||||||
|
| `GET /api/account_risk_status` | hub_bridge 专用 |
|
||||||
|
| `GET /api/hub/monitor` | 中控监控板,每账户含 `risk_status` |
|
||||||
|
| `POST /api/hub/account-risk/user-close` | 中控登记用户平仓,`body: { source, count }` |
|
||||||
|
|
||||||
|
`risk_status` 主要字段:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `status` | `normal` / `freeze_1h` / `freeze_4h` / `freeze_daily` / `freeze_position` |
|
||||||
|
| `status_label` | 中文标签 |
|
||||||
|
| `can_trade` | 是否允许新开仓(仅风控维度) |
|
||||||
|
| `reason` | 悬停提示文案 |
|
||||||
|
| `active_count` / `max_active_positions` | 当前活跃持仓与 `.env` 中 `MAX_ACTIVE_POSITIONS` |
|
||||||
|
| `cooloff_until_ms` | 1h/4h 冷静期结束时间戳(毫秒) |
|
||||||
|
| `freeze_until_ms` | 倒计时结束时间戳(日冻结为下一交易日切点) |
|
||||||
|
| `freeze_remaining_sec` | 服务端计算的剩余秒数(供调试) |
|
||||||
|
|
||||||
|
**仓位上限冻结**:当 **计入上限的** 活跃持仓数(不含趋势回调)≥ 实例 `.env` 的 `MAX_ACTIVE_POSITIONS`(默认 1)且账户无时间类冻结时,徽章显示 **仓位上限冻结**;此时 **新开仓** 被禁止,但 **顺势加仓**(在已有同向监控持仓上加仓)仍可用。仅存在趋势回调持仓时不触发该冻结。时间冻结(1h/4h/日)优先展示。
|
||||||
|
|
||||||
|
`risk_status.can_roll`:仓位上限冻结时为 `true`,表示顺势加仓不受该冻结限制。
|
||||||
|
|
||||||
|
## 前端倒计时
|
||||||
|
|
||||||
|
- 共用脚本:`static/account_risk_badge.js?v=4`
|
||||||
|
- 样式:`static/account_risk_badge.css`
|
||||||
|
- 展示格式:`4h冻结 · 3h 12m`;日冻结为距下一交易日切点剩余时间
|
||||||
|
- 倒计时优先用服务端 `freeze_remaining_sec` 推算结束时刻,避免绝对时间戳与时区/脏数据偏差
|
||||||
|
- 服务端在冷静期**已结束**或锚点无效时**自动清库**,避免重启后误读旧 `account_risk_state` 仍显示冻结
|
||||||
|
- 无效的未来 `last_close_at_ms` **不会**被当作「现在」重启计时
|
||||||
|
- 若当日手动平仓**已复盘**(journal 有说明)且 1h 窗口已过,即使 risk 表被误写也会强制恢复 **正常**
|
||||||
|
- 勿与交易记录列表中的历史平仓时间混淆:风控只看 `account_risk_state` 表内 **最后一次用户主动平仓** 及其复盘结果
|
||||||
|
|
||||||
|
## 相关代码
|
||||||
|
|
||||||
|
- `account_risk_lib.py` — 状态机、`enrich_risk_status_countdown`、`apply_position_limit_risk`、`on_user_initiated_close`
|
||||||
|
- `hub_bridge.py` — `/api/hub/account-risk/user-close`
|
||||||
|
- `manual_trading_hub/hub.py` — 中控平仓成功后调用 user-close
|
||||||
|
- `strategy_trend_register.py` — `stop_trend_pullback` 结束计划时登记风控
|
||||||
|
- `tests/test_account_risk_lib.py`
|
||||||
@@ -29,17 +29,20 @@
|
|||||||
|
|
||||||
## 区间统计(统计栏)
|
## 区间统计(统计栏)
|
||||||
|
|
||||||
基于所选日期区间内 **全部开仓**(不受盈利/亏损/犯病勾选与搜索影响;交易所筛选仍生效):
|
基于当前 **列表筛选结果**(含盈利/亏损/犯病勾选、合约搜索;交易所下拉仍限定数据源):
|
||||||
|
|
||||||
| 指标 | 说明 |
|
| 指标 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 总开仓次数 | 区间内开仓笔数 |
|
| 总开仓次数 | 区间内开仓笔数 |
|
||||||
|
| 盈利单 / 亏损单 | 盈亏 > 0 / < 0 的笔数(持平不计) |
|
||||||
|
| 平均盈利 / 平均亏损 | 盈利单、亏损单各自的均值(U) |
|
||||||
|
| 最大盈利 / 最大亏损 | 单笔最大盈利、最大亏损(U) |
|
||||||
| 犯病次数 / 占比 | `behavior_tag = sick` 的笔数及占开仓比例 |
|
| 犯病次数 / 占比 | `behavior_tag = sick` 的笔数及占开仓比例 |
|
||||||
| 盈亏 | 区间内全部已平仓盈亏合计 |
|
| 盈亏 | 区间内全部已平仓盈亏合计 |
|
||||||
| 剔除犯病盈亏 | 排除犯病单后的盈亏合计 |
|
| 剔除犯病盈亏 | 排除犯病单后的盈亏合计 |
|
||||||
| 各交易所 | 每所:开仓、犯病、盈亏、剔除犯病盈亏 |
|
| 各交易所 | 每所同上分项 |
|
||||||
|
|
||||||
表格列表仍可按盈利单 / 亏损单 / 犯病 / 搜索进一步过滤。
|
在搜索框输入币种(如 `BTC`)后,统计栏与下方列表同步按该条件收窄。
|
||||||
|
|
||||||
## 数据约定
|
## 数据约定
|
||||||
|
|
||||||
@@ -87,10 +90,10 @@
|
|||||||
| `trading_day` | 本日模式下的交易日 `YYYY-MM-DD` |
|
| `trading_day` | 本日模式下的交易日 `YYYY-MM-DD` |
|
||||||
| `date_from` / `date_to` | 区间模式起止日 |
|
| `date_from` / `date_to` | 区间模式起止日 |
|
||||||
| `exchange_key` | 可选,按交易所筛选 |
|
| `exchange_key` | 可选,按交易所筛选 |
|
||||||
| `filter_profit` / `filter_loss` / `filter_sick` | 仅过滤表格列表 |
|
| `filter_profit` / `filter_loss` / `filter_sick` | 过滤列表与统计 |
|
||||||
| `search` | 合约 / 交易所 / 备注搜索(仅列表) |
|
| `search` | 合约 / 交易所 / 备注搜索(同步过滤列表与统计) |
|
||||||
|
|
||||||
返回 `stats` 含 `open_count`、`sick_count`、`sick_pct`、`pnl_total`、`pnl_ex_sick`、`by_exchange`。
|
返回 `stats` 含 `open_count`、`win_count`、`loss_count`、`win_rate`、`avg_win`、`avg_loss`、`profit_loss_ratio`、`max_win`、`max_loss`、`sick_count`、`sick_pct`、`pnl_total`、`pnl_ex_sick`、`by_exchange`。
|
||||||
|
|
||||||
实例侧:
|
实例侧:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# lib/ 共用模块结构
|
||||||
|
|
||||||
|
四所实例与中控共用的 Python 库、模板与静态资源统一放在仓库根目录的 **`lib/`** 下。部署单元(`crypto_monitor_*`、`manual_trading_hub`)仍保持独立目录与 PM2 配置不变。
|
||||||
|
|
||||||
|
**重构前快照 Git 标签**:`pre-lib-modularization`(可用 `git checkout pre-lib-modularization` 查看旧布局)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 顶层目录
|
||||||
|
|
||||||
|
```
|
||||||
|
crypto_monitor/
|
||||||
|
├── crypto_monitor_binance/ # 四所:各自 app + .env + PM2
|
||||||
|
├── crypto_monitor_gate/
|
||||||
|
├── crypto_monitor_gate_bot/
|
||||||
|
├── crypto_monitor_okx/
|
||||||
|
├── manual_trading_hub/ # 中控 + 子代理 agent
|
||||||
|
│
|
||||||
|
├── lib/ # 共用模块(本说明)
|
||||||
|
│ ├── strategy/
|
||||||
|
│ ├── key_monitor/
|
||||||
|
│ ├── trade/
|
||||||
|
│ ├── hub/
|
||||||
|
│ ├── ai/
|
||||||
|
│ ├── instance/
|
||||||
|
│ ├── exchange/
|
||||||
|
│ ├── common/
|
||||||
|
│ └── paths.py
|
||||||
|
│
|
||||||
|
├── brand/ # 各所共用图标
|
||||||
|
├── docs/
|
||||||
|
├── deploy/
|
||||||
|
├── scripts/
|
||||||
|
├── tests/
|
||||||
|
├── requirements.txt
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## lib/ 子包说明
|
||||||
|
|
||||||
|
| 子包 | 职责 | 主要模块 |
|
||||||
|
|------|------|----------|
|
||||||
|
| **`lib/strategy/`** | 策略交易(顺势加仓、趋势回调、快照与记录) | `strategy_register.py`、`strategy_trend_register.py`、`strategy_db.py`、`strategy_roll_*`、`strategy_trend_*` |
|
||||||
|
| **`lib/strategy/templates/`** | 策略页 Jinja 模板(原 `strategy_templates/`) | `strategy_trading_page.html`、`strategy_roll_panel.html` 等 |
|
||||||
|
| **`lib/key_monitor/`** | 关键位监控、斐波、假突破、止盈止损方案 | `key_monitor_lib.py`、`fib_key_monitor_lib.py`、`key_sl_tp_lib.py` 等 |
|
||||||
|
| **`lib/trade/`** | 下单监控展示、计仓、账户风控、手动 SL/TP | `order_monitor_display_lib.py`、`position_sizing_lib.py`、`account_risk_lib.py` 等 |
|
||||||
|
| **`lib/hub/`** | 中控 API、K 线、归档、计仓器、SSO/Bridge | `hub_bridge.py`、`hub_kline_store.py`、`hub_trades_lib.py` 等 |
|
||||||
|
| **`lib/ai/`** | AI 复盘与文本生成 | `ai_client.py`、`ai_review_lib.py` |
|
||||||
|
| **`lib/instance/`** | 中控 iframe 嵌入、导航、复盘图表 | `instance_embed_lib.py`、`focus_chart_lib.py`、`journal_chart_lib.py` |
|
||||||
|
| **`lib/instance/templates/`** | 嵌入页片段(原 `embed_templates/`) | `embed_page_fragment.html` |
|
||||||
|
| **`lib/exchange/`** | 特定交易所工具 | `gate_transfer_lib.py`、`okx_orders_lib.py` 等 |
|
||||||
|
| **`lib/common/`** | 跨功能小工具 | `form_submit_lib.py`、`wechat_notify_lib.py` 等 |
|
||||||
|
| **`lib/common/static/`** | 四所与中控共用的 JS/CSS(原根目录 `static/`) | `instance_theme.js`、`strategy_roll.js` 等 |
|
||||||
|
|
||||||
|
> **说明**:`hub_*` 命名表示「中控侧能力或行情聚合」,但部分模块(如 `hub_volume_rank_lib`、`hub_market_info_lib`)四所 `app.py` 也会调用,并非中控独占。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 路径辅助函数
|
||||||
|
|
||||||
|
`lib/paths.py` 集中维护资源目录,避免硬编码:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from lib.paths import strategy_templates_dir, embed_templates_dir, common_static_dir
|
||||||
|
|
||||||
|
strategy_templates_dir() # .../lib/strategy/templates
|
||||||
|
embed_templates_dir() # .../lib/instance/templates
|
||||||
|
common_static_dir() # .../lib/common/static
|
||||||
|
```
|
||||||
|
|
||||||
|
可选传入 `repo_root`(字符串或 `Path`),默认使用 `lib/` 的上级目录即仓库根。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Python 导入约定
|
||||||
|
|
||||||
|
各部署目录在启动时将 **仓库根** 加入 `sys.path`(与重构前相同):
|
||||||
|
|
||||||
|
```python
|
||||||
|
_REPO_ROOT = os.path.dirname(BASE_DIR) # 或 Path(__file__).resolve().parent.parent
|
||||||
|
if _REPO_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _REPO_ROOT)
|
||||||
|
```
|
||||||
|
|
||||||
|
之后使用 **`lib.<子包>.<模块>`** 形式导入,例如:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from lib.strategy.strategy_db import init_strategy_tables
|
||||||
|
from lib.key_monitor.key_monitor_lib import check_key_monitors
|
||||||
|
from lib.hub.hub_bridge import install_on_app
|
||||||
|
from lib.ai.ai_client import ai_review
|
||||||
|
```
|
||||||
|
|
||||||
|
策略注册仍在各所 `app.py` 末尾:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from lib.strategy.strategy_register import install_strategy_trading
|
||||||
|
from lib.strategy.strategy_trend_register import install_strategy_trend
|
||||||
|
|
||||||
|
install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__])
|
||||||
|
install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 静态资源与 URL
|
||||||
|
|
||||||
|
- 四所页面仍通过 **`/static/...`** 访问共用脚本;`hub_bridge.install_instance_theme_static` 从 `lib/common/static/` 提供部分根级静态路由。
|
||||||
|
- 各所目录下 **`static/`**(图标、上传图片等)仍为实例私有,未迁入 `lib/`。
|
||||||
|
- 中控 `manual_trading_hub/hub.py` 通过 `_REPO_ROOT / "lib" / "common" / "static"` 挂载与四所共用的 badge、复盘 JS 等。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
在仓库根执行(需将根目录置于 Python 路径,或从根目录运行):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor
|
||||||
|
python -m unittest discover -s tests -p "test_*.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
测试文件内统一 `from lib.<子包>.<模块> import ...`。使用 `@patch` 时目标写完整模块路径,例如 `lib.hub.hub_calculator_lib._resolve_market`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 迁移脚本
|
||||||
|
|
||||||
|
一次性迁移由 `scripts/migrate_to_lib.py` 完成(移动文件 + 批量改写 import)。**不要在已迁移后的仓库上重复执行**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后续可选整理
|
||||||
|
|
||||||
|
- 四所 `app.py` 体量接近,可逐步抽取公共 `exchange_app` 基座(改动面大,单独规划)。
|
||||||
|
- `manual_trading_hub/okx_orders_lib.py` 为 agent 本地副本,可与 `lib/exchange/okx_orders_lib.py` 合并去重。
|
||||||
|
- 可引入 `pyproject.toml` + `pip install -e .`,替代 `sys.path.insert`(长期维护更规范)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [README.md](../README.md) — 总览与部署
|
||||||
|
- [策略交易说明.md](../策略交易说明.md)
|
||||||
|
- [manual_trading_hub/使用说明.md](../manual_trading_hub/使用说明.md)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 宏观关键数据 · 风控前置
|
||||||
|
|
||||||
|
中控 **系统设置** 手动录入 FOMC / CPI / 就业数据发布时间,在 **监控区** 发布前后各 1 小时给出风险提示。
|
||||||
|
**不看公布结果、不解读数据**,仅作波动窗口前的行为提醒;**不拦截下单**(与账户冷静期/日冻结独立)。
|
||||||
|
|
||||||
|
## 支持的数据类型
|
||||||
|
|
||||||
|
| 类型 ID | 显示名称 |
|
||||||
|
|---------|----------|
|
||||||
|
| `fomc` | FOMC 联邦基金利率 |
|
||||||
|
| `cpi` | 美国 CPI 通胀 |
|
||||||
|
| `employment` | 就业与劳工数据 |
|
||||||
|
|
||||||
|
每项在设置中 **名称下拉三选一**,**发布时间** 手动输入(北京时间,精确到分钟)。FOMC 只录 **一条**(决议公布时刻即可)。
|
||||||
|
|
||||||
|
## 风险窗口
|
||||||
|
|
||||||
|
- 默认:**发布时间 ±1 小时**
|
||||||
|
- 发布前 **30 分钟内**:文案加强为「即将发布」
|
||||||
|
- 窗口结束后横幅自动消失;设置列表中过期记录逐步不再展示
|
||||||
|
|
||||||
|
环境变量(可选):
|
||||||
|
|
||||||
|
```env
|
||||||
|
HUB_MACRO_WINDOW_BEFORE_SEC=3600
|
||||||
|
HUB_MACRO_WINDOW_AFTER_SEC=3600
|
||||||
|
HUB_MACRO_IMMINENT_BEFORE_SEC=1800
|
||||||
|
HUB_MACRO_LIST_FUTURE_DAYS=60
|
||||||
|
```
|
||||||
|
|
||||||
|
## 监控区提示文案
|
||||||
|
|
||||||
|
读取当前监控板:**任意交易所有持仓 = 有仓**,否则 = 无仓。
|
||||||
|
|
||||||
|
| 场景 | 提示要点 |
|
||||||
|
|------|----------|
|
||||||
|
| 无仓 · 窗口内 | 建议等待,避免新开仓 |
|
||||||
|
| 有仓 · 窗口内 | 注意仓位,勿加仓,检查止损/减仓 |
|
||||||
|
| 即将发布(30 分钟内) | 在上述基础上标注剩余分钟数 |
|
||||||
|
|
||||||
|
## 存储
|
||||||
|
|
||||||
|
- SQLite:`manual_trading_hub/data/hub_macro_calendar.db`
|
||||||
|
- 可覆盖:`HUB_MACRO_CALENDAR_DB_PATH`
|
||||||
|
|
||||||
|
表 `macro_events`:`event_type`, `event_at_ms`, `note`, `created_at_ms`, `updated_at_ms`
|
||||||
|
同类型 + 同一发布时间不可重复录入。
|
||||||
|
|
||||||
|
## API(均需中控登录)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/macro-calendar/meta` | 类型列表与窗口说明 |
|
||||||
|
| GET | `/api/macro-calendar/events` | 设置页列表 |
|
||||||
|
| GET | `/api/macro-calendar/active` | 当前处于窗口内的事件(监控横幅) |
|
||||||
|
| POST | `/api/macro-calendar/events` | 新增 |
|
||||||
|
| PATCH | `/api/macro-calendar/events/{id}` | 更新 |
|
||||||
|
| DELETE | `/api/macro-calendar/events/{id}` | 删除 |
|
||||||
|
|
||||||
|
请求体示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "cpi",
|
||||||
|
"event_at": "2026-06-18 20:30",
|
||||||
|
"note": "可选备注"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用习惯
|
||||||
|
|
||||||
|
1. 每月在金十/日历查看 **FOMC、CPI、非农** 公布时间
|
||||||
|
2. 中控 **系统设置 → 宏观关键数据** 录入 1~3 条
|
||||||
|
3. 到点前后监控区顶栏出现 **宏观风控** 横幅;无操作则窗口结束后自动消失
|
||||||
|
|
||||||
|
## 与账户风控的关系
|
||||||
|
|
||||||
|
| 模块 | 时机 | 作用 |
|
||||||
|
|------|------|------|
|
||||||
|
| 宏观日历 | **事前** | 已知高波动窗口,提醒等待或管仓 |
|
||||||
|
| 账户冷静期/日冻结 | **事后** | 用户主动平仓后的惩罚性限制 |
|
||||||
|
|
||||||
|
宏观提醒 **不触发** 冷静期、不计入手动平仓次数。
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 实盘下单 · 预估盈亏比
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
四所(Binance / OKX / Gate / Gate趋势)**实盘下单监控**表单中,在「开仓」按钮前显示 **预估盈亏比**。
|
||||||
|
|
||||||
|
- **价格模式**:填完币种、方向、止损价、止盈价后,调用 `GET /api/order_defaults` 取标记价,按几何距离计算 RR。
|
||||||
|
- **百分比模式**:填完币种、方向、止损%、止盈% 后拉快照校验币种,再显示 RR(`止盈% / 止损%`)。
|
||||||
|
- **固定盈亏比模式**:不显示预估盈亏比(盈亏比由输入框直接指定;仍保留原有「预估止盈」)。
|
||||||
|
|
||||||
|
- **以损定仓**(`POSITION_SIZING_MODE=risk`):预估风险 = 当前交易基数 × `risk%`。
|
||||||
|
- **全仓杠杆**(`full_margin`):预估风险 = 合约可用 × 缓冲比例 × 杠杆(BTC/ETH 与山寨按 `.env` 配置)× 止损距离比例,与开仓时 `calc_risk_amount_from_plan` 一致。
|
||||||
|
|
||||||
|
## 前端实现
|
||||||
|
|
||||||
|
- 共享脚本:`static/manual_order_rr_preview.js`
|
||||||
|
- 各所 `templates/index.html` 引入并在 `MANUAL_MIN_PLANNED_RR` 定义后执行:
|
||||||
|
```js
|
||||||
|
ManualOrderRrPreview.wire({ minRr: MANUAL_MIN_PLANNED_RR });
|
||||||
|
```
|
||||||
|
- 展示元素:`#order-rr-preview`(开仓按钮左侧)
|
||||||
|
- 颜色:≥ 最低要求为绿色,低于为红色,无效/取价失败为红色或灰色
|
||||||
|
|
||||||
|
## 与提交校验
|
||||||
|
|
||||||
|
提交时仍走原有 `calcClientRr` / `calcClientRrFromPct` 与 `rejectManualOrderRr`;预估仅用于下单前参考,不替代服务端风控。
|
||||||
|
|
||||||
|
## 校验记录
|
||||||
|
|
||||||
|
- `node --check static/manual_order_rr_preview.js`
|
||||||
|
- `tests/test_manual_order_rr_preview.py`:RR 公式与四所 `calc_rr_ratio` 口径一致
|
||||||
@@ -32,7 +32,7 @@ FULL_MARGIN_BUFFER_RATIO=0.98
|
|||||||
- 关键位:箱体突破、收敛突破、斐波、假突破(添加时拒绝;已存在则启动时撤销)。
|
- 关键位:箱体突破、收敛突破、斐波、假突破(添加时拒绝;已存在则启动时撤销)。
|
||||||
- 趋势回调、顺势加仓(策略入口返回明确错误)。
|
- 趋势回调、顺势加仓(策略入口返回明确错误)。
|
||||||
|
|
||||||
**允许:** 关键位 **触价开仓**(程序盯价、触达计划入场后市价成交,无交易所挂单;全仓下仅允许一条待触发)。
|
**允许:** 关键位 **回调触价开仓** / **突破触价开仓**(程序盯价、触达/穿越计划入场后市价成交,无交易所挂单;全仓下仅允许一条待触发)。
|
||||||
|
|
||||||
## 用脚本更新四所 `.env`
|
## 用脚本更新四所 `.env`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""crypto_monitor shared libraries."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Shared library package."""
|
||||||
@@ -5,7 +5,7 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Callable, List, Mapping, Optional, Sequence
|
from typing import Any, Callable, List, Mapping, Optional, Sequence
|
||||||
|
|
||||||
from journal_chart_lib import (
|
from lib.instance.journal_chart_lib import (
|
||||||
JOURNAL_CHART_ANCHOR_CLOSE,
|
JOURNAL_CHART_ANCHOR_CLOSE,
|
||||||
JOURNAL_CHART_DEFAULT_LIMIT,
|
JOURNAL_CHART_DEFAULT_LIMIT,
|
||||||
JOURNAL_CHART_DEFAULT_TF1,
|
JOURNAL_CHART_DEFAULT_TF1,
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Shared library package."""
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/* 账户风控状态徽章 — 四所实例 + 中控共用;兼容 data-theme light/dark */
|
||||||
|
|
||||||
|
:root,
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--risk-normal-fg: #9cf0c4;
|
||||||
|
--risk-normal-bg: rgba(36, 140, 96, 0.16);
|
||||||
|
--risk-normal-border: rgba(72, 190, 130, 0.42);
|
||||||
|
--risk-normal-glow: rgba(72, 190, 130, 0.35);
|
||||||
|
|
||||||
|
--risk-1h-fg: #ffd27a;
|
||||||
|
--risk-1h-bg: rgba(210, 150, 40, 0.16);
|
||||||
|
--risk-1h-border: rgba(230, 170, 60, 0.45);
|
||||||
|
--risk-1h-glow: rgba(230, 170, 60, 0.32);
|
||||||
|
|
||||||
|
--risk-4h-fg: #ffab8a;
|
||||||
|
--risk-4h-bg: rgba(210, 90, 55, 0.16);
|
||||||
|
--risk-4h-border: rgba(230, 110, 70, 0.48);
|
||||||
|
--risk-4h-glow: rgba(230, 110, 70, 0.34);
|
||||||
|
|
||||||
|
--risk-daily-fg: #ff9ec4;
|
||||||
|
--risk-daily-bg: rgba(190, 55, 100, 0.18);
|
||||||
|
--risk-daily-border: rgba(210, 75, 120, 0.5);
|
||||||
|
--risk-daily-glow: rgba(210, 75, 120, 0.36);
|
||||||
|
|
||||||
|
--risk-position-fg: #8ec8ff;
|
||||||
|
--risk-position-bg: rgba(55, 120, 210, 0.18);
|
||||||
|
--risk-position-border: rgba(75, 145, 230, 0.48);
|
||||||
|
--risk-position-glow: rgba(75, 145, 230, 0.34);
|
||||||
|
|
||||||
|
--risk-badge-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] {
|
||||||
|
--risk-normal-fg: #056b44;
|
||||||
|
--risk-normal-bg: rgba(10, 143, 92, 0.14);
|
||||||
|
--risk-normal-border: rgba(8, 122, 80, 0.38);
|
||||||
|
--risk-normal-glow: rgba(10, 143, 92, 0.22);
|
||||||
|
|
||||||
|
--risk-1h-fg: #8a5a00;
|
||||||
|
--risk-1h-bg: rgba(200, 140, 20, 0.14);
|
||||||
|
--risk-1h-border: rgba(170, 115, 10, 0.38);
|
||||||
|
--risk-1h-glow: rgba(200, 140, 20, 0.2);
|
||||||
|
|
||||||
|
--risk-4h-fg: #a83812;
|
||||||
|
--risk-4h-bg: rgba(210, 85, 35, 0.12);
|
||||||
|
--risk-4h-border: rgba(180, 65, 25, 0.36);
|
||||||
|
--risk-4h-glow: rgba(210, 85, 35, 0.2);
|
||||||
|
|
||||||
|
--risk-daily-fg: #9a1248;
|
||||||
|
--risk-daily-bg: rgba(180, 35, 80, 0.1);
|
||||||
|
--risk-daily-border: rgba(155, 28, 68, 0.34);
|
||||||
|
--risk-daily-glow: rgba(180, 35, 80, 0.18);
|
||||||
|
|
||||||
|
--risk-position-fg: #0b5cab;
|
||||||
|
--risk-position-bg: rgba(20, 100, 190, 0.12);
|
||||||
|
--risk-position-border: rgba(15, 85, 165, 0.36);
|
||||||
|
--risk-position-glow: rgba(20, 100, 190, 0.2);
|
||||||
|
|
||||||
|
--risk-badge-shadow: 0 1px 2px rgba(20, 50, 80, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
line-height: 1.15;
|
||||||
|
padding: 5px 12px 5px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--risk-border, transparent);
|
||||||
|
background: var(--risk-bg, transparent);
|
||||||
|
color: var(--risk-fg, inherit);
|
||||||
|
box-shadow: var(--risk-badge-shadow);
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中控 iframe 内切页:避免徽章过渡动画造成 header 闪动 */
|
||||||
|
html[data-hub-linked="1"] .header-row .risk-status-badge {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-status-badge::before {
|
||||||
|
content: "";
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, currentColor 30%, transparent),
|
||||||
|
0 0 8px var(--risk-glow, currentColor);
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-status-normal {
|
||||||
|
--risk-fg: var(--risk-normal-fg);
|
||||||
|
--risk-bg: var(--risk-normal-bg);
|
||||||
|
--risk-border: var(--risk-normal-border);
|
||||||
|
--risk-glow: var(--risk-normal-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-status-freeze_1h {
|
||||||
|
--risk-fg: var(--risk-1h-fg);
|
||||||
|
--risk-bg: var(--risk-1h-bg);
|
||||||
|
--risk-border: var(--risk-1h-border);
|
||||||
|
--risk-glow: var(--risk-1h-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-status-freeze_4h {
|
||||||
|
--risk-fg: var(--risk-4h-fg);
|
||||||
|
--risk-bg: var(--risk-4h-bg);
|
||||||
|
--risk-border: var(--risk-4h-border);
|
||||||
|
--risk-glow: var(--risk-4h-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-status-freeze_daily {
|
||||||
|
--risk-fg: var(--risk-daily-fg);
|
||||||
|
--risk-bg: var(--risk-daily-bg);
|
||||||
|
--risk-border: var(--risk-daily-border);
|
||||||
|
--risk-glow: var(--risk-daily-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-status-freeze_position {
|
||||||
|
--risk-fg: var(--risk-position-fg);
|
||||||
|
--risk-bg: var(--risk-position-bg);
|
||||||
|
--risk-border: var(--risk-position-border);
|
||||||
|
--risk-glow: var(--risk-position-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 实例页:与交易所标签并排 */
|
||||||
|
.header-row .risk-status-badge {
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中控卡片标题内 */
|
||||||
|
.card-title .risk-status-badge,
|
||||||
|
.hub-tile-name .risk-status-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 3px 10px 3px 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title .risk-status-badge::before,
|
||||||
|
.hub-tile-name .risk-status-badge::before {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* 账户风控徽章倒计时 — 四所实例 + 中控共用。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
function formatRemaining(totalSec) {
|
||||||
|
const sec = Math.max(0, Math.floor(Number(totalSec) || 0));
|
||||||
|
if (sec <= 0) return "";
|
||||||
|
const h = Math.floor(sec / 3600);
|
||||||
|
const m = Math.floor((sec % 3600) / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`;
|
||||||
|
if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseLabel(riskStatus, el) {
|
||||||
|
if (riskStatus && riskStatus.status_label) return String(riskStatus.status_label);
|
||||||
|
if (el && el.dataset && el.dataset.statusLabel) return String(el.dataset.statusLabel);
|
||||||
|
return "正常";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFreezeUntilMs(riskStatus) {
|
||||||
|
if (!riskStatus) return null;
|
||||||
|
const sec = Number(riskStatus.freeze_remaining_sec);
|
||||||
|
if (Number.isFinite(sec) && sec > 0) {
|
||||||
|
return Date.now() + sec * 1000;
|
||||||
|
}
|
||||||
|
const until = Number(riskStatus.freeze_until_ms);
|
||||||
|
return Number.isFinite(until) && until > 0 ? until : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeText(riskStatus) {
|
||||||
|
const label = baseLabel(riskStatus, null);
|
||||||
|
const until = resolveFreezeUntilMs(riskStatus);
|
||||||
|
if (!until || until <= Date.now()) return label;
|
||||||
|
const cd = formatRemaining((until - Date.now()) / 1000);
|
||||||
|
return cd ? `${label} · ${cd}` : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNormalBadge(el) {
|
||||||
|
el.className = "risk-status-badge risk-status-normal";
|
||||||
|
el.dataset.statusLabel = "正常";
|
||||||
|
el.textContent = "正常";
|
||||||
|
el.title = "";
|
||||||
|
if (el.dataset) delete el.dataset.freezeUntilMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshElement(el) {
|
||||||
|
if (!el) return;
|
||||||
|
const label = baseLabel(null, el);
|
||||||
|
const until = Number(el.dataset && el.dataset.freezeUntilMs);
|
||||||
|
if (!Number.isFinite(until) || until <= Date.now()) {
|
||||||
|
if (el.dataset && el.dataset.freezeUntilMs) {
|
||||||
|
setNormalBadge(el);
|
||||||
|
} else {
|
||||||
|
el.textContent = label;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cd = formatRemaining((until - Date.now()) / 1000);
|
||||||
|
el.textContent = cd ? `${label} · ${cd}` : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyToElement(el, riskStatus) {
|
||||||
|
if (!el || !riskStatus) return;
|
||||||
|
const st = riskStatus.status || "normal";
|
||||||
|
el.className = "risk-status-badge risk-status-" + st;
|
||||||
|
el.dataset.statusLabel = baseLabel(riskStatus, el);
|
||||||
|
const until = resolveFreezeUntilMs(riskStatus);
|
||||||
|
if (until) {
|
||||||
|
el.dataset.freezeUntilMs = String(until);
|
||||||
|
} else if (el.dataset) {
|
||||||
|
delete el.dataset.freezeUntilMs;
|
||||||
|
}
|
||||||
|
el.textContent = badgeText(riskStatus);
|
||||||
|
el.title = riskStatus.reason || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBadgeHtml(riskStatus, esc) {
|
||||||
|
if (!riskStatus || typeof riskStatus !== "object") return "";
|
||||||
|
const safe = typeof esc === "function" ? esc : (s) => String(s);
|
||||||
|
const st = riskStatus.status || "normal";
|
||||||
|
const label = safe(riskStatus.status_label || "正常");
|
||||||
|
const title = safe(riskStatus.reason || "");
|
||||||
|
const text = safe(badgeText(riskStatus));
|
||||||
|
const until = resolveFreezeUntilMs(riskStatus);
|
||||||
|
const untilAttr =
|
||||||
|
until != null
|
||||||
|
? ` data-freeze-until-ms="${safe(String(Math.floor(until)))}"`
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
`<span class="risk-status-badge risk-status-${safe(st)}" role="status"` +
|
||||||
|
` title="${title}" data-status-label="${label}"${untilAttr}>${text}</span>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickAll(root) {
|
||||||
|
const scope = root || document;
|
||||||
|
scope.querySelectorAll(".risk-status-badge[data-freeze-until-ms]").forEach(refreshElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer = null;
|
||||||
|
function startTicker() {
|
||||||
|
if (timer) return;
|
||||||
|
tickAll();
|
||||||
|
timer = setInterval(() => tickAll(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
global.AccountRiskBadge = {
|
||||||
|
formatRemaining,
|
||||||
|
badgeText,
|
||||||
|
refreshElement,
|
||||||
|
applyToElement,
|
||||||
|
formatBadgeHtml,
|
||||||
|
tickAll,
|
||||||
|
startTicker,
|
||||||
|
};
|
||||||
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* 中控 iframe 壳:顶栏/统计常驻,tab 内容走 /api/embed/page/<tab>。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
const TAB_PATH = {
|
||||||
|
key_monitor: "/key_monitor",
|
||||||
|
trade: "/trade",
|
||||||
|
strategy: "/strategy",
|
||||||
|
strategy_records: "/strategy/records",
|
||||||
|
records: "/records",
|
||||||
|
stats: "/stats",
|
||||||
|
};
|
||||||
|
|
||||||
|
let navToken = 0;
|
||||||
|
let loadingTab = false;
|
||||||
|
|
||||||
|
/** 自带校验后 form.submit() 的表单,勿在捕获阶段再 fetch 一份(会双发 POST) */
|
||||||
|
const CUSTOM_SUBMIT_FORM_IDS = new Set(["add-order-form", "key-form", "roll-form"]);
|
||||||
|
|
||||||
|
function isEmbedShell() {
|
||||||
|
return document.body && document.body.getAttribute("data-embed-shell") === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTab() {
|
||||||
|
try {
|
||||||
|
const t = new URLSearchParams(location.search).get("tab");
|
||||||
|
if (t) return t;
|
||||||
|
} catch (_) {}
|
||||||
|
return document.body.getAttribute("data-page") || "trade";
|
||||||
|
}
|
||||||
|
|
||||||
|
function listWindowQueryString() {
|
||||||
|
if (typeof global.listWindowQueryString === "function") {
|
||||||
|
return global.listWindowQueryString();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRootLoading(on) {
|
||||||
|
const root = document.getElementById("embed-page-root");
|
||||||
|
if (root) root.classList.toggle("is-embed-tab-loading", !!on);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNavActive(tab) {
|
||||||
|
document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => {
|
||||||
|
a.classList.toggle("active", a.getAttribute("data-embed-tab") === tab);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncUrl(tab, replace) {
|
||||||
|
const q = new URLSearchParams(location.search);
|
||||||
|
q.set("tab", tab);
|
||||||
|
q.set("embed", "1");
|
||||||
|
const qs = q.toString();
|
||||||
|
const url = "/embed?" + qs;
|
||||||
|
if (replace) history.replaceState({ embedTab: tab }, "", url);
|
||||||
|
else history.pushState({ embedTab: tab }, "", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPageInit(tab) {
|
||||||
|
document.body.setAttribute("data-page", tab);
|
||||||
|
if (typeof global.attachListWindowToExports === "function") {
|
||||||
|
global.attachListWindowToExports();
|
||||||
|
}
|
||||||
|
if (tab === "trade") {
|
||||||
|
if (typeof global.refreshOrderDefaults === "function") global.refreshOrderDefaults();
|
||||||
|
if (global.ManualOrderRrPreview && typeof global.ManualOrderRrPreview.wire === "function") {
|
||||||
|
global.ManualOrderRrPreview.wire();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tab === "key_monitor" && global.KeyMonitorForm && typeof global.KeyMonitorForm.init === "function") {
|
||||||
|
global.KeyMonitorForm.init();
|
||||||
|
}
|
||||||
|
if (tab === "strategy" && typeof global.initStrategyRollForm === "function") {
|
||||||
|
global.initStrategyRollForm();
|
||||||
|
}
|
||||||
|
if (tab === "records") {
|
||||||
|
if (typeof global.loadJournals === "function") global.loadJournals();
|
||||||
|
if (typeof global.loadReviews === "function") global.loadReviews();
|
||||||
|
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
|
||||||
|
}
|
||||||
|
if (tab === "stats") {
|
||||||
|
if (typeof global.initStatsSegmentFromUrl === "function") global.initStatsSegmentFromUrl();
|
||||||
|
}
|
||||||
|
if (typeof global.refreshPriceSnapshotConditional === "function") {
|
||||||
|
global.refreshPriceSnapshotConditional();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectFragment(html) {
|
||||||
|
const root = document.getElementById("embed-page-root");
|
||||||
|
if (!root) return;
|
||||||
|
root.innerHTML = html;
|
||||||
|
root.querySelectorAll("script").forEach((old) => {
|
||||||
|
const s = document.createElement("script");
|
||||||
|
if (old.src) s.src = old.src;
|
||||||
|
else s.textContent = old.textContent;
|
||||||
|
old.replaceWith(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTab(tab, opts) {
|
||||||
|
const options = opts || {};
|
||||||
|
if (!tab || loadingTab) return;
|
||||||
|
const token = ++navToken;
|
||||||
|
loadingTab = true;
|
||||||
|
setRootLoading(true);
|
||||||
|
try {
|
||||||
|
const qs = listWindowQueryString();
|
||||||
|
const url = "/api/embed/page/" + encodeURIComponent(tab) + (qs ? "?" + qs : "");
|
||||||
|
const r = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (token !== navToken) return;
|
||||||
|
const j = await r.json();
|
||||||
|
if (!j.ok || !j.html) throw new Error(j.msg || "加载失败");
|
||||||
|
injectFragment(j.html);
|
||||||
|
setNavActive(tab);
|
||||||
|
if (!options.skipUrl) syncUrl(tab, !!options.replace);
|
||||||
|
runPageInit(tab);
|
||||||
|
} catch (e) {
|
||||||
|
if (token === navToken) {
|
||||||
|
const flash = document.getElementById("embed-flash");
|
||||||
|
if (flash) {
|
||||||
|
flash.style.display = "";
|
||||||
|
flash.textContent = String(e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (token === navToken) {
|
||||||
|
loadingTab = false;
|
||||||
|
setRootLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadCurrentTab() {
|
||||||
|
return loadTab(getTab(), { replace: true, skipUrl: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function postFormAndReload(form, label) {
|
||||||
|
if (!form) return Promise.resolve();
|
||||||
|
if (global.FormSubmitGuard) {
|
||||||
|
if (global.FormSubmitGuard.isLocked(form)) {
|
||||||
|
global.FormSubmitGuard.setSubmitLabel(form, label || "提交中…");
|
||||||
|
} else {
|
||||||
|
global.FormSubmitGuard.lock(form, label || "提交中…");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fd = new FormData(form);
|
||||||
|
return fetch(form.action, {
|
||||||
|
method: form.method || "POST",
|
||||||
|
body: fd,
|
||||||
|
credentials: "same-origin",
|
||||||
|
redirect: "manual",
|
||||||
|
})
|
||||||
|
.then(() => reloadCurrentTab())
|
||||||
|
.catch(() => reloadCurrentTab());
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchApplyListWindow() {
|
||||||
|
if (typeof global.applyListWindow !== "function") return;
|
||||||
|
global.applyListWindow = function embedApplyListWindow() {
|
||||||
|
const qs = listWindowQueryString();
|
||||||
|
const tab = getTab();
|
||||||
|
const q = new URLSearchParams(qs);
|
||||||
|
q.set("tab", tab);
|
||||||
|
q.set("embed", "1");
|
||||||
|
window.location.href = "/embed?" + q.toString();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchHardNavigations() {
|
||||||
|
const resubmitPaths =
|
||||||
|
/^\/(del_|delete_|add_|stop_|strategy\/|trend_|roll_|cancel_|place_)/;
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
"click",
|
||||||
|
(ev) => {
|
||||||
|
if (!isEmbedShell()) return;
|
||||||
|
const a = ev.target.closest("a[href]");
|
||||||
|
if (!a || ev.defaultPrevented) return;
|
||||||
|
if (a.closest(".embed-top-nav")) return;
|
||||||
|
if (a.hasAttribute("download") || a.target === "_blank") return;
|
||||||
|
const raw = a.getAttribute("href");
|
||||||
|
if (!raw || raw.startsWith("#") || raw.startsWith("javascript:")) return;
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(raw, location.href);
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.origin !== location.origin) return;
|
||||||
|
if (url.pathname.startsWith("/export/") || url.pathname.startsWith("/order_focus") || url.pathname.startsWith("/key_focus")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!resubmitPaths.test(url.pathname)) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
fetch(url.pathname + url.search, { credentials: "same-origin", redirect: "manual" })
|
||||||
|
.then(() => reloadCurrentTab())
|
||||||
|
.catch(() => reloadCurrentTab());
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
"submit",
|
||||||
|
(ev) => {
|
||||||
|
if (!isEmbedShell()) return;
|
||||||
|
const form = ev.target;
|
||||||
|
if (!(form instanceof HTMLFormElement)) return;
|
||||||
|
if (form.method && form.method.toUpperCase() === "GET") return;
|
||||||
|
if (CUSTOM_SUBMIT_FORM_IDS.has(form.id)) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
const fd = new FormData(form);
|
||||||
|
fetch(form.action, {
|
||||||
|
method: form.method || "POST",
|
||||||
|
body: fd,
|
||||||
|
credentials: "same-origin",
|
||||||
|
redirect: "manual",
|
||||||
|
})
|
||||||
|
.then(() => reloadCurrentTab())
|
||||||
|
.catch(() => reloadCurrentTab());
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindNav() {
|
||||||
|
document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => {
|
||||||
|
a.addEventListener("click", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const tab = a.getAttribute("data-embed-tab");
|
||||||
|
if (!tab || tab === getTab()) return;
|
||||||
|
void loadTab(tab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
window.addEventListener("popstate", () => {
|
||||||
|
const tab = getTab();
|
||||||
|
void loadTab(tab, { replace: true, skipUrl: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
if (!isEmbedShell()) return;
|
||||||
|
patchApplyListWindow();
|
||||||
|
patchHardNavigations();
|
||||||
|
bindNav();
|
||||||
|
runPageInit(getTab());
|
||||||
|
try {
|
||||||
|
window.parent.postMessage({ type: "instance-frame-ready" }, "*");
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
global.InstanceEmbed = {
|
||||||
|
loadTab,
|
||||||
|
reloadCurrentTab,
|
||||||
|
getTab,
|
||||||
|
postFormAndReload,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", boot);
|
||||||
|
} else {
|
||||||
|
boot();
|
||||||
|
}
|
||||||
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;background:#0b0d14;color:#eaeaea;padding:14px 20px}
|
||||||
|
.container{width:100%;max-width:min(1440px,94vw);margin:0 auto;padding:0 clamp(8px,1.5vw,20px)}
|
||||||
|
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
|
||||||
|
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
|
||||||
|
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
|
||||||
|
.header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center}
|
||||||
|
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
|
||||||
|
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
|
||||||
|
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
|
||||||
|
.stat-box{display:grid;grid-template-columns:repeat(auto-fit,minmax(148px,1fr));gap:12px;margin-bottom:16px;align-items:stretch}
|
||||||
|
.stat-item{min-width:0;min-height:76px;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:6px;background:#151a2a;padding:12px 10px;border-radius:10px;text-align:center;border:1px solid #2a3152}
|
||||||
|
.stat-item .label{font-size:.8rem;color:#aaa;line-height:1.25;max-width:100%}
|
||||||
|
.stat-item .value{font-size:1.25rem;font-weight:600;color:#fff;line-height:1.3;min-height:1.35em;display:flex;align-items:center;justify-content:center}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
|
||||||
|
.card{background:#121726;border-radius:10px;padding:12px;border:1px solid #2a3150}
|
||||||
|
.full{grid-column:1/-1}
|
||||||
|
.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-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}
|
||||||
|
.order-preview-risk{color:#ff6b6b}
|
||||||
|
.order-preview-risk strong{color:#ff8f8f;font-weight:600}
|
||||||
|
.order-preview-profit{color:#4cd97f}
|
||||||
|
.order-preview-profit strong{color:#6ee7a0;font-weight:600}
|
||||||
|
.order-preview-rr{color:#cfd3ef}
|
||||||
|
.order-preview-rr strong{font-weight:600;color:#dbe4ff}
|
||||||
|
.order-preview-rr.order-preview-rr-low strong{color:#ff8f8f}
|
||||||
|
.order-preview-rr.order-preview-rr-ok strong{color:#8fc8ff}
|
||||||
|
.form-row > button,.form-row > label{flex:0 0 auto}
|
||||||
|
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
||||||
|
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
|
||||||
|
.journal-card .form-grid{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||||
|
.journal-card .form-grid > input,
|
||||||
|
.journal-card .form-grid > select{
|
||||||
|
min-width:0;
|
||||||
|
width:100%;
|
||||||
|
max-width:100%;
|
||||||
|
}
|
||||||
|
.journal-card .form-grid select[name="entry_reason"]{
|
||||||
|
grid-column:1/-1;
|
||||||
|
font-size:.8rem;
|
||||||
|
line-height:1.35;
|
||||||
|
}
|
||||||
|
.journal-card .form-grid input[name="entry_reason_custom"]{
|
||||||
|
grid-column:1/-1;
|
||||||
|
font-size:.8rem;
|
||||||
|
}
|
||||||
|
input,select,button,textarea{padding:8px 10px;border-radius:8px;border:1px solid #2e2e45;background:#1a1a29;color:#fff;font-size:.88rem;outline:none}
|
||||||
|
button{background:linear-gradient(90deg,#4285f4,#7b42ff);border:none;cursor:pointer}
|
||||||
|
.list{display:flex;flex-direction:column;gap:8px;margin-top:8px;max-height:240px;overflow:auto}
|
||||||
|
.list-item{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:9px;background:#1a2034;border:1px solid #2a3150;border-radius:8px}
|
||||||
|
.btn-del{padding:5px 9px;background:#2f2134;color:#ff7b7b;border-radius:8px;text-decoration:none;font-size:.8rem}
|
||||||
|
.rule-tip{font-size:.8rem;color:#95a2c2;margin-bottom:8px}
|
||||||
|
table{width:100%;border-collapse:collapse}
|
||||||
|
th,td{padding:8px;text-align:left;border-bottom:1px solid #25253b;font-size:.85rem}
|
||||||
|
th{color:#a9a9ff}
|
||||||
|
.badge{padding:2px 6px;border-radius:6px;font-size:.72rem}
|
||||||
|
.profit{background:#1e332f;color:#4cd97f}
|
||||||
|
.loss{background:#331e24;color:#ff6666}
|
||||||
|
.miss{background:#29241e;color:#eac147}
|
||||||
|
.direction{background:#1e2533;color:#4cc2ff}
|
||||||
|
.direction-long{background:#1e332f;color:#4cd97f}
|
||||||
|
.direction-short{background:#331e24;color:#ff6666}
|
||||||
|
.pnl-profit{color:#4cd97f;font-weight:600}
|
||||||
|
.pnl-loss{color:#ff6666;font-weight:600}
|
||||||
|
.flash{padding:10px;background:#1e2533;color:#4cc2ff;border-radius:10px;margin-bottom:12px;text-align:center;border:1px solid #304164}
|
||||||
|
form.is-form-submitting{opacity:.88;pointer-events:none}
|
||||||
|
form.is-form-submitting button[type=submit],form.is-form-submitting input[type=submit]{cursor:wait}
|
||||||
|
.ai-result{background:#1a1a29;border:1px solid #2e2e45;border-radius:8px;padding:10px;white-space:pre-wrap;max-height:220px;overflow:auto;font-size:.84rem;line-height:1.45;margin-top:8px}
|
||||||
|
.ai-result.ai-result-md,.detail-modal .panel-body.md-review{white-space:normal}
|
||||||
|
.ai-result-md p,.detail-modal .panel-body.md-review p{margin:6px 0;color:#dde2ff}
|
||||||
|
.ai-result-md ul,.ai-result-md ol,.detail-modal .panel-body.md-review ul,.detail-modal .panel-body.md-review ol{margin:6px 0 8px 1.25em;padding:0}
|
||||||
|
.ai-result-md li,.detail-modal .panel-body.md-review li{margin:5px 0;line-height:1.5}
|
||||||
|
.ai-result-md strong,.detail-modal .panel-body.md-review strong{color:#f0f3ff;font-weight:600}
|
||||||
|
.ai-result-md h2,.detail-modal .panel-body.md-review h2{font-size:1.02rem;color:#b8c8ff;margin:14px 0 8px;padding-bottom:4px;border-bottom:1px solid #2e2e45}
|
||||||
|
.ai-result-md h3,.detail-modal .panel-body.md-review h3{font-size:.92rem;color:#c9d4ff;margin:10px 0 6px}
|
||||||
|
.ai-result-md code,.detail-modal .panel-body.md-review code{background:#252538;padding:1px 4px;border-radius:4px;font-size:.82em}
|
||||||
|
.ai-result-md .md-raw-block-title,.detail-modal .panel-body.md-review .md-raw-block-title{margin-top:14px;padding-top:10px;border-top:1px dashed #3a3a55;color:#a8b0d8;font-weight:600}
|
||||||
|
.price-up{color:#4cd97f}
|
||||||
|
.price-down{color:#ff6666}
|
||||||
|
.price-flat{color:#cfd3ef}
|
||||||
|
.panel-list{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||||
|
.panel-item{background:#141423;border:1px solid #24243b;border-radius:10px;padding:10px;max-height:260px;overflow:auto}
|
||||||
|
.entry{border-bottom:1px solid #2b2b43;padding:8px 0}
|
||||||
|
.entry:last-child{border-bottom:none}
|
||||||
|
.table-del{padding:4px 8px;background:#2f2134;color:#ff7b7b;border:none;border-radius:6px;cursor:pointer;font-size:.78rem}
|
||||||
|
.mood-grid{display:flex;gap:10px;flex-wrap:wrap;font-size:.82rem;color:#d7d7ea}
|
||||||
|
.mood-grid label{display:flex;align-items:center;gap:3px}
|
||||||
|
.screenshot{width:100px;border-radius:6px;cursor:pointer;margin-top:6px}
|
||||||
|
.modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1210}
|
||||||
|
.modal img{max-width:90%;max-height:90%;border-radius:8px}
|
||||||
|
.detail-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1200;padding:20px}
|
||||||
|
.detail-modal .panel{width:min(92vw,980px);max-height:88vh;overflow:auto;background:#121726;border:1px solid #2a3150;border-radius:10px;padding:14px}
|
||||||
|
.detail-modal .panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px}
|
||||||
|
.detail-modal .panel-title{font-size:1rem;color:#dbe4ff}
|
||||||
|
.detail-modal .panel-close{padding:6px 10px;background:#2f2134;color:#ffb2b2;border:none;border-radius:8px;cursor:pointer}
|
||||||
|
.detail-modal .panel-body{white-space:pre-wrap;line-height:1.5;font-size:.86rem;color:#e5e9ff}
|
||||||
|
.detail-modal .panel-image{margin-top:10px;max-width:min(100%,680px);border-radius:8px;cursor:pointer;border:1px solid #2a3150}
|
||||||
|
.detail-modal .panel-actions{display:flex;gap:8px;align-items:center;flex-shrink:0}
|
||||||
|
.detail-modal .panel-fs{padding:6px 10px;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;cursor:pointer;font-size:.82rem}
|
||||||
|
.detail-modal.fullscreen{padding:10px}
|
||||||
|
.detail-modal.fullscreen .panel{width:100%;height:100%;max-width:none;max-height:none;display:flex;flex-direction:column;overflow:hidden}
|
||||||
|
.detail-modal.fullscreen .panel-body{flex:1;overflow:auto;min-height:0;font-size:.9rem}
|
||||||
|
.ai-result-wrap{margin-top:8px}
|
||||||
|
.ai-result-toolbar{display:flex;gap:8px;margin-top:6px}
|
||||||
|
.ai-result-toolbar .btn-fs{padding:4px 10px;font-size:.78rem;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:6px;cursor:pointer}
|
||||||
|
.table-wrap{overflow-x:auto}
|
||||||
|
.dual-panel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:stretch}
|
||||||
|
.dual-panel-grid .card{height:100%;display:flex;flex-direction:column}
|
||||||
|
.panel-scroll{flex:1;min-height:280px;max-height:420px;overflow:auto}
|
||||||
|
.records-card{grid-column:1/-1}
|
||||||
|
.review-card{grid-column:1/-1}
|
||||||
|
.review-card-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap}
|
||||||
|
.review-card-head h2{margin:0}
|
||||||
|
.review-card-fs-btn{padding:6px 12px;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;cursor:pointer;font-size:.82rem;white-space:nowrap}
|
||||||
|
.review-card-fs-btn:hover{filter:brightness(1.08)}
|
||||||
|
body.review-card-fullscreen-open{overflow:hidden}
|
||||||
|
.review-card.is-fullscreen{
|
||||||
|
position:fixed;inset:12px;z-index:1100;margin:0;
|
||||||
|
width:auto !important;max-width:none;height:auto;
|
||||||
|
overflow:auto;display:flex;flex-direction:column;
|
||||||
|
box-shadow:0 12px 48px rgba(0,0,0,.55);
|
||||||
|
}
|
||||||
|
.review-card.is-fullscreen .panel-list{flex:1;min-height:320px}
|
||||||
|
.review-card.is-fullscreen .panel-item{max-height:none;height:auto;min-height:280px}
|
||||||
|
.review-card.is-fullscreen .ai-result{max-height:min(36vh, 320px)}
|
||||||
|
@media (max-width: 1200px){
|
||||||
|
.stat-box{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))}
|
||||||
|
}
|
||||||
|
@media (min-width: 1440px){
|
||||||
|
.panel-scroll,.pos-list{max-height:420px}
|
||||||
|
.records-card .table-wrap{max-height:620px;overflow:auto}
|
||||||
|
}
|
||||||
|
@media (min-width: 2200px){
|
||||||
|
.container{max-width:min(1720px,90vw)}
|
||||||
|
}
|
||||||
|
@media (min-width: 2560px){
|
||||||
|
.container{max-width:min(1860px,88vw)}
|
||||||
|
.dual-panel-grid{gap:18px}
|
||||||
|
}
|
||||||
|
@media (min-width: 3000px){
|
||||||
|
.container{max-width:min(1980px,86vw)}
|
||||||
|
.pos-grid{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px){
|
||||||
|
.grid{grid-template-columns:1fr}
|
||||||
|
.dual-panel-grid{grid-template-columns:1fr}
|
||||||
|
.records-card,.review-card{grid-column:auto}
|
||||||
|
.panel-list{grid-template-columns:1fr}
|
||||||
|
}
|
||||||
|
@media (max-width: 960px){
|
||||||
|
body{padding:10px}
|
||||||
|
.form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||||
|
.stat-box{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||||
|
}
|
||||||
|
.stats-detail{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px;margin-top:10px}
|
||||||
|
.stats-detail .stat-item{min-width:0;min-height:0;display:block;text-align:left;padding:10px 12px;align-items:stretch;gap:4px}
|
||||||
|
.stats-detail .stat-item .value{min-height:0;display:block;font-size:1.05rem}
|
||||||
|
.stats-detail .stat-item .label{font-size:.75rem}
|
||||||
|
.stats-detail .stat-item .value{font-size:1.05rem;word-break:break-all}
|
||||||
|
.export-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;font-size:.85rem}
|
||||||
|
.export-bar a{color:#8fc8ff;text-decoration:none;padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a}
|
||||||
|
.export-bar a:hover{background:#1f2740}
|
||||||
|
.list-window-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;padding:10px 12px;background:#151a2a;border:1px solid #304164;border-radius:10px;font-size:.82rem}
|
||||||
|
.list-window-bar label{color:#9aa;display:flex;align-items:center;gap:6px}
|
||||||
|
.stats-segment-block{margin-top:20px;padding-top:14px;border-top:1px solid #3a4468}
|
||||||
|
.stats-segment-block h2{font-size:1.05rem;color:#dbe4ff;margin-bottom:8px}
|
||||||
|
.key-history{margin-top:12px;padding-top:10px;border-top:1px solid #2a3150}
|
||||||
|
.key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px}
|
||||||
|
.key-history .sub{font-size:.72rem;color:#8892b0;margin-bottom:6px}
|
||||||
|
.key-history .list{max-height:200px}
|
||||||
|
.pos-section{margin-top:12px}
|
||||||
|
.pos-section-title{font-size:.82rem;color:#8892b0;margin-bottom:8px;font-weight:500}
|
||||||
|
.pos-list{display:flex;flex-direction:column;gap:10px;max-height:280px;overflow:auto}
|
||||||
|
.dual-panel-grid .pos-list-live{max-height:none;overflow:visible;flex:1 1 auto}
|
||||||
|
.dual-panel-grid .panel-scroll.pos-list-live{max-height:none;overflow:visible}
|
||||||
|
.pos-card{background:#141923;border:1px solid #2a3348;border-radius:10px;padding:12px 14px}
|
||||||
|
.pos-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}
|
||||||
|
.pos-meta{font-size:.74rem;color:#8b95a8;line-height:1.45;margin-bottom:12px;display:flex;flex-wrap:wrap;align-items:center;gap:4px 0}
|
||||||
|
.pos-meta-item{display:inline-flex;align-items:center}
|
||||||
|
.pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659}
|
||||||
|
.pos-meta-on{color:#6eb5ff}
|
||||||
|
.pos-meta-off{color:#7d8799}
|
||||||
|
.pos-breakeven-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:6px;font-size:.72rem;font-weight:600;background:#1a3d2e;color:#4cd97f}
|
||||||
|
.pos-card-symbol{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
|
||||||
|
.pos-card-symbol strong{font-size:.95rem;color:#fff;font-weight:600}
|
||||||
|
.pos-side-badge{padding:3px 8px;border-radius:6px;font-size:.72rem;font-weight:500;line-height:1.2}
|
||||||
|
.pos-side-long{background:#253a6e;color:#6eb5ff}
|
||||||
|
.pos-side-short{background:#4a2230;color:#ff8a8a}
|
||||||
|
.pos-head-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}
|
||||||
|
.pos-entrust-btn{padding:6px 12px;background:#2a4a7a;color:#8fc8ff;border:none;border-radius:8px;font-size:.82rem;font-weight:500;cursor:pointer;white-space:nowrap}
|
||||||
|
.pos-entrust-btn:hover{background:#355d96}
|
||||||
|
.pos-close-btn{padding:6px 14px;background:#c45454;color:#fff;border-radius:8px;text-decoration:none;font-size:.82rem;font-weight:500;flex-shrink:0;white-space:nowrap;border:none;cursor:pointer;display:inline-block}
|
||||||
|
.pos-close-btn:hover{background:#d66565;color:#fff}
|
||||||
|
.pos-ex-orders{margin-top:10px;padding-top:10px;border-top:1px dashed #2a3348}
|
||||||
|
.pos-ex-orders-title{font-size:.74rem;color:#7d8799;margin-bottom:6px}
|
||||||
|
.pos-ex-order-row{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:.78rem;color:#c5cce0;margin-top:5px}
|
||||||
|
.pos-ex-order-main{flex:1;min-width:0;line-height:1.35}
|
||||||
|
.pos-ex-cancel-btn{padding:3px 10px;background:#3a3048;color:#d4b8ff;border:none;border-radius:6px;font-size:.74rem;cursor:pointer;flex-shrink:0}
|
||||||
|
.pos-ex-cancel-btn:disabled{opacity:.4;cursor:not-allowed}
|
||||||
|
.tpsl-modal-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9000;align-items:center;justify-content:center;padding:16px}
|
||||||
|
.tpsl-modal-backdrop.open{display:flex}
|
||||||
|
.tpsl-modal{background:#1a2030;border:1px solid #3a4a66;border-radius:12px;padding:16px 18px;width:min(440px,100%);max-height:90vh;overflow:auto}
|
||||||
|
.tpsl-modal h3{margin:0 0 12px;font-size:1rem;color:#fff}
|
||||||
|
.tpsl-modal .form-row{margin-bottom:10px}
|
||||||
|
.tpsl-modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
|
||||||
|
.tpsl-modal-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem}
|
||||||
|
.tpsl-modal-submit{background:#2d6a4f;color:#fff}
|
||||||
|
.tpsl-modal-cancel{background:#3a3f52;color:#ddd}
|
||||||
|
.pos-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px 14px;margin-bottom:12px}
|
||||||
|
.pos-cell{display:flex;flex-direction:column;gap:4px;min-width:0}
|
||||||
|
.pos-label{font-size:.72rem;color:#7d8799}
|
||||||
|
.pos-value{font-size:.88rem;color:#e8ecf4;font-weight:500;line-height:1.25}
|
||||||
|
.pos-val-dash{opacity:.75;color:#8b95a8}
|
||||||
|
.pos-value.price-up{color:#4cd97f}
|
||||||
|
.pos-value.price-down{color:#ff6666}
|
||||||
|
.pos-value.price-flat{color:#e8ecf4}
|
||||||
|
.pos-footer{display:flex;flex-wrap:wrap;gap:14px 18px;font-size:.75rem;color:#6d7689}
|
||||||
|
.pos-empty{padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px}
|
||||||
|
@media (max-width:520px){.pos-grid{grid-template-columns:repeat(2,1fr)}}
|
||||||
|
.stats-card{grid-column:1/-1;margin-top:14px}
|
||||||
|
.stats-card .stats-toggle{background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;padding:6px 10px;cursor:pointer}
|
||||||
|
.stats-card.collapsed .stats-content{display:none}
|
||||||
|
.stats-period-block{margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid #2a3150}
|
||||||
|
.stats-period-block:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0}
|
||||||
|
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
|
||||||
|
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
||||||
|
#embed-page-root{transition:opacity .12s ease}
|
||||||
|
#embed-page-root.is-embed-tab-loading{opacity:.55;pointer-events:none}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* 手机端:交易记录 / 复盘记录紧凑列表(币种 · 方向 · 盈亏),点击展开详情。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var resizeTimer = null;
|
||||||
|
|
||||||
|
function refreshTradeRecords() {
|
||||||
|
var UI = global.InstanceUI;
|
||||||
|
if (!UI) return;
|
||||||
|
var card = document.querySelector(".records-card");
|
||||||
|
if (!card) return;
|
||||||
|
var tableWrap = card.querySelector(".table-wrap");
|
||||||
|
var table = tableWrap && tableWrap.querySelector("table");
|
||||||
|
if (!table) return;
|
||||||
|
|
||||||
|
var listEl = card.querySelector(".mobile-record-list");
|
||||||
|
var mobile = UI.isMobileCompactRecords();
|
||||||
|
|
||||||
|
if (!mobile) {
|
||||||
|
if (listEl) listEl.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!listEl) {
|
||||||
|
listEl = document.createElement("div");
|
||||||
|
listEl.className = "mobile-record-list";
|
||||||
|
tableWrap.parentNode.insertBefore(listEl, tableWrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = table.querySelectorAll('tr[id^="trade-row-"]');
|
||||||
|
listEl.innerHTML = rows.length
|
||||||
|
? Array.prototype.map
|
||||||
|
.call(rows, function (tr) {
|
||||||
|
return UI.renderMobileTradeRow(tr);
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
: '<div class="journal-empty-msg">暂无交易记录</div>';
|
||||||
|
|
||||||
|
listEl.querySelectorAll(".mobile-record-row").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
var rowId = btn.getAttribute("data-row-id");
|
||||||
|
var tr = rowId && document.getElementById(rowId);
|
||||||
|
if (tr) UI.openTradeRecordDetailModal(tr);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize() {
|
||||||
|
if (resizeTimer) clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(function () {
|
||||||
|
refreshTradeRecords();
|
||||||
|
if (typeof global.loadJournals === "function" && document.getElementById("journal-list")) {
|
||||||
|
global.loadJournals();
|
||||||
|
}
|
||||||
|
}, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
refreshTradeRecords();
|
||||||
|
global.addEventListener("resize", onResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
global.InstanceRecordsMobile = {
|
||||||
|
refresh: refreshTradeRecords,
|
||||||
|
};
|
||||||
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
@@ -78,9 +78,235 @@
|
|||||||
.card {
|
.card {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-box {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-panel-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-card .table-wrap {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-record-list {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-record-row-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-record-row {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) auto minmax(0, 0.9fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(120, 140, 200, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(18, 24, 42, 0.65);
|
||||||
|
color: #e8ecff;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-record-row:active {
|
||||||
|
background: rgba(30, 42, 72, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mrr-symbol {
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mrr-dir {
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mrr-dir .badge {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mrr-pnl {
|
||||||
|
justify-self: end;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mrr-muted {
|
||||||
|
color: #8892b0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-record-del {
|
||||||
|
flex: 0 0 36px;
|
||||||
|
width: 36px;
|
||||||
|
border: 1px solid rgba(200, 80, 80, 0.35);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(80, 24, 24, 0.35);
|
||||||
|
color: #ff9a9a;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#journal-list .entry {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#journal-list .journal-empty-msg {
|
||||||
|
color: #8892b0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#detailActions.detail-actions,
|
||||||
|
.detail-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px 14px;
|
||||||
|
border-top: 1px solid rgba(120, 140, 200, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-actions-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-actions .table-del,
|
||||||
|
.detail-actions button {
|
||||||
|
font-size: 0.78rem !important;
|
||||||
|
padding: 6px 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-modal .panel-body.trade-record-detail-wrap {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trd-row {
|
||||||
|
grid-template-columns: 76px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 721px) {
|
||||||
|
.mobile-record-list {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-modal .panel-body.trade-record-detail-wrap {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-record-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trd-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 92px minmax(0, 1fr);
|
||||||
|
gap: 8px 12px;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trd-label {
|
||||||
|
color: #8892b0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trd-value {
|
||||||
|
color: #e5e9ff;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
text-align: left;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trd-value .badge {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 手机竖屏(含大屏手机) */
|
||||||
|
@media (max-width: 900px) and (orientation: portrait) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-panel-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 平板横屏:双列布局,充分利用宽屏 */
|
||||||
|
@media (min-width: 721px) and (max-width: 1200px) and (orientation: landscape) {
|
||||||
|
body {
|
||||||
|
padding: 10px 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-panel-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-box {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-card,
|
||||||
|
.review-card {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 实例页亮色主题(覆盖模板内联暗色样式) */
|
|
||||||
html[data-theme="light"] {
|
html[data-theme="light"] {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
@@ -1023,3 +1249,326 @@ html[data-theme="light"] .key-row-collapse.key-history-failed .key-history-outco
|
|||||||
border-color: rgba(192, 48, 48, 0.22) !important;
|
border-color: rgba(192, 48, 48, 0.22) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .trd-label {
|
||||||
|
color: #6a7588 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .trd-value {
|
||||||
|
color: #142232 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .mobile-record-row {
|
||||||
|
background: #fff !important;
|
||||||
|
border-color: #b8c8d8 !important;
|
||||||
|
color: #142232 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .mobile-record-row:active {
|
||||||
|
background: #eef3f8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .mrr-muted {
|
||||||
|
color: #6a7588 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .mobile-record-del {
|
||||||
|
background: rgba(192, 48, 48, 0.08) !important;
|
||||||
|
border-color: rgba(192, 48, 48, 0.28) !important;
|
||||||
|
color: #b04040 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .detail-actions {
|
||||||
|
border-top-color: #d0dae4 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 顺势加仓:表单字段按模式显隐(CSS 兜底,不依赖 JS)── */
|
||||||
|
#roll-form[data-add-mode="market"] .roll-field-fib,
|
||||||
|
#roll-form[data-add-mode="market"] .roll-field-breakout {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#roll-form[data-add-mode="fib_618"] .roll-field-breakout,
|
||||||
|
#roll-form[data-add-mode="fib_786"] .roll-field-breakout {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#roll-form[data-add-mode="breakout"] .roll-field-fib {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#roll-form[data-add-mode="fib_618"] .roll-field-fib,
|
||||||
|
#roll-form[data-add-mode="fib_786"] .roll-field-fib,
|
||||||
|
#roll-form[data-add-mode="breakout"] .roll-field-breakout {
|
||||||
|
display: inline-flex !important;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#roll-form[data-add-mode="fib_618"] #roll-preview-btn,
|
||||||
|
#roll-form[data-add-mode="fib_786"] #roll-preview-btn,
|
||||||
|
#roll-form[data-add-mode="breakout"] #roll-preview-btn {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#strategy-roll-panel .roll-risk-banner {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #8fc8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] #strategy-roll-panel .roll-risk-banner {
|
||||||
|
color: #006e9a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#strategy-roll-panel .roll-doc-link {
|
||||||
|
color: #8fc8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] #strategy-roll-panel .roll-doc-link {
|
||||||
|
color: #006e9a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#strategy-roll-panel .roll-section-title {
|
||||||
|
margin: 14px 0 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #b8c4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] #strategy-roll-panel .roll-section-title {
|
||||||
|
color: #006e9a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#roll-preview-box.roll-preview-box {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #3a5a8a;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #141a28;
|
||||||
|
color: #dde2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#roll-preview-box.roll-preview-box.is-error {
|
||||||
|
border-color: #8a3a4a;
|
||||||
|
background: #1a1218;
|
||||||
|
color: #ffb4b4;
|
||||||
|
}
|
||||||
|
|
||||||
|
#roll-preview-box.roll-preview-box.is-preview {
|
||||||
|
border-color: #3a5a8a;
|
||||||
|
background: #141a28;
|
||||||
|
color: #dde2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] #roll-preview-box.roll-preview-box {
|
||||||
|
background: #f6f9fc !important;
|
||||||
|
border-color: #b8c8d8 !important;
|
||||||
|
color: #1a2838 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] #roll-preview-box.roll-preview-box.is-error {
|
||||||
|
background: #fff5f5 !important;
|
||||||
|
border-color: #d8a0a8 !important;
|
||||||
|
color: #8a2030 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#roll-countdown.roll-countdown {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #ffb347;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] #roll-countdown.roll-countdown {
|
||||||
|
color: #a06010 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 顺势加仓说明页 ── */
|
||||||
|
body.roll-doc-page {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
background: #0f1117;
|
||||||
|
color: #e6e8ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] body.roll-doc-page {
|
||||||
|
background: #eef3f8 !important;
|
||||||
|
color: #142232 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roll-doc-container {
|
||||||
|
max-width: 920px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roll-doc-nav {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roll-doc-nav a {
|
||||||
|
color: #8fc8ff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .roll-doc-nav a {
|
||||||
|
color: #006e9a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roll-doc-body {
|
||||||
|
background: #151a2a;
|
||||||
|
border: 1px solid #2a3150;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
line-height: 1.65;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .roll-doc-body {
|
||||||
|
background: #fff !important;
|
||||||
|
border-color: #b8c8d8 !important;
|
||||||
|
color: #1a2838 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roll-doc-body h1 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: #f0f2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .roll-doc-body h1 {
|
||||||
|
color: #142232 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roll-doc-body h2 {
|
||||||
|
font-size: 1.08rem;
|
||||||
|
margin: 22px 0 10px;
|
||||||
|
color: #b8c4ff;
|
||||||
|
border-bottom: 1px solid #2a3150;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .roll-doc-body h2 {
|
||||||
|
color: #006e9a !important;
|
||||||
|
border-bottom-color: #d0dae4 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roll-doc-body h3 {
|
||||||
|
font-size: 0.98rem;
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
color: #c9d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .roll-doc-body h3 {
|
||||||
|
color: #142232 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roll-doc-body p,
|
||||||
|
.roll-doc-body li {
|
||||||
|
color: #dde2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .roll-doc-body p,
|
||||||
|
html[data-theme="light"] .roll-doc-body li {
|
||||||
|
color: #1a2838 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roll-doc-body ul,
|
||||||
|
.roll-doc-body ol {
|
||||||
|
margin: 8px 0 12px 1.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roll-doc-body code {
|
||||||
|
background: #252538;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.88em;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .roll-doc-body code {
|
||||||
|
background: #e8eef5 !important;
|
||||||
|
color: #142232 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roll-doc-body pre {
|
||||||
|
background: #0f1420;
|
||||||
|
border: 1px solid #2a3150;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #dde2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .roll-doc-body pre {
|
||||||
|
background: #f6f9fc !important;
|
||||||
|
border-color: #b8c8d8 !important;
|
||||||
|
color: #142232 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roll-doc-body pre code {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roll-doc-body table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 10px 0;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roll-doc-body th,
|
||||||
|
.roll-doc-body td {
|
||||||
|
border: 1px solid #2a3150;
|
||||||
|
padding: 6px 8px;
|
||||||
|
text-align: left;
|
||||||
|
color: #dde2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .roll-doc-body th,
|
||||||
|
html[data-theme="light"] .roll-doc-body td {
|
||||||
|
border-color: #b8c8d8 !important;
|
||||||
|
color: #1a2838 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roll-doc-body th {
|
||||||
|
background: #1a2030;
|
||||||
|
color: #b8c4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .roll-doc-body th {
|
||||||
|
background: #e8eef5 !important;
|
||||||
|
color: #142232 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roll-doc-body hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #2a3150;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .roll-doc-body hr {
|
||||||
|
border-top-color: #d0dae4 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 实盘下单:预估风险/盈利/盈亏比条 ── */
|
||||||
|
html[data-theme="light"] .order-plan-preview {
|
||||||
|
background: #f6f9fc !important;
|
||||||
|
border-color: #b8c8d8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .order-preview-rr {
|
||||||
|
color: #4a6078 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .order-preview-rr strong {
|
||||||
|
color: #142232 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .order-preview-risk strong {
|
||||||
|
color: #b03030 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .order-preview-profit strong {
|
||||||
|
color: #087a50 !important;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -63,6 +63,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _linkedTheme = null;
|
let _linkedTheme = null;
|
||||||
|
let _appliedTheme = null;
|
||||||
|
|
||||||
function get() {
|
function get() {
|
||||||
if (isHubLinked()) {
|
if (isHubLinked()) {
|
||||||
@@ -191,13 +192,25 @@
|
|||||||
const options = opts || {};
|
const options = opts || {};
|
||||||
const linked = isHubLinked();
|
const linked = isHubLinked();
|
||||||
const t = normalize(theme);
|
const t = normalize(theme);
|
||||||
|
const root = document.documentElement;
|
||||||
|
const unchanged =
|
||||||
|
!options.force &&
|
||||||
|
_appliedTheme === t &&
|
||||||
|
root.getAttribute("data-theme") === t;
|
||||||
|
if (unchanged) {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
_appliedTheme = t;
|
||||||
if (linked) {
|
if (linked) {
|
||||||
_linkedTheme = t;
|
_linkedTheme = t;
|
||||||
writeLinkedThemeStorage(t);
|
writeLinkedThemeStorage(t);
|
||||||
} else if (!options.skipStore) {
|
root.setAttribute("data-hub-linked", "1");
|
||||||
|
} else {
|
||||||
|
root.removeAttribute("data-hub-linked");
|
||||||
|
}
|
||||||
|
if (!linked && !options.skipStore) {
|
||||||
setStandalone(t);
|
setStandalone(t);
|
||||||
}
|
}
|
||||||
const root = document.documentElement;
|
|
||||||
root.setAttribute("data-theme", t);
|
root.setAttribute("data-theme", t);
|
||||||
const meta = document.querySelector('meta[name="theme-color"]');
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
if (meta) meta.setAttribute("content", META[t]);
|
if (meta) meta.setAttribute("content", META[t]);
|
||||||
@@ -283,10 +296,214 @@
|
|||||||
apply(data.theme, { skipStore: true });
|
apply(data.theme, { skipStore: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 交易记录页:核对开关与按钮 disabled 保持同步(iframe 软导航/表单恢复后不触发 change) */
|
||||||
|
function syncReviewEditButtons() {
|
||||||
|
const toggle = document.getElementById("review-mode-toggle");
|
||||||
|
if (!toggle) return;
|
||||||
|
const on = !!toggle.checked;
|
||||||
|
document.querySelectorAll(".review-edit-btn").forEach((btn) => {
|
||||||
|
btn.disabled = !on;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initReviewEditModeSync() {
|
||||||
|
const toggle = document.getElementById("review-mode-toggle");
|
||||||
|
if (!toggle) return;
|
||||||
|
if (toggle.dataset.instReviewModeBound !== "1") {
|
||||||
|
toggle.dataset.instReviewModeBound = "1";
|
||||||
|
toggle.addEventListener("input", () => {
|
||||||
|
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
|
||||||
|
else syncReviewEditButtons();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const run = () => {
|
||||||
|
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
|
||||||
|
else syncReviewEditButtons();
|
||||||
|
};
|
||||||
|
run();
|
||||||
|
requestAnimationFrame(run);
|
||||||
|
setTimeout(run, 0);
|
||||||
|
if (!global.__instReviewModePageshowBound) {
|
||||||
|
global.__instReviewModePageshowBound = true;
|
||||||
|
window.addEventListener("pageshow", run);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyParentFrameNavStart() {
|
||||||
|
if (!isHubLinked()) return;
|
||||||
|
try {
|
||||||
|
window.parent.postMessage({ type: "instance-frame-navigating", theme: get() }, "*");
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyParentFrameReady() {
|
||||||
|
if (!isHubLinked()) return;
|
||||||
|
dismissNavOverlay();
|
||||||
|
try {
|
||||||
|
window.parent.postMessage({ type: "instance-frame-ready", theme: get() }, "*");
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureNavOverlay() {
|
||||||
|
const t = normalize(get());
|
||||||
|
const bg = META[t];
|
||||||
|
let el = document.getElementById("inst-nav-overlay");
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement("div");
|
||||||
|
el.id = "inst-nav-overlay";
|
||||||
|
el.setAttribute("aria-hidden", "true");
|
||||||
|
(document.body || document.documentElement).appendChild(el);
|
||||||
|
}
|
||||||
|
el.style.cssText =
|
||||||
|
"position:fixed;inset:0;z-index:2147483646;background:" +
|
||||||
|
bg +
|
||||||
|
";opacity:1;pointer-events:auto;transition:opacity 80ms ease;";
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissNavOverlay() {
|
||||||
|
const el = document.getElementById("inst-nav-overlay");
|
||||||
|
if (!el) return;
|
||||||
|
el.style.opacity = "0";
|
||||||
|
window.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
el.remove();
|
||||||
|
} catch (_) {}
|
||||||
|
}, 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectNavOverlayIntoHtml(html, theme) {
|
||||||
|
const t = normalize(theme || get());
|
||||||
|
const bg = META[t];
|
||||||
|
let out = html || "";
|
||||||
|
const guard =
|
||||||
|
'<style id="inst-nav-guard">html,body{background:' +
|
||||||
|
bg +
|
||||||
|
"!important;color-scheme:" +
|
||||||
|
t +
|
||||||
|
';}</style>';
|
||||||
|
if (out.includes("</head>")) {
|
||||||
|
out = out.replace("</head>", guard + "</head>");
|
||||||
|
} else {
|
||||||
|
out = guard + out;
|
||||||
|
}
|
||||||
|
out = out.replace(/<html([^>]*)>/i, (m, attrs) => {
|
||||||
|
if (/data-theme=/i.test(attrs)) {
|
||||||
|
return m.replace(/data-theme="[^"]*"/i, 'data-theme="' + t + '"');
|
||||||
|
}
|
||||||
|
return "<html" + attrs + ' data-theme="' + t + '">';
|
||||||
|
});
|
||||||
|
const overlay =
|
||||||
|
'<div id="inst-nav-overlay" aria-hidden="true" style="position:fixed;inset:0;z-index:2147483646;background:' +
|
||||||
|
bg +
|
||||||
|
';opacity:1;pointer-events:auto"></div>';
|
||||||
|
if (/<body[^>]*>/i.test(out)) {
|
||||||
|
out = out.replace(/<body([^>]*)>/i, "<body$1>" + overlay);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 中控 iframe:fetch 换页 + 页内遮罩,避免整页卸载与中控侧长时间空白。 */
|
||||||
|
function initHubEmbedInFrameNav() {
|
||||||
|
if (!isHubLinked()) return;
|
||||||
|
if (document.body && document.body.getAttribute("data-embed-shell") === "1") return;
|
||||||
|
|
||||||
|
let navToken = 0;
|
||||||
|
|
||||||
|
function isSoftNavLink(a) {
|
||||||
|
if (!a || !a.getAttribute) return false;
|
||||||
|
if (a.hasAttribute("download") || a.target === "_blank") return false;
|
||||||
|
return !!a.closest(".top-nav, .strategy-subnav");
|
||||||
|
}
|
||||||
|
|
||||||
|
function softNavFetch(href) {
|
||||||
|
return fetch(href, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "X-Instance-Soft-Nav": "1" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateInFrame(href, opts) {
|
||||||
|
const token = ++navToken;
|
||||||
|
notifyParentFrameNavStart();
|
||||||
|
ensureNavOverlay();
|
||||||
|
try {
|
||||||
|
const r = await softNavFetch(href);
|
||||||
|
if (token !== navToken) return;
|
||||||
|
if (!r.ok) {
|
||||||
|
location.assign(href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = await r.text();
|
||||||
|
if (token !== navToken) return;
|
||||||
|
html = injectNavOverlayIntoHtml(html, get());
|
||||||
|
let path = href;
|
||||||
|
try {
|
||||||
|
const u = new URL(href, location.href);
|
||||||
|
path = u.pathname + u.search + u.hash;
|
||||||
|
} catch (_) {}
|
||||||
|
if (opts && opts.replace) history.replaceState(null, "", path);
|
||||||
|
else history.pushState(null, "", path);
|
||||||
|
document.open();
|
||||||
|
document.write(html);
|
||||||
|
document.close();
|
||||||
|
} catch (_) {
|
||||||
|
if (token === navToken) location.assign(href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
"click",
|
||||||
|
(ev) => {
|
||||||
|
const a = ev.target.closest("a[href]");
|
||||||
|
if (!a || !isSoftNavLink(a) || ev.defaultPrevented) return;
|
||||||
|
if (ev.button !== 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
|
||||||
|
const rawHref = a.getAttribute("href");
|
||||||
|
if (!rawHref || rawHref.startsWith("#") || rawHref.startsWith("javascript:")) return;
|
||||||
|
let target;
|
||||||
|
try {
|
||||||
|
target = new URL(rawHref, location.href);
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.origin !== location.origin) return;
|
||||||
|
const nextHref = target.pathname + target.search + target.hash;
|
||||||
|
if (target.pathname === location.pathname && target.search === location.search) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
void navigateInFrame(nextHref);
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
window.addEventListener("popstate", () => {
|
||||||
|
void navigateInFrame(location.pathname + location.search + location.hash, { replace: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function purgeLegacySoftNavCache() {
|
||||||
|
try {
|
||||||
|
for (let i = localStorage.length - 1; i >= 0; i -= 1) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (!key) continue;
|
||||||
|
if (
|
||||||
|
key.startsWith("inst-pc:") ||
|
||||||
|
key === "inst-page-cache-index" ||
|
||||||
|
key === "inst-page-cache-days"
|
||||||
|
) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessionStorage.removeItem("inst-soft-nav");
|
||||||
|
sessionStorage.removeItem("inst-cache-revalidate");
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
|
purgeLegacySoftNavCache();
|
||||||
if (isHubLinked()) {
|
if (isHubLinked()) {
|
||||||
apply(get(), { skipStore: true });
|
apply(get(), { skipStore: true });
|
||||||
window.addEventListener("message", (ev) => initFromHubMessage(ev.data));
|
window.addEventListener("message", (ev) => initFromHubMessage(ev.data));
|
||||||
|
initHubEmbedInFrameNav();
|
||||||
try {
|
try {
|
||||||
window.parent.postMessage({ type: "instance-theme-ready" }, "*");
|
window.parent.postMessage({ type: "instance-theme-ready" }, "*");
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -312,9 +529,15 @@
|
|||||||
const onReady = () => {
|
const onReady = () => {
|
||||||
initToggleUI();
|
initToggleUI();
|
||||||
initMobileTopNav();
|
initMobileTopNav();
|
||||||
|
initReviewEditModeSync();
|
||||||
syncInlineStyles(get());
|
syncInlineStyles(get());
|
||||||
patchHubNavLinks(get());
|
patchHubNavLinks(get());
|
||||||
observeDynamicLists();
|
observeDynamicLists();
|
||||||
|
if (isHubLinked()) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => notifyParentFrameReady());
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener("DOMContentLoaded", onReady);
|
document.addEventListener("DOMContentLoaded", onReady);
|
||||||
@@ -343,5 +566,7 @@
|
|||||||
syncInlineStyles,
|
syncInlineStyles,
|
||||||
patchHubNavLinks,
|
patchHubNavLinks,
|
||||||
mergeHubQueryIntoHref,
|
mergeHubQueryIntoHref,
|
||||||
|
syncReviewEditButtons,
|
||||||
|
initReviewEditModeSync,
|
||||||
};
|
};
|
||||||
})(typeof window !== "undefined" ? window : globalThis);
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
@@ -1,9 +1,24 @@
|
|||||||
/* 紧接 instance_theme.js 之后加载,避免亮色下先闪暗色底 */
|
/* 紧接 instance_theme.js 之后加载,避免亮色下先闪暗色底 */
|
||||||
|
html {
|
||||||
|
background: #0b0d14;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] {
|
||||||
|
background: #d8e2ec;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
html[data-theme="light"] body {
|
html[data-theme="light"] body {
|
||||||
background: #d8e2ec !important;
|
background: #d8e2ec !important;
|
||||||
color: #1a2838 !important;
|
color: #1a2838 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.review-edit-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
html[data-theme="light"] .header h1 {
|
html[data-theme="light"] .header h1 {
|
||||||
color: #142232 !important;
|
color: #142232 !important;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* 四所实例共用 UI:复盘详情、盈亏着色等。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pnlClassFromValue(val) {
|
||||||
|
const n = Number(String(val == null ? "" : val).replace(/[^\d.-]/g, ""));
|
||||||
|
if (!Number.isFinite(n) || n === 0) return "";
|
||||||
|
return n > 0 ? "pnl-profit" : "pnl-loss";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPnlSpan(val, suffix) {
|
||||||
|
const sfx = suffix == null ? "U" : suffix;
|
||||||
|
const cls = pnlClassFromValue(val);
|
||||||
|
const text = escapeHtml(val == null || val === "" ? "-" : val) + sfx;
|
||||||
|
return cls ? `<span class="${cls}">${text}</span>` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildJournalDetailHtml(o, formatExitLine) {
|
||||||
|
const moodTags =
|
||||||
|
Array.isArray(o.mood_issues) && o.mood_issues.length
|
||||||
|
? o.mood_issues.join(",")
|
||||||
|
: o.mood_issues || "无";
|
||||||
|
const exitText =
|
||||||
|
typeof formatExitLine === "function" ? formatExitLine(o) : o.exit_reason || "无";
|
||||||
|
const lines = [
|
||||||
|
`币种/周期:${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "-")}`,
|
||||||
|
`开仓时间:${escapeHtml(o.open_datetime || "-")}`,
|
||||||
|
`平仓时间:${escapeHtml(o.close_datetime || "-")}`,
|
||||||
|
`持仓时长:${escapeHtml(o.hold_duration || "-")}`,
|
||||||
|
`盈亏:${formatPnlSpan(o.pnl)}`,
|
||||||
|
`开仓类型:${escapeHtml(o.entry_reason || "无")}`,
|
||||||
|
`平仓/离场:${escapeHtml(exitText)}`,
|
||||||
|
`预期RR:${escapeHtml(o.expect_rr || "-")}`,
|
||||||
|
`实际RR:${escapeHtml(o.real_rr || "-")}`,
|
||||||
|
`保本后盯盘:${escapeHtml(o.post_breakeven_stare || "-")}`,
|
||||||
|
`占用时新开仓:${escapeHtml(o.new_trade_while_occupied || "-")}`,
|
||||||
|
`心态标签:${escapeHtml(moodTags)}`,
|
||||||
|
`备注:${escapeHtml(o.note || "无")}`,
|
||||||
|
];
|
||||||
|
return lines.join("<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setJournalDetailBody(o, formatExitLine) {
|
||||||
|
const body = document.getElementById("detailBody");
|
||||||
|
if (!body) return;
|
||||||
|
body.classList.remove("md-review", "trade-record-detail-wrap");
|
||||||
|
body.classList.add("journal-detail-meta");
|
||||||
|
body.innerHTML = buildJournalDetailHtml(o, formatExitLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openJournalDetailModal(id, journalCache, formatExitLine) {
|
||||||
|
const o = journalCache && journalCache[id];
|
||||||
|
if (!o) return;
|
||||||
|
const titleEl = document.getElementById("detailTitle");
|
||||||
|
if (titleEl) {
|
||||||
|
titleEl.innerText = `交易复盘详情|${o.coin || "-"} ${o.tf || "-"}`;
|
||||||
|
}
|
||||||
|
setJournalDetailBody(o, formatExitLine);
|
||||||
|
clearDetailActions();
|
||||||
|
const imgEl = document.getElementById("detailImage");
|
||||||
|
if (imgEl) {
|
||||||
|
if (o.image) {
|
||||||
|
imgEl.src = `/static/images/${o.image}`;
|
||||||
|
imgEl.style.display = "block";
|
||||||
|
} else {
|
||||||
|
imgEl.src = "";
|
||||||
|
imgEl.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof setDetailModalFullscreen === "function") {
|
||||||
|
setDetailModalFullscreen(false);
|
||||||
|
}
|
||||||
|
const modal = document.getElementById("detailModal");
|
||||||
|
if (modal) modal.style.display = "flex";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMobileCompactRecords() {
|
||||||
|
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||||
|
return window.matchMedia("(max-width: 720px)").matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferJournalDirection(o) {
|
||||||
|
const text = String((o && o.entry_reason) || "");
|
||||||
|
if (/做空|空头|short/i.test(text)) {
|
||||||
|
return { text: "做空", cls: "direction-short" };
|
||||||
|
}
|
||||||
|
if (/做多|多头|long/i.test(text)) {
|
||||||
|
return { text: "做多", cls: "direction-long" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderJournalListHtml(data) {
|
||||||
|
if (!data || !data.length) return "";
|
||||||
|
const mobile = isMobileCompactRecords();
|
||||||
|
return data
|
||||||
|
.map(function (o) {
|
||||||
|
if (mobile) {
|
||||||
|
const dir = inferJournalDirection(o);
|
||||||
|
const pnlCls = pnlClassFromValue(o.pnl);
|
||||||
|
const dirHtml = dir
|
||||||
|
? `<span class="badge ${dir.cls}">${escapeHtml(dir.text)}</span>`
|
||||||
|
: `<span class="mrr-muted">-</span>`;
|
||||||
|
const id = escapeHtml(o.id);
|
||||||
|
return `<div class="mobile-record-row-wrap">
|
||||||
|
<button type="button" class="mobile-record-row" onclick="openJournalDetail('${id}')">
|
||||||
|
<span class="mrr-symbol">${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "")}</span>
|
||||||
|
<span class="mrr-dir">${dirHtml}</span>
|
||||||
|
<span class="mrr-pnl ${pnlCls}">${escapeHtml(o.pnl == null || o.pnl === "" ? "-" : o.pnl)}U</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="mobile-record-del" title="删除" onclick="deleteJournal('${id}')">×</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
const moodTags = (o.mood_issues || []).join(",") || "无";
|
||||||
|
const id = escapeHtml(o.id);
|
||||||
|
return `<div class="entry">
|
||||||
|
<div><strong>${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "-")}</strong> | 盈亏:${escapeHtml(o.pnl == null || o.pnl === "" ? "-" : o.pnl)}U</div>
|
||||||
|
<div>开:${escapeHtml(o.open_datetime || "-")} 平:${escapeHtml(o.close_datetime || "-")} 持仓:${escapeHtml(o.hold_duration || "-")}</div>
|
||||||
|
<div>心态标签:${escapeHtml(moodTags)}</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:6px">
|
||||||
|
<button type="button" class="btn-del" style="border:none;cursor:pointer;background:#1f3a5a;color:#8fc8ff" onclick="openJournalDetail('${id}')">查看详情</button>
|
||||||
|
<button type="button" class="btn-del" onclick="deleteJournal('${id}')">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTradeRecordRow(tr) {
|
||||||
|
const cells = tr.querySelectorAll("td");
|
||||||
|
if (cells.length < 14) return null;
|
||||||
|
const dirBadge = cells[2].querySelector(".badge");
|
||||||
|
return {
|
||||||
|
rowId: tr.id,
|
||||||
|
symbol: cells[0].textContent.trim(),
|
||||||
|
type: cells[1].textContent.trim(),
|
||||||
|
directionHtml: (dirBadge ? dirBadge.outerHTML : cells[2].innerHTML).trim(),
|
||||||
|
directionText: cells[2].textContent.trim(),
|
||||||
|
trigger: cells[3].textContent.trim(),
|
||||||
|
stopLoss: cells[4].textContent.trim(),
|
||||||
|
takeProfit: cells[5].textContent.trim(),
|
||||||
|
margin: cells[6].textContent.trim(),
|
||||||
|
leverage: cells[7].textContent.trim(),
|
||||||
|
holdMinutes: cells[8].textContent.trim(),
|
||||||
|
openedAt: cells[9].textContent.trim(),
|
||||||
|
closedAt: cells[10].textContent.trim(),
|
||||||
|
pnlHtml: cells[11].innerHTML.trim(),
|
||||||
|
pnlText: cells[11].textContent.trim(),
|
||||||
|
resultHtml: cells[12].innerHTML.trim(),
|
||||||
|
resultText: cells[12].textContent.trim(),
|
||||||
|
actionsHtml: cells[13].innerHTML,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMobileTradeRow(tr) {
|
||||||
|
const row = parseTradeRecordRow(tr);
|
||||||
|
if (!row) return "";
|
||||||
|
const pnlCls = pnlClassFromValue(row.pnlText);
|
||||||
|
return `<button type="button" class="mobile-record-row" data-row-id="${escapeHtml(row.rowId)}">
|
||||||
|
<span class="mrr-symbol">${escapeHtml(row.symbol)}</span>
|
||||||
|
<span class="mrr-dir">${row.directionHtml}</span>
|
||||||
|
<span class="mrr-pnl ${pnlCls}">${escapeHtml(row.pnlText || "-")}</span>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tradeDetailRow(label, valueHtml) {
|
||||||
|
return `<div class="trd-row"><span class="trd-label">${escapeHtml(label)}</span><span class="trd-value">${valueHtml}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTradeRecordDetailHtml(row) {
|
||||||
|
return `<div class="trade-record-detail">${
|
||||||
|
tradeDetailRow("品种", escapeHtml(row.symbol)) +
|
||||||
|
tradeDetailRow("类型", escapeHtml(row.type)) +
|
||||||
|
tradeDetailRow("方向", row.directionHtml) +
|
||||||
|
tradeDetailRow("成交价", escapeHtml(row.trigger)) +
|
||||||
|
tradeDetailRow("止损(开仓)", escapeHtml(row.stopLoss)) +
|
||||||
|
tradeDetailRow("止盈", escapeHtml(row.takeProfit)) +
|
||||||
|
tradeDetailRow("基数", escapeHtml(row.margin)) +
|
||||||
|
tradeDetailRow("杠杆", escapeHtml(row.leverage)) +
|
||||||
|
tradeDetailRow("持仓分钟", escapeHtml(row.holdMinutes)) +
|
||||||
|
tradeDetailRow("开仓时间", escapeHtml(row.openedAt)) +
|
||||||
|
tradeDetailRow("平仓时间", escapeHtml(row.closedAt)) +
|
||||||
|
tradeDetailRow("盈亏U", row.pnlHtml) +
|
||||||
|
tradeDetailRow("结果", row.resultHtml)
|
||||||
|
}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDetailActions() {
|
||||||
|
const el = document.getElementById("detailActions");
|
||||||
|
if (el) {
|
||||||
|
el.innerHTML = "";
|
||||||
|
el.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDetailActionsHtml(html) {
|
||||||
|
let el = document.getElementById("detailActions");
|
||||||
|
if (!el) {
|
||||||
|
const panel = document.querySelector("#detailModal .panel");
|
||||||
|
if (!panel) return;
|
||||||
|
el = document.createElement("div");
|
||||||
|
el.id = "detailActions";
|
||||||
|
el.className = "detail-actions";
|
||||||
|
const body = document.getElementById("detailBody");
|
||||||
|
if (body && body.parentNode === panel) {
|
||||||
|
panel.insertBefore(el, body.nextSibling);
|
||||||
|
} else {
|
||||||
|
panel.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.innerHTML = html || "";
|
||||||
|
el.style.display = html ? "flex" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTradeRecordDetailModal(tr) {
|
||||||
|
const row = parseTradeRecordRow(tr);
|
||||||
|
if (!row) return;
|
||||||
|
const titleEl = document.getElementById("detailTitle");
|
||||||
|
if (titleEl) {
|
||||||
|
titleEl.innerText = `交易记录|${row.symbol}`;
|
||||||
|
}
|
||||||
|
const body = document.getElementById("detailBody");
|
||||||
|
if (body) {
|
||||||
|
body.classList.remove("md-review", "journal-detail-meta");
|
||||||
|
body.classList.add("trade-record-detail-wrap");
|
||||||
|
body.innerHTML = buildTradeRecordDetailHtml(row);
|
||||||
|
}
|
||||||
|
setDetailActionsHtml(
|
||||||
|
`<div class="detail-actions-inner">${row.actionsHtml}</div>`
|
||||||
|
);
|
||||||
|
const imgEl = document.getElementById("detailImage");
|
||||||
|
if (imgEl) {
|
||||||
|
imgEl.src = "";
|
||||||
|
imgEl.style.display = "none";
|
||||||
|
}
|
||||||
|
if (typeof setDetailModalFullscreen === "function") {
|
||||||
|
setDetailModalFullscreen(false);
|
||||||
|
}
|
||||||
|
const modal = document.getElementById("detailModal");
|
||||||
|
if (modal) modal.style.display = "flex";
|
||||||
|
}
|
||||||
|
|
||||||
|
global.InstanceUI = {
|
||||||
|
escapeHtml: escapeHtml,
|
||||||
|
pnlClassFromValue: pnlClassFromValue,
|
||||||
|
formatPnlSpan: formatPnlSpan,
|
||||||
|
buildJournalDetailHtml: buildJournalDetailHtml,
|
||||||
|
setJournalDetailBody: setJournalDetailBody,
|
||||||
|
openJournalDetailModal: openJournalDetailModal,
|
||||||
|
isMobileCompactRecords: isMobileCompactRecords,
|
||||||
|
inferJournalDirection: inferJournalDirection,
|
||||||
|
renderJournalListHtml: renderJournalListHtml,
|
||||||
|
parseTradeRecordRow: parseTradeRecordRow,
|
||||||
|
renderMobileTradeRow: renderMobileTradeRow,
|
||||||
|
buildTradeRecordDetailHtml: buildTradeRecordDetailHtml,
|
||||||
|
openTradeRecordDetailModal: openTradeRecordDetailModal,
|
||||||
|
clearDetailActions: clearDetailActions,
|
||||||
|
};
|
||||||
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* 关键位监控添加表单:类型切换显隐、成交量排名校验(四所实例共用)。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
const RS_TYPES = new Set([
|
||||||
|
"关键支撑阻力",
|
||||||
|
"关键阻力位",
|
||||||
|
"关键支撑位",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function syncKeyMonitorFormFields() {
|
||||||
|
const typeEl = document.querySelector('#key-form [name="type"]');
|
||||||
|
const dirEl = document.getElementById("key-direction");
|
||||||
|
const modeEl = document.getElementById("key-sl-tp-mode");
|
||||||
|
const manualTp = document.getElementById("key-manual-tp");
|
||||||
|
const beWrap = document.getElementById("key-breakeven-wrap");
|
||||||
|
if (!typeEl) return;
|
||||||
|
const t = (typeEl.value || "").trim();
|
||||||
|
const autoTypes = new Set(["箱体突破", "收敛突破"]);
|
||||||
|
const fibTypes = new Set(["斐波回调0.618", "斐波回调0.786"]);
|
||||||
|
const fbTypes = new Set(["假突破"]);
|
||||||
|
const teTypes = new Set(["回调触价开仓", "突破触价开仓", "触价开仓"]);
|
||||||
|
const showAuto = autoTypes.has(t);
|
||||||
|
const showFb = fbTypes.has(t);
|
||||||
|
const showTe = teTypes.has(t);
|
||||||
|
const showBe = showAuto || fibTypes.has(t) || showFb || showTe;
|
||||||
|
const showDir = !RS_TYPES.has(t);
|
||||||
|
const upperEl = document.getElementById("key-upper");
|
||||||
|
const lowerEl = document.getElementById("key-lower");
|
||||||
|
const fbPriceEl = document.getElementById("key-fb-price");
|
||||||
|
const teEntryEl = document.getElementById("key-trigger-entry");
|
||||||
|
const teSlEl = document.getElementById("key-trigger-sl");
|
||||||
|
const teTpEl = document.getElementById("key-trigger-tp");
|
||||||
|
if (dirEl) {
|
||||||
|
dirEl.style.display = showDir ? "" : "none";
|
||||||
|
dirEl.required = showDir;
|
||||||
|
if (!showDir) dirEl.value = "";
|
||||||
|
}
|
||||||
|
if (modeEl) modeEl.style.display = showAuto ? "" : "none";
|
||||||
|
if (manualTp) {
|
||||||
|
const trend = showAuto && modeEl && modeEl.value === "trend_manual";
|
||||||
|
manualTp.style.display = trend ? "" : "none";
|
||||||
|
manualTp.required = !!trend;
|
||||||
|
}
|
||||||
|
if (beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
|
||||||
|
if (global.TimeCloseUI) global.TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
|
||||||
|
const hideBounds = showFb || showTe;
|
||||||
|
if (upperEl) {
|
||||||
|
upperEl.style.display = hideBounds ? "none" : "";
|
||||||
|
upperEl.required = !hideBounds;
|
||||||
|
if (hideBounds) upperEl.value = "";
|
||||||
|
}
|
||||||
|
if (lowerEl) {
|
||||||
|
lowerEl.style.display = hideBounds ? "none" : "";
|
||||||
|
lowerEl.required = !hideBounds;
|
||||||
|
if (hideBounds) lowerEl.value = "";
|
||||||
|
}
|
||||||
|
if (fbPriceEl) {
|
||||||
|
fbPriceEl.style.display = showFb ? "" : "none";
|
||||||
|
fbPriceEl.required = showFb;
|
||||||
|
if (!showFb) fbPriceEl.value = "";
|
||||||
|
fbPriceEl.placeholder =
|
||||||
|
dirEl && dirEl.value === "short"
|
||||||
|
? "高点(阻力)"
|
||||||
|
: dirEl && dirEl.value === "long"
|
||||||
|
? "低点(支撑)"
|
||||||
|
: "做空填高点/做多填低点";
|
||||||
|
}
|
||||||
|
[teEntryEl, teSlEl, teTpEl].forEach((el) => {
|
||||||
|
if (!el) return;
|
||||||
|
el.style.display = showTe ? "" : "none";
|
||||||
|
el.required = showTe;
|
||||||
|
if (!showTe) el.value = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitKeyForm(keyForm, label) {
|
||||||
|
if (
|
||||||
|
document.body &&
|
||||||
|
document.body.getAttribute("data-embed-shell") === "1" &&
|
||||||
|
global.InstanceEmbed &&
|
||||||
|
typeof global.InstanceEmbed.postFormAndReload === "function"
|
||||||
|
) {
|
||||||
|
global.InstanceEmbed.postFormAndReload(keyForm, label || "提交中…");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (global.FormSubmitGuard) global.FormSubmitGuard.nativeSubmitOnce(keyForm, label || "提交中…");
|
||||||
|
else keyForm.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindKeyMonitorForm() {
|
||||||
|
const keyForm = document.getElementById("key-form");
|
||||||
|
const keyTypeSel = document.querySelector('#key-form [name="type"]');
|
||||||
|
const keyModeSel = document.getElementById("key-sl-tp-mode");
|
||||||
|
const keyDirSel = document.getElementById("key-direction");
|
||||||
|
if (keyTypeSel) keyTypeSel.addEventListener("change", syncKeyMonitorFormFields);
|
||||||
|
if (keyModeSel) keyModeSel.addEventListener("change", syncKeyMonitorFormFields);
|
||||||
|
if (keyDirSel) keyDirSel.addEventListener("change", syncKeyMonitorFormFields);
|
||||||
|
syncKeyMonitorFormFields();
|
||||||
|
if (global.TimeCloseUI) {
|
||||||
|
global.TimeCloseUI.bindTimeCloseForm(
|
||||||
|
"key-time-close-cb",
|
||||||
|
"key-time-close-hours",
|
||||||
|
"key-time-close-wrap"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!keyForm || keyForm.dataset.keyFormBound === "1") return;
|
||||||
|
keyForm.dataset.keyFormBound = "1";
|
||||||
|
keyForm.addEventListener("submit", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (global.FormSubmitGuard && global.FormSubmitGuard.isLocked(keyForm)) return;
|
||||||
|
const symbolEl = keyForm.querySelector('[name="symbol"]');
|
||||||
|
const symbol = (symbolEl ? symbolEl.value : "").trim();
|
||||||
|
if (!symbol) {
|
||||||
|
alert("请先输入交易对");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const typeVal = (keyForm.querySelector('[name="type"]') || {}).value || "";
|
||||||
|
if (typeVal === "假突破") {
|
||||||
|
submitKeyForm(keyForm, "提交中…");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (global.FormSubmitGuard) global.FormSubmitGuard.lock(keyForm, "校验排名中…");
|
||||||
|
fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`)
|
||||||
|
.then((r) => r.json().then((d) => ({ status: r.status, data: d })))
|
||||||
|
.then(({ status, data }) => {
|
||||||
|
if (status >= 400 || !data.ok) {
|
||||||
|
alert((data && data.msg) || "日成交量排名读取失败");
|
||||||
|
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rankMax = data.rank_max || 30;
|
||||||
|
const inTop = data.in_top != null ? data.in_top : data.in_top30;
|
||||||
|
if (data.rank == null || !inTop) {
|
||||||
|
alert(
|
||||||
|
`${data.symbol} 当前日成交量排名 ${data.rank == null ? "—" : data.rank}/${data.total},不在前${rankMax},已拦截。`
|
||||||
|
);
|
||||||
|
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitKeyForm(keyForm, "提交中…");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
alert("日成交量排名检查失败,请稍后重试");
|
||||||
|
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
global.KeyMonitorForm = {
|
||||||
|
syncFields: syncKeyMonitorFormFields,
|
||||||
|
init: bindKeyMonitorForm,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", bindKeyMonitorForm);
|
||||||
|
} else {
|
||||||
|
bindKeyMonitorForm();
|
||||||
|
}
|
||||||
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* 实盘下单:填完币种与止盈止损后,在表单下方显示预估风险 / 预估盈利 / 预估盈亏比。
|
||||||
|
* 以损定仓:风险 = 当前交易基数 × risk%。
|
||||||
|
* 全仓杠杆:风险 = 可用保证金×缓冲 × 杠杆 × |SL-入场|/入场(与开仓 calc_risk_amount_from_plan 一致)。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
let debounceMs = 400;
|
||||||
|
let minRr = 1.5;
|
||||||
|
let debounceTimer = null;
|
||||||
|
let fetchSeq = 0;
|
||||||
|
|
||||||
|
function $(id) {
|
||||||
|
return document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(v) {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRr(rr) {
|
||||||
|
if (rr === null || typeof rr === "undefined") return "—";
|
||||||
|
const n = Number(rr);
|
||||||
|
if (!Number.isFinite(n)) return "—";
|
||||||
|
const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2)));
|
||||||
|
return body + ":1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatU(v) {
|
||||||
|
if (v === null || typeof v === "undefined" || !Number.isFinite(Number(v))) return "—";
|
||||||
|
return Number(v).toFixed(2) + "U";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMetric(el, label, valueText) {
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = label + ":<strong>" + valueText + "</strong>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sizingMode() {
|
||||||
|
return (document.body && document.body.getAttribute("data-position-sizing-mode")) || "risk";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFullMarginMode() {
|
||||||
|
return sizingMode() === "full_margin";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullMarginBuffer() {
|
||||||
|
const n = Number(document.body && document.body.getAttribute("data-full-margin-buffer"));
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
function leverageForSymbol(sym) {
|
||||||
|
const u = (sym || "").trim().toUpperCase();
|
||||||
|
const btc = Number(document.body && document.body.getAttribute("data-btc-leverage"));
|
||||||
|
const alt = Number(document.body && document.body.getAttribute("data-alt-leverage"));
|
||||||
|
if (u.startsWith("BTC") || u.startsWith("ETH")) {
|
||||||
|
return Number.isFinite(btc) && btc > 0 ? btc : 10;
|
||||||
|
}
|
||||||
|
return Number.isFinite(alt) && alt > 0 ? alt : 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function riskPercent() {
|
||||||
|
const form = $("add-order-form");
|
||||||
|
const raw =
|
||||||
|
(form && form.getAttribute("data-risk-percent")) ||
|
||||||
|
(document.body && document.body.getAttribute("data-risk-percent")) ||
|
||||||
|
"";
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcRiskFraction(direction, entry, sl) {
|
||||||
|
const e = num(entry);
|
||||||
|
const s = num(sl);
|
||||||
|
if (e === null || s === null || e <= 0 || s <= 0) return null;
|
||||||
|
let risk = 0;
|
||||||
|
if (direction === "short") {
|
||||||
|
risk = s - e;
|
||||||
|
} else {
|
||||||
|
risk = e - s;
|
||||||
|
}
|
||||||
|
if (risk <= 0) return null;
|
||||||
|
return risk / e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcRr(direction, entry, sl, tp) {
|
||||||
|
const e = num(entry);
|
||||||
|
const s = num(sl);
|
||||||
|
const t = num(tp);
|
||||||
|
if (e === null || s === null || t === null) return null;
|
||||||
|
if (direction === "short") {
|
||||||
|
if (s <= e || t >= e) return null;
|
||||||
|
return (e - t) / (s - e);
|
||||||
|
}
|
||||||
|
if (s >= e || t <= e) return null;
|
||||||
|
return (t - e) / (e - s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcRrFromPct(slPct, tpPct) {
|
||||||
|
const sl = num(slPct);
|
||||||
|
const tp = num(tpPct);
|
||||||
|
if (sl === null || tp === null || sl <= 0 || tp <= 0) return null;
|
||||||
|
return tp / sl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcTpFromFixedRr(direction, entry, sl, rr) {
|
||||||
|
const e = num(entry);
|
||||||
|
const s = num(sl);
|
||||||
|
const r = num(rr);
|
||||||
|
if (e === null || s === null || r === null || r <= 0) return null;
|
||||||
|
if (direction === "short") {
|
||||||
|
if (s <= e) return null;
|
||||||
|
return e - (s - e) * r;
|
||||||
|
}
|
||||||
|
if (s >= e) return null;
|
||||||
|
return e + (e - s) * r;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSlPrice(mode, direction, entry) {
|
||||||
|
if (mode === "pct") {
|
||||||
|
const slPct = num($("order-sl-pct") && $("order-sl-pct").value);
|
||||||
|
if (slPct === null || slPct <= 0) return null;
|
||||||
|
if (direction === "short") return entry * (1 + slPct / 100);
|
||||||
|
return entry * (1 - slPct / 100);
|
||||||
|
}
|
||||||
|
return num($("order-sl") && $("order-sl").value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentMode() {
|
||||||
|
return ($("sltp-mode") && $("sltp-mode").value) || "fixed_rr";
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentDirection() {
|
||||||
|
return ($("order-direction") && $("order-direction").value) || "long";
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSymbol() {
|
||||||
|
return (($("order-symbol") && $("order-symbol").value) || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputsComplete(m) {
|
||||||
|
const dir = currentDirection();
|
||||||
|
if (!currentSymbol() || !dir) return false;
|
||||||
|
if (m === "pct") {
|
||||||
|
const sl = num($("order-sl-pct") && $("order-sl-pct").value);
|
||||||
|
const tp = num($("order-tp-pct") && $("order-tp-pct").value);
|
||||||
|
return sl !== null && tp !== null && sl > 0 && tp > 0;
|
||||||
|
}
|
||||||
|
if (m === "fixed_rr") {
|
||||||
|
const sl = num($("order-sl") && $("order-sl").value);
|
||||||
|
const rr = num($("order-fixed-rr") && $("order-fixed-rr").value);
|
||||||
|
return sl !== null && rr !== null && sl > 0 && rr > 0;
|
||||||
|
}
|
||||||
|
const sl = num($("order-sl") && $("order-sl").value);
|
||||||
|
const tp = num($("order-tp") && $("order-tp").value);
|
||||||
|
return sl !== null && tp !== null && sl > 0 && tp > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintEmpty() {
|
||||||
|
setMetric($("order-risk-preview"), "预估风险", "—");
|
||||||
|
setMetric($("order-profit-preview"), "预估盈利", "—");
|
||||||
|
setMetric($("order-rr-preview"), "预估盈亏比", "—");
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintLoading() {
|
||||||
|
setMetric($("order-risk-preview"), "预估风险", "计算中…");
|
||||||
|
setMetric($("order-profit-preview"), "预估盈利", "计算中…");
|
||||||
|
setMetric($("order-rr-preview"), "预估盈亏比", "计算中…");
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintFail(kind) {
|
||||||
|
const msg = kind === "fetch_fail" ? "取价失败" : "无效";
|
||||||
|
setMetric($("order-risk-preview"), "预估风险", msg);
|
||||||
|
setMetric($("order-profit-preview"), "预估盈利", msg);
|
||||||
|
setMetric($("order-rr-preview"), "预估盈亏比", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintOk(riskU, profitU, rr) {
|
||||||
|
setMetric($("order-risk-preview"), "预估风险", formatU(riskU));
|
||||||
|
setMetric($("order-profit-preview"), "预估盈利", formatU(profitU));
|
||||||
|
const rrEl = $("order-rr-preview");
|
||||||
|
const rrText = formatRr(rr);
|
||||||
|
setMetric(rrEl, "预估盈亏比", rrText);
|
||||||
|
if (rrEl && rr !== null && Number.isFinite(Number(rr))) {
|
||||||
|
rrEl.classList.toggle("order-preview-rr-low", Number(rr) < minRr);
|
||||||
|
rrEl.classList.toggle("order-preview-rr-ok", Number(rr) >= minRr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function plannedRiskFromRiskMode(capital) {
|
||||||
|
const cap = num(capital);
|
||||||
|
if (cap === null || cap <= 0) return null;
|
||||||
|
return Math.round((cap * riskPercent()) / 100 * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plannedRiskFromFullMargin(availableUsdt, symbol, direction, entry, sl) {
|
||||||
|
const avail = num(availableUsdt);
|
||||||
|
if (avail === null || avail <= 0) return null;
|
||||||
|
const slPx = num(sl);
|
||||||
|
const entryPx = num(entry);
|
||||||
|
if (slPx === null || entryPx === null) return null;
|
||||||
|
const rf = calcRiskFraction(direction, entryPx, slPx);
|
||||||
|
if (rf === null) return null;
|
||||||
|
const margin = Math.round(avail * fullMarginBuffer() * 100) / 100;
|
||||||
|
const lev = leverageForSymbol(symbol);
|
||||||
|
return Math.round(margin * lev * rf * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePreviewRr(m, dir, entry) {
|
||||||
|
if (m === "pct") {
|
||||||
|
return calcRrFromPct(
|
||||||
|
$("order-sl-pct") && $("order-sl-pct").value,
|
||||||
|
$("order-tp-pct") && $("order-tp-pct").value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const sl = num($("order-sl") && $("order-sl").value);
|
||||||
|
if (m === "fixed_rr") {
|
||||||
|
const fixed = num($("order-fixed-rr") && $("order-fixed-rr").value);
|
||||||
|
if (fixed !== null && fixed > 0) return fixed;
|
||||||
|
const tp = calcTpFromFixedRr(dir, entry, sl, fixed);
|
||||||
|
return calcRr(dir, entry, sl, tp);
|
||||||
|
}
|
||||||
|
const tp = num($("order-tp") && $("order-tp").value);
|
||||||
|
return calcRr(dir, entry, sl, tp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshNow() {
|
||||||
|
if (!$("order-plan-preview")) return;
|
||||||
|
const m = currentMode();
|
||||||
|
if (!inputsComplete(m)) {
|
||||||
|
paintEmpty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sym = currentSymbol();
|
||||||
|
const dir = currentDirection();
|
||||||
|
const seq = ++fetchSeq;
|
||||||
|
paintLoading();
|
||||||
|
|
||||||
|
const defaultsP = fetch(
|
||||||
|
"/api/order_defaults?symbol=" +
|
||||||
|
encodeURIComponent(sym) +
|
||||||
|
"&direction=" +
|
||||||
|
encodeURIComponent(dir)
|
||||||
|
).then(function (r) {
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
const capitalP = fetch("/api/account_snapshot").then(function (r) {
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all([defaultsP, capitalP])
|
||||||
|
.then(function (results) {
|
||||||
|
if (seq !== fetchSeq) return;
|
||||||
|
const data = results[0];
|
||||||
|
const account = results[1] || {};
|
||||||
|
if (!data.ok) {
|
||||||
|
paintFail("fetch_fail");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = num(data.last_price != null ? data.last_price : data.price);
|
||||||
|
if (entry === null) {
|
||||||
|
paintFail("fetch_fail");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rr = resolvePreviewRr(m, dir, entry);
|
||||||
|
if (rr === null) {
|
||||||
|
paintFail("invalid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let riskU = null;
|
||||||
|
if (isFullMarginMode()) {
|
||||||
|
const slPx = resolveSlPrice(m, dir, entry);
|
||||||
|
const avail =
|
||||||
|
data.available_trading_usdt != null
|
||||||
|
? data.available_trading_usdt
|
||||||
|
: account.available_trading_usdt;
|
||||||
|
riskU = plannedRiskFromFullMargin(avail, sym, dir, entry, slPx);
|
||||||
|
} else {
|
||||||
|
riskU = plannedRiskFromRiskMode(account.current_capital);
|
||||||
|
}
|
||||||
|
if (riskU === null) {
|
||||||
|
paintFail("fetch_fail");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const profitU = Math.round(riskU * rr * 100) / 100;
|
||||||
|
paintOk(riskU, profitU, rr);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (seq !== fetchSeq) return;
|
||||||
|
paintFail("fetch_fail");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(refreshNow, debounceMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wire(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
if (opts.minRr != null && Number.isFinite(Number(opts.minRr))) {
|
||||||
|
minRr = Number(opts.minRr);
|
||||||
|
}
|
||||||
|
if (opts.debounceMs != null && Number.isFinite(Number(opts.debounceMs))) {
|
||||||
|
debounceMs = Number(opts.debounceMs);
|
||||||
|
}
|
||||||
|
[
|
||||||
|
"order-symbol",
|
||||||
|
"order-direction",
|
||||||
|
"sltp-mode",
|
||||||
|
"order-sl",
|
||||||
|
"order-tp",
|
||||||
|
"order-sl-pct",
|
||||||
|
"order-tp-pct",
|
||||||
|
"order-fixed-rr",
|
||||||
|
"order-leverage",
|
||||||
|
].forEach(function (id) {
|
||||||
|
const el = $(id);
|
||||||
|
if (!el || el._rrPreviewBound) return;
|
||||||
|
el._rrPreviewBound = true;
|
||||||
|
el.addEventListener("input", schedule);
|
||||||
|
el.addEventListener("change", schedule);
|
||||||
|
});
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
global.ManualOrderRrPreview = {
|
||||||
|
wire: wire,
|
||||||
|
schedule: schedule,
|
||||||
|
refresh: refreshNow,
|
||||||
|
calcRr: calcRr,
|
||||||
|
calcRrFromPct: calcRrFromPct,
|
||||||
|
calcRiskFraction: calcRiskFraction,
|
||||||
|
formatRr: formatRr,
|
||||||
|
};
|
||||||
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
function syncRollFormMode(form, mode) {
|
||||||
|
if (!form) return;
|
||||||
|
const m = mode || "market";
|
||||||
|
form.setAttribute("data-add-mode", m);
|
||||||
|
const showFib = m === "fib_618" || m === "fib_786";
|
||||||
|
const showBreakout = m === "breakout";
|
||||||
|
const fibWrap = form.querySelector(".roll-field-fib");
|
||||||
|
const breakoutWrap = form.querySelector(".roll-field-breakout");
|
||||||
|
const fibUpper = form.querySelector("#roll-fib-upper");
|
||||||
|
const fibLower = form.querySelector("#roll-fib-lower");
|
||||||
|
const breakoutInput = form.querySelector("#roll-breakout");
|
||||||
|
|
||||||
|
function tuneInput(inp, active, required) {
|
||||||
|
if (!inp) return;
|
||||||
|
inp.disabled = !active;
|
||||||
|
inp.required = !!required && active;
|
||||||
|
inp.tabIndex = active ? 0 : -1;
|
||||||
|
if (!active) inp.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fibWrap) fibWrap.setAttribute("aria-hidden", showFib ? "false" : "true");
|
||||||
|
if (breakoutWrap) breakoutWrap.setAttribute("aria-hidden", showBreakout ? "false" : "true");
|
||||||
|
tuneInput(fibUpper, showFib, showFib);
|
||||||
|
tuneInput(fibLower, showFib, showFib);
|
||||||
|
tuneInput(breakoutInput, showBreakout, showBreakout);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.syncRollFormMode = syncRollFormMode;
|
||||||
|
|
||||||
|
function isEmbedShell() {
|
||||||
|
return document.body && document.body.getAttribute("data-embed-shell") === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitRollForm(form) {
|
||||||
|
if (isEmbedShell() && window.InstanceEmbed && typeof window.InstanceEmbed.postFormAndReload === "function") {
|
||||||
|
window.InstanceEmbed.postFormAndReload(form, "执行中…");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.FormSubmitGuard && typeof window.FormSubmitGuard.nativeSubmitOnce === "function") {
|
||||||
|
window.FormSubmitGuard.nativeSubmitOnce(form, "执行中…");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initStrategyRollForm() {
|
||||||
|
const form = document.getElementById("roll-form");
|
||||||
|
if (!form) return;
|
||||||
|
if (form.dataset.rollJsInit === "1") return;
|
||||||
|
form.dataset.rollJsInit = "1";
|
||||||
|
|
||||||
|
const symbolSel = document.getElementById("roll-symbol");
|
||||||
|
const dirInput = document.getElementById("roll-direction");
|
||||||
|
const modeSel = document.getElementById("roll-add-mode");
|
||||||
|
const riskBanner = document.getElementById("roll-risk-banner");
|
||||||
|
const previewBtn = document.getElementById("roll-preview-btn");
|
||||||
|
const submitBtn = document.getElementById("roll-submit-btn");
|
||||||
|
const previewBox = document.getElementById("roll-preview-box");
|
||||||
|
const previewText = document.getElementById("roll-preview-text");
|
||||||
|
const countdownEl = document.getElementById("roll-countdown");
|
||||||
|
const trendLocked = submitBtn && submitBtn.getAttribute("data-trend-locked") === "1";
|
||||||
|
|
||||||
|
let countdownTimer = null;
|
||||||
|
let previewOk = false;
|
||||||
|
let lastPreviewMode = "";
|
||||||
|
let monitorSubmitting = false;
|
||||||
|
|
||||||
|
function isMarketMode() {
|
||||||
|
return (modeSel.value || "market") === "market";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMonitorMode() {
|
||||||
|
const m = modeSel.value || "market";
|
||||||
|
return m === "fib_618" || m === "fib_786" || m === "breakout";
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedOption() {
|
||||||
|
return symbolSel.options[symbolSel.selectedIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDirectionLock() {
|
||||||
|
const opt = selectedOption();
|
||||||
|
if (!opt || !opt.value) {
|
||||||
|
riskBanner.textContent = "当前风险:请选择持仓币种";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dir = opt.getAttribute("data-direction") || "long";
|
||||||
|
const rp = opt.getAttribute("data-risk-percent") || "—";
|
||||||
|
dirInput.value = dir;
|
||||||
|
riskBanner.textContent =
|
||||||
|
"当前风险:" + rp + "%(来自监控单 #" + (opt.getAttribute("data-monitor-id") || "?") + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSubmitButton() {
|
||||||
|
if (!submitBtn || trendLocked) return;
|
||||||
|
if (isMonitorMode()) {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.removeAttribute("disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blocked = !previewOk || !!countdownTimer;
|
||||||
|
submitBtn.disabled = blocked;
|
||||||
|
if (!blocked) submitBtn.removeAttribute("disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMessageBox() {
|
||||||
|
if (!previewBox) return;
|
||||||
|
previewBox.style.display = "none";
|
||||||
|
previewBox.classList.remove("is-error", "is-preview");
|
||||||
|
if (previewText) previewText.textContent = "";
|
||||||
|
if (countdownEl) countdownEl.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showReject(msg) {
|
||||||
|
if (!previewBox || !previewText) return;
|
||||||
|
previewBox.style.display = "block";
|
||||||
|
previewBox.classList.remove("is-preview");
|
||||||
|
previewBox.classList.add("is-error");
|
||||||
|
previewText.textContent = msg || "无法执行";
|
||||||
|
if (countdownEl) countdownEl.style.display = "none";
|
||||||
|
previewBox.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPreviewResult(p) {
|
||||||
|
if (!previewBox || !previewText) return;
|
||||||
|
previewBox.style.display = "block";
|
||||||
|
previewBox.classList.remove("is-error");
|
||||||
|
previewBox.classList.add("is-preview");
|
||||||
|
previewText.innerHTML =
|
||||||
|
"<strong>" +
|
||||||
|
(p.add_mode_label || "") +
|
||||||
|
"</strong> · 约 <strong>" +
|
||||||
|
(p.add_amount_display != null ? p.add_amount_display : p.add_amount_raw) +
|
||||||
|
"</strong> 张<br>" +
|
||||||
|
"加仓参考价 " +
|
||||||
|
(p.add_price_display != null ? p.add_price_display : p.add_price) +
|
||||||
|
" · 新止损 " +
|
||||||
|
(p.new_sl_display != null ? p.new_sl_display : p.new_stop_loss) +
|
||||||
|
"<br>" +
|
||||||
|
"合并均价 " +
|
||||||
|
p.avg_entry_after +
|
||||||
|
" · 打到止损约 " +
|
||||||
|
p.loss_at_sl_usdt +
|
||||||
|
"U(风险预算 " +
|
||||||
|
(p.risk_budget_usdt != null ? p.risk_budget_usdt : "—") +
|
||||||
|
"U)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFieldVisibility() {
|
||||||
|
syncRollFormMode(form, modeSel.value || "market");
|
||||||
|
resetPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPreview() {
|
||||||
|
previewOk = false;
|
||||||
|
monitorSubmitting = false;
|
||||||
|
clearMessageBox();
|
||||||
|
if (countdownTimer) {
|
||||||
|
clearInterval(countdownTimer);
|
||||||
|
countdownTimer = null;
|
||||||
|
}
|
||||||
|
syncSubmitButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formPayload() {
|
||||||
|
const fd = new FormData(form);
|
||||||
|
const obj = {};
|
||||||
|
fd.forEach(function (v, k) {
|
||||||
|
if (v !== "") obj[k] = v;
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestPreview() {
|
||||||
|
return fetch("/strategy/roll/preview", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify(formPayload()),
|
||||||
|
credentials: "same-origin",
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPreview() {
|
||||||
|
resetPreview();
|
||||||
|
if (!symbolSel.value) {
|
||||||
|
showReject("请先选择持仓币种");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (previewBtn) previewBtn.disabled = true;
|
||||||
|
requestPreview()
|
||||||
|
.then(function (data) {
|
||||||
|
if (previewBtn) previewBtn.disabled = false;
|
||||||
|
if (!data.ok) {
|
||||||
|
showReject(data.msg || "预览失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = data.preview || {};
|
||||||
|
lastPreviewMode = p.add_mode || modeSel.value;
|
||||||
|
showPreviewResult(p);
|
||||||
|
previewOk = true;
|
||||||
|
if (lastPreviewMode === "market") {
|
||||||
|
startCountdown(10);
|
||||||
|
} else {
|
||||||
|
syncSubmitButton();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (previewBtn) previewBtn.disabled = false;
|
||||||
|
showReject("预览请求失败,请稍后重试");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runMonitorSubmit() {
|
||||||
|
if (monitorSubmitting) return;
|
||||||
|
if (!symbolSel.value) {
|
||||||
|
showReject("请先选择持仓币种");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
monitorSubmitting = true;
|
||||||
|
if (submitBtn) submitBtn.disabled = true;
|
||||||
|
requestPreview()
|
||||||
|
.then(function (data) {
|
||||||
|
monitorSubmitting = false;
|
||||||
|
if (submitBtn && !trendLocked) {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.removeAttribute("disabled");
|
||||||
|
}
|
||||||
|
if (!data.ok) {
|
||||||
|
showReject(data.msg || "无法提交监控");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = data.preview || {};
|
||||||
|
const modeLabel = modeSel.options[modeSel.selectedIndex].text;
|
||||||
|
const summary =
|
||||||
|
"约 " +
|
||||||
|
(p.add_amount_display != null ? p.add_amount_display : p.add_amount_raw) +
|
||||||
|
" 张 · 触发参考价 " +
|
||||||
|
(p.add_price_display != null ? p.add_price_display : p.add_price) +
|
||||||
|
" · 新止损 " +
|
||||||
|
(p.new_sl_display != null ? p.new_sl_display : p.new_stop_loss);
|
||||||
|
if (!confirm("确认提交「" + modeLabel + "」?\n" + summary)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitRollForm(form);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
monitorSubmitting = false;
|
||||||
|
if (submitBtn && !trendLocked) {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.removeAttribute("disabled");
|
||||||
|
}
|
||||||
|
showReject("校验请求失败,请稍后重试");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown(sec) {
|
||||||
|
let left = sec;
|
||||||
|
if (submitBtn) submitBtn.disabled = true;
|
||||||
|
if (countdownEl) {
|
||||||
|
countdownEl.style.display = "block";
|
||||||
|
countdownEl.textContent = "市价加仓:" + left + " 秒后可执行(修改表单将取消预览)";
|
||||||
|
}
|
||||||
|
countdownTimer = setInterval(function () {
|
||||||
|
left -= 1;
|
||||||
|
if (left <= 0) {
|
||||||
|
clearInterval(countdownTimer);
|
||||||
|
countdownTimer = null;
|
||||||
|
if (countdownEl) countdownEl.textContent = "可以执行市价加仓";
|
||||||
|
syncSubmitButton();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (countdownEl) countdownEl.textContent = "市价加仓:" + left + " 秒后可执行";
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
symbolSel.addEventListener("change", function () {
|
||||||
|
syncDirectionLock();
|
||||||
|
resetPreview();
|
||||||
|
});
|
||||||
|
modeSel.addEventListener("change", syncFieldVisibility);
|
||||||
|
form.addEventListener("input", resetPreview);
|
||||||
|
form.addEventListener("change", function (e) {
|
||||||
|
if (e.target !== previewBtn) resetPreview();
|
||||||
|
});
|
||||||
|
if (previewBtn) previewBtn.addEventListener("click", runPreview);
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isMonitorMode()) {
|
||||||
|
runMonitorSubmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!previewOk) {
|
||||||
|
showReject("请先点击「预览」并通过校验");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (submitBtn && submitBtn.disabled) {
|
||||||
|
showReject("请等待 10 秒确认倒计时结束后再执行市价加仓");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const modeLabel = modeSel.options[modeSel.selectedIndex].text;
|
||||||
|
if (!confirm("确认提交「" + modeLabel + "」?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitRollForm(form);
|
||||||
|
});
|
||||||
|
|
||||||
|
syncDirectionLock();
|
||||||
|
syncFieldVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.initStrategyRollForm = initStrategyRollForm;
|
||||||
|
initStrategyRollForm();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/* 交易日历:内照明心 + 四所统计分析共用,随 data-theme 浅/深切换 */
|
||||||
|
.trade-cal-wrap {
|
||||||
|
--trade-cal-wrap-bg: var(--inset-surface, rgba(0, 0, 0, 0.22));
|
||||||
|
--trade-cal-cell-bg: var(--section-surface, var(--inset-surface, rgba(0, 0, 0, 0.32)));
|
||||||
|
--trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #6366f1) 12%, var(--trade-cal-cell-bg));
|
||||||
|
--trade-cal-cell-hover-border: color-mix(in srgb, var(--accent, #6366f1) 45%, transparent);
|
||||||
|
--trade-cal-selected-border: rgba(59, 130, 246, 0.85);
|
||||||
|
--trade-cal-selected-bg: color-mix(in srgb, #3b82f6 16%, var(--trade-cal-cell-bg));
|
||||||
|
--trade-cal-selected-shadow: rgba(59, 130, 246, 0.45);
|
||||||
|
--trade-cal-sick-bg: color-mix(in srgb, var(--red, #ef4444) 14%, var(--trade-cal-cell-bg));
|
||||||
|
--trade-cal-sick-border: color-mix(in srgb, var(--red, #ef4444) 55%, transparent);
|
||||||
|
--trade-cal-sick-shadow: color-mix(in srgb, var(--red, #ef4444) 45%, transparent);
|
||||||
|
--trade-cal-sick-tag-bg: color-mix(in srgb, var(--red, #ef4444) 25%, transparent);
|
||||||
|
--trade-cal-sick-tag-fg: color-mix(in srgb, var(--red, #ef4444) 70%, #fff);
|
||||||
|
--trade-cal-pos: var(--green, #22c55e);
|
||||||
|
--trade-cal-neg: var(--red, #ef4444);
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border-soft, rgba(120, 140, 200, 0.28));
|
||||||
|
background: var(--trade-cal-wrap-bg);
|
||||||
|
}
|
||||||
|
.stats-calendar-wrap {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.trade-cal-wrap button.trade-cal-cell {
|
||||||
|
background: var(--trade-cal-cell-bg) !important;
|
||||||
|
background-image: none !important;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 4px 3px;
|
||||||
|
min-height: 68px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: none;
|
||||||
|
line-height: 1.15;
|
||||||
|
font-size: inherit;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.trade-cal-wrap button.trade-cal-cell:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.trade-cal-wrap .trade-cal-head .btn,
|
||||||
|
.trade-cal-wrap .trade-cal-head button {
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 34px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.trade-cal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.trade-cal-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 120px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text, #e8ecff);
|
||||||
|
}
|
||||||
|
.trade-cal-weekdays {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.trade-cal-wd {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--muted, #8892b0);
|
||||||
|
}
|
||||||
|
.trade-cal-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.trade-cal-cell {
|
||||||
|
min-height: 62px;
|
||||||
|
padding: 4px 3px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: var(--trade-cal-cell-bg);
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: default;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.trade-cal-cell.has-trade {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.trade-cal-wrap button.trade-cal-cell.has-trade:hover {
|
||||||
|
background: var(--trade-cal-cell-hover-bg) !important;
|
||||||
|
background-image: none !important;
|
||||||
|
border-color: var(--trade-cal-cell-hover-border);
|
||||||
|
}
|
||||||
|
.trade-cal-cell.is-selected {
|
||||||
|
border-color: var(--trade-cal-selected-border);
|
||||||
|
background: var(--trade-cal-selected-bg);
|
||||||
|
box-shadow: 0 0 0 2px var(--trade-cal-selected-shadow);
|
||||||
|
}
|
||||||
|
.trade-cal-cell.is-sick-day {
|
||||||
|
border-color: var(--trade-cal-sick-border);
|
||||||
|
background: var(--trade-cal-sick-bg);
|
||||||
|
}
|
||||||
|
.trade-cal-cell.is-sick-day.is-selected {
|
||||||
|
border-color: var(--trade-cal-selected-border);
|
||||||
|
background: color-mix(in srgb, #3b82f6 14%, var(--trade-cal-sick-bg));
|
||||||
|
box-shadow: 0 0 0 2px var(--trade-cal-selected-shadow);
|
||||||
|
}
|
||||||
|
.trade-cal-day-num {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text, #e8ecff);
|
||||||
|
}
|
||||||
|
.trade-cal-pnl {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--text, #e8ecff);
|
||||||
|
}
|
||||||
|
.trade-cal-cell.pnl-pos .trade-cal-pnl {
|
||||||
|
color: var(--trade-cal-pos);
|
||||||
|
}
|
||||||
|
.trade-cal-cell.pnl-neg .trade-cal-pnl {
|
||||||
|
color: var(--trade-cal-neg);
|
||||||
|
}
|
||||||
|
.trade-cal-cnt {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--muted, #8892b0);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.trade-cal-sick-tag {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--trade-cal-sick-tag-bg);
|
||||||
|
color: var(--trade-cal-sick-tag-fg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.trade-cal-pad {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .trade-cal-wrap {
|
||||||
|
--trade-cal-wrap-bg: var(--inset-surface, #eef3f8);
|
||||||
|
--trade-cal-cell-bg: var(--section-surface, #f6f9fc);
|
||||||
|
--trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #2563eb) 10%, #f6f9fc);
|
||||||
|
--trade-cal-selected-border: rgba(37, 99, 235, 0.75);
|
||||||
|
--trade-cal-selected-bg: color-mix(in srgb, #2563eb 12%, #f6f9fc);
|
||||||
|
--trade-cal-selected-shadow: rgba(37, 99, 235, 0.35);
|
||||||
|
--trade-cal-sick-tag-fg: #b91c1c;
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
/**
|
||||||
|
* 交易日历组件:内照明心档案 + 四所统计分析共用。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthLabel(y, m) {
|
||||||
|
return y + "年" + m + "月";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCalPnl(pnl) {
|
||||||
|
var n = Number(pnl);
|
||||||
|
if (!Number.isFinite(n)) n = 0;
|
||||||
|
return (n >= 0 ? "+" : "") + n.toFixed(1) + "U";
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayHasTrade(info) {
|
||||||
|
if (!info) return false;
|
||||||
|
var cnt = Number(info.open_count);
|
||||||
|
if (Number.isFinite(cnt) && cnt > 0) return true;
|
||||||
|
var pnl = Number(info.pnl_total);
|
||||||
|
return Number.isFinite(pnl) && Math.abs(pnl) > 0.0001;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayOpenCount(info) {
|
||||||
|
var cnt = Number(info && info.open_count);
|
||||||
|
return Number.isFinite(cnt) && cnt > 0 ? cnt : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayPnl(info) {
|
||||||
|
return Number(info && info.pnl_total) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TradeStatsCalendar(config) {
|
||||||
|
this.gridEl = config.gridEl;
|
||||||
|
this.titleEl = config.titleEl;
|
||||||
|
this.prevBtn = config.prevBtn || null;
|
||||||
|
this.nextBtn = config.nextBtn || null;
|
||||||
|
this.apiUrl = config.apiUrl || "/api/stats/calendar";
|
||||||
|
this.buildQuery =
|
||||||
|
config.buildQuery ||
|
||||||
|
function (year, month) {
|
||||||
|
var q = new URLSearchParams();
|
||||||
|
q.set("year", String(year));
|
||||||
|
q.set("month", String(month));
|
||||||
|
return q;
|
||||||
|
};
|
||||||
|
this.parseResponse =
|
||||||
|
config.parseResponse ||
|
||||||
|
function (data) {
|
||||||
|
if (data && data.ok === false) return {};
|
||||||
|
return (data && data.days) || {};
|
||||||
|
};
|
||||||
|
this.fetchFn = config.fetchFn || null;
|
||||||
|
this.showSick = config.showSick !== false;
|
||||||
|
this.selectedDay = config.selectedDay || "";
|
||||||
|
this.onDayClick = config.onDayClick || null;
|
||||||
|
this.onMonthChange = config.onMonthChange || null;
|
||||||
|
this.year = config.year || 0;
|
||||||
|
this.month = config.month || 0;
|
||||||
|
this.days = {};
|
||||||
|
this.monthPnlTotal = 0;
|
||||||
|
this.monthOpenCount = 0;
|
||||||
|
this._navBound = false;
|
||||||
|
this._bindNav();
|
||||||
|
}
|
||||||
|
|
||||||
|
TradeStatsCalendar.prototype.ensureMonth = function (ref) {
|
||||||
|
if (this.year > 0 && this.month > 0) return;
|
||||||
|
var d;
|
||||||
|
if (ref instanceof Date) d = ref;
|
||||||
|
else if (typeof ref === "string" && ref.length >= 7) {
|
||||||
|
var p = ref.slice(0, 10).split("-");
|
||||||
|
this.year = parseInt(p[0], 10) || new Date().getFullYear();
|
||||||
|
this.month = parseInt(p[1], 10) || new Date().getMonth() + 1;
|
||||||
|
return;
|
||||||
|
} else d = new Date();
|
||||||
|
this.year = d.getFullYear();
|
||||||
|
this.month = d.getMonth() + 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
TradeStatsCalendar.prototype.applyPayload = function (data) {
|
||||||
|
if (!data) return;
|
||||||
|
var y = Number(data.year);
|
||||||
|
var m = Number(data.month);
|
||||||
|
if (Number.isFinite(y) && y > 0) this.year = y;
|
||||||
|
if (Number.isFinite(m) && m > 0) this.month = m;
|
||||||
|
this.days = this.parseResponse(data) || {};
|
||||||
|
this.monthPnlTotal = Number(data.month_pnl_total) || 0;
|
||||||
|
this.monthOpenCount = Number(data.month_open_count) || 0;
|
||||||
|
if (!this.monthOpenCount) {
|
||||||
|
var self = this;
|
||||||
|
Object.keys(this.days).forEach(function (k) {
|
||||||
|
if (dayHasTrade(self.days[k])) {
|
||||||
|
self.monthOpenCount += dayOpenCount(self.days[k]);
|
||||||
|
self.monthPnlTotal += dayPnl(self.days[k]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.monthPnlTotal = Math.round(this.monthPnlTotal * 10000) / 10000;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function readStatsCalendarBootstrap() {
|
||||||
|
var el = document.getElementById("stats-calendar-bootstrap");
|
||||||
|
if (!el || !el.textContent) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(el.textContent);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[trade calendar] bootstrap parse", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TradeStatsCalendar.prototype.setSelectedDay = function (day) {
|
||||||
|
this.selectedDay = day || "";
|
||||||
|
this.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
TradeStatsCalendar.prototype.render = function () {
|
||||||
|
if (!this.gridEl || !this.titleEl) return;
|
||||||
|
if (this.year <= 0 || this.month <= 0) this.ensureMonth(new Date());
|
||||||
|
var title = monthLabel(this.year, this.month);
|
||||||
|
if (this.monthOpenCount > 0) {
|
||||||
|
title +=
|
||||||
|
" · " + formatCalPnl(this.monthPnlTotal) + " · " + this.monthOpenCount + "笔";
|
||||||
|
}
|
||||||
|
this.titleEl.textContent = title;
|
||||||
|
var first = new Date(this.year, this.month - 1, 1);
|
||||||
|
var lastDay = new Date(this.year, this.month, 0).getDate();
|
||||||
|
var startWd = first.getDay();
|
||||||
|
var html =
|
||||||
|
'<div class="trade-cal-weekdays">' +
|
||||||
|
WEEKDAYS.map(function (w) {
|
||||||
|
return '<span class="trade-cal-wd">' + w + "</span>";
|
||||||
|
}).join("") +
|
||||||
|
'</div><div class="trade-cal-grid">';
|
||||||
|
var i;
|
||||||
|
for (i = 0; i < startWd; i++) {
|
||||||
|
html += '<span class="trade-cal-cell trade-cal-pad"></span>';
|
||||||
|
}
|
||||||
|
for (var d = 1; d <= lastDay; d++) {
|
||||||
|
var dayStr =
|
||||||
|
this.year +
|
||||||
|
"-" +
|
||||||
|
String(this.month).padStart(2, "0") +
|
||||||
|
"-" +
|
||||||
|
String(d).padStart(2, "0");
|
||||||
|
var info = this.days[dayStr];
|
||||||
|
var hasTrade = dayHasTrade(info);
|
||||||
|
var sick = this.showSick && info && info.has_sick;
|
||||||
|
var pnl = hasTrade ? dayPnl(info) : null;
|
||||||
|
var cnt = hasTrade ? dayOpenCount(info) : 0;
|
||||||
|
var cls =
|
||||||
|
"trade-cal-cell" +
|
||||||
|
(hasTrade ? " has-trade" : "") +
|
||||||
|
(sick ? " is-sick-day" : "") +
|
||||||
|
(this.selectedDay === dayStr ? " is-selected" : "") +
|
||||||
|
(pnl != null && pnl > 0.0001
|
||||||
|
? " pnl-pos"
|
||||||
|
: pnl != null && pnl < -0.0001
|
||||||
|
? " pnl-neg"
|
||||||
|
: "");
|
||||||
|
var body = '<span class="trade-cal-day-num">' + d + "</span>";
|
||||||
|
if (hasTrade) {
|
||||||
|
body +=
|
||||||
|
'<span class="trade-cal-pnl">' +
|
||||||
|
esc(formatCalPnl(pnl)) +
|
||||||
|
"</span>" +
|
||||||
|
'<span class="trade-cal-cnt">' +
|
||||||
|
cnt +
|
||||||
|
"笔</span>";
|
||||||
|
if (sick) body += '<span class="trade-cal-sick-tag">犯病</span>';
|
||||||
|
}
|
||||||
|
html +=
|
||||||
|
'<button type="button" class="' +
|
||||||
|
cls +
|
||||||
|
'" data-day="' +
|
||||||
|
dayStr +
|
||||||
|
'" data-sick="' +
|
||||||
|
(sick ? "1" : "0") +
|
||||||
|
'"' +
|
||||||
|
(hasTrade ? "" : " disabled") +
|
||||||
|
">" +
|
||||||
|
body +
|
||||||
|
"</button>";
|
||||||
|
}
|
||||||
|
html += "</div>";
|
||||||
|
this.gridEl.innerHTML = html;
|
||||||
|
var self = this;
|
||||||
|
this.gridEl.querySelectorAll(".trade-cal-cell[data-day]").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
var day = btn.getAttribute("data-day");
|
||||||
|
if (!day || !self.onDayClick) return;
|
||||||
|
self.selectedDay = day;
|
||||||
|
self.render();
|
||||||
|
self.onDayClick(day, btn.getAttribute("data-sick") === "1", self.days[day] || null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
TradeStatsCalendar.prototype.load = async function () {
|
||||||
|
this.ensureMonth(new Date());
|
||||||
|
this.render();
|
||||||
|
var q = this.buildQuery(this.year, this.month);
|
||||||
|
if (!q.has("year")) q.set("year", String(this.year));
|
||||||
|
if (!q.has("month")) q.set("month", String(this.month));
|
||||||
|
try {
|
||||||
|
var data;
|
||||||
|
if (this.fetchFn) {
|
||||||
|
data = await this.fetchFn(q);
|
||||||
|
} else {
|
||||||
|
var resp = await fetch(this.apiUrl + "?" + q.toString(), {
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.warn("[trade calendar] api", resp.status);
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data = await resp.json();
|
||||||
|
}
|
||||||
|
this.applyPayload(data);
|
||||||
|
this.render();
|
||||||
|
if (this.onMonthChange) this.onMonthChange(this.year, this.month, this.days);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[trade calendar]", e);
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
TradeStatsCalendar.prototype.shiftMonth = function (delta) {
|
||||||
|
this.ensureMonth(new Date());
|
||||||
|
this.month += delta;
|
||||||
|
if (this.month > 12) {
|
||||||
|
this.month = 1;
|
||||||
|
this.year += 1;
|
||||||
|
} else if (this.month < 1) {
|
||||||
|
this.month = 12;
|
||||||
|
this.year -= 1;
|
||||||
|
}
|
||||||
|
void this.load();
|
||||||
|
};
|
||||||
|
|
||||||
|
TradeStatsCalendar.prototype._bindNav = function () {
|
||||||
|
if (this._navBound) return;
|
||||||
|
var self = this;
|
||||||
|
if (this.prevBtn) {
|
||||||
|
this.prevBtn.addEventListener("click", function () {
|
||||||
|
self.shiftMonth(-1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this.nextBtn) {
|
||||||
|
this.nextBtn.addEventListener("click", function () {
|
||||||
|
self.shiftMonth(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this._navBound = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
global.TradeStatsCalendar = TradeStatsCalendar;
|
||||||
|
|
||||||
|
global.statsCalendarWidget = null;
|
||||||
|
|
||||||
|
global.initInstanceStatsCalendar = function () {
|
||||||
|
var grid = document.getElementById("stats-calendar");
|
||||||
|
if (!grid || !global.TradeStatsCalendar) return null;
|
||||||
|
var bootstrap = readStatsCalendarBootstrap();
|
||||||
|
if (
|
||||||
|
global.statsCalendarWidget &&
|
||||||
|
global.statsCalendarWidget.gridEl === grid
|
||||||
|
) {
|
||||||
|
if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap);
|
||||||
|
global.statsCalendarWidget.render();
|
||||||
|
void global.statsCalendarWidget.load();
|
||||||
|
return global.statsCalendarWidget;
|
||||||
|
}
|
||||||
|
global.statsCalendarWidget = new TradeStatsCalendar({
|
||||||
|
gridEl: grid,
|
||||||
|
titleEl: document.getElementById("stats-cal-title"),
|
||||||
|
prevBtn: document.getElementById("stats-cal-prev"),
|
||||||
|
nextBtn: document.getElementById("stats-cal-next"),
|
||||||
|
apiUrl: "/api/stats/calendar",
|
||||||
|
showSick: false,
|
||||||
|
buildQuery: function (year, month) {
|
||||||
|
var q = new URLSearchParams();
|
||||||
|
q.set("year", String(year));
|
||||||
|
q.set("month", String(month));
|
||||||
|
var sel = document.getElementById("stats-segment-select");
|
||||||
|
if (sel) q.set("segment", sel.value || "all");
|
||||||
|
return q;
|
||||||
|
},
|
||||||
|
parseResponse: function (data) {
|
||||||
|
if (data && data.ok === false) return {};
|
||||||
|
return (data && data.days) || {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap);
|
||||||
|
global.statsCalendarWidget.render();
|
||||||
|
void global.statsCalendarWidget.load();
|
||||||
|
return global.statsCalendarWidget;
|
||||||
|
};
|
||||||
|
|
||||||
|
global.initStatsCalendarWidget = global.initInstanceStatsCalendar;
|
||||||
|
})(window);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Shared library package."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Shared library package."""
|
||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from hub_sso import (
|
from lib.hub.hub_sso import (
|
||||||
HUB_SSO_TTL_SEC,
|
HUB_SSO_TTL_SEC,
|
||||||
hub_bridge_token,
|
hub_bridge_token,
|
||||||
mint_hub_sso_token,
|
mint_hub_sso_token,
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
"""中控备份与恢复:四所 SQLite、K 线库、env、hub JSON。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from lib.paths import REPO_ROOT, hub_data_dir, manual_trading_hub_dir
|
||||||
|
|
||||||
|
HUB_DIR = manual_trading_hub_dir()
|
||||||
|
TZ_NAME = (os.getenv("HUB_BACKUP_TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai"
|
||||||
|
|
||||||
|
EXCHANGE_DIRS: list[tuple[str, str]] = [
|
||||||
|
("binance", "crypto_monitor_binance"),
|
||||||
|
("okx", "crypto_monitor_okx"),
|
||||||
|
("gate", "crypto_monitor_gate"),
|
||||||
|
("gate_bot", "crypto_monitor_gate_bot"),
|
||||||
|
]
|
||||||
|
|
||||||
|
HUB_JSON_FILES = (
|
||||||
|
"hub_settings.json",
|
||||||
|
"hub_fund_history.json",
|
||||||
|
"hub_ai_summaries.json",
|
||||||
|
"hub_ai_chat.json",
|
||||||
|
"hub_supervisor_state.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
HUB_DATA_FILES = (
|
||||||
|
"hub_kline.db",
|
||||||
|
"hub_symbol_archive.db",
|
||||||
|
"hub_entry_plans.db",
|
||||||
|
"hub_macro_calendar.db",
|
||||||
|
"hub_volume_rank.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_BACKUP_SETTINGS = {
|
||||||
|
"auto_enabled": True,
|
||||||
|
"auto_hour": 0,
|
||||||
|
"retention_days": 30,
|
||||||
|
"include_env": True,
|
||||||
|
"include_exchange_images": False,
|
||||||
|
"backup_root": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
BACKUP_STATE_PATH = HUB_DIR / "hub_backup_state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_backup_settings(raw: dict | None) -> dict:
|
||||||
|
out = dict(DEFAULT_BACKUP_SETTINGS)
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
for key in DEFAULT_BACKUP_SETTINGS:
|
||||||
|
if key in raw:
|
||||||
|
out[key] = raw[key]
|
||||||
|
try:
|
||||||
|
out["auto_hour"] = max(0, min(23, int(out.get("auto_hour", 0))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
out["auto_hour"] = 0
|
||||||
|
try:
|
||||||
|
out["retention_days"] = max(1, min(365, int(out.get("retention_days", 30))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
out["retention_days"] = 30
|
||||||
|
out["auto_enabled"] = bool(out.get("auto_enabled"))
|
||||||
|
out["include_env"] = bool(out.get("include_env", True))
|
||||||
|
out["include_exchange_images"] = bool(out.get("include_exchange_images"))
|
||||||
|
out["backup_root"] = str(out.get("backup_root") or "").strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def backup_root(settings: dict | None = None) -> Path:
|
||||||
|
cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None)
|
||||||
|
raw = cfg.get("backup_root") or (os.getenv("HUB_BACKUP_ROOT") or "").strip()
|
||||||
|
if not raw:
|
||||||
|
raw = (os.getenv("BACKUP_ROOT") or "/root/backups").strip()
|
||||||
|
root = Path(raw).expanduser()
|
||||||
|
if not root.is_absolute():
|
||||||
|
root = REPO_ROOT / root
|
||||||
|
portal = root / "crypto_monitor_portal"
|
||||||
|
portal.mkdir(parents=True, exist_ok=True)
|
||||||
|
return portal
|
||||||
|
|
||||||
|
|
||||||
|
def _now_local() -> datetime:
|
||||||
|
try:
|
||||||
|
return datetime.now(ZoneInfo(TZ_NAME))
|
||||||
|
except Exception:
|
||||||
|
return datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_env_var(env_path: Path, key: str, default: str = "") -> str:
|
||||||
|
if not env_path.is_file():
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
for line in env_path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||||
|
raw = line.strip()
|
||||||
|
if not raw or raw.startswith("#") or "=" not in raw:
|
||||||
|
continue
|
||||||
|
k, v = raw.split("=", 1)
|
||||||
|
if k.strip() == key:
|
||||||
|
return v.strip().strip('"').strip("'")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_project_path(project_dir: Path, rel: str) -> Path:
|
||||||
|
p = Path(rel or "")
|
||||||
|
if p.is_absolute():
|
||||||
|
return p
|
||||||
|
return project_dir / p
|
||||||
|
|
||||||
|
|
||||||
|
def _load_backup_state() -> dict:
|
||||||
|
if not BACKUP_STATE_PATH.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(BACKUP_STATE_PATH.read_text(encoding="utf-8"))
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_backup_state(state: dict) -> None:
|
||||||
|
BACKUP_STATE_PATH.write_text(
|
||||||
|
json.dumps(state, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_archive_name(name: str) -> bool:
|
||||||
|
return bool(re.fullmatch(r"backup_[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{6}\.zip", name or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_targets(
|
||||||
|
*,
|
||||||
|
include_env: bool,
|
||||||
|
include_exchange_images: bool,
|
||||||
|
) -> list[tuple[str, Path, str]]:
|
||||||
|
"""Return list of (archive_rel_path, source_path, kind)."""
|
||||||
|
items: list[tuple[str, Path, str]] = []
|
||||||
|
|
||||||
|
if include_env:
|
||||||
|
hub_env = HUB_DIR / ".env"
|
||||||
|
if hub_env.is_file():
|
||||||
|
items.append(("hub/.env", hub_env, "env"))
|
||||||
|
|
||||||
|
for name in HUB_JSON_FILES:
|
||||||
|
src = HUB_DIR / name
|
||||||
|
if src.is_file():
|
||||||
|
items.append((f"hub/{name}", src, "json"))
|
||||||
|
|
||||||
|
data_dir = hub_data_dir()
|
||||||
|
for name in HUB_DATA_FILES:
|
||||||
|
src = data_dir / name
|
||||||
|
if src.is_file():
|
||||||
|
items.append((f"hub/data/{name}", src, "sqlite" if name.endswith(".db") else "json"))
|
||||||
|
|
||||||
|
for key, dirname in EXCHANGE_DIRS:
|
||||||
|
proj = REPO_ROOT / dirname
|
||||||
|
prefix = dirname
|
||||||
|
env_path = proj / ".env"
|
||||||
|
db_rel = "crypto.db"
|
||||||
|
upload_rel = "static/images"
|
||||||
|
if env_path.is_file():
|
||||||
|
db_rel = _read_env_var(env_path, "DB_PATH", "crypto.db") or "crypto.db"
|
||||||
|
upload_rel = _read_env_var(env_path, "UPLOAD_DIR", "static/images") or "static/images"
|
||||||
|
if include_env:
|
||||||
|
items.append((f"{prefix}/.env", env_path, "env"))
|
||||||
|
db_path = _resolve_project_path(proj, db_rel)
|
||||||
|
if db_path.is_file():
|
||||||
|
items.append((f"{prefix}/{db_rel}", db_path, "sqlite"))
|
||||||
|
if include_exchange_images:
|
||||||
|
img_dir = _resolve_project_path(proj, upload_rel)
|
||||||
|
if img_dir.is_dir():
|
||||||
|
for fp in sorted(img_dir.rglob("*")):
|
||||||
|
if fp.is_file():
|
||||||
|
rel = fp.relative_to(proj).as_posix()
|
||||||
|
items.append((f"{prefix}/{rel}", fp, "image"))
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _write_manifest(staging: Path, trigger: str, files: list[dict]) -> None:
|
||||||
|
manifest = {
|
||||||
|
"version": 1,
|
||||||
|
"created_at": _now_local().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"timezone": TZ_NAME,
|
||||||
|
"trigger": trigger,
|
||||||
|
"repo_root": str(REPO_ROOT),
|
||||||
|
"files": files,
|
||||||
|
}
|
||||||
|
(staging / "manifest.json").write_text(
|
||||||
|
json.dumps(manifest, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_backup(
|
||||||
|
*,
|
||||||
|
trigger: str = "manual",
|
||||||
|
settings: dict | None = None,
|
||||||
|
log_fn: Callable[[str], None] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None)
|
||||||
|
root = backup_root(settings)
|
||||||
|
ts = _now_local().strftime("%Y-%m-%d_%H%M%S")
|
||||||
|
archive_name = f"backup_{ts}.zip"
|
||||||
|
archive_path = root / archive_name
|
||||||
|
|
||||||
|
def log(msg: str) -> None:
|
||||||
|
if log_fn:
|
||||||
|
log_fn(msg)
|
||||||
|
|
||||||
|
targets = _collect_targets(
|
||||||
|
include_env=cfg["include_env"],
|
||||||
|
include_exchange_images=cfg["include_exchange_images"],
|
||||||
|
)
|
||||||
|
if not targets:
|
||||||
|
return {"ok": False, "error": "没有可备份的文件"}
|
||||||
|
|
||||||
|
file_meta: list[dict] = []
|
||||||
|
with tempfile.TemporaryDirectory(prefix="hub_backup_") as tmp:
|
||||||
|
staging = Path(tmp)
|
||||||
|
for arc_rel, src, kind in targets:
|
||||||
|
dest = staging / arc_rel
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src, dest)
|
||||||
|
file_meta.append(
|
||||||
|
{
|
||||||
|
"path": arc_rel.replace("\\", "/"),
|
||||||
|
"size": src.stat().st_size,
|
||||||
|
"kind": kind,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_write_manifest(staging, trigger, file_meta)
|
||||||
|
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for fp in sorted(staging.rglob("*")):
|
||||||
|
if fp.is_file():
|
||||||
|
zf.write(fp, fp.relative_to(staging).as_posix())
|
||||||
|
|
||||||
|
size = archive_path.stat().st_size
|
||||||
|
prune_old_backups(root, cfg["retention_days"])
|
||||||
|
state = _load_backup_state()
|
||||||
|
if trigger == "auto":
|
||||||
|
state["last_auto_day"] = _now_local().strftime("%Y-%m-%d")
|
||||||
|
state["last_auto_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
state["last_backup_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
state["last_backup_file"] = archive_name
|
||||||
|
state["last_trigger"] = trigger
|
||||||
|
_save_backup_state(state)
|
||||||
|
log(f"backup written: {archive_path}")
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"file": archive_name,
|
||||||
|
"path": str(archive_path),
|
||||||
|
"size": size,
|
||||||
|
"file_count": len(file_meta),
|
||||||
|
"trigger": trigger,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def prune_old_backups(root: Path, retention_days: int) -> int:
|
||||||
|
if not root.is_dir():
|
||||||
|
return 0
|
||||||
|
cutoff = _now_local() - timedelta(days=max(1, retention_days))
|
||||||
|
removed = 0
|
||||||
|
for fp in root.glob("backup_*.zip"):
|
||||||
|
try:
|
||||||
|
mtime = datetime.fromtimestamp(fp.stat().st_mtime, tz=cutoff.tzinfo)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if mtime < cutoff:
|
||||||
|
fp.unlink(missing_ok=True)
|
||||||
|
removed += 1
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def list_backups(settings: dict | None = None) -> list[dict[str, Any]]:
|
||||||
|
root = backup_root(settings)
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
if not root.is_dir():
|
||||||
|
return rows
|
||||||
|
for fp in sorted(root.glob("backup_*.zip"), reverse=True):
|
||||||
|
try:
|
||||||
|
st = fp.stat()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"name": fp.name,
|
||||||
|
"size": st.st_size,
|
||||||
|
"modified_at": datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def backup_status(settings: dict | None = None) -> dict[str, Any]:
|
||||||
|
cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None)
|
||||||
|
state = _load_backup_state()
|
||||||
|
root = backup_root(settings)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"settings": cfg,
|
||||||
|
"backup_root": str(root),
|
||||||
|
"state": state,
|
||||||
|
"backups": list_backups(settings)[:50],
|
||||||
|
"timezone": TZ_NAME,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pm2_restart_all() -> dict[str, Any]:
|
||||||
|
if os.name != "posix":
|
||||||
|
return {"ok": False, "skipped": True, "reason": "non-posix"}
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["pm2", "restart", "all"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": proc.returncode == 0,
|
||||||
|
"returncode": proc.returncode,
|
||||||
|
"stdout": (proc.stdout or "")[-2000:],
|
||||||
|
"stderr": (proc.stderr or "")[-2000:],
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def restore_backup_archive(
|
||||||
|
archive_path: Path,
|
||||||
|
*,
|
||||||
|
settings: dict | None = None,
|
||||||
|
pre_backup: bool = True,
|
||||||
|
restart_pm2: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not archive_path.is_file():
|
||||||
|
return {"ok": False, "error": "备份文件不存在"}
|
||||||
|
|
||||||
|
pre = None
|
||||||
|
if pre_backup:
|
||||||
|
pre = run_backup(trigger="pre_restore", settings=settings)
|
||||||
|
|
||||||
|
restored: list[str] = []
|
||||||
|
skipped: list[str] = []
|
||||||
|
with tempfile.TemporaryDirectory(prefix="hub_restore_") as tmp:
|
||||||
|
extract_dir = Path(tmp)
|
||||||
|
with zipfile.ZipFile(archive_path, "r") as zf:
|
||||||
|
zf.extractall(extract_dir)
|
||||||
|
manifest_path = extract_dir / "manifest.json"
|
||||||
|
if not manifest_path.is_file():
|
||||||
|
return {"ok": False, "error": "无效的备份包:缺少 manifest.json"}
|
||||||
|
|
||||||
|
for fp in extract_dir.rglob("*"):
|
||||||
|
if not fp.is_file() or fp.name == "manifest.json":
|
||||||
|
continue
|
||||||
|
rel = fp.relative_to(extract_dir).as_posix()
|
||||||
|
parts = Path(rel).parts
|
||||||
|
if parts[0] == "hub":
|
||||||
|
if len(parts) >= 3 and parts[1] == "data":
|
||||||
|
dest = hub_data_dir() / parts[-1]
|
||||||
|
else:
|
||||||
|
dest = HUB_DIR.joinpath(*parts[1:])
|
||||||
|
else:
|
||||||
|
matched = False
|
||||||
|
for _key, dirname in EXCHANGE_DIRS:
|
||||||
|
if rel.startswith(dirname + "/"):
|
||||||
|
dest = REPO_ROOT / rel
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
if not matched:
|
||||||
|
skipped.append(rel)
|
||||||
|
continue
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(fp, dest)
|
||||||
|
restored.append(rel)
|
||||||
|
|
||||||
|
pm2 = _pm2_restart_all() if restart_pm2 else {"ok": False, "skipped": True}
|
||||||
|
state = _load_backup_state()
|
||||||
|
state["last_restore_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
state["last_restore_from"] = archive_path.name
|
||||||
|
_save_backup_state(state)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"restored": restored,
|
||||||
|
"skipped": skipped,
|
||||||
|
"pre_backup": pre,
|
||||||
|
"pm2": pm2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def restore_backup_upload(
|
||||||
|
content: bytes,
|
||||||
|
filename: str,
|
||||||
|
*,
|
||||||
|
settings: dict | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not content:
|
||||||
|
return {"ok": False, "error": "空文件"}
|
||||||
|
suffix = Path(filename or "").suffix.lower()
|
||||||
|
if suffix != ".zip":
|
||||||
|
return {"ok": False, "error": "仅支持 .zip 备份包"}
|
||||||
|
with tempfile.NamedTemporaryFile(prefix="hub_restore_upload_", suffix=".zip", delete=False) as tf:
|
||||||
|
tf.write(content)
|
||||||
|
temp_path = Path(tf.name)
|
||||||
|
try:
|
||||||
|
return restore_backup_archive(temp_path, settings=settings)
|
||||||
|
finally:
|
||||||
|
temp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_backup_download(settings: dict | None, name: str) -> Optional[Path]:
|
||||||
|
if not _safe_archive_name(name):
|
||||||
|
return None
|
||||||
|
fp = backup_root(settings) / name
|
||||||
|
if fp.is_file():
|
||||||
|
return fp
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def should_run_auto_backup(settings: dict) -> bool:
|
||||||
|
cfg = normalize_backup_settings(settings.get("backup"))
|
||||||
|
if not cfg.get("auto_enabled"):
|
||||||
|
return False
|
||||||
|
now = _now_local()
|
||||||
|
today = now.strftime("%Y-%m-%d")
|
||||||
|
state = _load_backup_state()
|
||||||
|
if state.get("last_auto_day") == today:
|
||||||
|
return False
|
||||||
|
if now.hour < int(cfg.get("auto_hour", 0)):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def mark_auto_backup_done() -> None:
|
||||||
|
state = _load_backup_state()
|
||||||
|
state["last_auto_day"] = _now_local().strftime("%Y-%m-%d")
|
||||||
|
state["last_auto_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
_save_backup_state(state)
|
||||||
@@ -19,8 +19,8 @@ from flask import (
|
|||||||
session,
|
session,
|
||||||
)
|
)
|
||||||
|
|
||||||
from hub_auth import request_allowed
|
from lib.hub.hub_auth import request_allowed
|
||||||
from hub_sso import (
|
from lib.hub.hub_sso import (
|
||||||
mint_hub_embed_bootstrap,
|
mint_hub_embed_bootstrap,
|
||||||
safe_next_path,
|
safe_next_path,
|
||||||
verify_hub_embed_bootstrap,
|
verify_hub_embed_bootstrap,
|
||||||
@@ -42,22 +42,34 @@ def _merge_query_into_path(path: str, **params: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def install_instance_theme_static(app) -> None:
|
def install_instance_theme_static(app) -> None:
|
||||||
"""仓库根 static/instance_theme.* 供四所页面共用。"""
|
"""仓库 lib/common/static 下 instance_theme.* 等供四所页面共用。"""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from flask import Response, send_file
|
from flask import Response, send_file
|
||||||
|
|
||||||
repo_static = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
from lib.paths import common_static_dir
|
||||||
|
|
||||||
|
repo_static = common_static_dir()
|
||||||
assets = {
|
assets = {
|
||||||
"instance_theme.js": "application/javascript; charset=utf-8",
|
"instance_theme.js": "application/javascript; charset=utf-8",
|
||||||
"instance_theme_early.css": "text/css; charset=utf-8",
|
"instance_theme_early.css": "text/css; charset=utf-8",
|
||||||
"instance_theme.css": "text/css; charset=utf-8",
|
"instance_theme.css": "text/css; charset=utf-8",
|
||||||
|
"account_risk_badge.css": "text/css; charset=utf-8",
|
||||||
|
"account_risk_badge.js": "application/javascript; charset=utf-8",
|
||||||
"instance_ui.js": "application/javascript; charset=utf-8",
|
"instance_ui.js": "application/javascript; charset=utf-8",
|
||||||
|
"instance_records_mobile.js": "application/javascript; charset=utf-8",
|
||||||
"ai_review_render.js": "application/javascript; charset=utf-8",
|
"ai_review_render.js": "application/javascript; charset=utf-8",
|
||||||
"form_submit_guard.js": "application/javascript; charset=utf-8",
|
"form_submit_guard.js": "application/javascript; charset=utf-8",
|
||||||
|
"key_monitor_form.js": "application/javascript; charset=utf-8",
|
||||||
"time_close_ui.js": "application/javascript; charset=utf-8",
|
"time_close_ui.js": "application/javascript; charset=utf-8",
|
||||||
|
"manual_order_rr_preview.js": "application/javascript; charset=utf-8",
|
||||||
|
"strategy_roll.js": "application/javascript; charset=utf-8",
|
||||||
|
"instance_page.css": "text/css; charset=utf-8",
|
||||||
|
"instance_embed.js": "application/javascript; charset=utf-8",
|
||||||
"focus_chart_page.js": "application/javascript; charset=utf-8",
|
"focus_chart_page.js": "application/javascript; charset=utf-8",
|
||||||
"focus_chart_page.css": "text/css; charset=utf-8",
|
"focus_chart_page.css": "text/css; charset=utf-8",
|
||||||
|
"trade_stats_calendar.js": "application/javascript; charset=utf-8",
|
||||||
|
"trade_stats_calendar.css": "text/css; charset=utf-8",
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, mime in assets.items():
|
for name, mime in assets.items():
|
||||||
@@ -75,6 +87,54 @@ def install_instance_theme_static(app) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_trade_stats_calendar_route(
|
||||||
|
app,
|
||||||
|
*,
|
||||||
|
login_required_fn,
|
||||||
|
load_pnls_fn,
|
||||||
|
row_matches_segment_fn,
|
||||||
|
reset_hour: int,
|
||||||
|
get_db_fn=None,
|
||||||
|
):
|
||||||
|
"""四所统计分析页:按月返回各交易日盈亏/笔数。"""
|
||||||
|
from flask import jsonify, request
|
||||||
|
|
||||||
|
from lib.trade.trade_stats_calendar_lib import build_trade_stats_calendar
|
||||||
|
|
||||||
|
@app.route("/api/stats/calendar")
|
||||||
|
@login_required_fn
|
||||||
|
def api_stats_calendar():
|
||||||
|
year = request.args.get("year", type=int)
|
||||||
|
month = request.args.get("month", type=int)
|
||||||
|
segment = (request.args.get("segment") or "all").strip() or "all"
|
||||||
|
if not year or not month:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
year = year or now.year
|
||||||
|
month = month or now.month
|
||||||
|
get_db = get_db_fn or (app.config.get("HUB_CTX") or {}).get("get_db")
|
||||||
|
if not get_db:
|
||||||
|
return jsonify({"ok": False, "msg": "未配置数据库"}), 500
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
pnls = load_pnls_fn(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
try:
|
||||||
|
payload = build_trade_stats_calendar(
|
||||||
|
pnls,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
segment,
|
||||||
|
row_matches_segment_fn,
|
||||||
|
reset_hour=int(reset_hour),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"ok": False, "msg": str(exc)}), 400
|
||||||
|
return jsonify({"ok": True, **payload})
|
||||||
|
|
||||||
|
|
||||||
def _hub_auth_required(f):
|
def _hub_auth_required(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def wrapped(*args, **kwargs):
|
def wrapped(*args, **kwargs):
|
||||||
@@ -106,6 +166,7 @@ def build_hub_monitor_payload(
|
|||||||
trends,
|
trends,
|
||||||
rolls,
|
rolls,
|
||||||
enrich=None,
|
enrich=None,
|
||||||
|
risk_status=None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""合并 enrich 增量字段;enrich 只返回 trends 等局部时不得丢掉 keys/orders。"""
|
"""合并 enrich 增量字段;enrich 只返回 trends 等局部时不得丢掉 keys/orders。"""
|
||||||
payload = {
|
payload = {
|
||||||
@@ -116,6 +177,8 @@ def build_hub_monitor_payload(
|
|||||||
"rolls": rolls,
|
"rolls": rolls,
|
||||||
"key_prices": [],
|
"key_prices": [],
|
||||||
}
|
}
|
||||||
|
if isinstance(risk_status, dict):
|
||||||
|
payload["risk_status"] = risk_status
|
||||||
if callable(enrich):
|
if callable(enrich):
|
||||||
extra = enrich(keys=keys, orders=orders, trends=trends, rolls=rolls)
|
extra = enrich(keys=keys, orders=orders, trends=trends, rolls=rolls)
|
||||||
if isinstance(extra, dict):
|
if isinstance(extra, dict):
|
||||||
@@ -195,6 +258,19 @@ def _hub_json(view_name: str, path: str, form=None):
|
|||||||
return jsonify({"ok": False, "messages": [str(e)]})
|
return jsonify({"ok": False, "messages": [str(e)]})
|
||||||
|
|
||||||
|
|
||||||
|
def _embed_login_dest(next_path: str) -> str:
|
||||||
|
"""embed=1 时把 /trade 等映射到 /embed?tab=…"""
|
||||||
|
ht = (request.args.get("hub_theme") or "").strip().lower()
|
||||||
|
hub_theme = ht if ht in ("light", "dark") else None
|
||||||
|
if request.args.get("embed", "").strip().lower() in ("1", "true", "yes", "on"):
|
||||||
|
from lib.instance.instance_embed_lib import rewrite_embed_dest
|
||||||
|
|
||||||
|
return rewrite_embed_dest(next_path, hub_theme=hub_theme)
|
||||||
|
if hub_theme:
|
||||||
|
return _merge_query_into_path(next_path, hub_theme=hub_theme)
|
||||||
|
return next_path
|
||||||
|
|
||||||
|
|
||||||
def install_on_app(
|
def install_on_app(
|
||||||
app,
|
app,
|
||||||
*,
|
*,
|
||||||
@@ -208,7 +284,12 @@ def install_on_app(
|
|||||||
ohlcv_fn=None,
|
ohlcv_fn=None,
|
||||||
account_fn=None,
|
account_fn=None,
|
||||||
volume_rank_fn=None,
|
volume_rank_fn=None,
|
||||||
|
market_fn=None,
|
||||||
reconcile_hub_flat_fn=None,
|
reconcile_hub_flat_fn=None,
|
||||||
|
risk_status_fn=None,
|
||||||
|
user_close_fn=None,
|
||||||
|
render_main_page_fn=None,
|
||||||
|
login_required_fn=None,
|
||||||
):
|
):
|
||||||
app.config["HUB_CTX"] = {
|
app.config["HUB_CTX"] = {
|
||||||
"exchange": exchange,
|
"exchange": exchange,
|
||||||
@@ -221,12 +302,21 @@ def install_on_app(
|
|||||||
"views": views,
|
"views": views,
|
||||||
"ohlcv_fn": ohlcv_fn,
|
"ohlcv_fn": ohlcv_fn,
|
||||||
"volume_rank_fn": volume_rank_fn,
|
"volume_rank_fn": volume_rank_fn,
|
||||||
|
"market_fn": market_fn,
|
||||||
"reconcile_hub_flat_fn": reconcile_hub_flat_fn,
|
"reconcile_hub_flat_fn": reconcile_hub_flat_fn,
|
||||||
|
"risk_status_fn": risk_status_fn,
|
||||||
|
"user_close_fn": user_close_fn,
|
||||||
}
|
}
|
||||||
install_hub_embed_headers(app)
|
install_hub_embed_headers(app)
|
||||||
configure_hub_embed_session(app)
|
configure_hub_embed_session(app)
|
||||||
install_instance_theme_static(app)
|
install_instance_theme_static(app)
|
||||||
register_hub_routes(app)
|
register_hub_routes(app)
|
||||||
|
if render_main_page_fn and login_required_fn:
|
||||||
|
from lib.instance.instance_embed_lib import attach_embed_templates, register_embed_routes
|
||||||
|
from lib.paths import REPO_ROOT
|
||||||
|
|
||||||
|
attach_embed_templates(app, str(REPO_ROOT))
|
||||||
|
register_embed_routes(app, login_required_fn, render_main_page_fn)
|
||||||
|
|
||||||
|
|
||||||
def configure_hub_embed_session(app):
|
def configure_hub_embed_session(app):
|
||||||
@@ -360,6 +450,58 @@ def register_hub_routes(app):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"ok": False, "msg": str(e)}), 500
|
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route("/api/account_risk_status")
|
||||||
|
@_hub_auth_required
|
||||||
|
def api_account_risk_status():
|
||||||
|
c = _ctx()
|
||||||
|
get_db = c.get("get_db")
|
||||||
|
risk_fn = c.get("risk_status_fn")
|
||||||
|
if not callable(get_db) or not callable(risk_fn):
|
||||||
|
return jsonify({"ok": False, "msg": "未配置风控"}), 501
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
payload = risk_fn(conn)
|
||||||
|
return jsonify({"ok": True, **(payload if isinstance(payload, dict) else {})})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@app.route("/api/hub/account-risk/user-close", methods=["POST"])
|
||||||
|
@_hub_auth_required
|
||||||
|
def api_hub_account_risk_user_close():
|
||||||
|
"""中控/实例:登记用户主动平仓(计入冷静期与日冻结)。"""
|
||||||
|
c = _ctx()
|
||||||
|
get_db = c.get("get_db")
|
||||||
|
user_close_fn = c.get("user_close_fn")
|
||||||
|
if not callable(get_db) or not callable(user_close_fn):
|
||||||
|
return jsonify({"ok": False, "msg": "未配置 user_close_fn"}), 501
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
source = (body.get("source") or request.form.get("source") or "").strip()
|
||||||
|
try:
|
||||||
|
count = max(0, int(body.get("count") if body.get("count") is not None else 1))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
count = 1
|
||||||
|
trade_record_id = body.get("trade_record_id")
|
||||||
|
closed_at_ms = body.get("closed_at_ms")
|
||||||
|
if count <= 0:
|
||||||
|
return jsonify({"ok": True, "skipped": True, "count": 0})
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
user_close_fn(
|
||||||
|
conn,
|
||||||
|
source=source,
|
||||||
|
count=count,
|
||||||
|
trade_record_id=trade_record_id,
|
||||||
|
closed_at_ms=closed_at_ms,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({"ok": True, "count": count, "source": source})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
@app.route("/api/hub/monitor")
|
@app.route("/api/hub/monitor")
|
||||||
@_hub_auth_required
|
@_hub_auth_required
|
||||||
def api_hub_monitor():
|
def api_hub_monitor():
|
||||||
@@ -377,7 +519,7 @@ def register_hub_routes(app):
|
|||||||
).fetchall():
|
).fetchall():
|
||||||
od = _row_to_dict(row)
|
od = _row_to_dict(row)
|
||||||
try:
|
try:
|
||||||
from strategy_trade_labels import apply_order_monitor_source_labels
|
from lib.strategy.strategy_trade_labels import apply_order_monitor_source_labels
|
||||||
|
|
||||||
od = apply_order_monitor_source_labels(od)
|
od = apply_order_monitor_source_labels(od)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -399,6 +541,13 @@ def register_hub_routes(app):
|
|||||||
rolls.append(_row_to_dict(row))
|
rolls.append(_row_to_dict(row))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
risk_status = None
|
||||||
|
risk_fn = c.get("risk_status_fn")
|
||||||
|
if callable(risk_fn):
|
||||||
|
try:
|
||||||
|
risk_status = risk_fn(conn)
|
||||||
|
except Exception:
|
||||||
|
risk_status = None
|
||||||
conn.close()
|
conn.close()
|
||||||
enrich = c.get("enrich_monitor")
|
enrich = c.get("enrich_monitor")
|
||||||
if callable(enrich):
|
if callable(enrich):
|
||||||
@@ -410,6 +559,7 @@ def register_hub_routes(app):
|
|||||||
trends=trends,
|
trends=trends,
|
||||||
rolls=rolls,
|
rolls=rolls,
|
||||||
enrich=enrich,
|
enrich=enrich,
|
||||||
|
risk_status=risk_status,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -420,6 +570,7 @@ def register_hub_routes(app):
|
|||||||
orders=orders,
|
orders=orders,
|
||||||
trends=trends,
|
trends=trends,
|
||||||
rolls=rolls,
|
rolls=rolls,
|
||||||
|
risk_status=risk_status,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -427,7 +578,7 @@ def register_hub_routes(app):
|
|||||||
@_hub_auth_required
|
@_hub_auth_required
|
||||||
def api_hub_trades_archive():
|
def api_hub_trades_archive():
|
||||||
"""中控币种档案:近 N 天已平仓记录。"""
|
"""中控币种档案:近 N 天已平仓记录。"""
|
||||||
from hub_trades_lib import fetch_trades_for_archive, summarize_trades
|
from lib.hub.hub_trades_lib import fetch_trades_for_archive, summarize_trades
|
||||||
|
|
||||||
c = _ctx()
|
c = _ctx()
|
||||||
get_db = c.get("get_db")
|
get_db = c.get("get_db")
|
||||||
@@ -474,7 +625,7 @@ def register_hub_routes(app):
|
|||||||
@_hub_auth_required
|
@_hub_auth_required
|
||||||
def api_hub_trades_today():
|
def api_hub_trades_today():
|
||||||
"""中控 AI:当日已平仓记录(按实例交易日)。"""
|
"""中控 AI:当日已平仓记录(按实例交易日)。"""
|
||||||
from hub_trades_lib import (
|
from lib.hub.hub_trades_lib import (
|
||||||
current_trading_day,
|
current_trading_day,
|
||||||
fetch_trades_for_trading_day,
|
fetch_trades_for_trading_day,
|
||||||
summarize_trades,
|
summarize_trades,
|
||||||
@@ -531,6 +682,21 @@ def register_hub_routes(app):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"ok": False, "msg": str(e)}), 500
|
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route("/api/hub/market")
|
||||||
|
@_hub_auth_required
|
||||||
|
def api_hub_market():
|
||||||
|
fn = _ctx().get("market_fn")
|
||||||
|
if not callable(fn):
|
||||||
|
return jsonify({"ok": False, "msg": "该实例未配置合约信息接口"}), 501
|
||||||
|
base = (request.args.get("base") or request.args.get("symbol") or "").strip()
|
||||||
|
try:
|
||||||
|
result = fn(base=base)
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return jsonify(result)
|
||||||
|
return jsonify({"ok": False, "msg": "合约信息返回格式无效"}), 500
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||||
|
|
||||||
@app.route("/api/hub/ohlcv")
|
@app.route("/api/hub/ohlcv")
|
||||||
@_hub_auth_required
|
@_hub_auth_required
|
||||||
def api_hub_ohlcv():
|
def api_hub_ohlcv():
|
||||||
@@ -667,7 +833,7 @@ def register_hub_routes(app):
|
|||||||
get_db = _ctx().get("get_db")
|
get_db = _ctx().get("get_db")
|
||||||
if not cfg or not callable(get_db):
|
if not cfg or not callable(get_db):
|
||||||
return jsonify({"ok": False, "msg": "趋势配置未就绪"}), 500
|
return jsonify({"ok": False, "msg": "趋势配置未就绪"}), 500
|
||||||
from strategy_trend_register import sync_trend_plans_after_external_close
|
from lib.strategy.strategy_trend_register import sync_trend_plans_after_external_close
|
||||||
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
try:
|
try:
|
||||||
@@ -677,6 +843,38 @@ def register_hub_routes(app):
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
@app.route("/api/hub/roll/sync-flat", methods=["POST"])
|
||||||
|
@_hub_auth_required
|
||||||
|
def api_hub_roll_sync_flat():
|
||||||
|
"""中控/实例手动平仓后:取消滚仓 pending 并关闭 active 滚仓组。"""
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
symbol = (body.get("symbol") or request.form.get("symbol") or "").strip()
|
||||||
|
side = (
|
||||||
|
body.get("side")
|
||||||
|
or body.get("direction")
|
||||||
|
or request.form.get("side")
|
||||||
|
or ""
|
||||||
|
).strip().lower()
|
||||||
|
if not symbol:
|
||||||
|
return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400
|
||||||
|
if side not in ("long", "short"):
|
||||||
|
return jsonify({"ok": False, "msg": "side 须为 long 或 short"}), 400
|
||||||
|
cfg = current_app.extensions.get("strategy_roll_cfg")
|
||||||
|
get_db = _ctx().get("get_db")
|
||||||
|
if not cfg or not callable(get_db):
|
||||||
|
return jsonify({"ok": False, "msg": "滚仓配置未就绪"}), 500
|
||||||
|
from lib.strategy.strategy_register import roll_sync_after_external_close
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
out = roll_sync_after_external_close(cfg, conn, symbol, side)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify(out)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
@app.route("/api/hub/trend/breakeven/<int:pid>", methods=["POST"])
|
@app.route("/api/hub/trend/breakeven/<int:pid>", methods=["POST"])
|
||||||
@_hub_auth_required
|
@_hub_auth_required
|
||||||
def api_hub_trend_breakeven(pid):
|
def api_hub_trend_breakeven(pid):
|
||||||
@@ -709,25 +907,30 @@ def register_hub_routes(app):
|
|||||||
token = (request.args.get("token") or "").strip()
|
token = (request.args.get("token") or "").strip()
|
||||||
ok, next_path, err = verify_hub_sso_token(token, ex)
|
ok, next_path, err = verify_hub_sso_token(token, ex)
|
||||||
if ok:
|
if ok:
|
||||||
if _sso_wants_embed_auth() and request.is_secure:
|
embed_on = request.args.get("embed", "").strip().lower() in (
|
||||||
boot = mint_hub_embed_bootstrap(ex, next_path)
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
)
|
||||||
|
dest_next = _embed_login_dest(next_path) if embed_on else next_path
|
||||||
|
if not embed_on:
|
||||||
|
ht = (request.args.get("hub_theme") or "").strip().lower()
|
||||||
|
if ht in ("light", "dark"):
|
||||||
|
dest_next = _merge_query_into_path(next_path, hub_theme=ht)
|
||||||
|
if embed_on and _sso_wants_embed_auth() and request.is_secure:
|
||||||
|
boot = mint_hub_embed_bootstrap(ex, dest_next)
|
||||||
if boot:
|
if boot:
|
||||||
from urllib.parse import urlencode as _ue
|
from urllib.parse import urlencode as _ue
|
||||||
|
|
||||||
qdict = {"t": boot, "next": next_path, "embed": "1"}
|
qdict = {"t": boot, "next": dest_next, "embed": "1"}
|
||||||
ht0 = (request.args.get("hub_theme") or "").strip().lower()
|
ht0 = (request.args.get("hub_theme") or "").strip().lower()
|
||||||
if ht0 in ("light", "dark"):
|
if ht0 in ("light", "dark"):
|
||||||
qdict["hub_theme"] = ht0
|
qdict["hub_theme"] = ht0
|
||||||
return redirect(f"/hub-embed-auth?{_ue(qdict)}")
|
return redirect(f"/hub-embed-auth?{_ue(qdict)}")
|
||||||
session["logged_in"] = True
|
session["logged_in"] = True
|
||||||
session.modified = True
|
session.modified = True
|
||||||
dest = next_path
|
return redirect(dest_next)
|
||||||
if request.args.get("embed", "").strip().lower() in ("1", "true", "yes", "on"):
|
|
||||||
dest = _merge_query_into_path(dest, embed="1")
|
|
||||||
ht = (request.args.get("hub_theme") or "").strip().lower()
|
|
||||||
if ht in ("light", "dark"):
|
|
||||||
dest = _merge_query_into_path(dest, hub_theme=ht)
|
|
||||||
return redirect(dest)
|
|
||||||
hint = err or "校验失败"
|
hint = err or "校验失败"
|
||||||
flash(
|
flash(
|
||||||
f"中控 SSO 未生效({hint})。"
|
f"中控 SSO 未生效({hint})。"
|
||||||
@@ -751,13 +954,7 @@ def register_hub_routes(app):
|
|||||||
if ok:
|
if ok:
|
||||||
session["logged_in"] = True
|
session["logged_in"] = True
|
||||||
session.modified = True
|
session.modified = True
|
||||||
dest = next_path
|
return redirect(_embed_login_dest(next_path))
|
||||||
if request.args.get("embed", "").strip().lower() in ("1", "true", "yes", "on"):
|
|
||||||
dest = _merge_query_into_path(dest, embed="1")
|
|
||||||
ht = (request.args.get("hub_theme") or "").strip().lower()
|
|
||||||
if ht in ("light", "dark"):
|
|
||||||
dest = _merge_query_into_path(dest, hub_theme=ht)
|
|
||||||
return redirect(dest)
|
|
||||||
hint = err or "校验失败"
|
hint = err or "校验失败"
|
||||||
flash(f"iframe 登录未生效({hint})。可点本地导航工具栏「实例免密」重试。")
|
flash(f"iframe 登录未生效({hint})。可点本地导航工具栏「实例免密」重试。")
|
||||||
return redirect("/login")
|
return redirect("/login")
|
||||||
@@ -790,7 +987,7 @@ def _fetch_preview(pid):
|
|||||||
now_ms = int(time.time() * 1000)
|
now_ms = int(time.time() * 1000)
|
||||||
d["expires_in_sec"] = max(0, int((int(d.get("expires_at_ms") or 0) - now_ms) / 1000))
|
d["expires_in_sec"] = max(0, int((int(d.get("expires_at_ms") or 0) - now_ms) / 1000))
|
||||||
try:
|
try:
|
||||||
from strategy_trend_lib import build_trend_preview_level_rows
|
from lib.strategy.strategy_trend_lib import build_trend_preview_level_rows
|
||||||
|
|
||||||
enriched, level_rows = build_trend_preview_level_rows(d)
|
enriched, level_rows = build_trend_preview_level_rows(d)
|
||||||
for key in (
|
for key in (
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
"""中控历史测算:趋势回调 / 滚仓,以损定仓(按交易所精度与张数规则)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Optional, Tuple
|
||||||
|
|
||||||
|
from lib.strategy.strategy_roll_lib import max_roll_legs
|
||||||
|
from lib.strategy.strategy_trend_lib import (
|
||||||
|
build_trend_preview_level_rows,
|
||||||
|
calc_risk_fraction,
|
||||||
|
compute_trend_plan_core,
|
||||||
|
validate_trend_bounds,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_DCA_LEGS = 5
|
||||||
|
MARGIN_BUFFER = 0.95
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_market(
|
||||||
|
exchange_id: str,
|
||||||
|
base: str,
|
||||||
|
) -> Tuple[Optional[dict[str, Any]], Optional[Callable[[float], Optional[float]]], Optional[str]]:
|
||||||
|
from lib.hub.hub_calculator_market_lib import get_calculator_market, make_amount_precise_fn_from_market
|
||||||
|
|
||||||
|
market, err = get_calculator_market(exchange_id, base)
|
||||||
|
if err or not market:
|
||||||
|
return None, None, err or "无法解析合约"
|
||||||
|
amount_precise = make_amount_precise_fn_from_market(market)
|
||||||
|
return market, amount_precise, None
|
||||||
|
|
||||||
|
|
||||||
|
def calc_trend_calculator(
|
||||||
|
*,
|
||||||
|
direction: str,
|
||||||
|
capital_usdt: float,
|
||||||
|
risk_percent: float,
|
||||||
|
leverage: int,
|
||||||
|
entry_price: float,
|
||||||
|
stop_loss: float,
|
||||||
|
add_upper: float,
|
||||||
|
take_profit: float,
|
||||||
|
dca_legs: int = DEFAULT_DCA_LEGS,
|
||||||
|
exchange_id: str = "0",
|
||||||
|
base: str = "ETH",
|
||||||
|
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||||
|
market, amount_precise, merr = _resolve_market(exchange_id, base)
|
||||||
|
if merr or not market or not amount_precise:
|
||||||
|
return None, merr or "无法解析合约"
|
||||||
|
contract_size = float(market.get("contract_size") or 1.0)
|
||||||
|
exchange_symbol = market["exchange_symbol"]
|
||||||
|
|
||||||
|
direction = (direction or "long").strip().lower()
|
||||||
|
if direction not in ("long", "short"):
|
||||||
|
return None, "方向须为 long 或 short"
|
||||||
|
try:
|
||||||
|
capital = float(capital_usdt)
|
||||||
|
rp = float(risk_percent)
|
||||||
|
lev = int(leverage)
|
||||||
|
entry = float(entry_price)
|
||||||
|
sl = float(stop_loss)
|
||||||
|
upper = float(add_upper)
|
||||||
|
tp = float(take_profit)
|
||||||
|
legs = max(1, int(dca_legs))
|
||||||
|
cs = float(contract_size) if contract_size else 1.0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, "参数格式错误"
|
||||||
|
if capital <= 0 or rp <= 0 or lev <= 0 or entry <= 0 or sl <= 0 or upper <= 0 or tp <= 0:
|
||||||
|
return None, "资金、风险、杠杆与价格须大于 0"
|
||||||
|
|
||||||
|
bound_err = validate_trend_bounds(direction, sl, upper)
|
||||||
|
if bound_err:
|
||||||
|
return None, bound_err
|
||||||
|
|
||||||
|
rf = calc_risk_fraction(direction, upper, sl)
|
||||||
|
if rf is None or rf <= 0:
|
||||||
|
return None, "止损与补仓区间边界组合无法计算风险比例"
|
||||||
|
|
||||||
|
risk_budget = capital * (rp / 100.0)
|
||||||
|
notional = risk_budget / rf
|
||||||
|
margin_plan = min(notional / float(lev), capital * MARGIN_BUFFER)
|
||||||
|
if margin_plan <= 0:
|
||||||
|
return None, "计划保证金过小"
|
||||||
|
|
||||||
|
target_amt = _amount_from_margin(margin_plan, lev, entry, cs)
|
||||||
|
if target_amt is None or target_amt <= 0:
|
||||||
|
return None, "无法计算计划张数,请检查入场价与杠杆"
|
||||||
|
target_amt = amount_precise(target_amt)
|
||||||
|
if target_amt is None or target_amt <= 0:
|
||||||
|
return None, "计划张数低于交易所最小精度"
|
||||||
|
|
||||||
|
def _amount_precise(_symbol: str, amount: float) -> Optional[float]:
|
||||||
|
return amount_precise(amount)
|
||||||
|
|
||||||
|
payload, err = compute_trend_plan_core(
|
||||||
|
direction=direction,
|
||||||
|
stop_loss=sl,
|
||||||
|
add_upper=upper,
|
||||||
|
risk_percent=rp,
|
||||||
|
snapshot_usdt=capital,
|
||||||
|
leverage=lev,
|
||||||
|
live_price=entry,
|
||||||
|
target_order_amount=target_amt,
|
||||||
|
exchange_symbol=exchange_symbol,
|
||||||
|
dca_legs=legs,
|
||||||
|
amount_precise=_amount_precise,
|
||||||
|
min_amount=float(market.get("min_amount") or 0.0),
|
||||||
|
full_margin_buffer_ratio=MARGIN_BUFFER,
|
||||||
|
)
|
||||||
|
if err:
|
||||||
|
return None, err
|
||||||
|
|
||||||
|
payload["take_profit"] = tp
|
||||||
|
payload["leverage"] = lev
|
||||||
|
payload["contract_size"] = cs
|
||||||
|
preview, rows = build_trend_preview_level_rows(payload)
|
||||||
|
|
||||||
|
px_dec = int(market.get("price_decimals") or 4)
|
||||||
|
amt_dec = int(market.get("amount_decimals") or 4)
|
||||||
|
|
||||||
|
def _f(v: Any, nd: int | None = None) -> Any:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return round(float(v), nd if nd is not None else 8)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return v
|
||||||
|
|
||||||
|
table = []
|
||||||
|
for row in rows:
|
||||||
|
table.append(
|
||||||
|
{
|
||||||
|
"label": row.get("label"),
|
||||||
|
"price": _f(row.get("price"), px_dec),
|
||||||
|
"contracts": _f(row.get("contracts"), amt_dec),
|
||||||
|
"avg_entry": _f(row.get("avg_entry"), px_dec),
|
||||||
|
"profit_u": _f(row.get("profit_u")),
|
||||||
|
"risk_u": _f(row.get("risk_u")),
|
||||||
|
"rr": _f(row.get("rr"), 4),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"direction": direction,
|
||||||
|
"capital_usdt": _f(capital),
|
||||||
|
"risk_percent": _f(rp, 2),
|
||||||
|
"risk_budget_u": _f(preview.get("preview_risk_amount_u")),
|
||||||
|
"leverage": lev,
|
||||||
|
"entry_price": _f(entry, px_dec),
|
||||||
|
"stop_loss": _f(sl, px_dec),
|
||||||
|
"add_upper": _f(upper, px_dec),
|
||||||
|
"take_profit": _f(tp, px_dec),
|
||||||
|
"plan_margin_u": _f(preview.get("plan_margin_capital")),
|
||||||
|
"target_contracts": _f(preview.get("target_order_amount"), amt_dec),
|
||||||
|
"first_contracts": _f(preview.get("first_order_amount"), amt_dec),
|
||||||
|
"dca_legs": int(preview.get("dca_legs") or legs),
|
||||||
|
"first_profit_u": _f(preview.get("preview_first_profit_u")),
|
||||||
|
"first_rr": _f(preview.get("preview_target_rr"), 4),
|
||||||
|
"market": market,
|
||||||
|
"rows": table,
|
||||||
|
}, None
|
||||||
|
|
||||||
|
|
||||||
|
def _amount_from_margin(
|
||||||
|
margin_capital: float,
|
||||||
|
leverage: int,
|
||||||
|
price: float,
|
||||||
|
contract_size: float,
|
||||||
|
) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
margin = float(margin_capital)
|
||||||
|
lev = int(leverage)
|
||||||
|
px = float(price)
|
||||||
|
cs = float(contract_size) if contract_size else 1.0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if margin <= 0 or lev <= 0 or px <= 0 or cs <= 0:
|
||||||
|
return None
|
||||||
|
notional = margin * lev
|
||||||
|
return notional / (px * cs)
|
||||||
|
|
||||||
|
|
||||||
|
def _round(v: Any, nd: int = 4) -> Any:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return round(float(v), nd)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _money_rr(profit_u: Optional[float], risk_u: Optional[float]) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
if risk_u is None or float(risk_u) <= 0 or profit_u is None:
|
||||||
|
return None
|
||||||
|
return round(float(profit_u) / float(risk_u), 4)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def calc_initial_roll_qty(
|
||||||
|
direction: str,
|
||||||
|
entry_price: float,
|
||||||
|
stop_loss: float,
|
||||||
|
risk_budget_usdt: float,
|
||||||
|
contract_size: float = 1.0,
|
||||||
|
) -> Tuple[Optional[float], Optional[str]]:
|
||||||
|
"""首仓以损定仓:打到初始止损亏损 = 风险预算。"""
|
||||||
|
try:
|
||||||
|
entry = float(entry_price)
|
||||||
|
sl = float(stop_loss)
|
||||||
|
budget = float(risk_budget_usdt)
|
||||||
|
cs = float(contract_size) if contract_size else 1.0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, "参数格式错误"
|
||||||
|
if entry <= 0 or sl <= 0 or budget <= 0 or cs <= 0:
|
||||||
|
return None, "入场价、止损与风险预算须大于 0"
|
||||||
|
direction = (direction or "long").strip().lower()
|
||||||
|
if direction == "short":
|
||||||
|
per_unit = (sl - entry) * cs
|
||||||
|
if per_unit <= 0:
|
||||||
|
return None, "做空:止损价须高于首仓入场价"
|
||||||
|
else:
|
||||||
|
per_unit = (entry - sl) * cs
|
||||||
|
if per_unit <= 0:
|
||||||
|
return None, "做多:止损价须低于首仓入场价"
|
||||||
|
return budget / per_unit, None
|
||||||
|
|
||||||
|
|
||||||
|
def solve_add_amount_for_total_risk(
|
||||||
|
direction: str,
|
||||||
|
qty_existing: float,
|
||||||
|
entry_existing: float,
|
||||||
|
add_price: float,
|
||||||
|
new_stop: float,
|
||||||
|
risk_budget_usdt: float,
|
||||||
|
contract_size: float = 1.0,
|
||||||
|
) -> Tuple[Optional[float], Optional[str]]:
|
||||||
|
"""合并持仓打到新止损总亏损 = 风险预算,反推本次加仓张数。"""
|
||||||
|
try:
|
||||||
|
q1 = float(qty_existing)
|
||||||
|
e1 = float(entry_existing)
|
||||||
|
e2 = float(add_price)
|
||||||
|
sl = float(new_stop)
|
||||||
|
b = float(risk_budget_usdt)
|
||||||
|
cs = float(contract_size) if contract_size else 1.0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, "参数格式错误"
|
||||||
|
if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0 or cs <= 0:
|
||||||
|
return None, "持仓或风险预算无效"
|
||||||
|
direction = (direction or "long").strip().lower()
|
||||||
|
if direction == "short":
|
||||||
|
denom = sl - e2
|
||||||
|
numer = b / cs - q1 * (sl - e1)
|
||||||
|
if denom <= 0:
|
||||||
|
return None, "做空:新止损须高于限价加仓价"
|
||||||
|
else:
|
||||||
|
denom = e2 - sl
|
||||||
|
numer = b / cs - q1 * (e1 - sl)
|
||||||
|
if denom <= 0:
|
||||||
|
return None, "做多:新止损须低于限价/市价加仓价"
|
||||||
|
q2 = numer / denom
|
||||||
|
if q2 <= 0:
|
||||||
|
return None, "按当前新止损与总风险%,无需加仓或无法再加(已满足风险上限)"
|
||||||
|
return q2, None
|
||||||
|
|
||||||
|
|
||||||
|
def _roll_leg_preview(
|
||||||
|
*,
|
||||||
|
direction: str,
|
||||||
|
qty_existing: float,
|
||||||
|
entry_existing: float,
|
||||||
|
take_profit: float,
|
||||||
|
add_price: float,
|
||||||
|
new_stop_loss: float,
|
||||||
|
risk_budget: float,
|
||||||
|
contract_size: float,
|
||||||
|
amount_precise: Callable[[float], Optional[float]],
|
||||||
|
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||||
|
direction = (direction or "long").strip().lower()
|
||||||
|
try:
|
||||||
|
tp = float(take_profit)
|
||||||
|
sl = float(new_stop_loss)
|
||||||
|
entry_add = float(add_price)
|
||||||
|
e1 = float(entry_existing)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, "止损/止盈格式错误"
|
||||||
|
if sl <= 0 or tp <= 0 or entry_add <= 0:
|
||||||
|
return None, "止损与首仓止盈须大于0"
|
||||||
|
if direction == "long":
|
||||||
|
if sl >= entry_add:
|
||||||
|
return None, "做多:新止损须低于加仓价"
|
||||||
|
if tp <= e1:
|
||||||
|
return None, "做多:首仓止盈须高于当前持仓均价参考"
|
||||||
|
else:
|
||||||
|
if sl <= entry_add:
|
||||||
|
return None, "做空:新止损须高于加仓价"
|
||||||
|
if tp >= e1:
|
||||||
|
return None, "做空:首仓止盈须低于当前持仓均价参考"
|
||||||
|
|
||||||
|
q2_raw, err = solve_add_amount_for_total_risk(
|
||||||
|
direction,
|
||||||
|
qty_existing,
|
||||||
|
entry_existing,
|
||||||
|
entry_add,
|
||||||
|
sl,
|
||||||
|
risk_budget,
|
||||||
|
contract_size,
|
||||||
|
)
|
||||||
|
if err:
|
||||||
|
return None, err
|
||||||
|
q2 = amount_precise(float(q2_raw))
|
||||||
|
if q2 is None or q2 <= 0:
|
||||||
|
return None, "加仓张数低于交易所最小精度"
|
||||||
|
new_qty = float(qty_existing) + float(q2)
|
||||||
|
new_avg = (float(qty_existing) * float(entry_existing) + float(q2) * entry_add) / new_qty
|
||||||
|
cs = float(contract_size) if contract_size else 1.0
|
||||||
|
if direction == "long":
|
||||||
|
loss_at_sl = (new_avg - sl) * new_qty * cs
|
||||||
|
reward_at_tp = (tp - new_avg) * new_qty * cs
|
||||||
|
else:
|
||||||
|
loss_at_sl = (sl - new_avg) * new_qty * cs
|
||||||
|
reward_at_tp = (new_avg - tp) * new_qty * cs
|
||||||
|
return {
|
||||||
|
"add_amount_raw": q2,
|
||||||
|
"qty_after": new_qty,
|
||||||
|
"avg_entry_after": new_avg,
|
||||||
|
"add_price": entry_add,
|
||||||
|
"new_stop_loss": sl,
|
||||||
|
"loss_at_sl_usdt": loss_at_sl,
|
||||||
|
"reward_at_tp_usdt": reward_at_tp,
|
||||||
|
}, None
|
||||||
|
|
||||||
|
|
||||||
|
def calc_roll_calculator(
|
||||||
|
*,
|
||||||
|
direction: str,
|
||||||
|
capital_usdt: float,
|
||||||
|
risk_percent: float,
|
||||||
|
entry_price: float,
|
||||||
|
stop_loss: float,
|
||||||
|
take_profit: float,
|
||||||
|
add_legs: list[dict[str, float]] | None = None,
|
||||||
|
legs_done: int = 0,
|
||||||
|
exchange_id: str = "0",
|
||||||
|
base: str = "ETH",
|
||||||
|
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||||
|
"""
|
||||||
|
滚仓历史测算:首仓自动以损定仓;止盈锁定首仓价;最多 3 次滚仓加仓。
|
||||||
|
add_legs: [{add_price, new_stop_loss}, ...],按顺序链式计算。
|
||||||
|
legs_done: 已完成滚仓次数(仅标记,仍参与链式状态推进)。
|
||||||
|
"""
|
||||||
|
market, amount_precise, merr = _resolve_market(exchange_id, base)
|
||||||
|
if merr or not market or not amount_precise:
|
||||||
|
return None, merr or "无法解析合约"
|
||||||
|
contract_size = float(market.get("contract_size") or 1.0)
|
||||||
|
px_dec = int(market.get("price_decimals") or 4)
|
||||||
|
amt_dec = int(market.get("amount_decimals") or 4)
|
||||||
|
|
||||||
|
direction = (direction or "long").strip().lower()
|
||||||
|
if direction not in ("long", "short"):
|
||||||
|
return None, "方向须为 long 或 short"
|
||||||
|
try:
|
||||||
|
capital = float(capital_usdt)
|
||||||
|
rp = float(risk_percent)
|
||||||
|
entry = float(entry_price)
|
||||||
|
initial_sl = float(stop_loss)
|
||||||
|
tp = float(take_profit)
|
||||||
|
done = max(0, int(legs_done))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, "参数格式错误"
|
||||||
|
if capital <= 0 or rp <= 0 or entry <= 0 or initial_sl <= 0 or tp <= 0:
|
||||||
|
return None, "资金、风险与价格须大于 0"
|
||||||
|
if done > max_roll_legs(direction):
|
||||||
|
return None, f"已完成滚仓次数不能超过 {max_roll_legs(direction)} 次"
|
||||||
|
|
||||||
|
legs_in: list[dict[str, float]] = []
|
||||||
|
for raw in add_legs or []:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ap = float(raw.get("add_price"))
|
||||||
|
nsl = float(raw.get("new_stop_loss"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, "加仓价与新止损须为有效数字"
|
||||||
|
if ap <= 0 or nsl <= 0:
|
||||||
|
return None, "加仓价与新止损须大于 0"
|
||||||
|
legs_in.append({"add_price": ap, "new_stop_loss": nsl})
|
||||||
|
|
||||||
|
if done + len(legs_in) > max_roll_legs(direction):
|
||||||
|
return None, f"已完成 {done} 次 + 待测算 {len(legs_in)} 次,合计不能超过 {max_roll_legs(direction)} 次滚仓"
|
||||||
|
|
||||||
|
if direction == "long":
|
||||||
|
if tp <= entry:
|
||||||
|
return None, "做多:止盈价须高于首仓入场价"
|
||||||
|
else:
|
||||||
|
if tp >= entry:
|
||||||
|
return None, "做空:止盈价须低于首仓入场价"
|
||||||
|
|
||||||
|
risk_budget = capital * (rp / 100.0)
|
||||||
|
qty, err = calc_initial_roll_qty(direction, entry, initial_sl, risk_budget, contract_size)
|
||||||
|
if err:
|
||||||
|
return None, err
|
||||||
|
if qty is None or qty <= 0:
|
||||||
|
return None, "无法计算首仓张数"
|
||||||
|
qty_p = amount_precise(float(qty))
|
||||||
|
if qty_p is None or qty_p <= 0:
|
||||||
|
return None, "首仓张数低于交易所最小精度"
|
||||||
|
|
||||||
|
qty_f = float(qty_p)
|
||||||
|
avg = entry
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
cs = contract_size
|
||||||
|
|
||||||
|
if direction == "long":
|
||||||
|
first_loss = (avg - initial_sl) * qty_f * cs
|
||||||
|
first_profit = (tp - avg) * qty_f * cs
|
||||||
|
else:
|
||||||
|
first_loss = (initial_sl - avg) * qty_f * cs
|
||||||
|
first_profit = (avg - tp) * qty_f * cs
|
||||||
|
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"label": "首仓",
|
||||||
|
"leg_index": 0,
|
||||||
|
"already_done": False,
|
||||||
|
"entry_or_add_price": _round(entry, px_dec),
|
||||||
|
"stop_loss": _round(initial_sl, px_dec),
|
||||||
|
"add_contracts": _round(qty_f, amt_dec),
|
||||||
|
"total_contracts": _round(qty_f, amt_dec),
|
||||||
|
"avg_entry": _round(avg, px_dec),
|
||||||
|
"take_profit": _round(tp, px_dec),
|
||||||
|
"loss_at_sl_u": _round(first_loss),
|
||||||
|
"profit_at_tp_u": _round(first_profit),
|
||||||
|
"rr": _money_rr(first_profit, first_loss),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
current_qty = qty_f
|
||||||
|
current_avg = avg
|
||||||
|
|
||||||
|
for i, leg in enumerate(legs_in):
|
||||||
|
leg_no = i + 1
|
||||||
|
preview, err = _roll_leg_preview(
|
||||||
|
direction=direction,
|
||||||
|
qty_existing=current_qty,
|
||||||
|
entry_existing=current_avg,
|
||||||
|
take_profit=tp,
|
||||||
|
add_price=leg["add_price"],
|
||||||
|
new_stop_loss=leg["new_stop_loss"],
|
||||||
|
risk_budget=risk_budget,
|
||||||
|
contract_size=cs,
|
||||||
|
amount_precise=amount_precise,
|
||||||
|
)
|
||||||
|
if err:
|
||||||
|
return None, f"滚仓第 {leg_no} 次:{err}"
|
||||||
|
if not preview:
|
||||||
|
return None, f"滚仓第 {leg_no} 次计算失败"
|
||||||
|
|
||||||
|
current_qty = float(preview["qty_after"])
|
||||||
|
current_avg = float(preview["avg_entry_after"])
|
||||||
|
loss = preview.get("loss_at_sl_usdt")
|
||||||
|
reward = preview.get("reward_at_tp_usdt")
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"label": f"滚仓{leg_no}",
|
||||||
|
"leg_index": leg_no,
|
||||||
|
"already_done": leg_no <= done,
|
||||||
|
"entry_or_add_price": _round(preview.get("add_price"), px_dec),
|
||||||
|
"stop_loss": _round(preview.get("new_stop_loss"), px_dec),
|
||||||
|
"add_contracts": _round(preview.get("add_amount_raw"), amt_dec),
|
||||||
|
"total_contracts": _round(current_qty, amt_dec),
|
||||||
|
"avg_entry": _round(current_avg, px_dec),
|
||||||
|
"take_profit": _round(tp, px_dec),
|
||||||
|
"loss_at_sl_u": _round(loss),
|
||||||
|
"profit_at_tp_u": _round(reward),
|
||||||
|
"rr": _money_rr(reward, loss),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
last = rows[-1]
|
||||||
|
return {
|
||||||
|
"direction": direction,
|
||||||
|
"capital_usdt": _round(capital),
|
||||||
|
"risk_percent": _round(rp, 2),
|
||||||
|
"risk_budget_u": _round(risk_budget),
|
||||||
|
"entry_price": _round(entry, px_dec),
|
||||||
|
"stop_loss": _round(initial_sl, px_dec),
|
||||||
|
"take_profit": _round(tp, px_dec),
|
||||||
|
"legs_done": done,
|
||||||
|
"roll_legs_planned": len(legs_in),
|
||||||
|
"first_contracts": _round(qty_f, amt_dec),
|
||||||
|
"final_contracts": last.get("total_contracts"),
|
||||||
|
"final_avg_entry": last.get("avg_entry"),
|
||||||
|
"final_loss_at_sl_u": last.get("loss_at_sl_u"),
|
||||||
|
"final_profit_at_tp_u": last.get("profit_at_tp_u"),
|
||||||
|
"final_rr": last.get("rr"),
|
||||||
|
"market": market,
|
||||||
|
"rows": rows,
|
||||||
|
}, None
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""计算器:从已配置交易实例读取 USDT 永续合约精度与张数规则。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Callable, Optional, Tuple
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
try:
|
||||||
|
from settings_store import enabled_exchanges, load_settings
|
||||||
|
except ImportError:
|
||||||
|
from manual_trading_hub.settings_store import enabled_exchanges, load_settings
|
||||||
|
|
||||||
|
MARKET_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||||
|
MARKET_LOCK = threading.Lock()
|
||||||
|
MARKET_TTL_SEC = 300.0
|
||||||
|
HUB_FLASK_TIMEOUT = float(__import__("os").getenv("HUB_FLASK_TIMEOUT", "20"))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_base_symbol(text: str) -> str:
|
||||||
|
s = str(text or "").upper().strip()
|
||||||
|
for suf in ("USDT:USDT", "/USDT:USDT", "/USDT", "USDT", "-USDT-SWAP"):
|
||||||
|
if s.endswith(suf) and len(s) > len(suf):
|
||||||
|
s = s[: -len(suf)].strip("-/")
|
||||||
|
break
|
||||||
|
if "/" in s:
|
||||||
|
s = s.split("/", 1)[0].strip()
|
||||||
|
if ":" in s:
|
||||||
|
s = s.split(":", 1)[0].strip()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_usdt_perp_symbol(exchange: Any, base: str) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
base_u = normalize_base_symbol(base)
|
||||||
|
if not base_u:
|
||||||
|
return None, "请输入币种,如 ETH"
|
||||||
|
candidates = [f"{base_u}/USDT:USDT", f"{base_u}/USDT"]
|
||||||
|
markets = getattr(exchange, "markets", None) or {}
|
||||||
|
for sym in candidates:
|
||||||
|
m = markets.get(sym)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
if m.get("active") is False:
|
||||||
|
continue
|
||||||
|
if m.get("swap") or m.get("linear") or m.get("contract"):
|
||||||
|
return sym, None
|
||||||
|
for sym, m in markets.items():
|
||||||
|
if m.get("active") is False:
|
||||||
|
continue
|
||||||
|
if not (m.get("swap") or m.get("linear")):
|
||||||
|
continue
|
||||||
|
if (m.get("quote") or "").upper() != "USDT":
|
||||||
|
continue
|
||||||
|
if (m.get("base") or "").upper() == base_u:
|
||||||
|
return sym, None
|
||||||
|
return None, f"未找到 {base_u}/USDT 永续合约"
|
||||||
|
|
||||||
|
|
||||||
|
def _decimals_from_precision_value(value: Any) -> Optional[int]:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
p = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if p >= 1 and abs(p - round(p)) < 1e-9 and p <= 12:
|
||||||
|
return int(round(p))
|
||||||
|
if 0 < p < 1:
|
||||||
|
s = f"{p:.12f}".rstrip("0")
|
||||||
|
if "." in s:
|
||||||
|
return min(12, len(s.split(".", 1)[1]))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _decimals_from_ccxt_str(text: str) -> int:
|
||||||
|
s = str(text or "").strip()
|
||||||
|
if not s or "." not in s:
|
||||||
|
return 0
|
||||||
|
frac = s.split(".", 1)[1]
|
||||||
|
if not frac:
|
||||||
|
return 0
|
||||||
|
return min(12, len(frac.rstrip("0") or frac))
|
||||||
|
|
||||||
|
|
||||||
|
def amount_decimals_from_exchange(exchange: Any, exchange_symbol: str) -> int:
|
||||||
|
try:
|
||||||
|
return _decimals_from_ccxt_str(exchange.amount_to_precision(exchange_symbol, 1.23456789))
|
||||||
|
except Exception:
|
||||||
|
market = exchange.market(exchange_symbol)
|
||||||
|
prec = (market.get("precision") or {}).get("amount")
|
||||||
|
d = _decimals_from_precision_value(prec)
|
||||||
|
return d if d is not None else 4
|
||||||
|
|
||||||
|
|
||||||
|
def price_decimals_from_exchange(
|
||||||
|
exchange: Any, exchange_symbol: str, price_tick: Optional[float]
|
||||||
|
) -> int:
|
||||||
|
from lib.hub.hub_ohlcv_lib import normalize_price_tick
|
||||||
|
|
||||||
|
tick = normalize_price_tick(price_tick)
|
||||||
|
if tick and tick > 0:
|
||||||
|
if tick >= 1:
|
||||||
|
return 0
|
||||||
|
s = f"{tick:.12f}".rstrip("0")
|
||||||
|
if "." in s:
|
||||||
|
return min(12, len(s.split(".", 1)[1]))
|
||||||
|
try:
|
||||||
|
return _decimals_from_ccxt_str(exchange.price_to_precision(exchange_symbol, 12345.678901234))
|
||||||
|
except Exception:
|
||||||
|
market = exchange.market(exchange_symbol)
|
||||||
|
prec = (market.get("precision") or {}).get("price")
|
||||||
|
d = _decimals_from_precision_value(prec)
|
||||||
|
return d if d is not None else 4
|
||||||
|
|
||||||
|
|
||||||
|
def make_amount_precise_fn_from_market(market: dict[str, Any]) -> Callable[[float], Optional[float]]:
|
||||||
|
dec = max(0, int(market.get("amount_decimals") or 4))
|
||||||
|
min_amt = market.get("min_amount")
|
||||||
|
|
||||||
|
def _fn(amount: float) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
v = float(amount)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if v <= 0:
|
||||||
|
return None
|
||||||
|
factor = 10**dec
|
||||||
|
v = int(v * factor + 1e-12) / factor
|
||||||
|
if min_amt is not None:
|
||||||
|
try:
|
||||||
|
if v < float(min_amt):
|
||||||
|
return None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if v <= 0:
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
|
return _fn
|
||||||
|
|
||||||
|
|
||||||
|
def find_exchange(exchange_id: str) -> dict | None:
|
||||||
|
needle = str(exchange_id or "").strip()
|
||||||
|
if not needle:
|
||||||
|
return None
|
||||||
|
for ex in load_settings().get("exchanges") or []:
|
||||||
|
if str(ex.get("id") or "").strip() == needle:
|
||||||
|
return ex
|
||||||
|
if str(ex.get("key") or "").strip().lower() == needle.lower():
|
||||||
|
return ex
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def list_calculator_exchanges() -> list[dict[str, Any]]:
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for ex in enabled_exchanges():
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"id": str(ex.get("id") or ""),
|
||||||
|
"key": str(ex.get("key") or ""),
|
||||||
|
"name": str(ex.get("name") or ex.get("key") or ""),
|
||||||
|
"enabled": bool(ex.get("enabled")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _hub_headers() -> dict[str, str]:
|
||||||
|
import os
|
||||||
|
|
||||||
|
token = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") or "").strip()
|
||||||
|
if token:
|
||||||
|
return {"X-Hub-Token": token}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_instance_market_sync(ex: dict, *, base: str) -> dict[str, Any]:
|
||||||
|
base_url = (ex.get("flask_url") or "").rstrip("/")
|
||||||
|
if not base_url:
|
||||||
|
return {"ok": False, "msg": "未配置 flask_url"}
|
||||||
|
params = urlencode({"base": normalize_base_symbol(base) or base})
|
||||||
|
url = f"{base_url}/api/hub/market?{params}"
|
||||||
|
req = urllib.request.Request(url, headers=_hub_headers(), method="GET")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=HUB_FLASK_TIMEOUT) as resp:
|
||||||
|
status = int(getattr(resp, "status", 200) or 200)
|
||||||
|
raw = resp.read().decode("utf-8", errors="replace")
|
||||||
|
data = json.loads(raw) if raw else {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {"ok": False, "msg": "无效 JSON"}
|
||||||
|
if status >= 400:
|
||||||
|
data.setdefault("ok", False)
|
||||||
|
return data
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
try:
|
||||||
|
raw = exc.read().decode("utf-8", errors="replace")
|
||||||
|
body = json.loads(raw) if raw else {}
|
||||||
|
except Exception:
|
||||||
|
body = {"ok": False, "msg": raw if "raw" in locals() else str(exc)}
|
||||||
|
if isinstance(body, dict):
|
||||||
|
body.setdefault("ok", False)
|
||||||
|
return body
|
||||||
|
return {"ok": False, "msg": f"HTTP {exc.code}"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "msg": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_market_from_settings(ex: dict, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
out = dict(payload)
|
||||||
|
out["exchange_id"] = str(ex.get("id") or "")
|
||||||
|
out["exchange_key"] = str(ex.get("key") or "")
|
||||||
|
out["exchange_name"] = str(ex.get("name") or ex.get("key") or "")
|
||||||
|
out["exchange_label"] = out["exchange_name"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get_calculator_market(
|
||||||
|
exchange_id: str,
|
||||||
|
base: str,
|
||||||
|
*,
|
||||||
|
ex: dict | None = None,
|
||||||
|
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||||
|
"""从系统设置中的交易实例拉取合约精度(与实盘一致)。"""
|
||||||
|
row = ex or find_exchange(exchange_id)
|
||||||
|
if not row:
|
||||||
|
return None, "未找到该交易所配置"
|
||||||
|
if not row.get("enabled"):
|
||||||
|
return None, f"{row.get('name') or exchange_id} 未启用"
|
||||||
|
|
||||||
|
base_u = normalize_base_symbol(base)
|
||||||
|
if not base_u:
|
||||||
|
return None, "请输入币种,如 ETH"
|
||||||
|
|
||||||
|
cache_key = f"{row.get('id')}:{base_u}"
|
||||||
|
now = time.time()
|
||||||
|
with MARKET_LOCK:
|
||||||
|
cached = MARKET_CACHE.get(cache_key)
|
||||||
|
if cached and now - cached[0] < MARKET_TTL_SEC:
|
||||||
|
return dict(cached[1]), None
|
||||||
|
|
||||||
|
remote = fetch_instance_market_sync(row, base=base_u)
|
||||||
|
if not remote.get("ok"):
|
||||||
|
return None, str(remote.get("msg") or "实例返回失败")
|
||||||
|
|
||||||
|
data = _enrich_market_from_settings(row, remote)
|
||||||
|
with MARKET_LOCK:
|
||||||
|
MARKET_CACHE[cache_key] = (now, data)
|
||||||
|
return data, None
|
||||||
|
|
||||||
|
|
||||||
|
def clear_market_cache() -> None:
|
||||||
|
with MARKET_LOCK:
|
||||||
|
MARKET_CACHE.clear()
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
"""中控开仓计划:进行中 / 历史归档 / 胜率统计。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
PLAN_TYPES = {
|
||||||
|
"trend": "趋势单",
|
||||||
|
"swing": "波段单",
|
||||||
|
"intraday": "日内短线",
|
||||||
|
}
|
||||||
|
TREND_TIMEFRAMES = ("5m", "15m", "30m", "1h", "4h", "1d")
|
||||||
|
ENTRY_TIMEFRAMES = ("1m", "5m", "15m", "30m", "1h")
|
||||||
|
DIRECTIONS = {"long": "多", "short": "空"}
|
||||||
|
ENTRY_SCHEMES = {
|
||||||
|
"breakout": "突破方案",
|
||||||
|
"false_breakout": "假突破突破方案",
|
||||||
|
"box_inflection": "箱体拐点方案",
|
||||||
|
}
|
||||||
|
RESULTS = {"win": "盈", "loss": "亏"}
|
||||||
|
STAT_DIMENSIONS = ("symbol", "trend_tf", "entry_scheme")
|
||||||
|
|
||||||
|
DISPLAY_TZ = ZoneInfo(
|
||||||
|
(os.getenv("HUB_ENTRY_PLAN_TZ") or os.getenv("HUB_VOLUME_RANK_TZ") or "Asia/Shanghai").strip()
|
||||||
|
or "Asia/Shanghai"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_db_path() -> Path:
|
||||||
|
raw = (os.getenv("HUB_ENTRY_PLAN_DB_PATH") or "").strip()
|
||||||
|
if raw:
|
||||||
|
return Path(raw)
|
||||||
|
from lib.paths import hub_data_dir
|
||||||
|
|
||||||
|
return hub_data_dir() / "hub_entry_plans.db"
|
||||||
|
|
||||||
|
|
||||||
|
def _now_ms() -> int:
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _connect(db_path: Path | None = None) -> sqlite3.Connection:
|
||||||
|
path = db_path or default_db_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(path), timeout=30, isolation_level=None)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(db_path: Path | None = None) -> None:
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS entry_plans (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_date TEXT NOT NULL,
|
||||||
|
exchange_key TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
plan_type TEXT NOT NULL,
|
||||||
|
trend_timeframe TEXT NOT NULL,
|
||||||
|
entry_timeframe TEXT NOT NULL,
|
||||||
|
direction TEXT NOT NULL,
|
||||||
|
target_level TEXT NOT NULL DEFAULT '',
|
||||||
|
current_range TEXT NOT NULL DEFAULT '',
|
||||||
|
entry_scheme TEXT NOT NULL,
|
||||||
|
result TEXT,
|
||||||
|
pnl_amount REAL,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
archived_at INTEGER
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entry_plans_status_date
|
||||||
|
ON entry_plans (status, plan_date DESC, id DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_plan_symbol(raw: str) -> str:
|
||||||
|
s = str(raw or "").strip().upper()
|
||||||
|
if not s:
|
||||||
|
raise ValueError("缺少币种")
|
||||||
|
if ":" in s:
|
||||||
|
s = s.split(":", 1)[0]
|
||||||
|
if "/" in s:
|
||||||
|
base, quote = s.split("/", 1)
|
||||||
|
base = base.strip()
|
||||||
|
quote = (quote or "USDT").strip() or "USDT"
|
||||||
|
if not base:
|
||||||
|
raise ValueError("币种无效")
|
||||||
|
return f"{base}/{quote}"
|
||||||
|
if s.endswith("USDT") and len(s) > 4:
|
||||||
|
return f"{s[:-4]}/{s[-4:]}"
|
||||||
|
return f"{s}/USDT"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_choice(value: str, allowed: dict[str, str] | tuple[str, ...], field: str) -> str:
|
||||||
|
key = str(value or "").strip().lower()
|
||||||
|
if isinstance(allowed, dict):
|
||||||
|
if key not in allowed:
|
||||||
|
raise ValueError(f"{field} 无效")
|
||||||
|
return key
|
||||||
|
if key not in allowed:
|
||||||
|
raise ValueError(f"{field} 无效")
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
d = dict(row)
|
||||||
|
d["plan_type_label"] = PLAN_TYPES.get(d.get("plan_type") or "", d.get("plan_type") or "")
|
||||||
|
d["direction_label"] = DIRECTIONS.get(d.get("direction") or "", d.get("direction") or "")
|
||||||
|
d["entry_scheme_label"] = ENTRY_SCHEMES.get(
|
||||||
|
d.get("entry_scheme") or "", d.get("entry_scheme") or ""
|
||||||
|
) or "待填写"
|
||||||
|
res = d.get("result")
|
||||||
|
d["result_label"] = RESULTS.get(res, "") if res else ""
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_pnl(raw: Any) -> float | None:
|
||||||
|
if raw is None or raw == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return round(float(raw), 4)
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
raise ValueError("盈亏金额无效") from e
|
||||||
|
|
||||||
|
|
||||||
|
def create_entry_plan(payload: dict[str, Any], *, db_path: Path | None = None) -> dict[str, Any]:
|
||||||
|
init_db(db_path)
|
||||||
|
plan_date = str(payload.get("plan_date") or "").strip()[:10]
|
||||||
|
if not plan_date:
|
||||||
|
raise ValueError("缺少 plan_date")
|
||||||
|
exchange_key = str(payload.get("exchange_key") or "").strip().lower()
|
||||||
|
if not exchange_key:
|
||||||
|
raise ValueError("缺少 exchange_key")
|
||||||
|
symbol = normalize_plan_symbol(payload.get("symbol") or "")
|
||||||
|
plan_type = _validate_choice(payload.get("plan_type"), PLAN_TYPES, "类型")
|
||||||
|
trend_tf = _validate_choice(payload.get("trend_timeframe"), TREND_TIMEFRAMES, "趋势周期")
|
||||||
|
entry_tf = _validate_choice(payload.get("entry_timeframe"), ENTRY_TIMEFRAMES, "入场周期")
|
||||||
|
direction = _validate_choice(payload.get("direction"), DIRECTIONS, "方向")
|
||||||
|
entry_scheme = ""
|
||||||
|
if payload.get("entry_scheme"):
|
||||||
|
entry_scheme = _validate_choice(payload.get("entry_scheme"), ENTRY_SCHEMES, "入场方案")
|
||||||
|
target_level = str(payload.get("target_level") or "").strip()
|
||||||
|
current_range = str(payload.get("current_range") or "").strip()
|
||||||
|
note = str(payload.get("note") or "").strip()
|
||||||
|
now = _now_ms()
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO entry_plans (
|
||||||
|
plan_date, exchange_key, symbol, plan_type, trend_timeframe, entry_timeframe,
|
||||||
|
direction, target_level, current_range, entry_scheme, note, status,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
plan_date,
|
||||||
|
exchange_key,
|
||||||
|
symbol,
|
||||||
|
plan_type,
|
||||||
|
trend_tf,
|
||||||
|
entry_tf,
|
||||||
|
direction,
|
||||||
|
target_level,
|
||||||
|
current_range,
|
||||||
|
entry_scheme,
|
||||||
|
note,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM entry_plans WHERE id=?",
|
||||||
|
(int(cur.lastrowid),),
|
||||||
|
).fetchone()
|
||||||
|
return _row_to_dict(row) or {}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def list_entry_plans(
|
||||||
|
*,
|
||||||
|
status: str = "active",
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
init_db(db_path)
|
||||||
|
st = (status or "active").strip().lower()
|
||||||
|
if st not in ("active", "archived"):
|
||||||
|
raise ValueError("status 无效")
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM entry_plans
|
||||||
|
WHERE status=?
|
||||||
|
ORDER BY plan_date DESC, id DESC
|
||||||
|
""",
|
||||||
|
(st,),
|
||||||
|
).fetchall()
|
||||||
|
return [_row_to_dict(r) for r in rows if r]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_entry_plan(plan_id: int, *, db_path: Path | None = None) -> dict[str, Any] | None:
|
||||||
|
init_db(db_path)
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
row = conn.execute("SELECT * FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone()
|
||||||
|
return _row_to_dict(row)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_entry_plan(
|
||||||
|
plan_id: int,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
init_db(db_path)
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
row = conn.execute("SELECT * FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
if row["status"] == "archived":
|
||||||
|
raise ValueError("已归档计划不可修改")
|
||||||
|
fields: dict[str, Any] = {}
|
||||||
|
if "plan_date" in payload:
|
||||||
|
qd = str(payload.get("plan_date") or "").strip()[:10]
|
||||||
|
if not qd:
|
||||||
|
raise ValueError("缺少 plan_date")
|
||||||
|
fields["plan_date"] = qd
|
||||||
|
if "exchange_key" in payload:
|
||||||
|
ex = str(payload.get("exchange_key") or "").strip().lower()
|
||||||
|
if not ex:
|
||||||
|
raise ValueError("缺少 exchange_key")
|
||||||
|
fields["exchange_key"] = ex
|
||||||
|
if "symbol" in payload:
|
||||||
|
fields["symbol"] = normalize_plan_symbol(payload.get("symbol") or "")
|
||||||
|
if "plan_type" in payload:
|
||||||
|
fields["plan_type"] = _validate_choice(payload.get("plan_type"), PLAN_TYPES, "类型")
|
||||||
|
if "trend_timeframe" in payload:
|
||||||
|
fields["trend_timeframe"] = _validate_choice(
|
||||||
|
payload.get("trend_timeframe"), TREND_TIMEFRAMES, "趋势周期"
|
||||||
|
)
|
||||||
|
if "entry_timeframe" in payload:
|
||||||
|
fields["entry_timeframe"] = _validate_choice(
|
||||||
|
payload.get("entry_timeframe"), ENTRY_TIMEFRAMES, "入场周期"
|
||||||
|
)
|
||||||
|
if "direction" in payload:
|
||||||
|
fields["direction"] = _validate_choice(payload.get("direction"), DIRECTIONS, "方向")
|
||||||
|
if "entry_scheme" in payload:
|
||||||
|
fields["entry_scheme"] = _validate_choice(
|
||||||
|
payload.get("entry_scheme"), ENTRY_SCHEMES, "入场方案"
|
||||||
|
)
|
||||||
|
if "target_level" in payload:
|
||||||
|
fields["target_level"] = str(payload.get("target_level") or "").strip()
|
||||||
|
if "current_range" in payload:
|
||||||
|
fields["current_range"] = str(payload.get("current_range") or "").strip()
|
||||||
|
if "note" in payload:
|
||||||
|
fields["note"] = str(payload.get("note") or "").strip()
|
||||||
|
if "pnl_amount" in payload:
|
||||||
|
fields["pnl_amount"] = _parse_optional_pnl(payload.get("pnl_amount"))
|
||||||
|
archive_now = False
|
||||||
|
if "result" in payload:
|
||||||
|
res_raw = payload.get("result")
|
||||||
|
if res_raw is None or str(res_raw).strip() == "":
|
||||||
|
fields["result"] = None
|
||||||
|
else:
|
||||||
|
fields["result"] = _validate_choice(res_raw, RESULTS, "结果")
|
||||||
|
archive_now = True
|
||||||
|
if not fields:
|
||||||
|
return _row_to_dict(row)
|
||||||
|
now = _now_ms()
|
||||||
|
fields["updated_at"] = now
|
||||||
|
if archive_now:
|
||||||
|
scheme_val = fields.get("entry_scheme", row["entry_scheme"])
|
||||||
|
if not str(scheme_val or "").strip():
|
||||||
|
raise ValueError("归档前请在进行中计划里选择入场方案")
|
||||||
|
fields["status"] = "archived"
|
||||||
|
fields["archived_at"] = now
|
||||||
|
sets = ", ".join(f"{k}=?" for k in fields)
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE entry_plans SET {sets} WHERE id=?",
|
||||||
|
(*fields.values(), int(plan_id)),
|
||||||
|
)
|
||||||
|
updated = conn.execute("SELECT * FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone()
|
||||||
|
return _row_to_dict(updated)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_entry_plan(plan_id: int, *, db_path: Path | None = None) -> bool:
|
||||||
|
init_db(db_path)
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
row = conn.execute("SELECT status FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
if row["status"] != "active":
|
||||||
|
raise ValueError("仅进行中的计划可删除")
|
||||||
|
cur = conn.execute("DELETE FROM entry_plans WHERE id=? AND status='active'", (int(plan_id),))
|
||||||
|
return int(cur.rowcount or 0) > 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _today_iso() -> str:
|
||||||
|
return datetime.now(DISPLAY_TZ).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_stats_date_bounds(
|
||||||
|
*,
|
||||||
|
period: str = "all",
|
||||||
|
date_from: str = "",
|
||||||
|
date_to: str = "",
|
||||||
|
) -> tuple[str | None, str | None, str]:
|
||||||
|
"""返回 (date_from, date_to, label);all 时 bounds 为 None。"""
|
||||||
|
p = (period or "all").strip().lower() or "all"
|
||||||
|
today = _today_iso()
|
||||||
|
if p == "all":
|
||||||
|
return None, None, "全部历史"
|
||||||
|
if p == "week":
|
||||||
|
day_dt = datetime.strptime(today, "%Y-%m-%d")
|
||||||
|
monday = (day_dt - timedelta(days=day_dt.weekday())).strftime("%Y-%m-%d")
|
||||||
|
return monday, today, f"本周 {monday}~{today}"
|
||||||
|
if p == "month":
|
||||||
|
day_dt = datetime.strptime(today, "%Y-%m-%d")
|
||||||
|
first = day_dt.replace(day=1).strftime("%Y-%m-%d")
|
||||||
|
return first, today, f"本月 {first}~{today}"
|
||||||
|
if p == "range":
|
||||||
|
df = (date_from or "").strip()[:10] or today
|
||||||
|
dt = (date_to or "").strip()[:10] or df
|
||||||
|
if df > dt:
|
||||||
|
df, dt = dt, df
|
||||||
|
label = f"区间 {df}~{dt}" if df != dt else f"区间 {df}"
|
||||||
|
return df, dt, label
|
||||||
|
return None, None, "全部历史"
|
||||||
|
|
||||||
|
|
||||||
|
def compute_entry_plan_stats(
|
||||||
|
*,
|
||||||
|
dimension: str = "symbol",
|
||||||
|
period: str = "all",
|
||||||
|
date_from: str = "",
|
||||||
|
date_to: str = "",
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
init_db(db_path)
|
||||||
|
dim = (dimension or "symbol").strip().lower()
|
||||||
|
if dim not in STAT_DIMENSIONS:
|
||||||
|
raise ValueError("dimension 无效")
|
||||||
|
df_bound, dt_bound, period_label = resolve_stats_date_bounds(
|
||||||
|
period=period, date_from=date_from, date_to=date_to
|
||||||
|
)
|
||||||
|
col_map = {
|
||||||
|
"symbol": "symbol",
|
||||||
|
"trend_tf": "trend_timeframe",
|
||||||
|
"entry_scheme": "entry_scheme",
|
||||||
|
}
|
||||||
|
col = col_map[dim]
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
where = "status='archived' AND result IN ('win','loss')"
|
||||||
|
params: list[Any] = []
|
||||||
|
if df_bound:
|
||||||
|
where += " AND plan_date >= ? AND plan_date <= ?"
|
||||||
|
params.extend([df_bound, dt_bound])
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {col} AS dim_key,
|
||||||
|
COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN result='win' THEN 1 ELSE 0 END) AS win_count,
|
||||||
|
SUM(CASE WHEN result='loss' THEN 1 ELSE 0 END) AS loss_count
|
||||||
|
FROM entry_plans
|
||||||
|
WHERE {where}
|
||||||
|
GROUP BY {col}
|
||||||
|
ORDER BY total DESC, dim_key ASC
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
total = int(r["total"] or 0)
|
||||||
|
wins = int(r["win_count"] or 0)
|
||||||
|
losses = int(r["loss_count"] or 0)
|
||||||
|
key = str(r["dim_key"] or "")
|
||||||
|
label = key
|
||||||
|
if dim == "entry_scheme":
|
||||||
|
label = ENTRY_SCHEMES.get(key, key)
|
||||||
|
elif dim == "trend_tf":
|
||||||
|
label = key
|
||||||
|
win_rate = round(wins / total * 100, 1) if total else None
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"label": label,
|
||||||
|
"total": total,
|
||||||
|
"win_count": wins,
|
||||||
|
"loss_count": losses,
|
||||||
|
"win_rate": win_rate,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"dimension": dim,
|
||||||
|
"period": period,
|
||||||
|
"period_label": period_label,
|
||||||
|
"date_from": df_bound,
|
||||||
|
"date_to": dt_bound,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def meta_payload(exchanges: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"plan_types": [{"value": k, "label": v} for k, v in PLAN_TYPES.items()],
|
||||||
|
"trend_timeframes": list(TREND_TIMEFRAMES),
|
||||||
|
"entry_timeframes": list(ENTRY_TIMEFRAMES),
|
||||||
|
"directions": [{"value": k, "label": v} for k, v in DIRECTIONS.items()],
|
||||||
|
"entry_schemes": [{"value": k, "label": v} for k, v in ENTRY_SCHEMES.items()],
|
||||||
|
"results": [{"value": k, "label": v} for k, v in RESULTS.items()],
|
||||||
|
"stat_dimensions": [
|
||||||
|
{"value": "symbol", "label": "币种"},
|
||||||
|
{"value": "trend_tf", "label": "趋势周期"},
|
||||||
|
{"value": "entry_scheme", "label": "入场方案"},
|
||||||
|
],
|
||||||
|
"exchanges": exchanges or [],
|
||||||
|
}
|
||||||
@@ -7,9 +7,11 @@ from datetime import datetime, timedelta
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from hub_trades_lib import current_trading_day
|
from lib.hub.hub_trades_lib import current_trading_day
|
||||||
|
|
||||||
HUB_DIR = Path(__file__).resolve().parent / "manual_trading_hub"
|
from lib.paths import manual_trading_hub_dir
|
||||||
|
|
||||||
|
HUB_DIR = manual_trading_hub_dir()
|
||||||
FUND_HISTORY_PATH = HUB_DIR / "hub_fund_history.json"
|
FUND_HISTORY_PATH = HUB_DIR / "hub_fund_history.json"
|
||||||
LEGACY_FUND_HISTORY_PATH = HUB_DIR / "hub_ai_fund_history.json"
|
LEGACY_FUND_HISTORY_PATH = HUB_DIR / "hub_ai_fund_history.json"
|
||||||
|
|
||||||
@@ -289,12 +291,14 @@ def build_fund_overview(
|
|||||||
live_known = 0
|
live_known = 0
|
||||||
|
|
||||||
for ex in exchanges or []:
|
for ex in exchanges or []:
|
||||||
|
if not _exchange_monitored(ex):
|
||||||
|
continue
|
||||||
key = str(ex.get("key") or "").strip()
|
key = str(ex.get("key") or "").strip()
|
||||||
monitored = _exchange_monitored(ex)
|
monitored = True
|
||||||
row = _live_row_for_exchange(ex, rows_by_key) if monitored else None
|
row = _live_row_for_exchange(ex, rows_by_key)
|
||||||
fu = tu = total = None
|
fu = tu = total = None
|
||||||
data_ok = False
|
data_ok = False
|
||||||
if monitored and row and row.get("account_ok"):
|
if row and row.get("account_ok"):
|
||||||
fu = _safe_float(row.get("funding_usdt"))
|
fu = _safe_float(row.get("funding_usdt"))
|
||||||
tu = _safe_float(row.get("trading_usdt"))
|
tu = _safe_float(row.get("trading_usdt"))
|
||||||
total = account_total_usdt(fu, tu)
|
total = account_total_usdt(fu, tu)
|
||||||
@@ -303,7 +307,7 @@ def build_fund_overview(
|
|||||||
live_total += total
|
live_total += total
|
||||||
live_known += 1
|
live_known += 1
|
||||||
|
|
||||||
series = _account_series(history, key) if monitored and key else []
|
series = _account_series(history, key) if key else []
|
||||||
dd = compute_drawdown([p["total_usdt"] for p in series]) if series else {
|
dd = compute_drawdown([p["total_usdt"] for p in series]) if series else {
|
||||||
"peak_usdt": None,
|
"peak_usdt": None,
|
||||||
"max_drawdown_u": None,
|
"max_drawdown_u": None,
|
||||||
@@ -331,7 +335,7 @@ def build_fund_overview(
|
|||||||
"day_delta_usdt": day_delta,
|
"day_delta_usdt": day_delta,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if monitored and key:
|
if key:
|
||||||
monitored_keys.append(key)
|
monitored_keys.append(key)
|
||||||
|
|
||||||
total_series = _series_from_history(history, monitored_keys)
|
total_series = _series_from_history(history, monitored_keys)
|
||||||
@@ -8,7 +8,7 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
from hub_ohlcv_lib import (
|
from lib.hub.hub_ohlcv_lib import (
|
||||||
HUB_KLINE_1M_MAX_BARS,
|
HUB_KLINE_1M_MAX_BARS,
|
||||||
HUB_KLINE_5M_1H_RETENTION_DAYS,
|
HUB_KLINE_5M_1H_RETENTION_DAYS,
|
||||||
TIMEFRAME_MS,
|
TIMEFRAME_MS,
|
||||||
@@ -44,9 +44,9 @@ def default_db_path() -> Path:
|
|||||||
raw = (os.getenv("HUB_KLINE_DB_PATH") or "").strip()
|
raw = (os.getenv("HUB_KLINE_DB_PATH") or "").strip()
|
||||||
if raw:
|
if raw:
|
||||||
return Path(raw)
|
return Path(raw)
|
||||||
hub_dir = Path(__file__).resolve().parent / "manual_trading_hub" / "data"
|
from lib.paths import hub_data_dir
|
||||||
hub_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
return hub_dir / "hub_kline.db"
|
return hub_data_dir() / "hub_kline.db"
|
||||||
|
|
||||||
|
|
||||||
def _connect(db_path: Path | None = None) -> sqlite3.Connection:
|
def _connect(db_path: Path | None = None) -> sqlite3.Connection:
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"""中控宏观关键数据日历:手动录入 FOMC / CPI / 非农档发布时间,±1h 风控前置窗口。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from lib.hub.hub_symbol_archive_lib import parse_wall_clock_ms
|
||||||
|
|
||||||
|
DISPLAY_TZ = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai"))
|
||||||
|
|
||||||
|
MACRO_EVENT_TYPES = ("fomc", "cpi", "employment")
|
||||||
|
|
||||||
|
MACRO_EVENT_LABELS: dict[str, str] = {
|
||||||
|
"fomc": "FOMC 联邦基金利率",
|
||||||
|
"cpi": "美国 CPI 通胀",
|
||||||
|
"employment": "就业与劳工数据",
|
||||||
|
}
|
||||||
|
|
||||||
|
WINDOW_BEFORE_MS = int(os.getenv("HUB_MACRO_WINDOW_BEFORE_SEC", str(3600))) * 1000
|
||||||
|
WINDOW_AFTER_MS = int(os.getenv("HUB_MACRO_WINDOW_AFTER_SEC", str(3600))) * 1000
|
||||||
|
IMMINENT_BEFORE_MS = int(os.getenv("HUB_MACRO_IMMINENT_BEFORE_SEC", str(1800))) * 1000
|
||||||
|
LIST_FUTURE_DAYS = int(os.getenv("HUB_MACRO_LIST_FUTURE_DAYS", "60"))
|
||||||
|
|
||||||
|
|
||||||
|
def default_db_path() -> Path:
|
||||||
|
raw = (os.getenv("HUB_MACRO_CALENDAR_DB_PATH") or "").strip()
|
||||||
|
if raw:
|
||||||
|
return Path(raw)
|
||||||
|
from lib.paths import hub_data_dir
|
||||||
|
|
||||||
|
return hub_data_dir() / "hub_macro_calendar.db"
|
||||||
|
|
||||||
|
|
||||||
|
def _connect(db_path: Path | None = None) -> sqlite3.Connection:
|
||||||
|
path = db_path or default_db_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(path), timeout=30, isolation_level=None)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(db_path: Path | None = None) -> None:
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS macro_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
event_at_ms INTEGER NOT NULL,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at_ms INTEGER NOT NULL,
|
||||||
|
updated_at_ms INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_macro_events_at ON macro_events(event_at_ms)"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_event_type(raw: str) -> str:
|
||||||
|
key = (raw or "").strip().lower()
|
||||||
|
if key not in MACRO_EVENT_TYPES:
|
||||||
|
raise ValueError(f"事件类型须为: {', '.join(MACRO_EVENT_LABELS.values())}")
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def parse_event_at_ms(raw: Any) -> int:
|
||||||
|
ms = parse_wall_clock_ms(raw, tz=DISPLAY_TZ)
|
||||||
|
if ms is None:
|
||||||
|
raise ValueError("发布时间格式错误,请使用 YYYY-MM-DD HH:MM 或 YYYY-MM-DDTHH:MM")
|
||||||
|
return int(ms)
|
||||||
|
|
||||||
|
|
||||||
|
def format_event_at(ms: int) -> str:
|
||||||
|
dt = datetime.fromtimestamp(ms / 1000, tz=DISPLAY_TZ)
|
||||||
|
return dt.strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
|
||||||
|
ms = int(row["event_at_ms"])
|
||||||
|
et = str(row["event_type"])
|
||||||
|
return {
|
||||||
|
"id": int(row["id"]),
|
||||||
|
"event_type": et,
|
||||||
|
"event_type_label": MACRO_EVENT_LABELS.get(et, et),
|
||||||
|
"event_at_ms": ms,
|
||||||
|
"event_at": format_event_at(ms),
|
||||||
|
"note": str(row["note"] or ""),
|
||||||
|
"created_at_ms": int(row["created_at_ms"]),
|
||||||
|
"updated_at_ms": int(row["updated_at_ms"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _window_bounds(event_at_ms: int) -> tuple[int, int]:
|
||||||
|
start = int(event_at_ms) - WINDOW_BEFORE_MS
|
||||||
|
end = int(event_at_ms) + WINDOW_AFTER_MS
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_alert(row: dict[str, Any], now_ms: int | None = None) -> dict[str, Any] | None:
|
||||||
|
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||||
|
event_at_ms = int(row["event_at_ms"])
|
||||||
|
window_start, window_end = _window_bounds(event_at_ms)
|
||||||
|
if now < window_start or now > window_end:
|
||||||
|
return None
|
||||||
|
imminent = now >= (event_at_ms - IMMINENT_BEFORE_MS) and now <= window_end
|
||||||
|
mins_to_event = max(0, int((event_at_ms - now) / 60000))
|
||||||
|
mins_from_event = max(0, int((now - event_at_ms) / 60000))
|
||||||
|
return {
|
||||||
|
**row,
|
||||||
|
"window_start_ms": window_start,
|
||||||
|
"window_end_ms": window_end,
|
||||||
|
"window_start": format_event_at(window_start),
|
||||||
|
"window_end": format_event_at(window_end),
|
||||||
|
"phase": "imminent" if imminent else "window",
|
||||||
|
"phase_label": "即将发布" if imminent and now < event_at_ms else "高波动窗口",
|
||||||
|
"minutes_to_event": mins_to_event if now < event_at_ms else 0,
|
||||||
|
"minutes_from_event": mins_from_event if now >= event_at_ms else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_events(
|
||||||
|
*,
|
||||||
|
now_ms: int | None = None,
|
||||||
|
include_expired_hours: int = 24,
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
init_db(db_path)
|
||||||
|
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||||
|
horizon = now + LIST_FUTURE_DAYS * 86400 * 1000
|
||||||
|
expired_cutoff = now - max(0, int(include_expired_hours)) * 3600 * 1000 - WINDOW_AFTER_MS
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM macro_events
|
||||||
|
WHERE event_at_ms >= ? AND event_at_ms <= ?
|
||||||
|
ORDER BY event_at_ms ASC, id ASC
|
||||||
|
""",
|
||||||
|
(expired_cutoff, horizon),
|
||||||
|
).fetchall()
|
||||||
|
return [_row_to_dict(r) for r in rows]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_event(event_id: int, db_path: Path | None = None) -> dict[str, Any] | None:
|
||||||
|
init_db(db_path)
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
row = conn.execute("SELECT * FROM macro_events WHERE id=?", (int(event_id),)).fetchone()
|
||||||
|
return _row_to_dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_no_duplicate(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
event_type: str,
|
||||||
|
event_at_ms: int,
|
||||||
|
*,
|
||||||
|
exclude_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
if exclude_id is None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM macro_events WHERE event_type=? AND event_at_ms=? LIMIT 1",
|
||||||
|
(event_type, int(event_at_ms)),
|
||||||
|
).fetchone()
|
||||||
|
else:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM macro_events
|
||||||
|
WHERE event_type=? AND event_at_ms=? AND id<>?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(event_type, int(event_at_ms), int(exclude_id)),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
raise ValueError("同类型、同发布时间的记录已存在")
|
||||||
|
|
||||||
|
|
||||||
|
def create_event(
|
||||||
|
event_type: str,
|
||||||
|
event_at: Any,
|
||||||
|
*,
|
||||||
|
note: str = "",
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
init_db(db_path)
|
||||||
|
et = normalize_event_type(event_type)
|
||||||
|
event_at_ms = parse_event_at_ms(event_at)
|
||||||
|
note_s = str(note or "").strip()[:500]
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
_assert_no_duplicate(conn, et, event_at_ms)
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO macro_events (event_type, event_at_ms, note, created_at_ms, updated_at_ms)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(et, event_at_ms, note_s, now_ms, now_ms),
|
||||||
|
)
|
||||||
|
eid = int(cur.lastrowid)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
row = get_event(eid, db_path=db_path)
|
||||||
|
assert row is not None
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def update_event(
|
||||||
|
event_id: int,
|
||||||
|
*,
|
||||||
|
event_type: str | None = None,
|
||||||
|
event_at: Any | None = None,
|
||||||
|
note: str | None = None,
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
init_db(db_path)
|
||||||
|
existing = get_event(event_id, db_path=db_path)
|
||||||
|
if not existing:
|
||||||
|
return None
|
||||||
|
et = normalize_event_type(event_type if event_type is not None else existing["event_type"])
|
||||||
|
event_at_ms = (
|
||||||
|
parse_event_at_ms(event_at) if event_at is not None else int(existing["event_at_ms"])
|
||||||
|
)
|
||||||
|
note_s = existing["note"] if note is None else str(note or "").strip()[:500]
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
_assert_no_duplicate(conn, et, event_at_ms, exclude_id=int(event_id))
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE macro_events
|
||||||
|
SET event_type=?, event_at_ms=?, note=?, updated_at_ms=?
|
||||||
|
WHERE id=?
|
||||||
|
""",
|
||||||
|
(et, event_at_ms, note_s, now_ms, int(event_id)),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return get_event(event_id, db_path=db_path)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_event(event_id: int, db_path: Path | None = None) -> bool:
|
||||||
|
init_db(db_path)
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
cur = conn.execute("DELETE FROM macro_events WHERE id=?", (int(event_id),))
|
||||||
|
return cur.rowcount > 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def list_active_alerts(
|
||||||
|
now_ms: int | None = None,
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||||
|
lookback = now - WINDOW_BEFORE_MS - IMMINENT_BEFORE_MS
|
||||||
|
lookahead = now + WINDOW_AFTER_MS
|
||||||
|
init_db(db_path)
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM macro_events
|
||||||
|
WHERE event_at_ms >= ? AND event_at_ms <= ?
|
||||||
|
ORDER BY event_at_ms ASC, id ASC
|
||||||
|
""",
|
||||||
|
(lookback, lookahead),
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
alerts: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
item = enrich_alert(_row_to_dict(row), now_ms=now)
|
||||||
|
if item:
|
||||||
|
alerts.append(item)
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
|
def build_banner_message(alert: dict[str, Any], *, has_positions: bool) -> str:
|
||||||
|
label = alert.get("event_type_label") or alert.get("event_type") or "宏观数据"
|
||||||
|
phase = alert.get("phase") or "window"
|
||||||
|
if has_positions:
|
||||||
|
if phase == "imminent" and int(alert.get("minutes_to_event") or 0) > 0:
|
||||||
|
return (
|
||||||
|
f"「{label}」即将发布(约 {alert['minutes_to_event']} 分钟),"
|
||||||
|
"注意仓位风险:勿加仓,检查止损/减仓"
|
||||||
|
)
|
||||||
|
return f"「{label}」高波动窗口(±1h),注意仓位风险:勿加仓,检查止损/减仓"
|
||||||
|
if phase == "imminent" and int(alert.get("minutes_to_event") or 0) > 0:
|
||||||
|
return (
|
||||||
|
f"「{label}」即将发布(约 {alert['minutes_to_event']} 分钟),"
|
||||||
|
"建议等待,避免新开仓"
|
||||||
|
)
|
||||||
|
return f"「{label}」高波动窗口(±1h),建议等待,避免新开仓"
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""实例 USDT 永续合约信息(与实盘 ccxt 精度一致)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Optional, Tuple
|
||||||
|
|
||||||
|
from lib.hub.hub_calculator_market_lib import (
|
||||||
|
amount_decimals_from_exchange,
|
||||||
|
normalize_base_symbol,
|
||||||
|
price_decimals_from_exchange,
|
||||||
|
resolve_usdt_perp_symbol,
|
||||||
|
)
|
||||||
|
from lib.hub.hub_ohlcv_lib import normalize_price_tick, price_tick_from_market
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_usdt_swap_market_info(
|
||||||
|
*,
|
||||||
|
base_or_symbol: str,
|
||||||
|
normalize_symbol_input: Callable[[str], str],
|
||||||
|
normalize_exchange_symbol: Callable[[str], str],
|
||||||
|
ensure_markets_loaded: Callable[[], None],
|
||||||
|
exchange: Any,
|
||||||
|
exchange_id: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""供各实例 /api/hub/market 调用。"""
|
||||||
|
raw = str(base_or_symbol or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return {"ok": False, "msg": "请输入币种,如 ETH"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
ensure_markets_loaded()
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "msg": f"加载市场失败: {exc}"}
|
||||||
|
|
||||||
|
base_u = normalize_base_symbol(raw)
|
||||||
|
hub_sym = normalize_symbol_input(raw if base_u else raw)
|
||||||
|
try:
|
||||||
|
ex_sym = normalize_exchange_symbol(hub_sym)
|
||||||
|
except Exception:
|
||||||
|
ex_sym = hub_sym
|
||||||
|
|
||||||
|
sym, err = resolve_usdt_perp_symbol(exchange, base_u or hub_sym)
|
||||||
|
if err and ex_sym:
|
||||||
|
markets = getattr(exchange, "markets", None) or {}
|
||||||
|
if ex_sym in markets:
|
||||||
|
sym = ex_sym
|
||||||
|
err = None
|
||||||
|
if err or not sym:
|
||||||
|
return {"ok": False, "msg": err or f"未找到 {base_u or raw}/USDT 永续合约"}
|
||||||
|
|
||||||
|
market = exchange.market(sym)
|
||||||
|
try:
|
||||||
|
contract_size = float(market.get("contractSize") or 1.0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
contract_size = 1.0
|
||||||
|
if contract_size <= 0:
|
||||||
|
contract_size = 1.0
|
||||||
|
|
||||||
|
price_tick = normalize_price_tick(price_tick_from_market(exchange, sym))
|
||||||
|
amt_dec = amount_decimals_from_exchange(exchange, sym)
|
||||||
|
px_dec = price_decimals_from_exchange(exchange, sym, price_tick)
|
||||||
|
min_amount = None
|
||||||
|
try:
|
||||||
|
min_amount = float((market.get("limits") or {}).get("amount", {}).get("min"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
min_amount = None
|
||||||
|
|
||||||
|
base_out = (market.get("base") or base_u or "").upper() or base_u
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"exchange": (exchange_id or "").strip().lower(),
|
||||||
|
"base": base_out,
|
||||||
|
"exchange_symbol": sym,
|
||||||
|
"display_symbol": f"{base_out}/USDT" if base_out else sym,
|
||||||
|
"contract_size": contract_size,
|
||||||
|
"price_tick": price_tick,
|
||||||
|
"price_decimals": px_dec,
|
||||||
|
"amount_decimals": amt_dec,
|
||||||
|
"min_amount": min_amount,
|
||||||
|
}
|
||||||
|
|
||||||
@@ -24,21 +24,24 @@ def _coerce_float(*values: Any) -> float | None:
|
|||||||
|
|
||||||
|
|
||||||
def position_contracts(p: dict[str, Any]) -> float:
|
def position_contracts(p: dict[str, Any]) -> float:
|
||||||
raw = p.get("contracts")
|
|
||||||
if raw is not None:
|
|
||||||
try:
|
|
||||||
return float(raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
info = p.get("info") or {}
|
info = p.get("info") or {}
|
||||||
if not isinstance(info, dict):
|
if not isinstance(info, dict):
|
||||||
info = {}
|
info = {}
|
||||||
for k in ("positionAmt", "positionamt", "pos", "size"):
|
# OKX 等:info.pos 为交易所张数,优先于 ccxt contracts(加仓后后者可能滞后)
|
||||||
|
for k in ("pos", "positionAmt", "positionamt", "size"):
|
||||||
if k in info:
|
if k in info:
|
||||||
try:
|
try:
|
||||||
v = float(info[k])
|
v = float(info[k])
|
||||||
if v != 0:
|
if v != 0:
|
||||||
return v
|
return abs(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
raw = p.get("contracts")
|
||||||
|
if raw is not None:
|
||||||
|
try:
|
||||||
|
v = float(raw)
|
||||||
|
if v != 0:
|
||||||
|
return abs(v)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
return 0.0
|
return 0.0
|
||||||
@@ -13,13 +13,13 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
CHART_DISPLAY_TZ = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai"))
|
CHART_DISPLAY_TZ = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai"))
|
||||||
|
|
||||||
from hub_ohlcv_lib import (
|
from lib.hub.hub_ohlcv_lib import (
|
||||||
TIMEFRAME_MS,
|
TIMEFRAME_MS,
|
||||||
aggregate_ohlcv_bars,
|
aggregate_ohlcv_bars,
|
||||||
normalize_chart_timeframe,
|
normalize_chart_timeframe,
|
||||||
normalize_perpetual_symbol,
|
normalize_perpetual_symbol,
|
||||||
)
|
)
|
||||||
from hub_trades_lib import (
|
from lib.hub.hub_trades_lib import (
|
||||||
display_entry_type_label,
|
display_entry_type_label,
|
||||||
effective_hold_minutes,
|
effective_hold_minutes,
|
||||||
format_hold_minutes,
|
format_hold_minutes,
|
||||||
@@ -49,9 +49,9 @@ def default_db_path() -> Path:
|
|||||||
raw = (os.getenv("HUB_ARCHIVE_DB_PATH") or "").strip()
|
raw = (os.getenv("HUB_ARCHIVE_DB_PATH") or "").strip()
|
||||||
if raw:
|
if raw:
|
||||||
return Path(raw)
|
return Path(raw)
|
||||||
hub_dir = Path(__file__).resolve().parent / "manual_trading_hub" / "data"
|
from lib.paths import hub_data_dir
|
||||||
hub_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
return hub_dir / "hub_symbol_archive.db"
|
return hub_data_dir() / "hub_symbol_archive.db"
|
||||||
|
|
||||||
|
|
||||||
def _connect(db_path: Path | None = None) -> sqlite3.Connection:
|
def _connect(db_path: Path | None = None) -> sqlite3.Connection:
|
||||||
@@ -118,6 +118,8 @@ def init_db(db_path: Path | None = None) -> None:
|
|||||||
closed_at_ms INTEGER,
|
closed_at_ms INTEGER,
|
||||||
monitor_type TEXT,
|
monitor_type TEXT,
|
||||||
entry_reason TEXT,
|
entry_reason TEXT,
|
||||||
|
exchange_turnover_usdt REAL,
|
||||||
|
exchange_commission_usdt REAL,
|
||||||
payload_json TEXT,
|
payload_json TEXT,
|
||||||
synced_at INTEGER NOT NULL,
|
synced_at INTEGER NOT NULL,
|
||||||
PRIMARY KEY (exchange_key, trade_id)
|
PRIMARY KEY (exchange_key, trade_id)
|
||||||
@@ -159,6 +161,14 @@ def init_db(db_path: Path | None = None) -> None:
|
|||||||
ON archive_review_quotes (quote_date DESC)
|
ON archive_review_quotes (quote_date DESC)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
for ddl in (
|
||||||
|
"ALTER TABLE archive_trade_cache ADD COLUMN exchange_turnover_usdt REAL",
|
||||||
|
"ALTER TABLE archive_trade_cache ADD COLUMN exchange_commission_usdt REAL",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
conn.execute(ddl)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -167,6 +177,15 @@ def _now_ms() -> int:
|
|||||||
return int(time.time() * 1000)
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_float(raw: Any) -> float | None:
|
||||||
|
if raw in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_wall_clock_ms(raw: Any, *, tz: ZoneInfo = CHART_DISPLAY_TZ) -> int | None:
|
def parse_wall_clock_ms(raw: Any, *, tz: ZoneInfo = CHART_DISPLAY_TZ) -> int | None:
|
||||||
"""将 YYYY-MM-DD[ HH:MM[:SS]] 按指定时区墙钟解析为 UTC 毫秒(默认 UTC+8)。"""
|
"""将 YYYY-MM-DD[ HH:MM[:SS]] 按指定时区墙钟解析为 UTC 毫秒(默认 UTC+8)。"""
|
||||||
if raw in (None, ""):
|
if raw in (None, ""):
|
||||||
@@ -331,8 +350,9 @@ def upsert_trades_cache(
|
|||||||
INSERT INTO archive_trade_cache (
|
INSERT INTO archive_trade_cache (
|
||||||
exchange_key, trade_id, symbol, direction, result, pnl_amount,
|
exchange_key, trade_id, symbol, direction, result, pnl_amount,
|
||||||
opened_at, closed_at, opened_at_ms, closed_at_ms,
|
opened_at, closed_at, opened_at_ms, closed_at_ms,
|
||||||
monitor_type, entry_reason, payload_json, synced_at
|
monitor_type, entry_reason, exchange_turnover_usdt, exchange_commission_usdt,
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
payload_json, synced_at
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
ON CONFLICT(exchange_key, trade_id) DO UPDATE SET
|
ON CONFLICT(exchange_key, trade_id) DO UPDATE SET
|
||||||
symbol=excluded.symbol,
|
symbol=excluded.symbol,
|
||||||
direction=excluded.direction,
|
direction=excluded.direction,
|
||||||
@@ -344,6 +364,8 @@ def upsert_trades_cache(
|
|||||||
closed_at_ms=excluded.closed_at_ms,
|
closed_at_ms=excluded.closed_at_ms,
|
||||||
monitor_type=excluded.monitor_type,
|
monitor_type=excluded.monitor_type,
|
||||||
entry_reason=excluded.entry_reason,
|
entry_reason=excluded.entry_reason,
|
||||||
|
exchange_turnover_usdt=excluded.exchange_turnover_usdt,
|
||||||
|
exchange_commission_usdt=excluded.exchange_commission_usdt,
|
||||||
payload_json=excluded.payload_json,
|
payload_json=excluded.payload_json,
|
||||||
synced_at=excluded.synced_at
|
synced_at=excluded.synced_at
|
||||||
""",
|
""",
|
||||||
@@ -360,6 +382,8 @@ def upsert_trades_cache(
|
|||||||
t.get("closed_at_ms") or _parse_dt_ms(t.get("closed_at")),
|
t.get("closed_at_ms") or _parse_dt_ms(t.get("closed_at")),
|
||||||
t.get("monitor_type"),
|
t.get("monitor_type"),
|
||||||
entry_label,
|
entry_label,
|
||||||
|
_optional_float(t.get("exchange_turnover_usdt")),
|
||||||
|
_optional_float(t.get("exchange_commission_usdt")),
|
||||||
json.dumps(payload, ensure_ascii=False, default=str),
|
json.dumps(payload, ensure_ascii=False, default=str),
|
||||||
now,
|
now,
|
||||||
),
|
),
|
||||||
@@ -437,6 +461,8 @@ def _trade_row_to_dict(row: sqlite3.Row, overlay: dict | None = None) -> dict[st
|
|||||||
"closed_at_ms",
|
"closed_at_ms",
|
||||||
"monitor_type",
|
"monitor_type",
|
||||||
"entry_reason",
|
"entry_reason",
|
||||||
|
"exchange_turnover_usdt",
|
||||||
|
"exchange_commission_usdt",
|
||||||
"synced_at",
|
"synced_at",
|
||||||
):
|
):
|
||||||
if key in d and d[key] not in (None, ""):
|
if key in d and d[key] not in (None, ""):
|
||||||
@@ -1249,46 +1275,118 @@ def resolve_period_bounds(
|
|||||||
return start_ms, end_ms, d, d, f"本日 {d}"
|
return start_ms, end_ms, d, d, f"本日 {d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _pnl_side(pnl: float) -> str:
|
||||||
|
if pnl > 0.0001:
|
||||||
|
return "win"
|
||||||
|
if pnl < -0.0001:
|
||||||
|
return "loss"
|
||||||
|
return "flat"
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_pnl_bucket() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"open_count": 0,
|
||||||
|
"sick_count": 0,
|
||||||
|
"pnl_total": 0.0,
|
||||||
|
"pnl_ex_sick": 0.0,
|
||||||
|
"turnover_total": 0.0,
|
||||||
|
"commission_total": 0.0,
|
||||||
|
"win_count": 0,
|
||||||
|
"loss_count": 0,
|
||||||
|
"avg_win": None,
|
||||||
|
"avg_loss": None,
|
||||||
|
"max_win": None,
|
||||||
|
"max_loss": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_pnl_bucket(bucket: dict[str, Any]) -> None:
|
||||||
|
wins = bucket.pop("_wins", [])
|
||||||
|
losses = bucket.pop("_losses", [])
|
||||||
|
open_count = int(bucket.get("open_count") or 0)
|
||||||
|
win_count = len(wins)
|
||||||
|
bucket["win_count"] = win_count
|
||||||
|
bucket["loss_count"] = len(losses)
|
||||||
|
bucket["avg_win"] = round(sum(wins) / len(wins), 4) if wins else None
|
||||||
|
avg_loss = round(sum(losses) / len(losses), 4) if losses else None
|
||||||
|
bucket["avg_loss"] = avg_loss
|
||||||
|
bucket["max_win"] = round(max(wins), 4) if wins else None
|
||||||
|
bucket["max_loss"] = round(min(losses), 4) if losses else None
|
||||||
|
bucket["pnl_total"] = round(float(bucket.get("pnl_total") or 0), 4)
|
||||||
|
bucket["pnl_ex_sick"] = round(float(bucket.get("pnl_ex_sick") or 0), 4)
|
||||||
|
bucket["turnover_total"] = round(float(bucket.get("turnover_total") or 0), 4)
|
||||||
|
bucket["commission_total"] = round(float(bucket.get("commission_total") or 0), 4)
|
||||||
|
bucket["win_rate"] = round(win_count / open_count * 100, 1) if open_count else None
|
||||||
|
avg_win = bucket["avg_win"]
|
||||||
|
if avg_win is not None and avg_loss is not None and avg_loss != 0:
|
||||||
|
bucket["profit_loss_ratio"] = round(avg_win / abs(avg_loss), 2)
|
||||||
|
else:
|
||||||
|
bucket["profit_loss_ratio"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _accumulate_trade_stat(
|
||||||
|
bucket: dict[str, Any],
|
||||||
|
*,
|
||||||
|
pnl: float,
|
||||||
|
is_sick: bool,
|
||||||
|
turnover: float = 0.0,
|
||||||
|
commission: float = 0.0,
|
||||||
|
) -> None:
|
||||||
|
bucket["open_count"] += 1
|
||||||
|
bucket["pnl_total"] += pnl
|
||||||
|
bucket["turnover_total"] += turnover
|
||||||
|
bucket["commission_total"] += commission
|
||||||
|
if is_sick:
|
||||||
|
bucket["sick_count"] += 1
|
||||||
|
else:
|
||||||
|
bucket["pnl_ex_sick"] += pnl
|
||||||
|
side = _pnl_side(pnl)
|
||||||
|
if side == "win":
|
||||||
|
bucket.setdefault("_wins", []).append(pnl)
|
||||||
|
elif side == "loss":
|
||||||
|
bucket.setdefault("_losses", []).append(pnl)
|
||||||
|
|
||||||
|
|
||||||
def _compute_period_stats(trade_rows: list[dict[str, Any]]) -> dict[str, Any]:
|
def _compute_period_stats(trade_rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
total = len(trade_rows)
|
total_bucket = _empty_pnl_bucket()
|
||||||
sick = 0
|
|
||||||
pnl_all = 0.0
|
|
||||||
pnl_ex = 0.0
|
|
||||||
by_ex: dict[str, dict[str, Any]] = {}
|
by_ex: dict[str, dict[str, Any]] = {}
|
||||||
for td_row in trade_rows:
|
for td_row in trade_rows:
|
||||||
ex = str(td_row.get("exchange_key") or "?")
|
ex = str(td_row.get("exchange_key") or "?")
|
||||||
pnl = float(td_row.get("pnl_amount") or 0)
|
pnl = float(td_row.get("pnl_amount") or 0)
|
||||||
tag = str(td_row.get("behavior_tag") or "")
|
tag = str(td_row.get("behavior_tag") or "")
|
||||||
is_sick = tag == "sick"
|
is_sick = tag == "sick"
|
||||||
if is_sick:
|
turnover = float(td_row.get("exchange_turnover_usdt") or 0)
|
||||||
sick += 1
|
commission = float(td_row.get("exchange_commission_usdt") or 0)
|
||||||
pnl_all += pnl
|
_accumulate_trade_stat(
|
||||||
if not is_sick:
|
total_bucket, pnl=pnl, is_sick=is_sick, turnover=turnover, commission=commission
|
||||||
pnl_ex += pnl
|
)
|
||||||
if ex not in by_ex:
|
if ex not in by_ex:
|
||||||
by_ex[ex] = {
|
by_ex[ex] = _empty_pnl_bucket()
|
||||||
"open_count": 0,
|
_accumulate_trade_stat(
|
||||||
"sick_count": 0,
|
by_ex[ex], pnl=pnl, is_sick=is_sick, turnover=turnover, commission=commission
|
||||||
"pnl_total": 0.0,
|
)
|
||||||
"pnl_ex_sick": 0.0,
|
_finalize_pnl_bucket(total_bucket)
|
||||||
}
|
|
||||||
bucket = by_ex[ex]
|
|
||||||
bucket["open_count"] += 1
|
|
||||||
bucket["pnl_total"] += pnl
|
|
||||||
if is_sick:
|
|
||||||
bucket["sick_count"] += 1
|
|
||||||
else:
|
|
||||||
bucket["pnl_ex_sick"] += pnl
|
|
||||||
for ex in by_ex:
|
for ex in by_ex:
|
||||||
by_ex[ex]["pnl_total"] = round(by_ex[ex]["pnl_total"], 4)
|
_finalize_pnl_bucket(by_ex[ex])
|
||||||
by_ex[ex]["pnl_ex_sick"] = round(by_ex[ex]["pnl_ex_sick"], 4)
|
total = int(total_bucket["open_count"] or 0)
|
||||||
|
sick = int(total_bucket["sick_count"] or 0)
|
||||||
sick_pct = round(sick / total * 100, 1) if total else 0.0
|
sick_pct = round(sick / total * 100, 1) if total else 0.0
|
||||||
return {
|
return {
|
||||||
"open_count": total,
|
"open_count": total,
|
||||||
"sick_count": sick,
|
"sick_count": sick,
|
||||||
"sick_pct": sick_pct,
|
"sick_pct": sick_pct,
|
||||||
"pnl_total": round(pnl_all, 4),
|
"pnl_total": total_bucket["pnl_total"],
|
||||||
"pnl_ex_sick": round(pnl_ex, 4),
|
"pnl_ex_sick": total_bucket["pnl_ex_sick"],
|
||||||
|
"win_count": total_bucket["win_count"],
|
||||||
|
"loss_count": total_bucket["loss_count"],
|
||||||
|
"avg_win": total_bucket["avg_win"],
|
||||||
|
"avg_loss": total_bucket["avg_loss"],
|
||||||
|
"max_win": total_bucket["max_win"],
|
||||||
|
"max_loss": total_bucket["max_loss"],
|
||||||
|
"win_rate": total_bucket["win_rate"],
|
||||||
|
"profit_loss_ratio": total_bucket["profit_loss_ratio"],
|
||||||
|
"turnover_total": total_bucket["turnover_total"],
|
||||||
|
"commission_total": total_bucket["commission_total"],
|
||||||
"by_exchange": by_ex,
|
"by_exchange": by_ex,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1418,7 +1516,7 @@ def list_daily_trades(
|
|||||||
search: str = "",
|
search: str = "",
|
||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""按日期区间列出开仓记录(本日/本周/本月/自选),含犯病与盈亏统计。"""
|
"""按日期区间列出平仓记录(本日/本周/本月/自选,以平仓时间计),含犯病与盈亏统计。"""
|
||||||
init_db(db_path)
|
init_db(db_path)
|
||||||
p = (period or "today").strip().lower() or "today"
|
p = (period or "today").strip().lower() or "today"
|
||||||
start_ms, end_ms, df, dt, period_label = resolve_period_bounds(
|
start_ms, end_ms, df, dt, period_label = resolve_period_bounds(
|
||||||
@@ -1431,7 +1529,7 @@ def list_daily_trades(
|
|||||||
conn = _connect(db_path)
|
conn = _connect(db_path)
|
||||||
try:
|
try:
|
||||||
params: list[Any] = [start_ms, end_ms]
|
params: list[Any] = [start_ms, end_ms]
|
||||||
where = "opened_at_ms >= ? AND opened_at_ms < ?"
|
where = "closed_at_ms IS NOT NULL AND closed_at_ms >= ? AND closed_at_ms < ?"
|
||||||
if ex_filter:
|
if ex_filter:
|
||||||
where += " AND exchange_key=?"
|
where += " AND exchange_key=?"
|
||||||
params.append(ex_filter)
|
params.append(ex_filter)
|
||||||
@@ -1439,12 +1537,11 @@ def list_daily_trades(
|
|||||||
f"""
|
f"""
|
||||||
SELECT * FROM archive_trade_cache
|
SELECT * FROM archive_trade_cache
|
||||||
WHERE {where}
|
WHERE {where}
|
||||||
ORDER BY opened_at_ms DESC, trade_id DESC
|
ORDER BY closed_at_ms DESC, trade_id DESC
|
||||||
""",
|
""",
|
||||||
params,
|
params,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
overlays_by_ex: dict[str, dict[int, dict]] = {}
|
overlays_by_ex: dict[str, dict[int, dict]] = {}
|
||||||
all_rows: list[dict[str, Any]] = []
|
|
||||||
trades: list[dict[str, Any]] = []
|
trades: list[dict[str, Any]] = []
|
||||||
q = (search or "").strip().lower()
|
q = (search or "").strip().lower()
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -1452,7 +1549,6 @@ def list_daily_trades(
|
|||||||
if ex_k not in overlays_by_ex:
|
if ex_k not in overlays_by_ex:
|
||||||
overlays_by_ex[ex_k] = load_overlays(ex_k, db_path=db_path)
|
overlays_by_ex[ex_k] = load_overlays(ex_k, db_path=db_path)
|
||||||
td_row = _trade_row_to_dict(r, overlays_by_ex[ex_k].get(int(r["trade_id"])))
|
td_row = _trade_row_to_dict(r, overlays_by_ex[ex_k].get(int(r["trade_id"])))
|
||||||
all_rows.append(td_row)
|
|
||||||
pnl = float(td_row.get("pnl_amount") or 0)
|
pnl = float(td_row.get("pnl_amount") or 0)
|
||||||
tag = td_row.get("behavior_tag") or ""
|
tag = td_row.get("behavior_tag") or ""
|
||||||
if filter_profit and pnl <= 0.0001:
|
if filter_profit and pnl <= 0.0001:
|
||||||
@@ -1484,7 +1580,97 @@ def list_daily_trades(
|
|||||||
"date_from": df,
|
"date_from": df,
|
||||||
"date_to": dt,
|
"date_to": dt,
|
||||||
"trades": trades,
|
"trades": trades,
|
||||||
"stats": _compute_period_stats(all_rows),
|
"stats": _compute_period_stats(trades),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def list_archive_calendar(
|
||||||
|
year: int,
|
||||||
|
month: int,
|
||||||
|
*,
|
||||||
|
exchange_key: str = "",
|
||||||
|
db_path: Path | None = None,
|
||||||
|
reset_hour: int = TRADING_DAY_RESET_HOUR,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""按月返回每个交易日的盈亏、笔数、犯病标记(08:00 切日)。"""
|
||||||
|
init_db(db_path)
|
||||||
|
y = int(year)
|
||||||
|
m = int(month)
|
||||||
|
if m < 1 or m > 12:
|
||||||
|
raise ValueError("month 无效")
|
||||||
|
first = f"{y:04d}-{m:02d}-01"
|
||||||
|
if m == 12:
|
||||||
|
next_first = datetime(y + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
next_first = datetime(y, m + 1, 1)
|
||||||
|
last = (next_first - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
start_ms, _ = trading_day_bounds_ms(first, reset_hour=reset_hour)
|
||||||
|
_, end_ms = trading_day_bounds_ms(last, reset_hour=reset_hour)
|
||||||
|
ex_filter = (exchange_key or "").strip().lower()
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
params: list[Any] = [start_ms, end_ms]
|
||||||
|
where = "closed_at_ms IS NOT NULL AND closed_at_ms >= ? AND closed_at_ms < ?"
|
||||||
|
if ex_filter:
|
||||||
|
where += " AND exchange_key=?"
|
||||||
|
params.append(ex_filter)
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM archive_trade_cache WHERE {where}",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
overlays_by_ex: dict[str, dict[int, dict]] = {}
|
||||||
|
days: dict[str, dict[str, Any]] = {}
|
||||||
|
for r in rows:
|
||||||
|
ex_k = r["exchange_key"]
|
||||||
|
if ex_k not in overlays_by_ex:
|
||||||
|
overlays_by_ex[ex_k] = load_overlays(ex_k, db_path=db_path)
|
||||||
|
td_row = _trade_row_to_dict(r, overlays_by_ex[ex_k].get(int(r["trade_id"])))
|
||||||
|
closed_ms = td_row.get("closed_at_ms") or _parse_dt_ms(td_row.get("closed_at"))
|
||||||
|
if not closed_ms:
|
||||||
|
continue
|
||||||
|
day = ms_to_trading_day(int(closed_ms), reset_hour=reset_hour)
|
||||||
|
if not day:
|
||||||
|
continue
|
||||||
|
if day < first or day > last:
|
||||||
|
continue
|
||||||
|
bucket = days.setdefault(
|
||||||
|
day,
|
||||||
|
{
|
||||||
|
"trading_day": day,
|
||||||
|
"open_count": 0,
|
||||||
|
"sick_count": 0,
|
||||||
|
"pnl_total": 0.0,
|
||||||
|
"turnover_total": 0.0,
|
||||||
|
"commission_total": 0.0,
|
||||||
|
"has_sick": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
pnl = float(td_row.get("pnl_amount") or 0)
|
||||||
|
tag = str(td_row.get("behavior_tag") or "")
|
||||||
|
is_sick = tag == "sick"
|
||||||
|
bucket["open_count"] += 1
|
||||||
|
bucket["pnl_total"] += pnl
|
||||||
|
bucket["turnover_total"] += float(td_row.get("exchange_turnover_usdt") or 0)
|
||||||
|
bucket["commission_total"] += float(td_row.get("exchange_commission_usdt") or 0)
|
||||||
|
if is_sick:
|
||||||
|
bucket["sick_count"] += 1
|
||||||
|
bucket["has_sick"] = True
|
||||||
|
for d in days.values():
|
||||||
|
d["pnl_total"] = round(float(d["pnl_total"]), 4)
|
||||||
|
d["turnover_total"] = round(float(d["turnover_total"]), 4)
|
||||||
|
d["commission_total"] = round(float(d["commission_total"]), 4)
|
||||||
|
month_pnl = sum(float(d["pnl_total"]) for d in days.values())
|
||||||
|
month_count = sum(int(d["open_count"]) for d in days.values())
|
||||||
|
return {
|
||||||
|
"year": y,
|
||||||
|
"month": m,
|
||||||
|
"date_from": first,
|
||||||
|
"date_to": last,
|
||||||
|
"days": days,
|
||||||
|
"month_pnl_total": round(month_pnl, 4),
|
||||||
|
"month_open_count": month_count,
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -4,12 +4,12 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
from strategy_trade_labels import (
|
from lib.strategy.strategy_trade_labels import (
|
||||||
MONITOR_TYPE_ROLL,
|
MONITOR_TYPE_ROLL,
|
||||||
MONITOR_TYPE_TREND_PULLBACK,
|
MONITOR_TYPE_TREND_PULLBACK,
|
||||||
entry_reason_for_monitor_type,
|
entry_reason_for_monitor_type,
|
||||||
)
|
)
|
||||||
from time_close_lib import TIME_CLOSE_RESULT
|
from lib.trade.time_close_lib import TIME_CLOSE_RESULT
|
||||||
|
|
||||||
TRADE_COMPLETED_RESULTS = (
|
TRADE_COMPLETED_RESULTS = (
|
||||||
"止盈",
|
"止盈",
|
||||||
@@ -327,6 +327,8 @@ def _normalize_archive_trade_row(
|
|||||||
"take_profit": _effective_field(d, "reviewed_take_profit", "take_profit"),
|
"take_profit": _effective_field(d, "reviewed_take_profit", "take_profit"),
|
||||||
"reviewed": reviewed,
|
"reviewed": reviewed,
|
||||||
"trading_day": trading_day_from_dt(close_dt, reset_hour),
|
"trading_day": trading_day_from_dt(close_dt, reset_hour),
|
||||||
|
"exchange_turnover_usdt": d.get("exchange_turnover_usdt"),
|
||||||
|
"exchange_commission_usdt": d.get("exchange_commission_usdt"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -397,6 +399,8 @@ def _archive_trade_select_sql(cols: set[str]) -> str:
|
|||||||
"reviewed_take_profit",
|
"reviewed_take_profit",
|
||||||
"reviewed_at",
|
"reviewed_at",
|
||||||
"trend_plan_id",
|
"trend_plan_id",
|
||||||
|
"exchange_turnover_usdt",
|
||||||
|
"exchange_commission_usdt",
|
||||||
]
|
]
|
||||||
select_cols = [c for c in wanted if c in cols]
|
select_cols = [c for c in wanted if c in cols]
|
||||||
if "id" not in select_cols:
|
if "id" not in select_cols:
|
||||||
@@ -9,10 +9,11 @@ from pathlib import Path
|
|||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from hub_trades_lib import trading_day_from_dt
|
from lib.hub.hub_trades_lib import trading_day_from_dt
|
||||||
|
|
||||||
TOP_N_DEFAULT = 20
|
TOP_N_DEFAULT = 20
|
||||||
CACHE_VERSION = 3
|
CACHE_VERSION = 3
|
||||||
|
LIQUIDITY_RANK_CACHE_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
def volume_rank_reset_hour() -> int:
|
def volume_rank_reset_hour() -> int:
|
||||||
@@ -58,9 +59,9 @@ def default_cache_path() -> Path:
|
|||||||
raw = (os.getenv("HUB_VOLUME_RANK_CACHE_PATH") or "").strip()
|
raw = (os.getenv("HUB_VOLUME_RANK_CACHE_PATH") or "").strip()
|
||||||
if raw:
|
if raw:
|
||||||
return Path(raw)
|
return Path(raw)
|
||||||
hub_dir = Path(__file__).resolve().parent / "manual_trading_hub" / "data"
|
from lib.paths import hub_data_dir
|
||||||
hub_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
return hub_dir / "hub_volume_rank.json"
|
return hub_data_dir() / "hub_volume_rank.json"
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(v: Any) -> float | None:
|
def _safe_float(v: Any) -> float | None:
|
||||||
@@ -291,8 +292,7 @@ def _scores_from_binance(exchange) -> list[tuple[str, str, float]]:
|
|||||||
return _merge_scores(by_base)
|
return _merge_scores(by_base)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
tickers = exchange.fetch_tickers()
|
return []
|
||||||
return _scores_from_markets(exchange, tickers or {}, "binance")
|
|
||||||
|
|
||||||
|
|
||||||
def _scores_from_gate(exchange) -> list[tuple[str, str, float]]:
|
def _scores_from_gate(exchange) -> list[tuple[str, str, float]]:
|
||||||
@@ -329,8 +329,7 @@ def _scores_from_gate(exchange) -> list[tuple[str, str, float]]:
|
|||||||
return _merge_scores(by_base)
|
return _merge_scores(by_base)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
tickers = exchange.fetch_tickers()
|
return []
|
||||||
return _scores_from_markets(exchange, tickers or {}, "gateio")
|
|
||||||
|
|
||||||
|
|
||||||
def _scores_from_markets(
|
def _scores_from_markets(
|
||||||
@@ -372,6 +371,69 @@ def _collect_scores(exchange, exchange_id: str) -> list[tuple[str, str, float]]:
|
|||||||
return _scores_from_markets(exchange, tickers or {}, ex_id)
|
return _scores_from_markets(exchange, tickers or {}, ex_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _uses_lightweight_volume_scores(exchange_id: str) -> bool:
|
||||||
|
ex_id = str(exchange_id or "").lower()
|
||||||
|
return ex_id in ("okx", "binance", "gateio", "gate", "gate_bot")
|
||||||
|
|
||||||
|
|
||||||
|
def build_usdt_swap_volume_ranks(
|
||||||
|
exchange,
|
||||||
|
ensure_markets_loaded: Callable[[], None],
|
||||||
|
*,
|
||||||
|
exchange_id: str | None = None,
|
||||||
|
) -> tuple[dict[str, int], int]:
|
||||||
|
"""
|
||||||
|
全市场 USDT 永续 24h 成交额排名(base -> rank)。
|
||||||
|
优先各所轻量 ticker API,避免 fetch_tickers() 拉全市场(Gate/Binance 内存优化)。
|
||||||
|
"""
|
||||||
|
ex_id = str(exchange_id or getattr(exchange, "id", "") or "").lower()
|
||||||
|
if not _uses_lightweight_volume_scores(ex_id):
|
||||||
|
ensure_markets_loaded()
|
||||||
|
scored = _collect_scores(exchange, ex_id)
|
||||||
|
ranks: dict[str, int] = {}
|
||||||
|
for idx, (_sym, base, _qv) in enumerate(scored, 1):
|
||||||
|
if base and base not in ranks:
|
||||||
|
ranks[base] = idx
|
||||||
|
return ranks, len(scored)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_daily_volume_rank(
|
||||||
|
target_base: str,
|
||||||
|
cache: dict[str, Any],
|
||||||
|
*,
|
||||||
|
now_ts: float,
|
||||||
|
ttl_sec: float,
|
||||||
|
exchange,
|
||||||
|
ensure_markets_loaded: Callable[[], None],
|
||||||
|
exchange_id: str | None = None,
|
||||||
|
cache_version: int = LIQUIDITY_RANK_CACHE_VERSION,
|
||||||
|
) -> tuple[int | None, int]:
|
||||||
|
"""关键位门控:按 base 查 24h 成交额全市场排名;cache 带 TTL。"""
|
||||||
|
cached_ok = (
|
||||||
|
cache.get("version") == cache_version
|
||||||
|
and cache.get("updated_at")
|
||||||
|
and now_ts - float(cache["updated_at"]) < ttl_sec
|
||||||
|
)
|
||||||
|
if not cached_ok:
|
||||||
|
try:
|
||||||
|
ranks, total = build_usdt_swap_volume_ranks(
|
||||||
|
exchange,
|
||||||
|
ensure_markets_loaded,
|
||||||
|
exchange_id=exchange_id,
|
||||||
|
)
|
||||||
|
if total > 0 and ranks:
|
||||||
|
cache["ranks"] = ranks
|
||||||
|
cache["total"] = total
|
||||||
|
cache["version"] = cache_version
|
||||||
|
cache["updated_at"] = now_ts
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ranks = cache.get("ranks") or {}
|
||||||
|
total = int(cache.get("total") or 0)
|
||||||
|
base = str(target_base or "").strip().upper()
|
||||||
|
return ranks.get(base), total
|
||||||
|
|
||||||
|
|
||||||
def fetch_usdt_swap_volume_rank(
|
def fetch_usdt_swap_volume_rank(
|
||||||
exchange,
|
exchange,
|
||||||
ensure_markets_loaded: Callable[[], None],
|
ensure_markets_loaded: Callable[[], None],
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Shared library package."""
|
||||||
@@ -3,12 +3,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
from hub_ohlcv_lib import (
|
from lib.hub.hub_ohlcv_lib import (
|
||||||
normalize_price_tick,
|
normalize_price_tick,
|
||||||
price_tick_from_market,
|
price_tick_from_market,
|
||||||
round_ohlcv_bars_to_tick,
|
round_ohlcv_bars_to_tick,
|
||||||
)
|
)
|
||||||
from order_monitor_display_lib import (
|
from lib.trade.order_monitor_display_lib import (
|
||||||
apply_order_live_price_display,
|
apply_order_live_price_display,
|
||||||
apply_order_price_display_fields,
|
apply_order_price_display_fields,
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""embed 壳/片段:按 tab 裁剪 render_main_page 的数据加载,降内存与 API 压力。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
EMBED_STRATEGY_PAGES = frozenset({"strategy", "strategy_trend", "strategy_roll", "strategy_records"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EmbedRenderPlan:
|
||||||
|
exchange_capitals: bool
|
||||||
|
records_rows: bool
|
||||||
|
records_summary: bool
|
||||||
|
key_history: bool
|
||||||
|
key_list: bool
|
||||||
|
orders: bool
|
||||||
|
stats_bundle: bool
|
||||||
|
strategy: bool
|
||||||
|
orphan_live: bool
|
||||||
|
|
||||||
|
|
||||||
|
def embed_render_plan(page: str, embed_mode: str | None) -> EmbedRenderPlan:
|
||||||
|
if embed_mode not in ("fragment", "shell"):
|
||||||
|
return EmbedRenderPlan(
|
||||||
|
exchange_capitals=True,
|
||||||
|
records_rows=True,
|
||||||
|
records_summary=False,
|
||||||
|
key_history=True,
|
||||||
|
key_list=True,
|
||||||
|
orders=True,
|
||||||
|
stats_bundle=True,
|
||||||
|
strategy=True,
|
||||||
|
orphan_live=True,
|
||||||
|
)
|
||||||
|
is_shell = embed_mode == "shell"
|
||||||
|
is_strategy = page in EMBED_STRATEGY_PAGES
|
||||||
|
return EmbedRenderPlan(
|
||||||
|
exchange_capitals=is_shell,
|
||||||
|
records_rows=page == "records",
|
||||||
|
records_summary=is_shell and page != "records",
|
||||||
|
key_history=page == "key_monitor",
|
||||||
|
key_list=page in ("key_monitor", "trade") or is_strategy,
|
||||||
|
orders=page == "trade" or is_strategy,
|
||||||
|
stats_bundle=page == "stats",
|
||||||
|
strategy=is_strategy,
|
||||||
|
orphan_live=page == "trade" and is_shell,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def trade_records_summary(conn, start_bj: str, end_bj: str, tr_ts: str) -> dict[str, Any]:
|
||||||
|
"""顶栏统计用 COUNT,避免 embed 壳拉 1000 行交易记录。"""
|
||||||
|
from lib.trade.trade_result_lib import sql_effective_pnl_expr
|
||||||
|
|
||||||
|
pnl_sql = sql_effective_pnl_expr()
|
||||||
|
row = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN result = '错过' THEN 1 ELSE 0 END) AS miss_count,
|
||||||
|
SUM(CASE WHEN {pnl_sql} > 0 THEN 1 ELSE 0 END) AS wins,
|
||||||
|
SUM(CASE WHEN result = '错过' AND COALESCE(miss_reason,'') LIKE '%持仓占用%' THEN 1 ELSE 0 END) AS occupied_miss
|
||||||
|
FROM trade_records
|
||||||
|
WHERE {tr_ts} >= ? AND {tr_ts} <= ?
|
||||||
|
""",
|
||||||
|
(start_bj, end_bj),
|
||||||
|
).fetchone()
|
||||||
|
total = int(row["total"] or 0) if row else 0
|
||||||
|
miss_count = int(row["miss_count"] or 0) if row else 0
|
||||||
|
wins = int(row["wins"] or 0) if row else 0
|
||||||
|
occupied_miss_total = int(row["occupied_miss"] or 0) if row else 0
|
||||||
|
rate = round(wins / total * 100, 2) if total else 0
|
||||||
|
return {
|
||||||
|
"records": [],
|
||||||
|
"total": total,
|
||||||
|
"miss_count": miss_count,
|
||||||
|
"rate": rate,
|
||||||
|
"occupied_miss_total": occupied_miss_total,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def minimal_stats_bundle(reset_hour: int) -> dict[str, Any]:
|
||||||
|
return {"stats_reset_hour": reset_hour, "segments": []}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""中控 iframe:壳常驻 + tab 内容 API(/embed、/api/embed/page/<tab>)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from lib.paths import embed_templates_dir
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Callable
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlsplit
|
||||||
|
|
||||||
|
from flask import Flask, Response, jsonify, redirect, request, session
|
||||||
|
from jinja2 import ChoiceLoader, FileSystemLoader
|
||||||
|
|
||||||
|
EMBED_TABS: tuple[str, ...] = (
|
||||||
|
"key_monitor",
|
||||||
|
"trade",
|
||||||
|
"strategy",
|
||||||
|
"strategy_records",
|
||||||
|
"records",
|
||||||
|
"stats",
|
||||||
|
)
|
||||||
|
|
||||||
|
PATH_TO_EMBED_TAB: dict[str, str] = {
|
||||||
|
"/": "trade",
|
||||||
|
"/trade": "trade",
|
||||||
|
"/key_monitor": "key_monitor",
|
||||||
|
"/strategy": "strategy",
|
||||||
|
"/strategy/trend": "strategy",
|
||||||
|
"/strategy/roll": "strategy",
|
||||||
|
"/strategy/records": "strategy_records",
|
||||||
|
"/records": "records",
|
||||||
|
"/stats": "stats",
|
||||||
|
}
|
||||||
|
|
||||||
|
ORDER_RULE_TIPS_BY_EXCHANGE: dict[str, str] = {
|
||||||
|
"gate": "order_monitor_rule_tips_gate.html",
|
||||||
|
"gate_bot": "order_monitor_rule_tips_gate.html",
|
||||||
|
"binance": "order_monitor_rule_tips_binance.html",
|
||||||
|
"okx": "order_monitor_rule_tips_okx.html",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def order_rule_tips_template(exchange_key: str) -> str:
|
||||||
|
ex = (exchange_key or "").strip().lower()
|
||||||
|
return ORDER_RULE_TIPS_BY_EXCHANGE.get(ex, "order_monitor_rule_tips_gate.html")
|
||||||
|
|
||||||
|
|
||||||
|
def include_transfer_block(exchange_key: str) -> bool:
|
||||||
|
return (exchange_key or "").strip().lower() in ("gate", "gate_bot")
|
||||||
|
|
||||||
|
|
||||||
|
def path_to_embed_tab(path: str) -> str | None:
|
||||||
|
p = (path or "/").strip()
|
||||||
|
if not p.startswith("/"):
|
||||||
|
p = "/" + p
|
||||||
|
base = urlsplit(p).path.rstrip("/") or "/"
|
||||||
|
return PATH_TO_EMBED_TAB.get(base)
|
||||||
|
|
||||||
|
|
||||||
|
def embed_shell_enabled() -> bool:
|
||||||
|
return (os.getenv("HUB_EMBED_SHELL") or "1").strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite_embed_dest(path: str, hub_theme: str | None = None) -> str:
|
||||||
|
"""embed=1 打开时:/trade → /embed?tab=trade&embed=1"""
|
||||||
|
if not embed_shell_enabled():
|
||||||
|
split = urlsplit(path or "/")
|
||||||
|
q = dict(parse_qsl(split.query, keep_blank_values=True))
|
||||||
|
q["embed"] = "1"
|
||||||
|
ht = (hub_theme or q.get("hub_theme") or "").strip().lower()
|
||||||
|
if ht in ("light", "dark"):
|
||||||
|
q["hub_theme"] = ht
|
||||||
|
dest = split.path or "/"
|
||||||
|
if q:
|
||||||
|
return f"{dest}?{urlencode(q)}"
|
||||||
|
return dest + "?embed=1"
|
||||||
|
split = urlsplit(path or "/")
|
||||||
|
tab = path_to_embed_tab(split.path)
|
||||||
|
q = dict(parse_qsl(split.query, keep_blank_values=True))
|
||||||
|
if tab:
|
||||||
|
q["tab"] = tab
|
||||||
|
q["embed"] = "1"
|
||||||
|
ht = (hub_theme or q.get("hub_theme") or "").strip().lower()
|
||||||
|
if ht in ("light", "dark"):
|
||||||
|
q["hub_theme"] = ht
|
||||||
|
return f"/embed?{urlencode(q)}"
|
||||||
|
q["embed"] = "1"
|
||||||
|
ht = (hub_theme or q.get("hub_theme") or "").strip().lower()
|
||||||
|
if ht in ("light", "dark"):
|
||||||
|
q["hub_theme"] = ht
|
||||||
|
dest = split.path or "/"
|
||||||
|
if split.query:
|
||||||
|
dest += "?" + split.query
|
||||||
|
if "embed=1" not in dest:
|
||||||
|
sep = "&" if "?" in dest else "?"
|
||||||
|
dest += f"{sep}embed=1"
|
||||||
|
if ht in ("light", "dark") and "hub_theme=" not in dest:
|
||||||
|
sep = "&" if "?" in dest else "?"
|
||||||
|
dest += f"{sep}hub_theme={ht}"
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def attach_embed_templates(app: Flask, repo_root: str) -> None:
|
||||||
|
embed_dir = embed_templates_dir(repo_root)
|
||||||
|
if not os.path.isdir(embed_dir):
|
||||||
|
return
|
||||||
|
existing = app.jinja_loader
|
||||||
|
loaders = [FileSystemLoader(embed_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 register_embed_routes(
|
||||||
|
app: Flask,
|
||||||
|
login_required: Callable,
|
||||||
|
render_main_page_fn: Callable,
|
||||||
|
) -> None:
|
||||||
|
app.config["RENDER_MAIN_PAGE_FN"] = render_main_page_fn
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@app.route("/embed")
|
||||||
|
def embed_shell_page():
|
||||||
|
tab = (request.args.get("tab") or "trade").strip()
|
||||||
|
if tab not in EMBED_TABS:
|
||||||
|
tab = "trade"
|
||||||
|
session["hub_embed_shell"] = True
|
||||||
|
return render_main_page_fn(tab, embed_mode="shell")
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@app.route("/api/embed/page/<tab>")
|
||||||
|
def api_embed_page(tab: str):
|
||||||
|
tab = (tab or "").strip()
|
||||||
|
if tab not in EMBED_TABS:
|
||||||
|
return jsonify({"ok": False, "msg": "unknown tab"}), 404
|
||||||
|
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})
|
||||||
|
|
||||||
|
|
||||||
|
def embed_context_extras(exchange_key: str) -> dict:
|
||||||
|
return {
|
||||||
|
"order_rule_tips_tpl": order_rule_tips_template(exchange_key),
|
||||||
|
"include_transfer_block": include_transfer_block(exchange_key),
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""中控 iframe 内软导航:服务端跳过重型同步,避免切 tab 等待数秒。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Request
|
||||||
|
|
||||||
|
|
||||||
|
def request_is_hub_soft_nav(req: Request | None = None) -> bool:
|
||||||
|
"""embed=1 且带 X-Instance-Soft-Nav 头:实例页内 fetch 换页,非整页刷新。"""
|
||||||
|
try:
|
||||||
|
from flask import request as flask_request
|
||||||
|
|
||||||
|
r = req or flask_request
|
||||||
|
if str(r.args.get("embed") or "").strip() != "1":
|
||||||
|
return False
|
||||||
|
flag = (r.headers.get("X-Instance-Soft-Nav") or "").strip().lower()
|
||||||
|
return flag in ("1", "true", "yes")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,469 @@
|
|||||||
|
{# Hub iframe tab fragment — shared via embed_templates #}
|
||||||
|
{% macro period_stats(title, s) %}
|
||||||
|
<div class="stats-period-block">
|
||||||
|
<h3>{{ title }}</h3>
|
||||||
|
<div class="sub">{{ s.range_label }}</div>
|
||||||
|
<div class="stats-detail">
|
||||||
|
<div class="stat-item"><div class="label">开单次数</div><div class="value">{{ s.opens_count }}</div></div>
|
||||||
|
<div class="stat-item"><div class="label">平仓笔数</div><div class="value">{{ s.closed_count }}</div></div>
|
||||||
|
<div class="stat-item"><div class="label">胜率</div><div class="value">{% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}</div></div>
|
||||||
|
<div class="stat-item"><div class="label">净盈亏(U)</div><div class="value">{{ funds_fmt(s.net_pnl_u) }}</div></div>
|
||||||
|
<div class="stat-item"><div class="label">亏损额合计(U)</div><div class="value">{{ funds_fmt(s.loss_sum_u) }}</div></div>
|
||||||
|
<div class="stat-item"><div class="label">单笔最大亏损(U)</div><div class="value">{% if s.max_single_loss is not none %}{{ funds_fmt(s.max_single_loss) }}{% else %}-{% endif %}</div></div>
|
||||||
|
<div class="stat-item"><div class="label">单笔最大盈利(U)</div><div class="value">{% if s.max_single_profit is not none %}{{ funds_fmt(s.max_single_profit) }}{% else %}-{% endif %}</div></div>
|
||||||
|
<div class="stat-item"><div class="label">最大回撤(U)</div><div class="value">{{ funds_fmt(s.max_drawdown_u) }}</div></div>
|
||||||
|
<div class="stat-item"><div class="label">当前连续亏损笔数</div><div class="value">{{ s.consecutive_losses }}</div></div>
|
||||||
|
<div class="stat-item"><div class="label">最长连续亏损(交易日)</div><div class="value">{{ s.max_loss_streak_days }} 天</div></div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
<div class="grid">
|
||||||
|
{% if page == 'key_monitor' %}
|
||||||
|
{% include 'key_monitor_panel.html' %}
|
||||||
|
{% elif page == 'trade' %}
|
||||||
|
<div class="dual-panel-grid" style="grid-column:1/-1">
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||||||
|
<h2 style="margin-bottom:0">实盘下单监控</h2>
|
||||||
|
{% if focus_order_id %}
|
||||||
|
<a href="/order_focus?order_id={{ focus_order_id }}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(100根)</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="btn-del" style="background:#2f2f44;color:#9aa;cursor:not-allowed">暂无持仓可放大</span>
|
||||||
|
{% 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 }}">
|
||||||
|
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
||||||
|
<select id="order-direction" name="direction" required>
|
||||||
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
|
</select>
|
||||||
|
<select id="sltp-mode" name="sltp_mode">
|
||||||
|
<option value="fixed_rr" selected>止盈止损:固定盈亏比</option>
|
||||||
|
<option value="price">止盈止损:价格模式</option>
|
||||||
|
<option value="pct">止盈止损:百分比模式</option>
|
||||||
|
</select>
|
||||||
|
<select name="trade_style" required>
|
||||||
|
<option value="trend">趋势单</option>
|
||||||
|
<option value="swing">波段单</option>
|
||||||
|
</select>
|
||||||
|
{% if position_sizing_mode != 'full_margin' %}
|
||||||
|
<input id="order-leverage" name="leverage" type="number" min="1" step="1" placeholder="杠杆(可选)">
|
||||||
|
{% endif %}
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<span style="display:flex;align-items:center;padding:0 10px;font-size:.8rem;color:#8fc8ff">成交价自动取交易所实时+成交回报</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="止盈距离=止损距离×盈亏比">
|
||||||
|
<span id="order-tp-preview" style="display:none;font-size:.8rem;color:#8fc8ff;align-self:center">预估止盈:—</span>
|
||||||
|
<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' %}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||||
|
<div class="panel-scroll pos-list pos-list-live">
|
||||||
|
{% for o in order %}
|
||||||
|
<div class="pos-card" id="order-row-{{ o.id }}"
|
||||||
|
data-monitor-id="{{ o.id }}"
|
||||||
|
data-symbol="{{ o.symbol }}"
|
||||||
|
data-direction="{{ o.direction }}"
|
||||||
|
data-plan-sl="{% if o.stop_loss %}{{ price_fmt(o.symbol, o.stop_loss) }}{% endif %}"
|
||||||
|
data-plan-tp="{% if o.take_profit %}{{ price_fmt(o.symbol, o.take_profit) }}{% endif %}"
|
||||||
|
data-entry="{% if o.trigger_price %}{{ price_fmt(o.symbol, o.trigger_price) }}{% endif %}">
|
||||||
|
<div class="pos-card-head">
|
||||||
|
<div class="pos-card-symbol">
|
||||||
|
<strong>{{ o.exchange_symbol or o.symbol }}</strong>
|
||||||
|
{% if o.time_close_enabled %}
|
||||||
|
<span class="pos-symbol-time-close pos-meta-on pos-time-close-meta" id="order-time-close-wrap-{{ o.id }}"
|
||||||
|
data-close-at-ms="{{ o.time_close_at_ms or '' }}">
|
||||||
|
<span class="pos-time-close-label">时间平仓 {{ o.time_close_hours or '' }}h</span>
|
||||||
|
· <span class="pos-time-close-cd" id="order-time-close-cd-{{ o.id }}">--:--:--</span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pos-meta">
|
||||||
|
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
|
||||||
|
<span class="pos-meta-item">风格: {{ o.trade_style or 'trend' }}</span>
|
||||||
|
<span class="pos-meta-item">风险: {% if position_sizing_mode == 'full_margin' %}{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% else %}{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% endif %}</span>
|
||||||
|
<span class="pos-meta-item" id="order-latest-risk-wrap-{{ o.id }}" style="display:none">最新风险: —</span>
|
||||||
|
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}">
|
||||||
|
{% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="pos-meta-item" id="order-be-wrap-{{ o.id }}" style="display:none"><span class="pos-breakeven-badge">已保本</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="pos-grid">
|
||||||
|
<div class="pos-cell">
|
||||||
|
<span class="pos-label">成交价</span>
|
||||||
|
<span class="pos-value">{{ price_fmt(o.symbol, o.trigger_price) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pos-cell">
|
||||||
|
<span class="pos-label">止损</span>
|
||||||
|
<span class="pos-value" id="order-plan-sl-{{ o.id }}">{{ price_fmt(o.symbol, o.stop_loss) if o.stop_loss else '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pos-cell">
|
||||||
|
<span class="pos-label">止盈</span>
|
||||||
|
<span class="pos-value" id="order-plan-tp-{{ o.id }}">{{ price_fmt(o.symbol, o.take_profit) if o.take_profit else '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pos-cell">
|
||||||
|
<span class="pos-label">盈亏比</span>
|
||||||
|
<span class="pos-value" id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pos-cell">
|
||||||
|
<span class="pos-label">张数</span>
|
||||||
|
<span class="pos-value" id="order-contracts-{{ o.id }}">{% if o.order_amount is not none %}{{ '%g'|format(o.order_amount) }}{% else %}—{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pos-cell">
|
||||||
|
<span class="pos-label">标记价</span>
|
||||||
|
<span class="pos-value" id="order-price-{{ o.id }}">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="pos-cell">
|
||||||
|
<span class="pos-label">浮盈亏</span>
|
||||||
|
<span class="pos-value" id="order-pnl-{{ o.id }}">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pos-footer">
|
||||||
|
<span>保证金: <span id="order-ex-margin-{{ o.id }}">-</span></span>
|
||||||
|
<span>计划基数: {{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
|
||||||
|
<span>杠杆: {{ o.leverage or '-' }}x</span>
|
||||||
|
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
|
||||||
|
<span>开仓时间: {{ (o.opened_at or '-')[:16] }}</span>
|
||||||
|
<span>持仓时长: <span class="order-hold-duration" id="order-hold-duration-{{ o.id }}" data-order-opened-ms="{{ o.opened_at_ms or '' }}">—</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="pos-ex-orders">
|
||||||
|
<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>
|
||||||
|
<button type="button" class="pos-ex-cancel-btn" id="ex-sl-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'sl')">撤单</button>
|
||||||
|
</div>
|
||||||
|
<div class="pos-ex-order-row">
|
||||||
|
<span class="pos-ex-order-main" id="ex-tp-text-{{ o.id }}">止盈:加载中…</span>
|
||||||
|
<button type="button" class="pos-ex-cancel-btn" id="ex-tp-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'tp')">撤单</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="pos-empty">暂无持仓</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tpsl-modal" class="tpsl-modal-backdrop" onclick="if(event.target===this)closeTpslEntrustModal()">
|
||||||
|
<div class="tpsl-modal" onclick="event.stopPropagation()">
|
||||||
|
<h3 id="tpsl-modal-title">挂止盈止损</h3>
|
||||||
|
<p style="font-size:.78rem;color:#8892b0;margin:0 0 10px">将先撤销该合约已有 TP/SL,再按下列价格重挂。</p>
|
||||||
|
<div class="form-row">
|
||||||
|
<select id="tpsl-modal-mode" onchange="toggleTpslModalMode()">
|
||||||
|
<option value="price">价格模式</option>
|
||||||
|
<option value="pct">百分比模式</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<input id="tpsl-modal-sl" step="any" placeholder="止损价格">
|
||||||
|
<input id="tpsl-modal-tp" step="any" placeholder="止盈价格">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<input id="tpsl-modal-sl-pct" type="number" min="0.01" step="0.01" placeholder="止损%" style="display:none">
|
||||||
|
<input id="tpsl-modal-tp-pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
|
||||||
|
</div>
|
||||||
|
<div class="tpsl-modal-actions">
|
||||||
|
<button type="button" class="tpsl-modal-cancel" onclick="closeTpslEntrustModal()">取消</button>
|
||||||
|
<button type="button" class="tpsl-modal-submit" onclick="submitTpslEntrust()">先撤后挂</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %}
|
||||||
|
{% include 'strategy_trading_page.html' %}
|
||||||
|
{% elif page == 'strategy_records' %}
|
||||||
|
{% include 'strategy_records_page.html' %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% if page == 'records' %}
|
||||||
|
<div class="card full records-card">
|
||||||
|
<h2>交易记录 & 错过机会</h2>
|
||||||
|
<div class="form-row" style="margin-bottom:10px;gap:8px">
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
||||||
|
<input id="review-mode-toggle" type="checkbox">
|
||||||
|
修改/核对开关(开启后可编辑关键字段)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<tr><th>品种</th><th>类型</th><th>方向</th><th>成交</th><th>止损(开仓)</th><th>止盈</th><th>基数</th><th>杠杆</th><th>持仓分钟</th><th>开仓时间(北京)</th><th>平仓时间(北京)</th><th>盈亏U</th><th>结果</th><th>操作</th></tr>
|
||||||
|
{% for r in record %}
|
||||||
|
<tr id="trade-row-{{ r.id }}">
|
||||||
|
{% set pnl_val = (r.pnl_amount or 0)|float %}
|
||||||
|
<td>{{ r.symbol }}</td>
|
||||||
|
<td>{{ r.monitor_type }}{% if r.key_signal_type %} · {{ r.key_signal_type }}{% endif %}</td>
|
||||||
|
<td><span class="badge {{ 'direction-long' if r.direction == 'long' else 'direction-short' }}">{{ '做多' if r.direction == 'long' else '做空' }}</span></td>
|
||||||
|
<td>{{ price_fmt(r.symbol, r.trigger_price) }}</td>
|
||||||
|
{% set stop_show = r.display_open_stop_loss or r.initial_stop_loss or r.stop_loss %}
|
||||||
|
{% set tp_show = r.effective_take_profit or r.take_profit %}
|
||||||
|
<td>{{ price_fmt(r.symbol, stop_show) }}</td>
|
||||||
|
<td>{{ price_fmt(r.symbol, tp_show) }}</td>
|
||||||
|
<td>{% if r.margin_capital is not none and r.margin_capital != '' %}{{ funds_fmt(r.margin_capital) }}{% else %}-{% endif %}</td>
|
||||||
|
<td>{{ r.leverage or '-' }}</td>
|
||||||
|
<td>{{ r.effective_hold_minutes or 0 }}</td>
|
||||||
|
<td>{{ (r.effective_opened_at or '-')[:16] }}</td>
|
||||||
|
<td>{{ (r.effective_closed_at or r.created_at or '-')[:16] }}</td>
|
||||||
|
{% set pnl_val = (r.effective_pnl_amount or 0)|float %}
|
||||||
|
<td><span class="{{ 'pnl-profit' if pnl_val > 0 else ('pnl-loss' if pnl_val < 0 else '') }}">{{ funds_fmt(r.effective_pnl_amount or 0) }}</span>{% if r.display_pnl_source == 'exchange' %}<span style="font-size:.68rem;color:#6ab88a">所</span>{% elif r.display_pnl_source != 'reviewed' %}<span style="font-size:.68rem;color:#8892b0">估</span>{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
{% set effective_result = r.effective_result %}
|
||||||
|
{% if effective_result in ["止盈","保本止盈","移动止盈"] %}<span class="badge profit">{{ effective_result }}</span>
|
||||||
|
{% elif effective_result in ["止损","强制清仓","手动平仓"] %}<span class="badge loss">{{ effective_result }}</span>
|
||||||
|
{% elif effective_result == "时间平仓" %}<span class="badge miss">{{ effective_result }}</span>
|
||||||
|
{% else %}<span class="badge miss">{{ effective_result }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="table-del"
|
||||||
|
style="background:#1f3a5a;color:#8fc8ff;margin-right:6px"
|
||||||
|
onclick='fillJournalFromTrade({{ {
|
||||||
|
"symbol": r.symbol,
|
||||||
|
"monitor_type": r.monitor_type,
|
||||||
|
"key_signal_type": r.key_signal_type or "",
|
||||||
|
"direction": r.direction,
|
||||||
|
"trigger_price": r.trigger_price,
|
||||||
|
"stop_loss": r.display_open_stop_loss or r.initial_stop_loss or r.stop_loss,
|
||||||
|
"take_profit": r.effective_take_profit or r.take_profit,
|
||||||
|
"opened_at": r.effective_opened_at,
|
||||||
|
"closed_at": r.effective_closed_at,
|
||||||
|
"pnl_amount": r.effective_pnl_amount,
|
||||||
|
"result": r.effective_result,
|
||||||
|
"risk_amount": r.risk_amount
|
||||||
|
}|tojson|safe }})'
|
||||||
|
>填入复盘</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="table-del review-edit-btn"
|
||||||
|
style="background:#1f3a5a;color:#8fc8ff;margin-right:6px"
|
||||||
|
onclick='editTradeRecordReview({{ {
|
||||||
|
"id": r.id,
|
||||||
|
"opened_at": r.effective_opened_at,
|
||||||
|
"closed_at": r.effective_closed_at,
|
||||||
|
"stop_loss": r.effective_stop_loss or r.initial_stop_loss or r.stop_loss,
|
||||||
|
"take_profit": r.effective_take_profit or r.take_profit,
|
||||||
|
"pnl_amount": r.effective_pnl_amount,
|
||||||
|
"result": r.effective_result,
|
||||||
|
"miss_reason": r.effective_miss_reason,
|
||||||
|
"effective_entry_reason": r.effective_entry_reason or ""
|
||||||
|
}|tojson|safe }})'
|
||||||
|
disabled
|
||||||
|
>核对修改</button>
|
||||||
|
<button type="button" class="table-del" onclick="deleteTradeRecord({{ r.id }})">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card miss-card" style="opacity:.78">
|
||||||
|
<h2>记录错过机会</h2>
|
||||||
|
<form action="/add_miss" method="post" class="form-row">
|
||||||
|
<input name="symbol" placeholder="品种" required>
|
||||||
|
<select name="type" required>
|
||||||
|
<option value="箱体突破">箱体突破</option>
|
||||||
|
<option value="收敛突破">收敛突破</option>
|
||||||
|
<option value="关键支撑阻力">关键支撑阻力</option>
|
||||||
|
</select>
|
||||||
|
<select name="direction" required>
|
||||||
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
|
</select>
|
||||||
|
<input name="tp" step="0.0001" placeholder="入场价" required>
|
||||||
|
<input name="sl" step="any" placeholder="止损" required>
|
||||||
|
<input name="tgt" step="any" placeholder="止盈" required>
|
||||||
|
<input name="reason" placeholder="错过原因" required>
|
||||||
|
<button type="submit">记录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card journal-card">
|
||||||
|
<h2>交易复盘记录上传(含截图)</h2>
|
||||||
|
<form id="journal-form" action="/add_journal" method="post" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="risk_amount_hint" id="risk-amount-hint">
|
||||||
|
<input type="hidden" name="entry_price_hint" id="entry-price-hint">
|
||||||
|
<input type="hidden" name="stop_loss_hint" id="stop-loss-hint">
|
||||||
|
<input type="hidden" name="exit_price_hint" id="exit-price-hint">
|
||||||
|
<input type="hidden" name="direction_hint" id="direction-hint">
|
||||||
|
<div class="form-grid">
|
||||||
|
<input type="datetime-local" name="open_datetime" required>
|
||||||
|
<input type="datetime-local" name="close_datetime" required>
|
||||||
|
<input name="coin" placeholder="BTC" required>
|
||||||
|
<input name="tf" placeholder="5m" required>
|
||||||
|
<input name="pnl" placeholder="盈亏(U)" required>
|
||||||
|
<select name="entry_reason" id="journal-entry-reason" required title="固定五种或选其他手写">
|
||||||
|
<option value="">开仓类型(必选)</option>
|
||||||
|
{% for er in entry_reason_options %}
|
||||||
|
<option value="{{ er }}">{{ er }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
<option value="{{ entry_reason_other_value }}">其他(自定义,见下方说明框)</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" name="entry_reason_custom" id="journal-entry-reason-custom" maxlength="2000" placeholder="选「其他」时在此填写开仓类型说明" autocomplete="off" style="display:none">
|
||||||
|
<input name="expect_rr" placeholder="预期RR">
|
||||||
|
<input name="real_rr" placeholder="实际RR">
|
||||||
|
<select name="early_exit_trigger" required title="平仓如何触发">
|
||||||
|
<option value="">离场触发(必选)</option>
|
||||||
|
<option value="止盈">止盈</option>
|
||||||
|
<option value="保本止盈">保本止盈</option>
|
||||||
|
<option value="移动止盈">移动止盈</option>
|
||||||
|
<option value="时间平仓">时间平仓</option>
|
||||||
|
<option value="手动平仓">手动平仓</option>
|
||||||
|
<option value="止损">止损</option>
|
||||||
|
<option value="其他">其他</option>
|
||||||
|
</select>
|
||||||
|
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)">
|
||||||
|
<select name="post_breakeven_stare"><option value="否">保本后盯盘:否</option><option value="是">保本后盯盘:是</option></select>
|
||||||
|
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
|
||||||
|
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="margin-top:8px;flex-wrap:wrap;gap:10px;align-items:center">
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
||||||
|
<input type="checkbox" name="journal_exchange_chart" value="true" checked>
|
||||||
|
保存时自动生成 K 线图并作为截图
|
||||||
|
</label>
|
||||||
|
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
||||||
|
<select name="journal_chart_tf1" style="min-width:72px">
|
||||||
|
{% for tf in journal_chart_tf_choices %}
|
||||||
|
<option value="{{ tf }}" {% if tf == journal_chart_default_tf1 %}selected{% endif %}>{{ tf }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label style="font-size:.82rem;color:#9aa">周期2</label>
|
||||||
|
<select name="journal_chart_tf2" style="min-width:72px">
|
||||||
|
{% for tf in journal_chart_tf_choices %}
|
||||||
|
<option value="{{ tf }}" {% if tf == journal_chart_default_tf2 %}selected{% endif %}>{{ tf }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label style="font-size:.82rem;color:#9aa">K线数</label>
|
||||||
|
<select name="journal_chart_limit" style="min-width:72px">
|
||||||
|
{% for n in [100, 150, 200, 250, 300, 400, 500] %}
|
||||||
|
<option value="{{ n }}" {% if n == journal_chart_default_limit %}selected{% endif %}>{{ n }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label style="font-size:.82rem;color:#9aa">K线截止</label>
|
||||||
|
<select name="journal_chart_anchor" id="journal-chart-anchor" style="min-width:96px" title="K线窗口右端对齐的时间">
|
||||||
|
<option value="close" {% if journal_chart_default_anchor == 'close' %}selected{% endif %}>平仓时间</option>
|
||||||
|
<option value="now" {% if journal_chart_default_anchor == 'now' %}selected{% endif %}>当前时间</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="sub" id="journal-chart-anchor-hint" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位</div>
|
||||||
|
<div class="form-row" style="margin-top:8px">
|
||||||
|
<button type="button" style="background:#1f3a5a" onclick="prefillJournalByImage()">AI识别预填(你再手动改原因)</button>
|
||||||
|
</div>
|
||||||
|
<div class="mood-grid" style="margin-top:8px">
|
||||||
|
<label><input type="checkbox" name="mood_issues" value="怕踏空">怕踏空</label>
|
||||||
|
<label><input type="checkbox" name="mood_issues" value="报复开仓">报复开仓</label>
|
||||||
|
<label><input type="checkbox" name="mood_issues" value="盈利飘了">盈利飘了</label>
|
||||||
|
<label><input type="checkbox" name="mood_issues" value="拿不住单">拿不住单</label>
|
||||||
|
<label><input type="checkbox" name="mood_issues" value="扛单">扛单</label>
|
||||||
|
<label><input type="checkbox" name="mood_issues" value="重仓违规">重仓违规</label>
|
||||||
|
</div>
|
||||||
|
<textarea name="note" rows="2" style="width:100%;margin-top:8px" placeholder="备注"></textarea>
|
||||||
|
<button type="submit" style="margin-top:8px">保存复盘记录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card full review-card" id="review-card">
|
||||||
|
<div class="review-card-head">
|
||||||
|
<h2>AI复盘(按交易记录)</h2>
|
||||||
|
<button type="button" class="review-card-fs-btn" id="review-card-fs-btn" onclick="toggleReviewCardFullscreen()">全屏</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<input type="date" id="day_date">
|
||||||
|
<button type="button" id="gen-daily-btn" onclick="genDaily()">生成日复盘</button>
|
||||||
|
<button type="button" onclick="exportDailyBundleMd()" style="background:#1f3a5a">导出当日日复盘MD</button>
|
||||||
|
<input type="date" id="week_start">
|
||||||
|
<input type="date" id="week_end">
|
||||||
|
<button type="button" id="gen-weekly-btn" onclick="genWeekly()">生成周复盘</button>
|
||||||
|
<button type="button" onclick="exportWeeklyBundleMd()" style="background:#1f3a5a">导出当周复盘MD</button>
|
||||||
|
</div>
|
||||||
|
<div class="ai-result-wrap" id="daily_result_wrap" style="display:none">
|
||||||
|
<div id="daily_result" class="ai-result"></div>
|
||||||
|
<div class="ai-result-toolbar">
|
||||||
|
<button type="button" class="btn-fs" onclick="openAiInlineResultFullscreen('日复盘结果', 'daily_result')">全屏查看</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ai-result-wrap" id="weekly_result_wrap" style="display:none">
|
||||||
|
<div id="weekly_result" class="ai-result"></div>
|
||||||
|
<div class="ai-result-toolbar">
|
||||||
|
<button type="button" class="btn-fs" onclick="openAiInlineResultFullscreen('周复盘结果', 'weekly_result')">全屏查看</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-list" style="margin-top:10px">
|
||||||
|
<div class="panel-item">
|
||||||
|
<strong>交易复盘记录</strong>
|
||||||
|
<div id="journal-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-item">
|
||||||
|
<strong>AI历史复盘</strong>
|
||||||
|
<div id="review-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if page == 'stats' %}
|
||||||
|
<div class="card stats-card full" id="stats-card">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">
|
||||||
|
<h2 style="margin-bottom:0">数据统计</h2>
|
||||||
|
<button type="button" class="stats-toggle" id="stats-toggle-btn" onclick="toggleStatsCard()">折叠</button>
|
||||||
|
</div>
|
||||||
|
<div class="stats-content" id="stats-content">
|
||||||
|
<div class="stat-box" style="margin-bottom:10px">
|
||||||
|
<div class="stat-item"><div class="label">持仓占用导致错过(累计)</div><div class="value">{{ occupied_miss_total }}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="sub" style="margin-bottom:12px;color:#8892b0;font-size:.82rem">
|
||||||
|
统计分析按<strong>北京时间 {{ stats_bundle.stats_reset_hour }}:00</strong>切日计入(与顶栏 UTC 列表窗无关)。历史总开仓(累计):
|
||||||
|
<strong style="color:#cfd3ef">{{ stats_bundle.total_opens_all }}</strong> 次
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="margin-bottom:14px;align-items:center">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;font-size:.88rem;color:#cfd3ef">
|
||||||
|
统计品类
|
||||||
|
<select id="stats-segment-select" onchange="switchStatsSegment()" style="min-width:200px">
|
||||||
|
{% for seg in stats_bundle.segments %}
|
||||||
|
<option value="{{ seg.key }}">{{ seg.title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% for seg in stats_bundle.segments %}
|
||||||
|
<div class="stats-segment-block stats-segment-panel" data-stats-segment="{{ seg.key }}"{% if not loop.first %} style="display:none"{% endif %}>
|
||||||
|
{{ period_stats("日统计", seg.day) }}
|
||||||
|
{{ period_stats("周统计", seg.week) }}
|
||||||
|
{{ period_stats("月统计", seg.month) }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<script src="/static/instance_theme.js?v=47"></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=2">
|
||||||
|
<link rel="stylesheet" href="/static/instance_theme.css?v=48">
|
||||||
|
<script src="/static/account_risk_badge.js?v=4"></script>
|
||||||
|
<meta name="theme-color" content="#0b0d14">
|
||||||
|
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
data-embed-shell="1"
|
||||||
|
data-risk-percent="{{ risk_percent }}"
|
||||||
|
data-page="{{ initial_tab }}"
|
||||||
|
data-position-sizing-mode="{{ position_sizing_mode }}"
|
||||||
|
data-btc-leverage="{{ btc_leverage }}"
|
||||||
|
data-alt-leverage="{{ alt_leverage }}"
|
||||||
|
data-full-margin-buffer="{{ full_margin_buffer_ratio }}"
|
||||||
|
data-balance-refresh-ms="{{ balance_refresh_seconds * 1000 }}"
|
||||||
|
data-price-refresh-ms="{{ price_refresh_seconds * 1000 }}"
|
||||||
|
>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
||||||
|
<div class="header-row">
|
||||||
|
<div class="exchange-tag">{{ exchange_display }}</div>
|
||||||
|
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}" data-status-label="{{ risk_status.status_label|default('正常') }}"{% if risk_status.freeze_until_ms %} data-freeze-until-ms="{{ risk_status.freeze_until_ms }}"{% endif %}>{{ risk_status.status_label|default('正常') }}</span>
|
||||||
|
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||||
|
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||||
|
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||||
|
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
|
||||||
|
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav class="top-nav embed-top-nav" aria-label="实例导航">
|
||||||
|
<a href="/key_monitor" data-embed-tab="key_monitor" class="{% if initial_tab == 'key_monitor' %}active{% endif %}">关键位监控</a>
|
||||||
|
<a href="/trade" data-embed-tab="trade" class="{% if initial_tab == 'trade' %}active{% endif %}">实盘下单</a>
|
||||||
|
<a href="/strategy" data-embed-tab="strategy" class="{% if initial_tab == 'strategy' %}active{% endif %}">策略交易</a>
|
||||||
|
<a href="/strategy/records" data-embed-tab="strategy_records" class="{% if initial_tab == 'strategy_records' %}active{% endif %}">策略交易记录</a>
|
||||||
|
<a href="/records" data-embed-tab="records" class="{% if initial_tab == 'records' %}active{% endif %}">交易记录与复盘</a>
|
||||||
|
<a href="/stats" data-embed-tab="stats" class="{% if initial_tab == 'stats' %}active{% endif %}">统计分析</a>
|
||||||
|
</nav>
|
||||||
|
<div id="embed-flash" class="flash" style="display:none" role="status"></div>
|
||||||
|
|
||||||
|
<div class="list-window-bar">
|
||||||
|
<span style="color:#cfd3ef">列表筛选(<strong>UTC</strong>,默认当日):{{ list_window.label }}</span>
|
||||||
|
<label>预设
|
||||||
|
<select id="win-preset-select" onchange="toggleListWindowCustom()">
|
||||||
|
<option value="utc_today" {% if list_window.preset == 'utc_today' %}selected{% endif %}>UTC 当日</option>
|
||||||
|
<option value="utc_last24h" {% if list_window.preset == 'utc_last24h' %}selected{% endif %}>近 24 小时</option>
|
||||||
|
<option value="utc_last7d" {% if list_window.preset == 'utc_last7d' %}selected{% endif %}>近 7 天</option>
|
||||||
|
<option value="custom" {% if list_window.preset == 'custom' %}selected{% endif %}>自定义</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span id="win-custom-range" style="{% if list_window.preset != 'custom' %}display:none{% endif %}">
|
||||||
|
<label>起(UTC) <input type="datetime-local" id="win-from-utc" value="{{ list_window.start_utc.strftime('%Y-%m-%dT%H:%M') }}"></label>
|
||||||
|
<label>止(UTC) <input type="datetime-local" id="win-to-utc" value="{{ list_window.end_utc.strftime('%Y-%m-%dT%H:%M') }}"></label>
|
||||||
|
</span>
|
||||||
|
<button type="button" style="padding:6px 12px" onclick="applyListWindow()">应用</button>
|
||||||
|
<span style="color:#8892b0;font-size:.75rem">统计页仍按北京时间 {{ stats_bundle.stats_reset_hour|default(reset_hour) }}:00 切日</span>
|
||||||
|
</div>
|
||||||
|
<div class="export-bar instance-desktop-only">
|
||||||
|
<span style="color:#9aa">数据导出(v{{ data_export_version }} CSV,UTF-8;交易记录含开仓类型列,复盘单独导出):</span>
|
||||||
|
<a href="/export/trade_records">交易记录</a>
|
||||||
|
<a href="/export/journal_entries">复盘记录</a>
|
||||||
|
<a href="/export/key_monitors">关键位(当前)</a>
|
||||||
|
<a href="/export/key_monitor_history">关键位历史</a>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box instance-desktop-only">
|
||||||
|
<div class="stat-item"><div class="label">交易所</div><div class="value">{{ exchange_display }}</div></div>
|
||||||
|
<div class="stat-item"><div class="label">总交易</div><div class="value" id="stat-total">{{ total }}</div></div>
|
||||||
|
<div class="stat-item"><div class="label">错过次数</div><div class="value" id="stat-miss">{{ miss_count }}</div></div>
|
||||||
|
<div class="stat-item"><div class="label">胜率</div><div class="value" id="stat-rate">{{ rate }}%</div></div>
|
||||||
|
<div class="stat-item"><div class="label">资金账户(USDT)</div><div class="value" id="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</div></div>
|
||||||
|
<div class="stat-item"><div class="label">交易日</div><div class="value">{{ trading_day }}</div></div>
|
||||||
|
<div class="stat-item"><div class="label">当日资金(交易账户)</div><div class="value" id="current-capital">{{ funds_fmt(current_capital) }}U</div></div>
|
||||||
|
</div>
|
||||||
|
{% if include_transfer_block %}
|
||||||
|
{% include 'gate_transfer_block.html' %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div id="embed-page-root">
|
||||||
|
{% include 'embed_page_fragment.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="imgModal" onclick="closeModal()">
|
||||||
|
<img id="bigImg" src="" alt="screenshot">
|
||||||
|
</div>
|
||||||
|
<div class="detail-modal" id="detailModal" onclick="closeDetailModal(event)">
|
||||||
|
<div class="panel" onclick="event.stopPropagation()">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title" id="detailTitle">详情</div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button type="button" class="panel-fs" onclick="expandDetailToFullscreen()">全屏</button>
|
||||||
|
<button type="button" class="panel-close" onclick="forceCloseDetailModal()">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body" id="detailBody"></div>
|
||||||
|
<img id="detailImage" class="panel-image" src="" alt="detail-image" style="display:none" onclick="showImage(this.src)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/instance_ui.js?v=4"></script>
|
||||||
|
<script src="/static/instance_records_mobile.js?v=2"></script>
|
||||||
|
<script src="/static/time_close_ui.js?v=2"></script>
|
||||||
|
<script src="/static/ai_review_render.js?v=2"></script>
|
||||||
|
<script src="/static/form_submit_guard.js?v=2"></script>
|
||||||
|
<script src="/static/manual_order_rr_preview.js?v=5"></script>
|
||||||
|
<script src="/static/strategy_roll.js?v=6"></script>
|
||||||
|
<script src="/static/key_monitor_form.js?v=2"></script>
|
||||||
|
{% include 'embed_boot_scripts.html' %}
|
||||||
|
<script src="/static/instance_embed.js?v=6"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Shared library package."""
|
||||||
+1
-1
@@ -17,7 +17,7 @@ def is_false_breakout_key_monitor_type(monitor_type: Optional[str]) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def is_limit_key_monitor_type(monitor_type: Optional[str]) -> bool:
|
def is_limit_key_monitor_type(monitor_type: Optional[str]) -> bool:
|
||||||
from fib_key_monitor_lib import is_fib_key_monitor_type
|
from lib.key_monitor.fib_key_monitor_lib import is_fib_key_monitor_type
|
||||||
|
|
||||||
return is_fib_key_monitor_type(monitor_type) or is_false_breakout_key_monitor_type(monitor_type)
|
return is_fib_key_monitor_type(monitor_type) or is_false_breakout_key_monitor_type(monitor_type)
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""斐波关键位监控:纯计算与类型判断(Gate / Binance 主站共用)。"""
|
"""斐波关键位监控:纯计算与类型判断(Gate / Binance 主站共用)。"""
|
||||||
|
|
||||||
from key_monitor_lib import KEY_MONITOR_AUTO_TYPES
|
from lib.key_monitor.key_monitor_lib import KEY_MONITOR_AUTO_TYPES
|
||||||
|
|
||||||
FIB_KEY_MONITOR_TYPES = frozenset({"斐波回调0.618", "斐波回调0.786"})
|
FIB_KEY_MONITOR_TYPES = frozenset({"斐波回调0.618", "斐波回调0.786"})
|
||||||
KEY_MONITOR_TRADE_TYPE = "关键位监控"
|
KEY_MONITOR_TRADE_TYPE = "关键位监控"
|
||||||
@@ -48,8 +48,8 @@ def stored_key_signal_type(monitor_type):
|
|||||||
mt = (monitor_type or "").strip()
|
mt = (monitor_type or "").strip()
|
||||||
if mt in FIB_KEY_MONITOR_TYPES:
|
if mt in FIB_KEY_MONITOR_TYPES:
|
||||||
return mt
|
return mt
|
||||||
if mt in ("假突破", "触价开仓"):
|
if mt in ("假突破", "回调触价开仓", "突破触价开仓", "触价开仓"):
|
||||||
return mt
|
return mt if mt != "触价开仓" else "回调触价开仓"
|
||||||
if mt in KEY_MONITOR_AUTO_TYPES:
|
if mt in KEY_MONITOR_AUTO_TYPES:
|
||||||
return mt
|
return mt
|
||||||
return None
|
return None
|
||||||
@@ -61,6 +61,8 @@ KEY_ENTRY_REASON_BY_SIGNAL = {
|
|||||||
"斐波回调0.618": "关键位斐波0.618",
|
"斐波回调0.618": "关键位斐波0.618",
|
||||||
"斐波回调0.786": "关键位斐波0.786",
|
"斐波回调0.786": "关键位斐波0.786",
|
||||||
"假突破": "关键位假突破",
|
"假突破": "关键位假突破",
|
||||||
|
"回调触价开仓": "关键位回调触价开仓",
|
||||||
|
"突破触价开仓": "关键位突破触价开仓",
|
||||||
"触价开仓": "关键位触价开仓",
|
"触价开仓": "关键位触价开仓",
|
||||||
"趋势回调": "趋势回调",
|
"趋势回调": "趋势回调",
|
||||||
}
|
}
|
||||||
@@ -75,10 +77,8 @@ def key_signal_type_for_trade_record(key_signal_type, box_auto_types):
|
|||||||
kst = (key_signal_type or "").strip()
|
kst = (key_signal_type or "").strip()
|
||||||
if kst in FIB_KEY_MONITOR_TYPES:
|
if kst in FIB_KEY_MONITOR_TYPES:
|
||||||
return kst
|
return kst
|
||||||
if kst == "假突破":
|
if kst in ("假突破", "回调触价开仓", "突破触价开仓", "触价开仓"):
|
||||||
return kst
|
return kst if kst != "触价开仓" else "回调触价开仓"
|
||||||
if kst == "触价开仓":
|
|
||||||
return kst
|
|
||||||
if box_auto_types and kst in box_auto_types:
|
if box_auto_types and kst in box_auto_types:
|
||||||
return kst
|
return kst
|
||||||
return None
|
return None
|
||||||
@@ -5,10 +5,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any, Callable, Iterable, Optional
|
from typing import Any, Callable, Iterable, Optional
|
||||||
|
|
||||||
from fib_key_monitor_lib import FIB_KEY_MONITOR_TYPES, is_fib_key_monitor_type
|
from lib.key_monitor.fib_key_monitor_lib import FIB_KEY_MONITOR_TYPES, is_fib_key_monitor_type
|
||||||
from false_breakout_key_monitor_lib import is_false_breakout_key_monitor_type
|
from lib.key_monitor.false_breakout_key_monitor_lib import is_false_breakout_key_monitor_type
|
||||||
from key_monitor_lib import KEY_MONITOR_AUTO_TYPES
|
from lib.key_monitor.key_monitor_lib import KEY_MONITOR_AUTO_TYPES
|
||||||
from position_sizing_lib import is_full_margin_mode, mode_label_zh
|
from lib.trade.position_sizing_lib import is_full_margin_mode, mode_label_zh
|
||||||
|
|
||||||
|
|
||||||
def monitor_type_disallowed_in_full_margin(monitor_type: str) -> bool:
|
def monitor_type_disallowed_in_full_margin(monitor_type: str) -> bool:
|
||||||
@@ -7,11 +7,30 @@ from datetime import datetime
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"})
|
KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"})
|
||||||
KEY_MONITOR_RS_TYPES = frozenset({"关键阻力位", "关键支撑位"})
|
KEY_MONITOR_RS_TYPE = "关键支撑阻力"
|
||||||
KEY_MONITOR_ALERT_ONLY_TYPES = KEY_MONITOR_RS_TYPES
|
KEY_MONITOR_RS_LEGACY_TYPES = frozenset({"关键阻力位", "关键支撑位"})
|
||||||
|
KEY_MONITOR_RS_TYPES = frozenset({KEY_MONITOR_RS_TYPE}) | KEY_MONITOR_RS_LEGACY_TYPES
|
||||||
|
KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({KEY_MONITOR_RS_TYPE}) | KEY_MONITOR_RS_LEGACY_TYPES
|
||||||
KEY_DIRECTION_WATCH = "watch"
|
KEY_DIRECTION_WATCH = "watch"
|
||||||
|
|
||||||
|
|
||||||
|
def is_rs_key_monitor_type(monitor_type: str) -> bool:
|
||||||
|
return (monitor_type or "").strip() in KEY_MONITOR_RS_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
def rs_monitor_type_label(monitor_type: str) -> str:
|
||||||
|
"""展示用:旧库里的阻力/支撑合并为「关键支撑阻力」。"""
|
||||||
|
if is_rs_key_monitor_type(monitor_type):
|
||||||
|
return KEY_MONITOR_RS_TYPE
|
||||||
|
return (monitor_type or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def rs_monitor_type_for_storage(monitor_type: str) -> str:
|
||||||
|
if is_rs_key_monitor_type(monitor_type):
|
||||||
|
return KEY_MONITOR_RS_TYPE
|
||||||
|
return (monitor_type or "").strip()
|
||||||
|
|
||||||
|
|
||||||
def calc_breakout_breach_pct(direction: str, close: float, upper: float, lower: float) -> float:
|
def calc_breakout_breach_pct(direction: str, close: float, upper: float, lower: float) -> float:
|
||||||
"""突破 K 收盘相对关键位的越过幅度(%)。未越过对应边界时返回 0。"""
|
"""突破 K 收盘相对关键位的越过幅度(%)。未越过对应边界时返回 0。"""
|
||||||
direction = (direction or "long").strip().lower()
|
direction = (direction or "long").strip().lower()
|
||||||
@@ -47,6 +66,30 @@ def auto_confirm_ok(direction: str, cfm_close: float, upper: float, lower: float
|
|||||||
return c < float(lower)
|
return c < float(lower)
|
||||||
|
|
||||||
|
|
||||||
|
BOX_BREAKOUT_CLOSE_OPPOSITE = "box_opposite_break"
|
||||||
|
|
||||||
|
|
||||||
|
def box_breakout_invalidate_by_mark(
|
||||||
|
direction: str, mark_price: float, upper: float, lower: float
|
||||||
|
) -> bool:
|
||||||
|
"""箱体/收敛:标记价先突破反向边界则失效。多:mark<=L;空:mark>=H。"""
|
||||||
|
try:
|
||||||
|
m = float(mark_price)
|
||||||
|
h = float(upper)
|
||||||
|
lo = float(lower)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
direction = (direction or "long").strip().lower()
|
||||||
|
if direction == "short":
|
||||||
|
return m >= h
|
||||||
|
return m <= lo
|
||||||
|
|
||||||
|
|
||||||
|
def box_breakout_invalidate_edge_label(direction: str) -> str:
|
||||||
|
direction = (direction or "long").strip().lower()
|
||||||
|
return "下沿" if direction == "long" else "上沿"
|
||||||
|
|
||||||
|
|
||||||
def detect_rs_box_break(close: float, upper: float, lower: float) -> Optional[dict[str, Any]]:
|
def detect_rs_box_break(close: float, upper: float, lower: float) -> Optional[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
阻力/支撑人工盯盘:最近 5m 收盘突破上沿或下沿(严格 > / <)。
|
阻力/支撑人工盯盘:最近 5m 收盘突破上沿或下沿(严格 > / <)。
|
||||||
@@ -313,12 +356,12 @@ def key_monitor_rule_template_context(
|
|||||||
trigger_entry_validity_hours: int | None = None,
|
trigger_entry_validity_hours: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""关键位监控页规则说明表格(Jinja key_rule_ctx)。"""
|
"""关键位监控页规则说明表格(Jinja key_rule_ctx)。"""
|
||||||
from false_breakout_key_monitor_lib import (
|
from lib.key_monitor.false_breakout_key_monitor_lib import (
|
||||||
FALSE_BREAKOUT_OFFSET_PCT,
|
FALSE_BREAKOUT_OFFSET_PCT,
|
||||||
FALSE_BREAKOUT_RR,
|
FALSE_BREAKOUT_RR,
|
||||||
FALSE_BREAKOUT_SL_PCT,
|
FALSE_BREAKOUT_SL_PCT,
|
||||||
)
|
)
|
||||||
from trigger_entry_key_monitor_lib import TRIGGER_ENTRY_VALIDITY_HOURS
|
from lib.key_monitor.trigger_entry_key_monitor_lib import TRIGGER_ENTRY_VALIDITY_HOURS
|
||||||
|
|
||||||
te_hours = (
|
te_hours = (
|
||||||
int(trigger_entry_validity_hours)
|
int(trigger_entry_validity_hours)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""关键位监控表结构迁移(四所共用)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_key_monitor_schema(conn: Any) -> None:
|
||||||
|
for sql in (
|
||||||
|
"ALTER TABLE key_monitors ADD COLUMN last_mark_price REAL",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
conn.execute(sql)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"""回调/突破触价开仓关键位监控:程序盯价、触达计划入场后市价成交(四所共用逻辑)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
from lib.key_monitor.false_breakout_key_monitor_lib import (
|
||||||
|
_parse_created_at,
|
||||||
|
expires_at_text,
|
||||||
|
is_false_breakout_expired,
|
||||||
|
)
|
||||||
|
from lib.strategy.strategy_trend_lib import trend_dca_level_reached
|
||||||
|
|
||||||
|
# 回调触价(原「触价开仓」)
|
||||||
|
CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE = "回调触价开仓"
|
||||||
|
LEGACY_TRIGGER_ENTRY_MONITOR_TYPE = "触价开仓"
|
||||||
|
|
||||||
|
# 突破触价:标记价穿越 E 后立即市价开仓
|
||||||
|
BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE = "突破触价开仓"
|
||||||
|
|
||||||
|
TRIGGER_ENTRY_MONITOR_TYPES = frozenset(
|
||||||
|
{
|
||||||
|
CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE,
|
||||||
|
BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE,
|
||||||
|
LEGACY_TRIGGER_ENTRY_MONITOR_TYPE,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
TRIGGER_ENTRY_VALIDITY_HOURS = 24
|
||||||
|
TRIGGER_ENTRY_CLOSE_FILLED = "trigger_entry_filled"
|
||||||
|
TRIGGER_ENTRY_CLOSE_TP_INVALIDATE = "trigger_tp_invalidate"
|
||||||
|
TRIGGER_ENTRY_CLOSE_SL_INVALIDATE = "trigger_sl_invalidate"
|
||||||
|
TRIGGER_ENTRY_CLOSE_EXPIRED = "trigger_entry_expired"
|
||||||
|
TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED = "trigger_exchange_failed"
|
||||||
|
|
||||||
|
KEY_ENTRY_REASON_CALLBACK = "关键位回调触价开仓"
|
||||||
|
KEY_ENTRY_REASON_BREAKOUT = "关键位突破触价开仓"
|
||||||
|
KEY_ENTRY_REASON_TRIGGER_LEGACY = "关键位触价开仓"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_trigger_entry_monitor_type(monitor_type: Optional[str]) -> str:
|
||||||
|
mt = (monitor_type or "").strip()
|
||||||
|
if mt == LEGACY_TRIGGER_ENTRY_MONITOR_TYPE:
|
||||||
|
return CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE
|
||||||
|
return mt
|
||||||
|
|
||||||
|
|
||||||
|
def is_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool:
|
||||||
|
return (monitor_type or "").strip() in TRIGGER_ENTRY_MONITOR_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
def is_callback_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool:
|
||||||
|
mt = normalize_trigger_entry_monitor_type(monitor_type)
|
||||||
|
return mt == CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
def is_breakout_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool:
|
||||||
|
return (monitor_type or "").strip() == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
def key_entry_reason_for_monitor_type(monitor_type: Optional[str]) -> str:
|
||||||
|
if is_breakout_trigger_entry_key_monitor_type(monitor_type):
|
||||||
|
return KEY_ENTRY_REASON_BREAKOUT
|
||||||
|
if is_trigger_entry_key_monitor_type(monitor_type):
|
||||||
|
return KEY_ENTRY_REASON_CALLBACK
|
||||||
|
return KEY_ENTRY_REASON_TRIGGER_LEGACY
|
||||||
|
|
||||||
|
|
||||||
|
def trigger_entry_reached(direction: str, mark_price: float, entry: float) -> bool:
|
||||||
|
"""回调触价:多=价跌至 E;空=价涨至 E。"""
|
||||||
|
return trend_dca_level_reached(direction, mark_price, entry)
|
||||||
|
|
||||||
|
|
||||||
|
def breakout_trigger_entry_crossed(
|
||||||
|
direction: str,
|
||||||
|
prev_mark: Optional[float],
|
||||||
|
mark: float,
|
||||||
|
entry: float,
|
||||||
|
) -> bool:
|
||||||
|
"""突破触价:多=向上穿越 E;空=向下穿越 E。"""
|
||||||
|
try:
|
||||||
|
m = float(mark)
|
||||||
|
e = float(entry)
|
||||||
|
pm = float(prev_mark) if prev_mark is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
direction = (direction or "long").strip().lower()
|
||||||
|
if direction == "long":
|
||||||
|
if pm is None:
|
||||||
|
return m > e
|
||||||
|
return pm <= e and m > e
|
||||||
|
if pm is None:
|
||||||
|
return m < e
|
||||||
|
return pm >= e and m < e
|
||||||
|
|
||||||
|
|
||||||
|
def trigger_should_fire(
|
||||||
|
monitor_type: Optional[str],
|
||||||
|
direction: str,
|
||||||
|
mark: float,
|
||||||
|
entry: float,
|
||||||
|
prev_mark: Optional[float] = None,
|
||||||
|
) -> bool:
|
||||||
|
if is_breakout_trigger_entry_key_monitor_type(monitor_type):
|
||||||
|
return breakout_trigger_entry_crossed(direction, prev_mark, mark, entry)
|
||||||
|
return trigger_entry_reached(direction, mark, entry)
|
||||||
|
|
||||||
|
|
||||||
|
def trigger_entry_invalidate_by_tp(direction: str, mark_price: float, take_profit: float) -> bool:
|
||||||
|
"""未开仓前标记价先触达止盈侧则失效。"""
|
||||||
|
try:
|
||||||
|
m = float(mark_price)
|
||||||
|
tp = float(take_profit)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
d = (direction or "long").strip().lower()
|
||||||
|
if d == "short":
|
||||||
|
return m <= tp
|
||||||
|
return m >= tp
|
||||||
|
|
||||||
|
|
||||||
|
def trigger_entry_invalidate_by_sl(direction: str, mark_price: float, stop_loss: float) -> bool:
|
||||||
|
"""突破触价:未到 E 先触达止损侧则失效。"""
|
||||||
|
try:
|
||||||
|
m = float(mark_price)
|
||||||
|
sl = float(stop_loss)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
d = (direction or "long").strip().lower()
|
||||||
|
if d == "long":
|
||||||
|
return m <= sl
|
||||||
|
return m >= sl
|
||||||
|
|
||||||
|
|
||||||
|
def trigger_entry_invalidate(
|
||||||
|
monitor_type: Optional[str],
|
||||||
|
direction: str,
|
||||||
|
mark: float,
|
||||||
|
stop_loss: float,
|
||||||
|
take_profit: float,
|
||||||
|
) -> Optional[str]:
|
||||||
|
if trigger_entry_invalidate_by_tp(direction, mark, take_profit):
|
||||||
|
return "tp"
|
||||||
|
if is_breakout_trigger_entry_key_monitor_type(monitor_type):
|
||||||
|
if trigger_entry_invalidate_by_sl(direction, mark, stop_loss):
|
||||||
|
return "sl"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_trigger_entry_geometry(
|
||||||
|
direction: str,
|
||||||
|
entry: float,
|
||||||
|
stop_loss: float,
|
||||||
|
take_profit: float,
|
||||||
|
mark_at_add: Optional[float] = None,
|
||||||
|
*,
|
||||||
|
monitor_type: Optional[str] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""返回错误文案;合法则 None。"""
|
||||||
|
try:
|
||||||
|
e = float(entry)
|
||||||
|
sl = float(stop_loss)
|
||||||
|
tp = float(take_profit)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "入场价、止损、止盈须为有效数字"
|
||||||
|
if e <= 0 or sl <= 0 or tp <= 0:
|
||||||
|
return "入场价、止损、止盈须大于 0"
|
||||||
|
d = (direction or "long").strip().lower()
|
||||||
|
mt = normalize_trigger_entry_monitor_type(monitor_type)
|
||||||
|
label = "突破触价开仓" if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE else "回调触价开仓"
|
||||||
|
if d == "long":
|
||||||
|
if not (sl < e < tp):
|
||||||
|
return "做多:须满足 止损 < 入场价 < 止盈"
|
||||||
|
if mark_at_add is not None:
|
||||||
|
m = float(mark_at_add)
|
||||||
|
if m >= tp:
|
||||||
|
return f"做多:当前价已不低于止盈,无法添加{label}"
|
||||||
|
if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE and m >= e:
|
||||||
|
return "做多:当前价须低于入场价(等待向上突破)"
|
||||||
|
elif d == "short":
|
||||||
|
if not (tp < e < sl):
|
||||||
|
return "做空:须满足 止盈 < 入场价 < 止损"
|
||||||
|
if mark_at_add is not None:
|
||||||
|
m = float(mark_at_add)
|
||||||
|
if m <= tp:
|
||||||
|
return f"做空:当前价已不高于止盈,无法添加{label}"
|
||||||
|
if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE and m <= e:
|
||||||
|
return "做空:当前价须高于入场价(等待向下跌破)"
|
||||||
|
else:
|
||||||
|
return "方向须为 long 或 short"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_trigger_entry_rr(
|
||||||
|
direction: str,
|
||||||
|
entry: float,
|
||||||
|
stop_loss: float,
|
||||||
|
take_profit: float,
|
||||||
|
min_rr: float,
|
||||||
|
calc_rr_ratio: Callable[..., Optional[float]],
|
||||||
|
) -> Optional[str]:
|
||||||
|
rr = calc_rr_ratio(direction, entry, stop_loss, take_profit)
|
||||||
|
if rr is None or rr <= float(min_rr):
|
||||||
|
fmt = f"{rr:.4f}" if rr is not None else "无法计算"
|
||||||
|
return f"计划盈亏比 {fmt}:1 未达要求(>{float(min_rr)}:1)"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_trigger_entry_expired(
|
||||||
|
created_at: Any,
|
||||||
|
now: datetime,
|
||||||
|
*,
|
||||||
|
hours: int = TRIGGER_ENTRY_VALIDITY_HOURS,
|
||||||
|
) -> bool:
|
||||||
|
return is_false_breakout_expired(created_at, now, hours=hours)
|
||||||
|
|
||||||
|
|
||||||
|
def trigger_entry_expires_at_text(
|
||||||
|
created_at: Any,
|
||||||
|
*,
|
||||||
|
hours: int = TRIGGER_ENTRY_VALIDITY_HOURS,
|
||||||
|
) -> str:
|
||||||
|
return expires_at_text(created_at, hours=hours)
|
||||||
|
|
||||||
|
|
||||||
|
def count_pending_trigger_entries(conn: Any, trading_day: str) -> int:
|
||||||
|
td = (trading_day or "").strip()
|
||||||
|
if not td:
|
||||||
|
return 0
|
||||||
|
placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES))
|
||||||
|
row = conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM key_monitors WHERE monitor_type IN ({placeholders}) AND session_date=?",
|
||||||
|
(*TRIGGER_ENTRY_MONITOR_TYPES, td),
|
||||||
|
).fetchone()
|
||||||
|
return int(row[0] if row else 0)
|
||||||
|
|
||||||
|
|
||||||
|
def check_trigger_entry_intent_limit(
|
||||||
|
conn: Any,
|
||||||
|
trading_day: str,
|
||||||
|
opens_today: int,
|
||||||
|
hard_limit: int,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""当日开仓意图:已成交次数 + 待触发触价条数。"""
|
||||||
|
if int(hard_limit) <= 0:
|
||||||
|
return True, ""
|
||||||
|
pending = count_pending_trigger_entries(conn, trading_day)
|
||||||
|
total = int(opens_today) + pending
|
||||||
|
if total >= int(hard_limit):
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"本交易日开仓意图已达上限(已开 {int(opens_today)} + 待触发 {pending} / 硬上限 {int(hard_limit)})",
|
||||||
|
)
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def trigger_entry_gate_preview(
|
||||||
|
*,
|
||||||
|
monitor_type: Optional[str] = None,
|
||||||
|
entry_display: str,
|
||||||
|
take_profit_display: str,
|
||||||
|
created_at: Any = None,
|
||||||
|
now: Optional[datetime] = None,
|
||||||
|
expired: bool = False,
|
||||||
|
tp_invalidated: bool = False,
|
||||||
|
sl_invalidated: bool = False,
|
||||||
|
hours: int = TRIGGER_ENTRY_VALIDITY_HOURS,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
now_dt = now or datetime.now()
|
||||||
|
is_exp = expired or is_trigger_entry_expired(created_at, now_dt, hours=hours)
|
||||||
|
exp_txt = trigger_entry_expires_at_text(created_at, hours=hours)
|
||||||
|
mt = normalize_trigger_entry_monitor_type(monitor_type)
|
||||||
|
if tp_invalidated:
|
||||||
|
status = "止盈侧失效"
|
||||||
|
elif sl_invalidated:
|
||||||
|
status = "止损侧失效"
|
||||||
|
elif is_exp:
|
||||||
|
status = "已过期"
|
||||||
|
elif mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE:
|
||||||
|
status = "突破待触发"
|
||||||
|
else:
|
||||||
|
status = "回调待触发"
|
||||||
|
mode = "突破" if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE else "回调"
|
||||||
|
metrics_parts: list[str] = [f"TP:{take_profit_display}"]
|
||||||
|
if exp_txt != "—":
|
||||||
|
metrics_parts.append(f"截至:{exp_txt}")
|
||||||
|
return {
|
||||||
|
"summary": f"{mode}触价 E={entry_display} {status}",
|
||||||
|
"metrics": " ".join(metrics_parts),
|
||||||
|
"gate_ok": not is_exp and not tp_invalidated and not sl_invalidated,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 兼容旧 import
|
||||||
|
TRIGGER_ENTRY_MONITOR_TYPE = CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE
|
||||||
|
KEY_ENTRY_REASON_TRIGGER = KEY_ENTRY_REASON_CALLBACK
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Repository path helpers for lib/ assets."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
LIB_DIR = Path(__file__).resolve().parent
|
||||||
|
REPO_ROOT = LIB_DIR.parent
|
||||||
|
|
||||||
|
|
||||||
|
def strategy_templates_dir(repo_root: str | Path | None = None) -> str:
|
||||||
|
root = Path(repo_root) if repo_root is not None else REPO_ROOT
|
||||||
|
return str(root / "lib" / "strategy" / "templates")
|
||||||
|
|
||||||
|
|
||||||
|
def embed_templates_dir(repo_root: str | Path | None = None) -> str:
|
||||||
|
root = Path(repo_root) if repo_root is not None else REPO_ROOT
|
||||||
|
return str(root / "lib" / "instance" / "templates")
|
||||||
|
|
||||||
|
|
||||||
|
def common_static_dir(repo_root: str | Path | None = None) -> str:
|
||||||
|
root = Path(repo_root) if repo_root is not None else REPO_ROOT
|
||||||
|
return str(root / "lib" / "common" / "static")
|
||||||
|
|
||||||
|
|
||||||
|
def manual_trading_hub_dir(repo_root: str | Path | None = None) -> Path:
|
||||||
|
root = Path(repo_root) if repo_root is not None else REPO_ROOT
|
||||||
|
return root / "manual_trading_hub"
|
||||||
|
|
||||||
|
|
||||||
|
def hub_data_dir(repo_root: str | Path | None = None) -> Path:
|
||||||
|
path = manual_trading_hub_dir(repo_root) / "data"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user