diff --git a/app.py b/app.py index 9ad1403..7c55abe 100644 --- a/app.py +++ b/app.py @@ -886,16 +886,6 @@ def keys(): return render_template("keys.html", keys=key_list, history=history) -@app.route("/positions") -@login_required -def positions(): - conn = get_db() - pos_list = conn.execute( - "SELECT * FROM position_monitors WHERE status='active' ORDER BY id DESC" - ).fetchall() - conn.close() - return render_template("positions.html", positions=pos_list) - @app.route("/add_key", methods=["POST"]) @login_required @@ -928,36 +918,7 @@ def add_key(): @app.route("/add_position", methods=["POST"]) @login_required def add_position(): - d = request.form - symbol = d.get("symbol", "").strip() - symbol_name = d.get("symbol_name", "").strip() - market_code = d.get("market_code", "").strip() - sina_code = d.get("sina_code", "").strip() - if not symbol or not market_code: - flash("请从下拉列表选择品种") - return redirect(url_for("positions")) - entry = float(d["entry_price"]) - sl = float(d["stop_loss"]) - tp = float(d["take_profit"]) - direction = d.get("direction", "").strip() - if not direction: - direction = "long" if sl < entry else "short" - open_time = d.get("open_time", "").strip() - lots = float(d.get("lots") or 1) - conn = get_db() - conn.execute( - """INSERT INTO position_monitors - (symbol, symbol_name, market_code, sina_code, direction, - lots, entry_price, stop_loss, take_profit, open_time) - VALUES (?,?,?,?,?,?,?,?,?,?)""", - ( - symbol, symbol_name, market_code, sina_code, direction, - lots, entry, sl, tp, open_time, - ), - ) - conn.commit() - conn.close() - flash("持仓已添加") + flash("持仓由策略交易或 CTP 自动同步,无需手工录入") return redirect(url_for("positions")) diff --git a/install_trading.py b/install_trading.py index 371baaf..537a24b 100644 --- a/install_trading.py +++ b/install_trading.py @@ -8,6 +8,7 @@ from typing import Any, Callable from flask import flash, jsonify, redirect, render_template, request, url_for from contract_specs import calc_position_metrics, get_contract_spec +from fee_specs import calc_fee_breakdown from position_sizing import ( MODE_FIXED, MODE_RISK, @@ -98,32 +99,264 @@ def install_trading(app, *, login_required, get_db, get_setting, set_setting, fe except Exception: return False + def _holding_duration(open_time: str, now_iso: str) -> str: + try: + from app import calc_holding_duration + return calc_holding_duration(open_time, now_iso) + except Exception: + return "" + + def _build_trading_live_rows(conn) -> list[dict]: + 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 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, + }) + + 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): + 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) + 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) + rows.append({ + "key": key, + "source": "program", + "source_label": r["monitor_type"] or "程序监控", + "monitor_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, + "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, + "can_close": True, + "close_url": f"/close_position/{r['id']}", + }) + return rows + @app.route("/trade") @login_required def trade_page(): + return redirect(url_for("positions")) + + @app.route("/positions") + @login_required + def positions(): conn = get_db() init_strategy_tables(conn) mode = get_trading_mode(get_setting) ctp_st = ctp_status(mode) capital = _capital(conn) - sizing = get_sizing_mode(get_setting) risk = get_risk_status(conn) ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {} - positions = _ctp_positions(mode) if ctp_st.get("connected") else [] conn.close() return render_template( "trade.html", trading_mode=mode, trading_mode_label=trading_mode_label(get_setting), - sizing_mode=sizing, - risk_percent=get_risk_percent(get_setting), capital=capital, risk_status=risk, ctp_status=ctp_st, ctp_account=ctp_acc, - ctp_positions=positions, ) + @app.route("/api/trading/live") + @login_required + def api_trading_live(): + conn = get_db() + init_strategy_tables(conn) + mode = get_trading_mode(get_setting) + ctp_st = ctp_status(mode) + rows = _build_trading_live_rows(conn) + conn.close() + return jsonify({ + "rows": rows, + "capital": _capital(get_db()), + "ctp_status": ctp_st, + "trading_mode_label": trading_mode_label(get_setting), + }) + + @app.route("/api/trading/close", methods=["POST"]) + @login_required + def api_trading_close(): + d = request.get_json(silent=True) or {} + source = (d.get("source") or "").strip() + conn = get_db() + init_strategy_tables(conn) + mode = get_trading_mode(get_setting) + if not ctp_status(mode).get("connected") and source in ("ctp", "program"): + conn.close() + return jsonify({"ok": False, "error": "请先连接 CTP"}), 400 + sym = (d.get("symbol_code") or d.get("symbol") or "").strip() + direction = (d.get("direction") or "long").strip().lower() + try: + lots = max(1, int(d.get("lots") or 1)) + price = float(d.get("price") or 0) + except (TypeError, ValueError): + conn.close() + return jsonify({"ok": False, "error": "参数无效"}), 400 + if not sym or price <= 0: + conn.close() + return jsonify({"ok": False, "error": "品种或价格无效"}), 400 + offset = "close_long" if direction == "long" else "close_short" + try: + execute_order( + conn, mode=mode, offset=offset, symbol=sym, direction=direction, + lots=lots, price=price, settings=_settings_dict(), + ) + if source == "program": + mid = int(d.get("monitor_id") or 0) + if mid: + conn.execute( + "UPDATE trade_order_monitors SET status='closed' WHERE id=?", + (mid,), + ) + conn.commit() + conn.close() + return jsonify({"ok": True}) + except ValueError as exc: + conn.close() + return jsonify({"ok": False, "error": str(exc)}), 400 + @app.route("/recommend") @login_required def recommend_page(): diff --git a/static/css/trade.css b/static/css/trade.css index d715958..29d9822 100644 --- a/static/css/trade.css +++ b/static/css/trade.css @@ -1,20 +1,9 @@ -.trade-page{max-width:720px;margin:0 auto} +.trade-page{max-width:960px;margin:0 auto} .trade-top-bar{display:flex;flex-wrap:wrap;gap:.65rem;align-items:center;margin-bottom:1rem} -.trade-order-card{padding:1.25rem} -.trade-tabs{display:flex;gap:1rem;margin-bottom:1rem;font-size:.88rem} -.trade-tabs span.active{color:var(--accent);font-weight:600;border-bottom:2px solid var(--accent);padding-bottom:.25rem} -.trade-tabs a{color:var(--text-muted);text-decoration:none} -.trade-input-row,.trade-risk-row{display:grid;grid-template-columns:2fr 1fr 1fr;gap:.65rem;margin-bottom:.75rem} -.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:1rem 0} -.trade-btn{border:none;border-radius:8px;padding:.75rem .35rem;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:.15rem;color:#fff;font-weight:600} -.trade-btn .btn-price{font-size:1.1rem} -.trade-btn .btn-label{font-size:.85rem} -.trade-btn .btn-sub{font-size:.68rem;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-footer{background:var(--card-inner);border-radius:8px;padding:.75rem 1rem;font-size:.82rem;line-height:1.55;border:1px solid var(--card-border)} +.trade-subnav{display:flex;gap:1rem;margin-bottom:1rem;font-size:.88rem} +.trade-subnav span.active{color:var(--accent);font-weight:600;border-bottom:2px solid var(--accent);padding-bottom:.25rem} +.trade-subnav a{color:var(--text-muted);text-decoration:none} +.trade-footer{background:var(--card-inner);border-radius:8px;padding:.75rem 1rem;font-size:.82rem;line-height:1.55;border:1px solid var(--card-border);margin-top:1rem} .trade-footer strong{color:var(--accent)} .rec-blocked td{opacity:.55} .rec-ok td:first-child{font-weight:600} diff --git a/static/js/trade.js b/static/js/trade.js index 33bed49..814ea46 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -1,95 +1,142 @@ (function () { - var symInput = document.getElementById('trade-symbol'); - var lotsInput = document.getElementById('trade-lots'); - 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 debounceTimer; + var list = document.getElementById('position-live-list'); + var pollTimer = null; - function selectedSymbol() { - return (symInput && symInput.value || '').trim(); + function fmtNum(v, digits) { + if (v === null || v === undefined) return '--'; + return Number(v).toFixed(digits === undefined ? 2 : digits); } - function refreshQuote() { - var sym = selectedSymbol(); - var lots = 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) { - 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 ml = document.getElementById('max-long'); - var ms = document.getElementById('max-short'); - if (ml) ml.textContent = '≤' + (data.max_open_long || '—'); - if (ms) ms.textContent = '≤' + (data.max_open_short || '—'); - document.getElementById('pos-long').textContent = '≤' + (data.pos_long || 0); - document.getElementById('pos-short').textContent = '≤' + (data.pos_short || 0); - if (footer && data.metrics) { - var m = data.metrics; - footer.innerHTML = - '
' + (data.name || sym) + ' ' + (data.footer_text || '') + '
' + - '价格精度 ' + m.price_precision + ' 位 · ' + - '最小变动 ' + m.tick_size + ' · ' + - '每跳 ' + m.tick_value_per_lot + ' 元/手 · ' + - '当前 ' + lots + ' 手每跳合计 ' + m.tick_value_total + ' 元
' + - (m.margin_total ? '预估保证金约 ' + m.margin_total + ' 元
' : ''); - } - }).catch(function () {}); + function buildPosCard(row) { + var pnlClass = ''; + if (row.float_pnl > 0) pnlClass = 'pnl-pos'; + if (row.float_pnl < 0) pnlClass = '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 dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空'); + var closeBtn = ''; + + if (row.close_url) { + closeBtn = + ''; + } else if (row.can_close) { + closeBtn = + ''; + } + + var metaParts = ['来源 ' + (row.source_label || row.source) + '']; + if (row.risk_pct != null) { + metaParts.push('风险 ' + fmtNum(row.risk_pct) + '%≈' + fmtNum(row.risk_amount) + '元'); + } + if (row.tick_value_total != null) { + metaParts.push('每跳 ' + fmtNum(row.tick_value_total) + '元'); + } + + var slTp = + '