diff --git a/deploy.sh b/deploy.sh index 7f9a25c..7c058d9 100644 --- a/deploy.sh +++ b/deploy.sh @@ -26,6 +26,11 @@ need_install python3 python3 need_install python3-venv python3-venv need_install git git +# vnpy_ctp 在 Linux 上可能需要编译 +if ! dpkg -s build-essential &>/dev/null; then + apt-get install -y build-essential python3-dev +fi + if ! command -v pm2 &>/dev/null; then echo "==> 安装 PM2..." if ! command -v npm &>/dev/null; then diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 21af2e6..a58b55d 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -20,13 +20,15 @@ ## 环境要求 - **系统**:Ubuntu 20.04+(推荐) -- **Python**:3.10+ +- **Python**:3.10+(vnpy_ctp 要求 ≥3.10) - **Node.js + PM2**:进程守护与开机自启 +- **编译工具**(安装 vnpy_ctp 时需要):`build-essential`、`python3-dev` - **网络**: - `hq.sinajs.cn`(新浪行情) - 企业微信 API(若启用推送) - `git.bz121.com`(拉取代码) - - 可选:`pip` 安装 akshare 时需访问 PyPI + - `pypi.org`(pip 安装依赖) + - SimNow / 期货公司 **CTP 前置地址**(下单与持仓,见下文) --- @@ -60,10 +62,12 @@ bash deploy.sh ```bash apt update -apt install -y python3 python3-venv python3-pip git nodejs npm +apt install -y python3 python3-venv python3-pip python3-dev git nodejs npm build-essential npm install -g pm2 ``` +`build-essential` 与 `python3-dev` 用于编译安装 **vnpy_ctp**(CTP 网关)。若 `pip install vnpy_ctp` 报编译错误,请先确认上述包已安装。 + ### 2. 克隆代码 ```bash @@ -80,16 +84,13 @@ pip install --upgrade pip pip install -r requirements.txt ``` -### 4. 可选:AKShare(手续费第三方同步) +依赖已包含 **vnpy**、**vnpy_ctp**(CTP 报单)、**akshare**(手续费同步)。安装完成后可验证: ```bash -source venv/bin/activate -pip install akshare +python -c "from vnpy_ctp import CtpGateway; print('vnpy_ctp OK')" ``` -不安装也可使用系统内置 `data/fee_rates.json` 默认费率。 - -### 5. 环境变量 +若提示找不到模块,查看本文「CTP / vnpy 故障排查」一节。 ```bash cp .env.example .env @@ -107,6 +108,12 @@ nano .env | `WECHAT_WEBHOOK` | 企业微信机器人地址(可选) | | `QUOTE_SOURCE` | `sina`(默认)/ `ths` / `auto` | | `THS_REFRESH_TOKEN` | 同花顺 iFinD token(机构用户) | +| `SIMNOW_USER` | SimNow 仿真账号(模拟盘必填) | +| `SIMNOW_PASSWORD` | SimNow 密码 | +| `SIMNOW_TD_ADDRESS` | SimNow 交易前置(以官网最新为准) | +| `SIMNOW_MD_ADDRESS` | SimNow 行情前置 | +| `CTP_LIVE_*` | 期货公司实盘 CTP(后期接入,见 `.env.example`) | +| `TRADING_MODE` | `simulation`(SimNow)/ `live`(实盘) | 示例: @@ -119,8 +126,21 @@ ADMIN_PASSWORD=你的强密码 ADMIN_SYNC_FROM_ENV=false WECHAT_WEBHOOK= QUOTE_SOURCE=sina + +# —— SimNow 模拟盘(在 simnow.com.cn 注册)—— +SIMNOW_USER=你的SimNow账号 +SIMNOW_PASSWORD=你的密码 +SIMNOW_BROKER_ID=9999 +SIMNOW_TD_ADDRESS=tcp://180.168.146.187:10201 +SIMNOW_MD_ADDRESS=tcp://180.168.146.187:10211 +SIMNOW_APP_ID=simnow_client_test +SIMNOW_AUTH_CODE=0000000000000000 +SIMNOW_PRODUCT_INFO=simnow_client_test +TRADING_MODE=simulation ``` +SimNow 前置地址会随官网更新,部署前请到 [SimNow 官网](https://www.simnow.com.cn/) 核对 **7×24** 或交易时段地址。 + ### 6. PM2 启动 ```bash @@ -150,15 +170,25 @@ pip install -r requirements.txt pm2 restart qihuo ``` -若新增可选依赖(如 akshare): +若 `vnpy_ctp` 安装失败(常见于缺少编译环境): ```bash -pip install akshare +apt install -y build-essential python3-dev +source venv/bin/activate +pip install --no-cache-dir vnpy vnpy_ctp pm2 restart qihuo ``` 应用启动时会自动执行 SQLite 表结构迁移(`ALTER TABLE` 容错),一般无需手工改库。 +### 首次启用 CTP 下单 + +1. 浏览器登录 → **系统设置** 确认 **模拟盘 · SimNow** +2. 打开 **持仓监控** 页 → 点击 **连接 CTP** +3. 连接成功后:权益来自柜台、**持仓监控** 显示 CTP 实际持仓、**期货下单** 可报单 + +详见 [TRADING.md](./TRADING.md)。 + --- ## PM2 常用命令 @@ -274,7 +304,11 @@ ufw allow 6600/tcp | 现价一直 `--` | 新浪网络不可达 | 检查服务器能否访问 `hq.sinajs.cn` | | 关键位 500 | 缺 `sina_code` 列 | `git pull` 重启;或手工 `ALTER TABLE` | | K 线生成失败 | matplotlib 未装 | `pip install matplotlib==3.9.2` | -| 手续费同步失败 | 未装 akshare | `pip install akshare` 或用「重载 JSON」 | +| 手续费同步失败 | akshare 异常 | 使用「重载 JSON」或检查 akshare | +| **未安装 vnpy / vnpy_ctp** | 依赖未装或编译失败 | 见下方「CTP / vnpy 故障排查」 | +| **CTP 连接超时** | SimNow 地址/账号/非交易时段 | 核对 `.env` 与 SimNow 官网前置 | +| **持仓监控为空** | 未连接 CTP 或确实无仓 | 先点「连接 CTP」 | +| `database is locked` | SQLite 并发 | `git pull` 最新版后重启 | | `git pull` 冲突 | 本地有修改 | 备份 `futures.db` 后处理冲突或 `git stash` | 查看应用是否在监听: @@ -283,6 +317,37 @@ ufw allow 6600/tcp ss -tlnp | grep 6600 ``` +### CTP / vnpy 故障排查 + +页面提示 **「未安装 vnpy / vnpy_ctp」** 表示 Python 环境未成功安装 CTP 网关,下单与柜台持仓不可用(看盘、策略、复盘仍可用)。 + +**1. 安装依赖** + +```bash +cd /opt/qihuo +source venv/bin/activate +apt install -y build-essential python3-dev # 首次需要 +pip install -r requirements.txt +python -c "from vnpy_ctp import CtpGateway; print('OK')" +pm2 restart qihuo +``` + +**2. 配置 SimNow(`.env`)** + +填写 `SIMNOW_USER`、`SIMNOW_PASSWORD`,前置地址以 SimNow 官网为准。 + +**3. 连接** + +登录系统 → **持仓监控** → **连接 CTP**。成功则顶栏显示「CTP 已连接」,权益变为 SimNow 账户资金。 + +**4. 常见错误** + +| 日志/现象 | 处理 | +|-----------|------| +| `pip install vnpy_ctp` 编译失败 | 安装 `build-essential python3-dev` 后重试 | +| CTP 连接超时 | 检查前置 IP、端口、SimNow 是否维护、是否在允许连接时段 | +| 已连接但下单拒单 | 检查合约代码、价格精度、是否有足够保证金 | + --- ## 安全建议 @@ -299,6 +364,9 @@ ss -tlnp | grep 6600 ``` /opt/qihuo/ ├── app.py +├── vnpy_bridge.py # CTP 执行层 +├── recommend_store.py # 品种推荐缓存 +├── recommend_stream.py # 品种推荐 SSE 推送 ├── venv/ ├── futures.db ├── .env @@ -309,9 +377,11 @@ ss -tlnp | grep 6600 ├── data/fee_rates.json ├── ecosystem.config.cjs ├── deploy.sh +├── requirements.txt # 含 vnpy、vnpy_ctp └── docs/ ├── FEATURES.md - └── DEPLOY.md + ├── DEPLOY.md + └── TRADING.md ``` --- @@ -319,4 +389,5 @@ ss -tlnp | grep 6600 ## 相关文档 - [功能说明文档](./FEATURES.md) +- [交易与 SimNow 配置](./TRADING.md) - [README](../README.md) diff --git a/docs/TRADING.md b/docs/TRADING.md index 580a6c4..180aa8e 100644 --- a/docs/TRADING.md +++ b/docs/TRADING.md @@ -9,14 +9,27 @@ 已移除「本地 SQLite 假撮合」;模拟盘与实盘均走 **vnpy_ctp**,仅 `.env` 前置与账号不同。 +## 依赖安装 + +`requirements.txt` 已包含 `vnpy`、`vnpy_ctp`。服务器部署: + +```bash +cd /opt/qihuo +source venv/bin/activate +pip install -r requirements.txt +python -c "from vnpy_ctp import CtpGateway; print('OK')" +``` + +详见 [DEPLOY.md](./DEPLOY.md) 中「CTP / vnpy 故障排查」。 + ## 首次使用 SimNow 1. 在 [SimNow](https://www.simnow.com.cn/) 注册仿真账号 2. 复制 `.env.example` → `.env`,填写 `SIMNOW_USER`、`SIMNOW_PASSWORD` 3. 核对 SimNow 官网最新的 **7×24 或交易时段** 前置地址 -4. `pip install vnpy vnpy_ctp` -5. 启动程序 → **期货下单** → 点击 **连接 CTP** -6. 连接成功后,权益、持仓、下单均来自 SimNow +4. `pip install -r requirements.txt`(含 vnpy、vnpy_ctp) +5. 启动程序 → **持仓监控** → 点击 **连接 CTP** +6. 连接成功后,权益、持仓、下单均来自 SimNow 柜台 ## 参考资金 @@ -26,8 +39,7 @@ | 页面 | 路径 | |------|------| -| 品种推荐 | `/recommend` | -| 期货下单 | `/trade` | +| 持仓监控(含下单、推荐) | `/positions` | | 策略交易 | `/strategy` | | 策略记录 | `/strategy/records` | @@ -37,4 +49,6 @@ |------|------| | `POST /api/ctp/connect` | 按当前模式连接 SimNow 或实盘 CTP | | `GET /api/ctp/status` | 连接状态与缺失配置项 | -| `POST /api/trade/order` | 限价报单(需已连接 CTP) | +| `POST /api/trade/order` | 报单(限价/市价,需已连接 CTP) | +| `GET /api/trading/live` | 持仓(CTP 柜台数据) | +| `GET /api/recommend/stream` | 品种推荐 SSE 推送 | diff --git a/install_trading.py b/install_trading.py index 34a4169..e706b1f 100644 --- a/install_trading.py +++ b/install_trading.py @@ -112,96 +112,48 @@ def install_trading(app, *, login_required, get_db, get_setting, set_setting, fe from zoneinfo import ZoneInfo tz = ZoneInfo("Asia/Shanghai") now_iso = datetime.now(tz).strftime("%Y-%m-%dT%H:%M") - capital = _capital(conn) mode = get_trading_mode(get_setting) ctp_st = ctp_status(mode) rows: list[dict] = [] - seen: set[str] = set() - ctp_pairs: list[tuple[str, str]] = [] + if not ctp_st.get("connected"): + return rows - if ctp_st.get("connected"): - for p in _ctp_positions(mode): - sym = (p.get("symbol") or "").strip() - direction = p.get("direction") or "long" - lots = int(p.get("lots") or 0) - if lots <= 0: - continue - ctp_pairs.append((sym, direction)) - key = f"ctp:{sym.lower()}:{direction}" - seen.add(key) - entry = float(p.get("avg_price") or 0) - codes = ths_to_codes(sym) - mark = fetch_price( - sym, - codes.get("market_code", "") if codes else "", - codes.get("sina_code", "") if codes else "", - ) - spec = get_contract_spec(sym) - mult = spec["mult"] - float_pnl = p.get("pnl") - if mark is not None and entry > 0: - if direction == "long": - float_pnl = round((mark - entry) * mult * lots, 2) - else: - float_pnl = round((entry - mark) * mult * lots, 2) - tick = calc_order_tick_metrics(sym, lots, mark or entry) - rows.append({ - "key": key, - "source": "ctp", - "source_label": "CTP 柜台", - "symbol": codes.get("name", sym) if codes else sym, - "symbol_code": sym, - "direction": direction, - "direction_label": "做多" if direction == "long" else "做空", - "lots": lots, - "entry_price": entry, - "stop_loss": None, - "take_profit": None, - "mark_price": mark, - "float_pnl": float_pnl, - "tick_value_total": tick.get("tick_value_total"), - "price_precision": tick.get("price_precision"), - "tick_size": tick.get("tick_size"), - "can_close": True, - }) + # 程序监控仅用于补充止损/止盈,持仓以 CTP 柜台为准 + monitor_map: dict[tuple[str, str], dict] = {} + for r in conn.execute( + "SELECT * FROM trade_order_monitors WHERE status='active'" + ).fetchall(): + key = (r["symbol"].lower(), r["direction"]) + monitor_map[key] = dict(r) - def _dup_ctp(ths_sym: str, direction: str) -> bool: - for cs, d in ctp_pairs: - if d != direction: - continue - if cs.lower() == ths_sym.lower() or _match_ctp_symbol(cs, ths_sym): - return True - return False - - monitors = conn.execute( - "SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC" - ).fetchall() - for r in monitors: - sym = r["symbol"] - direction = r["direction"] - if _dup_ctp(sym, direction): + for p in _ctp_positions(mode): + sym = (p.get("symbol") or "").strip() + direction = p.get("direction") or "long" + lots = int(p.get("lots") or 0) + if lots <= 0: continue - key = f"mon:{sym.lower()}:{direction}" - entry = float(r["entry_price"] or 0) - sl = float(r["stop_loss"]) if r["stop_loss"] is not None else None - tp = float(r["take_profit"]) if r["take_profit"] is not None else None - lots = float(r["lots"] or 1) + entry = float(p.get("avg_price") or 0) + float_pnl = p.get("pnl") + if float_pnl is not None: + float_pnl = round(float(float_pnl), 2) codes = ths_to_codes(sym) - market = r["market_code"] or (codes.get("market_code", "") if codes else "") or "" - sina = codes.get("sina_code", "") if codes else "" - mark = fetch_price(sym, market, sina) - metrics = calc_position_metrics(direction, entry, sl or entry, tp or entry, lots, mark, capital, sym) - fee_info = calc_fee_breakdown(sym, entry, mark or entry, lots, r["open_time"] or "", now_iso) - est_net = None - if metrics.get("float_pnl") is not None: - est_net = round(metrics["float_pnl"] - fee_info["total_fee"], 2) + tick = calc_order_tick_metrics(sym, lots, entry) + mon = None + for (ms, md), mv in monitor_map.items(): + if md != direction: + continue + if ms == sym.lower() or _match_ctp_symbol(sym, ms): + mon = mv + break + sl = float(mon["stop_loss"]) if mon and mon.get("stop_loss") is not None else None + tp = float(mon["take_profit"]) if mon and mon.get("take_profit") is not None else None rows.append({ - "key": key, - "source": "program", - "source_label": r["monitor_type"] or "程序监控", - "monitor_id": r["id"], - "symbol": r["symbol_name"] or sym, + "key": f"ctp:{sym.lower()}:{direction}", + "source": "ctp", + "source_label": "CTP 柜台", + "monitor_id": mon["id"] if mon else None, + "symbol": codes.get("name", sym) if codes else sym, "symbol_code": sym, "direction": direction, "direction_label": "做多" if direction == "long" else "做空", @@ -209,69 +161,12 @@ def install_trading(app, *, login_required, get_db, get_setting, set_setting, fe "entry_price": entry, "stop_loss": sl, "take_profit": tp, - "mark_price": mark, - "open_time": r["open_time"], - "holding_duration": _holding_duration(r["open_time"] or "", now_iso), - "float_pnl": metrics.get("float_pnl"), - "float_pct": metrics.get("float_pct"), - "risk_pct": metrics.get("risk_pct"), - "risk_amount": metrics.get("risk_amount"), - "rr_ratio": metrics.get("rr_ratio"), - "margin": metrics.get("margin"), - "position_pct": metrics.get("position_pct"), - "est_fee": fee_info["total_fee"], - "est_pnl_net": est_net, - "can_close": ctp_st.get("connected"), - }) - - legacy = conn.execute( - "SELECT * FROM position_monitors WHERE status='active' ORDER BY id DESC" - ).fetchall() - for r in legacy: - sym = r["symbol"] - direction = r["direction"] - key = f"leg:{sym.lower()}:{direction}" - if any(x.get("symbol_code", "").lower() == sym.lower() and x.get("direction") == direction for x in rows): - continue - entry = float(r["entry_price"]) - sl = float(r["stop_loss"]) - tp = float(r["take_profit"]) - lots = float(r["lots"] or 1) - market = r["market_code"] or "" - sina = r["sina_code"] or "" - mark = fetch_price(sym, market, sina) - metrics = calc_position_metrics(direction, entry, sl, tp, lots, mark, capital, sym) - fee_info = calc_fee_breakdown(sym, entry, mark or entry, lots, r["open_time"] or "", now_iso) - est_net = None - if metrics.get("float_pnl") is not None: - est_net = round(metrics["float_pnl"] - fee_info["total_fee"], 2) - rows.append({ - "key": key, - "source": "legacy", - "source_label": "历史录入", - "legacy_id": r["id"], - "symbol": r["symbol_name"] or sym, - "symbol_code": sym, - "direction": direction, - "direction_label": "做多" if direction == "long" else "做空", - "lots": lots, - "entry_price": entry, - "stop_loss": sl, - "take_profit": tp, - "mark_price": mark, - "open_time": r["open_time"], - "holding_duration": _holding_duration(r["open_time"] or "", now_iso), - "float_pnl": metrics.get("float_pnl"), - "float_pct": metrics.get("float_pct"), - "risk_pct": metrics.get("risk_pct"), - "risk_amount": metrics.get("risk_amount"), - "rr_ratio": metrics.get("rr_ratio"), - "margin": metrics.get("margin"), - "position_pct": metrics.get("position_pct"), - "est_fee": fee_info["total_fee"], - "est_pnl_net": est_net, + "mark_price": None, + "float_pnl": float_pnl, + "tick_value_total": tick.get("tick_value_total"), + "price_precision": tick.get("price_precision"), + "tick_size": tick.get("tick_size"), "can_close": True, - "close_url": f"/close_position/{r['id']}", }) return rows @@ -516,6 +411,14 @@ def install_trading(app, *, login_required, get_db, get_setting, set_setting, fe price = float(d.get("price") or 0) except (TypeError, ValueError): return jsonify({"ok": False, "error": "手数或价格无效"}), 400 + order_type = (d.get("order_type") or d.get("price_type") or "limit").strip().lower() + if order_type == "market" and price <= 0: + codes = ths_to_codes(sym) + price = fetch_price( + sym, + codes.get("market_code", "") if codes else "", + codes.get("sina_code", "") if codes else "", + ) or 0 if not sym or price <= 0: return jsonify({"ok": False, "error": "品种或价格无效"}), 400 conn = get_db() @@ -547,6 +450,7 @@ def install_trading(app, *, login_required, get_db, get_setting, set_setting, fe lots=lots, price=price, settings=_settings_dict(), + order_type=order_type, ) if offset.startswith("open"): sl = d.get("stop_loss") diff --git a/requirements.txt b/requirements.txt index 52d1df5..2ef565b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,7 @@ python-dotenv==1.0.1 Werkzeug==3.0.3 matplotlib==3.9.2 akshare==1.18.64 -# 实盘 / 模拟 CTP(SimNow + 期货公司) -# pip install vnpy vnpy_ctp + +# CTP 下单:SimNow 模拟盘 / 期货公司实盘(见 docs/DEPLOY.md) +vnpy>=3.9.0 +vnpy_ctp>=6.7.11.4 diff --git a/static/css/trade.css b/static/css/trade.css index 61b8841..53162d8 100644 --- a/static/css/trade.css +++ b/static/css/trade.css @@ -3,30 +3,35 @@ .trade-dashboard{display:flex;flex-direction:column;gap:1.25rem} .trade-row-split{display:grid;grid-template-columns:1fr 1fr;gap:1.25rem;align-items:stretch} .trade-card{margin-bottom:0;height:100%;display:flex;flex-direction:column} -.trade-card h2{margin-bottom:.65rem;flex-shrink:0} +.trade-card h2{margin-bottom:.35rem;flex-shrink:0} .trade-card .card-body{flex:1;min-height:0} .trade-card-full{margin-bottom:0} +.pos-hint{font-size:.75rem;margin:-.15rem 0 .5rem .25rem;color:var(--text-muted)} .trade-order-status{display:grid;gap:.55rem;margin:.5rem 0 .75rem;padding:.65rem .85rem;background:var(--card-inner);border:1px solid var(--card-border);border-radius:8px;font-size:.82rem} .trade-order-status-compact{margin-top:0} .trade-order-status .status-row{display:flex;flex-wrap:wrap;align-items:center;gap:.35rem .65rem} -.trade-input-row,.trade-risk-row{display:grid;grid-template-columns:2fr 1fr 1fr;gap:.65rem;margin-bottom:.75rem} +.trade-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:.65rem;margin-bottom:.75rem} +.trade-form-grid .span-2{grid-column:span 2} .trade-field label{display:block;font-size:.72rem;margin-bottom:.25rem;color:var(--text-label)} -.trade-btn-row{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem;margin:.75rem 0} -.trade-btn{border:none;border-radius:8px;padding:.65rem .3rem;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:.12rem;color:#fff;font-weight:600} -.trade-btn .btn-price{font-size:1rem} -.trade-btn .btn-label{font-size:.82rem} -.trade-btn .btn-sub{font-size:.66rem;opacity:.85;font-weight:400} -.trade-btn.long{background:linear-gradient(180deg,#e74c3c,#c0392b)} -.trade-btn.lock{background:linear-gradient(180deg,#27ae60,#1e8449)} -.trade-btn.close{background:linear-gradient(180deg,#3498db,#2980b9)} +.trade-field select,.trade-field input{width:100%} +.price-type-tabs{display:flex;gap:.35rem;margin-bottom:.35rem} +.price-tab{border:1px solid var(--card-border);background:var(--card-inner);color:var(--text-muted);padding:.25rem .65rem;border-radius:6px;font-size:.75rem;cursor:pointer} +.price-tab.active{border-color:var(--accent);color:var(--accent);font-weight:600} +.market-hint{font-size:.7rem;margin-top:.25rem} +.calc-lots-row{display:flex;gap:.4rem} +.calc-lots-row input{flex:1} +.calc-lots-row .btn-secondary{padding:.35rem .6rem;font-size:.75rem;white-space:nowrap} +.trade-action-row{display:flex;gap:.65rem;margin:.75rem 0 .5rem} +.trade-action-row .btn-open{flex:1;padding:.65rem} .trade-footer{background:var(--card-inner);border-radius:8px;padding:.65rem .85rem;font-size:.78rem;line-height:1.5;border:1px solid var(--card-border);margin-top:.5rem} .trade-footer strong{color:var(--accent)} .rec-blocked td{opacity:.55} .rec-ok td:first-child{font-weight:600} -#positions .card-body{max-height:480px;overflow-y:auto} +#positions .card-body{max-height:460px;overflow-y:auto} @media (max-width:900px){ .trade-row-split{grid-template-columns:1fr} #positions .card-body{max-height:360px} - .trade-btn-row{grid-template-columns:repeat(2,1fr)} + .trade-form-grid{grid-template-columns:1fr} + .trade-form-grid .span-2{grid-column:span 1} } diff --git a/static/js/trade.js b/static/js/trade.js index 340b338..4473f70 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -1,15 +1,21 @@ (function () { + var sizingMode = window.TRADE_SIZING_MODE || 'risk'; var list = document.getElementById('position-live-list'); var recommendList = document.getElementById('recommend-list'); var symInput = document.getElementById('trade-symbol'); + var dirSelect = document.getElementById('trade-direction'); var lotsInput = document.getElementById('trade-lots'); + var lotsCalc = document.getElementById('trade-lots-calc'); var priceInput = document.getElementById('trade-price'); - var footer = document.getElementById('trade-footer'); var slInput = document.getElementById('trade-sl'); var tpInput = document.getElementById('trade-tp'); + var marketHint = document.getElementById('market-hint'); + var metricsHint = document.getElementById('trade-metrics-hint'); var pollTimer = null; var recommendSource = null; var quoteTimer = null; + var lastQuotePrice = null; + var priceType = 'limit'; function runWhenReady(fn) { if (document.readyState === 'loading') { @@ -28,37 +34,54 @@ return (symInput && symInput.value || '').trim(); } + function isRiskMode() { + return sizingMode === 'risk'; + } + + function effectiveLots() { + if (isRiskMode()) { + var v = parseInt(lotsCalc && lotsCalc.value, 10); + return v > 0 ? v : 0; + } + return parseInt(lotsInput && lotsInput.value, 10) || 1; + } + + function entryPrice() { + if (priceType === 'market') return lastQuotePrice; + return parseFloat(priceInput && priceInput.value) || 0; + } + + function setPriceType(type) { + priceType = type === 'market' ? 'market' : 'limit'; + document.querySelectorAll('.price-tab').forEach(function (btn) { + btn.classList.toggle('active', btn.getAttribute('data-type') === priceType); + }); + if (priceInput) { + priceInput.disabled = priceType === 'market'; + if (priceType === 'market' && lastQuotePrice) priceInput.value = lastQuotePrice; + } + if (marketHint) marketHint.hidden = priceType !== 'market'; + } + function refreshQuote() { var sym = selectedSymbol(); - var lots = lotsInput ? lotsInput.value : '1'; + var lots = isRiskMode() ? (effectiveLots() || 1) : (lotsInput ? lotsInput.value : '1'); if (!sym) return; fetch('/api/trade/quote?symbol=' + encodeURIComponent(sym) + '&lots=' + encodeURIComponent(lots)) .then(function (r) { return r.json(); }) .then(function (data) { if (!data.ok) return; - if (priceInput && !priceInput.dataset.manual && data.price) { + lastQuotePrice = data.price; + if (priceType === 'market' && priceInput && data.price) { + priceInput.value = data.price; + } else if (priceInput && !priceInput.dataset.manual && data.price) { priceInput.value = data.price; } - var px = data.price != null ? data.price : '—'; - ['px-long', 'px-short'].forEach(function (id) { - var el = document.getElementById(id); - if (el) el.textContent = px; - }); - var pl = document.getElementById('pos-long'); - var ps = document.getElementById('pos-short'); - if (pl) pl.textContent = '≤' + (data.pos_long || 0); - if (ps) ps.textContent = '≤' + (data.pos_short || 0); - if (footer && data.metrics) { + if (metricsHint && data.metrics) { var m = data.metrics; - var hint = footer.querySelector('.hint'); - var extra = - '
' + (data.name || sym) + ' 精度 ' + m.price_precision + - ' 位 · 每跳 ' + m.tick_value_total + ' 元(' + lots + ' 手)
'; - if (hint) { - hint.insertAdjacentHTML('afterend', extra); - var olds = footer.querySelectorAll('p:not(.hint):not(.text-loss)'); - for (var i = 0; i < olds.length - 1; i++) olds[i].remove(); - } + metricsHint.innerHTML = + '' + (data.name || sym) + ' 精度 ' + m.price_precision + + ' 位 · 每跳 ' + m.tick_value_total + ' 元(' + lots + ' 手)'; } }).catch(function () {}); } @@ -68,17 +91,63 @@ quoteTimer = setTimeout(refreshQuote, 400); } - function postOrder(offset, direction) { + function calcLotsPreview() { + var sym = selectedSymbol(); + var entry = entryPrice() || parseFloat(priceInput && priceInput.value) || 0; + var sl = parseFloat(slInput && slInput.value) || 0; + if (!sym || !entry || !sl) { + alert('请填写品种、入场价与止损'); + return; + } + fetch('/api/trade/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + symbol: sym, + direction: dirSelect ? dirSelect.value : 'long', + entry: entry, + price: entry, + stop_loss: sl, + take_profit: parseFloat(tpInput && tpInput.value) || 0 + }) + }).then(function (r) { return r.json(); }).then(function (data) { + if (!data.ok) { alert(data.error || '计算失败'); return; } + if (lotsCalc) lotsCalc.value = data.lots; + scheduleQuote(); + }); + } + + function postOrder(offset) { var sym = selectedSymbol(); if (!sym) { alert('请选择品种'); return; } + var direction = dirSelect ? dirSelect.value : 'long'; + var price = entryPrice(); + if (!price || price <= 0) { + alert('无法获取有效价格,请先填写或刷新行情'); + return; + } + var lots = effectiveLots(); + if (offset === 'open') { + if (isRiskMode() && lots <= 0) { + alert('请先点击「计算」得到手数'); + return; + } + if (!isRiskMode() && lots <= 0) { + alert('请填写手数'); + return; + } + } else { + lots = parseInt(lotsInput && lotsInput.value, 10) || 1; + } var body = { symbol: sym, offset: offset, direction: direction, - lots: parseInt(lotsInput.value, 10) || 1, - price: parseFloat(priceInput.value) || 0, - stop_loss: slInput ? parseFloat(slInput.value) : null, - take_profit: tpInput ? parseFloat(tpInput.value) : null + lots: lots, + price: price, + order_type: priceType, + stop_loss: slInput && slInput.value ? parseFloat(slInput.value) : null, + take_profit: tpInput && tpInput.value ? parseFloat(tpInput.value) : null }; fetch('/api/trade/order', { method: 'POST', @@ -86,7 +155,7 @@ body: JSON.stringify(body) }).then(function (r) { return r.json(); }).then(function (data) { if (!data.ok) { alert(data.error || '下单失败'); return; } - alert('已提交 ' + (data.lots || '') + ' 手'); + alert((offset === 'open' ? '开仓' : '平仓') + '已提交 ' + (data.lots || lots) + ' 手'); pollPositions(); refreshQuote(); }); @@ -94,50 +163,47 @@ function buildPosCard(row) { var pnlClass = row.float_pnl > 0 ? 'pnl-pos' : (row.float_pnl < 0 ? 'pnl-neg' : ''); - var pnlText = '--'; - if (row.float_pnl != null) { - var sign = row.float_pnl >= 0 ? '+' : ''; - pnlText = sign + fmtNum(row.float_pnl) + '元'; - if (row.float_pct != null) pnlText += ' (' + sign + fmtNum(row.float_pct) + '%)'; - } - var rr = row.rr_ratio != null ? row.rr_ratio + ':1' : '--'; - var openT = (row.open_time || '').replace('T', ' ').slice(0, 16); + var pnlText = row.float_pnl != null ? ((row.float_pnl >= 0 ? '+' : '') + fmtNum(row.float_pnl) + ' 元') : '--'; var dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空'); - var closeBtn = ''; - if (row.close_url) { - closeBtn = ''; - } else if (row.can_close) { - closeBtn = ''; - } + }) + '\'>平仓' : ''; return ( - '市价将按最新行情价报单
+数据来自 CTP 柜台(交易所回报),浮盈等为柜台实际值。