feat: 持仓保证金占比与止盈止损自动委托守护
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+229
-8
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
@@ -18,10 +19,17 @@ from position_sizing import (
|
||||
calc_order_tick_metrics,
|
||||
normalize_sizing_mode,
|
||||
)
|
||||
from recommend_store import load_recommend_cache, refresh_recommend_cache
|
||||
from recommend_store import load_recommend_cache, recommend_payload, refresh_recommend_cache
|
||||
from recommend_stream import recommend_hub, start_recommend_worker
|
||||
from ctp_reconnect import start_ctp_reconnect_worker
|
||||
from ctp_fee_worker import start_ctp_fee_worker
|
||||
from sl_tp_guard import (
|
||||
ensure_monitor_order_columns,
|
||||
monitor_order_status,
|
||||
place_monitor_exit_orders,
|
||||
start_sl_tp_guard_worker,
|
||||
sync_all_sl_tp_orders,
|
||||
)
|
||||
from risk.account_risk_lib import (
|
||||
assert_can_open,
|
||||
get_risk_status,
|
||||
@@ -58,6 +66,9 @@ from vnpy_bridge import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def install_trading(app, *, login_required, require_nav, get_db, get_setting, set_setting, fetch_price, send_wechat_msg):
|
||||
"""注册交易相关路由。"""
|
||||
_nav = require_nav
|
||||
@@ -238,10 +249,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
||||
mode = get_trading_mode(get_setting)
|
||||
ctp_st = ctp_status(mode)
|
||||
rows: list[dict] = []
|
||||
capital = _capital(conn)
|
||||
|
||||
if not ctp_st.get("connected"):
|
||||
return rows
|
||||
|
||||
ensure_monitor_order_columns(conn)
|
||||
|
||||
# 程序监控仅用于补充止损/止盈,持仓以 CTP 柜台为准
|
||||
monitor_map: dict[tuple[str, str], dict] = {}
|
||||
for r in conn.execute(
|
||||
@@ -271,6 +285,46 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
||||
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
|
||||
open_time = (mon.get("open_time") or "") if mon else ""
|
||||
holding = _holding_duration(open_time, now_iso) if open_time else ""
|
||||
mark = None
|
||||
if codes:
|
||||
mark = fetch_price(
|
||||
sym,
|
||||
codes.get("market_code", ""),
|
||||
codes.get("sina_code", ""),
|
||||
)
|
||||
close_est = float(mark) if mark and mark > 0 else entry
|
||||
fee_info = calc_fee_breakdown(
|
||||
sym,
|
||||
entry,
|
||||
close_est,
|
||||
lots,
|
||||
open_time or now_iso,
|
||||
now_iso,
|
||||
trading_mode=mode,
|
||||
)
|
||||
est_net = None
|
||||
if float_pnl is not None:
|
||||
est_net = round(float(float_pnl) - fee_info["total_fee"], 2)
|
||||
pos_metrics = calc_position_metrics(
|
||||
direction,
|
||||
entry,
|
||||
sl if sl is not None else entry,
|
||||
tp if tp is not None else entry,
|
||||
lots,
|
||||
mark,
|
||||
capital,
|
||||
sym,
|
||||
)
|
||||
order_st = monitor_order_status(
|
||||
mon or {}, mode=mode, ths_code=sym, direction=direction,
|
||||
)
|
||||
can_place = bool(
|
||||
mon
|
||||
and (mon.get("stop_loss") is not None or mon.get("take_profit") is not None)
|
||||
and (order_st.get("needs_sl_order") or order_st.get("needs_tp_order"))
|
||||
)
|
||||
pending_for_row: list[dict] = []
|
||||
if sl is not None:
|
||||
pending_for_row.append({
|
||||
@@ -303,8 +357,21 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
||||
"entry_price": entry,
|
||||
"stop_loss": sl,
|
||||
"take_profit": tp,
|
||||
"mark_price": None,
|
||||
"open_time": open_time or None,
|
||||
"holding_duration": holding or None,
|
||||
"mark_price": mark,
|
||||
"current_price": mark,
|
||||
"margin": pos_metrics.get("margin"),
|
||||
"position_pct": pos_metrics.get("position_pct"),
|
||||
"float_pnl": float_pnl,
|
||||
"est_fee": fee_info["total_fee"],
|
||||
"est_fee_open": fee_info["open_fee"],
|
||||
"est_fee_close": fee_info["close_fee"],
|
||||
"est_fee_close_type": fee_info["close_type"],
|
||||
"est_pnl_net": est_net,
|
||||
"sl_order_active": order_st.get("sl_order_active"),
|
||||
"tp_order_active": order_st.get("tp_order_active"),
|
||||
"can_place_orders": can_place,
|
||||
"tick_value_total": tick.get("tick_value_total"),
|
||||
"price_precision": tick.get("price_precision"),
|
||||
"tick_size": tick.get("tick_size"),
|
||||
@@ -341,7 +408,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
||||
).fetchone()["n"]
|
||||
conn.commit()
|
||||
sizing = get_sizing_mode(get_setting)
|
||||
rec_cache = load_recommend_cache(conn)
|
||||
rec_cache = recommend_payload(conn, live_capital=capital)
|
||||
return render_template(
|
||||
"trade.html",
|
||||
trading_mode=mode,
|
||||
@@ -376,6 +443,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
||||
mode = get_trading_mode(get_setting)
|
||||
ctp_st = ctp_status(mode)
|
||||
_sync_trade_monitors_with_ctp(conn, mode)
|
||||
sync_all_sl_tp_orders(conn, mode)
|
||||
rows = _build_trading_live_rows(conn)
|
||||
pending_orders = _build_pending_orders(conn, mode)
|
||||
capital = _capital(conn)
|
||||
@@ -392,6 +460,143 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/api/trading/monitor/upsert", methods=["POST"])
|
||||
@login_required
|
||||
def api_trading_monitor_upsert():
|
||||
"""为已有 CTP 持仓补充/更新本地止盈止损监控。"""
|
||||
d = request.get_json(silent=True) or {}
|
||||
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))
|
||||
entry = float(d.get("entry_price") or d.get("entry") or 0)
|
||||
sl = float(d["stop_loss"]) if d.get("stop_loss") not in (None, "") else None
|
||||
tp = float(d["take_profit"]) if d.get("take_profit") not in (None, "") else None
|
||||
except (TypeError, ValueError, KeyError):
|
||||
return jsonify({"ok": False, "error": "参数无效"}), 400
|
||||
if not sym:
|
||||
return jsonify({"ok": False, "error": "缺少品种代码"}), 400
|
||||
if sl is None and tp is None:
|
||||
return jsonify({"ok": False, "error": "请至少填写止损或止盈"}), 400
|
||||
mode = get_trading_mode(get_setting)
|
||||
if not ctp_status(mode).get("connected"):
|
||||
return jsonify({"ok": False, "error": "请先连接 CTP"}), 400
|
||||
has_pos = False
|
||||
for p in _ctp_positions(mode):
|
||||
if int(p.get("lots") or 0) <= 0:
|
||||
continue
|
||||
if (p.get("direction") or "long") != direction:
|
||||
continue
|
||||
if _match_ctp_symbol(p.get("symbol") or "", sym):
|
||||
has_pos = True
|
||||
lots = int(p.get("lots") or lots)
|
||||
entry = float(p.get("avg_price") or entry or 0)
|
||||
sym = (p.get("symbol") or sym).strip()
|
||||
break
|
||||
if not has_pos:
|
||||
return jsonify({"ok": False, "error": "柜台无对应持仓"}), 400
|
||||
conn = get_db()
|
||||
try:
|
||||
init_strategy_tables(conn)
|
||||
mon = None
|
||||
for r in conn.execute(
|
||||
"SELECT * FROM trade_order_monitors WHERE status='active'"
|
||||
).fetchall():
|
||||
row = dict(r)
|
||||
if row.get("direction") != direction:
|
||||
continue
|
||||
if _match_ctp_symbol(sym, row.get("symbol") or ""):
|
||||
mon = row
|
||||
break
|
||||
codes = ths_to_codes(sym)
|
||||
now_s = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if mon:
|
||||
conn.execute(
|
||||
"""UPDATE trade_order_monitors SET stop_loss=?, take_profit=?, lots=?, entry_price=?
|
||||
WHERE id=?""",
|
||||
(sl, tp, lots, entry or mon.get("entry_price"), mon["id"]),
|
||||
)
|
||||
mid = mon["id"]
|
||||
else:
|
||||
conn.execute(
|
||||
"""INSERT INTO trade_order_monitors (
|
||||
symbol, symbol_name, market_code, direction, lots, entry_price,
|
||||
stop_loss, take_profit, open_time, monitor_type, status
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?, 'active')""",
|
||||
(
|
||||
sym,
|
||||
codes.get("name", sym) if codes else sym,
|
||||
codes.get("market_code", "") if codes else "",
|
||||
direction,
|
||||
lots,
|
||||
entry,
|
||||
sl,
|
||||
tp,
|
||||
now_s,
|
||||
"manual",
|
||||
),
|
||||
)
|
||||
mid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
conn.commit()
|
||||
mon_row = conn.execute(
|
||||
"SELECT * FROM trade_order_monitors WHERE id=?", (mid,),
|
||||
).fetchone()
|
||||
if mon_row and (sl is not None or tp is not None):
|
||||
try:
|
||||
ensure_monitor_order_columns(conn)
|
||||
place_monitor_exit_orders(conn, dict(mon_row), mode=mode, force=False)
|
||||
except Exception as exc:
|
||||
logger.warning("补充止盈止损后自动委托失败: %s", exc)
|
||||
return jsonify({"ok": True, "monitor_id": mid, "message": "止盈止损已保存"})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/api/trading/monitor/place-orders", methods=["POST"])
|
||||
@login_required
|
||||
def api_trading_monitor_place_orders():
|
||||
"""按开仓快照向 CTP 挂止盈止损平仓委托。"""
|
||||
d = request.get_json(silent=True) or {}
|
||||
try:
|
||||
monitor_id = int(d.get("monitor_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
monitor_id = 0
|
||||
conn = get_db()
|
||||
try:
|
||||
init_strategy_tables(conn)
|
||||
ensure_monitor_order_columns(conn)
|
||||
mode = get_trading_mode(get_setting)
|
||||
if not ctp_status(mode).get("connected"):
|
||||
return jsonify({"ok": False, "error": "请先连接 CTP"}), 400
|
||||
mon = None
|
||||
if monitor_id > 0:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM trade_order_monitors WHERE id=? AND status='active'",
|
||||
(monitor_id,),
|
||||
).fetchone()
|
||||
mon = dict(row) if row else None
|
||||
if not mon:
|
||||
sym = (d.get("symbol_code") or "").strip()
|
||||
direction = (d.get("direction") or "long").strip().lower()
|
||||
for r in conn.execute(
|
||||
"SELECT * FROM trade_order_monitors WHERE status='active'"
|
||||
).fetchall():
|
||||
row = dict(r)
|
||||
if row.get("direction") != direction:
|
||||
continue
|
||||
if _match_ctp_symbol(sym, row.get("symbol") or ""):
|
||||
mon = row
|
||||
break
|
||||
if not mon:
|
||||
return jsonify({"ok": False, "error": "未找到有效监控快照"}), 404
|
||||
result = place_monitor_exit_orders(
|
||||
conn, mon, mode=mode, force=bool(d.get("force")),
|
||||
)
|
||||
if not result.get("ok"):
|
||||
return jsonify(result), 400
|
||||
return jsonify(result)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/api/trading/monitor/dismiss", methods=["POST"])
|
||||
@login_required
|
||||
def api_trading_monitor_dismiss():
|
||||
@@ -646,7 +851,6 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
||||
if offset.startswith("open"):
|
||||
sl = d.get("stop_loss")
|
||||
tp = d.get("take_profit")
|
||||
if sl or tp:
|
||||
import time
|
||||
time.sleep(2.0)
|
||||
actual_lots = lots
|
||||
@@ -680,6 +884,16 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
||||
"manual",
|
||||
),
|
||||
)
|
||||
mid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
mon_row = conn.execute(
|
||||
"SELECT * FROM trade_order_monitors WHERE id=?", (mid,),
|
||||
).fetchone()
|
||||
if mon_row and (sl or tp):
|
||||
try:
|
||||
ensure_monitor_order_columns(conn)
|
||||
place_monitor_exit_orders(conn, dict(mon_row), mode=mode, force=False)
|
||||
except Exception as exc:
|
||||
logger.warning("开仓后自动挂止盈止损失败: %s", exc)
|
||||
conn.commit()
|
||||
send_wechat_msg(f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}")
|
||||
conn.close()
|
||||
@@ -760,7 +974,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
||||
"""只读数据库缓存,不在请求时拉行情。"""
|
||||
conn = get_db()
|
||||
try:
|
||||
payload = load_recommend_cache(conn)
|
||||
payload = recommend_payload(conn, live_capital=_capital(conn))
|
||||
return jsonify({"ok": True, **payload})
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -775,7 +989,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
||||
try:
|
||||
conn = get_db()
|
||||
try:
|
||||
payload = load_recommend_cache(conn)
|
||||
payload = recommend_payload(conn, live_capital=_capital(conn))
|
||||
finally:
|
||||
conn.close()
|
||||
yield sse_format("recommend", {"ok": True, **payload})
|
||||
@@ -806,8 +1020,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
||||
try:
|
||||
init_strategy_tables(conn)
|
||||
capital = _capital(conn)
|
||||
rows = refresh_recommend_cache(conn, capital, _main_quote)
|
||||
payload = load_recommend_cache(conn)
|
||||
mode = get_trading_mode(get_setting)
|
||||
rows = refresh_recommend_cache(conn, capital, _main_quote, trading_mode=mode)
|
||||
payload = recommend_payload(conn, live_capital=capital)
|
||||
recommend_hub.broadcast("recommend", {"ok": True, **payload})
|
||||
return jsonify({"ok": True, "count": len(rows), **payload})
|
||||
finally:
|
||||
@@ -1139,8 +1354,14 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
||||
get_capital_fn=_capital,
|
||||
quote_fn=_main_quote,
|
||||
init_tables_fn=_init_tables,
|
||||
get_mode_fn=lambda: get_trading_mode(get_setting),
|
||||
)
|
||||
start_ctp_reconnect_worker(get_mode_fn=lambda: get_trading_mode(get_setting))
|
||||
start_sl_tp_guard_worker(
|
||||
db_path=DB_PATH,
|
||||
get_mode_fn=lambda: get_trading_mode(get_setting),
|
||||
init_tables_fn=_init_tables,
|
||||
)
|
||||
start_ctp_fee_worker(
|
||||
get_mode_fn=lambda: get_trading_mode(get_setting),
|
||||
get_setting_fn=get_setting,
|
||||
|
||||
+17
-1
@@ -5,6 +5,7 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Callable, Optional
|
||||
|
||||
from contract_specs import get_contract_spec
|
||||
from fee_specs import calc_fee_breakdown
|
||||
from symbols import PRODUCTS
|
||||
|
||||
|
||||
@@ -21,6 +22,8 @@ def assess_product_for_capital(
|
||||
*,
|
||||
max_position_pct: float = 50.0,
|
||||
default_stop_ticks: int = 20,
|
||||
reward_risk_ratio: float = 2.0,
|
||||
trading_mode: str = "simulation",
|
||||
) -> dict:
|
||||
"""评估单品种在当前资金下是否可交易。"""
|
||||
ths = product.get("ths") or ""
|
||||
@@ -50,6 +53,12 @@ def assess_product_for_capital(
|
||||
stop_dist = tick * default_stop_ticks
|
||||
risk_one_lot = stop_dist * mult
|
||||
risk_pct_1lot = (risk_one_lot / cap * 100) if cap > 0 else 999.0
|
||||
ref_sl = round(p - stop_dist, 4)
|
||||
ref_tp = round(p + stop_dist * reward_risk_ratio, 4)
|
||||
fee_ths = ths + "8888"
|
||||
fee_info = calc_fee_breakdown(
|
||||
fee_ths, p, p, 1.0, open_time="", close_time="", trading_mode=trading_mode,
|
||||
)
|
||||
|
||||
can_margin = cap >= min_capital
|
||||
can_risk = cap > 0 and risk_one_lot <= cap * 0.01
|
||||
@@ -72,6 +81,10 @@ def assess_product_for_capital(
|
||||
"min_capital_one_lot": round(min_capital, 2),
|
||||
"risk_one_lot_1pct": round(risk_one_lot, 2),
|
||||
"risk_pct_1lot_at_1pct_rule": round(risk_pct_1lot, 2),
|
||||
"ref_stop_loss": ref_sl,
|
||||
"ref_take_profit": ref_tp,
|
||||
"open_fee_one_lot": fee_info["open_fee"],
|
||||
"roundtrip_fee_one_lot": fee_info["total_fee"],
|
||||
"status": status,
|
||||
"status_label": label,
|
||||
}
|
||||
@@ -82,6 +95,7 @@ def list_product_recommendations(
|
||||
quote_fn: Callable[[str], Optional[dict]],
|
||||
*,
|
||||
max_position_pct: float = 50.0,
|
||||
trading_mode: str = "simulation",
|
||||
) -> list[dict]:
|
||||
"""扫描全部品种并排序:推荐 > 可开 > 不足。quote_fn(品种代码) -> {price, ths_code, ...}"""
|
||||
|
||||
@@ -90,7 +104,9 @@ def list_product_recommendations(
|
||||
quote = quote_fn(ths) or {}
|
||||
price = quote.get("price")
|
||||
row = assess_product_for_capital(
|
||||
product, capital, price, max_position_pct=max_position_pct
|
||||
product, capital, price,
|
||||
max_position_pct=max_position_pct,
|
||||
trading_mode=trading_mode,
|
||||
)
|
||||
main_code = (quote.get("ths_code") or "").strip()
|
||||
row["main_code"] = main_code
|
||||
|
||||
+26
-3
@@ -30,10 +30,12 @@ def refresh_recommend_cache(
|
||||
conn,
|
||||
capital: float,
|
||||
quote_fn: Callable[[str], Optional[dict]],
|
||||
*,
|
||||
trading_mode: str = "simulation",
|
||||
) -> list[dict]:
|
||||
"""后台拉行情、筛选并写入数据库。"""
|
||||
ensure_recommend_tables(conn)
|
||||
all_rows = list_product_recommendations(capital, quote_fn)
|
||||
all_rows = list_product_recommendations(capital, quote_fn, trading_mode=trading_mode)
|
||||
rows = filter_affordable_recommendations(all_rows)
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn.execute(
|
||||
@@ -49,18 +51,39 @@ def refresh_recommend_cache(
|
||||
return rows
|
||||
|
||||
|
||||
def recommend_cache_stale(updated_at: Optional[str], *, now: Optional[datetime] = None) -> bool:
|
||||
"""缓存是否不是今日更新(需重新拉行情计算)。"""
|
||||
if not updated_at:
|
||||
return True
|
||||
try:
|
||||
cached_day = datetime.strptime(str(updated_at)[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return True
|
||||
today = (now or datetime.now()).date()
|
||||
return cached_day != today
|
||||
|
||||
|
||||
def load_recommend_cache(conn) -> dict:
|
||||
"""优先从数据库读取推荐列表。"""
|
||||
ensure_recommend_tables(conn)
|
||||
row = conn.execute("SELECT capital, rows_json, updated_at FROM product_recommend_cache WHERE id=1").fetchone()
|
||||
if not row:
|
||||
return {"capital": 0.0, "rows": [], "updated_at": None}
|
||||
return {"capital": 0.0, "rows": [], "updated_at": None, "stale": True}
|
||||
try:
|
||||
rows = json.loads(row["rows_json"] or "[]")
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
rows = []
|
||||
updated_at = row["updated_at"]
|
||||
return {
|
||||
"capital": float(row["capital"] or 0),
|
||||
"rows": rows if isinstance(rows, list) else [],
|
||||
"updated_at": row["updated_at"],
|
||||
"updated_at": updated_at,
|
||||
"stale": recommend_cache_stale(updated_at),
|
||||
}
|
||||
|
||||
|
||||
def recommend_payload(conn, *, live_capital: float) -> dict:
|
||||
"""读取缓存并附带当前权益(展示用,可能与缓存计算时不同)。"""
|
||||
payload = load_recommend_cache(conn)
|
||||
payload["capital"] = float(live_capital or 0)
|
||||
return payload
|
||||
|
||||
+13
-7
@@ -10,11 +10,11 @@ from typing import Callable, Optional
|
||||
|
||||
from db_conn import connect_db
|
||||
from kline_stream import sse_format
|
||||
from recommend_store import load_recommend_cache, refresh_recommend_cache
|
||||
from recommend_store import load_recommend_cache, recommend_cache_stale, refresh_recommend_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REFRESH_INTERVAL_SEC = 60
|
||||
CHECK_INTERVAL_SEC = 3600
|
||||
|
||||
|
||||
class RecommendStreamHub:
|
||||
@@ -55,9 +55,10 @@ def start_recommend_worker(
|
||||
get_capital_fn: Callable,
|
||||
quote_fn: Callable[[str], Optional[dict]],
|
||||
init_tables_fn: Callable | None = None,
|
||||
interval: int = REFRESH_INTERVAL_SEC,
|
||||
get_mode_fn: Callable[[], str] | None = None,
|
||||
interval: int = CHECK_INTERVAL_SEC,
|
||||
) -> None:
|
||||
"""后台定时刷新推荐并推送给 SSE 订阅者。"""
|
||||
"""后台每日刷新推荐(每小时检查一次是否需更新),并推送给 SSE 订阅者。"""
|
||||
|
||||
def _loop() -> None:
|
||||
while True:
|
||||
@@ -67,13 +68,18 @@ def start_recommend_worker(
|
||||
if init_tables_fn:
|
||||
init_tables_fn(conn)
|
||||
capital = float(get_capital_fn(conn) or 0)
|
||||
refresh_recommend_cache(conn, capital, quote_fn)
|
||||
payload = load_recommend_cache(conn)
|
||||
mode = get_mode_fn() if get_mode_fn else "simulation"
|
||||
cached = load_recommend_cache(conn)
|
||||
if recommend_cache_stale(cached.get("updated_at")):
|
||||
refresh_recommend_cache(conn, capital, quote_fn, trading_mode=mode)
|
||||
cached = load_recommend_cache(conn)
|
||||
logger.info("品种推荐每日刷新完成,capital=%.2f rows=%d", capital, len(cached.get("rows") or []))
|
||||
payload = {**cached, "capital": capital}
|
||||
finally:
|
||||
conn.close()
|
||||
recommend_hub.broadcast("recommend", {"ok": True, **payload})
|
||||
except Exception as exc:
|
||||
logger.warning("recommend worker failed: %s", exc)
|
||||
time.sleep(max(15, interval))
|
||||
time.sleep(max(300, interval))
|
||||
|
||||
threading.Thread(target=_loop, daemon=True, name="recommend-worker").start()
|
||||
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
"""止盈止损守护:检测持仓快照,自动/手动向 CTP 挂平仓限价委托。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from contract_specs import get_contract_spec
|
||||
from symbols import ths_to_vnpy_symbol
|
||||
from vnpy_bridge import (
|
||||
ctp_list_active_orders,
|
||||
ctp_list_positions,
|
||||
ctp_status,
|
||||
execute_order,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHECK_INTERVAL_SEC = 20
|
||||
|
||||
MONITOR_ORDER_COLUMNS = (
|
||||
"ALTER TABLE trade_order_monitors ADD COLUMN sl_vt_order_id TEXT",
|
||||
"ALTER TABLE trade_order_monitors ADD COLUMN tp_vt_order_id TEXT",
|
||||
)
|
||||
|
||||
|
||||
def ensure_monitor_order_columns(conn) -> None:
|
||||
for sql in MONITOR_ORDER_COLUMNS:
|
||||
try:
|
||||
conn.execute(sql)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _tick_size(ths_code: str) -> float:
|
||||
return float(get_contract_spec(ths_code).get("tick_size") or 1.0)
|
||||
|
||||
|
||||
def _match_symbol(ctp_sym: str, ths: str) -> bool:
|
||||
a = (ctp_sym or "").lower()
|
||||
b = (ths or "").lower()
|
||||
if a == b:
|
||||
return True
|
||||
try:
|
||||
vnpy_sym, _ = ths_to_vnpy_symbol(ths)
|
||||
return a == vnpy_sym.lower()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _close_order_direction(hold_direction: str) -> str:
|
||||
return "short" if hold_direction == "long" else "long"
|
||||
|
||||
|
||||
def _price_near(a: float, b: float, tick: float) -> bool:
|
||||
return abs(float(a) - float(b)) <= max(tick * 0.501, 1e-9)
|
||||
|
||||
|
||||
def _find_close_order(
|
||||
active_orders: list[dict],
|
||||
*,
|
||||
ths_code: str,
|
||||
hold_direction: str,
|
||||
price: float,
|
||||
tick: float,
|
||||
) -> Optional[dict]:
|
||||
close_dir = _close_order_direction(hold_direction)
|
||||
for o in active_orders:
|
||||
sym = o.get("symbol") or ""
|
||||
if not _match_symbol(sym, ths_code):
|
||||
continue
|
||||
offset_s = (o.get("offset") or "").upper()
|
||||
if "CLOSE" not in offset_s:
|
||||
continue
|
||||
if (o.get("direction") or "") != close_dir:
|
||||
continue
|
||||
if not _price_near(o.get("price") or 0, price, tick):
|
||||
continue
|
||||
return o
|
||||
return None
|
||||
|
||||
|
||||
def _find_position(positions: list[dict], ths_code: str, direction: str) -> Optional[dict]:
|
||||
for p in positions:
|
||||
if int(p.get("lots") or 0) <= 0:
|
||||
continue
|
||||
if (p.get("direction") or "long") != direction:
|
||||
continue
|
||||
if _match_symbol(p.get("symbol") or "", ths_code):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _order_still_active(active_orders: list[dict], vt_order_id: str) -> bool:
|
||||
if not vt_order_id:
|
||||
return False
|
||||
oid = str(vt_order_id).strip()
|
||||
for o in active_orders:
|
||||
if str(o.get("order_id") or "") == oid:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def place_monitor_exit_orders(
|
||||
conn,
|
||||
mon: dict,
|
||||
*,
|
||||
mode: str,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""按开仓快照中的止损/止盈价,向 CTP 挂平仓限价单(缺则补)。"""
|
||||
ensure_monitor_order_columns(conn)
|
||||
if not ctp_status(mode).get("connected"):
|
||||
return {"ok": False, "error": "CTP 未连接", "placed": []}
|
||||
|
||||
sym = (mon.get("symbol") or "").strip()
|
||||
direction = (mon.get("direction") or "long").strip().lower()
|
||||
sl = mon.get("stop_loss")
|
||||
tp = mon.get("take_profit")
|
||||
try:
|
||||
sl_f = float(sl) if sl is not None else None
|
||||
tp_f = float(tp) if tp is not None else None
|
||||
except (TypeError, ValueError):
|
||||
sl_f, tp_f = None, None
|
||||
|
||||
if sl_f is None and tp_f is None:
|
||||
return {"ok": False, "error": "快照无止盈止损,无法委托", "placed": []}
|
||||
|
||||
positions = ctp_list_positions(mode)
|
||||
pos = _find_position(positions, sym, direction)
|
||||
if not pos:
|
||||
return {"ok": False, "error": "柜台无对应持仓", "placed": []}
|
||||
|
||||
lots = int(pos.get("lots") or mon.get("lots") or 1)
|
||||
active = ctp_list_active_orders(mode)
|
||||
tick = _tick_size(sym)
|
||||
offset = "close_long" if direction == "long" else "close_short"
|
||||
placed: list[str] = []
|
||||
updates: dict[str, Optional[str]] = {}
|
||||
|
||||
def _maybe_place(kind: str, price: Optional[float], stored_id: str) -> None:
|
||||
if price is None or price <= 0:
|
||||
return
|
||||
existing = _find_close_order(
|
||||
active, ths_code=sym, hold_direction=direction, price=price, tick=tick,
|
||||
)
|
||||
if existing:
|
||||
updates[f"{kind}_vt_order_id"] = str(existing.get("order_id") or stored_id or "")
|
||||
return
|
||||
if stored_id and _order_still_active(active, stored_id) and not force:
|
||||
return
|
||||
result = execute_order(
|
||||
conn,
|
||||
mode=mode,
|
||||
offset=offset,
|
||||
symbol=sym,
|
||||
direction=direction,
|
||||
lots=lots,
|
||||
price=price,
|
||||
order_type="limit",
|
||||
)
|
||||
oid = str(result.get("order_id") or "")
|
||||
updates[f"{kind}_vt_order_id"] = oid
|
||||
placed.append(f"{kind}@{price}")
|
||||
|
||||
sl_id = str(mon.get("sl_vt_order_id") or "")
|
||||
tp_id = str(mon.get("tp_vt_order_id") or "")
|
||||
_maybe_place("sl", sl_f, sl_id)
|
||||
_maybe_place("tp", tp_f, tp_id)
|
||||
|
||||
if updates:
|
||||
sl_new = updates.get("sl_vt_order_id", mon.get("sl_vt_order_id"))
|
||||
tp_new = updates.get("tp_vt_order_id", mon.get("tp_vt_order_id"))
|
||||
conn.execute(
|
||||
"UPDATE trade_order_monitors SET sl_vt_order_id=?, tp_vt_order_id=? WHERE id=?",
|
||||
(sl_new, tp_new, mon["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
if not placed and not updates:
|
||||
return {"ok": True, "message": "无需新委托", "placed": []}
|
||||
msg = "已提交: " + ", ".join(placed) if placed else "委托已在柜台"
|
||||
return {"ok": True, "message": msg, "placed": placed}
|
||||
|
||||
|
||||
def monitor_order_status(
|
||||
mon: dict,
|
||||
*,
|
||||
mode: str,
|
||||
ths_code: str,
|
||||
direction: str,
|
||||
) -> dict[str, bool]:
|
||||
"""检查快照价位是否已有对应平仓挂单。"""
|
||||
sl = mon.get("stop_loss") if mon else None
|
||||
tp = mon.get("take_profit") if mon else None
|
||||
try:
|
||||
sl_f = float(sl) if sl is not None else None
|
||||
tp_f = float(tp) if tp is not None else None
|
||||
except (TypeError, ValueError):
|
||||
sl_f, tp_f = None, None
|
||||
|
||||
if not ctp_status(mode).get("connected"):
|
||||
return {
|
||||
"sl_order_active": False,
|
||||
"tp_order_active": False,
|
||||
"needs_sl_order": sl_f is not None,
|
||||
"needs_tp_order": tp_f is not None,
|
||||
}
|
||||
|
||||
active = ctp_list_active_orders(mode)
|
||||
tick = _tick_size(ths_code)
|
||||
sl_active = False
|
||||
tp_active = False
|
||||
if sl_f is not None:
|
||||
sl_active = _find_close_order(
|
||||
active, ths_code=ths_code, hold_direction=direction, price=sl_f, tick=tick,
|
||||
) is not None
|
||||
if tp_f is not None:
|
||||
tp_active = _find_close_order(
|
||||
active, ths_code=ths_code, hold_direction=direction, price=tp_f, tick=tick,
|
||||
) is not None
|
||||
|
||||
return {
|
||||
"sl_order_active": sl_active,
|
||||
"tp_order_active": tp_active,
|
||||
"needs_sl_order": sl_f is not None and not sl_active,
|
||||
"needs_tp_order": tp_f is not None and not tp_active,
|
||||
}
|
||||
|
||||
|
||||
def sync_all_sl_tp_orders(conn, mode: str) -> int:
|
||||
"""扫描全部 active 监控,为缺失的止盈止损自动挂单。返回新挂单数。"""
|
||||
ensure_monitor_order_columns(conn)
|
||||
if not ctp_status(mode).get("connected"):
|
||||
return 0
|
||||
placed_n = 0
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM trade_order_monitors WHERE status='active'"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
mon = dict(r)
|
||||
st = monitor_order_status(
|
||||
mon, mode=mode, ths_code=mon.get("symbol") or "", direction=mon.get("direction") or "long",
|
||||
)
|
||||
if not st.get("needs_sl_order") and not st.get("needs_tp_order"):
|
||||
continue
|
||||
if mon.get("stop_loss") is None and mon.get("take_profit") is None:
|
||||
continue
|
||||
try:
|
||||
res = place_monitor_exit_orders(conn, mon, mode=mode, force=False)
|
||||
placed_n += len(res.get("placed") or [])
|
||||
except Exception as exc:
|
||||
logger.warning("SL/TP auto place failed monitor=%s: %s", mon.get("id"), exc)
|
||||
return placed_n
|
||||
|
||||
|
||||
def start_sl_tp_guard_worker(
|
||||
*,
|
||||
db_path: str,
|
||||
get_mode_fn: Callable[[], str],
|
||||
init_tables_fn: Callable | None = None,
|
||||
interval: int = CHECK_INTERVAL_SEC,
|
||||
) -> None:
|
||||
from db_conn import connect_db
|
||||
|
||||
def _loop() -> None:
|
||||
time.sleep(8)
|
||||
while True:
|
||||
try:
|
||||
mode = get_mode_fn()
|
||||
if ctp_status(mode).get("connected"):
|
||||
conn = connect_db(db_path)
|
||||
try:
|
||||
if init_tables_fn:
|
||||
init_tables_fn(conn)
|
||||
n = sync_all_sl_tp_orders(conn, mode)
|
||||
if n:
|
||||
logger.info("止盈止损守护: 新挂 %d 笔委托", n)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
logger.warning("sl_tp_guard worker: %s", exc)
|
||||
time.sleep(max(10, interval))
|
||||
|
||||
threading.Thread(target=_loop, daemon=True, name="sl-tp-guard").start()
|
||||
@@ -46,11 +46,16 @@
|
||||
.pos-pending-right{display:flex;align-items:center;gap:.45rem;flex-shrink:0}
|
||||
.pos-dismiss-btn{padding:.2rem .55rem;font-size:.68rem;border-radius:6px;border:1px solid var(--table-border);background:var(--card-inner);color:var(--text-muted);cursor:pointer;width:auto;min-height:auto;line-height:1.3}
|
||||
.pos-dismiss-btn:disabled{opacity:.55;cursor:wait}
|
||||
.pos-sl-btn{border-color:var(--accent);color:var(--accent)}
|
||||
.pos-pending-item.sl{border-left:3px solid var(--loss)}
|
||||
.pos-pending-item.tp{border-left:3px solid var(--profit)}
|
||||
.pos-pending-item.ctp{border-left:3px solid var(--accent)}
|
||||
.pos-close-btn{padding:.4rem .85rem;font-size:.78rem;border-radius:8px;border:1px solid var(--loss);background:var(--loss-bg);color:var(--loss);cursor:pointer;white-space:nowrap;width:auto;flex-shrink:0;min-height:36px}
|
||||
.pos-close-btn:disabled{opacity:.55;cursor:wait}
|
||||
.pos-card-actions{display:flex;gap:.35rem;flex-shrink:0;align-items:center}
|
||||
.pos-order-btn{padding:.4rem .85rem;font-size:.78rem;border-radius:8px;border:1px solid var(--accent);background:rgba(56,189,248,.1);color:var(--accent);cursor:pointer;white-space:nowrap;width:auto;flex-shrink:0;min-height:36px}
|
||||
.pos-order-btn:disabled,.pos-order-btn.pos-order-done{opacity:.55;cursor:default;border-color:var(--table-border);background:var(--card-inner);color:var(--text-muted)}
|
||||
.pos-order-btn:disabled:not(.pos-order-done){cursor:wait}
|
||||
|
||||
@media (min-width:768px) and (max-width:1100px){
|
||||
.trade-split .card{min-height:420px}
|
||||
|
||||
+133
-4
@@ -371,28 +371,148 @@
|
||||
function buildPosCard(row) {
|
||||
var pnlClass = row.float_pnl > 0 ? 'pnl-pos' : (row.float_pnl < 0 ? 'pnl-neg' : '');
|
||||
var pnlText = row.float_pnl != null ? ((row.float_pnl >= 0 ? '+' : '') + fmtNum(row.float_pnl) + ' 元') : '--';
|
||||
var netClass = row.est_pnl_net > 0 ? 'pnl-pos' : (row.est_pnl_net < 0 ? 'pnl-neg' : '');
|
||||
var dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空');
|
||||
var openT = (row.open_time || '').replace('T', ' ').slice(0, 16);
|
||||
var slTpBtn = (!row.stop_loss && !row.take_profit && row.can_close) ?
|
||||
'<button type="button" class="pos-dismiss-btn pos-sl-btn" data-sl-tp="' +
|
||||
encodeURIComponent(JSON.stringify({
|
||||
symbol_code: row.symbol_code, direction: row.direction,
|
||||
lots: row.lots, entry_price: row.entry_price
|
||||
})) + '">设置止盈止损</button>' : '';
|
||||
var orderBtn = '';
|
||||
if (row.monitor_id && (row.stop_loss != null || row.take_profit != null)) {
|
||||
if (row.can_place_orders) {
|
||||
orderBtn = '<button type="button" class="pos-order-btn" data-place-orders="' + row.monitor_id + '">委托</button>';
|
||||
} else {
|
||||
orderBtn = '<button type="button" class="pos-order-btn pos-order-done" disabled title="止盈止损委托已在柜台">委托</button>';
|
||||
}
|
||||
}
|
||||
var closePayload = encodeURIComponent(JSON.stringify({
|
||||
source: row.source, symbol_code: row.symbol_code, direction: row.direction,
|
||||
lots: row.lots, mark_price: row.mark_price, monitor_id: row.monitor_id || null
|
||||
}));
|
||||
var closeBtn = row.can_close ?
|
||||
'<button type="button" class="pos-close-btn" data-close="' + closePayload + '">平仓</button>' : '';
|
||||
var actionBtns = (orderBtn || closeBtn) ?
|
||||
'<div class="pos-card-actions">' + orderBtn + closeBtn + '</div>' : '';
|
||||
return (
|
||||
'<div class="pos-card">' +
|
||||
'<div class="pos-card-head"><div><div class="title">' + row.symbol + ' <span class="badge dir">' + dirBadge + '</span></div>' +
|
||||
'<div class="text-muted" style="font-size:.72rem">' + (row.symbol_code || '') + '</div></div>' + closeBtn + '</div>' +
|
||||
'<div class="pos-card-meta">来源 <strong>' + (row.source_label || 'CTP') + '</strong> · 柜台浮盈</div>' +
|
||||
'<div class="text-muted" style="font-size:.72rem">' + (row.symbol_code || '') + '</div></div>' +
|
||||
actionBtns + '</div>' +
|
||||
'<div class="pos-card-meta">来源 <strong>' + (row.source_label || 'CTP') + '</strong> · 柜台浮盈' +
|
||||
(slTpBtn ? ' · ' + slTpBtn : '') +
|
||||
(row.sl_order_active ? ' · <span class="text-profit">止损已挂</span>' : '') +
|
||||
(row.tp_order_active ? ' · <span class="text-profit">止盈已挂</span>' : '') + '</div>' +
|
||||
'<div class="pos-metrics">' +
|
||||
'<div class="cell"><label>持仓均价</label><div>' + fmtNum(row.entry_price) + '</div></div>' +
|
||||
'<div class="cell"><label>当前价格</label><div>' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '</div></div>' +
|
||||
'<div class="cell"><label>止损</label><div>' + (row.stop_loss != null ? fmtNum(row.stop_loss) : '--') + '</div></div>' +
|
||||
'<div class="cell"><label>止盈</label><div>' + (row.take_profit != null ? fmtNum(row.take_profit) : '--') + '</div></div>' +
|
||||
'<div class="cell"><label>占用保证金</label><div>' + (row.margin != null ? fmtNum(row.margin) + ' 元' : '--') + '</div></div>' +
|
||||
'<div class="cell"><label>仓位占比</label><div>' + (row.position_pct != null ? fmtNum(row.position_pct) + '%' : '--') + '</div></div>' +
|
||||
'<div class="cell ' + pnlClass + '"><label>浮盈亏</label><div>' + pnlText + '</div></div>' +
|
||||
'<div class="cell"><label>预估手续费</label><div>' + (row.est_fee != null ? fmtNum(row.est_fee) + ' 元' : '--') + '</div></div>' +
|
||||
'<div class="cell ' + netClass + '"><label>扣费后</label><div>' +
|
||||
(row.est_pnl_net != null ? fmtNum(row.est_pnl_net) + ' 元' : '--') + '</div></div>' +
|
||||
'</div>' + buildPendingHtml(row.pending_orders) +
|
||||
'<div class="pos-footer"><span>' + row.lots + ' 手</span></div></div>'
|
||||
'<div class="pos-footer">' +
|
||||
'<span>' + row.lots + ' 手</span>' +
|
||||
'<span>开仓 ' + (openT || '--') + '</span>' +
|
||||
'<span>持仓 ' + (row.holding_duration || '--') + '</span>' +
|
||||
'<span>保证金 ' + (row.margin != null ? fmtNum(row.margin) + ' 元' : '--') + '</span>' +
|
||||
'<span>仓位 ' + (row.position_pct != null ? fmtNum(row.position_pct) + '%' : '--') + '</span>' +
|
||||
'</div></div>'
|
||||
);
|
||||
}
|
||||
|
||||
function placeMonitorOrders(monitorId, btn) {
|
||||
if (!monitorId) return;
|
||||
if (!confirm('按开仓快照向柜台挂止盈/止损平仓委托?')) return;
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '委托中…';
|
||||
}
|
||||
fetch('/api/trading/monitor/place-orders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ monitor_id: monitorId })
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.ok) throw new Error(d.error || d.message || '委托失败');
|
||||
alert(d.message || '委托已提交');
|
||||
pollPositions();
|
||||
})
|
||||
.catch(function (e) {
|
||||
alert(e.message || '委托失败');
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '委托';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindPlaceOrderButtons(root) {
|
||||
if (!root) return;
|
||||
root.querySelectorAll('[data-place-orders]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
placeMonitorOrders(parseInt(btn.getAttribute('data-place-orders'), 10), btn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function promptStopTakeProfit(payload, btn) {
|
||||
var slRaw = prompt('止损价(可留空)', '');
|
||||
if (slRaw === null) return;
|
||||
var tpRaw = prompt('止盈价(可留空)', '');
|
||||
if (tpRaw === null) return;
|
||||
var sl = slRaw.trim() ? parseFloat(slRaw) : null;
|
||||
var tp = tpRaw.trim() ? parseFloat(tpRaw) : null;
|
||||
if (sl == null && tp == null) {
|
||||
alert('请至少填写止损或止盈');
|
||||
return;
|
||||
}
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '保存中…';
|
||||
}
|
||||
fetch('/api/trading/monitor/upsert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
symbol_code: payload.symbol_code,
|
||||
direction: payload.direction,
|
||||
lots: payload.lots,
|
||||
entry_price: payload.entry_price,
|
||||
stop_loss: sl,
|
||||
take_profit: tp
|
||||
})
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.ok) throw new Error(d.error || '保存失败');
|
||||
pollPositions();
|
||||
})
|
||||
.catch(function (e) {
|
||||
alert(e.message || '保存失败');
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '设置止盈止损';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindSlTpButtons(root) {
|
||||
if (!root) return;
|
||||
root.querySelectorAll('[data-sl-tp]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
promptStopTakeProfit(JSON.parse(decodeURIComponent(btn.getAttribute('data-sl-tp'))), btn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closePosition(payload, btn) {
|
||||
function doClose(price) {
|
||||
if (!price || price <= 0) { alert('无法获取现价'); return; }
|
||||
@@ -483,6 +603,8 @@
|
||||
}
|
||||
list.innerHTML = rows.map(buildPosCard).join('');
|
||||
bindPendingDismiss(list);
|
||||
bindSlTpButtons(list);
|
||||
bindPlaceOrderButtons(list);
|
||||
list.querySelectorAll('[data-close]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
closePosition(JSON.parse(decodeURIComponent(btn.getAttribute('data-close'))), btn);
|
||||
@@ -500,9 +622,13 @@
|
||||
if (!recommendList || !data) return;
|
||||
var recCap = document.getElementById('rec-capital');
|
||||
if (recCap && data.capital != null) recCap.textContent = Number(data.capital).toFixed(2);
|
||||
var recUpdated = document.getElementById('rec-updated');
|
||||
if (recUpdated && data.updated_at) {
|
||||
recUpdated.textContent = '每日后台更新 · 最近 ' + data.updated_at;
|
||||
}
|
||||
var rows = data.rows || [];
|
||||
if (!rows.length) {
|
||||
recommendList.innerHTML = '<tr><td colspan="6" class="empty-hint">当前资金下暂无推荐品种</td></tr>';
|
||||
recommendList.innerHTML = '<tr><td colspan="9" class="empty-hint">当前资金下暂无推荐品种(每日后台刷新)</td></tr>';
|
||||
return;
|
||||
}
|
||||
recommendList.innerHTML = rows.map(function (r) {
|
||||
@@ -511,7 +637,10 @@
|
||||
'<td><strong>' + (r.name || '') + '</strong> <span class="text-accent">' + (r.main_code || r.ths || '') + '</span></td>' +
|
||||
'<td>' + (r.exchange || '') + '</td>' +
|
||||
'<td>' + (r.price != null ? r.price : '—') + '</td>' +
|
||||
'<td>' + (r.ref_stop_loss != null ? r.ref_stop_loss : '—') + '</td>' +
|
||||
'<td>' + (r.ref_take_profit != null ? r.ref_take_profit : '—') + '</td>' +
|
||||
'<td>' + (r.margin_one_lot != null ? r.margin_one_lot : '—') + '</td>' +
|
||||
'<td>' + (r.open_fee_one_lot != null ? r.open_fee_one_lot : '—') + '</td>' +
|
||||
'<td>' + (r.min_capital_one_lot != null ? r.min_capital_one_lot : '—') + '</td>' +
|
||||
'<td><span class="badge ' + (r.status === 'ok' ? 'profit' : 'planned') + '">' + (r.status_label || '') + '</span></td>' +
|
||||
'</tr>'
|
||||
|
||||
@@ -107,14 +107,16 @@
|
||||
<div class="card trade-card trade-card-full" id="recommend">
|
||||
<h2>品种推荐</h2>
|
||||
<div class="card-body">
|
||||
<p class="hint">按权益 <strong class="text-accent" id="rec-capital">{{ '%.2f'|format(capital) }}</strong> 元筛选,仅显示可开 1 手的品种。
|
||||
{% if recommend_updated_at %}<span class="text-muted">更新 {{ recommend_updated_at }}</span>{% else %}<span class="text-muted" id="rec-updated">后台刷新中…</span>{% endif %}
|
||||
<p class="hint">按权益 <strong class="text-accent" id="rec-capital">{{ '%.2f'|format(capital) }}</strong> 元筛选,仅显示可开 1 手的品种;参考止损/止盈按 20 跳、盈亏比 2:1 估算。
|
||||
{% if recommend_updated_at %}<span class="text-muted">每日后台更新 · 最近 {{ recommend_updated_at }}</span>{% else %}<span class="text-muted" id="rec-updated">等待今日后台刷新…</span>{% endif %}
|
||||
</p>
|
||||
<div class="trade-table-wrap">
|
||||
<table class="trade-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>品种</th><th>交易所</th><th>参考价</th><th>1手保证金</th><th>建议最低资金</th><th>状态</th>
|
||||
<th>品种</th><th>交易所</th><th>参考价</th>
|
||||
<th>参考止损</th><th>参考止盈</th>
|
||||
<th>1手保证金</th><th>1手手续费</th><th>建议最低资金</th><th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recommend-list">
|
||||
@@ -124,13 +126,16 @@
|
||||
<td><strong>{{ r.name }}</strong> <span class="text-accent">{{ r.main_code or r.ths }}</span></td>
|
||||
<td>{{ r.exchange }}</td>
|
||||
<td>{% if r.price %}{{ r.price }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.ref_stop_loss %}{{ r.ref_stop_loss }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.ref_take_profit %}{{ r.ref_take_profit }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.margin_one_lot %}{{ r.margin_one_lot }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.open_fee_one_lot is defined and r.open_fee_one_lot is not none %}{{ r.open_fee_one_lot }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.min_capital_one_lot %}{{ r.min_capital_one_lot }}{% else %}—{% endif %}</td>
|
||||
<td><span class="badge {% if r.status=='ok' %}profit{% else %}planned{% endif %}">{{ r.status_label }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="empty-hint">等待后台推送推荐…</td></tr>
|
||||
<tr><td colspan="9" class="empty-hint">等待今日后台刷新推荐…</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user