Compare commits

..

111 Commits

Author SHA1 Message Date
dekun 54c1984ec7 fix: 中控 iframe 嵌入模板路径改用 REPO_ROOT
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 22:58:56 +08:00
dekun be7896cc25 fix: OKX 持仓张数优先读 info.pos,滚仓后同步 order_amount
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 22:40:41 +08:00
dekun 394793b9d2 fix: 突破加仓按 mark 触价触发,避免漏单
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 22:31:49 +08:00
dekun 0b8f410fbe fix: 中控持仓卡合并最新风险与保证金展示
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 22:19:07 +08:00
dekun 687a34474d feat: 修改委托后展示最新风险,四所持仓卡增加张数
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 22:08:27 +08:00
dekun 9d9d0af31e fix: 市价加仓预览后无法执行滚仓
修复 embed 壳拦截 roll-form 提交,以及策略 tab 切换后未重新绑定顺势加仓脚本的问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 21:42:12 +08:00
dekun bfa3352122 feat: 系统设置增加备份恢复与默认登录 admin
支持手动/每日自动备份四所数据库、K线库与 env,上传 zip 一键恢复;中控默认账号 admin/admin123。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 16:39:46 +08:00
dekun 55261b7812 fix: lib 迁移后中控数据路径指向 manual_trading_hub
修复 hub_fund_history 等模块误读 lib/hub/manual_trading_hub 导致资金概况历史曲线丢失的问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 16:28:36 +08:00
dekun 5797d49d8a refactor: 将共用代码迁入 lib/ 模块化目录
统一 strategy、key_monitor、trade、hub 等共用库到 lib/ 子包,并补充 lib-structure 文档,便于四所与中控维护。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 16:23:09 +08:00
dekun 4742a0bb9d refactor: 移除四所统计分析页交易日历
删除日历 UI、bootstrap 与 /api/stats/calendar 注册;保留日/周/月统计表。内照明心档案日历不受影响。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 09:51:10 +08:00
dekun 32079bb4c2 fix: 修复统计日历 bootstrap 导致整站 500
日历数据改为安全 JSON 内嵌,仅统计页构建;构建失败时降级为空,避免拖垮其他页面。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 09:24:54 +08:00
dekun 3b687d17eb fix: 统计日历服务端内嵌 bootstrap,首屏显示盈亏与笔数
与月统计同源 initial_calendar 写入页面,API 失败时仍渲染;四所日历路由独立注册并传入 get_db_fn。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 09:13:58 +08:00
dekun 4f784d09ac fix: 四所统计日历显示每日盈亏与交易笔数
日历格子重置实例全局 button 样式,日期格展示 +X.XU 与 N 笔,标题栏汇总当月盈亏与总笔数。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 08:56:20 +08:00
dekun 052dcf63bd fix: 四所统计日历 embed 切换后初始化与加载失败仍渲染网格
统一 initInstanceStatsCalendar,统计 tab 动态注入时重新挂载日历,API 异常时保留月历骨架。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 08:42:39 +08:00
dekun ac4cdceb39 feat: 四所独立统计页日历,修复档案盈亏重复与日历交互
四所 index.html 统计分析页接入交易日历;内照明心剔除犯病盈亏列不再重复计入,犯病日点击显示全部交易,选中日历蓝色高亮。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 08:27:38 +08:00
dekun 14dbf25798 feat: 档案统计独立卡片、共用交易日历与四所统计页日历
内照明心统计表移至顶部卡片,右侧为日历/图表/交易记录;日历样式适配浅深主题,四所统计分析页同步展示按月盈亏日历。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 08:17:53 +08:00
dekun 6b872b1f43 feat: 内照明心交易日历与交易所口径成交额/手续费统计
新增按 08:00 切日的月历(盈亏、笔数、犯病日高亮与点击筛选);平仓时从交易所 fill 写入双边成交额与手续费,统计表与明细同步展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 08:05:46 +08:00
dekun 865567fbd3 fix: add_key 允许突破触价开仓类型通过白名单校验
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 20:55:51 +08:00
dekun a0d57fc65e fix: 箱体/收敛突破标记价反向越界时自动撤销监控
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 10:54:36 +08:00
dekun 5b3448b52b feat: 关键位回调/突破触价开仓拆分与穿越触发
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 10:49:43 +08:00
dekun e51d7824a7 fix: 全仓模式预估风险/盈利按杠杆与可用保证金计算
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 00:38:27 +08:00
dekun 9cb63c368a fix: 修正突破加仓当前价与突破价的几何校验
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 23:34:59 +08:00
dekun be8e4ce6c6 fix: 注册 strategy_roll.js 与 account_risk_badge.js 静态路由
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 23:29:56 +08:00
dekun 02255a3b02 fix: 修复斐波/突破滚仓重复脚本导致无法提交
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 23:25:27 +08:00
dekun 6352fa6be3 fix: 滚仓拒绝原因页内展示,斐波/突破隐藏预览按钮
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 23:16:59 +08:00
dekun 5a887de6f4 fix: 滚仓斐波/突破价输入框可在切换模式后编辑
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 23:07:08 +08:00
dekun 7d03e8e93e fix: 滚仓字段显隐与浅色模式样式
市价加仓默认隐藏上沿/下沿/突破价(CSS+JS);说明页与预估风险条适配浅色主题。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 22:11:58 +08:00
dekun d467760d5c 顺势加仓 v2:程序监控滚仓、文档页与平仓同步
重写滚仓计仓与四种加仓方式(市价/斐波/突破),程序盯 mark 触价成交;风险读监控单;pending 可删不可改;手动平仓同步结束滚仓。新增 /strategy/roll/docs 说明页与顺势加仓滚仓说明.md。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 22:03:23 +08:00
dekun 4aebe70611 Fix hub market chart live K-line updates in manual follow mode.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 19:41:17 +08:00
dekun ee011800e1 Open instance in new tab as full page, not embed shell.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 22:47:02 +08:00
dekun 5cf88818c1 Open instance in new tab; add in-hub trade, monitor, and review shortcuts.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 22:37:54 +08:00
dekun 448e88ec55 Count instance win rate by positive PnL and show external closes as manual close.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 22:27:52 +08:00
dekun 0a20ee7eec Show estimated risk, profit, and RR below manual order form.
Add a preview bar under the live order form with risk in red and profit in green; extend preview logic for all SL/TP modes across embed and standalone instances.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 19:19:32 +08:00
dekun cfc703ae5b Fix double POST on open position in embed shell mode.
Embed capture-phase form handler and allowManualOrderSubmit both submitted /add_order; skip custom forms and use a single fetch reload path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 19:03:25 +08:00
dekun 2dadd93d91 Fix embed date filter reload and classify profitable stops as trailing TP.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 11:27:42 +08:00
dekun 924a385d6c Fix plan history detail modal unreadable text over stats table.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 11:17:31 +08:00
dekun 61d79c4de1 Fix hub market chart live K-line updates without manual reload.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:48:01 +08:00
dekun 6ffae02d30 Allow roll add-ons while position-limit freeze is active.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 02:08:44 +08:00
dekun 9d1986d771 Exclude trend and roll monitors from position-limit freeze count.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 02:04:09 +08:00
dekun 322060de31 Show position-limit freeze on hub and instance risk badges.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 01:58:24 +08:00
dekun 3e8ecbf712 Add refresh buttons to hub plan and calculator pages.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 01:43:27 +08:00
dekun 384d404bb3 Slim embed tab rendering to cut memory use and restore calculator.
Load only per-tab data for embed fragments, skip exchange capital fetches on tab switches, and harden calculator market imports/timeouts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 01:33:42 +08:00
dekun bced61b9d7 Avoid hub iframe overlay on embed shell tab switches.
Use in-shell content loading state instead of parent postMessage so tab changes do not trigger the full instance-frame loading mask.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 01:23:40 +08:00
dekun 4ad335ca84 Add hub iframe embed shell with tab fragment API.
Replace full-page soft nav with a persistent shell and /api/embed/page loads so tab switches in the hub iframe avoid document.write flicker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 01:13:34 +08:00
dekun 157d9ada21 Fix calculator import error for hub_supervisor_lib.
Ensure settings_store resolves supervisor module when imported from repo-root calculator libs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 00:42:21 +08:00
dekun 813ebf0e4e Skip exchange PnL sync on hub iframe soft nav to fix slow records tab.
Remove hover prefetch and mark soft-nav fetches so Gate/OKX render pages from local DB without blocking on exchange history sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 00:39:54 +08:00
dekun b18b2143b5 Restore hub iframe soft nav to cut blank tab switch gap.
Use fetch in-frame navigation with overlay and hover prefetch; show delayed hub loading spinner instead of hiding the iframe.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 00:32:03 +08:00
dekun f63f8810e6 Fix Gate/Binance memory regression and roll stop offset from avg.
Stop fetch_tickers fallback for volume rank and keep stale cache on failed refresh. Compute roll unified stop as merge-average plus offset percent instead of break-even.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 00:21:07 +08:00
dekun 7f8ae97a98 Fix hub iframe nav flicker with normal navigation and loading overlay
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 00:06:17 +08:00
dekun e3559531d9 Revert instance soft nav cache to fix navigation flicker
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 23:59:27 +08:00
dekun 016c93faf2 Add 7-day local page cache for instant instance nav switching
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 23:52:28 +08:00
dekun e03863d780 Add roll leg avg/TP profit display and reduce instance nav flicker
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 23:42:02 +08:00
dekun 54ba412d1d Fix false supervisor open events for existing holdings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 20:20:33 +08:00
dekun 65901c5577 Fix supervisor AI empty replies with fallback templates
Skip appending AI error strings to the session and use event-specific fallback commentary when the model returns empty content.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 20:10:33 +08:00
dekun acc158f85d Use auto-fit grid for funds and dashboard account cards
Account cards expand to fill available width when fewer exchanges are enabled instead of staying in fixed four-column tracks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 19:57:24 +08:00
dekun ea5c6cddb4 Add per-card save and collapse on settings page
Each settings section and exchange card gets its own save button and fold toggle with state persisted in localStorage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 19:37:16 +08:00
dekun 0dedaa2b4d Fix supervisor settings panel inner padding
Align the trading supervision card with other settings panels so content is not flush against the border.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 19:30:14 +08:00
dekun bfbd6879d6 Add AI trading supervisor with WeChat push and daily session
Proactive monitoring for manual/hub closes and new opens prevents overtrading via in-app alerts, configurable WeChat links, and supervisor chat.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 19:25:01 +08:00
dekun d3d366d0ee Hide disabled exchanges from dashboard and fund overview.
Only aggregate and display exchanges with enabled monitoring in system settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 18:41:52 +08:00
dekun faa41eece1 Make calculator cards equal height on desktop layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 18:34:15 +08:00
dekun f4d7dec111 Trim trailing zeros in calculator market info display.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 18:29:08 +08:00
dekun b0ec291345 Replace httpx with urllib in calculator market fetch.
Avoid ModuleNotFoundError when hub_calculator_market_lib loads outside the manual-trading-hub venv.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 18:20:51 +08:00
dekun f78ea1288e Fix calculator instance auth header to X-Hub-Token.
Market info fetch was rejected by instances because it sent the wrong bridge token header; align with hub monitor calls.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 18:17:42 +08:00
dekun 5e507d0b66 Use hub exchange instances for calculator contract precision.
Load enabled instances from settings, fetch market info via /api/hub/market, and apply exchange-specific amount and price precision in trend and roll calculators.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 18:13:02 +08:00
dekun d938bc6c59 Redesign roll calculator with auto first entry and chained add legs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 17:44:27 +08:00
dekun 253d353206 Add hub strategy calculator page with trend and roll risk-based sizing.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 17:35:06 +08:00
dekun 1ba0014fff Add padding to macro settings panel so text is not flush to edges.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 17:24:07 +08:00
dekun caf4996159 Add settings toggles for plan, archive, and AI coach nav items.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 17:19:12 +08:00
dekun 89909c64a3 Align trade record detail fields with label-value grid layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 17:04:50 +08:00
dekun 21f86906da Fix mobile trade records hidden by CSS cascade overriding compact list.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 16:58:56 +08:00
dekun c302c3e4ea Add mobile compact trade and journal lists with tap-to-expand detail.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 16:53:56 +08:00
dekun 8e810154ca Add open-instance to trade page and mobile/tablet responsive layouts.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 16:38:30 +08:00
dekun ed3709dddf Move entry scheme to active plans only, required on archive.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 16:56:34 +08:00
dekun a837cfd14c Fix broken hub_macro_calendar_lib import in hub.py.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 16:50:09 +08:00
dekun 091317276d Add entry plan page with CRUD, archive flow, and win-rate stats.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 16:19:56 +08:00
dekun bd759c42d6 Use lightweight ticker APIs for liquidity rank to cut memory.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 09:24:27 +08:00
dekun c0f3606ecc Add win rate and profit-loss ratio to archive stats.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 09:13:37 +08:00
dekun c05afbbedf Add win/loss metrics to archive stats with symbol filter sync.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 09:03:21 +08:00
dekun 073a382d41 Unify key support/resistance monitor type and fix form parity.
Merge 关键阻力位/关键支撑位 into 关键支撑阻力, share key_monitor_form.js across hub and new-tab views, and add hub shortcut to /key_monitor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 08:31:14 +08:00
dekun ce172a7cee Fix false freeze after restart from stale account_risk_state.
Clear expired cooloff on read, never restart timer from invalid future anchors, and reconcile with journaled manual closes when the 1h window already ended.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 22:14:58 +08:00
dekun 9330e356fc Fix freeze countdown exceeding configured cooloff hours.
Clamp future last_close anchors, cap remaining time server-side, prefer freeze_remaining_sec in the badge JS, and auto-repair stale DB rows on read.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 22:11:09 +08:00
dekun deb240d4eb docs(risk): clarify cooloff countdown uses last manual close only
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 22:04:50 +08:00
dekun c73944581c fix(risk): stop stale 4h cooloff after 1h journal expires
Anchor last_close on journal save, ignore leftover stored until when 1h window ended, and clear expired cooloff on trading-day rollover.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 22:04:14 +08:00
dekun 9c778e0232 fix(risk): align freeze countdown with latest close not stale 4h until
Pick the shortest active cooloff end and derive 1h/4h label from remaining time.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 20:27:13 +08:00
dekun ff8caf7f8d fix(risk): correct freeze countdown timezone (Asia/Shanghai)
Treat naive app datetimes as local time, normalize legacy UTC-ms rows, and resolve cooloff end from stored until or last_close+duration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 18:00:40 +08:00
dekun f8e760961e fix(risk): reset badge to normal when freeze countdown expires
Also expand account-risk-cooldown docs with countdown format, API fields, and frontend assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 17:47:47 +08:00
dekun 97370926d6 feat(risk): show live countdown on freeze status badges
Expose freeze_until_ms from risk API and tick hub/instance badges with remaining 1h/4h/daily time.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 17:41:04 +08:00
dekun 0280b4f065 fix(risk): shorten cooloff on review save and when count was reset
Allow 1h reduction for any active 4h-tier cooloff, hook trade record review updates, and fix freeze label thresholds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 16:35:51 +08:00
dekun f0a158686e fix(risk): allow journal to reduce 4h cooloff to 1h without pending trade id
Hub closes and late journal saves now shorten active manual cooloffs when exit trigger and note are filled in.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 16:27:21 +08:00
dekun e470c5952f feat(hub): add macro calendar for pre-release risk alerts
Manual FOMC/CPI/employment entries in settings drive ±1h monitor banners without touching exchange instances.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 11:52:30 +08:00
dekun 3d29b4f9d9 fix(instance): restore review edit button after hub iframe nav
Sync review-mode checkbox with disabled state after form restore; repair initHubEmbedInFrameNav regression in instance_theme.js.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 11:21:31 +08:00
dekun d8dccb8606 fix(hub): stop instance iframe nav flash after account status badge
Load account_risk_badge.css before body paint, skip redundant hub theme re-apply, remove iframe hide overlay, and disable badge transitions in hub embed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 19:36:48 +08:00
dekun 6520234bd8 fix(hub): eliminate iframe flash when switching instance nav tabs
Use soft in-frame navigation and loading overlay in hub instance shell; pass embed=1 for iframe SSO opens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 19:27:52 +08:00
dekun be7f5d5072 fix(auth): stop pre-filling login username on new devices
Remove username_hint from hub auth status API and disable autocomplete on hub and instance login forms.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 19:21:46 +08:00
dekun b6acbf4b2c fix(risk): trigger cooldown only on user-initiated closes
Remove external-close risk hooks; register user_instance, user_hub, and user_trend_stop via hub API and trend stop; update docs and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 19:14:05 +08:00
dekun 850ffcd7d2 style(risk): polish account status badge for light and dark themes
Extract shared account_risk_badge.css with theme-aware contrast, dot indicator, and hub/instance layout fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:37:32 +08:00
dekun e307eef690 feat(risk): add account cooldown and daily freeze after manual/external close
Implements shared account_risk_lib with 4h/1h cooloff and daily freeze rules, wires hooks into all four exchange apps and hub monitor UI, with tests and docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:05:19 +08:00
dekun b77741ee21 fix(order): hide RR preview in fixed-RR mode and serve shared JS
manual_order_rr_preview.js was not routed from repo static/, so hide logic never ran. Add Flask routes on four exchanges, default hidden in HTML, and toggleSltpMode visibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:59:45 +08:00
dekun ca1e25888d fix(sync-close): reject pre-open fills when backfilling trade records
False external-close sync could match historical closing trades before opened_at, producing stop-loss results with closed_at earlier than opened_at. Only use fills at or after open time.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 17:03:51 +08:00
dekun 6287ca9129 fix(binance): show orphan recover banner on trade page load
Trade tab uses refreshPriceSnapshotConditional, not refreshPriceSnapshot; render recover banner there and on server when live exchange position lacks active monitor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:59:20 +08:00
dekun 7fe7c2e918 feat(binance): recover live position when local monitor was lost
Detect exchange positions without active order_monitors, show a recover banner on the trade page, and reactivate stopped monitors with optional TP/SL restore via API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:54:38 +08:00
dekun c1ee0dae25 fix(reconcile): guard Binance/OKX restart false flat sync
Add startup grace and consecutive flat polls before external-close reconcile, matching Gate, to avoid stopping monitors and canceling TP/SL when positions API is not ready after restart.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:34:17 +08:00
dekun 58e940629a fix(order): hide estimated RR preview in fixed RR SLTP mode
Only price and percentage modes show estimated profit-loss ratio before open; fixed RR mode keeps the existing estimated take-profit display.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:26:18 +08:00
dekun f9257b64e4 feat(order): show estimated RR before open across four exchanges
Add shared manual_order_rr_preview.js to fetch order_defaults after symbol and TP/SL inputs complete, display estimated profit-loss ratio before submit in price and percentage modes (and fixed RR), unified for risk and full-margin sizing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:19:32 +08:00
dekun 869728ce10 feat: archive trades by close time and show open time on live positions
Sort inner-archive daily trades by closed_at_ms; add open time and live hold duration to instance and hub position cards, with exchange margin on hub footer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 06:20:18 +08:00
dekun ad1c08a2cc fix(hub): remove duplicate AI chat declarations causing white screen
Drop redeclared AI_CHAT_MAX_ATTACHMENTS and aiChatPendingFiles in app.js that broke script parsing after the chat perf update.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 02:02:33 +08:00
dekun 467d160f4d perf(hub-ai): reduce CPU load during trading coach chat
Cache chat context, parallelize exchange fetches, skip fund history writes, defer rolling summary to a background thread, and cache markdown rendering on the client.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 01:59:43 +08:00
dekun 28a23008f3 feat(hub-ai): paste screenshots in chat and include position TP/SL in coach context
Let users paste images into AI chat with removable pending attachments, and feed exchange/monitor stop-loss and take-profit into trading coach snapshots so replies reflect actual protection on open positions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 01:53:24 +08:00
dekun 42c06c0f38 chore(scripts): remove one-off trigger entry patch scripts
These temporary patch helpers are no longer needed after the feature landed in the main apps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 01:11:55 +08:00
dekun 4573ccca9a fix(key-monitor): repair trigger entry bugs in four exchange apps
Restore missing check_fib_key_monitors, fix gate preview and OKX add_key,
and unify trigger execution error handling to avoid duplicate history writes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 01:06:53 +08:00
dekun edf4bb835d feat(key-monitor): add program trigger entry across four exchanges
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 00:42:21 +08:00
250 changed files with 46429 additions and 21708 deletions
+3
View File
@@ -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
+5 -2
View File
@@ -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)**。
--- ---
+12 -2
View File
@@ -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
# 前端价格快照轮询(秒) # 前端价格快照轮询(秒)
File diff suppressed because it is too large Load Diff
+181 -123
View File
@@ -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,108 +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 rsTypes = new Set(["关键阻力位","关键支撑位"]);
const showAuto = autoTypes.has(t);
const showFb = fbTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb;
const showDir = !rsTypes.has(t);
const upperEl = document.getElementById("key-upper");
const lowerEl = document.getElementById("key-lower");
const fbPriceEl = document.getElementById("key-fb-price");
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);
if(upperEl){
upperEl.style.display = showFb ? "none" : "";
upperEl.required = !showFb;
if(showFb) upperEl.value = "";
}
if(lowerEl){
lowerEl.style.display = showFb ? "none" : "";
lowerEl.required = !showFb;
if(showFb) 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") ? "低点(支撑)" : "做空填高点/做多填低点");
}
}
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();
@@ -1681,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);
@@ -1767,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('委托请求失败'));
} }
@@ -1834,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;
@@ -1849,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");
@@ -1917,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(()=>{});
} }
@@ -1953,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(()=>{});
} }
@@ -1969,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}`);
@@ -2032,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(); });
@@ -2045,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){
@@ -2182,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>
+3 -3
View File
@@ -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>
+4 -2
View File
@@ -66,9 +66,11 @@
| **收敛突破** | 同上(自动开仓类)。 | | **收敛突破** | 同上(自动开仓类)。 |
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 | | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
| **关键支撑位** | 同上(仅提醒)。 | | **关键支撑位** | 同上(仅提醒)。 |
| **回调触价开仓** | **不挂交易所限价**;标记价回调触达 E 后 **下一轮询市价开仓**RR 门槛同 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h** |
| **突破触价开仓** | **不挂交易所限价**;标记价 **穿越 E 立即市价开仓**;先触 SL/TP 侧失效;有效期 **24h** |
3. **方向**:做多 / 做空(必选)。 3. **方向**:做多 / 做空(回调/突破触价、箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整 4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价填 **入场 E / 止损 SL / 止盈 TP**
**限制:** **限制:**
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。 活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
@@ -1,7 +1,7 @@
# 关键位监控说明(自动开仓 + 人工盯盘) # 关键位监控说明(自动开仓 + 人工盯盘)
**适用:`crypto_monitor_binance`Binance U 本位永续)** **适用:`crypto_monitor_gate`Gate U 本位永续)**
Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_monitor_lib.py` Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_monitor_lib.py`
本文档与 `.env``check_key_monitors``add_key``_key_hard_checks``_process_key_rs_level_alert` 一致。 本文档与 `.env``check_key_monitors``add_key``_key_hard_checks``_process_key_rs_level_alert` 一致。
@@ -16,8 +16,10 @@ Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_
| **关键阻力位** | **不选**`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 立即市价** | 见下文 **§四** |
**添加时(所有类型):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30**;上沿 **>** 下沿。 **添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位)
--- ---
@@ -110,6 +112,7 @@ Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_
| `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 达标且市价开仓成功 |
@@ -117,7 +120,37 @@ Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_
--- ---
## 四、环境与参数(`.env` 摘要 ## 四、回调 / 突破触价开仓(程序触价,无交易所挂单
### 4.1 录入
- **回调触价开仓**:方向必选多/空;填写 **计划入场价 E**、**止损 SL**、**止盈 TP**(做多须 `SL < E < TP`)。
- **突破触价开仓**:同上;添加时当前价须在突破方向一侧(做多:价低于 E;做空:价高于 E)。
- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5)。
- 可选移动保本、时间平仓;**全仓杠杆模式**下可用。
### 4.2 触发与结案
| 类型 | 触发条件(标记价) |
|------|-------------------|
| **回调触价** | 做多 `≤ E`;做空 `≥ E` → 下一轮询市价开仓 |
| **突破触价** | 做多**向上穿越** E;做空**向下穿越** E → **立即**市价开仓 |
- 未成交前标记价先触 **TP 侧**`trigger_tp_invalidate`
- **突破触价**另:未穿越 E 先触 **SL 侧**`trigger_sl_invalidate`
- **24h** 未触发 → `trigger_entry_expired`
- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`
### 4.3 计仓与占位
- **以损定仓**:按 E、SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x、其它 5x。
- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条触价监控(含回调/突破)。
共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`
---
## 五、环境与参数(`.env` 摘要)
| 变量 | 箱体/收敛 | 阻力/支撑 | | 变量 | 箱体/收敛 | 阻力/支撑 |
|------|-----------|-----------| |------|-----------|-----------|
@@ -130,7 +163,7 @@ Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_
--- ---
## 、相关代码 ## 、相关代码
| 说明 | 位置 | | 说明 | 位置 |
|------|------| |------|------|
+12 -2
View File
@@ -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
# 前端价格快照轮询(秒) # 前端价格快照轮询(秒)
+1039 -143
View File
File diff suppressed because it is too large Load Diff
+126 -123
View File
@@ -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,108 +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 rsTypes = new Set(["关键阻力位","关键支撑位"]);
const showAuto = autoTypes.has(t);
const showFb = fbTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb;
const showDir = !rsTypes.has(t);
const upperEl = document.getElementById("key-upper");
const lowerEl = document.getElementById("key-lower");
const fbPriceEl = document.getElementById("key-fb-price");
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);
if(upperEl){
upperEl.style.display = showFb ? "none" : "";
upperEl.required = !showFb;
if(showFb) upperEl.value = "";
}
if(lowerEl){
lowerEl.style.display = showFb ? "none" : "";
lowerEl.required = !showFb;
if(showFb) 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") ? "低点(支撑)" : "做空填高点/做多填低点");
}
}
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();
@@ -1661,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);
@@ -1747,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('委托请求失败'));
} }
@@ -1814,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;
@@ -1897,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);
@@ -1933,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(()=>{});
} }
@@ -1949,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}`);
@@ -2012,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(); });
@@ -2025,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){
@@ -2162,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>
+3 -3
View File
@@ -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>
+4 -2
View File
@@ -65,9 +65,11 @@
| **收敛突破** | 同上(自动开仓类)。 | | **收敛突破** | 同上(自动开仓类)。 |
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 | | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
| **关键支撑位** | 同上(仅提醒)。 | | **关键支撑位** | 同上(仅提醒)。 |
| **回调触价开仓** | **不挂交易所限价**;标记价回调触达 E 后 **下一轮询市价开仓**RR 门槛同 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h** |
| **突破触价开仓** | **不挂交易所限价**;标记价 **穿越 E 立即市价开仓**;先触 SL/TP 侧失效;有效期 **24h** |
3. **方向**:做多 / 做空(选)。 3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整 4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**
**限制:** **限制:**
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。 活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
@@ -16,8 +16,10 @@ 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 立即市价** | 见下文 **§四** |
**添加时(所有类型):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30**;上沿 **>** 下沿。 **添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位)
--- ---
@@ -110,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 达标且市价开仓成功 |
@@ -117,7 +120,37 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
--- ---
## 四、环境与参数(`.env` 摘要 ## 四、回调 / 突破触价开仓(程序触价,无交易所挂单
### 4.1 录入
- **回调触价开仓**:方向必选多/空;填写 **计划入场价 E**、**止损 SL**、**止盈 TP**(做多须 `SL < E < TP`)。
- **突破触价开仓**:同上;添加时当前价须在突破方向一侧(做多:价低于 E;做空:价高于 E)。
- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5)。
- 可选移动保本、时间平仓;**全仓杠杆模式**下可用。
### 4.2 触发与结案
| 类型 | 触发条件(标记价) |
|------|-------------------|
| **回调触价** | 做多 `≤ E`;做空 `≥ E` → 下一轮询市价开仓 |
| **突破触价** | 做多**向上穿越** E;做空**向下穿越** E → **立即**市价开仓 |
- 未成交前标记价先触 **TP 侧**`trigger_tp_invalidate`
- **突破触价**另:未穿越 E 先触 **SL 侧**`trigger_sl_invalidate`
- **24h** 未触发 → `trigger_entry_expired`
- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`
### 4.3 计仓与占位
- **以损定仓**:按 E、SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x、其它 5x。
- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条触价监控(含回调/突破)。
共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`
---
## 五、环境与参数(`.env` 摘要)
| 变量 | 箱体/收敛 | 阻力/支撑 | | 变量 | 箱体/收敛 | 阻力/支撑 |
|------|-----------|-----------| |------|-----------|-----------|
@@ -130,7 +163,7 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
--- ---
## 、相关代码 ## 、相关代码
| 说明 | 位置 | | 说明 | 位置 |
|------|------| |------|------|
+12 -2
View File
@@ -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
# 前端价格快照轮询(秒) # 前端价格快照轮询(秒)
File diff suppressed because it is too large Load Diff
+126 -123
View File
@@ -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,108 +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 rsTypes = new Set(["关键阻力位","关键支撑位"]);
const showAuto = autoTypes.has(t);
const showFb = fbTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb;
const showDir = !rsTypes.has(t);
const upperEl = document.getElementById("key-upper");
const lowerEl = document.getElementById("key-lower");
const fbPriceEl = document.getElementById("key-fb-price");
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);
if(upperEl){
upperEl.style.display = showFb ? "none" : "";
upperEl.required = !showFb;
if(showFb) upperEl.value = "";
}
if(lowerEl){
lowerEl.style.display = showFb ? "none" : "";
lowerEl.required = !showFb;
if(showFb) 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") ? "低点(支撑)" : "做空填高点/做多填低点");
}
}
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();
@@ -1661,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);
@@ -1747,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('委托请求失败'));
} }
@@ -1814,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;
@@ -1897,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);
@@ -1933,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(()=>{});
} }
@@ -1949,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}`);
@@ -2012,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(); });
@@ -2025,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){
@@ -2162,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>
+3 -3
View File
@@ -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>
+4 -2
View File
@@ -65,9 +65,11 @@
| **收敛突破** | 同上(自动开仓类)。 | | **收敛突破** | 同上(自动开仓类)。 |
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 | | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
| **关键支撑位** | 同上(仅提醒)。 | | **关键支撑位** | 同上(仅提醒)。 |
| **回调触价开仓** | **不挂交易所限价**;标记价回调触达 E 后 **下一轮询市价开仓**RR 门槛同 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h** |
| **突破触价开仓** | **不挂交易所限价**;标记价 **穿越 E 立即市价开仓**;先触 SL/TP 侧失效;有效期 **24h** |
3. **方向**:做多 / 做空(选)。 3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整 4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**
**限制:** **限制:**
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。 活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
@@ -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 达标且市价开仓成功 |
+12 -2
View File
@@ -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
+1084 -234
View File
File diff suppressed because it is too large Load Diff
+126 -124
View File
@@ -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,109 +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 rsTypes = new Set(["关键阻力位","关键支撑位"]);
const showAuto = autoTypes.has(t);
const showFb = fbTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb;
const showDir = !rsTypes.has(t);
const upperEl = document.getElementById("key-upper");
const lowerEl = document.getElementById("key-lower");
const fbPriceEl = document.getElementById("key-fb-price");
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);
if(upperEl){
upperEl.style.display = showFb ? "none" : "";
upperEl.required = !showFb;
if(showFb) upperEl.value = "";
}
if(lowerEl){
lowerEl.style.display = showFb ? "none" : "";
lowerEl.required = !showFb;
if(showFb) 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") ? "低点(支撑)" : "做空填高点/做多填低点");
}
}
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();
@@ -1691,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);
@@ -1777,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('委托请求失败'));
} }
@@ -1844,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;
@@ -1927,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);
@@ -1963,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(()=>{});
} }
@@ -1979,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);
@@ -2065,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(); });
@@ -2078,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){
@@ -2215,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>
+3 -3
View File
@@ -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>
+4 -2
View File
@@ -65,9 +65,11 @@
| **收敛突破** | 同上(自动开仓类)。 | | **收敛突破** | 同上(自动开仓类)。 |
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 | | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
| **关键支撑位** | 同上(仅提醒)。 | | **关键支撑位** | 同上(仅提醒)。 |
| **回调触价开仓** | **不挂交易所限价**;标记价回调触达 E 后 **下一轮询市价开仓**RR 门槛同 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h** |
| **突破触价开仓** | **不挂交易所限价**;标记价 **穿越 E 立即市价开仓**;先触 SL/TP 侧失效;有效期 **24h** |
3. **方向**:做多 / 做空(选)。 3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整 4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**
**限制:** **限制:**
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。 活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
@@ -1,7 +1,7 @@
# 关键位监控说明(自动开仓 + 人工盯盘) # 关键位监控说明(自动开仓 + 人工盯盘)
**适用:`crypto_monitor_okx`OKX 永续)** **适用:`crypto_monitor_gate`Gate U 本位永续)**
箱体/收敛与 Binance、Gate 相同:**门控通过后自动市价开仓**(须 `LIVE_TRADING_ENABLED=true`)。阻力/支撑仍为微信提醒。共享逻辑见 `key_monitor_lib.py` Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_monitor_lib.py`
本文档与 `.env``check_key_monitors``add_key``_key_hard_checks``_process_key_rs_level_alert` 一致。 本文档与 `.env``check_key_monitors``add_key``_key_hard_checks``_process_key_rs_level_alert` 一致。
@@ -16,8 +16,10 @@
| **关键阻力位** | **不选**`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 立即市价** | 见下文 **§四** |
**添加时(所有类型):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30**;上沿 **>** 下沿。 **添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位)
--- ---
@@ -110,6 +112,7 @@
| `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 达标且市价开仓成功 |
@@ -117,7 +120,37 @@
--- ---
## 四、环境与参数(`.env` 摘要 ## 四、回调 / 突破触价开仓(程序触价,无交易所挂单
### 4.1 录入
- **回调触价开仓**:方向必选多/空;填写 **计划入场价 E**、**止损 SL**、**止盈 TP**(做多须 `SL < E < TP`)。
- **突破触价开仓**:同上;添加时当前价须在突破方向一侧(做多:价低于 E;做空:价高于 E)。
- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5)。
- 可选移动保本、时间平仓;**全仓杠杆模式**下可用。
### 4.2 触发与结案
| 类型 | 触发条件(标记价) |
|------|-------------------|
| **回调触价** | 做多 `≤ E`;做空 `≥ E` → 下一轮询市价开仓 |
| **突破触价** | 做多**向上穿越** E;做空**向下穿越** E → **立即**市价开仓 |
- 未成交前标记价先触 **TP 侧**`trigger_tp_invalidate`
- **突破触价**另:未穿越 E 先触 **SL 侧**`trigger_sl_invalidate`
- **24h** 未触发 → `trigger_entry_expired`
- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`
### 4.3 计仓与占位
- **以损定仓**:按 E、SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x、其它 5x。
- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条触价监控(含回调/突破)。
共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`
---
## 五、环境与参数(`.env` 摘要)
| 变量 | 箱体/收敛 | 阻力/支撑 | | 变量 | 箱体/收敛 | 阻力/支撑 |
|------|-----------|-----------| |------|-----------|-----------|
@@ -130,7 +163,7 @@
--- ---
## 、相关代码 ## 、相关代码
| 说明 | 位置 | | 说明 | 位置 |
|------|------| |------|------|
+130
View File
@@ -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`
+9 -6
View File
@@ -29,17 +29,20 @@
## 区间统计(统计栏) ## 区间统计(统计栏)
基于所选日期区间内 **全部开仓**不受盈利/亏损/犯病勾选与搜索影响;交易所筛选仍生效): 基于当前 **列表筛选结果**盈利/亏损/犯病勾选、合约搜索;交易所下拉仍限定数据源):
| 指标 | 说明 | | 指标 | 说明 |
|------|------| |------|------|
| 总开仓次数 | 区间内开仓笔数 | | 总开仓次数 | 区间内开仓笔数 |
| 盈利单 / 亏损单 | 盈亏 &gt; 0 / &lt; 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`
实例侧: 实例侧:
+147
View File
@@ -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)
+83
View File
@@ -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. 中控 **系统设置 → 宏观关键数据** 录入 13 条
3. 到点前后监控区顶栏出现 **宏观风控** 横幅;无操作则窗口结束后自动消失
## 与账户风控的关系
| 模块 | 时机 | 作用 |
|------|------|------|
| 宏观日历 | **事前** | 已知高波动窗口,提醒等待或管仓 |
| 账户冷静期/日冻结 | **事后** | 用户主动平仓后的惩罚性限制 |
宏观提醒 **不触发** 冷静期、不计入手动平仓次数。
+31
View File
@@ -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` 口径一致
+6 -4
View File
@@ -18,20 +18,22 @@ FULL_MARGIN_BUFFER_RATIO=0.98
| 模式 | 保证金计算 | 杠杆 | 允许入口 | | 模式 | 保证金计算 | 杠杆 | 允许入口 |
|------|------------|------|----------| |------|------------|------|----------|
| `risk` | `RISK_PERCENT` × 交易资金,按止损距离反推 | 表单可选 / 同步交易所 | 实盘人工、关键位自动、趋势回调、顺势加仓 | | `risk` | `RISK_PERCENT` × 交易资金,按止损距离反推 | 表单可选 / 同步交易所 | 实盘人工、关键位自动、趋势回调、顺势加仓 |
| `full_margin` | **合约账户可用 USDT × `FULL_MARGIN_BUFFER_RATIO`**(保留 2 位小数) | BTC/ETH **10x**,其它 **5x**(与 `BTC_LEVERAGE`/`ALT_LEVERAGE` 一致) | **** 实盘人工下单;阻力/支撑仅提醒 | | `full_margin` | **合约账户可用 USDT × `FULL_MARGIN_BUFFER_RATIO`**(保留 2 位小数) | BTC/ETH **10x**,其它 **5x**(与 `BTC_LEVERAGE`/`ALT_LEVERAGE` 一致) | **实盘人工下单**、**关键位触价开仓**;阻力/支撑仅提醒 |
全仓模式下: 全仓模式下:
- 仍校验 **计划盈亏比**`MANUAL_MIN_PLANNED_RR`)。 - 仍校验 **计划盈亏比**实盘用 `MANUAL_MIN_PLANNED_RR`;触价开仓用 `KEY_AUTO_MIN_PLANNED_RR`)。
- 下单张数由 `prepare_order_amount` + 交易所 `amount_to_precision` 决定。 - 下单张数由 `prepare_order_amount` + 交易所 `amount_to_precision` 决定。
- `order_monitors.initial_stop_loss` 仍记录**开仓时**止损快照;交易记录复盘以该快照为准。 - `order_monitors.initial_stop_loss` 仍记录**开仓时**止损快照;交易记录复盘以该快照为准。
- 已存在的 **箱体突破 / 收敛突破 / 斐波** 监控:进程启动时**自动撤销**并企业微信通知。 - 已存在的 **箱体突破 / 收敛突破 / 斐波 / 假突破** 监控:进程启动时**自动撤销**并企业微信通知。
## 不允许(全仓模式) ## 不允许(全仓模式)
- 关键位:箱体突破、收敛突破、斐波自动单(添加时拒绝;已存在则启动时撤销)。 - 关键位:箱体突破、收敛突破、斐波、假突破(添加时拒绝;已存在则启动时撤销)。
- 趋势回调、顺势加仓(策略入口返回明确错误)。 - 趋势回调、顺势加仓(策略入口返回明确错误)。
**允许:** 关键位 **回调触价开仓** / **突破触价开仓**(程序盯价、触达/穿越计划入场后市价成交,无交易所挂单;全仓下仅允许一条待触发)。
## 用脚本更新四所 `.env` ## 用脚本更新四所 `.env`
详见 **[env-sync-scripts.md](./env-sync-scripts.md)**。常用命令: 详见 **[env-sync-scripts.md](./env-sync-scripts.md)**。常用命令:
+1
View File
@@ -0,0 +1 @@
"""crypto_monitor shared libraries."""
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+1 -1
View File
@@ -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,
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+150
View File
@@ -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;
}
+120
View File
@@ -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);
+265
View File
@@ -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);
+231
View File
@@ -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;
}
/** 中控 iframefetch 换页 + 页内遮罩,避免整页卸载与中控侧长时间空白。 */
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;
} }
+269
View File
@@ -0,0 +1,269 @@
/**
* 四所实例共用 UI复盘详情盈亏着色等
*/
(function (global) {
"use strict";
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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);
+160
View File
@@ -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);
+318
View File
@@ -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();
})();
+160
View File
@@ -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;
}
+314
View File
@@ -0,0 +1,314 @@
/**
* 交易日历组件内照明心档案 + 四所统计分析共用
*/
(function (global) {
"use strict";
var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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);
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+1 -1
View File
@@ -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,
+447
View File
@@ -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)
+223 -26
View File
@@ -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/staticinstance_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 (
+498
View File
@@ -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
+257
View File
@@ -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()
+453
View File
@@ -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:
+311
View File
@@ -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),建议等待,避免新开仓"
+81
View File
@@ -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
View File
@@ -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],
+1
View File
@@ -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": []}
+148
View File
@@ -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),
}
+19
View File
@@ -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 %}
+126
View File
@@ -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 }} CSVUTF-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>
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
@@ -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 = "关键位监控"
@@ -44,12 +44,12 @@ def calc_fib_plan(direction, upper, lower, ratio):
def stored_key_signal_type(monitor_type): def stored_key_signal_type(monitor_type):
"""写入 order_monitors / trade_records 的 key_signal_type(箱体/收敛/斐波/假突破)。""" """写入 order_monitors / trade_records 的 key_signal_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 == "假突破": 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,9 @@ KEY_ENTRY_REASON_BY_SIGNAL = {
"斐波回调0.618": "关键位斐波0.618", "斐波回调0.618": "关键位斐波0.618",
"斐波回调0.786": "关键位斐波0.786", "斐波回调0.786": "关键位斐波0.786",
"假突破": "关键位假突破", "假突破": "关键位假突破",
"回调触价开仓": "关键位回调触价开仓",
"突破触价开仓": "关键位突破触价开仓",
"触价开仓": "关键位触价开仓",
"趋势回调": "趋势回调", "趋势回调": "趋势回调",
} }
@@ -74,8 +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 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 收盘突破上沿或下沿严格 > / <
@@ -310,13 +353,21 @@ def key_monitor_rule_template_context(
key_stop_outside_breakout_pct: float, key_stop_outside_breakout_pct: float,
key_trend_stop_outside_pct: float, key_trend_stop_outside_pct: float,
false_breakout_validity_hours: int, false_breakout_validity_hours: int,
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 lib.key_monitor.trigger_entry_key_monitor_lib import TRIGGER_ENTRY_VALIDITY_HOURS
te_hours = (
int(trigger_entry_validity_hours)
if trigger_entry_validity_hours is not None
else TRIGGER_ENTRY_VALIDITY_HOURS
)
return { return {
"tf": (kline_timeframe or "5m").strip(), "tf": (kline_timeframe or "5m").strip(),
@@ -335,4 +386,5 @@ def key_monitor_rule_template_context(
"fb_sl_pct": FALSE_BREAKOUT_SL_PCT, "fb_sl_pct": FALSE_BREAKOUT_SL_PCT,
"fb_rr": FALSE_BREAKOUT_RR, "fb_rr": FALSE_BREAKOUT_RR,
"fb_valid_hours": false_breakout_validity_hours, "fb_valid_hours": false_breakout_validity_hours,
"trigger_entry_validity_hours": te_hours,
} }
+14
View File
@@ -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
+33
View File
@@ -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