diff --git a/LICENSE.zh-CN.txt b/LICENSE.zh-CN.txt new file mode 100644 index 0000000..afafe44 --- /dev/null +++ b/LICENSE.zh-CN.txt @@ -0,0 +1,42 @@ +国内期货交易监控复盘系统 — 软件使用许可与版权声明 + +著作权人:马建军 +Copyright (c) 2025-2026 马建军. All rights reserved. + +【权利声明】 +本软件(含源代码、文档、界面、脚本及后续更新版本)之著作权及相关知识产权, +均归马建军所有。除本许可明确允许的范围外,保留一切权利。 + +【授权范围 — 个人版】 +经著作权人书面或付费交付同意的自然人购买者,仅可在本人名下单一服务器或 +个人设备上部署并使用本软件,用于个人期货交易纪律管理、记录与复盘,且须 +遵守中华人民共和国相关法律法规及期货监管规定。 + +【严禁用途】 +未经著作权人事先书面许可,严禁将本软件用于包括但不限于以下用途: +(1)带单、代客理财、代客下单、跟单室、信号群、付费喊单、向他人推荐具体 + 期货买卖方向或具体合约; +(2)向他人推荐、介绍、引导参与特定期货品种或交易机会(若构成投资咨询或 + 其他需许可之业务,使用者依法另行承担法律责任); +(3)融资、配资、分仓、分润、对赌、非法吸收资金等与期货相关的资金融通 + 或变相配资业务; +(4)复制、传播、转售、出租、出借源代码或编译产物,或授权第三方使用; +(5)搭建共享交易室、多租户 SaaS、白标系统对外经营(须另行签订机构版协议); +(6)删除、篡改或隐藏本版权及许可声明。 + +【免责声明】 +本软件为交易纪律与记录辅助工具,不构成任何投资建议、咨询或收益承诺。 +期货交易具有高风险,使用者须独立决策并自行承担全部盈亏及法律责任。 +因使用者违反法律法规、监管规定或本许可导致的后果,由使用者自行承担。 + +【更新与维护】 +源代码更新、部署服务及共享交易室等机构授权,以双方另行书面约定为准。 +未经约定,不视为自动授予新版本或扩展用途之权利。 + +【联系】 +著作权人:马建军 +手机:18364911125 +微信:dekun03 + +详细购买条款见 docs/软件购买与使用协议.md。 +本许可之解释与适用以中华人民共和国法律为准(法律强制性规定除外)。 diff --git a/README.md b/README.md index 9f3af03..f4855ad 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 国内期货交易监控复盘系统 -基于 Flask 的国内期货 **CTP 下单 + 监控 + 复盘 + 统计** Web 应用。模拟盘连接 SimNow,实盘连接期货公司 CTP;支持关键位/计划提醒、交易记录同步、资金曲线、品种推荐与企业微信推送。 +基于 Flask 的国内期货 **CTP 下单 + 监控 + 复盘 + 统计** Web 应用。模拟盘连接 SimNow,实盘连接期货公司 CTP;支持关键位/计划提醒、交易记录同步、资金曲线、可开仓品种(仓位纪律)与企业微信推送。 ## 文档 @@ -9,14 +9,15 @@ | **[功能说明](docs/FEATURES.md)** | 各模块功能、页面路径、数据库与后台任务 | | **[部署文档](docs/DEPLOY.md)** | 一键部署、更新、PM2、故障排查 | | **[SimNow 接入](docs/SIMNOW.md)** | 仿真账号注册与 CTP 前置 | -| **[交易与策略](docs/TRADING.md)** | 下单、持仓、品种推荐、策略 API | +| **[交易与策略](docs/TRADING.md)** | 下单、持仓、可开仓品种、策略 API | | **[手续费与导航](docs/FEES.md)** | CTP 费率同步、导航开关 | +| **[软件购买与使用协议](docs/软件购买与使用协议.md)** | 个人版授权模板(含签署栏) | ## 功能一览 | 模块 | 路径 | 说明 | |------|------|------| -| **下单监控**(默认首页) | `/positions` | CTP 连接、期货下单、当前持仓、品种推荐 | +| **下单监控**(默认首页) | `/positions` | CTP 连接、期货下单、当前持仓、可开仓品种 | | **策略交易** | `/strategy` | 趋势回调 / 顺势加仓(可导航开关) | | **开单计划** | `/plans` | 当日决策区间、触发推送(可开关) | | **关键位监控** | `/keys` | 箱体/阻力支撑突破提醒 | @@ -74,6 +75,12 @@ python app.py https://git.bz121.com/dekun/qihuo.git -## License +## 版权与授权 -Private / 个人使用 +- 著作权人:**马建军** +- 许可说明:[LICENSE.zh-CN.txt](LICENSE.zh-CN.txt) +- 个人购买协议模板:[docs/软件购买与使用协议.md](docs/软件购买与使用协议.md) + +本软件为 **专有软件**,仅供经授权的个人自用部署。严禁用于带单、向他人推荐期货品种或买卖建议、融资配资、转售源码或搭建共享交易室等用途。本软件不构成投资建议,期货交易风险由使用者自行承担。 + +联系:手机 18364911125 · 微信 dekun03 diff --git a/app.py b/app.py index 506a970..b9e5719 100644 --- a/app.py +++ b/app.py @@ -1,1851 +1,1856 @@ -import os - -from locale_fix import ensure_process_locale - -ensure_process_locale() - -import sqlite3 -import time -import threading -import requests -from datetime import datetime, timedelta -from typing import Optional -from functools import wraps -from zoneinfo import ZoneInfo - -from werkzeug.utils import secure_filename - -from dotenv import load_dotenv -from flask import ( - Flask, render_template, request, redirect, url_for, - flash, session, jsonify, Response, stream_with_context, -) -from werkzeug.security import check_password_hash, generate_password_hash - -from functools import wraps - -from symbols import ( - search_symbols, - ths_to_codes, - list_main_contracts_grouped, - list_recommended_symbols_grouped, - refresh_main_index, -) -from contract_specs import calc_position_metrics -from fee_specs import ( - calc_fee_breakdown, - calc_round_trip_fee, - list_fee_rates_for_ui, - count_fee_rates_by_source, - purge_non_ctp_fee_rates, -) -from nav_settings import NAV_TOGGLES, get_nav_items, nav_enabled, save_nav_items -from contract_profile import get_contract_profile -from stats_engine import STATS_VIEWS, load_stats_cache, refresh_stats_cache -from kline_store import ensure_kline_tables -from kline_stream import kline_hub, sse_format -from kline_chart import generate_review_kline_chart, fetch_market_klines, MARKET_PERIODS -from market import get_price as market_get_price, set_ths_refresh_token, get_quote_source_label -from db_conn import connect_db -from strategy.strategy_db import init_strategy_tables -from install_trading import install_trading -from vnpy_bridge import try_init_vnpy - -load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")) - -app = Flask(__name__) -app.secret_key = os.getenv("SECRET_KEY", "futures_monitor_default_secret") - -HOST = os.getenv("HOST", "0.0.0.0") -PORT = int(os.getenv("PORT", "6600")) -DEBUG = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes") - -DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "futures.db") -UPLOAD_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "uploads") -TZ = ZoneInfo("Asia/Shanghai") - -OPEN_TYPES = ["突破开仓", "回调开仓", "追涨杀跌", "计划内开仓", "震荡摸顶底", "其他"] -EXIT_TRIGGERS = ["止盈", "止损", "手工平仓", "移动止损", "时间离场", "其他"] -BEHAVIOR_TAGS = ["怕踏空", "报复开仓", "盈利飘了", "拿不住单", "扛单", "重仓违规"] -KLINE_PERIODS = ["1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"] -KLINE_CUTOFFS = ["平仓时间", "开仓时间", "当前时间"] - - -def today_str() -> str: - return datetime.now(TZ).date().isoformat() - - -def calc_holding_duration(open_time: str, close_time: str) -> str: - try: - o = datetime.fromisoformat(open_time.strip().replace(" ", "T")[:19]) - c = datetime.fromisoformat(close_time.strip().replace(" ", "T")[:19]) - delta = c - o - if delta.total_seconds() < 0: - return "" - secs = int(delta.total_seconds()) - h, rem = divmod(secs, 3600) - m, _ = divmod(rem, 60) - if h: - return f"{h}小时{m}分钟" - return f"{m}分钟" - except Exception: - return "" - - -def holding_to_minutes(open_time: str, close_time: str) -> int: - try: - o = datetime.fromisoformat(open_time.strip().replace(" ", "T")) - c = datetime.fromisoformat(close_time.strip().replace(" ", "T")) - secs = int((c - o).total_seconds()) - return max(0, secs // 60) - except Exception: - return 0 - - -def classify_close_result(direction: str, close: float, sl: float, tp: float) -> str: - """根据平仓价与止损/止盈距离判断结果。""" - if close is None: - return "手动平仓" - tol = max(abs(close) * 0.002, 1.0) - if abs(close - tp) <= tol: - return "止盈" - if abs(close - sl) <= tol: - return "止损" - return "手动平仓" - - -def calc_rr_ratio(direction: str, entry: float, stop: float, target: float) -> Optional[float]: - """盈亏比 = 盈利空间 / 风险空间。""" - if entry is None or stop is None or target is None: - return None - if direction == "long": - risk = entry - stop - if risk <= 0: - return None - return round((target - entry) / risk, 2) - if direction == "short": - risk = stop - entry - if risk <= 0: - return None - return round((entry - target) / risk, 2) - return None - - -def calc_theoretical_pnl(direction: str, entry: float, target: float, lots: float) -> Optional[float]: - if entry is None or target is None or lots is None: - return None - if direction == "long": - return round((target - entry) * lots, 2) - if direction == "short": - return round((entry - target) * lots, 2) - return None - - -def parse_review_date_filter(preset: str, start: str, end: str) -> tuple[str, str]: - today = datetime.now(TZ).date() - if preset == "today": - s = today.isoformat() - return s, s - if preset == "week": - monday = today - timedelta(days=today.weekday()) - return monday.isoformat(), today.isoformat() - if preset == "month": - return today.replace(day=1).isoformat(), today.isoformat() - return start.strip(), end.strip() - - -def expire_old_plans(): - """当日结束后计划自动失效,保留历史。""" - today = today_str() - conn = get_db() - conn.execute( - "UPDATE order_plans SET status='expired' WHERE plan_date < ? AND status IN ('planned', 'active')", - (today,), - ) - conn.execute( - "UPDATE order_plans SET plan_date=date(created_at) WHERE plan_date IS NULL OR plan_date=''" - ) - conn.commit() - conn.close() - - -def get_db(): - return connect_db() - - -def get_setting(key: str, default: str = "") -> str: - conn = get_db() - row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() - conn.close() - return row["value"] if row else default - - -def set_setting(key: str, value: str): - conn = get_db() - conn.execute( - "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=?", - (key, value, value), - ) - conn.commit() - conn.close() - - -def require_nav(key: str): - """导航项关闭时拒绝访问对应页面。""" - def decorator(f): - @wraps(f) - def wrapped(*args, **kwargs): - if not nav_enabled(get_setting, key): - flash("该页面已在系统设置中关闭") - return redirect(url_for("positions")) - return f(*args, **kwargs) - return wrapped - return decorator - - -@app.context_processor -def inject_globals(): - trade_js = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static", "js", "trade.js") - asset_v = str(int(os.path.getmtime(trade_js))) if os.path.isfile(trade_js) else "0" - return {"nav_items": get_nav_items(get_setting), "asset_v": asset_v} - - -def _trading_mode() -> str: - return (get_setting("trading_mode", "simulation") or "simulation").strip() - - -def touch_stats_cache(): - try: - conn = get_db() - capital = float(get_setting("live_capital", "0") or 0) - refresh_stats_cache(conn, capital) - conn.close() - except Exception as exc: - app.logger.warning("stats cache refresh failed: %s", exc) - - -def get_stats_data() -> dict: - conn = get_db() - capital = float(get_setting("live_capital", "0") or 0) - data = load_stats_cache(conn) - if not data: - data = refresh_stats_cache(conn, capital) - conn.close() - return data - - -def init_db(): - conn = get_db() - c = conn.cursor() - c.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)") - c.execute('''CREATE TABLE IF NOT EXISTS order_plans - (id INTEGER PRIMARY KEY AUTOINCREMENT, - symbol TEXT, symbol_name TEXT, direction TEXT, - zone_upper REAL, zone_lower REAL, - stop_loss REAL, take_profit REAL, - status TEXT DEFAULT "planned", - triggered_at TIMESTAMP, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') - c.execute('''CREATE TABLE IF NOT EXISTS key_monitors - (id INTEGER PRIMARY KEY AUTOINCREMENT, - symbol TEXT, symbol_name TEXT, monitor_type TEXT, direction TEXT, - upper REAL, lower REAL, - upper_triggered INTEGER DEFAULT 0, - lower_triggered INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') - c.execute('''CREATE TABLE IF NOT EXISTS trade_records - (id INTEGER PRIMARY KEY AUTOINCREMENT, - symbol TEXT, symbol_name TEXT, monitor_type TEXT, direction TEXT, - trigger_price REAL, stop_loss REAL, take_profit REAL, - result TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') - migrations = [ - "ALTER TABLE key_monitors ADD COLUMN symbol_name TEXT", - "ALTER TABLE key_monitors ADD COLUMN upper_triggered INTEGER DEFAULT 0", - "ALTER TABLE key_monitors ADD COLUMN lower_triggered INTEGER DEFAULT 0", - "ALTER TABLE trade_records ADD COLUMN symbol_name TEXT", - "ALTER TABLE order_plans ADD COLUMN sina_code TEXT", - "ALTER TABLE order_plans ADD COLUMN market_code TEXT", - "ALTER TABLE key_monitors ADD COLUMN market_code TEXT", - "ALTER TABLE key_monitors ADD COLUMN sina_code TEXT", - "ALTER TABLE trade_records ADD COLUMN market_code TEXT", - "ALTER TABLE order_plans ADD COLUMN plan_date TEXT", - "ALTER TABLE order_plans ADD COLUMN decision_reason TEXT", - "ALTER TABLE key_monitors ADD COLUMN status TEXT DEFAULT 'active'", - "ALTER TABLE key_monitors ADD COLUMN archived_at TEXT", - "ALTER TABLE review_records ADD COLUMN direction TEXT", - "ALTER TABLE review_records ADD COLUMN entry_price REAL", - "ALTER TABLE review_records ADD COLUMN stop_loss REAL", - "ALTER TABLE review_records ADD COLUMN take_profit REAL", - "ALTER TABLE review_records ADD COLUMN close_price REAL", - "ALTER TABLE review_records ADD COLUMN lots REAL", - "ALTER TABLE review_records ADD COLUMN holding_duration TEXT", - "ALTER TABLE review_records ADD COLUMN initial_pnl REAL", - "ALTER TABLE review_records ADD COLUMN actual_pnl REAL", - "ALTER TABLE review_records ADD COLUMN is_emotion INTEGER DEFAULT 0", - "ALTER TABLE review_records ADD COLUMN symbol_name TEXT", - "ALTER TABLE review_records ADD COLUMN market_code TEXT", - "ALTER TABLE review_records ADD COLUMN sina_code TEXT", - "ALTER TABLE trade_logs ADD COLUMN fee REAL", - "ALTER TABLE trade_logs ADD COLUMN pnl_net REAL", - "ALTER TABLE trade_logs ADD COLUMN margin_pct REAL", - "ALTER TABLE trade_logs ADD COLUMN equity_after REAL", - "ALTER TABLE review_records ADD COLUMN fee REAL", - "ALTER TABLE review_records ADD COLUMN pnl_net REAL", - ] - for sql in migrations: - try: - c.execute(sql) - except sqlite3.OperationalError: - pass - c.execute('''CREATE TABLE IF NOT EXISTS review_records - (id INTEGER PRIMARY KEY AUTOINCREMENT, - open_time TEXT, close_time TEXT, - symbol TEXT, timeframe TEXT, - pnl REAL, - open_type TEXT, expected_rr REAL, actual_rr REAL, - exit_trigger TEXT, exit_supplement TEXT, - watch_after_breakeven TEXT, new_position_while_occupied TEXT, - screenshot TEXT, - auto_kline INTEGER DEFAULT 0, - kline_period1 TEXT, kline_period2 TEXT, - kline_count INTEGER, kline_cutoff TEXT, - behavior_tags TEXT, notes TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') - c.execute('''CREATE TABLE IF NOT EXISTS position_monitors - (id INTEGER PRIMARY KEY AUTOINCREMENT, - symbol TEXT, symbol_name TEXT, market_code TEXT, sina_code TEXT, - direction TEXT, lots REAL, entry_price REAL, - stop_loss REAL, take_profit REAL, open_time TEXT, - status TEXT DEFAULT 'active', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') - c.execute('''CREATE TABLE IF NOT EXISTS trade_logs - (id INTEGER PRIMARY KEY AUTOINCREMENT, - symbol TEXT, symbol_name TEXT, market_code TEXT, sina_code TEXT, - monitor_type TEXT, direction TEXT, - entry_price REAL, stop_loss REAL, take_profit REAL, close_price REAL, - lots REAL, margin REAL, holding_minutes INTEGER, - open_time TEXT, close_time TEXT, - pnl REAL, result TEXT, - verified INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') - c.execute('''CREATE TABLE IF NOT EXISTS fee_rates - (product TEXT PRIMARY KEY, - exchange TEXT, - mult INTEGER, - open_fixed REAL DEFAULT 0, - open_ratio REAL DEFAULT 0, - close_yesterday_fixed REAL DEFAULT 0, - close_yesterday_ratio REAL DEFAULT 0, - close_today_fixed REAL DEFAULT 0, - close_today_ratio REAL DEFAULT 0, - updated_at TEXT)''') - c.execute('''CREATE TABLE IF NOT EXISTS stats_cache - (key TEXT PRIMARY KEY, - data_json TEXT NOT NULL, - updated_at TEXT NOT NULL)''') - for sql in ( - "ALTER TABLE fee_rates ADD COLUMN source TEXT DEFAULT 'local'", - ): - try: - c.execute(sql) - except sqlite3.OperationalError: - pass - ensure_kline_tables(conn) - init_strategy_tables(conn) - from risk.account_risk_lib import ensure_account_risk_schema - from recommend_store import ensure_recommend_tables - ensure_account_risk_schema(conn) - ensure_recommend_tables(conn) - conn.commit() - conn.close() - - sync_admin_from_env() - - if not get_setting("wechat_webhook") and os.getenv("WECHAT_WEBHOOK"): - set_setting("wechat_webhook", os.getenv("WECHAT_WEBHOOK")) - - if not get_setting("ths_refresh_token") and os.getenv("THS_REFRESH_TOKEN"): - set_setting("ths_refresh_token", os.getenv("THS_REFRESH_TOKEN")) - - from ctp_settings import seed_ctp_settings_from_env - seed_ctp_settings_from_env(set_setting) - - os.makedirs(UPLOAD_DIR, exist_ok=True) - expire_old_plans() - - if not get_setting("fee_multiplier"): - set_setting("fee_multiplier", "2") - if not get_setting("trading_mode"): - set_setting("trading_mode", "simulation") - if not get_setting("position_sizing_mode"): - set_setting("position_sizing_mode", "fixed") - if not get_setting("fixed_lots"): - set_setting("fixed_lots", "1") - if not get_setting("fixed_amount"): - set_setting("fixed_amount", "5000") - if not get_setting("risk_percent"): - set_setting("risk_percent", "1") - if not get_setting("max_margin_pct"): - set_setting("max_margin_pct", "30") - if not get_setting("trailing_be_tick_buffer"): - set_setting("trailing_be_tick_buffer", "2") - if not get_setting("pending_order_timeout_min"): - set_setting("pending_order_timeout_min", "5") - if not get_setting("fee_source_mode"): - set_setting("fee_source_mode", "ctp") - set_setting("fee_source_mode", "ctp") - try: - purge_non_ctp_fee_rates() - except Exception: - pass - - -def sync_admin_from_env(): - """ - 从 .env 同步管理员账号。 - - 首次建库:自动写入 ADMIN_USERNAME / ADMIN_PASSWORD - - 已建库后改 .env:需设 ADMIN_SYNC_FROM_ENV=true 并重启服务 - """ - sync = os.getenv("ADMIN_SYNC_FROM_ENV", "false").lower() in ("1", "true", "yes") - env_username = os.getenv("ADMIN_USERNAME", "").strip() - env_password = os.getenv("ADMIN_PASSWORD", "").strip() - placeholder_passwords = {"", "change-me-on-first-login", "admin123"} - - if not get_setting("admin_username"): - username = env_username or "admin" - password = env_password if env_password not in placeholder_passwords else "admin123" - set_setting("admin_username", username) - set_setting("admin_password_hash", generate_password_hash(password)) - return - - if not sync: - return - - if env_username: - set_setting("admin_username", env_username) - if env_password and env_password not in placeholder_passwords: - set_setting("admin_password_hash", generate_password_hash(env_password)) - - -init_db() - - -def sync_ths_token(): - set_ths_refresh_token(get_setting("ths_refresh_token")) - - -sync_ths_token() - - -def build_market_quote_payload( - symbol: str, - market_code: str = "", - sina_code: str = "", -) -> dict: - if not market_code or not sina_code: - codes = ths_to_codes(symbol) - if codes: - market_code = codes.get("market_code", "") or market_code - sina_code = codes.get("sina_code", "") or sina_code - quote_source = "sina" - price = None - prev_close = None - try: - from vnpy_bridge import ctp_status, ctp_get_tick_detail - from trading_context import get_trading_mode - - mode = get_trading_mode(get_setting) - if ctp_status(mode).get("connected"): - detail = ctp_get_tick_detail(mode, symbol) - if detail.get("price"): - price = detail["price"] - quote_source = "ctp" - if detail.get("pre_close") is not None: - prev_close = detail["pre_close"] - except Exception: - pass - if price is None: - price = fetch_price(symbol, market_code, sina_code) - name = symbol - codes = ths_to_codes(symbol) - if codes: - name = codes.get("name", symbol) - if prev_close is None and sina_code: - from market import fetch_raw_for_volume - raw = fetch_raw_for_volume(sina_code) - if raw and raw.get("prev_close") is not None: - prev_close = raw["prev_close"] - return { - "symbol": symbol, - "name": name, - "price": price, - "prev_close": prev_close, - "quote_source": quote_source, - } - - -# —————————————— 推送 —————————————— - -def send_wechat_msg(content: str): - webhook = get_setting("wechat_webhook") - if not webhook: - return - full = f"【国内期货】\n{content}" - data = {"msgtype": "text", "text": {"content": full}} - try: - requests.post(webhook, json=data, timeout=10) - except Exception: - pass - -# —————————————— 行情 —————————————— - -def resolve_market_codes(ths_code: str, market_code: str = "", sina_code: str = "") -> tuple[str, str]: - """返回 (market_code, sina_code) 用于行情拉取。""" - if market_code: - return market_code, sina_code - if sina_code and "." in sina_code: - return sina_code, "" - codes = ths_to_codes(ths_code) - if codes: - return codes["market_code"], codes["sina_code"] - if ths_code.startswith("nf_") or ths_code.startswith("CFF_RE_"): - return ths_code, ths_code - return "", sina_code or "" - - -def fetch_price(ths_code: str, market_code: str = "", sina_code: str = "") -> Optional[float]: - sym = (ths_code or "").strip() - if sym: - try: - from vnpy_bridge import ctp_status, ctp_get_tick_price - from trading_context import get_trading_mode - - mode = get_trading_mode(get_setting) - if ctp_status(mode).get("connected"): - p = ctp_get_tick_price(mode, sym) - if p and p > 0: - return p - except Exception: - pass - mc, sc = resolve_market_codes(sym, market_code, sina_code) - if not mc and not sc: - return None - return market_get_price(mc, sc) - -# —————————————— 监控逻辑 —————————————— - -def check_order_plans(): - expire_old_plans() - today = today_str() - conn = get_db() - rows = conn.execute( - "SELECT * FROM order_plans WHERE plan_date=? AND status IN ('planned', 'active')", - (today,), - ).fetchall() - - for r in rows: - sym = r["symbol"] - sina = r["sina_code"] if "sina_code" in r.keys() else "" - market = r["market_code"] if "market_code" in r.keys() else "" - p = fetch_price(sym, market, sina) - if not p: - continue - - direction = r["direction"] - zone_upper = r["zone_upper"] - zone_lower = r["zone_lower"] - stop_loss = r["stop_loss"] - take_profit = r["take_profit"] - status = r["status"] - pid = r["id"] - name = r["symbol_name"] or sym - reason = r["decision_reason"] if "decision_reason" in r.keys() and r["decision_reason"] else "—" - - # 计划状态:价格进入决策区间则激活并通知 - if status == "planned": - in_zone = zone_lower <= p <= zone_upper - if in_zone: - msg = ( - f"【开单计划触发】{name} ({sym})\n" - f"方向:{'做多' if direction == 'long' else '做空'}\n" - f"决策区间:{zone_lower} ~ {zone_upper}\n" - f"决策理由:{reason}\n" - f"当前价:{p}\n" - f"止损:{stop_loss} 止盈:{take_profit}" - ) - send_wechat_msg(msg) - conn.execute( - "UPDATE order_plans SET status='active', triggered_at=? WHERE id=?", - (datetime.now().isoformat(), pid), - ) - status = "active" - - # 激活状态:监控止盈止损 - if status == "active": - res = None - if direction == "long": - if p >= take_profit: - res = "止盈" - elif p <= stop_loss: - res = "止损" - elif direction == "short": - if p <= take_profit: - res = "止盈" - elif p >= stop_loss: - res = "止损" - - if res: - msg = ( - f"[{'做多' if direction == 'long' else '做空'}] {name} 已{res}\n" - f"决策区间:{zone_lower} ~ {zone_upper}\n" - f"止损:{stop_loss} 止盈:{take_profit}\n" - f"当前价:{p}" - ) - send_wechat_msg(msg) - conn.execute( - """INSERT INTO trade_records - (symbol, symbol_name, monitor_type, direction, - trigger_price, stop_loss, take_profit, result) - VALUES (?,?,?,?,?,?,?,?)""", - (sym, name, "开单计划", direction, p, stop_loss, take_profit, res), - ) - conn.execute( - "UPDATE order_plans SET status='closed' WHERE id=?", (pid,) - ) - - conn.commit() - conn.close() - - -def check_key_monitors(): - conn = get_db() - rows = conn.execute( - "SELECT * FROM key_monitors WHERE status='active' OR status IS NULL" - ).fetchall() - - for r in rows: - sym = r["symbol"] - typ = r["monitor_type"] - up = r["upper"] - low = r["lower"] - up_trig = r["upper_triggered"] - low_trig = r["lower_triggered"] - name = r["symbol_name"] or sym - pid = r["id"] - sina = r["sina_code"] if "sina_code" in r.keys() else "" - market = r["market_code"] if "market_code" in r.keys() else "" - - p = fetch_price(sym, market, sina) - if not p: - continue - - if typ in ("箱体突破", "收敛突破"): - if p > up and not up_trig: - send_wechat_msg(f"{name} 突破{typ}上沿 {up}\n当前价:{p}") - conn.execute( - "UPDATE key_monitors SET upper_triggered=1 WHERE id=?", (pid,) - ) - if p < low and not low_trig: - send_wechat_msg(f"{name} 跌破{typ}下沿 {low}\n当前价:{p}") - conn.execute( - "UPDATE key_monitors SET lower_triggered=1 WHERE id=?", (pid,) - ) - elif typ == "关键阻力位" and p > up and not up_trig: - send_wechat_msg(f"{name} 突破阻力位 {up}\n当前价:{p}") - conn.execute( - "UPDATE key_monitors SET upper_triggered=1 WHERE id=?", (pid,) - ) - elif typ == "关键支撑位" and p < low and not low_trig: - send_wechat_msg(f"{name} 跌破支撑位 {low}\n当前价:{p}") - conn.execute( - "UPDATE key_monitors SET lower_triggered=1 WHERE id=?", (pid,) - ) - - conn.commit() - conn.close() - - -def background_task(): - while True: - try: - expire_old_plans() - check_key_monitors() - check_order_plans() - fn = getattr(app, "_check_trend_plans", None) - if fn: - fn(app) - except Exception: - pass - time.sleep(3) - - -def start_background_threads(): - from trading_context import get_trading_mode - - threading.Thread(target=background_task, daemon=True).start() - threading.Thread( - target=lambda: kline_hub.worker_loop( - DB_PATH, - build_market_quote_payload, - get_mode_fn=lambda: get_trading_mode(get_setting), - ), - daemon=True, - ).start() - threading.Thread(target=refresh_main_index, daemon=True).start() - - -# —————————————— 登录 —————————————— - -def login_required(f): - @wraps(f) - def wrap(*args, **kwargs): - if not session.get("logged_in"): - return redirect(url_for("login")) - return f(*args, **kwargs) - return wrap - - -@app.route("/") -def index(): - if session.get("logged_in"): - return redirect(url_for("positions")) - return redirect(url_for("login")) - - -@app.route("/manifest.webmanifest") -def web_manifest(): - response = app.send_static_file("manifest.json") - response.mimetype = "application/manifest+json" - return response - - -@app.route("/sw.js") -def service_worker(): - response = app.send_static_file("sw.js") - response.headers["Cache-Control"] = "no-cache" - response.headers["Service-Worker-Allowed"] = "/" - return response - - -@app.route("/login", methods=["GET", "POST"]) -def login(): - if request.method == "POST": - u = request.form.get("username", "").strip() - p = request.form.get("password", "") - admin_u = get_setting("admin_username") - admin_hash = get_setting("admin_password_hash") - if u == admin_u and check_password_hash(admin_hash, p): - session["logged_in"] = True - session["username"] = u - return redirect(url_for("positions")) - flash("账号或密码错误") - return render_template("login.html") - - -@app.route("/logout") -def logout(): - session.clear() - return redirect(url_for("login")) - -# —————————————— API —————————————— - -@app.route("/api/symbols/search") -@login_required -def api_symbol_search(): - q = request.args.get("q", "") - return jsonify(search_symbols(q)) - - -@app.route("/api/symbols/mains") -@login_required -def api_symbols_mains(): - return jsonify(list_main_contracts_grouped()) - - -@app.route("/api/symbols/recommended") -@login_required -def api_symbols_recommended(): - """品种下拉:仅展示当前资金下推荐的品种(与下方品种推荐表一致)。""" - from recommend_store import recommend_payload - from trading_context import ( - get_account_capital, - get_fixed_lots, - get_max_margin_pct, - get_sizing_mode, - get_trading_mode, - ) - - conn = get_db() - try: - capital = get_account_capital(conn, get_setting) - payload = recommend_payload( - conn, - live_capital=capital, - max_margin_pct=get_max_margin_pct(get_setting), - trading_mode=get_trading_mode(get_setting), - sizing_mode=get_sizing_mode(get_setting), - fixed_lots=get_fixed_lots(get_setting), - ) - return jsonify(list_recommended_symbols_grouped(payload.get("rows") or [])) - finally: - conn.close() - - -@app.route("/api/key_prices") -@login_required -def api_key_prices(): - """关键位监控列表:批量现价与距上/下沿距离。""" - conn = get_db() - rows = conn.execute( - "SELECT id, symbol, market_code, sina_code, upper, lower " - "FROM key_monitors WHERE status='active' OR status IS NULL" - ).fetchall() - conn.close() - out = [] - for r in rows: - sym = r["symbol"] - market = r["market_code"] or "" - sina = r["sina_code"] or "" - upper = float(r["upper"]) - lower = float(r["lower"]) - price = fetch_price(sym, market, sina) - dist_upper = None - dist_lower = None - if price is not None: - dist_upper = round(upper - price, 2) - dist_lower = round(price - lower, 2) - out.append({ - "id": r["id"], - "price": price, - "dist_upper": dist_upper, - "dist_lower": dist_lower, - }) - return jsonify(out) - - -@app.route("/api/plan_prices") -@login_required -def api_plan_prices(): - """今日计划:批量现价与距决策区间上/下沿距离。""" - today = today_str() - conn = get_db() - rows = conn.execute( - "SELECT id, symbol, market_code, sina_code, zone_upper, zone_lower " - "FROM order_plans WHERE plan_date=? AND status IN ('planned', 'active')", - (today,), - ).fetchall() - conn.close() - out = [] - for r in rows: - sym = r["symbol"] - market = r["market_code"] or "" - sina = r["sina_code"] or "" - upper = float(r["zone_upper"]) - lower = float(r["zone_lower"]) - price = fetch_price(sym, market, sina) - dist_upper = None - dist_lower = None - in_zone = False - if price is not None: - dist_upper = round(upper - price, 2) - dist_lower = round(price - lower, 2) - in_zone = lower <= price <= upper - out.append({ - "id": r["id"], - "price": price, - "dist_upper": dist_upper, - "dist_lower": dist_lower, - "in_zone": in_zone, - }) - return jsonify(out) - - -@app.route("/api/position_live") -@login_required -def api_position_live(): - capital = float(get_setting("live_capital", "0") or 0) - now_iso = datetime.now(TZ).strftime("%Y-%m-%dT%H:%M") - conn = get_db() - rows = conn.execute( - "SELECT * FROM position_monitors WHERE status='active' ORDER BY id DESC" - ).fetchall() - conn.close() - out = [] - for r in rows: - sym = r["symbol"] - market = r["market_code"] or "" - sina = r["sina_code"] or "" - direction = r["direction"] - entry = float(r["entry_price"]) - sl = float(r["stop_loss"]) - tp = float(r["take_profit"]) - lots = float(r["lots"] or 1) - mark = fetch_price(sym, market, sina) - metrics = calc_position_metrics( - direction, entry, sl, tp, lots, mark, capital, sym, - ) - holding = calc_holding_duration(r["open_time"] or "", now_iso) - close_est = mark if mark is not None else entry - fee_info = calc_fee_breakdown( - sym, entry, close_est, lots, r["open_time"] or "", now_iso, - trading_mode=_trading_mode(), - ) - est_net = None - if metrics.get("float_pnl") is not None: - est_net = round(metrics["float_pnl"] - fee_info["total_fee"], 2) - out.append({ - "id": r["id"], - "symbol": r["symbol_name"] or sym, - "symbol_code": sym, - "direction": "做多" if direction == "long" else "做空", - "lots": lots, - "entry_price": entry, - "stop_loss": sl, - "take_profit": tp, - "open_time": r["open_time"], - "mark_price": mark, - "holding_duration": holding, - "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, - **metrics, - }) - return jsonify(out) - - -@app.route("/plans") -@login_required -@require_nav("plans") -def plans(): - today = today_str() - start = request.args.get("start", "") - end = request.args.get("end", "") - - conn = get_db() - plan_list = conn.execute( - "SELECT * FROM order_plans WHERE plan_date=? AND status IN ('planned', 'active') ORDER BY id DESC", - (today,), - ).fetchall() - - sql = "SELECT * FROM order_plans WHERE plan_date < ? OR status IN ('closed', 'expired')" - params: list = [today] - if start: - sql += " AND plan_date >= ?" - params.append(start) - if end: - sql += " AND plan_date <= ?" - params.append(end) - sql += " ORDER BY plan_date DESC, id DESC LIMIT 200" - history = conn.execute(sql, params).fetchall() - conn.close() - return render_template( - "plans.html", - plans=plan_list, - history=history, - today=today, - start=start, - end=end, - ) - - -@app.route("/add_plan", methods=["POST"]) -@login_required -def add_plan(): - d = request.form - direction = d.get("direction") - 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 direction: - flash("请选择多空方向") - return redirect(url_for("plans")) - if not symbol or not market_code: - flash("请从下拉列表选择品种(同花顺合约代码)") - return redirect(url_for("plans")) - conn = get_db() - conn.execute( - """INSERT INTO order_plans - (symbol, symbol_name, market_code, sina_code, direction, - zone_upper, zone_lower, stop_loss, take_profit, plan_date, decision_reason) - VALUES (?,?,?,?,?,?,?,?,?,?,?)""", - ( - symbol, symbol_name, market_code, sina_code, direction, - float(d["zone_upper"]), float(d["zone_lower"]), - float(d["stop_loss"]), float(d["take_profit"]), - today_str(), - d.get("decision_reason", "").strip(), - ), - ) - conn.commit() - conn.close() - flash("开单计划已添加") - return redirect(url_for("plans")) - - -@app.route("/del_plan/") -@login_required -def del_plan(pid): - conn = get_db() - conn.execute("DELETE FROM order_plans WHERE id=?", (pid,)) - conn.commit() - conn.close() - flash("已删除") - return redirect(url_for("plans")) - - -@app.route("/keys") -@login_required -def keys(): - conn = get_db() - key_list = conn.execute( - "SELECT * FROM key_monitors WHERE status='active' OR status IS NULL ORDER BY id DESC" - ).fetchall() - history = conn.execute( - "SELECT * FROM key_monitors WHERE status='archived' ORDER BY archived_at DESC LIMIT 100" - ).fetchall() - conn.close() - return render_template("keys.html", keys=key_list, history=history) - - - -@app.route("/add_key", methods=["POST"]) -@login_required -def add_key(): - d = request.form - direction = d.get("direction") - 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 direction: - flash("请选择多空方向") - return redirect(url_for("keys")) - if not symbol or not market_code: - flash("请从下拉列表选择品种(同花顺合约代码)") - return redirect(url_for("keys")) - conn = get_db() - conn.execute( - """INSERT INTO key_monitors - (symbol, symbol_name, market_code, sina_code, monitor_type, direction, upper, lower) - VALUES (?,?,?,?,?,?,?,?)""", - (symbol, symbol_name, market_code, sina_code, d["type"], direction, float(d["upper"]), float(d["lower"])), - ) - conn.commit() - conn.close() - flash("关键位监控已添加") - return redirect(url_for("keys")) - - -@app.route("/add_position", methods=["POST"]) -@login_required -def add_position(): - flash("持仓由策略交易或 CTP 自动同步,无需手工录入") - return redirect(url_for("positions")) - - -@app.route("/del_position/") -@login_required -def del_position(pid): - return close_position(pid) - - -@app.route("/close_position/", methods=["POST"]) -@login_required -def close_position(pid): - conn = get_db() - row = conn.execute("SELECT * FROM position_monitors WHERE id=?", (pid,)).fetchone() - if not row: - conn.close() - flash("持仓不存在") - return redirect(url_for("positions")) - sym = row["symbol"] - market = row["market_code"] or "" - sina = row["sina_code"] or "" - direction = row["direction"] - entry = float(row["entry_price"]) - sl = float(row["stop_loss"]) - tp = float(row["take_profit"]) - lots = float(row["lots"] or 1) - open_time = row["open_time"] or "" - close_time = datetime.now(TZ).strftime("%Y-%m-%dT%H:%M") - close_price = fetch_price(sym, market, sina) - if close_price is None: - conn.close() - flash("无法获取现价,平仓失败") - return redirect(url_for("positions")) - capital = float(get_setting("live_capital", "0") or 0) - metrics = calc_position_metrics(direction, entry, sl, tp, lots, close_price, capital, sym) - pnl = metrics.get("float_pnl") or 0.0 - fee = calc_round_trip_fee(sym, entry, close_price, lots, open_time, close_time, trading_mode=_trading_mode()) - pnl_net = round(pnl - fee, 2) - result = classify_close_result(direction, close_price, sl, tp) - minutes = holding_to_minutes(open_time, close_time) - margin_pct = metrics.get("position_pct") - from trade_log_lib import calc_equity_after - equity_after = calc_equity_after(capital, pnl_net) - conn.execute( - """INSERT INTO trade_logs - (symbol, symbol_name, market_code, sina_code, monitor_type, direction, - entry_price, stop_loss, take_profit, close_price, lots, margin, - margin_pct, holding_minutes, open_time, close_time, pnl, fee, pnl_net, - equity_after, result) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - sym, row["symbol_name"], market, sina, "持仓监控", direction, - entry, sl, tp, close_price, lots, metrics["margin"], - margin_pct, - minutes, open_time, close_time, pnl, fee, pnl_net, equity_after, result, - ), - ) - conn.execute("DELETE FROM position_monitors WHERE id=?", (pid,)) - conn.commit() - conn.close() - touch_stats_cache() - flash(f"已平仓,盈亏 {pnl:.2f} 元(扣费后 {pnl_net:.2f} 元),已记入交易记录") - return redirect(url_for("positions")) - - -@app.route("/trades") -@login_required -def trades(): - return redirect(url_for("records")) - - -@app.route("/update_trade/", methods=["POST"]) -@login_required -def update_trade(tid): - d = request.form - conn = get_db() - conn.execute( - """UPDATE trade_logs SET - symbol_name=?, monitor_type=?, direction=?, - entry_price=?, stop_loss=?, take_profit=?, close_price=?, - lots=?, margin=?, holding_minutes=?, open_time=?, close_time=?, - pnl=?, result=?, verified=1 - WHERE id=?""", - ( - d.get("symbol_name", "").strip(), - d.get("monitor_type", "").strip(), - d.get("direction", "").strip(), - float(d.get("entry_price") or 0), - float(d.get("stop_loss") or 0), - float(d.get("take_profit") or 0), - float(d.get("close_price") or 0), - float(d.get("lots") or 0), - float(d.get("margin") or 0), - int(d.get("holding_minutes") or 0), - d.get("open_time", "").strip(), - d.get("close_time", "").strip(), - float(d.get("pnl") or 0), - d.get("result", "").strip(), - tid, - ), - ) - conn.commit() - conn.close() - touch_stats_cache() - flash("交易记录已核对保存") - return redirect(url_for("records")) - - -@app.route("/del_trade/") -@login_required -def del_trade(tid): - conn = get_db() - conn.execute("DELETE FROM trade_logs WHERE id=?", (tid,)) - conn.commit() - conn.close() - touch_stats_cache() - flash("已删除") - return redirect(url_for("records")) - - -@app.route("/fill_review/") -@login_required -def fill_review_from_trade(tid): - conn = get_db() - row = conn.execute("SELECT * FROM trade_logs WHERE id=?", (tid,)).fetchone() - conn.close() - if not row: - flash("记录不存在") - return redirect(url_for("records")) - q = { - "symbol": row["symbol"], - "symbol_name": row["symbol_name"] or row["symbol"], - "market_code": row["market_code"] or "", - "sina_code": row["sina_code"] or "", - "direction": row["direction"], - "entry_price": row["entry_price"], - "stop_loss": row["stop_loss"], - "take_profit": row["take_profit"], - "close_price": row["close_price"], - "lots": row["lots"], - "open_time": row["open_time"], - "close_time": row["close_time"], - "pnl": row["pnl"], - } - params = {k: v for k, v in q.items() if v is not None} - return redirect(url_for("records", **params) + "#review-panel") - - -@app.route("/del_key/") -@login_required -def del_key(pid): - conn = get_db() - conn.execute( - "UPDATE key_monitors SET status='archived', archived_at=? WHERE id=?", - (datetime.now(TZ).isoformat(), pid), - ) - conn.commit() - conn.close() - flash("已移入监控历史") - return redirect(url_for("keys")) - - -@app.route("/records") -@login_required -def records(): - preset = request.args.get("preset", "") - start = request.args.get("start", "") - end = request.args.get("end", "") - if preset: - start, end = parse_review_date_filter(preset, start, end) - - conn = get_db() - ctp_sync_info = None - try: - from ctp_trade_sync import sync_trade_logs_from_ctp - from trading_context import get_account_capital, get_trading_mode - from vnpy_bridge import ctp_status - - mode = get_trading_mode(get_setting) - if ctp_status(mode).get("connected"): - capital = get_account_capital(conn, get_setting) - ctp_sync_info = sync_trade_logs_from_ctp( - conn, mode, capital=capital, trading_mode=mode, - ) - conn.commit() - except Exception as exc: - app.logger.warning("ctp trade sync on records page: %s", exc) - - sql = "SELECT * FROM review_records WHERE 1=1" - params: list = [] - if start: - sql += " AND date(close_time) >= ?" - params.append(start) - if end: - sql += " AND date(close_time) <= ?" - params.append(end) - sql += " ORDER BY id DESC LIMIT 200" - review_list = conn.execute(sql, params).fetchall() - - auto_list = conn.execute( - "SELECT * FROM trade_records ORDER BY id DESC LIMIT 30" - ).fetchall() - trade_list = conn.execute( - "SELECT * FROM trade_logs ORDER BY id DESC LIMIT 500" - ).fetchall() - from trade_log_lib import enrich_trades_for_records - try: - initial_capital = float(get_setting("live_capital", "0") or 0) - except (TypeError, ValueError): - initial_capital = 0.0 - trades, equity_curve = enrich_trades_for_records( - [dict(r) for r in trade_list], - initial_capital=initial_capital, - ) - conn.close() - - trade_prefill_keys = ( - "symbol", "symbol_name", "market_code", "sina_code", "direction", - "entry_price", "stop_loss", "take_profit", "close_price", - "lots", "open_time", "close_time", "pnl", - ) - prefill = {k: request.args.get(k) for k in trade_prefill_keys if request.args.get(k)} - - return render_template( - "records.html", - reviews=review_list, - trades=trades, - equity_curve=equity_curve, - auto_records=auto_list, - ctp_sync_info=ctp_sync_info, - preset=preset, - start=start, - end=end, - prefill=prefill, - open_types=OPEN_TYPES, - exit_triggers=EXIT_TRIGGERS, - behavior_tags=BEHAVIOR_TAGS, - kline_periods=KLINE_PERIODS, - kline_cutoffs=KLINE_CUTOFFS, - ) - - -@app.route("/add_review", methods=["POST"]) -@login_required -def add_review(): - d = request.form - open_type = d.get("open_type", "").strip() - exit_trigger = d.get("exit_trigger", "").strip() - if not open_type: - flash("请选择开仓类型") - return redirect(url_for("records")) - if not exit_trigger: - flash("请选择离场触发") - return redirect(url_for("records")) - - 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("records")) - - screenshot = "" - f = request.files.get("screenshot") - if f and f.filename: - fname = secure_filename(f.filename) - ts = datetime.now(TZ).strftime("%Y%m%d%H%M%S") - screenshot = f"{ts}_{fname}" - f.save(os.path.join(UPLOAD_DIR, screenshot)) - - tags = [t for t in BEHAVIOR_TAGS if d.get(f"tag_{t}")] - is_emotion = 1 if tags else 0 - - def num(key: str) -> Optional[float]: - v = d.get(key, "").strip() - if not v: - return None - return float(v) - - open_time = d.get("open_time", "").strip() - close_time = d.get("close_time", "").strip() - direction = d.get("direction", "").strip() - entry_price = num("entry_price") - stop_loss = num("stop_loss") - take_profit = num("take_profit") - close_price = num("close_price") - lots = num("lots") or 1.0 - - holding = calc_holding_duration(open_time, close_time) - initial_pnl = calc_rr_ratio(direction, entry_price, stop_loss, take_profit) - actual_pnl = calc_rr_ratio(direction, entry_price, stop_loss, close_price) - - gross_pnl = num("pnl") - if gross_pnl is None and entry_price and close_price: - spec_mult = calc_position_metrics( - direction, entry_price, stop_loss, take_profit, - lots, close_price, 0, symbol, - ) - gross_pnl = spec_mult.get("float_pnl") - fee = calc_round_trip_fee( - symbol, entry_price or 0, close_price or 0, lots, open_time, close_time, - trading_mode=_trading_mode(), - ) - pnl_net = round((gross_pnl or 0) - fee, 2) if gross_pnl is not None else None - - auto_kline = bool(d.get("auto_kline")) - if auto_kline and not screenshot: - try: - generated = generate_review_kline_chart( - symbol=symbol, - periods=[d.get("kline_period1", "15m"), d.get("kline_period2", "1h")], - count=int(d.get("kline_count") or 300), - cutoff_label=d.get("kline_cutoff", "平仓时间"), - open_time=open_time, - close_time=close_time, - entry_price=entry_price, - stop_loss=stop_loss, - take_profit=take_profit, - close_price=close_price, - upload_dir=UPLOAD_DIR, - ) - if generated: - screenshot = generated - except Exception as exc: - app.logger.warning("auto kline failed: %s", exc) - - conn = get_db() - conn.execute( - """INSERT INTO review_records - (open_time, close_time, symbol, symbol_name, market_code, sina_code, - timeframe, direction, - entry_price, stop_loss, take_profit, close_price, lots, - holding_duration, initial_pnl, actual_pnl, pnl, fee, pnl_net, - open_type, expected_rr, actual_rr, exit_trigger, exit_supplement, - watch_after_breakeven, new_position_while_occupied, screenshot, - auto_kline, kline_period1, kline_period2, kline_count, kline_cutoff, - behavior_tags, is_emotion, notes) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - open_time, close_time, - symbol, symbol_name, market_code, sina_code, - d.get("timeframe", "").strip(), - direction, - entry_price, stop_loss, take_profit, close_price, lots, - holding, initial_pnl, actual_pnl, gross_pnl, fee, pnl_net, - open_type, - None, - None, - exit_trigger, - d.get("exit_supplement", "").strip(), - d.get("watch_after_breakeven", "否"), - d.get("new_position_while_occupied", "否"), - screenshot, - 1 if auto_kline else 0, - d.get("kline_period1", "15m"), - d.get("kline_period2", "1h"), - int(d.get("kline_count") or 300), - d.get("kline_cutoff", "平仓时间"), - ",".join(tags), - is_emotion, - d.get("notes", "").strip(), - ), - ) - hook = getattr(app, "_risk_review_hook", None) - if hook: - hook( - conn, - ",".join(tags), - exit_trigger, - d.get("exit_supplement", "").strip(), - ) - conn.commit() - conn.close() - touch_stats_cache() - flash("复盘记录已保存") - return redirect(url_for("records")) - - -@app.route("/del_review/") -@login_required -def del_review(rid): - conn = get_db() - row = conn.execute("SELECT screenshot FROM review_records WHERE id=?", (rid,)).fetchone() - if row and row["screenshot"]: - path = os.path.join(UPLOAD_DIR, row["screenshot"]) - if os.path.isfile(path): - os.remove(path) - conn.execute("DELETE FROM review_records WHERE id=?", (rid,)) - conn.commit() - conn.close() - touch_stats_cache() - flash("已删除") - return redirect(url_for("records")) - - -@app.route("/uploads/") -@login_required -def uploaded_file(filename): - from flask import send_from_directory - return send_from_directory(UPLOAD_DIR, filename) - - -@app.route("/del_record/") -@login_required -def del_record(rid): - conn = get_db() - conn.execute("DELETE FROM trade_records WHERE id=?", (rid,)) - conn.commit() - conn.close() - flash("已删除") - return redirect(url_for("records")) - - -@app.route("/stats") -@login_required -def stats(): - return render_template("stats.html") - - -@app.route("/api/stats") -@login_required -def api_stats(): - return jsonify(get_stats_data()) - - -@app.route("/api/stats/views") -@login_required -def api_stats_views(): - return jsonify({"views": STATS_VIEWS}) - - -@app.route("/api/stats/refresh", methods=["POST"]) -@login_required -def api_stats_refresh(): - conn = get_db() - capital = float(get_setting("live_capital", "0") or 0) - data = refresh_stats_cache(conn, capital) - conn.close() - return jsonify(data) - - -@app.route("/market") -@login_required -@require_nav("market") -def market_page(): - symbol = request.args.get("symbol", "").strip() - period = request.args.get("period", "15m").strip() - valid = {p["key"] for p in MARKET_PERIODS} - if period not in valid: - period = "15m" - ctp_st = {} - try: - from vnpy_bridge import ctp_status - from trading_context import get_trading_mode - - ctp_st = ctp_status(get_trading_mode(get_setting)) - except Exception: - pass - return render_template( - "market.html", - symbol=symbol, - period=period, - market_periods=MARKET_PERIODS, - quote_label=get_quote_source_label(ctp_connected=bool(ctp_st.get("connected"))), - ctp_connected=bool(ctp_st.get("connected")), - ) - - -@app.route("/api/kline") -@login_required -def api_kline(): - symbol = request.args.get("symbol", "").strip() - period = request.args.get("period", "15m").strip() - if not symbol: - return jsonify({"error": "请提供合约代码"}), 400 - try: - from trading_context import get_trading_mode - - data = fetch_market_klines( - symbol, period, DB_PATH, trading_mode=get_trading_mode(get_setting), - ) - except Exception as exc: - app.logger.warning("kline api failed: %s", exc) - return jsonify({"error": str(exc)}), 500 - if not data.get("chart_symbol"): - return jsonify({"error": "无法识别合约代码"}), 400 - if not data.get("bars"): - return jsonify({"error": "未获取到K线数据,请稍后重试或更换合约"}), 404 - return jsonify(data) - - -@app.route("/api/kline/stream") -@login_required -def api_kline_stream(): - from queue import Empty - - symbol = request.args.get("symbol", "").strip() - period = request.args.get("period", "15m").strip() - market_code = request.args.get("market_code", "").strip() - sina_code = request.args.get("sina_code", "").strip() - if not symbol: - return jsonify({"error": "请提供合约代码"}), 400 - - def generate(): - from trading_context import get_trading_mode - - mode = get_trading_mode(get_setting) - sub = kline_hub.subscribe(symbol, period, market_code, sina_code) - try: - kline_data = fetch_market_klines( - symbol, period, DB_PATH, trading_mode=mode, - ) - if kline_data.get("bars"): - yield sse_format("kline", kline_data) - yield sse_format( - "quote", - build_market_quote_payload(symbol, market_code, sina_code), - ) - while True: - try: - msg = sub.queue.get(timeout=20) - yield sse_format(msg["event"], msg["data"]) - except Empty: - yield ": heartbeat\n\n" - finally: - kline_hub.unsubscribe(sub) - - return Response( - stream_with_context(generate()), - mimetype="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - - -@app.route("/api/market_quote") -@login_required -def api_market_quote(): - symbol = request.args.get("symbol", "").strip() - market_code = request.args.get("market_code", "").strip() - sina_code = request.args.get("sina_code", "").strip() - if not symbol and not market_code: - return jsonify({"error": "请提供合约"}), 400 - return jsonify(build_market_quote_payload(symbol, market_code, sina_code)) - - -@app.route("/contract") -@login_required -@require_nav("contract") -def contract_profile_page(): - symbol = request.args.get("symbol", "").strip() - profile = None - error = None - if symbol: - try: - profile = get_contract_profile(symbol) - if not profile: - error = "未查询到该合约简介,请检查合约代码" - except Exception as exc: - app.logger.warning("contract profile failed: %s", exc) - error = f"查询失败:{exc}" - return render_template( - "contract.html", - symbol=symbol, - profile=profile, - error=error, - ) - - -@app.route("/api/contract_profile") -@login_required -def api_contract_profile(): - symbol = request.args.get("symbol", "").strip() - if not symbol: - return jsonify({"error": "请提供合约代码"}), 400 - try: - profile = get_contract_profile(symbol) - except Exception as exc: - return jsonify({"error": str(exc)}), 500 - if not profile: - return jsonify({"error": "未查询到合约简介"}), 404 - return jsonify(profile) - - -@app.route("/fees", methods=["GET", "POST"]) -@login_required -@require_nav("fees") -def fees(): - from trading_context import get_trading_mode - from ctp_fee_worker import ( - schedule_ctp_fee_sync, - get_fee_last_sync, - fees_synced_today, - fee_sync_in_progress, - ) - from vnpy_bridge import ctp_status - - mode = get_trading_mode(get_setting) - if request.method == "POST": - action = request.form.get("action") - if action == "sync_ctp": - force = request.form.get("force") == "1" - _, msg = schedule_ctp_fee_sync( - mode, - get_setting=get_setting, - set_setting=set_setting, - force=force, - ) - flash(msg) - return redirect(url_for("fees")) - - rates = list_fee_rates_for_ui() - fee_counts = count_fee_rates_by_source() - ctp_st = ctp_status(mode) - return render_template( - "fees.html", - rates=rates, - fee_counts=fee_counts, - fee_last_sync=get_fee_last_sync(get_setting), - fee_synced_today=fees_synced_today(get_setting), - fee_sync_running=fee_sync_in_progress(), - ctp_connected=bool(ctp_st.get("connected")), - ) - - -@app.route("/settings", methods=["GET", "POST"]) -@login_required -def settings(): - if request.method == "POST": - action = request.form.get("action") - if action == "wechat": - webhook = request.form.get("wechat_webhook", "").strip() - set_setting("wechat_webhook", webhook) - flash("企业微信配置已保存") - elif action == "trading": - mode = request.form.get("trading_mode", "simulation").strip() - if mode not in ("simulation", "live"): - mode = "simulation" - sizing = request.form.get("position_sizing_mode", "fixed").strip() - if sizing == "risk": - sizing = "amount" - if sizing not in ("fixed", "amount"): - sizing = "fixed" - set_setting("trading_mode", mode) - set_setting("position_sizing_mode", sizing) - try: - fl = int(float(request.form.get("fixed_lots", "1") or 1)) - set_setting("fixed_lots", str(max(1, fl))) - except ValueError: - flash("固定手数无效") - return redirect(url_for("settings")) - try: - fa = float(request.form.get("fixed_amount", "5000") or 5000) - set_setting("fixed_amount", str(max(1.0, fa))) - except ValueError: - flash("固定金额无效") - return redirect(url_for("settings")) - try: - rp = float(request.form.get("risk_percent", "1") or 1) - set_setting("risk_percent", str(max(0.1, min(100.0, rp)))) - except ValueError: - pass - try: - mp = float(request.form.get("max_margin_pct", "30") or 30) - set_setting("max_margin_pct", str(max(1.0, min(100.0, mp)))) - except ValueError: - flash("保证金比例无效") - return redirect(url_for("settings")) - try: - tb = int(float(request.form.get("trailing_be_tick_buffer", "2") or 2)) - set_setting("trailing_be_tick_buffer", str(max(1, min(20, tb)))) - except ValueError: - flash("移动保本缓冲无效") - return redirect(url_for("settings")) - try: - pt = int(float(request.form.get("pending_order_timeout_min", "5") or 5)) - set_setting("pending_order_timeout_min", str(max(1, min(60, pt)))) - except ValueError: - flash("挂单超时无效") - return redirect(url_for("settings")) - flash("交易模式已保存") - elif action == "ctp": - from ctp_settings import save_ctp_settings_from_form - - save_result = save_ctp_settings_from_form(request.form, set_setting) - pwd_updated = save_result.get("passwords_updated") or [] - pwd_empty = save_result.get("passwords_submitted_empty") or [] - simnow_pwd_len = len((request.form.get("simnow_password") or "").strip()) - live_pwd_len = len((request.form.get("ctp_live_password") or "").strip()) - print( - f"CTP settings save: simnow_password_len={simnow_pwd_len} " - f"live_password_len={live_pwd_len} updated={pwd_updated}", - flush=True, - ) - app.logger.info( - "CTP settings save: simnow_password_len=%s live_password_len=%s updated=%s", - simnow_pwd_len, - live_pwd_len, - pwd_updated, - ) - if "simnow_password" in pwd_updated: - pwd_note = f"SimNow 交易密码已更新({simnow_pwd_len} 位)" - elif "simnow_password" in pwd_empty: - pwd_note = "SimNow 交易密码未改:提交为空,请在「交易密码」框手打后再保存" - elif "ctp_live_password" in pwd_updated: - pwd_note = "实盘交易密码已更新" - elif "ctp_live_password" in pwd_empty: - pwd_note = "实盘交易密码未改(提交为空)" - else: - pwd_note = "" - flash_msg = "CTP 配置已保存,正在使用新地址重连…" - if pwd_note: - flash_msg = f"CTP 配置已保存;{pwd_note},正在重连…" - try: - from vnpy_bridge import get_bridge - from trading_context import get_trading_mode - - b = get_bridge() - if pwd_updated: - b._clear_login_cooldown() - mode = get_trading_mode(get_setting) - info = b.reconnect_after_settings_saved(mode) - if info.get("cooldown"): - flash_msg = f"CTP 配置已保存;{pwd_note or '请稍后再连'}" - elif not info.get("started") and info.get("connected"): - flash_msg = f"CTP 配置已保存;{pwd_note or '当前连接正常'}" - except Exception as exc: - app.logger.warning("CTP reconnect after settings save: %s", exc) - flash_msg = f"CTP 配置已保存;{pwd_note or '请稍后在持仓监控页重连'}" - flash(flash_msg) - elif action == "nav": - items = {k: request.form.get(f"nav_{k}") == "on" for k in NAV_TOGGLES} - save_nav_items(set_setting, items) - flash("导航显示已保存") - elif action == "password": - old_p = request.form.get("old_password", "") - new_p = request.form.get("new_password", "") - new_p2 = request.form.get("new_password2", "") - admin_hash = get_setting("admin_password_hash") - if not check_password_hash(admin_hash, old_p): - flash("原密码错误") - elif len(new_p) < 6: - flash("新密码至少 6 位") - elif new_p != new_p2: - flash("两次新密码不一致") - else: - set_setting("admin_password_hash", generate_password_hash(new_p)) - flash("密码修改成功") - return redirect(url_for("settings")) - - webhook = get_setting("wechat_webhook") - username = get_setting("admin_username") - ctp_st = {} - try: - from vnpy_bridge import ctp_status - from trading_context import get_trading_mode - - ctp_st = ctp_status(get_trading_mode(get_setting)) - except Exception: - pass - from ctp_settings import get_ctp_settings_for_ui - - return render_template( - "settings.html", - webhook=webhook, - username=username, - quote_label=get_quote_source_label(ctp_connected=bool(ctp_st.get("connected"))), - ctp_status=ctp_st, - ctp_cfg=get_ctp_settings_for_ui(), - trading_mode=get_setting("trading_mode", "simulation"), - position_sizing_mode=get_setting("position_sizing_mode", "fixed"), - fixed_lots=get_setting("fixed_lots", "1"), - fixed_amount=get_setting("fixed_amount", "5000"), - risk_percent=get_setting("risk_percent", "1"), - max_margin_pct=get_setting("max_margin_pct", "30"), - trailing_be_tick_buffer=get_setting("trailing_be_tick_buffer", "2"), - pending_order_timeout_min=get_setting("pending_order_timeout_min", "5"), - nav_items=get_nav_items(get_setting), - nav_toggles=NAV_TOGGLES, - ) - - -install_trading( - app, - login_required=login_required, - require_nav=require_nav, - get_db=get_db, - get_setting=get_setting, - set_setting=set_setting, - fetch_price=fetch_price, - send_wechat_msg=send_wechat_msg, -) - -try_init_vnpy({}) - -start_background_threads() - -# —————————————— 启动 —————————————— - -if __name__ == "__main__": - app.run(host=HOST, port=PORT, debug=DEBUG, threaded=True) +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +import os + +from locale_fix import ensure_process_locale + +ensure_process_locale() + +import sqlite3 +import time +import threading +import requests +from datetime import datetime, timedelta +from typing import Optional +from functools import wraps +from zoneinfo import ZoneInfo + +from werkzeug.utils import secure_filename + +from dotenv import load_dotenv +from flask import ( + Flask, render_template, request, redirect, url_for, + flash, session, jsonify, Response, stream_with_context, +) +from werkzeug.security import check_password_hash, generate_password_hash + +from functools import wraps + +from symbols import ( + search_symbols, + ths_to_codes, + list_main_contracts_grouped, + list_recommended_symbols_grouped, + refresh_main_index, +) +from contract_specs import calc_position_metrics +from fee_specs import ( + calc_fee_breakdown, + calc_round_trip_fee, + list_fee_rates_for_ui, + count_fee_rates_by_source, + purge_non_ctp_fee_rates, +) +from nav_settings import NAV_TOGGLES, get_nav_items, nav_enabled, save_nav_items +from contract_profile import get_contract_profile +from stats_engine import STATS_VIEWS, load_stats_cache, refresh_stats_cache +from kline_store import ensure_kline_tables +from kline_stream import kline_hub, sse_format +from kline_chart import generate_review_kline_chart, fetch_market_klines, MARKET_PERIODS +from market import get_price as market_get_price, set_ths_refresh_token, get_quote_source_label +from db_conn import connect_db +from strategy.strategy_db import init_strategy_tables +from install_trading import install_trading +from vnpy_bridge import try_init_vnpy + +load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")) + +app = Flask(__name__) +app.secret_key = os.getenv("SECRET_KEY", "futures_monitor_default_secret") + +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "6600")) +DEBUG = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes") + +DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "futures.db") +UPLOAD_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "uploads") +TZ = ZoneInfo("Asia/Shanghai") + +OPEN_TYPES = ["突破开仓", "回调开仓", "追涨杀跌", "计划内开仓", "震荡摸顶底", "其他"] +EXIT_TRIGGERS = ["止盈", "止损", "手工平仓", "移动止损", "时间离场", "其他"] +BEHAVIOR_TAGS = ["怕踏空", "报复开仓", "盈利飘了", "拿不住单", "扛单", "重仓违规"] +KLINE_PERIODS = ["1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"] +KLINE_CUTOFFS = ["平仓时间", "开仓时间", "当前时间"] + + +def today_str() -> str: + return datetime.now(TZ).date().isoformat() + + +def calc_holding_duration(open_time: str, close_time: str) -> str: + try: + o = datetime.fromisoformat(open_time.strip().replace(" ", "T")[:19]) + c = datetime.fromisoformat(close_time.strip().replace(" ", "T")[:19]) + delta = c - o + if delta.total_seconds() < 0: + return "" + secs = int(delta.total_seconds()) + h, rem = divmod(secs, 3600) + m, _ = divmod(rem, 60) + if h: + return f"{h}小时{m}分钟" + return f"{m}分钟" + except Exception: + return "" + + +def holding_to_minutes(open_time: str, close_time: str) -> int: + try: + o = datetime.fromisoformat(open_time.strip().replace(" ", "T")) + c = datetime.fromisoformat(close_time.strip().replace(" ", "T")) + secs = int((c - o).total_seconds()) + return max(0, secs // 60) + except Exception: + return 0 + + +def classify_close_result(direction: str, close: float, sl: float, tp: float) -> str: + """根据平仓价与止损/止盈距离判断结果。""" + if close is None: + return "手动平仓" + tol = max(abs(close) * 0.002, 1.0) + if abs(close - tp) <= tol: + return "止盈" + if abs(close - sl) <= tol: + return "止损" + return "手动平仓" + + +def calc_rr_ratio(direction: str, entry: float, stop: float, target: float) -> Optional[float]: + """盈亏比 = 盈利空间 / 风险空间。""" + if entry is None or stop is None or target is None: + return None + if direction == "long": + risk = entry - stop + if risk <= 0: + return None + return round((target - entry) / risk, 2) + if direction == "short": + risk = stop - entry + if risk <= 0: + return None + return round((entry - target) / risk, 2) + return None + + +def calc_theoretical_pnl(direction: str, entry: float, target: float, lots: float) -> Optional[float]: + if entry is None or target is None or lots is None: + return None + if direction == "long": + return round((target - entry) * lots, 2) + if direction == "short": + return round((entry - target) * lots, 2) + return None + + +def parse_review_date_filter(preset: str, start: str, end: str) -> tuple[str, str]: + today = datetime.now(TZ).date() + if preset == "today": + s = today.isoformat() + return s, s + if preset == "week": + monday = today - timedelta(days=today.weekday()) + return monday.isoformat(), today.isoformat() + if preset == "month": + return today.replace(day=1).isoformat(), today.isoformat() + return start.strip(), end.strip() + + +def expire_old_plans(): + """当日结束后计划自动失效,保留历史。""" + today = today_str() + conn = get_db() + conn.execute( + "UPDATE order_plans SET status='expired' WHERE plan_date < ? AND status IN ('planned', 'active')", + (today,), + ) + conn.execute( + "UPDATE order_plans SET plan_date=date(created_at) WHERE plan_date IS NULL OR plan_date=''" + ) + conn.commit() + conn.close() + + +def get_db(): + return connect_db() + + +def get_setting(key: str, default: str = "") -> str: + conn = get_db() + row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() + conn.close() + return row["value"] if row else default + + +def set_setting(key: str, value: str): + conn = get_db() + conn.execute( + "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=?", + (key, value, value), + ) + conn.commit() + conn.close() + + +def require_nav(key: str): + """导航项关闭时拒绝访问对应页面。""" + def decorator(f): + @wraps(f) + def wrapped(*args, **kwargs): + if not nav_enabled(get_setting, key): + flash("该页面已在系统设置中关闭") + return redirect(url_for("positions")) + return f(*args, **kwargs) + return wrapped + return decorator + + +@app.context_processor +def inject_globals(): + trade_js = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static", "js", "trade.js") + asset_v = str(int(os.path.getmtime(trade_js))) if os.path.isfile(trade_js) else "0" + return {"nav_items": get_nav_items(get_setting), "asset_v": asset_v} + + +def _trading_mode() -> str: + return (get_setting("trading_mode", "simulation") or "simulation").strip() + + +def touch_stats_cache(): + try: + conn = get_db() + capital = float(get_setting("live_capital", "0") or 0) + refresh_stats_cache(conn, capital) + conn.close() + except Exception as exc: + app.logger.warning("stats cache refresh failed: %s", exc) + + +def get_stats_data() -> dict: + conn = get_db() + capital = float(get_setting("live_capital", "0") or 0) + data = load_stats_cache(conn) + if not data: + data = refresh_stats_cache(conn, capital) + conn.close() + return data + + +def init_db(): + conn = get_db() + c = conn.cursor() + c.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)") + c.execute('''CREATE TABLE IF NOT EXISTS order_plans + (id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT, symbol_name TEXT, direction TEXT, + zone_upper REAL, zone_lower REAL, + stop_loss REAL, take_profit REAL, + status TEXT DEFAULT "planned", + triggered_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute('''CREATE TABLE IF NOT EXISTS key_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT, symbol_name TEXT, monitor_type TEXT, direction TEXT, + upper REAL, lower REAL, + upper_triggered INTEGER DEFAULT 0, + lower_triggered INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute('''CREATE TABLE IF NOT EXISTS trade_records + (id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT, symbol_name TEXT, monitor_type TEXT, direction TEXT, + trigger_price REAL, stop_loss REAL, take_profit REAL, + result TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + migrations = [ + "ALTER TABLE key_monitors ADD COLUMN symbol_name TEXT", + "ALTER TABLE key_monitors ADD COLUMN upper_triggered INTEGER DEFAULT 0", + "ALTER TABLE key_monitors ADD COLUMN lower_triggered INTEGER DEFAULT 0", + "ALTER TABLE trade_records ADD COLUMN symbol_name TEXT", + "ALTER TABLE order_plans ADD COLUMN sina_code TEXT", + "ALTER TABLE order_plans ADD COLUMN market_code TEXT", + "ALTER TABLE key_monitors ADD COLUMN market_code TEXT", + "ALTER TABLE key_monitors ADD COLUMN sina_code TEXT", + "ALTER TABLE trade_records ADD COLUMN market_code TEXT", + "ALTER TABLE order_plans ADD COLUMN plan_date TEXT", + "ALTER TABLE order_plans ADD COLUMN decision_reason TEXT", + "ALTER TABLE key_monitors ADD COLUMN status TEXT DEFAULT 'active'", + "ALTER TABLE key_monitors ADD COLUMN archived_at TEXT", + "ALTER TABLE review_records ADD COLUMN direction TEXT", + "ALTER TABLE review_records ADD COLUMN entry_price REAL", + "ALTER TABLE review_records ADD COLUMN stop_loss REAL", + "ALTER TABLE review_records ADD COLUMN take_profit REAL", + "ALTER TABLE review_records ADD COLUMN close_price REAL", + "ALTER TABLE review_records ADD COLUMN lots REAL", + "ALTER TABLE review_records ADD COLUMN holding_duration TEXT", + "ALTER TABLE review_records ADD COLUMN initial_pnl REAL", + "ALTER TABLE review_records ADD COLUMN actual_pnl REAL", + "ALTER TABLE review_records ADD COLUMN is_emotion INTEGER DEFAULT 0", + "ALTER TABLE review_records ADD COLUMN symbol_name TEXT", + "ALTER TABLE review_records ADD COLUMN market_code TEXT", + "ALTER TABLE review_records ADD COLUMN sina_code TEXT", + "ALTER TABLE trade_logs ADD COLUMN fee REAL", + "ALTER TABLE trade_logs ADD COLUMN pnl_net REAL", + "ALTER TABLE trade_logs ADD COLUMN margin_pct REAL", + "ALTER TABLE trade_logs ADD COLUMN equity_after REAL", + "ALTER TABLE review_records ADD COLUMN fee REAL", + "ALTER TABLE review_records ADD COLUMN pnl_net REAL", + ] + for sql in migrations: + try: + c.execute(sql) + except sqlite3.OperationalError: + pass + c.execute('''CREATE TABLE IF NOT EXISTS review_records + (id INTEGER PRIMARY KEY AUTOINCREMENT, + open_time TEXT, close_time TEXT, + symbol TEXT, timeframe TEXT, + pnl REAL, + open_type TEXT, expected_rr REAL, actual_rr REAL, + exit_trigger TEXT, exit_supplement TEXT, + watch_after_breakeven TEXT, new_position_while_occupied TEXT, + screenshot TEXT, + auto_kline INTEGER DEFAULT 0, + kline_period1 TEXT, kline_period2 TEXT, + kline_count INTEGER, kline_cutoff TEXT, + behavior_tags TEXT, notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute('''CREATE TABLE IF NOT EXISTS position_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT, symbol_name TEXT, market_code TEXT, sina_code TEXT, + direction TEXT, lots REAL, entry_price REAL, + stop_loss REAL, take_profit REAL, open_time TEXT, + status TEXT DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute('''CREATE TABLE IF NOT EXISTS trade_logs + (id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT, symbol_name TEXT, market_code TEXT, sina_code TEXT, + monitor_type TEXT, direction TEXT, + entry_price REAL, stop_loss REAL, take_profit REAL, close_price REAL, + lots REAL, margin REAL, holding_minutes INTEGER, + open_time TEXT, close_time TEXT, + pnl REAL, result TEXT, + verified INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute('''CREATE TABLE IF NOT EXISTS fee_rates + (product TEXT PRIMARY KEY, + exchange TEXT, + mult INTEGER, + open_fixed REAL DEFAULT 0, + open_ratio REAL DEFAULT 0, + close_yesterday_fixed REAL DEFAULT 0, + close_yesterday_ratio REAL DEFAULT 0, + close_today_fixed REAL DEFAULT 0, + close_today_ratio REAL DEFAULT 0, + updated_at TEXT)''') + c.execute('''CREATE TABLE IF NOT EXISTS stats_cache + (key TEXT PRIMARY KEY, + data_json TEXT NOT NULL, + updated_at TEXT NOT NULL)''') + for sql in ( + "ALTER TABLE fee_rates ADD COLUMN source TEXT DEFAULT 'local'", + ): + try: + c.execute(sql) + except sqlite3.OperationalError: + pass + ensure_kline_tables(conn) + init_strategy_tables(conn) + from risk.account_risk_lib import ensure_account_risk_schema + from recommend_store import ensure_recommend_tables + ensure_account_risk_schema(conn) + ensure_recommend_tables(conn) + conn.commit() + conn.close() + + sync_admin_from_env() + + if not get_setting("wechat_webhook") and os.getenv("WECHAT_WEBHOOK"): + set_setting("wechat_webhook", os.getenv("WECHAT_WEBHOOK")) + + if not get_setting("ths_refresh_token") and os.getenv("THS_REFRESH_TOKEN"): + set_setting("ths_refresh_token", os.getenv("THS_REFRESH_TOKEN")) + + from ctp_settings import seed_ctp_settings_from_env + seed_ctp_settings_from_env(set_setting) + + os.makedirs(UPLOAD_DIR, exist_ok=True) + expire_old_plans() + + if not get_setting("fee_multiplier"): + set_setting("fee_multiplier", "2") + if not get_setting("trading_mode"): + set_setting("trading_mode", "simulation") + if not get_setting("position_sizing_mode"): + set_setting("position_sizing_mode", "fixed") + if not get_setting("fixed_lots"): + set_setting("fixed_lots", "1") + if not get_setting("fixed_amount"): + set_setting("fixed_amount", "5000") + if not get_setting("risk_percent"): + set_setting("risk_percent", "1") + if not get_setting("max_margin_pct"): + set_setting("max_margin_pct", "30") + if not get_setting("trailing_be_tick_buffer"): + set_setting("trailing_be_tick_buffer", "2") + if not get_setting("pending_order_timeout_min"): + set_setting("pending_order_timeout_min", "5") + if not get_setting("fee_source_mode"): + set_setting("fee_source_mode", "ctp") + set_setting("fee_source_mode", "ctp") + try: + purge_non_ctp_fee_rates() + except Exception: + pass + + +def sync_admin_from_env(): + """ + 从 .env 同步管理员账号。 + - 首次建库:自动写入 ADMIN_USERNAME / ADMIN_PASSWORD + - 已建库后改 .env:需设 ADMIN_SYNC_FROM_ENV=true 并重启服务 + """ + sync = os.getenv("ADMIN_SYNC_FROM_ENV", "false").lower() in ("1", "true", "yes") + env_username = os.getenv("ADMIN_USERNAME", "").strip() + env_password = os.getenv("ADMIN_PASSWORD", "").strip() + placeholder_passwords = {"", "change-me-on-first-login", "admin123"} + + if not get_setting("admin_username"): + username = env_username or "admin" + password = env_password if env_password not in placeholder_passwords else "admin123" + set_setting("admin_username", username) + set_setting("admin_password_hash", generate_password_hash(password)) + return + + if not sync: + return + + if env_username: + set_setting("admin_username", env_username) + if env_password and env_password not in placeholder_passwords: + set_setting("admin_password_hash", generate_password_hash(env_password)) + + +init_db() + + +def sync_ths_token(): + set_ths_refresh_token(get_setting("ths_refresh_token")) + + +sync_ths_token() + + +def build_market_quote_payload( + symbol: str, + market_code: str = "", + sina_code: str = "", +) -> dict: + if not market_code or not sina_code: + codes = ths_to_codes(symbol) + if codes: + market_code = codes.get("market_code", "") or market_code + sina_code = codes.get("sina_code", "") or sina_code + quote_source = "sina" + price = None + prev_close = None + try: + from vnpy_bridge import ctp_status, ctp_get_tick_detail + from trading_context import get_trading_mode + + mode = get_trading_mode(get_setting) + if ctp_status(mode).get("connected"): + detail = ctp_get_tick_detail(mode, symbol) + if detail.get("price"): + price = detail["price"] + quote_source = "ctp" + if detail.get("pre_close") is not None: + prev_close = detail["pre_close"] + except Exception: + pass + if price is None: + price = fetch_price(symbol, market_code, sina_code) + name = symbol + codes = ths_to_codes(symbol) + if codes: + name = codes.get("name", symbol) + if prev_close is None and sina_code: + from market import fetch_raw_for_volume + raw = fetch_raw_for_volume(sina_code) + if raw and raw.get("prev_close") is not None: + prev_close = raw["prev_close"] + return { + "symbol": symbol, + "name": name, + "price": price, + "prev_close": prev_close, + "quote_source": quote_source, + } + + +# —————————————— 推送 —————————————— + +def send_wechat_msg(content: str): + webhook = get_setting("wechat_webhook") + if not webhook: + return + full = f"【国内期货】\n{content}" + data = {"msgtype": "text", "text": {"content": full}} + try: + requests.post(webhook, json=data, timeout=10) + except Exception: + pass + +# —————————————— 行情 —————————————— + +def resolve_market_codes(ths_code: str, market_code: str = "", sina_code: str = "") -> tuple[str, str]: + """返回 (market_code, sina_code) 用于行情拉取。""" + if market_code: + return market_code, sina_code + if sina_code and "." in sina_code: + return sina_code, "" + codes = ths_to_codes(ths_code) + if codes: + return codes["market_code"], codes["sina_code"] + if ths_code.startswith("nf_") or ths_code.startswith("CFF_RE_"): + return ths_code, ths_code + return "", sina_code or "" + + +def fetch_price(ths_code: str, market_code: str = "", sina_code: str = "") -> Optional[float]: + sym = (ths_code or "").strip() + if sym: + try: + from vnpy_bridge import ctp_status, ctp_get_tick_price + from trading_context import get_trading_mode + + mode = get_trading_mode(get_setting) + if ctp_status(mode).get("connected"): + p = ctp_get_tick_price(mode, sym) + if p and p > 0: + return p + except Exception: + pass + mc, sc = resolve_market_codes(sym, market_code, sina_code) + if not mc and not sc: + return None + return market_get_price(mc, sc) + +# —————————————— 监控逻辑 —————————————— + +def check_order_plans(): + expire_old_plans() + today = today_str() + conn = get_db() + rows = conn.execute( + "SELECT * FROM order_plans WHERE plan_date=? AND status IN ('planned', 'active')", + (today,), + ).fetchall() + + for r in rows: + sym = r["symbol"] + sina = r["sina_code"] if "sina_code" in r.keys() else "" + market = r["market_code"] if "market_code" in r.keys() else "" + p = fetch_price(sym, market, sina) + if not p: + continue + + direction = r["direction"] + zone_upper = r["zone_upper"] + zone_lower = r["zone_lower"] + stop_loss = r["stop_loss"] + take_profit = r["take_profit"] + status = r["status"] + pid = r["id"] + name = r["symbol_name"] or sym + reason = r["decision_reason"] if "decision_reason" in r.keys() and r["decision_reason"] else "—" + + # 计划状态:价格进入决策区间则激活并通知 + if status == "planned": + in_zone = zone_lower <= p <= zone_upper + if in_zone: + msg = ( + f"【开单计划触发】{name} ({sym})\n" + f"方向:{'做多' if direction == 'long' else '做空'}\n" + f"决策区间:{zone_lower} ~ {zone_upper}\n" + f"决策理由:{reason}\n" + f"当前价:{p}\n" + f"止损:{stop_loss} 止盈:{take_profit}" + ) + send_wechat_msg(msg) + conn.execute( + "UPDATE order_plans SET status='active', triggered_at=? WHERE id=?", + (datetime.now().isoformat(), pid), + ) + status = "active" + + # 激活状态:监控止盈止损 + if status == "active": + res = None + if direction == "long": + if p >= take_profit: + res = "止盈" + elif p <= stop_loss: + res = "止损" + elif direction == "short": + if p <= take_profit: + res = "止盈" + elif p >= stop_loss: + res = "止损" + + if res: + msg = ( + f"[{'做多' if direction == 'long' else '做空'}] {name} 已{res}\n" + f"决策区间:{zone_lower} ~ {zone_upper}\n" + f"止损:{stop_loss} 止盈:{take_profit}\n" + f"当前价:{p}" + ) + send_wechat_msg(msg) + conn.execute( + """INSERT INTO trade_records + (symbol, symbol_name, monitor_type, direction, + trigger_price, stop_loss, take_profit, result) + VALUES (?,?,?,?,?,?,?,?)""", + (sym, name, "开单计划", direction, p, stop_loss, take_profit, res), + ) + conn.execute( + "UPDATE order_plans SET status='closed' WHERE id=?", (pid,) + ) + + conn.commit() + conn.close() + + +def check_key_monitors(): + conn = get_db() + rows = conn.execute( + "SELECT * FROM key_monitors WHERE status='active' OR status IS NULL" + ).fetchall() + + for r in rows: + sym = r["symbol"] + typ = r["monitor_type"] + up = r["upper"] + low = r["lower"] + up_trig = r["upper_triggered"] + low_trig = r["lower_triggered"] + name = r["symbol_name"] or sym + pid = r["id"] + sina = r["sina_code"] if "sina_code" in r.keys() else "" + market = r["market_code"] if "market_code" in r.keys() else "" + + p = fetch_price(sym, market, sina) + if not p: + continue + + if typ in ("箱体突破", "收敛突破"): + if p > up and not up_trig: + send_wechat_msg(f"{name} 突破{typ}上沿 {up}\n当前价:{p}") + conn.execute( + "UPDATE key_monitors SET upper_triggered=1 WHERE id=?", (pid,) + ) + if p < low and not low_trig: + send_wechat_msg(f"{name} 跌破{typ}下沿 {low}\n当前价:{p}") + conn.execute( + "UPDATE key_monitors SET lower_triggered=1 WHERE id=?", (pid,) + ) + elif typ == "关键阻力位" and p > up and not up_trig: + send_wechat_msg(f"{name} 突破阻力位 {up}\n当前价:{p}") + conn.execute( + "UPDATE key_monitors SET upper_triggered=1 WHERE id=?", (pid,) + ) + elif typ == "关键支撑位" and p < low and not low_trig: + send_wechat_msg(f"{name} 跌破支撑位 {low}\n当前价:{p}") + conn.execute( + "UPDATE key_monitors SET lower_triggered=1 WHERE id=?", (pid,) + ) + + conn.commit() + conn.close() + + +def background_task(): + while True: + try: + expire_old_plans() + check_key_monitors() + check_order_plans() + fn = getattr(app, "_check_trend_plans", None) + if fn: + fn(app) + except Exception: + pass + time.sleep(3) + + +def start_background_threads(): + from trading_context import get_trading_mode + + threading.Thread(target=background_task, daemon=True).start() + threading.Thread( + target=lambda: kline_hub.worker_loop( + DB_PATH, + build_market_quote_payload, + get_mode_fn=lambda: get_trading_mode(get_setting), + ), + daemon=True, + ).start() + threading.Thread(target=refresh_main_index, daemon=True).start() + + +# —————————————— 登录 —————————————— + +def login_required(f): + @wraps(f) + def wrap(*args, **kwargs): + if not session.get("logged_in"): + return redirect(url_for("login")) + return f(*args, **kwargs) + return wrap + + +@app.route("/") +def index(): + if session.get("logged_in"): + return redirect(url_for("positions")) + return redirect(url_for("login")) + + +@app.route("/manifest.webmanifest") +def web_manifest(): + response = app.send_static_file("manifest.json") + response.mimetype = "application/manifest+json" + return response + + +@app.route("/sw.js") +def service_worker(): + response = app.send_static_file("sw.js") + response.headers["Cache-Control"] = "no-cache" + response.headers["Service-Worker-Allowed"] = "/" + return response + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + u = request.form.get("username", "").strip() + p = request.form.get("password", "") + admin_u = get_setting("admin_username") + admin_hash = get_setting("admin_password_hash") + if u == admin_u and check_password_hash(admin_hash, p): + session["logged_in"] = True + session["username"] = u + return redirect(url_for("positions")) + flash("账号或密码错误") + return render_template("login.html") + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + +# —————————————— API —————————————— + +@app.route("/api/symbols/search") +@login_required +def api_symbol_search(): + q = request.args.get("q", "") + return jsonify(search_symbols(q)) + + +@app.route("/api/symbols/mains") +@login_required +def api_symbols_mains(): + return jsonify(list_main_contracts_grouped()) + + +@app.route("/api/symbols/recommended") +@login_required +def api_symbols_recommended(): + """品种下拉:仅展示当前资金下可开仓品种(与下方可开仓品种表一致)。""" + from recommend_store import recommend_payload + from trading_context import ( + get_account_capital, + get_fixed_lots, + get_max_margin_pct, + get_sizing_mode, + get_trading_mode, + ) + + conn = get_db() + try: + capital = get_account_capital(conn, get_setting) + payload = recommend_payload( + conn, + live_capital=capital, + max_margin_pct=get_max_margin_pct(get_setting), + trading_mode=get_trading_mode(get_setting), + sizing_mode=get_sizing_mode(get_setting), + fixed_lots=get_fixed_lots(get_setting), + ) + return jsonify(list_recommended_symbols_grouped(payload.get("rows") or [])) + finally: + conn.close() + + +@app.route("/api/key_prices") +@login_required +def api_key_prices(): + """关键位监控列表:批量现价与距上/下沿距离。""" + conn = get_db() + rows = conn.execute( + "SELECT id, symbol, market_code, sina_code, upper, lower " + "FROM key_monitors WHERE status='active' OR status IS NULL" + ).fetchall() + conn.close() + out = [] + for r in rows: + sym = r["symbol"] + market = r["market_code"] or "" + sina = r["sina_code"] or "" + upper = float(r["upper"]) + lower = float(r["lower"]) + price = fetch_price(sym, market, sina) + dist_upper = None + dist_lower = None + if price is not None: + dist_upper = round(upper - price, 2) + dist_lower = round(price - lower, 2) + out.append({ + "id": r["id"], + "price": price, + "dist_upper": dist_upper, + "dist_lower": dist_lower, + }) + return jsonify(out) + + +@app.route("/api/plan_prices") +@login_required +def api_plan_prices(): + """今日计划:批量现价与距决策区间上/下沿距离。""" + today = today_str() + conn = get_db() + rows = conn.execute( + "SELECT id, symbol, market_code, sina_code, zone_upper, zone_lower " + "FROM order_plans WHERE plan_date=? AND status IN ('planned', 'active')", + (today,), + ).fetchall() + conn.close() + out = [] + for r in rows: + sym = r["symbol"] + market = r["market_code"] or "" + sina = r["sina_code"] or "" + upper = float(r["zone_upper"]) + lower = float(r["zone_lower"]) + price = fetch_price(sym, market, sina) + dist_upper = None + dist_lower = None + in_zone = False + if price is not None: + dist_upper = round(upper - price, 2) + dist_lower = round(price - lower, 2) + in_zone = lower <= price <= upper + out.append({ + "id": r["id"], + "price": price, + "dist_upper": dist_upper, + "dist_lower": dist_lower, + "in_zone": in_zone, + }) + return jsonify(out) + + +@app.route("/api/position_live") +@login_required +def api_position_live(): + capital = float(get_setting("live_capital", "0") or 0) + now_iso = datetime.now(TZ).strftime("%Y-%m-%dT%H:%M") + conn = get_db() + rows = conn.execute( + "SELECT * FROM position_monitors WHERE status='active' ORDER BY id DESC" + ).fetchall() + conn.close() + out = [] + for r in rows: + sym = r["symbol"] + market = r["market_code"] or "" + sina = r["sina_code"] or "" + direction = r["direction"] + entry = float(r["entry_price"]) + sl = float(r["stop_loss"]) + tp = float(r["take_profit"]) + lots = float(r["lots"] or 1) + mark = fetch_price(sym, market, sina) + metrics = calc_position_metrics( + direction, entry, sl, tp, lots, mark, capital, sym, + ) + holding = calc_holding_duration(r["open_time"] or "", now_iso) + close_est = mark if mark is not None else entry + fee_info = calc_fee_breakdown( + sym, entry, close_est, lots, r["open_time"] or "", now_iso, + trading_mode=_trading_mode(), + ) + est_net = None + if metrics.get("float_pnl") is not None: + est_net = round(metrics["float_pnl"] - fee_info["total_fee"], 2) + out.append({ + "id": r["id"], + "symbol": r["symbol_name"] or sym, + "symbol_code": sym, + "direction": "做多" if direction == "long" else "做空", + "lots": lots, + "entry_price": entry, + "stop_loss": sl, + "take_profit": tp, + "open_time": r["open_time"], + "mark_price": mark, + "holding_duration": holding, + "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, + **metrics, + }) + return jsonify(out) + + +@app.route("/plans") +@login_required +@require_nav("plans") +def plans(): + today = today_str() + start = request.args.get("start", "") + end = request.args.get("end", "") + + conn = get_db() + plan_list = conn.execute( + "SELECT * FROM order_plans WHERE plan_date=? AND status IN ('planned', 'active') ORDER BY id DESC", + (today,), + ).fetchall() + + sql = "SELECT * FROM order_plans WHERE plan_date < ? OR status IN ('closed', 'expired')" + params: list = [today] + if start: + sql += " AND plan_date >= ?" + params.append(start) + if end: + sql += " AND plan_date <= ?" + params.append(end) + sql += " ORDER BY plan_date DESC, id DESC LIMIT 200" + history = conn.execute(sql, params).fetchall() + conn.close() + return render_template( + "plans.html", + plans=plan_list, + history=history, + today=today, + start=start, + end=end, + ) + + +@app.route("/add_plan", methods=["POST"]) +@login_required +def add_plan(): + d = request.form + direction = d.get("direction") + 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 direction: + flash("请选择多空方向") + return redirect(url_for("plans")) + if not symbol or not market_code: + flash("请从下拉列表选择品种(同花顺合约代码)") + return redirect(url_for("plans")) + conn = get_db() + conn.execute( + """INSERT INTO order_plans + (symbol, symbol_name, market_code, sina_code, direction, + zone_upper, zone_lower, stop_loss, take_profit, plan_date, decision_reason) + VALUES (?,?,?,?,?,?,?,?,?,?,?)""", + ( + symbol, symbol_name, market_code, sina_code, direction, + float(d["zone_upper"]), float(d["zone_lower"]), + float(d["stop_loss"]), float(d["take_profit"]), + today_str(), + d.get("decision_reason", "").strip(), + ), + ) + conn.commit() + conn.close() + flash("开单计划已添加") + return redirect(url_for("plans")) + + +@app.route("/del_plan/") +@login_required +def del_plan(pid): + conn = get_db() + conn.execute("DELETE FROM order_plans WHERE id=?", (pid,)) + conn.commit() + conn.close() + flash("已删除") + return redirect(url_for("plans")) + + +@app.route("/keys") +@login_required +def keys(): + conn = get_db() + key_list = conn.execute( + "SELECT * FROM key_monitors WHERE status='active' OR status IS NULL ORDER BY id DESC" + ).fetchall() + history = conn.execute( + "SELECT * FROM key_monitors WHERE status='archived' ORDER BY archived_at DESC LIMIT 100" + ).fetchall() + conn.close() + return render_template("keys.html", keys=key_list, history=history) + + + +@app.route("/add_key", methods=["POST"]) +@login_required +def add_key(): + d = request.form + direction = d.get("direction") + 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 direction: + flash("请选择多空方向") + return redirect(url_for("keys")) + if not symbol or not market_code: + flash("请从下拉列表选择品种(同花顺合约代码)") + return redirect(url_for("keys")) + conn = get_db() + conn.execute( + """INSERT INTO key_monitors + (symbol, symbol_name, market_code, sina_code, monitor_type, direction, upper, lower) + VALUES (?,?,?,?,?,?,?,?)""", + (symbol, symbol_name, market_code, sina_code, d["type"], direction, float(d["upper"]), float(d["lower"])), + ) + conn.commit() + conn.close() + flash("关键位监控已添加") + return redirect(url_for("keys")) + + +@app.route("/add_position", methods=["POST"]) +@login_required +def add_position(): + flash("持仓由策略交易或 CTP 自动同步,无需手工录入") + return redirect(url_for("positions")) + + +@app.route("/del_position/") +@login_required +def del_position(pid): + return close_position(pid) + + +@app.route("/close_position/", methods=["POST"]) +@login_required +def close_position(pid): + conn = get_db() + row = conn.execute("SELECT * FROM position_monitors WHERE id=?", (pid,)).fetchone() + if not row: + conn.close() + flash("持仓不存在") + return redirect(url_for("positions")) + sym = row["symbol"] + market = row["market_code"] or "" + sina = row["sina_code"] or "" + direction = row["direction"] + entry = float(row["entry_price"]) + sl = float(row["stop_loss"]) + tp = float(row["take_profit"]) + lots = float(row["lots"] or 1) + open_time = row["open_time"] or "" + close_time = datetime.now(TZ).strftime("%Y-%m-%dT%H:%M") + close_price = fetch_price(sym, market, sina) + if close_price is None: + conn.close() + flash("无法获取现价,平仓失败") + return redirect(url_for("positions")) + capital = float(get_setting("live_capital", "0") or 0) + metrics = calc_position_metrics(direction, entry, sl, tp, lots, close_price, capital, sym) + pnl = metrics.get("float_pnl") or 0.0 + fee = calc_round_trip_fee(sym, entry, close_price, lots, open_time, close_time, trading_mode=_trading_mode()) + pnl_net = round(pnl - fee, 2) + result = classify_close_result(direction, close_price, sl, tp) + minutes = holding_to_minutes(open_time, close_time) + margin_pct = metrics.get("position_pct") + from trade_log_lib import calc_equity_after + equity_after = calc_equity_after(capital, pnl_net) + conn.execute( + """INSERT INTO trade_logs + (symbol, symbol_name, market_code, sina_code, monitor_type, direction, + entry_price, stop_loss, take_profit, close_price, lots, margin, + margin_pct, holding_minutes, open_time, close_time, pnl, fee, pnl_net, + equity_after, result) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + sym, row["symbol_name"], market, sina, "持仓监控", direction, + entry, sl, tp, close_price, lots, metrics["margin"], + margin_pct, + minutes, open_time, close_time, pnl, fee, pnl_net, equity_after, result, + ), + ) + conn.execute("DELETE FROM position_monitors WHERE id=?", (pid,)) + conn.commit() + conn.close() + touch_stats_cache() + flash(f"已平仓,盈亏 {pnl:.2f} 元(扣费后 {pnl_net:.2f} 元),已记入交易记录") + return redirect(url_for("positions")) + + +@app.route("/trades") +@login_required +def trades(): + return redirect(url_for("records")) + + +@app.route("/update_trade/", methods=["POST"]) +@login_required +def update_trade(tid): + d = request.form + conn = get_db() + conn.execute( + """UPDATE trade_logs SET + symbol_name=?, monitor_type=?, direction=?, + entry_price=?, stop_loss=?, take_profit=?, close_price=?, + lots=?, margin=?, holding_minutes=?, open_time=?, close_time=?, + pnl=?, result=?, verified=1 + WHERE id=?""", + ( + d.get("symbol_name", "").strip(), + d.get("monitor_type", "").strip(), + d.get("direction", "").strip(), + float(d.get("entry_price") or 0), + float(d.get("stop_loss") or 0), + float(d.get("take_profit") or 0), + float(d.get("close_price") or 0), + float(d.get("lots") or 0), + float(d.get("margin") or 0), + int(d.get("holding_minutes") or 0), + d.get("open_time", "").strip(), + d.get("close_time", "").strip(), + float(d.get("pnl") or 0), + d.get("result", "").strip(), + tid, + ), + ) + conn.commit() + conn.close() + touch_stats_cache() + flash("交易记录已核对保存") + return redirect(url_for("records")) + + +@app.route("/del_trade/") +@login_required +def del_trade(tid): + conn = get_db() + conn.execute("DELETE FROM trade_logs WHERE id=?", (tid,)) + conn.commit() + conn.close() + touch_stats_cache() + flash("已删除") + return redirect(url_for("records")) + + +@app.route("/fill_review/") +@login_required +def fill_review_from_trade(tid): + conn = get_db() + row = conn.execute("SELECT * FROM trade_logs WHERE id=?", (tid,)).fetchone() + conn.close() + if not row: + flash("记录不存在") + return redirect(url_for("records")) + q = { + "symbol": row["symbol"], + "symbol_name": row["symbol_name"] or row["symbol"], + "market_code": row["market_code"] or "", + "sina_code": row["sina_code"] or "", + "direction": row["direction"], + "entry_price": row["entry_price"], + "stop_loss": row["stop_loss"], + "take_profit": row["take_profit"], + "close_price": row["close_price"], + "lots": row["lots"], + "open_time": row["open_time"], + "close_time": row["close_time"], + "pnl": row["pnl"], + } + params = {k: v for k, v in q.items() if v is not None} + return redirect(url_for("records", **params) + "#review-panel") + + +@app.route("/del_key/") +@login_required +def del_key(pid): + conn = get_db() + conn.execute( + "UPDATE key_monitors SET status='archived', archived_at=? WHERE id=?", + (datetime.now(TZ).isoformat(), pid), + ) + conn.commit() + conn.close() + flash("已移入监控历史") + return redirect(url_for("keys")) + + +@app.route("/records") +@login_required +def records(): + preset = request.args.get("preset", "") + start = request.args.get("start", "") + end = request.args.get("end", "") + if preset: + start, end = parse_review_date_filter(preset, start, end) + + conn = get_db() + ctp_sync_info = None + try: + from ctp_trade_sync import sync_trade_logs_from_ctp + from trading_context import get_account_capital, get_trading_mode + from vnpy_bridge import ctp_status + + mode = get_trading_mode(get_setting) + if ctp_status(mode).get("connected"): + capital = get_account_capital(conn, get_setting) + ctp_sync_info = sync_trade_logs_from_ctp( + conn, mode, capital=capital, trading_mode=mode, + ) + conn.commit() + except Exception as exc: + app.logger.warning("ctp trade sync on records page: %s", exc) + + sql = "SELECT * FROM review_records WHERE 1=1" + params: list = [] + if start: + sql += " AND date(close_time) >= ?" + params.append(start) + if end: + sql += " AND date(close_time) <= ?" + params.append(end) + sql += " ORDER BY id DESC LIMIT 200" + review_list = conn.execute(sql, params).fetchall() + + auto_list = conn.execute( + "SELECT * FROM trade_records ORDER BY id DESC LIMIT 30" + ).fetchall() + trade_list = conn.execute( + "SELECT * FROM trade_logs ORDER BY id DESC LIMIT 500" + ).fetchall() + from trade_log_lib import enrich_trades_for_records + try: + initial_capital = float(get_setting("live_capital", "0") or 0) + except (TypeError, ValueError): + initial_capital = 0.0 + trades, equity_curve = enrich_trades_for_records( + [dict(r) for r in trade_list], + initial_capital=initial_capital, + ) + conn.close() + + trade_prefill_keys = ( + "symbol", "symbol_name", "market_code", "sina_code", "direction", + "entry_price", "stop_loss", "take_profit", "close_price", + "lots", "open_time", "close_time", "pnl", + ) + prefill = {k: request.args.get(k) for k in trade_prefill_keys if request.args.get(k)} + + return render_template( + "records.html", + reviews=review_list, + trades=trades, + equity_curve=equity_curve, + auto_records=auto_list, + ctp_sync_info=ctp_sync_info, + preset=preset, + start=start, + end=end, + prefill=prefill, + open_types=OPEN_TYPES, + exit_triggers=EXIT_TRIGGERS, + behavior_tags=BEHAVIOR_TAGS, + kline_periods=KLINE_PERIODS, + kline_cutoffs=KLINE_CUTOFFS, + ) + + +@app.route("/add_review", methods=["POST"]) +@login_required +def add_review(): + d = request.form + open_type = d.get("open_type", "").strip() + exit_trigger = d.get("exit_trigger", "").strip() + if not open_type: + flash("请选择开仓类型") + return redirect(url_for("records")) + if not exit_trigger: + flash("请选择离场触发") + return redirect(url_for("records")) + + 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("records")) + + screenshot = "" + f = request.files.get("screenshot") + if f and f.filename: + fname = secure_filename(f.filename) + ts = datetime.now(TZ).strftime("%Y%m%d%H%M%S") + screenshot = f"{ts}_{fname}" + f.save(os.path.join(UPLOAD_DIR, screenshot)) + + tags = [t for t in BEHAVIOR_TAGS if d.get(f"tag_{t}")] + is_emotion = 1 if tags else 0 + + def num(key: str) -> Optional[float]: + v = d.get(key, "").strip() + if not v: + return None + return float(v) + + open_time = d.get("open_time", "").strip() + close_time = d.get("close_time", "").strip() + direction = d.get("direction", "").strip() + entry_price = num("entry_price") + stop_loss = num("stop_loss") + take_profit = num("take_profit") + close_price = num("close_price") + lots = num("lots") or 1.0 + + holding = calc_holding_duration(open_time, close_time) + initial_pnl = calc_rr_ratio(direction, entry_price, stop_loss, take_profit) + actual_pnl = calc_rr_ratio(direction, entry_price, stop_loss, close_price) + + gross_pnl = num("pnl") + if gross_pnl is None and entry_price and close_price: + spec_mult = calc_position_metrics( + direction, entry_price, stop_loss, take_profit, + lots, close_price, 0, symbol, + ) + gross_pnl = spec_mult.get("float_pnl") + fee = calc_round_trip_fee( + symbol, entry_price or 0, close_price or 0, lots, open_time, close_time, + trading_mode=_trading_mode(), + ) + pnl_net = round((gross_pnl or 0) - fee, 2) if gross_pnl is not None else None + + auto_kline = bool(d.get("auto_kline")) + if auto_kline and not screenshot: + try: + generated = generate_review_kline_chart( + symbol=symbol, + periods=[d.get("kline_period1", "15m"), d.get("kline_period2", "1h")], + count=int(d.get("kline_count") or 300), + cutoff_label=d.get("kline_cutoff", "平仓时间"), + open_time=open_time, + close_time=close_time, + entry_price=entry_price, + stop_loss=stop_loss, + take_profit=take_profit, + close_price=close_price, + upload_dir=UPLOAD_DIR, + ) + if generated: + screenshot = generated + except Exception as exc: + app.logger.warning("auto kline failed: %s", exc) + + conn = get_db() + conn.execute( + """INSERT INTO review_records + (open_time, close_time, symbol, symbol_name, market_code, sina_code, + timeframe, direction, + entry_price, stop_loss, take_profit, close_price, lots, + holding_duration, initial_pnl, actual_pnl, pnl, fee, pnl_net, + open_type, expected_rr, actual_rr, exit_trigger, exit_supplement, + watch_after_breakeven, new_position_while_occupied, screenshot, + auto_kline, kline_period1, kline_period2, kline_count, kline_cutoff, + behavior_tags, is_emotion, notes) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + open_time, close_time, + symbol, symbol_name, market_code, sina_code, + d.get("timeframe", "").strip(), + direction, + entry_price, stop_loss, take_profit, close_price, lots, + holding, initial_pnl, actual_pnl, gross_pnl, fee, pnl_net, + open_type, + None, + None, + exit_trigger, + d.get("exit_supplement", "").strip(), + d.get("watch_after_breakeven", "否"), + d.get("new_position_while_occupied", "否"), + screenshot, + 1 if auto_kline else 0, + d.get("kline_period1", "15m"), + d.get("kline_period2", "1h"), + int(d.get("kline_count") or 300), + d.get("kline_cutoff", "平仓时间"), + ",".join(tags), + is_emotion, + d.get("notes", "").strip(), + ), + ) + hook = getattr(app, "_risk_review_hook", None) + if hook: + hook( + conn, + ",".join(tags), + exit_trigger, + d.get("exit_supplement", "").strip(), + ) + conn.commit() + conn.close() + touch_stats_cache() + flash("复盘记录已保存") + return redirect(url_for("records")) + + +@app.route("/del_review/") +@login_required +def del_review(rid): + conn = get_db() + row = conn.execute("SELECT screenshot FROM review_records WHERE id=?", (rid,)).fetchone() + if row and row["screenshot"]: + path = os.path.join(UPLOAD_DIR, row["screenshot"]) + if os.path.isfile(path): + os.remove(path) + conn.execute("DELETE FROM review_records WHERE id=?", (rid,)) + conn.commit() + conn.close() + touch_stats_cache() + flash("已删除") + return redirect(url_for("records")) + + +@app.route("/uploads/") +@login_required +def uploaded_file(filename): + from flask import send_from_directory + return send_from_directory(UPLOAD_DIR, filename) + + +@app.route("/del_record/") +@login_required +def del_record(rid): + conn = get_db() + conn.execute("DELETE FROM trade_records WHERE id=?", (rid,)) + conn.commit() + conn.close() + flash("已删除") + return redirect(url_for("records")) + + +@app.route("/stats") +@login_required +def stats(): + return render_template("stats.html") + + +@app.route("/api/stats") +@login_required +def api_stats(): + return jsonify(get_stats_data()) + + +@app.route("/api/stats/views") +@login_required +def api_stats_views(): + return jsonify({"views": STATS_VIEWS}) + + +@app.route("/api/stats/refresh", methods=["POST"]) +@login_required +def api_stats_refresh(): + conn = get_db() + capital = float(get_setting("live_capital", "0") or 0) + data = refresh_stats_cache(conn, capital) + conn.close() + return jsonify(data) + + +@app.route("/market") +@login_required +@require_nav("market") +def market_page(): + symbol = request.args.get("symbol", "").strip() + period = request.args.get("period", "15m").strip() + valid = {p["key"] for p in MARKET_PERIODS} + if period not in valid: + period = "15m" + ctp_st = {} + try: + from vnpy_bridge import ctp_status + from trading_context import get_trading_mode + + ctp_st = ctp_status(get_trading_mode(get_setting)) + except Exception: + pass + return render_template( + "market.html", + symbol=symbol, + period=period, + market_periods=MARKET_PERIODS, + quote_label=get_quote_source_label(ctp_connected=bool(ctp_st.get("connected"))), + ctp_connected=bool(ctp_st.get("connected")), + ) + + +@app.route("/api/kline") +@login_required +def api_kline(): + symbol = request.args.get("symbol", "").strip() + period = request.args.get("period", "15m").strip() + if not symbol: + return jsonify({"error": "请提供合约代码"}), 400 + try: + from trading_context import get_trading_mode + + data = fetch_market_klines( + symbol, period, DB_PATH, trading_mode=get_trading_mode(get_setting), + ) + except Exception as exc: + app.logger.warning("kline api failed: %s", exc) + return jsonify({"error": str(exc)}), 500 + if not data.get("chart_symbol"): + return jsonify({"error": "无法识别合约代码"}), 400 + if not data.get("bars"): + return jsonify({"error": "未获取到K线数据,请稍后重试或更换合约"}), 404 + return jsonify(data) + + +@app.route("/api/kline/stream") +@login_required +def api_kline_stream(): + from queue import Empty + + symbol = request.args.get("symbol", "").strip() + period = request.args.get("period", "15m").strip() + market_code = request.args.get("market_code", "").strip() + sina_code = request.args.get("sina_code", "").strip() + if not symbol: + return jsonify({"error": "请提供合约代码"}), 400 + + def generate(): + from trading_context import get_trading_mode + + mode = get_trading_mode(get_setting) + sub = kline_hub.subscribe(symbol, period, market_code, sina_code) + try: + kline_data = fetch_market_klines( + symbol, period, DB_PATH, trading_mode=mode, + ) + if kline_data.get("bars"): + yield sse_format("kline", kline_data) + yield sse_format( + "quote", + build_market_quote_payload(symbol, market_code, sina_code), + ) + while True: + try: + msg = sub.queue.get(timeout=20) + yield sse_format(msg["event"], msg["data"]) + except Empty: + yield ": heartbeat\n\n" + finally: + kline_hub.unsubscribe(sub) + + return Response( + stream_with_context(generate()), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@app.route("/api/market_quote") +@login_required +def api_market_quote(): + symbol = request.args.get("symbol", "").strip() + market_code = request.args.get("market_code", "").strip() + sina_code = request.args.get("sina_code", "").strip() + if not symbol and not market_code: + return jsonify({"error": "请提供合约"}), 400 + return jsonify(build_market_quote_payload(symbol, market_code, sina_code)) + + +@app.route("/contract") +@login_required +@require_nav("contract") +def contract_profile_page(): + symbol = request.args.get("symbol", "").strip() + profile = None + error = None + if symbol: + try: + profile = get_contract_profile(symbol) + if not profile: + error = "未查询到该合约简介,请检查合约代码" + except Exception as exc: + app.logger.warning("contract profile failed: %s", exc) + error = f"查询失败:{exc}" + return render_template( + "contract.html", + symbol=symbol, + profile=profile, + error=error, + ) + + +@app.route("/api/contract_profile") +@login_required +def api_contract_profile(): + symbol = request.args.get("symbol", "").strip() + if not symbol: + return jsonify({"error": "请提供合约代码"}), 400 + try: + profile = get_contract_profile(symbol) + except Exception as exc: + return jsonify({"error": str(exc)}), 500 + if not profile: + return jsonify({"error": "未查询到合约简介"}), 404 + return jsonify(profile) + + +@app.route("/fees", methods=["GET", "POST"]) +@login_required +@require_nav("fees") +def fees(): + from trading_context import get_trading_mode + from ctp_fee_worker import ( + schedule_ctp_fee_sync, + get_fee_last_sync, + fees_synced_today, + fee_sync_in_progress, + ) + from vnpy_bridge import ctp_status + + mode = get_trading_mode(get_setting) + if request.method == "POST": + action = request.form.get("action") + if action == "sync_ctp": + force = request.form.get("force") == "1" + _, msg = schedule_ctp_fee_sync( + mode, + get_setting=get_setting, + set_setting=set_setting, + force=force, + ) + flash(msg) + return redirect(url_for("fees")) + + rates = list_fee_rates_for_ui() + fee_counts = count_fee_rates_by_source() + ctp_st = ctp_status(mode) + return render_template( + "fees.html", + rates=rates, + fee_counts=fee_counts, + fee_last_sync=get_fee_last_sync(get_setting), + fee_synced_today=fees_synced_today(get_setting), + fee_sync_running=fee_sync_in_progress(), + ctp_connected=bool(ctp_st.get("connected")), + ) + + +@app.route("/settings", methods=["GET", "POST"]) +@login_required +def settings(): + if request.method == "POST": + action = request.form.get("action") + if action == "wechat": + webhook = request.form.get("wechat_webhook", "").strip() + set_setting("wechat_webhook", webhook) + flash("企业微信配置已保存") + elif action == "trading": + mode = request.form.get("trading_mode", "simulation").strip() + if mode not in ("simulation", "live"): + mode = "simulation" + sizing = request.form.get("position_sizing_mode", "fixed").strip() + if sizing == "risk": + sizing = "amount" + if sizing not in ("fixed", "amount"): + sizing = "fixed" + set_setting("trading_mode", mode) + set_setting("position_sizing_mode", sizing) + try: + fl = int(float(request.form.get("fixed_lots", "1") or 1)) + set_setting("fixed_lots", str(max(1, fl))) + except ValueError: + flash("固定手数无效") + return redirect(url_for("settings")) + try: + fa = float(request.form.get("fixed_amount", "5000") or 5000) + set_setting("fixed_amount", str(max(1.0, fa))) + except ValueError: + flash("固定金额无效") + return redirect(url_for("settings")) + try: + rp = float(request.form.get("risk_percent", "1") or 1) + set_setting("risk_percent", str(max(0.1, min(100.0, rp)))) + except ValueError: + pass + try: + mp = float(request.form.get("max_margin_pct", "30") or 30) + set_setting("max_margin_pct", str(max(1.0, min(100.0, mp)))) + except ValueError: + flash("保证金比例无效") + return redirect(url_for("settings")) + try: + tb = int(float(request.form.get("trailing_be_tick_buffer", "2") or 2)) + set_setting("trailing_be_tick_buffer", str(max(1, min(20, tb)))) + except ValueError: + flash("移动保本缓冲无效") + return redirect(url_for("settings")) + try: + pt = int(float(request.form.get("pending_order_timeout_min", "5") or 5)) + set_setting("pending_order_timeout_min", str(max(1, min(60, pt)))) + except ValueError: + flash("挂单超时无效") + return redirect(url_for("settings")) + flash("交易模式已保存") + elif action == "ctp": + from ctp_settings import save_ctp_settings_from_form + + save_result = save_ctp_settings_from_form(request.form, set_setting) + pwd_updated = save_result.get("passwords_updated") or [] + pwd_empty = save_result.get("passwords_submitted_empty") or [] + simnow_pwd_len = len((request.form.get("simnow_password") or "").strip()) + live_pwd_len = len((request.form.get("ctp_live_password") or "").strip()) + print( + f"CTP settings save: simnow_password_len={simnow_pwd_len} " + f"live_password_len={live_pwd_len} updated={pwd_updated}", + flush=True, + ) + app.logger.info( + "CTP settings save: simnow_password_len=%s live_password_len=%s updated=%s", + simnow_pwd_len, + live_pwd_len, + pwd_updated, + ) + if "simnow_password" in pwd_updated: + pwd_note = f"SimNow 交易密码已更新({simnow_pwd_len} 位)" + elif "simnow_password" in pwd_empty: + pwd_note = "SimNow 交易密码未改:提交为空,请在「交易密码」框手打后再保存" + elif "ctp_live_password" in pwd_updated: + pwd_note = "实盘交易密码已更新" + elif "ctp_live_password" in pwd_empty: + pwd_note = "实盘交易密码未改(提交为空)" + else: + pwd_note = "" + flash_msg = "CTP 配置已保存,正在使用新地址重连…" + if pwd_note: + flash_msg = f"CTP 配置已保存;{pwd_note},正在重连…" + try: + from vnpy_bridge import get_bridge + from trading_context import get_trading_mode + + b = get_bridge() + if pwd_updated: + b._clear_login_cooldown() + mode = get_trading_mode(get_setting) + info = b.reconnect_after_settings_saved(mode) + if info.get("cooldown"): + flash_msg = f"CTP 配置已保存;{pwd_note or '请稍后再连'}" + elif not info.get("started") and info.get("connected"): + flash_msg = f"CTP 配置已保存;{pwd_note or '当前连接正常'}" + except Exception as exc: + app.logger.warning("CTP reconnect after settings save: %s", exc) + flash_msg = f"CTP 配置已保存;{pwd_note or '请稍后在持仓监控页重连'}" + flash(flash_msg) + elif action == "nav": + items = {k: request.form.get(f"nav_{k}") == "on" for k in NAV_TOGGLES} + save_nav_items(set_setting, items) + flash("导航显示已保存") + elif action == "password": + old_p = request.form.get("old_password", "") + new_p = request.form.get("new_password", "") + new_p2 = request.form.get("new_password2", "") + admin_hash = get_setting("admin_password_hash") + if not check_password_hash(admin_hash, old_p): + flash("原密码错误") + elif len(new_p) < 6: + flash("新密码至少 6 位") + elif new_p != new_p2: + flash("两次新密码不一致") + else: + set_setting("admin_password_hash", generate_password_hash(new_p)) + flash("密码修改成功") + return redirect(url_for("settings")) + + webhook = get_setting("wechat_webhook") + username = get_setting("admin_username") + ctp_st = {} + try: + from vnpy_bridge import ctp_status + from trading_context import get_trading_mode + + ctp_st = ctp_status(get_trading_mode(get_setting)) + except Exception: + pass + from ctp_settings import get_ctp_settings_for_ui + + return render_template( + "settings.html", + webhook=webhook, + username=username, + quote_label=get_quote_source_label(ctp_connected=bool(ctp_st.get("connected"))), + ctp_status=ctp_st, + ctp_cfg=get_ctp_settings_for_ui(), + trading_mode=get_setting("trading_mode", "simulation"), + position_sizing_mode=get_setting("position_sizing_mode", "fixed"), + fixed_lots=get_setting("fixed_lots", "1"), + fixed_amount=get_setting("fixed_amount", "5000"), + risk_percent=get_setting("risk_percent", "1"), + max_margin_pct=get_setting("max_margin_pct", "30"), + trailing_be_tick_buffer=get_setting("trailing_be_tick_buffer", "2"), + pending_order_timeout_min=get_setting("pending_order_timeout_min", "5"), + nav_items=get_nav_items(get_setting), + nav_toggles=NAV_TOGGLES, + ) + + +install_trading( + app, + login_required=login_required, + require_nav=require_nav, + get_db=get_db, + get_setting=get_setting, + set_setting=set_setting, + fetch_price=fetch_price, + send_wechat_msg=send_wechat_msg, +) + +try_init_vnpy({}) + +start_background_threads() + +# —————————————— 启动 —————————————— + +if __name__ == "__main__": + app.run(host=HOST, port=PORT, debug=DEBUG, threaded=True) diff --git a/contract_profile.py b/contract_profile.py index 5713478..ccdfd3a 100644 --- a/contract_profile.py +++ b/contract_profile.py @@ -1,275 +1,280 @@ -"""期货合约简介:东方财富 / 新浪 / AKShare。""" -import logging -import re -from typing import Any, Optional - -import requests - -from contract_specs import get_contract_spec -from symbols import ths_to_codes, search_symbols - -logger = logging.getLogger(__name__) - -EM_LABEL_MAP = { - "vname": "交易品种", - "vcode": "交易代码", - "jydw": "交易单位", - "bjdw": "报价单位", - "market": "交易所", - "zxbddw": "最小变动价位", - "zdtbfd": "涨跌停幅度", - "hyjgyf": "合约月份", - "jysj": "交易时间", - "zhjyr": "最后交易日", - "zhjgr": "交割日期", - "jgpj": "交割品级", - "zcjybzj": "最低交易保证金", - "jgfs": "交割方式", - "jgdd": "交割地点", - "ssrq": "上市日期", -} - -DISPLAY_ORDER = [ - "交易品种", - "交易代码", - "交易单位", - "报价单位", - "最小变动价位", - "最低交易保证金", - "涨跌停幅度", - "合约月份", - "交易时间", - "最后交易日", - "交割日期", - "交割方式", - "交割地点", - "交割品级", - "上市日期", - "交易所", -] - -SKIP_ITEMS = {"", "-", "None", "nan", "null"} - - -def _normalize_ths_code(raw: str) -> Optional[str]: - code = (raw or "").strip() - if not code: - return None - # 已是完整合约 - if re.match(r"^[A-Za-z]+\d{3,4}$", code): - return code - # 仅品种字母时尝试匹配主力 - results = search_symbols(code) - if results: - return results[0].get("ths_code") or code - codes = ths_to_codes(code) - if codes: - return codes["ths_code"] - return code - - -def _to_sina_quote_symbol(ths_code: str) -> str: - m = re.match(r"^([A-Za-z]+)(\d+)$", ths_code.strip()) - if not m: - return ths_code.upper() - return m.group(1).upper() + m.group(2) - - -def _to_em_page_symbol(ths_code: str) -> str: - return ths_code.strip().lower() + "F" - - -def _clean_value(val: Any) -> str: - if val is None: - return "" - s = str(val).strip() - if s in SKIP_ITEMS: - return "" - return s - - -def _rows_from_dict(data: dict[str, str]) -> list[dict]: - rows: list[dict] = [] - seen: set[str] = set() - for label in DISPLAY_ORDER: - val = _clean_value(data.get(label)) - if not val: - continue - hint = _clean_value(data.get(f"{label}_hint")) - rows.append({"label": label, "value": val, "hint": hint}) - seen.add(label) - for label, val in data.items(): - if label.endswith("_hint") or label in seen: - continue - val = _clean_value(val) - if val: - rows.append({"label": label, "value": val, "hint": ""}) - return rows - - -def _add_computed_hints(ths_code: str, data: dict[str, str]) -> None: - spec = get_contract_spec(ths_code) - mult = spec.get("mult") or 0 - tick_raw = data.get("最小变动价位", "") - m = re.search(r"([\d.]+)", tick_raw) - if m and mult: - tick = float(m.group(1)) - data["最小变动价位_hint"] = f"一手合约最小波动{round(tick * mult, 2)}元" - - -def _fetch_em_direct(em_symbol: str) -> dict[str, str]: - page_url = f"https://quote.eastmoney.com/qihuo/{em_symbol}.html" - r = requests.get(page_url, timeout=12) - r.encoding = r.apparent_encoding or "utf-8" - inner = None - for pat in [ - r"futures_([A-Za-z0-9_]+)", - r"#(futures_[A-Za-z0-9_]+)", - r"/(futures_[A-Za-z0-9_]+)", - ]: - m = re.search(pat, r.text) - if m: - inner = m.group(1).replace("futures_", "") - break - if not inner: - raise ValueError("无法解析东方财富合约标识") - - info_url = f"https://futsse-static.eastmoney.com/redis?msgid={inner}_info" - r2 = requests.get(info_url, timeout=12) - payload = r2.json() - if not isinstance(payload, dict): - raise ValueError("东方财富返回数据无效") - - out: dict[str, str] = {} - for key, label in EM_LABEL_MAP.items(): - val = _clean_value(payload.get(key)) - if val: - out[label] = val - if not out: - raise ValueError("东方财富合约字段为空") - return out - - -def _fetch_em_akshare(em_symbol: str) -> dict[str, str]: - import akshare as ak - - df = ak.futures_contract_detail_em(symbol=em_symbol) - out: dict[str, str] = {} - for _, row in df.iterrows(): - label = _clean_value(row.get("item")) - val = _clean_value(row.get("value")) - if label and val: - if label == "跌涨停板幅度": - label = "涨跌停幅度" - if label == "最后交割日": - label = "交割日期" - if label == "上市交易所": - label = "交易所" - if label == "合约交割月份": - label = "合约月份" - if label == "最初交易保证金": - label = "最低交易保证金" - if label == "最小变动价格": - label = "最小变动价位" - out[label] = val - return out - - -def _fetch_sina_direct(sina_symbol: str) -> dict[str, str]: - from io import StringIO - - import pandas as pd - - url = f"https://finance.sina.com.cn/futures/quotes/{sina_symbol}.shtml" - r = requests.get(url, timeout=12, headers={"Referer": "https://finance.sina.com.cn/"}) - r.encoding = "gb2312" - tables = pd.read_html(StringIO(r.text)) - if len(tables) < 7: - raise ValueError("新浪页面结构变化") - temp_df = tables[6] - parts = [] - for ncol in [slice(0, 2), slice(2, 4), slice(4, None)]: - part = temp_df.iloc[:, ncol] - part.columns = ["item", "value"] - parts.append(part) - merged = pd.concat(parts, axis=0, ignore_index=True) - out: dict[str, str] = {} - for _, row in merged.iterrows(): - label = _clean_value(row["item"]) - val = _clean_value(row["value"]) - if not label or not val or len(label) > 80 or "发帖" in val: - continue - out[label] = val - return out - - -def _fetch_sina_akshare(sina_symbol: str) -> dict[str, str]: - import akshare as ak - - df = ak.futures_contract_detail(symbol=sina_symbol) - out: dict[str, str] = {} - for _, row in df.iterrows(): - label = _clean_value(row.get("item")) - val = _clean_value(row.get("value")) - if label and val and "发帖" not in val: - out[label] = val - return out - - -def _merge_profile(primary: dict[str, str], secondary: dict[str, str]) -> dict[str, str]: - merged = dict(secondary) - merged.update(primary) - return merged - - -def get_contract_profile(raw_symbol: str) -> Optional[dict]: - ths_code = _normalize_ths_code(raw_symbol) - if not ths_code: - return None - - em_symbol = _to_em_page_symbol(ths_code) - sina_symbol = _to_sina_quote_symbol(ths_code) - data: dict[str, str] = {} - source_parts: list[str] = [] - - # 东方财富(字段与看盘软件简介接近) - try: - try: - data = _fetch_em_akshare(em_symbol) - source_parts.append("东方财富") - except ImportError: - data = _fetch_em_direct(em_symbol) - source_parts.append("东方财富") - except Exception as exc: - logger.warning("eastmoney profile failed %s: %s", em_symbol, exc) - - # 新浪补充交割地点、上市日期等 - sina_data: dict[str, str] = {} - try: - try: - sina_data = _fetch_sina_akshare(sina_symbol) - except ImportError: - sina_data = _fetch_sina_direct(sina_symbol) - if sina_data: - source_parts.append("新浪") - except Exception as exc: - logger.warning("sina profile failed %s: %s", sina_symbol, exc) - - if sina_data: - data = _merge_profile(data, sina_data) - - if not data: - return None - - _add_computed_hints(ths_code, data) - rows = _rows_from_dict(data) - if not rows: - return None - - return { - "ths_code": ths_code, - "symbol_name": data.get("交易品种", ""), - "exchange": data.get("交易所", ""), - "rows": rows, - "source": " + ".join(source_parts) if source_parts else "未知", - } +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""期货合约简介:东方财富 / 新浪 / AKShare。""" +import logging +import re +from typing import Any, Optional + +import requests + +from contract_specs import get_contract_spec +from symbols import ths_to_codes, search_symbols + +logger = logging.getLogger(__name__) + +EM_LABEL_MAP = { + "vname": "交易品种", + "vcode": "交易代码", + "jydw": "交易单位", + "bjdw": "报价单位", + "market": "交易所", + "zxbddw": "最小变动价位", + "zdtbfd": "涨跌停幅度", + "hyjgyf": "合约月份", + "jysj": "交易时间", + "zhjyr": "最后交易日", + "zhjgr": "交割日期", + "jgpj": "交割品级", + "zcjybzj": "最低交易保证金", + "jgfs": "交割方式", + "jgdd": "交割地点", + "ssrq": "上市日期", +} + +DISPLAY_ORDER = [ + "交易品种", + "交易代码", + "交易单位", + "报价单位", + "最小变动价位", + "最低交易保证金", + "涨跌停幅度", + "合约月份", + "交易时间", + "最后交易日", + "交割日期", + "交割方式", + "交割地点", + "交割品级", + "上市日期", + "交易所", +] + +SKIP_ITEMS = {"", "-", "None", "nan", "null"} + + +def _normalize_ths_code(raw: str) -> Optional[str]: + code = (raw or "").strip() + if not code: + return None + # 已是完整合约 + if re.match(r"^[A-Za-z]+\d{3,4}$", code): + return code + # 仅品种字母时尝试匹配主力 + results = search_symbols(code) + if results: + return results[0].get("ths_code") or code + codes = ths_to_codes(code) + if codes: + return codes["ths_code"] + return code + + +def _to_sina_quote_symbol(ths_code: str) -> str: + m = re.match(r"^([A-Za-z]+)(\d+)$", ths_code.strip()) + if not m: + return ths_code.upper() + return m.group(1).upper() + m.group(2) + + +def _to_em_page_symbol(ths_code: str) -> str: + return ths_code.strip().lower() + "F" + + +def _clean_value(val: Any) -> str: + if val is None: + return "" + s = str(val).strip() + if s in SKIP_ITEMS: + return "" + return s + + +def _rows_from_dict(data: dict[str, str]) -> list[dict]: + rows: list[dict] = [] + seen: set[str] = set() + for label in DISPLAY_ORDER: + val = _clean_value(data.get(label)) + if not val: + continue + hint = _clean_value(data.get(f"{label}_hint")) + rows.append({"label": label, "value": val, "hint": hint}) + seen.add(label) + for label, val in data.items(): + if label.endswith("_hint") or label in seen: + continue + val = _clean_value(val) + if val: + rows.append({"label": label, "value": val, "hint": ""}) + return rows + + +def _add_computed_hints(ths_code: str, data: dict[str, str]) -> None: + spec = get_contract_spec(ths_code) + mult = spec.get("mult") or 0 + tick_raw = data.get("最小变动价位", "") + m = re.search(r"([\d.]+)", tick_raw) + if m and mult: + tick = float(m.group(1)) + data["最小变动价位_hint"] = f"一手合约最小波动{round(tick * mult, 2)}元" + + +def _fetch_em_direct(em_symbol: str) -> dict[str, str]: + page_url = f"https://quote.eastmoney.com/qihuo/{em_symbol}.html" + r = requests.get(page_url, timeout=12) + r.encoding = r.apparent_encoding or "utf-8" + inner = None + for pat in [ + r"futures_([A-Za-z0-9_]+)", + r"#(futures_[A-Za-z0-9_]+)", + r"/(futures_[A-Za-z0-9_]+)", + ]: + m = re.search(pat, r.text) + if m: + inner = m.group(1).replace("futures_", "") + break + if not inner: + raise ValueError("无法解析东方财富合约标识") + + info_url = f"https://futsse-static.eastmoney.com/redis?msgid={inner}_info" + r2 = requests.get(info_url, timeout=12) + payload = r2.json() + if not isinstance(payload, dict): + raise ValueError("东方财富返回数据无效") + + out: dict[str, str] = {} + for key, label in EM_LABEL_MAP.items(): + val = _clean_value(payload.get(key)) + if val: + out[label] = val + if not out: + raise ValueError("东方财富合约字段为空") + return out + + +def _fetch_em_akshare(em_symbol: str) -> dict[str, str]: + import akshare as ak + + df = ak.futures_contract_detail_em(symbol=em_symbol) + out: dict[str, str] = {} + for _, row in df.iterrows(): + label = _clean_value(row.get("item")) + val = _clean_value(row.get("value")) + if label and val: + if label == "跌涨停板幅度": + label = "涨跌停幅度" + if label == "最后交割日": + label = "交割日期" + if label == "上市交易所": + label = "交易所" + if label == "合约交割月份": + label = "合约月份" + if label == "最初交易保证金": + label = "最低交易保证金" + if label == "最小变动价格": + label = "最小变动价位" + out[label] = val + return out + + +def _fetch_sina_direct(sina_symbol: str) -> dict[str, str]: + from io import StringIO + + import pandas as pd + + url = f"https://finance.sina.com.cn/futures/quotes/{sina_symbol}.shtml" + r = requests.get(url, timeout=12, headers={"Referer": "https://finance.sina.com.cn/"}) + r.encoding = "gb2312" + tables = pd.read_html(StringIO(r.text)) + if len(tables) < 7: + raise ValueError("新浪页面结构变化") + temp_df = tables[6] + parts = [] + for ncol in [slice(0, 2), slice(2, 4), slice(4, None)]: + part = temp_df.iloc[:, ncol] + part.columns = ["item", "value"] + parts.append(part) + merged = pd.concat(parts, axis=0, ignore_index=True) + out: dict[str, str] = {} + for _, row in merged.iterrows(): + label = _clean_value(row["item"]) + val = _clean_value(row["value"]) + if not label or not val or len(label) > 80 or "发帖" in val: + continue + out[label] = val + return out + + +def _fetch_sina_akshare(sina_symbol: str) -> dict[str, str]: + import akshare as ak + + df = ak.futures_contract_detail(symbol=sina_symbol) + out: dict[str, str] = {} + for _, row in df.iterrows(): + label = _clean_value(row.get("item")) + val = _clean_value(row.get("value")) + if label and val and "发帖" not in val: + out[label] = val + return out + + +def _merge_profile(primary: dict[str, str], secondary: dict[str, str]) -> dict[str, str]: + merged = dict(secondary) + merged.update(primary) + return merged + + +def get_contract_profile(raw_symbol: str) -> Optional[dict]: + ths_code = _normalize_ths_code(raw_symbol) + if not ths_code: + return None + + em_symbol = _to_em_page_symbol(ths_code) + sina_symbol = _to_sina_quote_symbol(ths_code) + data: dict[str, str] = {} + source_parts: list[str] = [] + + # 东方财富(字段与看盘软件简介接近) + try: + try: + data = _fetch_em_akshare(em_symbol) + source_parts.append("东方财富") + except ImportError: + data = _fetch_em_direct(em_symbol) + source_parts.append("东方财富") + except Exception as exc: + logger.warning("eastmoney profile failed %s: %s", em_symbol, exc) + + # 新浪补充交割地点、上市日期等 + sina_data: dict[str, str] = {} + try: + try: + sina_data = _fetch_sina_akshare(sina_symbol) + except ImportError: + sina_data = _fetch_sina_direct(sina_symbol) + if sina_data: + source_parts.append("新浪") + except Exception as exc: + logger.warning("sina profile failed %s: %s", sina_symbol, exc) + + if sina_data: + data = _merge_profile(data, sina_data) + + if not data: + return None + + _add_computed_hints(ths_code, data) + rows = _rows_from_dict(data) + if not rows: + return None + + return { + "ths_code": ths_code, + "symbol_name": data.get("交易品种", ""), + "exchange": data.get("交易所", ""), + "rows": rows, + "source": " + ".join(source_parts) if source_parts else "未知", + } diff --git a/contract_specs.py b/contract_specs.py index c0fed12..e8e7892 100644 --- a/contract_specs.py +++ b/contract_specs.py @@ -1,122 +1,127 @@ -"""国内期货合约乘数与参考保证金比例(用于估算保证金与风险)。""" -import re -from typing import Optional - -DEFAULT_SPEC = {"mult": 10, "margin_rate": 0.10, "tick_size": 1.0} - -# 参考交易所常见规格(乘数 + 保证金比例 + 最小变动价位) -_SPEC_BY_THS: dict[str, dict] = { - "ag": {"mult": 15, "margin_rate": 0.14, "tick_size": 1.0}, - "au": {"mult": 1000, "margin_rate": 0.10, "tick_size": 0.02}, - "cu": {"mult": 5, "margin_rate": 0.10, "tick_size": 10.0}, - "al": {"mult": 5, "margin_rate": 0.10}, - "zn": {"mult": 5, "margin_rate": 0.10}, - "pb": {"mult": 5, "margin_rate": 0.10}, - "ni": {"mult": 1, "margin_rate": 0.12}, - "sn": {"mult": 1, "margin_rate": 0.12}, - "rb": {"mult": 10, "margin_rate": 0.09}, - "hc": {"mult": 10, "margin_rate": 0.09}, - "ss": {"mult": 5, "margin_rate": 0.11}, - "sc": {"mult": 1000, "margin_rate": 0.11}, - "fu": {"mult": 10, "margin_rate": 0.11}, - "bu": {"mult": 10, "margin_rate": 0.11}, - "ru": {"mult": 10, "margin_rate": 0.11}, - "sp": {"mult": 10, "margin_rate": 0.10}, - "i": {"mult": 100, "margin_rate": 0.11}, - "j": {"mult": 100, "margin_rate": 0.12}, - "jm": {"mult": 60, "margin_rate": 0.12}, - "m": {"mult": 10, "margin_rate": 0.08}, - "y": {"mult": 10, "margin_rate": 0.08}, - "p": {"mult": 10, "margin_rate": 0.09}, - "c": {"mult": 10, "margin_rate": 0.08}, - "cs": {"mult": 10, "margin_rate": 0.08}, - "jd": {"mult": 10, "margin_rate": 0.09}, - "lh": {"mult": 16, "margin_rate": 0.12}, - "l": {"mult": 5, "margin_rate": 0.09}, - "pp": {"mult": 5, "margin_rate": 0.09}, - "v": {"mult": 5, "margin_rate": 0.09}, - "eg": {"mult": 10, "margin_rate": 0.09}, - "eb": {"mult": 5, "margin_rate": 0.10}, - "pg": {"mult": 20, "margin_rate": 0.10}, - "RM": {"mult": 10, "margin_rate": 0.08}, - "OI": {"mult": 10, "margin_rate": 0.08}, - "SR": {"mult": 10, "margin_rate": 0.08}, - "CF": {"mult": 5, "margin_rate": 0.08}, - "MA": {"mult": 10, "margin_rate": 0.09}, - "TA": {"mult": 5, "margin_rate": 0.09}, - "FG": {"mult": 20, "margin_rate": 0.10}, - "SA": {"mult": 20, "margin_rate": 0.10}, - "UR": {"mult": 20, "margin_rate": 0.10}, - "SF": {"mult": 5, "margin_rate": 0.10}, - "SM": {"mult": 5, "margin_rate": 0.10}, - "AP": {"mult": 10, "margin_rate": 0.10}, - "CJ": {"mult": 5, "margin_rate": 0.10}, - "PK": {"mult": 5, "margin_rate": 0.10}, - "IF": {"mult": 300, "margin_rate": 0.12, "tick_size": 0.2}, - "IH": {"mult": 300, "margin_rate": 0.12, "tick_size": 0.2}, - "IC": {"mult": 200, "margin_rate": 0.12, "tick_size": 0.2}, - "IM": {"mult": 200, "margin_rate": 0.12, "tick_size": 0.2}, -} - -_TICK_OVERRIDES: dict[str, float] = { - "sc": 0.1, "TA": 2.0, "CF": 5.0, "SF": 2.0, "SM": 2.0, -} - - -def get_contract_spec(ths_code: str) -> dict: - code = (ths_code or "").strip() - m = re.match(r"^([A-Za-z]+)", code) - if not m: - return dict(DEFAULT_SPEC) - letters = m.group(1) - spec = _SPEC_BY_THS.get(letters) or _SPEC_BY_THS.get(letters.upper()) or _SPEC_BY_THS.get(letters.lower()) - if spec: - tick = spec.get("tick_size") - if tick is None: - tick = _TICK_OVERRIDES.get(letters) or _TICK_OVERRIDES.get(letters.upper()) or 1.0 - return {"mult": spec["mult"], "margin_rate": spec["margin_rate"], "tick_size": float(tick)} - return dict(DEFAULT_SPEC) - - -def calc_position_metrics( - direction: str, - entry: float, - stop_loss: float, - take_profit: float, - lots: float, - mark_price: Optional[float], - capital: float, - ths_code: str, -) -> dict: - spec = get_contract_spec(ths_code) - mult = spec["mult"] - margin_rate = spec["margin_rate"] - lots = lots or 1.0 - margin = entry * mult * lots * margin_rate - - if direction == "long": - risk_amt = max(0.0, (entry - stop_loss) * mult * lots) - reward = max(0.0, (take_profit - entry) * mult * lots) - float_pnl = (mark_price - entry) * mult * lots if mark_price is not None else None - else: - risk_amt = max(0.0, (stop_loss - entry) * mult * lots) - reward = max(0.0, (entry - take_profit) * mult * lots) - float_pnl = (entry - mark_price) * mult * lots if mark_price is not None else None - - risk_pct = (risk_amt / capital * 100) if capital > 0 else 0.0 - pos_pct = (margin / capital * 100) if capital > 0 else 0.0 - rr = (reward / risk_amt) if risk_amt > 0 else None - float_pct = (float_pnl / margin * 100) if margin > 0 and float_pnl is not None else None - - return { - "mult": mult, - "margin_rate": margin_rate, - "margin": round(margin, 2), - "risk_amount": round(risk_amt, 2), - "risk_pct": round(risk_pct, 2), - "position_pct": round(pos_pct, 2), - "float_pnl": round(float_pnl, 2) if float_pnl is not None else None, - "float_pct": round(float_pct, 2) if float_pct is not None else None, - "reward_amount": round(reward, 2) if reward else None, - "rr_ratio": round(rr, 2) if rr is not None else None, - } +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""国内期货合约乘数与参考保证金比例(用于估算保证金与风险)。""" +import re +from typing import Optional + +DEFAULT_SPEC = {"mult": 10, "margin_rate": 0.10, "tick_size": 1.0} + +# 参考交易所常见规格(乘数 + 保证金比例 + 最小变动价位) +_SPEC_BY_THS: dict[str, dict] = { + "ag": {"mult": 15, "margin_rate": 0.14, "tick_size": 1.0}, + "au": {"mult": 1000, "margin_rate": 0.10, "tick_size": 0.02}, + "cu": {"mult": 5, "margin_rate": 0.10, "tick_size": 10.0}, + "al": {"mult": 5, "margin_rate": 0.10}, + "zn": {"mult": 5, "margin_rate": 0.10}, + "pb": {"mult": 5, "margin_rate": 0.10}, + "ni": {"mult": 1, "margin_rate": 0.12}, + "sn": {"mult": 1, "margin_rate": 0.12}, + "rb": {"mult": 10, "margin_rate": 0.09}, + "hc": {"mult": 10, "margin_rate": 0.09}, + "ss": {"mult": 5, "margin_rate": 0.11}, + "sc": {"mult": 1000, "margin_rate": 0.11}, + "fu": {"mult": 10, "margin_rate": 0.11}, + "bu": {"mult": 10, "margin_rate": 0.11}, + "ru": {"mult": 10, "margin_rate": 0.11}, + "sp": {"mult": 10, "margin_rate": 0.10}, + "i": {"mult": 100, "margin_rate": 0.11}, + "j": {"mult": 100, "margin_rate": 0.12}, + "jm": {"mult": 60, "margin_rate": 0.12}, + "m": {"mult": 10, "margin_rate": 0.08}, + "y": {"mult": 10, "margin_rate": 0.08}, + "p": {"mult": 10, "margin_rate": 0.09}, + "c": {"mult": 10, "margin_rate": 0.08}, + "cs": {"mult": 10, "margin_rate": 0.08}, + "jd": {"mult": 10, "margin_rate": 0.09}, + "lh": {"mult": 16, "margin_rate": 0.12}, + "l": {"mult": 5, "margin_rate": 0.09}, + "pp": {"mult": 5, "margin_rate": 0.09}, + "v": {"mult": 5, "margin_rate": 0.09}, + "eg": {"mult": 10, "margin_rate": 0.09}, + "eb": {"mult": 5, "margin_rate": 0.10}, + "pg": {"mult": 20, "margin_rate": 0.10}, + "RM": {"mult": 10, "margin_rate": 0.08}, + "OI": {"mult": 10, "margin_rate": 0.08}, + "SR": {"mult": 10, "margin_rate": 0.08}, + "CF": {"mult": 5, "margin_rate": 0.08}, + "MA": {"mult": 10, "margin_rate": 0.09}, + "TA": {"mult": 5, "margin_rate": 0.09}, + "FG": {"mult": 20, "margin_rate": 0.10}, + "SA": {"mult": 20, "margin_rate": 0.10}, + "UR": {"mult": 20, "margin_rate": 0.10}, + "SF": {"mult": 5, "margin_rate": 0.10}, + "SM": {"mult": 5, "margin_rate": 0.10}, + "AP": {"mult": 10, "margin_rate": 0.10}, + "CJ": {"mult": 5, "margin_rate": 0.10}, + "PK": {"mult": 5, "margin_rate": 0.10}, + "IF": {"mult": 300, "margin_rate": 0.12, "tick_size": 0.2}, + "IH": {"mult": 300, "margin_rate": 0.12, "tick_size": 0.2}, + "IC": {"mult": 200, "margin_rate": 0.12, "tick_size": 0.2}, + "IM": {"mult": 200, "margin_rate": 0.12, "tick_size": 0.2}, +} + +_TICK_OVERRIDES: dict[str, float] = { + "sc": 0.1, "TA": 2.0, "CF": 5.0, "SF": 2.0, "SM": 2.0, +} + + +def get_contract_spec(ths_code: str) -> dict: + code = (ths_code or "").strip() + m = re.match(r"^([A-Za-z]+)", code) + if not m: + return dict(DEFAULT_SPEC) + letters = m.group(1) + spec = _SPEC_BY_THS.get(letters) or _SPEC_BY_THS.get(letters.upper()) or _SPEC_BY_THS.get(letters.lower()) + if spec: + tick = spec.get("tick_size") + if tick is None: + tick = _TICK_OVERRIDES.get(letters) or _TICK_OVERRIDES.get(letters.upper()) or 1.0 + return {"mult": spec["mult"], "margin_rate": spec["margin_rate"], "tick_size": float(tick)} + return dict(DEFAULT_SPEC) + + +def calc_position_metrics( + direction: str, + entry: float, + stop_loss: float, + take_profit: float, + lots: float, + mark_price: Optional[float], + capital: float, + ths_code: str, +) -> dict: + spec = get_contract_spec(ths_code) + mult = spec["mult"] + margin_rate = spec["margin_rate"] + lots = lots or 1.0 + margin = entry * mult * lots * margin_rate + + if direction == "long": + risk_amt = max(0.0, (entry - stop_loss) * mult * lots) + reward = max(0.0, (take_profit - entry) * mult * lots) + float_pnl = (mark_price - entry) * mult * lots if mark_price is not None else None + else: + risk_amt = max(0.0, (stop_loss - entry) * mult * lots) + reward = max(0.0, (entry - take_profit) * mult * lots) + float_pnl = (entry - mark_price) * mult * lots if mark_price is not None else None + + risk_pct = (risk_amt / capital * 100) if capital > 0 else 0.0 + pos_pct = (margin / capital * 100) if capital > 0 else 0.0 + rr = (reward / risk_amt) if risk_amt > 0 else None + float_pct = (float_pnl / margin * 100) if margin > 0 and float_pnl is not None else None + + return { + "mult": mult, + "margin_rate": margin_rate, + "margin": round(margin, 2), + "risk_amount": round(risk_amt, 2), + "risk_pct": round(risk_pct, 2), + "position_pct": round(pos_pct, 2), + "float_pnl": round(float_pnl, 2) if float_pnl is not None else None, + "float_pct": round(float_pct, 2) if float_pct is not None else None, + "reward_amount": round(reward, 2) if reward else None, + "rr_ratio": round(rr, 2) if rr is not None else None, + } diff --git a/ctp_fee_sync.py b/ctp_fee_sync.py index 394d3ab..86986f6 100644 --- a/ctp_fee_sync.py +++ b/ctp_fee_sync.py @@ -1,139 +1,144 @@ -"""从 CTP 柜台同步手续费率(SimNow / 期货公司)。""" -from __future__ import annotations - -import logging -import re -import time -from typing import Optional - -from contract_specs import get_contract_spec -from fee_specs import upsert_fee_rate -from vnpy_bridge import get_bridge - -logger = logging.getLogger(__name__) - - -def _product_from_instrument(instrument_id: str) -> str: - m = re.match(r"^([A-Za-z]+)", instrument_id or "") - return m.group(1).lower() if m else "" - - -def ctp_commission_to_fee_fields(data: dict, ths_code: str) -> dict: - """CTP OnRspQryInstrumentCommissionRate → fee_rates 字段。""" - mult = int(get_contract_spec(ths_code)["mult"]) - exchange = str(data.get("ExchangeID") or "").strip() - return { - "exchange": exchange, - "mult": mult, - "open_fixed": float(data.get("OpenRatioByVolume") or 0), - "open_ratio": float(data.get("OpenRatioByMoney") or 0), - "close_yesterday_fixed": float(data.get("CloseRatioByVolume") or 0), - "close_yesterday_ratio": float(data.get("CloseRatioByMoney") or 0), - "close_today_fixed": float(data.get("CloseTodayRatioByVolume") or 0), - "close_today_ratio": float(data.get("CloseTodayRatioByMoney") or 0), - "source": "ctp", - } - - -def _collect_main_ths_codes() -> list[str]: - """从主力列表收集同花顺合约代码(供 CTP 手续费查询)。""" - from datetime import date - - from symbols import PRODUCTS, build_ths_code, list_main_contracts_grouped - - symbols: list[str] = [] - for group in list_main_contracts_grouped(): - for item in group.get("items") or []: - ths = (item.get("ths_code") or item.get("ths") or item.get("code") or "").strip() - if ths and not ths.endswith("888"): - symbols.append(ths) - - if symbols: - return symbols - - today = date.today() - for p in PRODUCTS: - symbols.append(build_ths_code(p, today.year, today.month)) - return symbols - - -def sync_fees_from_ctp(mode: str, *, max_symbols: int = 80) -> tuple[int, str]: - """CTP 已连接时查询手续费并写入 fee_rates(source=ctp,覆盖同品种旧数据)。""" - bridge = get_bridge() - if not bridge.available(): - return 0, "vnpy 未安装" - if bridge.connected_mode != mode: - return 0, "请先连接 CTP" - if not bridge.ping(): - return 0, "CTP 连接无效,请重连" - - seen: set[str] = set() - ok = 0 - errors = 0 - - batch = bridge.query_all_commissions(mode=mode) - if batch: - for raw in batch: - inst = str(raw.get("InstrumentID") or "").strip() - product = _product_from_instrument(inst) - if not product or product in seen: - continue - seen.add(product) - try: - fields = ctp_commission_to_fee_fields(raw, inst or product) - upsert_fee_rate(product, fields) - ok += 1 - except Exception as exc: - logger.debug("CTP fee batch %s: %s", inst, exc) - errors += 1 - if ok > 0: - msg = f"已从 CTP 批量同步 {ok} 个品种手续费" - if errors: - msg += f"({errors} 个跳过)" - return ok, msg - - symbols = _collect_main_ths_codes()[:max_symbols] - - if not symbols: - return 0, "无主力合约列表" - - for ths in symbols: - product = _product_from_instrument(ths) - if not product or product in seen: - continue - seen.add(product) - try: - raw = bridge.query_instrument_commission(ths, mode=mode) - if not raw: - errors += 1 - continue - fields = ctp_commission_to_fee_fields(raw, ths) - upsert_fee_rate(product, fields) - ok += 1 - time.sleep(0.35) - except Exception as exc: - logger.debug("CTP fee sync %s: %s", ths, exc) - errors += 1 - - if ok == 0: - return 0, f"CTP 未返回手续费率(失败 {errors} 次),请确认柜台支持查询" - msg = f"已从 CTP 同步 {ok} 个品种手续费" - if errors: - msg += f"({errors} 个跳过)" - return ok, msg - - -def sync_fee_for_symbol(mode: str, ths_code: str) -> Optional[dict]: - """单品种按需从 CTP 拉取并缓存。""" - bridge = get_bridge() - if bridge.connected_mode != mode or not bridge.ping(): - return None - raw = bridge.query_instrument_commission(ths_code, mode=mode) - if not raw: - return None - product = _product_from_instrument(ths_code) - if not product: - return None - fields = ctp_commission_to_fee_fields(raw, ths_code) - upsert_fee_rate(product, fields) - return fields +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""从 CTP 柜台同步手续费率(SimNow / 期货公司)。""" +from __future__ import annotations + +import logging +import re +import time +from typing import Optional + +from contract_specs import get_contract_spec +from fee_specs import upsert_fee_rate +from vnpy_bridge import get_bridge + +logger = logging.getLogger(__name__) + + +def _product_from_instrument(instrument_id: str) -> str: + m = re.match(r"^([A-Za-z]+)", instrument_id or "") + return m.group(1).lower() if m else "" + + +def ctp_commission_to_fee_fields(data: dict, ths_code: str) -> dict: + """CTP OnRspQryInstrumentCommissionRate → fee_rates 字段。""" + mult = int(get_contract_spec(ths_code)["mult"]) + exchange = str(data.get("ExchangeID") or "").strip() + return { + "exchange": exchange, + "mult": mult, + "open_fixed": float(data.get("OpenRatioByVolume") or 0), + "open_ratio": float(data.get("OpenRatioByMoney") or 0), + "close_yesterday_fixed": float(data.get("CloseRatioByVolume") or 0), + "close_yesterday_ratio": float(data.get("CloseRatioByMoney") or 0), + "close_today_fixed": float(data.get("CloseTodayRatioByVolume") or 0), + "close_today_ratio": float(data.get("CloseTodayRatioByMoney") or 0), + "source": "ctp", + } + + +def _collect_main_ths_codes() -> list[str]: + """从主力列表收集同花顺合约代码(供 CTP 手续费查询)。""" + from datetime import date + + from symbols import PRODUCTS, build_ths_code, list_main_contracts_grouped + + symbols: list[str] = [] + for group in list_main_contracts_grouped(): + for item in group.get("items") or []: + ths = (item.get("ths_code") or item.get("ths") or item.get("code") or "").strip() + if ths and not ths.endswith("888"): + symbols.append(ths) + + if symbols: + return symbols + + today = date.today() + for p in PRODUCTS: + symbols.append(build_ths_code(p, today.year, today.month)) + return symbols + + +def sync_fees_from_ctp(mode: str, *, max_symbols: int = 80) -> tuple[int, str]: + """CTP 已连接时查询手续费并写入 fee_rates(source=ctp,覆盖同品种旧数据)。""" + bridge = get_bridge() + if not bridge.available(): + return 0, "vnpy 未安装" + if bridge.connected_mode != mode: + return 0, "请先连接 CTP" + if not bridge.ping(): + return 0, "CTP 连接无效,请重连" + + seen: set[str] = set() + ok = 0 + errors = 0 + + batch = bridge.query_all_commissions(mode=mode) + if batch: + for raw in batch: + inst = str(raw.get("InstrumentID") or "").strip() + product = _product_from_instrument(inst) + if not product or product in seen: + continue + seen.add(product) + try: + fields = ctp_commission_to_fee_fields(raw, inst or product) + upsert_fee_rate(product, fields) + ok += 1 + except Exception as exc: + logger.debug("CTP fee batch %s: %s", inst, exc) + errors += 1 + if ok > 0: + msg = f"已从 CTP 批量同步 {ok} 个品种手续费" + if errors: + msg += f"({errors} 个跳过)" + return ok, msg + + symbols = _collect_main_ths_codes()[:max_symbols] + + if not symbols: + return 0, "无主力合约列表" + + for ths in symbols: + product = _product_from_instrument(ths) + if not product or product in seen: + continue + seen.add(product) + try: + raw = bridge.query_instrument_commission(ths, mode=mode) + if not raw: + errors += 1 + continue + fields = ctp_commission_to_fee_fields(raw, ths) + upsert_fee_rate(product, fields) + ok += 1 + time.sleep(0.35) + except Exception as exc: + logger.debug("CTP fee sync %s: %s", ths, exc) + errors += 1 + + if ok == 0: + return 0, f"CTP 未返回手续费率(失败 {errors} 次),请确认柜台支持查询" + msg = f"已从 CTP 同步 {ok} 个品种手续费" + if errors: + msg += f"({errors} 个跳过)" + return ok, msg + + +def sync_fee_for_symbol(mode: str, ths_code: str) -> Optional[dict]: + """单品种按需从 CTP 拉取并缓存。""" + bridge = get_bridge() + if bridge.connected_mode != mode or not bridge.ping(): + return None + raw = bridge.query_instrument_commission(ths_code, mode=mode) + if not raw: + return None + product = _product_from_instrument(ths_code) + if not product: + return None + fields = ctp_commission_to_fee_fields(raw, ths_code) + upsert_fee_rate(product, fields) + return fields diff --git a/ctp_fee_worker.py b/ctp_fee_worker.py index 2f6ce98..d7ab04b 100644 --- a/ctp_fee_worker.py +++ b/ctp_fee_worker.py @@ -1,126 +1,131 @@ -"""CTP 手续费后台同步:每日一次写入数据库,前端只读展示。""" -from __future__ import annotations - -import logging -import threading -import time -from datetime import date, datetime -from typing import Callable, Optional -from zoneinfo import ZoneInfo - -logger = logging.getLogger(__name__) - -TZ = ZoneInfo("Asia/Shanghai") -FEE_SYNC_KEY = "ctp_fee_last_sync" -CHECK_INTERVAL_SEC = 3600 -_sync_lock = threading.Lock() - - -def fee_sync_in_progress() -> bool: - return _sync_lock.locked() - - -def _today_str() -> str: - return datetime.now(TZ).date().isoformat() - - -def get_fee_last_sync(get_setting: Callable[[str, str], str]) -> str: - return (get_setting(FEE_SYNC_KEY, "") or "").strip() - - -def fees_synced_today(get_setting: Callable[[str, str], str]) -> bool: - last = get_fee_last_sync(get_setting) - return bool(last) and last[:10] == _today_str() - - -def mark_fees_synced(set_setting: Callable[[str, str], None]) -> None: - set_setting(FEE_SYNC_KEY, datetime.now(TZ).isoformat(timespec="seconds")) - - -def try_daily_ctp_fee_sync( - mode: str, - *, - get_setting: Callable[[str, str], str], - set_setting: Callable[[str, str], None], - force: bool = False, -) -> tuple[int, str]: - """CTP 已连接且今日未同步时拉取费率入库;force=True 忽略日期限制。""" - if not force and fees_synced_today(get_setting): - return 0, "今日已从 CTP 同步过,无需重复(可点「立即同步」强制刷新)" - - with _sync_lock: - if not force and fees_synced_today(get_setting): - return 0, "今日已从 CTP 同步过" - - t0 = time.monotonic() - from ctp_fee_sync import sync_fees_from_ctp - - count, msg = sync_fees_from_ctp(mode) - elapsed = time.monotonic() - t0 - if count > 0: - mark_fees_synced(set_setting) - msg = f"{msg}(耗时 {elapsed:.1f} 秒)" - logger.info("CTP 手续费每日同步: %s", msg) - elif force: - msg = f"{msg}(耗时 {elapsed:.1f} 秒)" - logger.warning("CTP 手续费强制同步未写入: %s", msg) - return count, msg - - -def schedule_ctp_fee_sync( - mode: str, - *, - get_setting: Callable[[str, str], str], - set_setting: Callable[[str, str], None], - force: bool = False, -) -> tuple[bool, str]: - """后台线程同步,避免阻塞 Web 请求。""" - if _sync_lock.locked(): - return False, "手续费同步进行中,请稍后再试(约 1~3 分钟)" - - def _run() -> None: - try: - try_daily_ctp_fee_sync( - mode, - get_setting=get_setting, - set_setting=set_setting, - force=force, - ) - except Exception as exc: - logger.exception("CTP 手续费后台同步失败: %s", exc) - - threading.Thread(target=_run, daemon=True, name="ctp-fee-sync-run").start() - if force: - return True, "已在后台开始同步,约 30 秒~2 分钟完成,请稍后刷新本页查看" - return True, "已在后台检查同步,请稍后刷新本页" - - -def start_ctp_fee_worker( - *, - get_mode_fn: Callable[[], str], - get_setting_fn: Callable[[str, str], str], - set_setting_fn: Callable[[str, str], None], - interval: int = CHECK_INTERVAL_SEC, -) -> None: - """后台线程:每小时检查,CTP 已连接且当日未同步则自动同步。""" - - def _loop() -> None: - time.sleep(20) - while True: - try: - from vnpy_bridge import ctp_status - - mode = get_mode_fn() - st = ctp_status(mode) - if st.get("connected") and not fees_synced_today(get_setting_fn): - try_daily_ctp_fee_sync( - mode, - get_setting=get_setting_fn, - set_setting=set_setting_fn, - force=False, - ) - except Exception as exc: - logger.warning("CTP fee worker: %s", exc) - time.sleep(max(300, interval)) - - threading.Thread(target=_loop, daemon=True, name="ctp-fee-worker").start() +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""CTP 手续费后台同步:每日一次写入数据库,前端只读展示。""" +from __future__ import annotations + +import logging +import threading +import time +from datetime import date, datetime +from typing import Callable, Optional +from zoneinfo import ZoneInfo + +logger = logging.getLogger(__name__) + +TZ = ZoneInfo("Asia/Shanghai") +FEE_SYNC_KEY = "ctp_fee_last_sync" +CHECK_INTERVAL_SEC = 3600 +_sync_lock = threading.Lock() + + +def fee_sync_in_progress() -> bool: + return _sync_lock.locked() + + +def _today_str() -> str: + return datetime.now(TZ).date().isoformat() + + +def get_fee_last_sync(get_setting: Callable[[str, str], str]) -> str: + return (get_setting(FEE_SYNC_KEY, "") or "").strip() + + +def fees_synced_today(get_setting: Callable[[str, str], str]) -> bool: + last = get_fee_last_sync(get_setting) + return bool(last) and last[:10] == _today_str() + + +def mark_fees_synced(set_setting: Callable[[str, str], None]) -> None: + set_setting(FEE_SYNC_KEY, datetime.now(TZ).isoformat(timespec="seconds")) + + +def try_daily_ctp_fee_sync( + mode: str, + *, + get_setting: Callable[[str, str], str], + set_setting: Callable[[str, str], None], + force: bool = False, +) -> tuple[int, str]: + """CTP 已连接且今日未同步时拉取费率入库;force=True 忽略日期限制。""" + if not force and fees_synced_today(get_setting): + return 0, "今日已从 CTP 同步过,无需重复(可点「立即同步」强制刷新)" + + with _sync_lock: + if not force and fees_synced_today(get_setting): + return 0, "今日已从 CTP 同步过" + + t0 = time.monotonic() + from ctp_fee_sync import sync_fees_from_ctp + + count, msg = sync_fees_from_ctp(mode) + elapsed = time.monotonic() - t0 + if count > 0: + mark_fees_synced(set_setting) + msg = f"{msg}(耗时 {elapsed:.1f} 秒)" + logger.info("CTP 手续费每日同步: %s", msg) + elif force: + msg = f"{msg}(耗时 {elapsed:.1f} 秒)" + logger.warning("CTP 手续费强制同步未写入: %s", msg) + return count, msg + + +def schedule_ctp_fee_sync( + mode: str, + *, + get_setting: Callable[[str, str], str], + set_setting: Callable[[str, str], None], + force: bool = False, +) -> tuple[bool, str]: + """后台线程同步,避免阻塞 Web 请求。""" + if _sync_lock.locked(): + return False, "手续费同步进行中,请稍后再试(约 1~3 分钟)" + + def _run() -> None: + try: + try_daily_ctp_fee_sync( + mode, + get_setting=get_setting, + set_setting=set_setting, + force=force, + ) + except Exception as exc: + logger.exception("CTP 手续费后台同步失败: %s", exc) + + threading.Thread(target=_run, daemon=True, name="ctp-fee-sync-run").start() + if force: + return True, "已在后台开始同步,约 30 秒~2 分钟完成,请稍后刷新本页查看" + return True, "已在后台检查同步,请稍后刷新本页" + + +def start_ctp_fee_worker( + *, + get_mode_fn: Callable[[], str], + get_setting_fn: Callable[[str, str], str], + set_setting_fn: Callable[[str, str], None], + interval: int = CHECK_INTERVAL_SEC, +) -> None: + """后台线程:每小时检查,CTP 已连接且当日未同步则自动同步。""" + + def _loop() -> None: + time.sleep(20) + while True: + try: + from vnpy_bridge import ctp_status + + mode = get_mode_fn() + st = ctp_status(mode) + if st.get("connected") and not fees_synced_today(get_setting_fn): + try_daily_ctp_fee_sync( + mode, + get_setting=get_setting_fn, + set_setting=set_setting_fn, + force=False, + ) + except Exception as exc: + logger.warning("CTP fee worker: %s", exc) + time.sleep(max(300, interval)) + + threading.Thread(target=_loop, daemon=True, name="ctp-fee-worker").start() diff --git a/ctp_kline.py b/ctp_kline.py index 7a91ab9..71d14e4 100644 --- a/ctp_kline.py +++ b/ctp_kline.py @@ -1,84 +1,89 @@ -"""CTP tick 聚合 K 线(1 分钟为基础,再合成各周期)。""" -from __future__ import annotations - -import logging -from typing import Optional - -from kline_chart import ( - PERIOD_MINUTES, - _aggregate_bars, - _bar_datetime, - _merge_bars, - _timeshare_session, - _weekly_from_daily, -) - -logger = logging.getLogger(__name__) - -PERIOD_AGG = { - "2m": 2, - "3m": 3, - "5m": 5, - "15m": 15, - "30m": 30, - "1h": 60, - "2h": 120, - "4h": 240, -} - - -def _daily_from_1m(bars_1m: list) -> list: - if not bars_1m: - return [] - buckets: dict[str, list] = {} - for bar in bars_1m: - dt = _bar_datetime(bar) - if not dt: - continue - key = dt.strftime("%Y-%m-%d") - buckets.setdefault(key, []).append(bar) - out = [] - for day in sorted(buckets.keys()): - chunk = buckets[day] - merged = _merge_bars(chunk) - merged["d"] = day + " 15:00:00" - out.append(merged) - return out - - -def compose_period_bars(bars_1m: list, period: str) -> list: - p = (period or "15m").lower() - if p == "timeshare": - return _timeshare_session(bars_1m) - if p in ("1d", "d"): - return _daily_from_1m(bars_1m) - if p == "w": - return _weekly_from_daily(_daily_from_1m(bars_1m)) - if p == "1m": - return list(bars_1m) - n = PERIOD_AGG.get(p) - if n: - return _aggregate_bars(bars_1m, n) - if p in PERIOD_MINUTES: - try: - n = int(PERIOD_MINUTES[p]) - return _aggregate_bars(bars_1m, n) - except (TypeError, ValueError): - pass - return list(bars_1m) - - -def fetch_ctp_klines(symbol: str, period: str, mode: str) -> Optional[list]: - """CTP 已连接时由 tick 聚合 K 线;失败返回 None。""" - try: - from vnpy_bridge import ctp_status, get_bridge - - if not ctp_status(mode).get("connected"): - return None - bars_1m = get_bridge().get_kline_bars_1m(symbol, mode=mode) - if not bars_1m: - return None - return compose_period_bars(bars_1m, period) - except Exception as exc: - logger.debug("fetch_ctp_klines %s %s: %s", symbol, period, exc) - return None +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""CTP tick 聚合 K 线(1 分钟为基础,再合成各周期)。""" +from __future__ import annotations + +import logging +from typing import Optional + +from kline_chart import ( + PERIOD_MINUTES, + _aggregate_bars, + _bar_datetime, + _merge_bars, + _timeshare_session, + _weekly_from_daily, +) + +logger = logging.getLogger(__name__) + +PERIOD_AGG = { + "2m": 2, + "3m": 3, + "5m": 5, + "15m": 15, + "30m": 30, + "1h": 60, + "2h": 120, + "4h": 240, +} + + +def _daily_from_1m(bars_1m: list) -> list: + if not bars_1m: + return [] + buckets: dict[str, list] = {} + for bar in bars_1m: + dt = _bar_datetime(bar) + if not dt: + continue + key = dt.strftime("%Y-%m-%d") + buckets.setdefault(key, []).append(bar) + out = [] + for day in sorted(buckets.keys()): + chunk = buckets[day] + merged = _merge_bars(chunk) + merged["d"] = day + " 15:00:00" + out.append(merged) + return out + + +def compose_period_bars(bars_1m: list, period: str) -> list: + p = (period or "15m").lower() + if p == "timeshare": + return _timeshare_session(bars_1m) + if p in ("1d", "d"): + return _daily_from_1m(bars_1m) + if p == "w": + return _weekly_from_daily(_daily_from_1m(bars_1m)) + if p == "1m": + return list(bars_1m) + n = PERIOD_AGG.get(p) + if n: + return _aggregate_bars(bars_1m, n) + if p in PERIOD_MINUTES: + try: + n = int(PERIOD_MINUTES[p]) + return _aggregate_bars(bars_1m, n) + except (TypeError, ValueError): + pass + return list(bars_1m) + + +def fetch_ctp_klines(symbol: str, period: str, mode: str) -> Optional[list]: + """CTP 已连接时由 tick 聚合 K 线;失败返回 None。""" + try: + from vnpy_bridge import ctp_status, get_bridge + + if not ctp_status(mode).get("connected"): + return None + bars_1m = get_bridge().get_kline_bars_1m(symbol, mode=mode) + if not bars_1m: + return None + return compose_period_bars(bars_1m, period) + except Exception as exc: + logger.debug("fetch_ctp_klines %s %s: %s", symbol, period, exc) + return None diff --git a/ctp_premarket_connect.py b/ctp_premarket_connect.py index a4f22bd..214ba1d 100644 --- a/ctp_premarket_connect.py +++ b/ctp_premarket_connect.py @@ -1,66 +1,71 @@ -"""交易前自动连接 CTP(默认开盘前 30 分钟)。""" -from __future__ import annotations - -import logging -import os -import threading -import time -from typing import Callable - -from market_sessions import in_premarket_connect_window -from vnpy_bridge import ctp_start_connect, ctp_status - -logger = logging.getLogger(__name__) - -CHECK_INTERVAL_SEC = 60 -DEFAULT_MINUTES_BEFORE = 30 - - -def _premarket_enabled() -> bool: - return (os.getenv("CTP_PREMARKET_CONNECT", "true") or "true").strip().lower() in ( - "1", - "true", - "yes", - ) - - -def _minutes_before_open() -> int: - try: - return max(5, int(os.getenv("CTP_PREMARKET_MINUTES", str(DEFAULT_MINUTES_BEFORE)))) - except (TypeError, ValueError): - return DEFAULT_MINUTES_BEFORE - - -def start_ctp_premarket_connect_worker( - *, - get_mode_fn: Callable[[], str], - interval: int = CHECK_INTERVAL_SEC, -) -> None: - """在交易开始前若干分钟自动发起 CTP 连接。""" - - def _loop() -> None: - time.sleep(10) - while True: - try: - if _premarket_enabled() and in_premarket_connect_window( - minutes_before=_minutes_before_open(), - ): - mode = get_mode_fn() - st = ctp_status(mode) - if ( - not st.get("connected") - and not st.get("connecting") - and int(st.get("login_cooldown_sec") or 0) <= 0 - ): - info = ctp_start_connect(mode, force=False) - if info.get("started"): - logger.info( - "盘前自动连接 CTP [%s](开盘前 %d 分钟)", - mode, - _minutes_before_open(), - ) - except Exception as exc: - logger.warning("CTP premarket connect worker: %s", exc) - time.sleep(max(30, interval)) - - threading.Thread(target=_loop, daemon=True, name="ctp-premarket-connect").start() +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""交易前自动连接 CTP(默认开盘前 30 分钟)。""" +from __future__ import annotations + +import logging +import os +import threading +import time +from typing import Callable + +from market_sessions import in_premarket_connect_window +from vnpy_bridge import ctp_start_connect, ctp_status + +logger = logging.getLogger(__name__) + +CHECK_INTERVAL_SEC = 60 +DEFAULT_MINUTES_BEFORE = 30 + + +def _premarket_enabled() -> bool: + return (os.getenv("CTP_PREMARKET_CONNECT", "true") or "true").strip().lower() in ( + "1", + "true", + "yes", + ) + + +def _minutes_before_open() -> int: + try: + return max(5, int(os.getenv("CTP_PREMARKET_MINUTES", str(DEFAULT_MINUTES_BEFORE)))) + except (TypeError, ValueError): + return DEFAULT_MINUTES_BEFORE + + +def start_ctp_premarket_connect_worker( + *, + get_mode_fn: Callable[[], str], + interval: int = CHECK_INTERVAL_SEC, +) -> None: + """在交易开始前若干分钟自动发起 CTP 连接。""" + + def _loop() -> None: + time.sleep(10) + while True: + try: + if _premarket_enabled() and in_premarket_connect_window( + minutes_before=_minutes_before_open(), + ): + mode = get_mode_fn() + st = ctp_status(mode) + if ( + not st.get("connected") + and not st.get("connecting") + and int(st.get("login_cooldown_sec") or 0) <= 0 + ): + info = ctp_start_connect(mode, force=False) + if info.get("started"): + logger.info( + "盘前自动连接 CTP [%s](开盘前 %d 分钟)", + mode, + _minutes_before_open(), + ) + except Exception as exc: + logger.warning("CTP premarket connect worker: %s", exc) + time.sleep(max(30, interval)) + + threading.Thread(target=_loop, daemon=True, name="ctp-premarket-connect").start() diff --git a/ctp_reconnect.py b/ctp_reconnect.py index 95b1b48..0d72824 100644 --- a/ctp_reconnect.py +++ b/ctp_reconnect.py @@ -1,39 +1,44 @@ -"""CTP 断线自动重连(后台线程)。""" -from __future__ import annotations - -import logging -import os -import threading -import time -from typing import Callable - -from vnpy_bridge import ctp_try_auto_reconnect - -logger = logging.getLogger(__name__) - -RECONNECT_INTERVAL_SEC = 60 - - -def _auto_reconnect_enabled() -> bool: - return (os.getenv("CTP_AUTO_RECONNECT", "true") or "true").strip().lower() in ( - "1", - "true", - "yes", - ) - - -def start_ctp_reconnect_worker(*, get_mode_fn: Callable[[], str], interval: int = RECONNECT_INTERVAL_SEC) -> None: - """定时检测 CTP 连接,断线后自动重连。""" - - def _loop() -> None: - while True: - try: - if _auto_reconnect_enabled(): - mode = get_mode_fn() - if ctp_try_auto_reconnect(mode): - logger.debug("CTP 连接正常 [%s]", mode) - except Exception as exc: - logger.warning("CTP reconnect worker: %s", exc) - time.sleep(max(5, interval)) - - threading.Thread(target=_loop, daemon=True, name="ctp-reconnect-worker").start() +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""CTP 断线自动重连(后台线程)。""" +from __future__ import annotations + +import logging +import os +import threading +import time +from typing import Callable + +from vnpy_bridge import ctp_try_auto_reconnect + +logger = logging.getLogger(__name__) + +RECONNECT_INTERVAL_SEC = 60 + + +def _auto_reconnect_enabled() -> bool: + return (os.getenv("CTP_AUTO_RECONNECT", "true") or "true").strip().lower() in ( + "1", + "true", + "yes", + ) + + +def start_ctp_reconnect_worker(*, get_mode_fn: Callable[[], str], interval: int = RECONNECT_INTERVAL_SEC) -> None: + """定时检测 CTP 连接,断线后自动重连。""" + + def _loop() -> None: + while True: + try: + if _auto_reconnect_enabled(): + mode = get_mode_fn() + if ctp_try_auto_reconnect(mode): + logger.debug("CTP 连接正常 [%s]", mode) + except Exception as exc: + logger.warning("CTP reconnect worker: %s", exc) + time.sleep(max(5, interval)) + + threading.Thread(target=_loop, daemon=True, name="ctp-reconnect-worker").start() diff --git a/ctp_settings.py b/ctp_settings.py index cf0ce8c..d25dd43 100644 --- a/ctp_settings.py +++ b/ctp_settings.py @@ -1,124 +1,129 @@ -"""CTP / SimNow 配置:系统设置优先,.env 作兜底。""" -from __future__ import annotations - -import os -from typing import Any, Callable - -# (db_key, env_key, vnpy字段名, 默认值) -SIMNOW_FIELDS: tuple[tuple[str, str, str, str], ...] = ( - ("simnow_user", "SIMNOW_USER", "用户名", ""), - ("simnow_password", "SIMNOW_PASSWORD", "密码", ""), - ("simnow_broker_id", "SIMNOW_BROKER_ID", "经纪商代码", "9999"), - ("simnow_td_address", "SIMNOW_TD_ADDRESS", "交易服务器", "tcp://180.168.146.187:10201"), - ("simnow_md_address", "SIMNOW_MD_ADDRESS", "行情服务器", "tcp://180.168.146.187:10211"), - ("simnow_app_id", "SIMNOW_APP_ID", "产品名称", "simnow_client_test"), - ("simnow_auth_code", "SIMNOW_AUTH_CODE", "授权编码", "0000000000000000"), - ("simnow_env", "SIMNOW_ENV", "柜台环境", "实盘"), -) - -LIVE_FIELDS: tuple[tuple[str, str, str, str], ...] = ( - ("ctp_live_user", "CTP_LIVE_USER", "用户名", ""), - ("ctp_live_password", "CTP_LIVE_PASSWORD", "密码", ""), - ("ctp_live_broker_id", "CTP_LIVE_BROKER_ID", "经纪商代码", ""), - ("ctp_live_td_address", "CTP_LIVE_TD_ADDRESS", "交易服务器", ""), - ("ctp_live_md_address", "CTP_LIVE_MD_ADDRESS", "行情服务器", ""), - ("ctp_live_app_id", "CTP_LIVE_APP_ID", "产品名称", ""), - ("ctp_live_auth_code", "CTP_LIVE_AUTH_CODE", "授权编码", ""), - ("ctp_live_env", "CTP_LIVE_ENV", "柜台环境", "实盘"), -) - -PASSWORD_DB_KEYS = frozenset({"simnow_password", "ctp_live_password"}) - - -def _get_db_setting(key: str, default: str = "") -> str: - from fee_specs import get_setting - - return (get_setting(key, default) or default).strip() - - -def resolve_ctp_value(db_key: str, env_key: str, default: str = "") -> str: - v = _get_db_setting(db_key, "") - if v: - return v - return (os.getenv(env_key) or default).strip() - - -def _build_setting_dict(fields: tuple[tuple[str, str, str, str], ...]) -> dict[str, str]: - out: dict[str, str] = {} - for db_key, env_key, vnpy_key, default in fields: - out[vnpy_key] = resolve_ctp_value(db_key, env_key, default) - return out - - -def simnow_setting_dict() -> dict[str, str]: - return _build_setting_dict(SIMNOW_FIELDS) - - -def live_setting_dict() -> dict[str, str]: - return _build_setting_dict(LIVE_FIELDS) - - -def seed_ctp_settings_from_env(set_setting: Callable[[str, str], None]) -> None: - """首次启动:将 .env 中已有 CTP 配置写入 settings 表。""" - for db_key, env_key, _, _ in (*SIMNOW_FIELDS, *LIVE_FIELDS): - if _get_db_setting(db_key, ""): - continue - env_val = (os.getenv(env_key) or "").strip() - if env_val: - set_setting(db_key, env_val) - - -def get_ctp_settings_for_ui() -> dict[str, Any]: - ui: dict[str, Any] = {} - for db_key, env_key, _, default in SIMNOW_FIELDS: - ui[db_key] = resolve_ctp_value(db_key, env_key, default) - if db_key in PASSWORD_DB_KEYS: - ui[f"{db_key}_set"] = bool(ui[db_key]) - ui[db_key] = "" - for db_key, env_key, _, default in LIVE_FIELDS: - ui[db_key] = resolve_ctp_value(db_key, env_key, default) - if db_key in PASSWORD_DB_KEYS: - ui[f"{db_key}_set"] = bool(ui[db_key]) - ui[db_key] = "" - return ui - - -def save_ctp_settings_from_form( - form: Any, - set_setting: Callable[[str, str], None], -) -> dict[str, Any]: - """保存 CTP 配置;密码留空表示不修改。返回摘要供页面提示。""" - passwords_updated: list[str] = [] - passwords_submitted_empty: list[str] = [] - - for db_key, _, _, default in SIMNOW_FIELDS: - if db_key in PASSWORD_DB_KEYS: - raw = form.get(db_key) - val = (raw or "").strip() - if val: - set_setting(db_key, val) - passwords_updated.append(db_key) - else: - passwords_submitted_empty.append(db_key) - continue - val = (form.get(db_key) or "").strip() - set_setting(db_key, val or default) - - for db_key, _, _, default in LIVE_FIELDS: - if db_key in PASSWORD_DB_KEYS: - raw = form.get(db_key) - val = (raw or "").strip() - if val: - set_setting(db_key, val) - passwords_updated.append(db_key) - else: - passwords_submitted_empty.append(db_key) - continue - val = (form.get(db_key) or "").strip() - if default or val: - set_setting(db_key, val or default) - - return { - "passwords_updated": passwords_updated, - "passwords_submitted_empty": passwords_submitted_empty, - } +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""CTP / SimNow 配置:系统设置优先,.env 作兜底。""" +from __future__ import annotations + +import os +from typing import Any, Callable + +# (db_key, env_key, vnpy字段名, 默认值) +SIMNOW_FIELDS: tuple[tuple[str, str, str, str], ...] = ( + ("simnow_user", "SIMNOW_USER", "用户名", ""), + ("simnow_password", "SIMNOW_PASSWORD", "密码", ""), + ("simnow_broker_id", "SIMNOW_BROKER_ID", "经纪商代码", "9999"), + ("simnow_td_address", "SIMNOW_TD_ADDRESS", "交易服务器", "tcp://180.168.146.187:10201"), + ("simnow_md_address", "SIMNOW_MD_ADDRESS", "行情服务器", "tcp://180.168.146.187:10211"), + ("simnow_app_id", "SIMNOW_APP_ID", "产品名称", "simnow_client_test"), + ("simnow_auth_code", "SIMNOW_AUTH_CODE", "授权编码", "0000000000000000"), + ("simnow_env", "SIMNOW_ENV", "柜台环境", "实盘"), +) + +LIVE_FIELDS: tuple[tuple[str, str, str, str], ...] = ( + ("ctp_live_user", "CTP_LIVE_USER", "用户名", ""), + ("ctp_live_password", "CTP_LIVE_PASSWORD", "密码", ""), + ("ctp_live_broker_id", "CTP_LIVE_BROKER_ID", "经纪商代码", ""), + ("ctp_live_td_address", "CTP_LIVE_TD_ADDRESS", "交易服务器", ""), + ("ctp_live_md_address", "CTP_LIVE_MD_ADDRESS", "行情服务器", ""), + ("ctp_live_app_id", "CTP_LIVE_APP_ID", "产品名称", ""), + ("ctp_live_auth_code", "CTP_LIVE_AUTH_CODE", "授权编码", ""), + ("ctp_live_env", "CTP_LIVE_ENV", "柜台环境", "实盘"), +) + +PASSWORD_DB_KEYS = frozenset({"simnow_password", "ctp_live_password"}) + + +def _get_db_setting(key: str, default: str = "") -> str: + from fee_specs import get_setting + + return (get_setting(key, default) or default).strip() + + +def resolve_ctp_value(db_key: str, env_key: str, default: str = "") -> str: + v = _get_db_setting(db_key, "") + if v: + return v + return (os.getenv(env_key) or default).strip() + + +def _build_setting_dict(fields: tuple[tuple[str, str, str, str], ...]) -> dict[str, str]: + out: dict[str, str] = {} + for db_key, env_key, vnpy_key, default in fields: + out[vnpy_key] = resolve_ctp_value(db_key, env_key, default) + return out + + +def simnow_setting_dict() -> dict[str, str]: + return _build_setting_dict(SIMNOW_FIELDS) + + +def live_setting_dict() -> dict[str, str]: + return _build_setting_dict(LIVE_FIELDS) + + +def seed_ctp_settings_from_env(set_setting: Callable[[str, str], None]) -> None: + """首次启动:将 .env 中已有 CTP 配置写入 settings 表。""" + for db_key, env_key, _, _ in (*SIMNOW_FIELDS, *LIVE_FIELDS): + if _get_db_setting(db_key, ""): + continue + env_val = (os.getenv(env_key) or "").strip() + if env_val: + set_setting(db_key, env_val) + + +def get_ctp_settings_for_ui() -> dict[str, Any]: + ui: dict[str, Any] = {} + for db_key, env_key, _, default in SIMNOW_FIELDS: + ui[db_key] = resolve_ctp_value(db_key, env_key, default) + if db_key in PASSWORD_DB_KEYS: + ui[f"{db_key}_set"] = bool(ui[db_key]) + ui[db_key] = "" + for db_key, env_key, _, default in LIVE_FIELDS: + ui[db_key] = resolve_ctp_value(db_key, env_key, default) + if db_key in PASSWORD_DB_KEYS: + ui[f"{db_key}_set"] = bool(ui[db_key]) + ui[db_key] = "" + return ui + + +def save_ctp_settings_from_form( + form: Any, + set_setting: Callable[[str, str], None], +) -> dict[str, Any]: + """保存 CTP 配置;密码留空表示不修改。返回摘要供页面提示。""" + passwords_updated: list[str] = [] + passwords_submitted_empty: list[str] = [] + + for db_key, _, _, default in SIMNOW_FIELDS: + if db_key in PASSWORD_DB_KEYS: + raw = form.get(db_key) + val = (raw or "").strip() + if val: + set_setting(db_key, val) + passwords_updated.append(db_key) + else: + passwords_submitted_empty.append(db_key) + continue + val = (form.get(db_key) or "").strip() + set_setting(db_key, val or default) + + for db_key, _, _, default in LIVE_FIELDS: + if db_key in PASSWORD_DB_KEYS: + raw = form.get(db_key) + val = (raw or "").strip() + if val: + set_setting(db_key, val) + passwords_updated.append(db_key) + else: + passwords_submitted_empty.append(db_key) + continue + val = (form.get(db_key) or "").strip() + if default or val: + set_setting(db_key, val or default) + + return { + "passwords_updated": passwords_updated, + "passwords_submitted_empty": passwords_submitted_empty, + } diff --git a/ctp_symbol.py b/ctp_symbol.py index 8be3df4..e30cd83 100644 --- a/ctp_symbol.py +++ b/ctp_symbol.py @@ -1,57 +1,62 @@ -"""同花顺合约代码 → vnpy Symbol + Exchange。""" -from __future__ import annotations - -import re -from typing import Optional, Tuple - -from symbols import ths_to_codes - -try: - from vnpy.trader.constant import Exchange -except ImportError: - Exchange = None # type: ignore - -_EX_MAP = { - "SHFE": "SHFE", - "DCE": "DCE", - "CZCE": "CZCE", - "CFFEX": "CFFEX", - "INE": "INE", -} - - -def ths_to_vnpy_symbol(ths_code: str) -> Tuple[str, str]: - """ - 返回 (symbol, exchange_enum_name)。 - 例:rb2610 → rb2610, SHFE;SR609 → SR609, CZCE - """ - code = (ths_code or "").strip() - codes = ths_to_codes(code) - ex = (codes.get("ex") if codes else None) or "SHFE" - ex = _EX_MAP.get(ex, "SHFE") - m = re.match(r"^([A-Za-z]+)(\d+)$", code) - if not m: - return code, ex - letters, digits = m.group(1), m.group(2) - if ex == "CZCE": - # 郑商所 CTP 常为大写 + 3 位年月(如 SR509);4 位则取后 3 位 - sym = letters.upper() + (digits[-3:] if len(digits) >= 3 else digits) - else: - sym = letters.lower() + digits - return sym, ex - - -def to_vnpy_exchange(ex_name: str): - if Exchange is None: - raise ImportError("vnpy 未安装") - mapping = { - "SHFE": Exchange.SHFE, - "DCE": Exchange.DCE, - "CZCE": Exchange.CZCE, - "CFFEX": Exchange.CFFEX, - "INE": Exchange.INE, - } - ex = mapping.get((ex_name or "").upper()) - if ex is None: - raise ValueError(f"未知交易所: {ex_name}") - return ex +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""同花顺合约代码 → vnpy Symbol + Exchange。""" +from __future__ import annotations + +import re +from typing import Optional, Tuple + +from symbols import ths_to_codes + +try: + from vnpy.trader.constant import Exchange +except ImportError: + Exchange = None # type: ignore + +_EX_MAP = { + "SHFE": "SHFE", + "DCE": "DCE", + "CZCE": "CZCE", + "CFFEX": "CFFEX", + "INE": "INE", +} + + +def ths_to_vnpy_symbol(ths_code: str) -> Tuple[str, str]: + """ + 返回 (symbol, exchange_enum_name)。 + 例:rb2610 → rb2610, SHFE;SR609 → SR609, CZCE + """ + code = (ths_code or "").strip() + codes = ths_to_codes(code) + ex = (codes.get("ex") if codes else None) or "SHFE" + ex = _EX_MAP.get(ex, "SHFE") + m = re.match(r"^([A-Za-z]+)(\d+)$", code) + if not m: + return code, ex + letters, digits = m.group(1), m.group(2) + if ex == "CZCE": + # 郑商所 CTP 常为大写 + 3 位年月(如 SR509);4 位则取后 3 位 + sym = letters.upper() + (digits[-3:] if len(digits) >= 3 else digits) + else: + sym = letters.lower() + digits + return sym, ex + + +def to_vnpy_exchange(ex_name: str): + if Exchange is None: + raise ImportError("vnpy 未安装") + mapping = { + "SHFE": Exchange.SHFE, + "DCE": Exchange.DCE, + "CZCE": Exchange.CZCE, + "CFFEX": Exchange.CFFEX, + "INE": Exchange.INE, + } + ex = mapping.get((ex_name or "").upper()) + if ex is None: + raise ValueError(f"未知交易所: {ex_name}") + return ex diff --git a/ctp_trade_sync.py b/ctp_trade_sync.py index 1641e12..4fedab8 100644 --- a/ctp_trade_sync.py +++ b/ctp_trade_sync.py @@ -1,262 +1,267 @@ -"""从 CTP 柜台同步成交,写入 trade_logs(以交易所成交为准)。""" -from __future__ import annotations - -import logging -from collections import defaultdict -from datetime import datetime -from typing import Any, Callable, Optional -from zoneinfo import ZoneInfo - -from contract_specs import calc_position_metrics -from ctp_symbol import ths_to_vnpy_symbol -from fee_specs import calc_round_trip_fee -from symbols import ths_to_codes -from trade_log_lib import calc_equity_after, ensure_trade_log_columns -from vnpy_bridge import ctp_list_trades, ctp_status - -logger = logging.getLogger(__name__) -TZ = ZoneInfo("Asia/Shanghai") - - -def _match_symbol(ctp_sym: str, ths: str) -> bool: - a = (ctp_sym or "").lower() - b = (ths or "").lower() - if a == b: - return True - if a and b and a.split(".")[0] == b.split(".")[0]: - return True - try: - vnpy_sym, _ = ths_to_vnpy_symbol(ths) - if a == vnpy_sym.lower(): - return True - except Exception: - pass - return False - - -def _to_ths_code(symbol: str) -> str: - sym = (symbol or "").strip() - if not sym: - return "" - codes = ths_to_codes(sym) - if codes: - return codes.get("ths_code") or sym - return sym.lower() - - -def build_round_trips(trades: list[dict[str, Any]]) -> list[dict[str, Any]]: - """按 FIFO 将开/平仓成交配对为完整回合。""" - stacks: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list) - trips: list[dict[str, Any]] = [] - - ordered = sorted( - trades, - key=lambda t: ((t.get("datetime") or ""), str(t.get("trade_id") or "")), - ) - for t in ordered: - sym = (t.get("symbol") or "").lower() - pos_dir = (t.get("position_direction") or "long").strip().lower() - offset = (t.get("offset") or "open").strip().lower() - lots = int(t.get("lots") or 0) - if not sym or lots <= 0: - continue - key = (sym, pos_dir) - if offset == "open": - stacks[key].append({ - **t, - "remaining": lots, - }) - continue - - close_lots_left = lots - close_price = float(t.get("price") or 0) - close_time = t.get("datetime") or "" - close_trade_id = str(t.get("trade_id") or "") - while close_lots_left > 0 and stacks[key]: - open_t = stacks[key][0] - matched = min(close_lots_left, int(open_t.get("remaining") or 0)) - if matched <= 0: - stacks[key].pop(0) - continue - open_t["remaining"] = int(open_t.get("remaining") or 0) - matched - if open_t["remaining"] <= 0: - stacks[key].pop(0) - close_lots_left -= matched - open_trade_id = str(open_t.get("trade_id") or "") - ctp_key = f"{open_trade_id}|{close_trade_id}|{sym}|{pos_dir}|{matched}" - trips.append({ - "ctp_trade_key": ctp_key, - "symbol": sym, - "ths_code": _to_ths_code(sym), - "direction": pos_dir, - "lots": matched, - "entry_price": float(open_t.get("price") or 0), - "close_price": close_price, - "open_time": open_t.get("datetime") or "", - "close_time": close_time, - "open_trade_id": open_trade_id, - "close_trade_id": close_trade_id, - }) - return trips - - -def _find_monitor_meta( - conn, - *, - symbol: str, - direction: str, - open_time: str, - match_symbol_fn: Callable[[str, str], bool] | None = None, -) -> dict[str, Any]: - match = match_symbol_fn or _match_symbol - direction = (direction or "long").strip().lower() - best: Optional[dict[str, Any]] = None - for r in conn.execute( - "SELECT * FROM trade_order_monitors ORDER BY id DESC LIMIT 200" - ).fetchall(): - row = dict(r) - if (row.get("direction") or "long").strip().lower() != direction: - continue - if not match(symbol, row.get("symbol") or ""): - continue - if best is None: - best = row - continue - ot = (row.get("open_time") or "").strip() - if open_time and ot and abs(len(ot) - len(open_time)) <= 2 and ot[:16] == open_time[:16]: - return row - return best or {} - - -def _holding_minutes(open_time: str, close_time: str) -> int: - try: - from app import holding_to_minutes - return int(holding_to_minutes(open_time, close_time) or 0) - except Exception: - return 0 - - -def sync_trade_logs_from_ctp( - conn, - mode: str, - *, - capital: float = 0.0, - trading_mode: str = "simulation", -) -> dict[str, Any]: - """查询 CTP 成交并 upsert 到 trade_logs。返回同步摘要。""" - stats = {"synced": 0, "updated": 0, "skipped": 0, "connected": False} - if not ctp_status(mode).get("connected"): - return stats - stats["connected"] = True - ensure_trade_log_columns(conn) - try: - conn.execute("ALTER TABLE trade_logs ADD COLUMN source TEXT DEFAULT 'local'") - except Exception: - pass - try: - conn.execute("ALTER TABLE trade_logs ADD COLUMN ctp_trade_key TEXT") - except Exception: - pass - - trades = ctp_list_trades(mode, refresh=True) - trips = build_round_trips(trades) - for trip in trips: - key = trip.get("ctp_trade_key") or "" - if not key: - stats["skipped"] += 1 - continue - existing = conn.execute( - "SELECT id FROM trade_logs WHERE ctp_trade_key=?", - (key,), - ).fetchone() - - ths = trip.get("ths_code") or trip.get("symbol") or "" - codes = ths_to_codes(ths) or {} - direction = trip.get("direction") or "long" - entry = float(trip.get("entry_price") or 0) - close_px = float(trip.get("close_price") or 0) - lots = float(trip.get("lots") or 0) - open_time = trip.get("open_time") or "" - close_time = trip.get("close_time") or datetime.now(TZ).strftime("%Y-%m-%dT%H:%M") - - mon = _find_monitor_meta( - conn, - symbol=trip.get("symbol") or ths, - direction=direction, - open_time=open_time, - ) - sl = mon.get("stop_loss") - tp = mon.get("take_profit") - try: - sl_f = float(sl) if sl is not None else entry - tp_f = float(tp) if tp is not None else entry - except (TypeError, ValueError): - sl_f, tp_f = entry, entry - - metrics = calc_position_metrics( - direction, entry, sl_f, tp_f, lots, close_px, capital, ths, - ) - pnl = float(metrics.get("float_pnl") or 0) - fee = calc_round_trip_fee( - ths, entry, close_px, lots, open_time, close_time, trading_mode=trading_mode, - ) - pnl_net = round(pnl - fee, 2) - margin_pct = metrics.get("position_pct") - equity_after = calc_equity_after(capital, pnl_net) - minutes = _holding_minutes(open_time, close_time) - result = "CTP同步" - monitor_type = mon.get("monitor_type") or "CTP同步" - - row_vals = ( - ths, - codes.get("name") or mon.get("symbol_name") or ths, - codes.get("market_code") or mon.get("market_code") or "", - codes.get("sina_code") or mon.get("sina_code") or "", - monitor_type, - direction, - entry, - sl if sl is not None else None, - tp if tp is not None else None, - close_px, - lots, - metrics.get("margin"), - margin_pct, - minutes, - open_time, - close_time, - pnl, - fee, - pnl_net, - equity_after, - result, - ) - if existing: - conn.execute( - """UPDATE trade_logs SET - symbol=?, symbol_name=?, market_code=?, sina_code=?, monitor_type=?, - direction=?, entry_price=?, stop_loss=?, take_profit=?, close_price=?, - lots=?, margin=?, margin_pct=?, holding_minutes=?, open_time=?, close_time=?, - pnl=?, fee=?, pnl_net=?, equity_after=?, result=?, source='ctp', verified=1 - WHERE ctp_trade_key=?""", - row_vals + (key,), - ) - stats["updated"] += 1 - else: - conn.execute( - """INSERT INTO trade_logs - (symbol, symbol_name, market_code, sina_code, monitor_type, direction, - entry_price, stop_loss, take_profit, close_price, lots, margin, - margin_pct, holding_minutes, open_time, close_time, pnl, fee, pnl_net, - equity_after, result, source, ctp_trade_key, verified) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - row_vals + ("ctp", key, 1), - ) - stats["synced"] += 1 - - if stats["synced"] or stats["updated"]: - try: - from stats_engine import refresh_stats_cache - refresh_stats_cache(conn, capital) - except Exception as exc: - logger.debug("stats refresh after ctp trade sync: %s", exc) - return stats +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""从 CTP 柜台同步成交,写入 trade_logs(以交易所成交为准)。""" +from __future__ import annotations + +import logging +from collections import defaultdict +from datetime import datetime +from typing import Any, Callable, Optional +from zoneinfo import ZoneInfo + +from contract_specs import calc_position_metrics +from ctp_symbol import ths_to_vnpy_symbol +from fee_specs import calc_round_trip_fee +from symbols import ths_to_codes +from trade_log_lib import calc_equity_after, ensure_trade_log_columns +from vnpy_bridge import ctp_list_trades, ctp_status + +logger = logging.getLogger(__name__) +TZ = ZoneInfo("Asia/Shanghai") + + +def _match_symbol(ctp_sym: str, ths: str) -> bool: + a = (ctp_sym or "").lower() + b = (ths or "").lower() + if a == b: + return True + if a and b and a.split(".")[0] == b.split(".")[0]: + return True + try: + vnpy_sym, _ = ths_to_vnpy_symbol(ths) + if a == vnpy_sym.lower(): + return True + except Exception: + pass + return False + + +def _to_ths_code(symbol: str) -> str: + sym = (symbol or "").strip() + if not sym: + return "" + codes = ths_to_codes(sym) + if codes: + return codes.get("ths_code") or sym + return sym.lower() + + +def build_round_trips(trades: list[dict[str, Any]]) -> list[dict[str, Any]]: + """按 FIFO 将开/平仓成交配对为完整回合。""" + stacks: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list) + trips: list[dict[str, Any]] = [] + + ordered = sorted( + trades, + key=lambda t: ((t.get("datetime") or ""), str(t.get("trade_id") or "")), + ) + for t in ordered: + sym = (t.get("symbol") or "").lower() + pos_dir = (t.get("position_direction") or "long").strip().lower() + offset = (t.get("offset") or "open").strip().lower() + lots = int(t.get("lots") or 0) + if not sym or lots <= 0: + continue + key = (sym, pos_dir) + if offset == "open": + stacks[key].append({ + **t, + "remaining": lots, + }) + continue + + close_lots_left = lots + close_price = float(t.get("price") or 0) + close_time = t.get("datetime") or "" + close_trade_id = str(t.get("trade_id") or "") + while close_lots_left > 0 and stacks[key]: + open_t = stacks[key][0] + matched = min(close_lots_left, int(open_t.get("remaining") or 0)) + if matched <= 0: + stacks[key].pop(0) + continue + open_t["remaining"] = int(open_t.get("remaining") or 0) - matched + if open_t["remaining"] <= 0: + stacks[key].pop(0) + close_lots_left -= matched + open_trade_id = str(open_t.get("trade_id") or "") + ctp_key = f"{open_trade_id}|{close_trade_id}|{sym}|{pos_dir}|{matched}" + trips.append({ + "ctp_trade_key": ctp_key, + "symbol": sym, + "ths_code": _to_ths_code(sym), + "direction": pos_dir, + "lots": matched, + "entry_price": float(open_t.get("price") or 0), + "close_price": close_price, + "open_time": open_t.get("datetime") or "", + "close_time": close_time, + "open_trade_id": open_trade_id, + "close_trade_id": close_trade_id, + }) + return trips + + +def _find_monitor_meta( + conn, + *, + symbol: str, + direction: str, + open_time: str, + match_symbol_fn: Callable[[str, str], bool] | None = None, +) -> dict[str, Any]: + match = match_symbol_fn or _match_symbol + direction = (direction or "long").strip().lower() + best: Optional[dict[str, Any]] = None + for r in conn.execute( + "SELECT * FROM trade_order_monitors ORDER BY id DESC LIMIT 200" + ).fetchall(): + row = dict(r) + if (row.get("direction") or "long").strip().lower() != direction: + continue + if not match(symbol, row.get("symbol") or ""): + continue + if best is None: + best = row + continue + ot = (row.get("open_time") or "").strip() + if open_time and ot and abs(len(ot) - len(open_time)) <= 2 and ot[:16] == open_time[:16]: + return row + return best or {} + + +def _holding_minutes(open_time: str, close_time: str) -> int: + try: + from app import holding_to_minutes + return int(holding_to_minutes(open_time, close_time) or 0) + except Exception: + return 0 + + +def sync_trade_logs_from_ctp( + conn, + mode: str, + *, + capital: float = 0.0, + trading_mode: str = "simulation", +) -> dict[str, Any]: + """查询 CTP 成交并 upsert 到 trade_logs。返回同步摘要。""" + stats = {"synced": 0, "updated": 0, "skipped": 0, "connected": False} + if not ctp_status(mode).get("connected"): + return stats + stats["connected"] = True + ensure_trade_log_columns(conn) + try: + conn.execute("ALTER TABLE trade_logs ADD COLUMN source TEXT DEFAULT 'local'") + except Exception: + pass + try: + conn.execute("ALTER TABLE trade_logs ADD COLUMN ctp_trade_key TEXT") + except Exception: + pass + + trades = ctp_list_trades(mode, refresh=True) + trips = build_round_trips(trades) + for trip in trips: + key = trip.get("ctp_trade_key") or "" + if not key: + stats["skipped"] += 1 + continue + existing = conn.execute( + "SELECT id FROM trade_logs WHERE ctp_trade_key=?", + (key,), + ).fetchone() + + ths = trip.get("ths_code") or trip.get("symbol") or "" + codes = ths_to_codes(ths) or {} + direction = trip.get("direction") or "long" + entry = float(trip.get("entry_price") or 0) + close_px = float(trip.get("close_price") or 0) + lots = float(trip.get("lots") or 0) + open_time = trip.get("open_time") or "" + close_time = trip.get("close_time") or datetime.now(TZ).strftime("%Y-%m-%dT%H:%M") + + mon = _find_monitor_meta( + conn, + symbol=trip.get("symbol") or ths, + direction=direction, + open_time=open_time, + ) + sl = mon.get("stop_loss") + tp = mon.get("take_profit") + try: + sl_f = float(sl) if sl is not None else entry + tp_f = float(tp) if tp is not None else entry + except (TypeError, ValueError): + sl_f, tp_f = entry, entry + + metrics = calc_position_metrics( + direction, entry, sl_f, tp_f, lots, close_px, capital, ths, + ) + pnl = float(metrics.get("float_pnl") or 0) + fee = calc_round_trip_fee( + ths, entry, close_px, lots, open_time, close_time, trading_mode=trading_mode, + ) + pnl_net = round(pnl - fee, 2) + margin_pct = metrics.get("position_pct") + equity_after = calc_equity_after(capital, pnl_net) + minutes = _holding_minutes(open_time, close_time) + result = "CTP同步" + monitor_type = mon.get("monitor_type") or "CTP同步" + + row_vals = ( + ths, + codes.get("name") or mon.get("symbol_name") or ths, + codes.get("market_code") or mon.get("market_code") or "", + codes.get("sina_code") or mon.get("sina_code") or "", + monitor_type, + direction, + entry, + sl if sl is not None else None, + tp if tp is not None else None, + close_px, + lots, + metrics.get("margin"), + margin_pct, + minutes, + open_time, + close_time, + pnl, + fee, + pnl_net, + equity_after, + result, + ) + if existing: + conn.execute( + """UPDATE trade_logs SET + symbol=?, symbol_name=?, market_code=?, sina_code=?, monitor_type=?, + direction=?, entry_price=?, stop_loss=?, take_profit=?, close_price=?, + lots=?, margin=?, margin_pct=?, holding_minutes=?, open_time=?, close_time=?, + pnl=?, fee=?, pnl_net=?, equity_after=?, result=?, source='ctp', verified=1 + WHERE ctp_trade_key=?""", + row_vals + (key,), + ) + stats["updated"] += 1 + else: + conn.execute( + """INSERT INTO trade_logs + (symbol, symbol_name, market_code, sina_code, monitor_type, direction, + entry_price, stop_loss, take_profit, close_price, lots, margin, + margin_pct, holding_minutes, open_time, close_time, pnl, fee, pnl_net, + equity_after, result, source, ctp_trade_key, verified) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + row_vals + ("ctp", key, 1), + ) + stats["synced"] += 1 + + if stats["synced"] or stats["updated"]: + try: + from stats_engine import refresh_stats_cache + refresh_stats_cache(conn, capital) + except Exception as exc: + logger.debug("stats refresh after ctp trade sync: %s", exc) + return stats diff --git a/db_conn.py b/db_conn.py index 4b2a2f5..737e701 100644 --- a/db_conn.py +++ b/db_conn.py @@ -1,44 +1,49 @@ -"""SQLite 连接统一配置(WAL + busy_timeout,降低并发锁冲突)。""" -from __future__ import annotations - -import os -import sqlite3 -import time - -DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "futures.db") - - -def connect_db(path: str | None = None) -> sqlite3.Connection: - db_path = path or DB_PATH - conn = sqlite3.connect(db_path, timeout=30, check_same_thread=False) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA busy_timeout=30000") - try: - conn.execute("PRAGMA journal_mode=WAL") - except sqlite3.OperationalError: - pass - return conn - - -def execute_retry( - conn: sqlite3.Connection, - sql: str, - params: tuple = (), - *, - retries: int = 6, - base_delay: float = 0.05, -) -> sqlite3.Cursor: - """遇 database is locked 时短暂退避重试。""" - last_exc: Exception | None = None - for attempt in range(retries): - try: - return conn.execute(sql, params) - except sqlite3.OperationalError as exc: - if "locked" not in str(exc).lower(): - raise - last_exc = exc - if attempt < retries - 1: - time.sleep(base_delay * (attempt + 1)) - if last_exc: - raise last_exc - raise sqlite3.OperationalError("database is locked") +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""SQLite 连接统一配置(WAL + busy_timeout,降低并发锁冲突)。""" +from __future__ import annotations + +import os +import sqlite3 +import time + +DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "futures.db") + + +def connect_db(path: str | None = None) -> sqlite3.Connection: + db_path = path or DB_PATH + conn = sqlite3.connect(db_path, timeout=30, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout=30000") + try: + conn.execute("PRAGMA journal_mode=WAL") + except sqlite3.OperationalError: + pass + return conn + + +def execute_retry( + conn: sqlite3.Connection, + sql: str, + params: tuple = (), + *, + retries: int = 6, + base_delay: float = 0.05, +) -> sqlite3.Cursor: + """遇 database is locked 时短暂退避重试。""" + last_exc: Exception | None = None + for attempt in range(retries): + try: + return conn.execute(sql, params) + except sqlite3.OperationalError as exc: + if "locked" not in str(exc).lower(): + raise + last_exc = exc + if attempt < retries - 1: + time.sleep(base_delay * (attempt + 1)) + if last_exc: + raise last_exc + raise sqlite3.OperationalError("database is locked") diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 3e97d51..5db9d52 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -199,7 +199,7 @@ pm2 restart qihuo 1. 浏览器登录 → **系统设置** 确认 **模拟盘 · SimNow** 2. 打开 **下单监控** 页 → 点击 **连接 CTP** -3. 连接成功后:权益来自柜台、显示 CTP 持仓、可报单与品种推荐 +3. 连接成功后:权益来自柜台、显示 CTP 持仓、可报单与可开仓品种筛选 详见 [TRADING.md](./TRADING.md)。 @@ -399,8 +399,8 @@ pm2 restart qihuo /opt/qihuo/ ├── app.py ├── vnpy_bridge.py # CTP 执行层 -├── recommend_store.py # 品种推荐缓存 -├── recommend_stream.py # 品种推荐 SSE 推送 +├── recommend_store.py # 可开仓品种缓存 +├── recommend_stream.py # 可开仓品种 SSE 推送 ├── venv/ ├── futures.db ├── .env diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 18d302f..431a856 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -46,7 +46,7 @@ ### 期货下单 -- 品种联想(仅推荐可开品种或全部主力,取决于计仓模式) +- 品种联想(仅列出可开仓品种或全部主力,取决于计仓模式) - 方向、手数(固定手数 / 固定金额计仓) - 限价 / 市价(FAK)、止盈、止损 - 非交易时段禁止报单 @@ -58,9 +58,9 @@ - 持仓卡片:浮盈亏、保证金、止盈止损、平仓等 - 数据经 SSE 推送,无需整页刷新 -### 品种推荐 +### 可开仓品种 -- 按当前权益与保证金上限筛选可开品种 +- 按当前权益与保证金上限筛选可开品种,养成开仓纪律、限制仓位 - **行业分类**、走势(多头/空头/震荡/转多/转空)、跳空、昨日成交量(手)、成交额 - 支持行业筛选与多字段排序 - 每日后台刷新缓存 @@ -184,7 +184,7 @@ | 计仓模式 | 固定手数、固定金额 | | 保证金上限、移动保本、挂单超时 | 见表单说明 | | CTP 连接 | SimNow / 实盘前置与账号(可覆盖 `.env`) | -| 参考资金 | CTP 未连接时用于推荐与估算 | +| 参考资金 | CTP 未连接时用于可开仓筛选与估算 | | 企业微信 Webhook | 计划/关键位推送 | | 修改密码 | 管理员密码 | | 深色/浅色主题 | 页头切换 | @@ -224,7 +224,7 @@ | `review_records` | 复盘 | | `trade_records` | 计划自动止盈止损记录 | | `fee_rates` | 手续费缓存 | -| `product_recommend_cache` | 品种推荐缓存 | +| `product_recommend_cache` | 可开仓品种缓存 | | `stats_cache` | 统计缓存 | 数据库文件:项目根目录 `futures.db`。 @@ -236,7 +236,7 @@ | 任务 | 说明 | |------|------| | 计划/关键位轮询 | 约 3 秒,触发判断与微信推送 | -| 品种推荐刷新 | 每日 + 按需 | +| 可开仓品种刷新 | 每日 + 按需 | | 持仓 SSE | 前端订阅 `/api/trading/stream` | | CTP 开盘前连接 | 默认开盘前 30 分钟 | | 挂单超时撤单 | 可配置分钟数 | @@ -249,10 +249,10 @@ ``` qihuo/ ├── app.py # 主路由、计划/关键位/记录/统计 -├── install_trading.py # 下单、推荐、策略路由 +├── install_trading.py # 下单、可开仓品种、策略路由 ├── vnpy_bridge.py # CTP 连接、报单、持仓 ├── ctp_trade_sync.py # 柜台成交同步到 trade_logs -├── product_recommend.py # 品种推荐计算 +├── product_recommend.py # 可开仓品种计算 ├── stats_engine.py # 统计分析 ├── fee_specs.py / ctp_fee_sync.py ├── market.py / kline_chart.py diff --git a/docs/SIMNOW.md b/docs/SIMNOW.md index eec5f13..5666b6e 100644 --- a/docs/SIMNOW.md +++ b/docs/SIMNOW.md @@ -156,7 +156,7 @@ pm2 restart qihuo 4. 点击 **连接 CTP** 5. 顶栏显示 **CTP 已连接**,权益变为 SimNow 账户资金即成功 -连接成功后:下单、持仓、浮盈均来自 SimNow 柜台;**系统设置里的「参考资金」不再用于交易**,仅 CTP 未连接时用于品种推荐与以损定仓估算。 +连接成功后:下单、持仓、浮盈均来自 SimNow 柜台;**系统设置里的「参考资金」不再用于交易**,仅 CTP 未连接时用于可开仓品种筛选与以损定仓估算。 --- diff --git a/docs/TRADING.md b/docs/TRADING.md index e775a24..986350e 100644 --- a/docs/TRADING.md +++ b/docs/TRADING.md @@ -9,9 +9,9 @@ | 顶栏 | 交易模式、CTP 状态、权益/可用、连接 CTP | | 期货下单 | 限价/市价报单、止盈止损、以损定仓/固定手数 | | 当前持仓 | CTP 持仓卡片、挂单中、撤单、平仓 | -| 品种推荐 | 按权益筛选、行业分类、走势/跳空/成交量排序 | +| 可开仓品种 | 按权益与保证金上限筛选、行业分类、走势/跳空/成交量排序 | -`/trade`、`/recommend` 均重定向到 `/positions`(推荐锚点 `#recommend`)。 +`/trade`、`/recommend` 均重定向到 `/positions`(可开仓品种锚点 `#recommend`)。 ## 两种交易通道 @@ -29,9 +29,10 @@ - **平仓**:程序平仓写入 `trade_logs`(来源「本地」) - **持仓数据**:SSE `/api/trading/stream` 推送,约 1 秒刷新 -## 品种推荐 +## 可开仓品种 -- 每日后台刷新可开品种列表(`/api/recommend/stream`) +- 用于开仓纪律与仓位限制:按保证金上限计算最大手数,仅展示当前权益下可开的品种 +- 每日后台刷新列表(`/api/recommend/stream`) - 最大手数 = floor(权益 × 保证金上限 ÷ 1 手保证金) - 展示近一周日线走势、跳空、昨日成交量(手)、成交额 - 可按 **行业** 筛选,支持多字段排序 @@ -47,7 +48,7 @@ ## 参考资金 -系统设置中的「参考资金」仅在 **CTP 未连接** 时用于品种推荐与以损定仓估算;连接后自动改用柜台权益。 +系统设置中的「参考资金」仅在 **CTP 未连接** 时用于可开仓品种筛选与以损定仓估算;连接后自动改用柜台权益。 ## 首次使用 SimNow @@ -65,8 +66,8 @@ | `POST /api/trading/order/cancel` | 撤单(交易时段) | | `POST /api/trading/close` | 平仓 | | `GET /api/trading/stream` | 持仓 SSE | -| `GET /api/recommend/list` | 品种推荐 JSON | -| `GET /api/recommend/stream` | 品种推荐 SSE | +| `GET /api/recommend/list` | 可开仓品种 JSON | +| `GET /api/recommend/stream` | 可开仓品种 SSE | | `POST /api/strategy/trend/execute` | 执行趋势策略 | 详见 [DEPLOY.md](./DEPLOY.md) 中 CTP 故障排查。 diff --git a/docs/软件购买与使用协议.md b/docs/软件购买与使用协议.md new file mode 100644 index 0000000..a2971df --- /dev/null +++ b/docs/软件购买与使用协议.md @@ -0,0 +1,185 @@ +# 软件购买与使用协议(个人版) + +> **说明**:本协议为个人购买者使用模板。正式交付时可打印或转为 PDF,由双方签字/确认。 +> 本模板不构成法律意见;金额较大或机构/共享交易室合作,建议由执业律师审阅后使用。 + +--- + +**协议编号**:_______________ +**签订日期**:_______________ + +--- + +## 甲方(著作权人 / 许可方) + +- **姓名**:马建军 +- **联系电话**:18364911125 +- **微信**:dekun03 + +## 乙方(被许可方 / 购买方) + +- **姓名**:_______________ +- **联系电话**:_______________ +- **微信/邮箱**:_______________ + +--- + +## 第一条 软件与交付内容 + +1.1 甲方向乙方提供的软件名称为 **「国内期货交易监控复盘系统」**(以下简称「本软件」),包括甲方交付时约定版本的源代码、部署说明及必要配置指导。 + +1.2 **交付方式**(勾选适用项): + +- [ ] 部署服务:甲方协助乙方在乙方指定服务器完成安装与基础配置 +- [ ] 源代码:甲方提供约定版本源代码(Git 归档 / 压缩包 / 私有仓库只读权限,择一填写:_______________) +- [ ] 其他:_______________ + +1.3 **交付版本标识**(建议填写 Git 提交号或日期):_______________ + +--- + +## 第二条 授权范围 + +2.1 甲方授予乙方 **非独占、不可转让、不可再许可** 的个人使用许可。 + +2.2 乙方仅可将本软件部署在 **乙方本人名下单一实例**(一台 VPS 或一台个人电脑服务器,二选一或填写:_______________),供 **乙方本人** 用于个人期货交易的纪律管理、记录与复盘。 + +2.3 本授权 **不包括** 以下权利(须另行书面协议并支付费用): + +- 共享交易室、培训室、跟单室等多人共用或对外经营 +- 白标、OEM、二次分发、转售源码 +- 将本软件作为带单、荐品种、配资等业务的工具或平台 + +--- + +## 第三条 严禁用途(乙方承诺) + +乙方承诺 **不得** 利用本软件从事以下行为: + +1. **带单、代客理财、代客下单、信号群喊单、跟单服务** 等可能违反期货监管及咨询资质要求的行为; +2. **向他人推荐、介绍特定期货品种、合约或具体买卖方向**,并以此向他人收费或获利; +3. **融资、配资、分仓、对赌、非法吸收资金** 等资金融通或变相配资行为; +4. **复制、传播、转售、出租、出借** 源代码或部署包给任何第三方; +5. **删除、篡改** 软件内或文档中的版权声明与许可说明; +6. 其他违反中国法律法规及期货监管规定的行为。 + +乙方违反本条,甲方有权 **立即终止许可**;乙方已付费用 **不予退还**(法律另有强制性规定的除外)。因乙方违规导致甲方损失的,乙方应依法赔偿。 + +--- + +## 第四条 费用与支付 + +4.1 乙方应向甲方支付: + +| 项目 | 金额(元) | 备注 | +|------|------------|------| +| 部署服务费 | | | +| 源代码许可费 | | | +| 其他 | | | +| **合计** | | | + +4.2 支付方式:_______________ +4.3 甲方收到约定款项后 ___ 个工作日内完成交付(或双方另行约定)。 + +--- + +## 第五条 更新、维护与支持 + +5.1 **版本更新**(勾选): + +- [ ] 本次交付为固定版本,后续大版本更新需 **另行付费** +- [ ] 含 ___ 个月内的缺陷修复与小版本更新(不含新功能模块) +- [ ] 其他:_______________ + +5.2 支持方式与范围:_______________(如:微信答疑、远程协助次数等)。 +5.3 超出约定范围的支持,双方可 **另行协商费用**。 + +--- + +## 第六条 知识产权 + +6.1 本软件之著作权及其他知识产权 **均归甲方所有**。乙方仅获得本协议第二条约定之 **有限使用权**,不取得著作权转让或共有。 + +6.2 乙方可在本协议授权范围内备份源代码供 **自用**,不得用于再分发。 + +--- + +## 第七条 免责声明与风险提示 + +7.1 本软件为 **交易纪律与记录辅助工具**,不提供投资咨询,不构成任何 **投资建议、收益承诺或交易信号**。 + +7.2 **期货交易风险极大**,乙方须具备相应风险承受能力,独立作出交易决策,盈亏由乙方 **自行承担**。 + +7.3 因 CTP/SimNow/网络/服务器/第三方接口故障、断线、延迟等导致的数据偏差、下单失败或损失,甲方在已尽合理交付与说明义务的前提下, **不承担** 由此产生的交易损失(法律强制性规定除外)。 + +7.4 甲方不保证软件持续符合某一交易所或期货公司的全部最新规则;监管或接口变化时,乙方应配合升级或调整配置。 + +--- + +## 第八条 责任限制 + +8.1 除因甲方 **故意或重大过失** 直接导致乙方人身或财产损害的情形外,甲方对乙方因使用或无法使用本软件产生的 **间接损失、交易亏损、数据丢失、业务中断** 等不承担责任。 + +8.2 在任何情况下,甲方对乙方的 **累计赔偿责任** 不超过乙方就本协议 **实际已支付给甲方的费用总额**(法律强制性规定除外)。 + +--- + +## 第九条 保密 + +9.1 乙方对交付的 **未公开源代码、部署文档、配置信息** 负有保密义务,不得向无关第三方披露,法律法规或监管要求除外。 + +9.2 保密期限:许可终止后 **三(3)年** 内仍有效(源代码本身仍不得非法传播)。 + +--- + +## 第十条 协议期限与终止 + +10.1 本协议自双方签字/确认之日起生效。个人使用许可为 **长期有效**,直至依本条终止。 + +10.2 有下列情形之一的,甲方有权终止许可,乙方应停止使用并销毁多余副本(保留一份备份法律允许的范围内自用备份除外): + +- 乙方违反第三条严禁用途或第二条授权范围; +- 乙方非法转售、传播源码; +- 乙方从事违法经营活动并使用本软件。 + +10.3 终止后,乙方 **不得** 继续使用本软件开展新业务;已产生的法律责任不因终止而免除。 + +--- + +## 第十一条 争议解决 + +11.1 本协议之订立、效力、解释、履行及争议解决均适用 **中华人民共和国法律**。 + +11.2 双方因本协议发生争议,应先友好协商;协商不成的,任一方可向 **甲方住所地有管辖权的人民法院** 提起诉讼。 + +--- + +## 第十二条 其他 + +12.1 本协议与仓库根目录 `LICENSE.zh-CN.txt` 内容不一致的, **以本协议为准**(仅针对甲乙双方之间)。 + +12.2 本协议一式两份,甲乙双方各执一份,具有同等效力(电子确认、微信确认截图与纸质同等有效,双方认可时)。 + +12.3 未尽事宜,双方可签订 **补充协议**;补充协议与本协议具有同等效力。 + +--- + +## 签署栏 + +**甲方(许可方)** + +签名:_______________ +日期:_______________ + +**乙方(被许可方)** + +签名:_______________ +日期:_______________ + +--- + +## 附件(可选) + +- [ ] 交付清单(版本号、文件列表、服务器信息) +- [ ] 部署完成确认单 +- [ ] 乙方身份证复印件(线下签约时) diff --git a/fee_specs.py b/fee_specs.py index 2853c34..16c76f3 100644 --- a/fee_specs.py +++ b/fee_specs.py @@ -1,380 +1,385 @@ -"""期货手续费:仅 CTP 柜台同步入库,前端只读展示。""" -import json -import os -import re -import sqlite3 -from datetime import datetime -from typing import Optional - -from contract_specs import get_contract_spec - -from db_conn import connect_db - -DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "futures.db") -DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") -DEFAULT_JSON = os.path.join(DATA_DIR, "fee_rates.json") - -# 无配置时的兜底(已为交易所标准约 2 倍) -DEFAULT_FEE = { - "open_fixed": 2.0, - "open_ratio": 0.0, - "close_yesterday_fixed": 2.0, - "close_yesterday_ratio": 0.0, - "close_today_fixed": 4.0, - "close_today_ratio": 0.0, -} - -_INDEX_PRODUCTS = {"if", "ih", "ic", "im"} - - -def product_from_code(ths_code: str) -> str: - code = (ths_code or "").strip() - m = re.match(r"^([A-Za-z]+)", code) - return m.group(1).lower() if m else "" - - -def _get_db(): - return connect_db() - - -def ensure_fee_rates_schema(conn=None) -> None: - """补齐 fee_rates 表结构(旧库可能缺少 source 列)。""" - close = False - if conn is None: - conn = _get_db() - close = True - try: - for sql in ( - "ALTER TABLE fee_rates ADD COLUMN source TEXT DEFAULT 'local'", - ): - try: - conn.execute(sql) - except sqlite3.OperationalError: - pass - conn.commit() - finally: - if close: - conn.close() - - -def get_setting(key: str, default: str = "") -> str: - conn = _get_db() - row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() - conn.close() - if not row: - return default - return (row["value"] or default) if row["value"] is not None else default - - -def set_setting(key: str, value: str) -> None: - conn = _get_db() - conn.execute( - """INSERT INTO settings (key, value) VALUES (?,?) - ON CONFLICT(key) DO UPDATE SET value=excluded.value""", - (key, value), - ) - conn.commit() - conn.close() - - -def get_fee_multiplier() -> float: - conn = _get_db() - row = conn.execute( - "SELECT value FROM settings WHERE key='fee_multiplier'" - ).fetchone() - conn.close() - if row and row["value"]: - try: - return max(0.0, float(row["value"])) - except ValueError: - pass - return 2.0 - - -def get_fee_source_mode() -> str: - """固定 CTP 柜台。""" - return "ctp" - - -def purge_non_ctp_fee_rates() -> int: - """删除非 CTP 来源的费率缓存。""" - conn = _get_db() - cur = conn.execute( - "DELETE FROM fee_rates WHERE COALESCE(source, '') != 'ctp'" - ) - n = cur.rowcount - conn.commit() - conn.close() - return n - - -def _row_to_spec(row, mult: int) -> dict: - return { - "product": row["product"], - "exchange": row["exchange"] or "", - "mult": int(row["mult"] or mult), - "open_fixed": float(row["open_fixed"] or 0), - "open_ratio": float(row["open_ratio"] or 0), - "close_yesterday_fixed": float(row["close_yesterday_fixed"] or 0), - "close_yesterday_ratio": float(row["close_yesterday_ratio"] or 0), - "close_today_fixed": float(row["close_today_fixed"] or 0), - "close_today_ratio": float(row["close_today_ratio"] or 0), - "source": row["source"] if "source" in row.keys() else "local", - } - - -def get_fee_spec(ths_code: str, *, trading_mode: str = "simulation") -> dict: - product = product_from_code(ths_code) - if not product: - spec = get_contract_spec(ths_code) - return {**DEFAULT_FEE, "mult": spec["mult"], "product": "", "exchange": "", "source": "default"} - - mult = get_contract_spec(ths_code)["mult"] - conn = _get_db() - ensure_fee_rates_schema(conn) - row = conn.execute( - "SELECT * FROM fee_rates WHERE product=? AND source='ctp'", - (product,), - ).fetchone() - conn.close() - if row: - return _row_to_spec(row, mult) - try: - from ctp_fee_sync import sync_fee_for_symbol - fields = sync_fee_for_symbol(trading_mode, ths_code) - if fields: - return {"product": product, **fields} - except Exception: - pass - - if product in _INDEX_PRODUCTS: - return { - "product": product, - "exchange": "CFFEX", - "mult": mult, - "open_fixed": 0.0, - "open_ratio": 0.000092, - "close_yesterday_fixed": 0.0, - "close_yesterday_ratio": 0.000092, - "close_today_fixed": 0.0, - "close_today_ratio": 0.000276, - } - - return { - "product": product, - "exchange": "", - "mult": mult, - **DEFAULT_FEE, - "source": "default", - } - - -def calc_side_fee( - price: float, - lots: float, - mult: int, - fixed: float, - ratio: float, -) -> float: - lots = lots or 1.0 - fixed = fixed or 0.0 - ratio = ratio or 0.0 - return fixed * lots + ratio * price * mult * lots - - -def is_same_day(open_time: str, close_time: str) -> bool: - if not open_time or not close_time: - return True - o = open_time.strip().replace(" ", "T")[:10] - c = close_time.strip().replace(" ", "T")[:10] - return o == c - - -def calc_round_trip_fee( - ths_code: str, - entry_price: float, - close_price: float, - lots: float, - open_time: str = "", - close_time: str = "", - trading_mode: str = "simulation", -) -> float: - if not entry_price or not close_price: - return 0.0 - spec = get_fee_spec(ths_code, trading_mode=trading_mode) - mult = spec["mult"] - lots = lots or 1.0 - - open_fee = calc_side_fee( - entry_price, lots, mult, - spec["open_fixed"], spec["open_ratio"], - ) - if is_same_day(open_time, close_time): - close_fee = calc_side_fee( - close_price, lots, mult, - spec["close_today_fixed"], spec["close_today_ratio"], - ) - else: - close_fee = calc_side_fee( - close_price, lots, mult, - spec["close_yesterday_fixed"], spec["close_yesterday_ratio"], - ) - return round(open_fee + close_fee, 2) - - -def calc_fee_breakdown( - ths_code: str, - entry_price: float, - close_price: float, - lots: float, - open_time: str = "", - close_time: str = "", - trading_mode: str = "simulation", -) -> dict: - spec = get_fee_spec(ths_code, trading_mode=trading_mode) - mult = spec["mult"] - lots = lots or 1.0 - open_fee = calc_side_fee( - entry_price, lots, mult, spec["open_fixed"], spec["open_ratio"], - ) - same_day = is_same_day(open_time, close_time) - if same_day: - close_fee = calc_side_fee( - close_price, lots, mult, - spec["close_today_fixed"], spec["close_today_ratio"], - ) - close_type = "平今" - else: - close_fee = calc_side_fee( - close_price, lots, mult, - spec["close_yesterday_fixed"], spec["close_yesterday_ratio"], - ) - close_type = "平昨" - total = round(open_fee + close_fee, 2) - return { - "open_fee": round(open_fee, 2), - "close_fee": round(close_fee, 2), - "close_type": close_type, - "total_fee": total, - "same_day": same_day, - "fee_source": spec.get("source", "local"), - } - - -def load_fee_rates_from_json(path: Optional[str] = None) -> int: - path = path or DEFAULT_JSON - if not os.path.isfile(path): - return 0 - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - conn = _get_db() - now = datetime.now().isoformat(timespec="seconds") - count = 0 - for product, item in data.items(): - if not isinstance(item, dict): - continue - conn.execute( - """INSERT INTO fee_rates - (product, exchange, mult, - open_fixed, open_ratio, - close_yesterday_fixed, close_yesterday_ratio, - close_today_fixed, close_today_ratio, updated_at, source) - VALUES (?,?,?,?,?,?,?,?,?,?,?) - ON CONFLICT(product) DO UPDATE SET - exchange=excluded.exchange, mult=excluded.mult, - open_fixed=excluded.open_fixed, open_ratio=excluded.open_ratio, - close_yesterday_fixed=excluded.close_yesterday_fixed, - close_yesterday_ratio=excluded.close_yesterday_ratio, - close_today_fixed=excluded.close_today_fixed, - close_today_ratio=excluded.close_today_ratio, - updated_at=excluded.updated_at, - source=excluded.source""", - ( - product.lower(), - item.get("exchange", ""), - int(item.get("mult") or get_contract_spec(product)["mult"]), - float(item.get("open_fixed") or 0), - float(item.get("open_ratio") or 0), - float(item.get("close_yesterday_fixed") or 0), - float(item.get("close_yesterday_ratio") or 0), - float(item.get("close_today_fixed") or 0), - float(item.get("close_today_ratio") or 0), - now, - item.get("source", "json"), - ), - ) - count += 1 - conn.commit() - conn.close() - return count - - -def list_ctp_fee_rates() -> list: - """手续费页:仅展示 CTP 同步结果。""" - conn = _get_db() - rows = conn.execute( - "SELECT * FROM fee_rates WHERE source='ctp' ORDER BY product" - ).fetchall() - conn.close() - return [dict(r) for r in rows] - - -def list_all_fee_rates() -> list: - conn = _get_db() - rows = conn.execute( - "SELECT * FROM fee_rates ORDER BY product" - ).fetchall() - conn.close() - return [dict(r) for r in rows] - - -def list_fee_rates_for_ui() -> list: - return list_ctp_fee_rates() - - -def count_fee_rates_by_source() -> dict[str, int]: - conn = _get_db() - n = conn.execute( - "SELECT COUNT(*) FROM fee_rates WHERE source='ctp'" - ).fetchone()[0] - conn.close() - return {"ctp": int(n or 0)} - - -def upsert_fee_rate(product: str, fields: dict) -> None: - product = product.lower().strip() - conn = _get_db() - now = datetime.now().isoformat(timespec="seconds") - source = fields.get("source", "manual") - conn.execute( - """INSERT INTO fee_rates - (product, exchange, mult, - open_fixed, open_ratio, - close_yesterday_fixed, close_yesterday_ratio, - close_today_fixed, close_today_ratio, updated_at, source) - VALUES (?,?,?,?,?,?,?,?,?,?,?) - ON CONFLICT(product) DO UPDATE SET - exchange=excluded.exchange, mult=excluded.mult, - open_fixed=excluded.open_fixed, open_ratio=excluded.open_ratio, - close_yesterday_fixed=excluded.close_yesterday_fixed, - close_yesterday_ratio=excluded.close_yesterday_ratio, - close_today_fixed=excluded.close_today_fixed, - close_today_ratio=excluded.close_today_ratio, - updated_at=excluded.updated_at, - source=excluded.source""", - ( - product, - fields.get("exchange", ""), - int(fields.get("mult") or 10), - float(fields.get("open_fixed") or 0), - float(fields.get("open_ratio") or 0), - float(fields.get("close_yesterday_fixed") or 0), - float(fields.get("close_yesterday_ratio") or 0), - float(fields.get("close_today_fixed") or 0), - float(fields.get("close_today_ratio") or 0), - now, - source, - ), - ) - conn.commit() - conn.close() +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""期货手续费:仅 CTP 柜台同步入库,前端只读展示。""" +import json +import os +import re +import sqlite3 +from datetime import datetime +from typing import Optional + +from contract_specs import get_contract_spec + +from db_conn import connect_db + +DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "futures.db") +DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") +DEFAULT_JSON = os.path.join(DATA_DIR, "fee_rates.json") + +# 无配置时的兜底(已为交易所标准约 2 倍) +DEFAULT_FEE = { + "open_fixed": 2.0, + "open_ratio": 0.0, + "close_yesterday_fixed": 2.0, + "close_yesterday_ratio": 0.0, + "close_today_fixed": 4.0, + "close_today_ratio": 0.0, +} + +_INDEX_PRODUCTS = {"if", "ih", "ic", "im"} + + +def product_from_code(ths_code: str) -> str: + code = (ths_code or "").strip() + m = re.match(r"^([A-Za-z]+)", code) + return m.group(1).lower() if m else "" + + +def _get_db(): + return connect_db() + + +def ensure_fee_rates_schema(conn=None) -> None: + """补齐 fee_rates 表结构(旧库可能缺少 source 列)。""" + close = False + if conn is None: + conn = _get_db() + close = True + try: + for sql in ( + "ALTER TABLE fee_rates ADD COLUMN source TEXT DEFAULT 'local'", + ): + try: + conn.execute(sql) + except sqlite3.OperationalError: + pass + conn.commit() + finally: + if close: + conn.close() + + +def get_setting(key: str, default: str = "") -> str: + conn = _get_db() + row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() + conn.close() + if not row: + return default + return (row["value"] or default) if row["value"] is not None else default + + +def set_setting(key: str, value: str) -> None: + conn = _get_db() + conn.execute( + """INSERT INTO settings (key, value) VALUES (?,?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value""", + (key, value), + ) + conn.commit() + conn.close() + + +def get_fee_multiplier() -> float: + conn = _get_db() + row = conn.execute( + "SELECT value FROM settings WHERE key='fee_multiplier'" + ).fetchone() + conn.close() + if row and row["value"]: + try: + return max(0.0, float(row["value"])) + except ValueError: + pass + return 2.0 + + +def get_fee_source_mode() -> str: + """固定 CTP 柜台。""" + return "ctp" + + +def purge_non_ctp_fee_rates() -> int: + """删除非 CTP 来源的费率缓存。""" + conn = _get_db() + cur = conn.execute( + "DELETE FROM fee_rates WHERE COALESCE(source, '') != 'ctp'" + ) + n = cur.rowcount + conn.commit() + conn.close() + return n + + +def _row_to_spec(row, mult: int) -> dict: + return { + "product": row["product"], + "exchange": row["exchange"] or "", + "mult": int(row["mult"] or mult), + "open_fixed": float(row["open_fixed"] or 0), + "open_ratio": float(row["open_ratio"] or 0), + "close_yesterday_fixed": float(row["close_yesterday_fixed"] or 0), + "close_yesterday_ratio": float(row["close_yesterday_ratio"] or 0), + "close_today_fixed": float(row["close_today_fixed"] or 0), + "close_today_ratio": float(row["close_today_ratio"] or 0), + "source": row["source"] if "source" in row.keys() else "local", + } + + +def get_fee_spec(ths_code: str, *, trading_mode: str = "simulation") -> dict: + product = product_from_code(ths_code) + if not product: + spec = get_contract_spec(ths_code) + return {**DEFAULT_FEE, "mult": spec["mult"], "product": "", "exchange": "", "source": "default"} + + mult = get_contract_spec(ths_code)["mult"] + conn = _get_db() + ensure_fee_rates_schema(conn) + row = conn.execute( + "SELECT * FROM fee_rates WHERE product=? AND source='ctp'", + (product,), + ).fetchone() + conn.close() + if row: + return _row_to_spec(row, mult) + try: + from ctp_fee_sync import sync_fee_for_symbol + fields = sync_fee_for_symbol(trading_mode, ths_code) + if fields: + return {"product": product, **fields} + except Exception: + pass + + if product in _INDEX_PRODUCTS: + return { + "product": product, + "exchange": "CFFEX", + "mult": mult, + "open_fixed": 0.0, + "open_ratio": 0.000092, + "close_yesterday_fixed": 0.0, + "close_yesterday_ratio": 0.000092, + "close_today_fixed": 0.0, + "close_today_ratio": 0.000276, + } + + return { + "product": product, + "exchange": "", + "mult": mult, + **DEFAULT_FEE, + "source": "default", + } + + +def calc_side_fee( + price: float, + lots: float, + mult: int, + fixed: float, + ratio: float, +) -> float: + lots = lots or 1.0 + fixed = fixed or 0.0 + ratio = ratio or 0.0 + return fixed * lots + ratio * price * mult * lots + + +def is_same_day(open_time: str, close_time: str) -> bool: + if not open_time or not close_time: + return True + o = open_time.strip().replace(" ", "T")[:10] + c = close_time.strip().replace(" ", "T")[:10] + return o == c + + +def calc_round_trip_fee( + ths_code: str, + entry_price: float, + close_price: float, + lots: float, + open_time: str = "", + close_time: str = "", + trading_mode: str = "simulation", +) -> float: + if not entry_price or not close_price: + return 0.0 + spec = get_fee_spec(ths_code, trading_mode=trading_mode) + mult = spec["mult"] + lots = lots or 1.0 + + open_fee = calc_side_fee( + entry_price, lots, mult, + spec["open_fixed"], spec["open_ratio"], + ) + if is_same_day(open_time, close_time): + close_fee = calc_side_fee( + close_price, lots, mult, + spec["close_today_fixed"], spec["close_today_ratio"], + ) + else: + close_fee = calc_side_fee( + close_price, lots, mult, + spec["close_yesterday_fixed"], spec["close_yesterday_ratio"], + ) + return round(open_fee + close_fee, 2) + + +def calc_fee_breakdown( + ths_code: str, + entry_price: float, + close_price: float, + lots: float, + open_time: str = "", + close_time: str = "", + trading_mode: str = "simulation", +) -> dict: + spec = get_fee_spec(ths_code, trading_mode=trading_mode) + mult = spec["mult"] + lots = lots or 1.0 + open_fee = calc_side_fee( + entry_price, lots, mult, spec["open_fixed"], spec["open_ratio"], + ) + same_day = is_same_day(open_time, close_time) + if same_day: + close_fee = calc_side_fee( + close_price, lots, mult, + spec["close_today_fixed"], spec["close_today_ratio"], + ) + close_type = "平今" + else: + close_fee = calc_side_fee( + close_price, lots, mult, + spec["close_yesterday_fixed"], spec["close_yesterday_ratio"], + ) + close_type = "平昨" + total = round(open_fee + close_fee, 2) + return { + "open_fee": round(open_fee, 2), + "close_fee": round(close_fee, 2), + "close_type": close_type, + "total_fee": total, + "same_day": same_day, + "fee_source": spec.get("source", "local"), + } + + +def load_fee_rates_from_json(path: Optional[str] = None) -> int: + path = path or DEFAULT_JSON + if not os.path.isfile(path): + return 0 + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + conn = _get_db() + now = datetime.now().isoformat(timespec="seconds") + count = 0 + for product, item in data.items(): + if not isinstance(item, dict): + continue + conn.execute( + """INSERT INTO fee_rates + (product, exchange, mult, + open_fixed, open_ratio, + close_yesterday_fixed, close_yesterday_ratio, + close_today_fixed, close_today_ratio, updated_at, source) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(product) DO UPDATE SET + exchange=excluded.exchange, mult=excluded.mult, + open_fixed=excluded.open_fixed, open_ratio=excluded.open_ratio, + close_yesterday_fixed=excluded.close_yesterday_fixed, + close_yesterday_ratio=excluded.close_yesterday_ratio, + close_today_fixed=excluded.close_today_fixed, + close_today_ratio=excluded.close_today_ratio, + updated_at=excluded.updated_at, + source=excluded.source""", + ( + product.lower(), + item.get("exchange", ""), + int(item.get("mult") or get_contract_spec(product)["mult"]), + float(item.get("open_fixed") or 0), + float(item.get("open_ratio") or 0), + float(item.get("close_yesterday_fixed") or 0), + float(item.get("close_yesterday_ratio") or 0), + float(item.get("close_today_fixed") or 0), + float(item.get("close_today_ratio") or 0), + now, + item.get("source", "json"), + ), + ) + count += 1 + conn.commit() + conn.close() + return count + + +def list_ctp_fee_rates() -> list: + """手续费页:仅展示 CTP 同步结果。""" + conn = _get_db() + rows = conn.execute( + "SELECT * FROM fee_rates WHERE source='ctp' ORDER BY product" + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def list_all_fee_rates() -> list: + conn = _get_db() + rows = conn.execute( + "SELECT * FROM fee_rates ORDER BY product" + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def list_fee_rates_for_ui() -> list: + return list_ctp_fee_rates() + + +def count_fee_rates_by_source() -> dict[str, int]: + conn = _get_db() + n = conn.execute( + "SELECT COUNT(*) FROM fee_rates WHERE source='ctp'" + ).fetchone()[0] + conn.close() + return {"ctp": int(n or 0)} + + +def upsert_fee_rate(product: str, fields: dict) -> None: + product = product.lower().strip() + conn = _get_db() + now = datetime.now().isoformat(timespec="seconds") + source = fields.get("source", "manual") + conn.execute( + """INSERT INTO fee_rates + (product, exchange, mult, + open_fixed, open_ratio, + close_yesterday_fixed, close_yesterday_ratio, + close_today_fixed, close_today_ratio, updated_at, source) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(product) DO UPDATE SET + exchange=excluded.exchange, mult=excluded.mult, + open_fixed=excluded.open_fixed, open_ratio=excluded.open_ratio, + close_yesterday_fixed=excluded.close_yesterday_fixed, + close_yesterday_ratio=excluded.close_yesterday_ratio, + close_today_fixed=excluded.close_today_fixed, + close_today_ratio=excluded.close_today_ratio, + updated_at=excluded.updated_at, + source=excluded.source""", + ( + product, + fields.get("exchange", ""), + int(fields.get("mult") or 10), + float(fields.get("open_fixed") or 0), + float(fields.get("open_ratio") or 0), + float(fields.get("close_yesterday_fixed") or 0), + float(fields.get("close_yesterday_ratio") or 0), + float(fields.get("close_today_fixed") or 0), + float(fields.get("close_today_ratio") or 0), + now, + source, + ), + ) + conn.commit() + conn.close() diff --git a/fee_sync.py b/fee_sync.py index 2a16b7c..022b06b 100644 --- a/fee_sync.py +++ b/fee_sync.py @@ -1,86 +1,91 @@ -"""从第三方(AKShare)同步交易所参考手续费,并按倍率写入本地表。""" -import re -from typing import Any, Optional - -from contract_specs import get_contract_spec -from fee_specs import get_fee_multiplier, upsert_fee_rate - - -def _to_float(val: Any) -> float: - if val is None: - return 0.0 - s = str(val).strip().replace(",", "") - if not s or s in ("-", "None", "nan"): - return 0.0 - try: - return float(s) - except ValueError: - return 0.0 - - -def _parse_akshare_row(row: dict, multiplier: float) -> Optional[dict]: - code = str(row.get("合约代码") or row.get("代码") or "").strip() - if not code: - return None - m = re.match(r"^([A-Za-z]+)", code) - if not m: - return None - product = m.group(1).lower() - - open_ratio = _to_float(row.get("手续费标准-开仓-万分之")) / 10000.0 - open_fixed = _to_float(row.get("手续费标准-开仓-元")) - if open_fixed == 0 and row.get("开仓"): - open_fixed = _to_float(row.get("开仓")) - close_y_ratio = _to_float(row.get("手续费标准-平昨-万分之")) / 10000.0 - close_y_fixed = _to_float(row.get("手续费标准-平昨-元")) - if close_y_fixed == 0 and row.get("平昨"): - close_y_fixed = _to_float(row.get("平昨")) - close_t_ratio = _to_float(row.get("手续费标准-平今-万分之")) / 10000.0 - close_t_fixed = _to_float(row.get("手续费标准-平今-元")) - if close_t_fixed == 0 and row.get("平今"): - close_t_fixed = _to_float(row.get("平今")) - - mult = int(get_contract_spec(code)["mult"]) - exchange = str(row.get("交易所名称") or row.get("交易所") or "").strip() - - return { - "product": product, - "exchange": exchange, - "mult": mult, - "open_fixed": round(open_fixed * multiplier, 6), - "open_ratio": round(open_ratio * multiplier, 8), - "close_yesterday_fixed": round(close_y_fixed * multiplier, 6), - "close_yesterday_ratio": round(close_y_ratio * multiplier, 8), - "close_today_fixed": round(close_t_fixed * multiplier, 6), - "close_today_ratio": round(close_t_ratio * multiplier, 8), - "source": "akshare", - } - - -def sync_fees_from_akshare(multiplier: Optional[float] = None) -> tuple[int, str]: - multiplier = multiplier if multiplier is not None else get_fee_multiplier() - try: - import akshare as ak - except ImportError: - return 0, "未安装 akshare,请执行 pip install akshare 后重试,或使用默认费率表" - - try: - df = ak.futures_comm_info(symbol="所有") - except Exception as exc: - return 0, f"拉取第三方数据失败: {exc}" - - if df is None or df.empty: - return 0, "第三方返回空数据" - - seen: set[str] = set() - count = 0 - for _, series in df.iterrows(): - row = series.to_dict() - parsed = _parse_akshare_row(row, multiplier) - if not parsed or parsed["product"] in seen: - continue - seen.add(parsed["product"]) - upsert_fee_rate(parsed["product"], parsed) - count += 1 - - return count, f"已同步 {count} 个品种(标准费率 × {multiplier})" +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""从第三方(AKShare)同步交易所参考手续费,并按倍率写入本地表。""" +import re +from typing import Any, Optional + +from contract_specs import get_contract_spec +from fee_specs import get_fee_multiplier, upsert_fee_rate + + +def _to_float(val: Any) -> float: + if val is None: + return 0.0 + s = str(val).strip().replace(",", "") + if not s or s in ("-", "None", "nan"): + return 0.0 + try: + return float(s) + except ValueError: + return 0.0 + + +def _parse_akshare_row(row: dict, multiplier: float) -> Optional[dict]: + code = str(row.get("合约代码") or row.get("代码") or "").strip() + if not code: + return None + m = re.match(r"^([A-Za-z]+)", code) + if not m: + return None + product = m.group(1).lower() + + open_ratio = _to_float(row.get("手续费标准-开仓-万分之")) / 10000.0 + open_fixed = _to_float(row.get("手续费标准-开仓-元")) + if open_fixed == 0 and row.get("开仓"): + open_fixed = _to_float(row.get("开仓")) + close_y_ratio = _to_float(row.get("手续费标准-平昨-万分之")) / 10000.0 + close_y_fixed = _to_float(row.get("手续费标准-平昨-元")) + if close_y_fixed == 0 and row.get("平昨"): + close_y_fixed = _to_float(row.get("平昨")) + close_t_ratio = _to_float(row.get("手续费标准-平今-万分之")) / 10000.0 + close_t_fixed = _to_float(row.get("手续费标准-平今-元")) + if close_t_fixed == 0 and row.get("平今"): + close_t_fixed = _to_float(row.get("平今")) + + mult = int(get_contract_spec(code)["mult"]) + exchange = str(row.get("交易所名称") or row.get("交易所") or "").strip() + + return { + "product": product, + "exchange": exchange, + "mult": mult, + "open_fixed": round(open_fixed * multiplier, 6), + "open_ratio": round(open_ratio * multiplier, 8), + "close_yesterday_fixed": round(close_y_fixed * multiplier, 6), + "close_yesterday_ratio": round(close_y_ratio * multiplier, 8), + "close_today_fixed": round(close_t_fixed * multiplier, 6), + "close_today_ratio": round(close_t_ratio * multiplier, 8), + "source": "akshare", + } + + +def sync_fees_from_akshare(multiplier: Optional[float] = None) -> tuple[int, str]: + multiplier = multiplier if multiplier is not None else get_fee_multiplier() + try: + import akshare as ak + except ImportError: + return 0, "未安装 akshare,请执行 pip install akshare 后重试,或使用默认费率表" + + try: + df = ak.futures_comm_info(symbol="所有") + except Exception as exc: + return 0, f"拉取第三方数据失败: {exc}" + + if df is None or df.empty: + return 0, "第三方返回空数据" + + seen: set[str] = set() + count = 0 + for _, series in df.iterrows(): + row = series.to_dict() + parsed = _parse_akshare_row(row, multiplier) + if not parsed or parsed["product"] in seen: + continue + seen.add(parsed["product"]) + upsert_fee_rate(parsed["product"], parsed) + count += 1 + + return count, f"已同步 {count} 个品种(标准费率 × {multiplier})" diff --git a/install_trading.py b/install_trading.py index 3063822..6562eec 100644 --- a/install_trading.py +++ b/install_trading.py @@ -1,2262 +1,2267 @@ -"""期货下单、品种推荐、策略交易路由注册。""" -from __future__ import annotations - -import json -import logging -import threading -from datetime import datetime -from typing import Any, Callable, Optional - -from flask import flash, jsonify, redirect, render_template, request, url_for, Response, stream_with_context - -from contract_specs import calc_position_metrics, get_contract_spec -from fee_specs import calc_fee_breakdown -from kline_stream import sse_format -from market_sessions import is_trading_session -from position_sizing import ( - MODE_AMOUNT, - MODE_FIXED, - DEFAULT_MAX_ORDER_LOTS, - calc_lots_by_amount, - calc_lots_by_risk, - calc_margin_usage_pct, - calc_order_tick_metrics, - normalize_sizing_mode, -) -from recommend_store import ( - recommend_payload, - refresh_recommend_cache, -) -from recommend_stream import recommend_hub, schedule_recommend_refresh, start_recommend_worker -from position_stream import position_hub, start_position_worker -from ctp_reconnect import start_ctp_reconnect_worker -from ctp_premarket_connect import start_ctp_premarket_connect_worker -from ctp_fee_worker import start_ctp_fee_worker -from order_pending import ( - cancel_pending_monitor, - pending_auto_cancel_remaining, - reconcile_pending_orders, -) -from db_conn import execute_retry -from sl_tp_guard import ( - cancel_monitor_exit_orders, - ensure_monitor_order_columns, - monitor_order_status, - monitor_source_label, - place_monitor_exit_orders, - reconcile_monitors_without_position, - start_sl_tp_guard_worker, - write_manual_close_trade_log, -) -from risk.account_risk_lib import ( - assert_can_open, - get_risk_status, - on_mood_journal_freeze, - on_user_initiated_close, - parse_mood_issues, - reduce_cooloff_after_journal, - trading_day_label, -) -from strategy.strategy_db import init_strategy_tables -from strategy.strategy_roll_lib import preview_roll -from strategy.strategy_snapshot_lib import list_snapshots, save_snapshot -from strategy.strategy_trend_lib import compute_trend_plan_futures, trend_dca_level_reached -from strategy.strategy_snapshot_lib import STRATEGY_ROLL, STRATEGY_TREND -from symbols import ths_to_codes, resolve_main_contract, PRODUCTS, PRODUCT_CATEGORIES -from trading_context import ( - TRADING_MODE_LIVE, - TRADING_MODE_SIM, - get_account_capital, - get_fixed_amount, - get_fixed_lots, - get_max_margin_pct, - get_pending_order_timeout_min, - get_pending_order_timeout_sec, - get_risk_percent, - get_sizing_mode, - get_trailing_be_tick_buffer, - get_trading_mode, - trading_mode_label, -) -from ctp_symbol import ths_to_vnpy_symbol -from vnpy_bridge import ( - _ctp_td_lock, - ctp_cancel_order, - ctp_connect, - ctp_get_account, - ctp_get_tick_price, - ctp_list_active_orders, - ctp_list_positions, - ctp_status, - execute_order, - get_bridge, - set_position_refresh_callback, -) - - -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 - - def _sizing_mode_label(mode: str) -> str: - m = normalize_sizing_mode(mode) - if m == MODE_AMOUNT: - return "固定金额" - return "固定手数" - - def _schedule_recommend_refresh() -> None: - from db_conn import DB_PATH - - schedule_recommend_refresh( - db_path=DB_PATH, - get_capital_fn=_capital, - quote_fn=_main_quote, - init_tables_fn=lambda c: init_strategy_tables(c), - get_mode_fn=lambda: get_trading_mode(get_setting), - get_max_margin_pct_fn=lambda: get_max_margin_pct(get_setting), - get_sizing_mode_fn=lambda: get_sizing_mode(get_setting), - get_fixed_lots_fn=lambda: get_fixed_lots(get_setting), - ) - - def _recommend_payload(conn) -> dict: - mode = get_trading_mode(get_setting) - return recommend_payload( - conn, - live_capital=_capital(conn), - max_margin_pct=get_max_margin_pct(get_setting), - trading_mode=mode, - sizing_mode=get_sizing_mode(get_setting), - fixed_lots=get_fixed_lots(get_setting), - ) - - def _settings_dict() -> dict: - return { - "trading_mode": get_trading_mode(get_setting), - "position_sizing_mode": get_sizing_mode(get_setting), - "risk_percent": str(get_risk_percent(get_setting)), - "max_margin_pct": str(get_max_margin_pct(get_setting)), - } - - def _capital(conn) -> float: - return get_account_capital(conn, get_setting) - - def _main_quote(product_ths: str) -> Optional[dict]: - for p in PRODUCTS: - if p["ths"] == product_ths: - main = resolve_main_contract(p) - if not main: - return None - sym = main.get("ths_code") or "" - codes = ths_to_codes(sym) - price = None - if codes: - price = fetch_price( - sym, - codes.get("market_code", ""), - codes.get("sina_code", ""), - ) - return { - "ths_code": sym, - "price": price, - "display": main.get("display") or sym, - "name": main.get("name") or p.get("name"), - } - return None - - def _ctp_account(mode: str) -> dict: - try: - return ctp_get_account(mode) - except Exception: - return {} - - def _ctp_positions( - mode: str, - *, - refresh_if_empty: bool = True, - refresh_margin: bool = False, - ) -> list: - try: - return ctp_list_positions( - mode, - refresh_if_empty=refresh_if_empty, - refresh_margin=refresh_margin, - ) - except Exception: - return [] - - def _ctp_pos_to_ths_code(p: dict) -> str: - sym = (p.get("symbol") or "").strip() - if not sym: - return "" - codes = ths_to_codes(sym) - if codes: - return codes.get("ths_code") or sym - return sym - - def _ensure_monitors_from_ctp(conn, mode: str) -> None: - """CTP 有持仓但本地无监控时,自动补写一条 active 记录供展示。""" - if not ctp_status(mode).get("connected"): - return - for p in _ctp_positions(mode, refresh_if_empty=True): - lots = int(p.get("lots") or 0) - if lots <= 0: - continue - direction = p.get("direction") or "long" - ths = _ctp_pos_to_ths_code(p) - if not ths: - continue - existing = _find_active_monitor(conn, ths, direction) - if existing: - _sync_monitor_from_ctp( - conn, int(existing["id"]), ths, direction, mode, ctp=p, - capital=_capital(conn), - ) - continue - sl, tp, trailing_be, initial_sl = _restore_sl_tp_from_closed(conn, ths, direction) - ctp_open = (p.get("open_time") or "").strip() - mid = _upsert_open_monitor( - conn, - sym=ths, - direction=direction, - lots=lots, - price=float(p.get("avg_price") or 0), - sl=sl, - tp=tp, - trailing_be=trailing_be, - ctp_open_time=ctp_open or None, - monitor_type="ctp_sync", - ) - if initial_sl is not None and sl is not None: - conn.execute( - "UPDATE trade_order_monitors SET initial_stop_loss=? WHERE id=?", - (initial_sl, mid), - ) - - def _match_ctp_symbol(ctp_sym: str, ths: str) -> bool: - a = (ctp_sym or "").lower() - b = (ths or "").lower() - if a == b: - return True - if a and b and a.split(".")[0] == b.split(".")[0]: - return True - try: - vnpy_sym, _ = ths_to_vnpy_symbol(ths) - if a == vnpy_sym.lower(): - return True - except Exception: - pass - try: - vnpy_sym, _ = ths_to_vnpy_symbol(ctp_sym) - if vnpy_sym.lower() == b.split(".")[0]: - return True - except Exception: - pass - return False - - def _holding_duration(open_time: str, now_iso: str) -> str: - try: - from app import calc_holding_duration - open_s = (open_time or "").strip().replace("T", " ")[:19] - now_s = (now_iso or "").strip().replace("T", " ")[:19] - if not open_s or not now_s: - return "" - return calc_holding_duration(open_s, now_s) - except Exception: - return "" - - def _restore_sl_tp_from_closed(conn, sym: str, direction: str) -> tuple: - """重启后从最近关闭的同品种监控恢复止盈止损。""" - direction = (direction or "long").strip().lower() - for r in conn.execute( - "SELECT symbol, direction, stop_loss, take_profit, trailing_be, initial_stop_loss " - "FROM trade_order_monitors WHERE status='closed' ORDER BY id DESC LIMIT 80" - ).fetchall(): - row = dict(r) - if (row.get("direction") or "long") != direction: - continue - if not _match_ctp_symbol(sym, row.get("symbol") or ""): - continue - if row.get("stop_loss") is None and row.get("take_profit") is None: - continue - return ( - row.get("stop_loss"), - row.get("take_profit"), - int(row.get("trailing_be") or 0), - row.get("initial_stop_loss"), - ) - return None, None, 0, None - - def _ctp_position_keys(mode: str) -> set[tuple[str, str]]: - keys: set[tuple[str, str]] = set() - for p in _ctp_positions(mode): - lots = int(p.get("lots") or 0) - if lots <= 0: - continue - sym = (p.get("symbol") or "").lower() - direction = p.get("direction") or "long" - keys.add((sym, direction)) - return keys - - def _monitor_matches_ctp_position(mon: dict, position_keys: set[tuple[str, str]]) -> bool: - ms = mon.get("symbol") or "" - md = mon.get("direction") or "long" - for ps, pd in position_keys: - if pd != md: - continue - if _match_ctp_symbol(ps, ms): - return True - return False - - def _sync_trade_monitors_with_ctp(conn, mode: str) -> int: - """关闭无对应 CTP 持仓的监控,并撤销残留止盈止损挂单。""" - return reconcile_monitors_without_position(conn, mode) - - def _effective_active_position_count(conn, mode: str) -> int: - if ctp_status(mode).get("connected"): - return len(_ctp_position_keys(mode)) - row = conn.execute( - "SELECT COUNT(*) AS n FROM trade_order_monitors WHERE status='active'" - ).fetchone() - return int(row["n"] or 0) - - def _build_pending_orders(conn, mode: str) -> list[dict]: - pending: list[dict] = [] - for r in conn.execute( - "SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC" - ).fetchall(): - mon = dict(r) - sym = mon.get("symbol") or "" - direction = mon.get("direction") or "long" - lots = int(mon.get("lots") or 0) - base = { - "symbol_code": sym, - "symbol": mon.get("symbol_name") or sym, - "direction": direction, - "direction_label": "做多" if direction == "long" else "做空", - "lots": lots, - "source": "monitor", - "monitor_id": mon.get("id"), - } - sl = mon.get("stop_loss") - tp = mon.get("take_profit") - if sl is not None: - pending.append({ - **base, - "order_kind": "stop_loss", - "label": "止损监控", - "price": float(sl), - }) - if tp is not None: - pending.append({ - **base, - "order_kind": "take_profit", - "label": "止盈监控", - "price": float(tp), - }) - for r in conn.execute( - "SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC" - ).fetchall(): - mon = dict(r) - sym = mon.get("symbol") or "" - pending.append({ - "symbol_code": sym, - "symbol": mon.get("symbol_name") or sym, - "direction": mon.get("direction") or "long", - "direction_label": "做多" if (mon.get("direction") or "long") == "long" else "做空", - "lots": int(mon.get("lots") or 0), - "price": float(mon.get("order_price") or mon.get("entry_price") or 0), - "order_kind": "open_pending", - "label": "开仓挂单中", - "source": "monitor", - "monitor_id": mon.get("id"), - "can_cancel_order": is_trading_session(), - "cancel_allowed": is_trading_session(), - }) - ctp_st = ctp_status(mode) - if ctp_st.get("connected"): - for o in _ctp_active_orders(mode): - sym = o.get("symbol") or "" - offset_s = (o.get("offset") or "").upper() - kind = "limit" - label = "委托挂单" - if "CLOSE" in offset_s: - label = "平仓委托" - pending.append({ - "symbol_code": sym, - "symbol": sym, - "direction": o.get("direction") or "long", - "direction_label": "做多" if o.get("direction") == "long" else "做空", - "lots": int(o.get("lots") or 0), - "price": float(o.get("price") or 0), - "order_kind": kind, - "label": label, - "source": "ctp", - "order_id": o.get("order_id"), - "can_cancel_order": is_trading_session(), - "cancel_allowed": is_trading_session(), - }) - return pending - - def _ctp_active_orders(mode: str) -> list: - try: - return ctp_list_active_orders(mode) - except Exception: - return [] - - def _canonical_position_key(symbol: str, direction: str) -> str: - sym = (symbol or "").strip() - d = (direction or "long").strip().lower() - try: - vnpy_sym, _ = ths_to_vnpy_symbol(sym) - return f"{vnpy_sym.lower()}:{d}" - except Exception: - return f"{sym.lower()}:{d}" - - def _find_active_monitor(conn, symbol: str, direction: str) -> Optional[dict]: - direction = (direction or "long").strip().lower() - for r in conn.execute( - "SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC" - ).fetchall(): - row = dict(r) - if (row.get("direction") or "long") != direction: - continue - if _match_ctp_symbol(symbol, row.get("symbol") or ""): - return row - return None - - def _close_duplicate_monitors(conn, symbol: str, direction: str, keep_id: int) -> None: - direction = (direction or "long").strip().lower() - for r in conn.execute( - "SELECT id, symbol, direction FROM trade_order_monitors WHERE status='active'" - ).fetchall(): - if int(r["id"]) == int(keep_id): - continue - if (r["direction"] or "long") != direction: - continue - if _match_ctp_symbol(symbol, r["symbol"] or ""): - conn.execute( - "UPDATE trade_order_monitors SET status='closed' WHERE id=?", - (r["id"],), - ) - - def _upsert_open_monitor( - conn, - *, - sym: str, - direction: str, - lots: int, - price: float, - sl, - tp, - trailing_be: int, - ctp_open_time: Optional[str] = None, - open_time: Optional[str] = None, - monitor_type: str = "manual", - status: str = "active", - vt_order_id: Optional[str] = None, - order_price: Optional[float] = None, - ) -> int: - ensure_monitor_order_columns(conn) - codes = ths_to_codes(sym) or {} - sl_f = float(sl) if sl not in (None, "") else None - tp_f = float(tp) if tp not in (None, "") else None - now_s = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - status_val = status if status in ("pending", "active") else "active" - order_px = float(order_price if order_price is not None else price) - existing = _find_active_monitor(conn, sym, direction) - if not existing: - for r in conn.execute( - "SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC" - ).fetchall(): - row = dict(r) - if (row.get("direction") or "long") != (direction or "long").strip().lower(): - continue - if _match_ctp_symbol(sym, row.get("symbol") or ""): - existing = row - break - if existing: - mid = int(existing["id"]) - existing_status = (existing.get("status") or "active").strip().lower() - if existing_status == "active" and status_val == "pending": - status_val = "active" - initial_sl = existing.get("initial_stop_loss") - if sl_f is None: - sl_f = float(existing["stop_loss"]) if existing.get("stop_loss") is not None else None - if tp_f is None: - tp_f = float(existing["take_profit"]) if existing.get("take_profit") is not None else None - if sl_f is not None and initial_sl is None: - initial_sl = sl_f - if not trailing_be: - trailing_be = int(existing.get("trailing_be") or 0) - open_time_val = (existing.get("open_time") or "").strip() or now_s - if open_time: - open_time_val = open_time - elif monitor_type == "ctp_sync" and ctp_open_time: - open_time_val = ctp_open_time - vt_val = vt_order_id or existing.get("vt_order_id") - conn.execute( - """UPDATE trade_order_monitors SET - symbol=?, symbol_name=?, market_code=?, lots=?, entry_price=?, - stop_loss=?, take_profit=?, initial_stop_loss=?, trailing_be=?, open_time=?, - monitor_type=?, status=?, vt_order_id=?, order_price=? - WHERE id=?""", - ( - sym, - codes.get("name", sym), - codes.get("market_code", ""), - lots, - price, - sl_f, - tp_f, - initial_sl, - trailing_be, - open_time_val, - monitor_type if monitor_type != "manual" else (existing.get("monitor_type") or "manual"), - status_val, - vt_val, - order_px, - mid, - ), - ) - else: - if open_time: - open_time_val = open_time - elif monitor_type == "ctp_sync" and ctp_open_time: - open_time_val = ctp_open_time - else: - open_time_val = now_s - conn.execute( - """INSERT INTO trade_order_monitors ( - symbol, symbol_name, market_code, direction, lots, entry_price, - stop_loss, take_profit, initial_stop_loss, trailing_be, - open_time, monitor_type, status, vt_order_id, order_price - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - sym, - codes.get("name", sym), - codes.get("market_code", ""), - direction, - lots, - price, - sl_f, - tp_f, - sl_f, - trailing_be, - open_time_val, - monitor_type, - status_val, - vt_order_id, - order_px, - ), - ) - mid = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) - if status_val == "active": - _close_duplicate_monitors(conn, sym, direction, mid) - return mid - - def _sync_monitor_from_ctp( - conn, - mid: int, - sym: str, - direction: str, - mode: str, - *, - ctp: Optional[dict] = None, - capital: float = 0.0, - ) -> None: - """CTP 同步:均价、现价、保证金、仓位占比写入数据库;不覆盖期货下单的开仓时间。""" - positions = [ctp] if ctp else _ctp_positions(mode, refresh_if_empty=False, refresh_margin=True) - for p in positions: - if not p or int(p.get("lots") or 0) <= 0: - continue - if (p.get("direction") or "long") != direction: - continue - if not _match_ctp_symbol(p.get("symbol") or "", sym): - continue - row = conn.execute( - "SELECT open_time, monitor_type FROM trade_order_monitors WHERE id=?", (mid,), - ).fetchone() - db_open = (row["open_time"] or "").strip() if row else "" - monitor_type = (row["monitor_type"] or "manual").strip().lower() if row else "manual" - ctp_open = (p.get("open_time") or "").strip() or None - open_time_val = db_open - if monitor_type == "ctp_sync" and ctp_open: - open_time_val = ctp_open - lots = int(p.get("lots") or 0) - entry = float(p.get("avg_price") or 0) - ctp_margin = float(p.get("margin") or 0) - float_pnl = p.get("pnl") - if float_pnl is not None: - float_pnl = round(float(float_pnl), 2) - mark = None - if ctp_status(mode).get("connected"): - mark = ctp_get_tick_price(mode, sym) - if mark is None or mark <= 0: - mark = entry if entry else None - margin = ctp_margin if ctp_margin > 0 else None - position_pct = None - if margin and capital > 0: - position_pct = round(float(margin) / float(capital) * 100, 2) - execute_retry( - conn, - """UPDATE trade_order_monitors SET lots=?, entry_price=?, - open_time=?, margin=?, position_pct=?, mark_price=?, float_pnl=? - WHERE id=?""", - ( - lots, - entry, - open_time_val, - margin, - position_pct, - float(mark) if mark else None, - float_pnl, - mid, - ), - ) - return - - def _sync_monitor_lots_from_ctp( - conn, mid: int, sym: str, direction: str, mode: str, *, ctp: Optional[dict] = None, - ) -> None: - _sync_monitor_from_ctp( - conn, mid, sym, direction, mode, ctp=ctp, capital=_capital(conn), - ) - - def _compose_position_row( - conn, - *, - mon: Optional[dict], - ctp: Optional[dict], - mode: str, - capital: float, - now_iso: str, - fast: bool = False, - ) -> Optional[dict]: - if not mon and not ctp: - return None - - if mon: - sym = (mon.get("symbol") or "").strip() - direction = mon.get("direction") or "long" - lots = int(mon.get("lots") or 0) - entry = float(mon.get("entry_price") or 0) - source_label = monitor_source_label(mon.get("monitor_type")) - open_time = (mon.get("open_time") or "").strip() - open_time_source = "order" - margin = mon.get("margin") - position_pct = mon.get("position_pct") - mark = mon.get("mark_price") - float_pnl = mon.get("float_pnl") - if float_pnl is not None: - float_pnl = round(float(float_pnl), 2) - else: - sym = (ctp.get("symbol") or "").strip() - direction = ctp.get("direction") or "long" - lots = int(ctp.get("lots") or 0) - entry = float(ctp.get("avg_price") or 0) - source_label = "CTP 柜台" - open_time = (ctp.get("open_time") or "").strip() - open_time_source = "ctp" - margin = None - position_pct = None - mark = None - float_pnl = ctp.get("pnl") - if float_pnl is not None: - float_pnl = round(float(float_pnl), 2) - - if lots <= 0: - return None - - if ctp: - if ctp.get("pnl") is not None: - float_pnl = round(float(ctp["pnl"]), 2) - if not mon: - ctp_lots = int(ctp.get("lots") or 0) - if ctp_lots > 0: - lots = ctp_lots - if float(ctp.get("avg_price") or 0) > 0: - entry = float(ctp.get("avg_price") or 0) - ctp_margin = float(ctp.get("margin") or 0) - if (margin is None or float(margin or 0) <= 0) and ctp_margin > 0: - margin = ctp_margin - - codes = ths_to_codes(sym) - tick = calc_order_tick_metrics(sym, lots, entry) - 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 - holding = _holding_duration(open_time, now_iso) if open_time else "" - - if (mark is None or float(mark or 0) <= 0) and not fast and ctp_status(mode).get("connected"): - live_mark = ctp_get_tick_price(mode, sym) - if live_mark and live_mark > 0: - mark = live_mark - if (mark is None or float(mark or 0) <= 0) and not fast and codes: - mark = fetch_price( - sym, - codes.get("market_code", ""), - codes.get("sina_code", ""), - ) - if mark is None or mark <= 0: - mark = entry if entry else None - close_est = float(mark) if mark and mark > 0 else entry - if float_pnl is None and mark and entry: - pos_tmp = calc_position_metrics( - direction, entry, sl or entry, tp or entry, lots, mark, capital, sym, - ) - float_pnl = pos_tmp.get("float_pnl") - - 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, - ) - if margin is None or float(margin or 0) <= 0: - ctp_margin = float(ctp.get("margin") or 0) if ctp else 0.0 - est_margin = pos_metrics.get("margin") - margin = ctp_margin if ctp_margin > 0 else est_margin - margin_source = "ctp" if ctp_margin > 0 else "estimate" - else: - margin_source = "ctp" - if position_pct is None or float(position_pct or 0) <= 0: - position_pct = ( - round(float(margin) / capital * 100, 2) - if capital > 0 and margin - else pos_metrics.get("position_pct") - ) - else: - position_pct = float(position_pct) - order_st = monitor_order_status( - mon or {}, mode=mode, ths_code=sym, direction=direction, - ) - pending_for_row: list[dict] = [] - if sl is not None: - pending_for_row.append({ - "order_kind": "stop_loss", - "label": "止损监控", - "price": sl, - "lots": lots, - "source": "monitor", - "monitor_id": mon["id"] if mon else None, - }) - if tp is not None: - pending_for_row.append({ - "order_kind": "take_profit", - "label": "止盈监控", - "price": tp, - "lots": lots, - "source": "monitor", - "monitor_id": mon["id"] if mon else None, - }) - row_key = _canonical_position_key(sym, direction) - return { - "key": row_key, - "source": "ctp" if ctp else "local", - "source_label": source_label, - "sync_pending": ctp is None and mon is not None, - "monitor_id": mon["id"] if mon else None, - "symbol": codes.get("name", sym) if codes else (mon.get("symbol_name") if mon else sym), - "symbol_code": sym, - "direction": direction, - "direction_label": "做多" if direction == "long" else "做空", - "lots": lots, - "entry_price": entry, - "stop_loss": sl, - "take_profit": tp, - "open_time": open_time or None, - "open_time_source": open_time_source or None, - "holding_duration": holding or None, - "mark_price": mark, - "current_price": mark, - "margin": margin, - "margin_source": margin_source, - "position_pct": position_pct, - "risk_amount": pos_metrics.get("risk_amount") if sl is not None else None, - "reward_amount": pos_metrics.get("reward_amount") if tp is not None else None, - "risk_pct": pos_metrics.get("risk_pct") if sl is not None else None, - "rr_ratio": pos_metrics.get("rr_ratio") if sl is not None and tp is not None else None, - "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"], - "fee_source": fee_info.get("fee_source") or "local", - "est_pnl_net": est_net, - "sl_order_active": order_st.get("sl_monitoring"), - "tp_order_active": order_st.get("tp_monitoring"), - "sl_monitoring": order_st.get("sl_monitoring"), - "tp_monitoring": order_st.get("tp_monitoring"), - "can_place_orders": False, - "tick_value_total": tick.get("tick_value_total"), - "price_precision": tick.get("price_precision"), - "tick_size": tick.get("tick_size"), - "can_close": True, - "close_allowed": is_trading_session(), - "pending_orders": pending_for_row, - "trailing_be": bool(mon.get("trailing_be")) if mon else False, - "trailing_r_locked": int(mon.get("trailing_r_locked") or 0) if mon else 0, - } - - def _compose_pending_row( - mon: dict, - *, - mode: str, - capital: float, - now_iso: str, - ) -> Optional[dict]: - sym = (mon.get("symbol") or "").strip() - direction = (mon.get("direction") or "long").strip().lower() - lots = int(mon.get("lots") or 0) - if not sym or lots <= 0: - return None - order_price = float(mon.get("order_price") or mon.get("entry_price") or 0) - codes = ths_to_codes(sym) - sl = float(mon["stop_loss"]) if mon.get("stop_loss") is not None else None - tp = float(mon["take_profit"]) if mon.get("take_profit") is not None else None - pos_metrics = calc_position_metrics( - direction, order_price, sl or order_price, tp or order_price, lots, order_price, capital, sym, - ) - open_time = (mon.get("open_time") or "").strip() - timeout_sec = get_pending_order_timeout_sec(get_setting) - remain = pending_auto_cancel_remaining(mon, timeout_sec=timeout_sec) - return { - "key": f"{_canonical_position_key(sym, direction)}:pending:{mon.get('id')}", - "order_state": "pending", - "source": "pending", - "source_label": "委托挂单中", - "sync_pending": True, - "monitor_id": mon.get("id"), - "symbol": codes.get("name", sym) if codes else (mon.get("symbol_name") or sym), - "symbol_code": sym, - "direction": direction, - "direction_label": "做多" if direction == "long" else "做空", - "lots": lots, - "entry_price": order_price, - "order_price": order_price, - "stop_loss": sl, - "take_profit": tp, - "open_time": open_time or None, - "holding_duration": _holding_duration(open_time, now_iso) if open_time else None, - "mark_price": order_price, - "current_price": order_price, - "margin": pos_metrics.get("margin"), - "margin_source": "estimate", - "position_pct": pos_metrics.get("position_pct"), - "risk_amount": pos_metrics.get("risk_amount") if sl is not None else None, - "reward_amount": pos_metrics.get("reward_amount") if tp is not None else None, - "rr_ratio": pos_metrics.get("rr_ratio") if sl is not None and tp is not None else None, - "float_pnl": None, - "est_fee": None, - "can_close": False, - "close_allowed": False, - "can_cancel_order": is_trading_session(), - "cancel_allowed": is_trading_session(), - "auto_cancel_sec": remain, - "pending_timeout_sec": timeout_sec, - "pending_timeout_min": max(1, timeout_sec // 60), - "vt_order_id": mon.get("vt_order_id"), - "sl_order_active": False, - "tp_order_active": False, - "sl_monitoring": bool(sl is not None), - "tp_monitoring": bool(tp is not None), - "can_place_orders": False, - "pending_orders": [], - "trailing_be": bool(mon.get("trailing_be")), - "trailing_r_locked": int(mon.get("trailing_r_locked") or 0), - } - - def _reconcile_pending(conn, mode: str, *, capital: float = 0.0) -> None: - reconcile_pending_orders( - conn, - mode, - match_symbol_fn=_match_ctp_symbol, - sync_monitor_fn=_sync_monitor_from_ctp, - capital=capital, - list_positions_fn=_ctp_positions, - timeout_sec=get_pending_order_timeout_sec(get_setting), - ) - - def _build_trading_live_rows(conn, *, fast: bool = False) -> list[dict]: - from zoneinfo import ZoneInfo - tz = ZoneInfo("Asia/Shanghai") - now_iso = datetime.now(tz).strftime("%Y-%m-%dT%H:%M") - mode = get_trading_mode(get_setting) - capital = _capital(conn) - ensure_monitor_order_columns(conn) - - monitors_raw = [ - dict(r) for r in conn.execute( - "SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC" - ).fetchall() - ] - monitor_by_key: dict[str, dict] = {} - for mon in monitors_raw: - key = _canonical_position_key(mon.get("symbol") or "", mon.get("direction") or "long") - if key not in monitor_by_key: - monitor_by_key[key] = mon - - ctp_list: list[dict] = ( - _ctp_positions(mode, refresh_if_empty=not fast, refresh_margin=not fast) - if ctp_status(mode).get("connected") else [] - ) - ctp_by_key: dict[str, dict] = {} - for p in ctp_list: - if int(p.get("lots") or 0) <= 0: - continue - key = _canonical_position_key(p.get("symbol") or "", p.get("direction") or "long") - ctp_by_key[key] = p - - rows: list[dict] = [] - used_ctp_keys: set[str] = set() - - for key, mon in monitor_by_key.items(): - ctp = ctp_by_key.get(key) - if not ctp: - for ck, cp in ctp_by_key.items(): - if ck in used_ctp_keys: - continue - if (cp.get("direction") or "long") != (mon.get("direction") or "long"): - continue - if _match_ctp_symbol(cp.get("symbol") or "", mon.get("symbol") or ""): - ctp = cp - used_ctp_keys.add(ck) - break - elif key in ctp_by_key: - used_ctp_keys.add(key) - if ctp and mon and not fast: - _sync_monitor_from_ctp( - conn, int(mon["id"]), mon.get("symbol") or "", - mon.get("direction") or "long", mode, ctp=ctp, - capital=capital, - ) - mon = _find_active_monitor(conn, mon.get("symbol") or "", mon.get("direction") or "long") or mon - try: - row = _compose_position_row( - conn, mon=mon, ctp=ctp, mode=mode, capital=capital, now_iso=now_iso, - fast=fast, - ) - if row: - rows.append(row) - except Exception as exc: - logger.warning("compose monitor row failed: %s", exc) - - for key, ctp in ctp_by_key.items(): - if key in used_ctp_keys: - continue - matched = False - for uk in used_ctp_keys: - if uk == key: - matched = True - break - if matched: - continue - for existing in rows: - if _match_ctp_symbol( - ctp.get("symbol") or "", existing.get("symbol_code") or "", - ) and (ctp.get("direction") or "long") == (existing.get("direction") or "long"): - matched = True - break - if matched: - continue - mon = _find_active_monitor( - conn, ctp.get("symbol") or "", ctp.get("direction") or "long", - ) - try: - row = _compose_position_row( - conn, mon=mon, ctp=ctp, mode=mode, capital=capital, now_iso=now_iso, - fast=fast, - ) - if row: - rows.append(row) - except Exception as exc: - logger.warning("compose ctp row failed: %s", exc) - - seen: set[str] = set() - deduped: list[dict] = [] - for row in rows: - rk = row.get("key") or f"{row.get('symbol_code')}:{row.get('direction')}" - if rk in seen: - continue - seen.add(rk) - deduped.append(row) - - pending_raw = [ - dict(r) for r in conn.execute( - "SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC" - ).fetchall() - ] - for mon in pending_raw: - try: - prow = _compose_pending_row( - mon, mode=mode, capital=capital, now_iso=now_iso, - ) - if prow: - deduped.insert(0, prow) - except Exception as exc: - logger.warning("compose pending row failed: %s", exc) - return deduped - - def _build_trading_live_payload(conn, *, fast: bool = False) -> dict: - mode = get_trading_mode(get_setting) - ctp_st = ctp_status(mode) - capital = _capital(conn) - if not fast and ctp_st.get("connected"): - _reconcile_pending(conn, mode, capital=capital) - if not fast: - _ensure_monitors_from_ctp(conn, mode) - rows = _build_trading_live_rows(conn, fast=fast) - pending_orders = _build_pending_orders(conn, mode) - risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode)) - return { - "ok": True, - "rows": rows, - "pending_orders": pending_orders, - "capital": capital, - "ctp_status": ctp_st, - "trading_mode_label": trading_mode_label(get_setting), - "risk_status": risk, - "trading_session": is_trading_session(), - "pending_order_timeout_min": get_pending_order_timeout_min(get_setting), - } - - def _refresh_trading_live_snapshot(*, fast: bool = False) -> dict: - mode = get_trading_mode(get_setting) - if not fast and ctp_status(mode).get("connected"): - try: - with _ctp_td_lock: - get_bridge().refresh_positions() - except Exception as exc: - logger.debug("refresh positions before snapshot: %s", exc) - conn = get_db() - try: - init_strategy_tables(conn) - payload = _build_trading_live_payload(conn, fast=fast) - conn.commit() - return payload - finally: - conn.close() - - def _push_position_snapshot_async(*, fast: bool = False) -> None: - def _run() -> None: - try: - payload = _refresh_trading_live_snapshot(fast=fast) - position_hub.broadcast("positions", payload) - except Exception as exc: - logger.debug("push position snapshot: %s", exc) - - threading.Thread(target=_run, daemon=True).start() - - def _bootstrap_trading_runtime() -> None: - """进程启动:立刻读库展示持仓,并异步连 CTP。""" - set_position_refresh_callback( - lambda: _push_position_snapshot_async(fast=False) - ) - - def _warm() -> None: - try: - payload = _refresh_trading_live_snapshot(fast=True) - position_hub.set_snapshot(payload) - position_hub.broadcast("positions", payload) - except Exception as exc: - logger.warning("bootstrap position snapshot: %s", exc) - - threading.Thread(target=_warm, daemon=True, name="position-bootstrap").start() - try: - from vnpy_bridge import ctp_start_connect - mode = get_trading_mode(get_setting) - ctp_start_connect(mode, force=False) - except Exception as exc: - logger.debug("bootstrap ctp connect: %s", exc) - - @app.route("/trade") - @login_required - def trade_page(): - return redirect(url_for("positions")) - - @app.route("/positions") - @login_required - def positions(): - conn = get_db() - try: - init_strategy_tables(conn) - mode = get_trading_mode(get_setting) - ctp_st = ctp_status(mode) - capital = _capital(conn) - risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode)) - ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {} - active_trend = conn.execute( - "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC LIMIT 1" - ).fetchone() - monitor_count = conn.execute( - "SELECT COUNT(*) AS n FROM trade_order_monitors WHERE status='active'" - ).fetchone()["n"] - roll_count = conn.execute( - "SELECT COUNT(*) AS n FROM roll_groups WHERE status='active'" - ).fetchone()["n"] - conn.commit() - sizing = get_sizing_mode(get_setting) - max_pct = get_max_margin_pct(get_setting) - rec_cache = _recommend_payload(conn) - if rec_cache.get("needs_refresh"): - _schedule_recommend_refresh() - return render_template( - "trade.html", - trading_mode=mode, - trading_mode_label=trading_mode_label(get_setting), - capital=capital, - risk_status=risk, - ctp_status=ctp_st, - ctp_account=ctp_acc, - active_trend=dict(active_trend) if active_trend else None, - monitor_count=monitor_count, - roll_count=roll_count, - sizing_mode=sizing, - sizing_mode_label=_sizing_mode_label(sizing), - fixed_lots=get_fixed_lots(get_setting), - fixed_amount=get_fixed_amount(get_setting), - risk_percent=get_risk_percent(get_setting), - max_margin_pct=get_max_margin_pct(get_setting), - pending_order_timeout_min=get_pending_order_timeout_min(get_setting), - recommend_rows=rec_cache.get("rows") or [], - recommend_updated_at=rec_cache.get("updated_at"), - product_categories=PRODUCT_CATEGORIES, - ) - finally: - conn.close() - - @app.route("/recommend") - @login_required - def recommend_page(): - return redirect(url_for("positions") + "#recommend") - - @app.route("/api/trading/live") - @login_required - def api_trading_live(): - conn = get_db() - try: - init_strategy_tables(conn) - payload = _build_trading_live_payload(conn, fast=True) - position_hub.set_snapshot(payload) - return jsonify(payload) - finally: - conn.close() - - @app.route("/api/trading/stream") - @login_required - def api_trading_stream(): - from queue import Empty - - def generate(): - q = position_hub.subscribe() - try: - snap = position_hub.get_snapshot() - if snap: - yield sse_format("positions", snap) - else: - payload = _refresh_trading_live_snapshot(fast=True) - position_hub.set_snapshot(payload) - yield sse_format("positions", payload) - while True: - try: - msg = q.get(timeout=25) - yield sse_format(msg["event"], msg["data"]) - except Empty: - yield ": heartbeat\n\n" - finally: - position_hub.unsubscribe(q) - - return Response( - generate(), - mimetype="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "X-Accel-Buffering": "no", - }, - ) - - @app.route("/api/trading/monitor/upsert", methods=["POST"]) - @login_required - def api_trading_monitor_upsert(): - """为已有持仓补充/更新本地止盈止损监控。""" - 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) - conn = get_db() - try: - init_strategy_tables(conn) - mon = _find_active_monitor(conn, sym, direction) - has_pos = bool(mon) - ths_sym = sym - if ctp_status(mode).get("connected"): - for p in _ctp_positions(mode, refresh_if_empty=False): - 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) - ths_sym = _ctp_pos_to_ths_code(p) or sym - break - if not has_pos: - return jsonify({"ok": False, "error": "未找到对应持仓"}), 400 - trailing_be = 1 if d.get("trailing_be") else ( - int(mon.get("trailing_be") or 0) if mon else 0 - ) - mid = _upsert_open_monitor( - conn, - sym=ths_sym, - direction=direction, - lots=lots, - price=entry, - sl=sl, - tp=tp, - trailing_be=trailing_be, - ) - conn.commit() - _push_position_snapshot_async() - 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(): - """本地监控模式:清理旧版柜台挂单,不再向交易所挂止盈止损。""" - 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(): - d = request.get_json(silent=True) or {} - try: - monitor_id = int(d.get("monitor_id") or 0) - except (TypeError, ValueError): - monitor_id = 0 - if monitor_id <= 0: - return jsonify({"ok": False, "error": "无效的监控记录"}), 400 - conn = get_db() - try: - init_strategy_tables(conn) - mode = get_trading_mode(get_setting) - row = conn.execute( - "SELECT * FROM trade_order_monitors WHERE id=? AND status IN ('active', 'pending')", - (monitor_id,), - ).fetchone() - if not row: - return jsonify({"ok": False, "error": "记录不存在或已关闭"}), 404 - mon = dict(row) - if (mon.get("status") or "").strip().lower() == "pending": - if not is_trading_session(): - return jsonify({"ok": False, "error": "不在交易时间段,无法撤单"}), 403 - ok, msg = cancel_pending_monitor(conn, mon, mode) - _push_position_snapshot_async(fast=False) - return jsonify({"ok": ok, "message": msg}) - conn.execute( - "UPDATE trade_order_monitors SET status='closed' WHERE id=?", - (monitor_id,), - ) - conn.commit() - _push_position_snapshot_async(fast=False) - return jsonify({"ok": True, "message": "已取消本地止盈止损监控"}) - finally: - conn.close() - - @app.route("/api/trading/monitor/cancel-open", methods=["POST"]) - @login_required - def api_trading_monitor_cancel_open(): - """撤销 pending 开仓委托(柜台撤单 + 关闭本地记录)。""" - d = request.get_json(silent=True) or {} - try: - monitor_id = int(d.get("monitor_id") or 0) - except (TypeError, ValueError): - monitor_id = 0 - if monitor_id <= 0: - return jsonify({"ok": False, "error": "无效的委托记录"}), 400 - conn = get_db() - try: - init_strategy_tables(conn) - mode = get_trading_mode(get_setting) - if not ctp_status(mode).get("connected"): - return jsonify({"ok": False, "error": "请先连接 CTP"}), 400 - if not is_trading_session(): - return jsonify({"ok": False, "error": "不在交易时间段,无法撤单"}), 403 - row = conn.execute( - "SELECT * FROM trade_order_monitors WHERE id=? AND status='pending'", - (monitor_id,), - ).fetchone() - if not row: - return jsonify({"ok": False, "error": "未找到挂单中的开仓委托"}), 404 - ok, msg = cancel_pending_monitor(conn, dict(row), mode) - _push_position_snapshot_async(fast=False) - return jsonify({"ok": ok, "message": msg}) - finally: - conn.close() - - @app.route("/api/trading/order/cancel", methods=["POST"]) - @login_required - def api_trading_order_cancel(): - """撤销柜台未成交委托(按 vt_order_id)。""" - d = request.get_json(silent=True) or {} - order_id = (d.get("order_id") or "").strip() - if not order_id: - 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 - if not is_trading_session(): - return jsonify({"ok": False, "error": "不在交易时间段,无法撤单"}), 403 - ok = ctp_cancel_order(mode, order_id) - _push_position_snapshot_async(fast=False) - if not ok: - return jsonify({"ok": False, "error": "撤单失败,委托可能已成交或已撤销"}), 400 - return jsonify({"ok": True, "message": "撤单已提交"}) - - @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" - capital = _capital(conn) - mon = None - mid = int(d.get("monitor_id") or 0) - if mid: - row = conn.execute( - "SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", - (mid,), - ).fetchone() - if row: - mon = dict(row) - if not mon: - 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 - mid = int(row["id"]) - break - entry = float(mon.get("entry_price") or 0) if mon else 0.0 - if entry <= 0: - 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): - entry = float(p.get("avg_price") or price) - break - try: - execute_order( - conn, mode=mode, offset=offset, symbol=sym, direction=direction, - lots=lots, price=price, settings=_settings_dict(), - order_type="market", - ) - write_manual_close_trade_log( - conn, - mon, - symbol=sym, - direction=direction, - lots=lots, - close_price=price, - entry_price=entry or price, - trading_mode=mode, - capital=capital, - stop_loss=float(mon["stop_loss"]) if mon and mon.get("stop_loss") is not None else None, - take_profit=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 "", - symbol_name=(mon.get("symbol_name") or "") if mon else "", - market_code=(mon.get("market_code") or "") if mon else "", - ) - if mid: - conn.execute( - "UPDATE trade_order_monitors SET status='closed' WHERE id=?", - (mid,), - ) - conn.commit() - try: - from ctp_trade_sync import sync_trade_logs_from_ctp - sync_trade_logs_from_ctp(conn, mode, capital=capital, trading_mode=mode) - conn.commit() - except Exception as exc: - logger.debug("sync trades after close: %s", exc) - conn.close() - _push_position_snapshot_async() - return jsonify({"ok": True, "message": "已平仓;交易记录将按柜台成交同步"}) - except ValueError as exc: - conn.close() - return jsonify({"ok": False, "error": str(exc)}), 400 - - - @app.route("/strategy") - @login_required - @_nav("strategy") - def strategy_page(): - conn = get_db() - init_strategy_tables(conn) - capital = _capital(conn) - active_trend = conn.execute( - "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC LIMIT 1" - ).fetchone() - monitors = conn.execute( - "SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC" - ).fetchall() - roll_groups = conn.execute( - "SELECT * FROM roll_groups WHERE status='active' ORDER BY id DESC" - ).fetchall() - conn.close() - return render_template( - "strategy.html", - capital=capital, - risk_percent=get_risk_percent(get_setting), - sizing_mode=get_sizing_mode(get_setting), - active_trend=dict(active_trend) if active_trend else None, - monitors=[dict(m) for m in monitors], - roll_groups=[dict(g) for g in roll_groups], - ) - - @app.route("/strategy/records") - @login_required - def strategy_records_page(): - conn = get_db() - init_strategy_tables(conn) - trend, roll = list_snapshots(conn) - conn.close() - return render_template("strategy_records.html", trend_rows=trend, roll_rows=roll) - - @app.route("/api/trade/quote") - @login_required - def api_trade_quote(): - sym = (request.args.get("symbol") or "").strip() - lots = request.args.get("lots") or "1" - if not sym: - return jsonify({"ok": False, "error": "缺少品种"}), 400 - codes = ths_to_codes(sym) - price = fetch_price(sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "") - try: - lots_f = max(1, int(float(lots))) - except (TypeError, ValueError): - lots_f = 1 - metrics = calc_order_tick_metrics(sym, lots_f, price) - spec = get_contract_spec(sym) - name = codes.get("name", sym) if codes else sym - pos_long = pos_short = 0 - mode = get_trading_mode(get_setting) - ctp_st = ctp_status(mode) - if ctp_st.get("connected"): - for p in _ctp_positions(mode): - if not _match_ctp_symbol(p.get("symbol", ""), sym): - continue - if p["direction"] == "long": - pos_long = int(p["lots"]) - else: - pos_short = int(p["lots"]) - max_open = int(_capital(get_db()) / (metrics["margin_per_lot"] or 1)) if metrics.get("margin_per_lot") else 0 - return jsonify({ - "ok": True, - "symbol": sym, - "name": name, - "price": price, - "lots": lots_f, - "metrics": metrics, - "exchange": codes.get("exchange", "") if codes else "", - "pos_long": pos_long, - "pos_short": pos_short, - "max_open_long": max_open, - "max_open_short": max_open, - "footer_text": ( - f"*{name} 每手{spec['mult']}吨/点 最小变动{metrics['tick_size']} " - f"每跳{metrics['tick_value_per_lot']}元/手×{lots_f}={metrics['tick_value_total']}元 " - f"精度{metrics['price_precision']}位小数" - ), - }) - - @app.route("/api/trade/preview", methods=["POST"]) - @login_required - def api_trade_preview(): - d = request.get_json(silent=True) or {} - sym = (d.get("symbol") or "").strip() - direction = (d.get("direction") or "long").strip().lower() - try: - entry = float(d.get("entry") or d.get("price") or 0) - sl = float(d.get("stop_loss") or 0) - tp = float(d.get("take_profit") or 0) - except (TypeError, ValueError): - return jsonify({"ok": False, "error": "价格参数无效"}), 400 - conn = get_db() - capital = _capital(conn) - conn.close() - sizing = get_sizing_mode(get_setting) - margin_pct = get_max_margin_pct(get_setting) - if sizing == MODE_AMOUNT: - lots, err = calc_lots_by_amount( - entry, sl, direction, get_fixed_amount(get_setting), sym, - capital=capital, max_margin_pct=margin_pct, - ) - if err: - return jsonify({"ok": False, "error": err}), 400 - elif sizing == MODE_FIXED: - lots = get_fixed_lots(get_setting) - else: - try: - lots = max(1, int(d.get("lots") or 1)) - except (TypeError, ValueError): - lots = 1 - metrics = calc_position_metrics(direction, entry, sl, tp, lots, entry, capital, sym) - tick = calc_order_tick_metrics(sym, lots, entry) - return jsonify({"ok": True, "lots": lots, "sizing_mode": sizing, "metrics": metrics, "tick": tick, "capital": capital}) - - @app.route("/api/trade/order", methods=["POST"]) - @login_required - def api_trade_order(): - d = request.get_json(silent=True) or {} - sym = (d.get("symbol") or "").strip() - offset = (d.get("offset") or "open").strip().lower() - 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): - 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() - init_strategy_tables(conn) - mode = get_trading_mode(get_setting) - if offset.startswith("open"): - _sync_trade_monitors_with_ctp(conn, mode) - if not is_trading_session(): - conn.close() - return jsonify({"ok": False, "error": "不在交易时间段"}), 403 - if d.get("trailing_be") and not d.get("stop_loss"): - conn.close() - return jsonify({"ok": False, "error": "开启移动保本须填写止损价"}), 400 - err = assert_can_open(conn, active_count=_effective_active_position_count(conn, mode)) - if err: - conn.close() - return jsonify({"ok": False, "error": err}), 403 - ctp_st = ctp_status(mode) - if not ctp_st.get("connected"): - conn.close() - if get_bridge().connect_in_progress(): - return jsonify({"ok": False, "error": "CTP 连接中,请稍候再下单"}), 400 - return jsonify({"ok": False, "error": "请先连接 CTP"}), 400 - sizing = get_sizing_mode(get_setting) - if offset.startswith("open") and sizing == MODE_AMOUNT: - sl = float(d.get("stop_loss") or 0) - if sl <= 0: - conn.close() - return jsonify({"ok": False, "error": "固定金额模式须填写止损价"}), 400 - lots_calc, err = calc_lots_by_amount( - price, sl, direction, get_fixed_amount(get_setting), sym, - capital=_capital(conn), max_margin_pct=get_max_margin_pct(get_setting), - ) - if err: - conn.close() - return jsonify({"ok": False, "error": err}), 400 - lots = lots_calc or lots - elif offset.startswith("open") and sizing == MODE_FIXED: - lots = get_fixed_lots(get_setting) - margin_pct = get_max_margin_pct(get_setting) - usage = calc_margin_usage_pct( - _ctp_positions(mode), - _capital(conn), - extra_symbol=sym if offset.startswith("open") else "", - extra_lots=lots if offset.startswith("open") else 0, - extra_price=price if offset.startswith("open") else 0, - ) - if offset.startswith("open") and usage > margin_pct: - conn.close() - return jsonify({ - "ok": False, - "error": f"保证金占用 {usage:.1f}% 超过上限 {margin_pct:g}%(可在系统设置修改)", - }), 403 - if lots > DEFAULT_MAX_ORDER_LOTS: - conn.close() - return jsonify({ - "ok": False, - "error": f"单笔手数 {lots} 超过上限 {DEFAULT_MAX_ORDER_LOTS},请加大止损距离或改固定手数", - }), 400 - try: - result = execute_order( - conn, - mode=mode, - offset=offset, - symbol=sym, - direction=direction, - lots=lots, - price=price, - settings=_settings_dict(), - order_type=order_type, - ) - if offset.startswith("open") and d.get("trailing_be") and not d.get("stop_loss"): - conn.close() - return jsonify({"ok": False, "error": "开启移动保本须填写止损价"}), 400 - if offset.startswith("open"): - from zoneinfo import ZoneInfo - sl = d.get("stop_loss") - tp = d.get("take_profit") - trailing_be = 1 if d.get("trailing_be") else 0 - open_ts = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") - vt_order_id = str(result.get("order_id") or "") - mid = _upsert_open_monitor( - conn, - sym=sym, - direction=direction, - lots=lots, - price=price, - sl=sl, - tp=tp, - trailing_be=trailing_be, - open_time=open_ts, - monitor_type="manual", - status="pending", - vt_order_id=vt_order_id or None, - order_price=price, - ) - conn.commit() - _reconcile_pending(conn, mode, capital=_capital(conn)) - st_row = conn.execute( - "SELECT status FROM trade_order_monitors WHERE id=?", (mid,), - ).fetchone() - filled = st_row and (st_row["status"] or "").strip().lower() == "active" - if not filled: - try: - get_bridge().refresh_positions() - except Exception: - pass - _reconcile_pending(conn, mode, capital=_capital(conn)) - st_row = conn.execute( - "SELECT status FROM trade_order_monitors WHERE id=?", (mid,), - ).fetchone() - filled = st_row and (st_row["status"] or "").strip().lower() == "active" - if filled: - _sync_monitor_from_ctp( - conn, mid, sym, direction, mode, capital=_capital(conn), - ) - 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) - cancel_monitor_exit_orders(conn, dict(mon_row), mode=mode) - except Exception as exc: - logger.warning("清理旧版止盈止损挂单失败: %s", exc) - conn.commit() - _push_position_snapshot_async(fast=False) - msg = ( - f"开仓成功 · {lots} 手" - if filled - else ( - f"委托已提交 · {lots} 手挂单中" - f"({get_pending_order_timeout_sec(get_setting) // 60} 分钟未成交自动撤单)" - ) - ) - conn.commit() - send_wechat_msg(f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}") - conn.close() - _push_position_snapshot_async() - return jsonify({ - "ok": True, - "result": result, - "lots": lots, - "message": msg if offset.startswith("open") else "委托已提交柜台", - "filled": filled if offset.startswith("open") else None, - }) - except (ValueError, RuntimeError) as exc: - conn.close() - return jsonify({"ok": False, "error": str(exc)}), 400 - except Exception as exc: - conn.close() - return jsonify({"ok": False, "error": str(exc)}), 500 - - @app.route("/api/ctp/connect", methods=["POST"]) - @login_required - def api_ctp_connect(): - from vnpy_bridge import ctp_start_connect - - mode = get_trading_mode(get_setting) - body = request.get_json(silent=True) or {} - force = bool(body.get("force")) - info = ctp_start_connect(mode, force=force) - st = info.get("status") or ctp_status(mode) - acc = _ctp_account(mode) if st.get("connected") else {} - if st.get("connected"): - return jsonify({"ok": True, "status": st, "account": acc}) - if info.get("connecting") or info.get("started"): - return jsonify({ - "ok": True, - "connecting": True, - "status": st, - "account": acc, - }) - if info.get("cooldown"): - return jsonify({ - "ok": False, - "cooldown": True, - "error": st.get("last_error") or "CTP 登录冷却中", - "status": st, - "account": acc, - }), 400 - return jsonify({ - "ok": False, - "error": st.get("last_error") or "CTP 连接未启动", - "status": st, - "account": acc, - }), 400 - - @app.route("/api/ctp/status") - @login_required - def api_ctp_status(): - mode = get_trading_mode(get_setting) - st = ctp_status(mode) - acc = {} - if st.get("connected"): - try: - acc = _ctp_account(mode) - except Exception: - acc = {} - return jsonify({"ok": True, "status": st, "account": acc}) - - @app.route("/api/account_snapshot") - @login_required - def api_account_snapshot(): - conn = get_db() - try: - init_strategy_tables(conn) - mode = get_trading_mode(get_setting) - ctp_st = ctp_status(mode) - capital = _capital(conn) - risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode)) - conn.commit() - ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {} - positions = _ctp_positions(mode) if ctp_st.get("connected") else [] - return jsonify({ - "capital": capital, - "trading_mode": mode, - "trading_mode_label": trading_mode_label(get_setting), - "sizing_mode": get_sizing_mode(get_setting), - "risk_status": risk, - "ctp_status": ctp_st, - "ctp_account": ctp_acc, - "positions": positions, - }) - finally: - conn.close() - - @app.route("/api/recommend/list") - @login_required - def api_recommend_list(): - """只读数据库缓存,不在请求时拉行情。""" - conn = get_db() - try: - payload = _recommend_payload(conn) - return jsonify({"ok": True, **payload}) - finally: - conn.close() - - @app.route("/api/recommend/stream") - @login_required - def api_recommend_stream(): - from queue import Empty - - def generate(): - q = recommend_hub.subscribe() - try: - conn = get_db() - try: - payload = _recommend_payload(conn) - finally: - conn.close() - yield sse_format("recommend", {"ok": True, **payload}) - while True: - try: - msg = q.get(timeout=25) - yield sse_format(msg["event"], msg["data"]) - except Empty: - yield ": heartbeat\n\n" - finally: - recommend_hub.unsubscribe(q) - - return Response( - stream_with_context(generate()), - mimetype="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - - @app.route("/api/recommend/refresh", methods=["POST"]) - @login_required - def api_recommend_refresh(): - """手动触发一次后台刷新(仍写入数据库)。""" - conn = get_db() - try: - init_strategy_tables(conn) - capital = _capital(conn) - mode = get_trading_mode(get_setting) - rows = refresh_recommend_cache( - conn, capital, _main_quote, trading_mode=mode, - max_margin_pct=get_max_margin_pct(get_setting), - ) - max_pct = get_max_margin_pct(get_setting) - payload = _recommend_payload(conn) - recommend_hub.broadcast("recommend", {"ok": True, **payload}) - return jsonify({"ok": True, "count": len(rows), **payload}) - finally: - conn.close() - - @app.route("/api/strategy/trend/preview", methods=["POST"]) - @login_required - def api_trend_preview(): - d = request.get_json(silent=True) or {} - sym = (d.get("symbol") or "").strip() - conn = get_db() - if conn.execute("SELECT id FROM trend_pullback_plans WHERE status='active'").fetchone(): - conn.close() - return jsonify({"ok": False, "error": "已有运行中趋势计划"}), 400 - capital = _capital(conn) - codes = ths_to_codes(sym) - price = fetch_price(sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "") - conn.close() - if not price: - return jsonify({"ok": False, "error": "无法获取现价"}), 400 - plan, err = compute_trend_plan_futures( - direction=d.get("direction") or "long", - stop_loss=float(d.get("stop_loss") or 0), - add_upper=float(d.get("add_upper") or 0), - take_profit=float(d.get("take_profit") or 0), - risk_percent=float(d.get("risk_percent") or get_risk_percent(get_setting)), - capital=capital, - live_price=price, - ths_code=sym, - dca_legs=int(d.get("dca_legs") or 5), - ) - if err: - return jsonify({"ok": False, "error": err}), 400 - return jsonify({"ok": True, "plan": plan}) - - @app.route("/api/strategy/trend/execute", methods=["POST"]) - @login_required - def api_trend_execute(): - d = request.get_json(silent=True) or {} - sym = (d.get("symbol") or "").strip() - conn = get_db() - init_strategy_tables(conn) - err = assert_can_open(conn) - if err: - conn.close() - return jsonify({"ok": False, "error": err}), 403 - capital = _capital(conn) - codes = ths_to_codes(sym) - price = fetch_price(sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "") - plan, perr = compute_trend_plan_futures( - direction=d.get("direction") or "long", - stop_loss=float(d.get("stop_loss") or 0), - add_upper=float(d.get("add_upper") or 0), - take_profit=float(d.get("take_profit") or 0), - risk_percent=float(d.get("risk_percent") or get_risk_percent(get_setting)), - capital=capital, - live_price=price or float(d.get("live_price") or 0), - ths_code=sym, - ) - if perr: - conn.close() - return jsonify({"ok": False, "error": perr}), 400 - mode = get_trading_mode(get_setting) - try: - execute_order( - conn, mode=mode, offset="open", symbol=sym, - direction=plan["direction"], lots=plan["first_lots"], price=price, settings=_settings_dict(), - ) - except ValueError as exc: - conn.close() - return jsonify({"ok": False, "error": str(exc)}), 400 - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - cur = conn.execute( - """INSERT INTO trend_pullback_plans ( - status, symbol, symbol_name, direction, stop_loss, add_upper, take_profit, - risk_percent, capital_snapshot, plan_margin, target_lots, first_lots, remainder_lots, - dca_legs, leg_amounts_json, grid_prices_json, first_order_done, avg_entry_price, - lots_open, opened_at - ) VALUES ('active',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?,?)""", - ( - sym, codes.get("name", sym) if codes else sym, plan["direction"], - plan["stop_loss"], plan["add_upper"], plan["take_profit"], - plan["risk_percent"], plan["capital_snapshot"], plan["plan_margin"], - plan["target_lots"], plan["first_lots"], plan["remainder_lots"], - plan["dca_legs"], plan["leg_amounts_json"], plan["grid_prices_json"], - price, plan["first_lots"], now, - ), - ) - plan_id = cur.lastrowid - conn.commit() - conn.close() - send_wechat_msg(f"趋势回调首仓 {sym} {plan['first_lots']}手") - return jsonify({"ok": True, "plan_id": plan_id, "plan": plan}) - - @app.route("/api/strategy/roll/preview", methods=["POST"]) - @login_required - def api_roll_preview(): - d = request.get_json(silent=True) or {} - conn = get_db() - mon_id = int(d.get("monitor_id") or 0) - mon = conn.execute("SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", (mon_id,)).fetchone() - conn.close() - if not mon: - return jsonify({"ok": False, "error": "无有效持仓监控"}), 400 - sym = mon["symbol"] - spec = get_contract_spec(sym) - capital = _capital(get_db()) - preview, err = preview_roll( - direction=mon["direction"], - symbol=sym, - qty_existing=float(mon["lots"]), - entry_existing=float(mon["entry_price"]), - initial_take_profit=float(mon["take_profit"] or 0), - add_mode=d.get("add_mode") or "market", - new_stop_loss=float(d.get("new_stop_loss") or 0), - risk_percent=float(d.get("risk_percent") or 2), - capital_base=capital, - mult=spec["mult"], - add_price=float(d.get("add_price") or mon["entry_price"]), - fib_upper=d.get("fib_upper"), - fib_lower=d.get("fib_lower"), - legs_done=int(d.get("legs_done") or 0), - ) - if err: - return jsonify({"ok": False, "error": err}), 400 - return jsonify({"ok": True, "preview": preview}) - - @app.route("/api/strategy/roll/execute", methods=["POST"]) - @login_required - def api_roll_execute(): - d = request.get_json(silent=True) or {} - conn = get_db() - init_strategy_tables(conn) - mon_id = int(d.get("monitor_id") or 0) - mon = conn.execute("SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", (mon_id,)).fetchone() - if not mon: - conn.close() - return jsonify({"ok": False, "error": "无有效持仓监控"}), 400 - if conn.execute("SELECT id FROM trend_pullback_plans WHERE status='active'").fetchone(): - conn.close() - return jsonify({"ok": False, "error": "趋势回调运行中,不可滚仓"}), 400 - sym = mon["symbol"] - spec = get_contract_spec(sym) - capital = _capital(conn) - prev, err = preview_roll( - direction=mon["direction"], - symbol=sym, - qty_existing=float(mon["lots"]), - entry_existing=float(mon["entry_price"]), - initial_take_profit=float(mon["take_profit"] or 0), - add_mode=d.get("add_mode") or "market", - new_stop_loss=float(d.get("new_stop_loss") or 0), - risk_percent=float(d.get("risk_percent") or 2), - capital_base=capital, - mult=spec["mult"], - add_price=float(d.get("add_price") or mon["entry_price"]), - ) - if err: - conn.close() - return jsonify({"ok": False, "error": err}), 400 - price = float(prev["add_price"]) - mode = get_trading_mode(get_setting) - try: - execute_order( - conn, mode=mode, offset="open", symbol=sym, - direction=mon["direction"], lots=int(prev["add_lots"]), price=price, settings=_settings_dict(), - ) - except ValueError as exc: - conn.close() - return jsonify({"ok": False, "error": str(exc)}), 400 - new_lots = int(mon["lots"]) + int(prev["add_lots"]) - new_avg = prev["avg_entry_after"] - new_sl = prev["new_stop_loss"] - conn.execute( - "UPDATE trade_order_monitors SET lots=?, entry_price=?, stop_loss=? WHERE id=?", - (new_lots, new_avg, new_sl, mon_id), - ) - grp = conn.execute( - "SELECT * FROM roll_groups WHERE order_monitor_id=? AND status='active'", - (mon_id,), - ).fetchone() - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - if grp: - gid = grp["id"] - leg_n = int(grp["leg_count"] or 0) + 1 - conn.execute( - "UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?", - (leg_n, new_sl, now, gid), - ) - else: - cur = conn.execute( - """INSERT INTO roll_groups ( - order_monitor_id, symbol, direction, initial_take_profit, initial_stop_loss, - current_stop_loss, risk_percent, leg_count, status, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,1,'active',?,?)""", - (mon_id, sym, mon["direction"], mon["take_profit"], mon["stop_loss"], new_sl, - float(d.get("risk_percent") or 2), now, now), - ) - gid = cur.lastrowid - leg_n = 1 - conn.execute( - """INSERT INTO roll_legs (roll_group_id, leg_index, add_mode, fill_price, lots, new_stop_loss, status, created_at) - VALUES (?,?,?,?,?,?, 'filled', ?)""", - (gid, leg_n, d.get("add_mode") or "market", price, int(prev["add_lots"]), new_sl, now), - ) - conn.commit() - conn.close() - return jsonify({"ok": True, "preview": prev}) - - @app.route("/api/strategy/trend/stop", methods=["POST"]) - @login_required - def api_trend_stop(): - d = request.get_json(silent=True) or {} - plan_id = int(d.get("plan_id") or 0) - conn = get_db() - plan = conn.execute("SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (plan_id,)).fetchone() - if not plan: - conn.close() - return jsonify({"ok": False, "error": "计划不存在"}), 404 - mode = get_trading_mode(get_setting) - price = fetch_price(plan["symbol"]) or float(plan["avg_entry_price"] or 0) - try: - if int(plan["lots_open"] or 0) > 0: - execute_order( - conn, mode=mode, offset="close", symbol=plan["symbol"], - direction=plan["direction"], lots=int(plan["lots_open"]), price=price, settings=_settings_dict(), - ) - except ValueError: - pass - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - conn.execute( - "UPDATE trend_pullback_plans SET status='stopped_manual', message=?, opened_at=opened_at WHERE id=?", - ("手动结束", plan_id), - ) - save_snapshot( - conn, strategy_type=STRATEGY_TREND, source_id=plan_id, - symbol=plan["symbol"], direction=plan["direction"], result_label="手动结束", - payload=dict(plan), opened_at=plan["opened_at"] or "", - ) - on_user_initiated_close(conn, trading_day=trading_day_label()) - conn.commit() - conn.close() - return jsonify({"ok": True}) - - def check_trend_plans(app_ref): - """后台:趋势补仓与止盈。""" - conn = get_db() - init_strategy_tables(conn) - rows = conn.execute("SELECT * FROM trend_pullback_plans WHERE status='active'").fetchall() - mode = get_trading_mode(get_setting) - for plan in rows: - sym = plan["symbol"] - price = fetch_price(sym) - if not price: - continue - direction = plan["direction"] - tp = float(plan["take_profit"] or 0) - if tp > 0: - hit_tp = (direction == "long" and price >= tp) or (direction == "short" and price <= tp) - if hit_tp: - try: - execute_order( - conn, mode=mode, offset="close", symbol=sym, direction=direction, - lots=int(plan["lots_open"] or 0), price=price, settings=_settings_dict(), - ) - except ValueError: - pass - conn.execute( - "UPDATE trend_pullback_plans SET status='stopped_tp', message=? WHERE id=?", - ("程序止盈", plan["id"]), - ) - save_snapshot( - conn, strategy_type=STRATEGY_TREND, source_id=plan["id"], - symbol=sym, direction=direction, result_label="止盈", - payload=dict(plan), opened_at=plan["opened_at"] or "", - ) - send_wechat_msg(f"趋势回调止盈 {sym}") - continue - try: - grid = json.loads(plan["grid_prices_json"] or "[]") - legs = json.loads(plan["leg_amounts_json"] or "[]") - except Exception: - grid, legs = [], [] - done = int(plan["legs_done"] or 0) - if done < len(grid) and done < len(legs): - level = float(grid[done]) - if trend_dca_level_reached(direction, price, level): - add_lots = int(legs[done]) - try: - execute_order( - conn, mode=mode, offset="open", symbol=sym, direction=direction, - lots=add_lots, price=price, settings=_settings_dict(), - ) - new_open = int(plan["lots_open"] or 0) + add_lots - old_avg = float(plan["avg_entry_price"] or price) - new_avg = (old_avg * int(plan["lots_open"] or 0) + price * add_lots) / new_open if new_open else price - conn.execute( - """UPDATE trend_pullback_plans SET legs_done=?, lots_open=?, avg_entry_price=? WHERE id=?""", - (done + 1, new_open, new_avg, plan["id"]), - ) - send_wechat_msg(f"趋势回调补仓 {sym} +{add_lots}手 @档位{done+1}") - except ValueError: - pass - conn.commit() - conn.close() - - app._check_trend_plans = check_trend_plans - - @app.route("/settings/trading", methods=["POST"]) - @login_required - def settings_trading_post(): - return redirect(url_for("settings")) - - def hook_review_mood(conn, behavior_tags: str, exit_trigger: str, exit_supplement: str): - if parse_mood_issues(behavior_tags): - on_mood_journal_freeze(conn, trading_day=trading_day_label()) - if (exit_trigger or "").strip() == "手动平仓" and (exit_supplement or "").strip(): - reduce_cooloff_after_journal(conn, trading_day=trading_day_label()) - - app._risk_review_hook = hook_review_mood - - from db_conn import DB_PATH - - def _init_tables(conn): - init_strategy_tables(conn) - - start_recommend_worker( - db_path=DB_PATH, - get_capital_fn=_capital, - quote_fn=_main_quote, - init_tables_fn=_init_tables, - get_mode_fn=lambda: get_trading_mode(get_setting), - get_max_margin_pct_fn=lambda: get_max_margin_pct(get_setting), - get_sizing_mode_fn=lambda: get_sizing_mode(get_setting), - get_fixed_lots_fn=lambda: get_fixed_lots(get_setting), - ) - start_ctp_reconnect_worker(get_mode_fn=lambda: get_trading_mode(get_setting)) - start_ctp_premarket_connect_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, - get_capital_fn=_capital, - get_be_tick_buffer_fn=lambda: get_trailing_be_tick_buffer(get_setting), - notify_fn=send_wechat_msg, - interval=1, - ) - _pos_refresh_tick = {"n": 0} - - def _position_worker_refresh() -> dict: - _pos_refresh_tick["n"] += 1 - # 每秒轻量刷新;每 5 秒做一次 CTP 持仓/挂单对账,避免频繁 query 导致 vnctptd 崩溃 - return _refresh_trading_live_snapshot(fast=(_pos_refresh_tick["n"] % 5 != 0)) - - start_position_worker( - refresh_fn=_position_worker_refresh, - interval=1, - ) - _bootstrap_trading_runtime() - start_ctp_fee_worker( - get_mode_fn=lambda: get_trading_mode(get_setting), - get_setting_fn=get_setting, - set_setting_fn=set_setting, - ) +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""期货下单、可开仓品种、策略交易路由注册。""" +from __future__ import annotations + +import json +import logging +import threading +from datetime import datetime +from typing import Any, Callable, Optional + +from flask import flash, jsonify, redirect, render_template, request, url_for, Response, stream_with_context + +from contract_specs import calc_position_metrics, get_contract_spec +from fee_specs import calc_fee_breakdown +from kline_stream import sse_format +from market_sessions import is_trading_session +from position_sizing import ( + MODE_AMOUNT, + MODE_FIXED, + DEFAULT_MAX_ORDER_LOTS, + calc_lots_by_amount, + calc_lots_by_risk, + calc_margin_usage_pct, + calc_order_tick_metrics, + normalize_sizing_mode, +) +from recommend_store import ( + recommend_payload, + refresh_recommend_cache, +) +from recommend_stream import recommend_hub, schedule_recommend_refresh, start_recommend_worker +from position_stream import position_hub, start_position_worker +from ctp_reconnect import start_ctp_reconnect_worker +from ctp_premarket_connect import start_ctp_premarket_connect_worker +from ctp_fee_worker import start_ctp_fee_worker +from order_pending import ( + cancel_pending_monitor, + pending_auto_cancel_remaining, + reconcile_pending_orders, +) +from db_conn import execute_retry +from sl_tp_guard import ( + cancel_monitor_exit_orders, + ensure_monitor_order_columns, + monitor_order_status, + monitor_source_label, + place_monitor_exit_orders, + reconcile_monitors_without_position, + start_sl_tp_guard_worker, + write_manual_close_trade_log, +) +from risk.account_risk_lib import ( + assert_can_open, + get_risk_status, + on_mood_journal_freeze, + on_user_initiated_close, + parse_mood_issues, + reduce_cooloff_after_journal, + trading_day_label, +) +from strategy.strategy_db import init_strategy_tables +from strategy.strategy_roll_lib import preview_roll +from strategy.strategy_snapshot_lib import list_snapshots, save_snapshot +from strategy.strategy_trend_lib import compute_trend_plan_futures, trend_dca_level_reached +from strategy.strategy_snapshot_lib import STRATEGY_ROLL, STRATEGY_TREND +from symbols import ths_to_codes, resolve_main_contract, PRODUCTS, PRODUCT_CATEGORIES +from trading_context import ( + TRADING_MODE_LIVE, + TRADING_MODE_SIM, + get_account_capital, + get_fixed_amount, + get_fixed_lots, + get_max_margin_pct, + get_pending_order_timeout_min, + get_pending_order_timeout_sec, + get_risk_percent, + get_sizing_mode, + get_trailing_be_tick_buffer, + get_trading_mode, + trading_mode_label, +) +from ctp_symbol import ths_to_vnpy_symbol +from vnpy_bridge import ( + _ctp_td_lock, + ctp_cancel_order, + ctp_connect, + ctp_get_account, + ctp_get_tick_price, + ctp_list_active_orders, + ctp_list_positions, + ctp_status, + execute_order, + get_bridge, + set_position_refresh_callback, +) + + +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 + + def _sizing_mode_label(mode: str) -> str: + m = normalize_sizing_mode(mode) + if m == MODE_AMOUNT: + return "固定金额" + return "固定手数" + + def _schedule_recommend_refresh() -> None: + from db_conn import DB_PATH + + schedule_recommend_refresh( + db_path=DB_PATH, + get_capital_fn=_capital, + quote_fn=_main_quote, + init_tables_fn=lambda c: init_strategy_tables(c), + get_mode_fn=lambda: get_trading_mode(get_setting), + get_max_margin_pct_fn=lambda: get_max_margin_pct(get_setting), + get_sizing_mode_fn=lambda: get_sizing_mode(get_setting), + get_fixed_lots_fn=lambda: get_fixed_lots(get_setting), + ) + + def _recommend_payload(conn) -> dict: + mode = get_trading_mode(get_setting) + return recommend_payload( + conn, + live_capital=_capital(conn), + max_margin_pct=get_max_margin_pct(get_setting), + trading_mode=mode, + sizing_mode=get_sizing_mode(get_setting), + fixed_lots=get_fixed_lots(get_setting), + ) + + def _settings_dict() -> dict: + return { + "trading_mode": get_trading_mode(get_setting), + "position_sizing_mode": get_sizing_mode(get_setting), + "risk_percent": str(get_risk_percent(get_setting)), + "max_margin_pct": str(get_max_margin_pct(get_setting)), + } + + def _capital(conn) -> float: + return get_account_capital(conn, get_setting) + + def _main_quote(product_ths: str) -> Optional[dict]: + for p in PRODUCTS: + if p["ths"] == product_ths: + main = resolve_main_contract(p) + if not main: + return None + sym = main.get("ths_code") or "" + codes = ths_to_codes(sym) + price = None + if codes: + price = fetch_price( + sym, + codes.get("market_code", ""), + codes.get("sina_code", ""), + ) + return { + "ths_code": sym, + "price": price, + "display": main.get("display") or sym, + "name": main.get("name") or p.get("name"), + } + return None + + def _ctp_account(mode: str) -> dict: + try: + return ctp_get_account(mode) + except Exception: + return {} + + def _ctp_positions( + mode: str, + *, + refresh_if_empty: bool = True, + refresh_margin: bool = False, + ) -> list: + try: + return ctp_list_positions( + mode, + refresh_if_empty=refresh_if_empty, + refresh_margin=refresh_margin, + ) + except Exception: + return [] + + def _ctp_pos_to_ths_code(p: dict) -> str: + sym = (p.get("symbol") or "").strip() + if not sym: + return "" + codes = ths_to_codes(sym) + if codes: + return codes.get("ths_code") or sym + return sym + + def _ensure_monitors_from_ctp(conn, mode: str) -> None: + """CTP 有持仓但本地无监控时,自动补写一条 active 记录供展示。""" + if not ctp_status(mode).get("connected"): + return + for p in _ctp_positions(mode, refresh_if_empty=True): + lots = int(p.get("lots") or 0) + if lots <= 0: + continue + direction = p.get("direction") or "long" + ths = _ctp_pos_to_ths_code(p) + if not ths: + continue + existing = _find_active_monitor(conn, ths, direction) + if existing: + _sync_monitor_from_ctp( + conn, int(existing["id"]), ths, direction, mode, ctp=p, + capital=_capital(conn), + ) + continue + sl, tp, trailing_be, initial_sl = _restore_sl_tp_from_closed(conn, ths, direction) + ctp_open = (p.get("open_time") or "").strip() + mid = _upsert_open_monitor( + conn, + sym=ths, + direction=direction, + lots=lots, + price=float(p.get("avg_price") or 0), + sl=sl, + tp=tp, + trailing_be=trailing_be, + ctp_open_time=ctp_open or None, + monitor_type="ctp_sync", + ) + if initial_sl is not None and sl is not None: + conn.execute( + "UPDATE trade_order_monitors SET initial_stop_loss=? WHERE id=?", + (initial_sl, mid), + ) + + def _match_ctp_symbol(ctp_sym: str, ths: str) -> bool: + a = (ctp_sym or "").lower() + b = (ths or "").lower() + if a == b: + return True + if a and b and a.split(".")[0] == b.split(".")[0]: + return True + try: + vnpy_sym, _ = ths_to_vnpy_symbol(ths) + if a == vnpy_sym.lower(): + return True + except Exception: + pass + try: + vnpy_sym, _ = ths_to_vnpy_symbol(ctp_sym) + if vnpy_sym.lower() == b.split(".")[0]: + return True + except Exception: + pass + return False + + def _holding_duration(open_time: str, now_iso: str) -> str: + try: + from app import calc_holding_duration + open_s = (open_time or "").strip().replace("T", " ")[:19] + now_s = (now_iso or "").strip().replace("T", " ")[:19] + if not open_s or not now_s: + return "" + return calc_holding_duration(open_s, now_s) + except Exception: + return "" + + def _restore_sl_tp_from_closed(conn, sym: str, direction: str) -> tuple: + """重启后从最近关闭的同品种监控恢复止盈止损。""" + direction = (direction or "long").strip().lower() + for r in conn.execute( + "SELECT symbol, direction, stop_loss, take_profit, trailing_be, initial_stop_loss " + "FROM trade_order_monitors WHERE status='closed' ORDER BY id DESC LIMIT 80" + ).fetchall(): + row = dict(r) + if (row.get("direction") or "long") != direction: + continue + if not _match_ctp_symbol(sym, row.get("symbol") or ""): + continue + if row.get("stop_loss") is None and row.get("take_profit") is None: + continue + return ( + row.get("stop_loss"), + row.get("take_profit"), + int(row.get("trailing_be") or 0), + row.get("initial_stop_loss"), + ) + return None, None, 0, None + + def _ctp_position_keys(mode: str) -> set[tuple[str, str]]: + keys: set[tuple[str, str]] = set() + for p in _ctp_positions(mode): + lots = int(p.get("lots") or 0) + if lots <= 0: + continue + sym = (p.get("symbol") or "").lower() + direction = p.get("direction") or "long" + keys.add((sym, direction)) + return keys + + def _monitor_matches_ctp_position(mon: dict, position_keys: set[tuple[str, str]]) -> bool: + ms = mon.get("symbol") or "" + md = mon.get("direction") or "long" + for ps, pd in position_keys: + if pd != md: + continue + if _match_ctp_symbol(ps, ms): + return True + return False + + def _sync_trade_monitors_with_ctp(conn, mode: str) -> int: + """关闭无对应 CTP 持仓的监控,并撤销残留止盈止损挂单。""" + return reconcile_monitors_without_position(conn, mode) + + def _effective_active_position_count(conn, mode: str) -> int: + if ctp_status(mode).get("connected"): + return len(_ctp_position_keys(mode)) + row = conn.execute( + "SELECT COUNT(*) AS n FROM trade_order_monitors WHERE status='active'" + ).fetchone() + return int(row["n"] or 0) + + def _build_pending_orders(conn, mode: str) -> list[dict]: + pending: list[dict] = [] + for r in conn.execute( + "SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC" + ).fetchall(): + mon = dict(r) + sym = mon.get("symbol") or "" + direction = mon.get("direction") or "long" + lots = int(mon.get("lots") or 0) + base = { + "symbol_code": sym, + "symbol": mon.get("symbol_name") or sym, + "direction": direction, + "direction_label": "做多" if direction == "long" else "做空", + "lots": lots, + "source": "monitor", + "monitor_id": mon.get("id"), + } + sl = mon.get("stop_loss") + tp = mon.get("take_profit") + if sl is not None: + pending.append({ + **base, + "order_kind": "stop_loss", + "label": "止损监控", + "price": float(sl), + }) + if tp is not None: + pending.append({ + **base, + "order_kind": "take_profit", + "label": "止盈监控", + "price": float(tp), + }) + for r in conn.execute( + "SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC" + ).fetchall(): + mon = dict(r) + sym = mon.get("symbol") or "" + pending.append({ + "symbol_code": sym, + "symbol": mon.get("symbol_name") or sym, + "direction": mon.get("direction") or "long", + "direction_label": "做多" if (mon.get("direction") or "long") == "long" else "做空", + "lots": int(mon.get("lots") or 0), + "price": float(mon.get("order_price") or mon.get("entry_price") or 0), + "order_kind": "open_pending", + "label": "开仓挂单中", + "source": "monitor", + "monitor_id": mon.get("id"), + "can_cancel_order": is_trading_session(), + "cancel_allowed": is_trading_session(), + }) + ctp_st = ctp_status(mode) + if ctp_st.get("connected"): + for o in _ctp_active_orders(mode): + sym = o.get("symbol") or "" + offset_s = (o.get("offset") or "").upper() + kind = "limit" + label = "委托挂单" + if "CLOSE" in offset_s: + label = "平仓委托" + pending.append({ + "symbol_code": sym, + "symbol": sym, + "direction": o.get("direction") or "long", + "direction_label": "做多" if o.get("direction") == "long" else "做空", + "lots": int(o.get("lots") or 0), + "price": float(o.get("price") or 0), + "order_kind": kind, + "label": label, + "source": "ctp", + "order_id": o.get("order_id"), + "can_cancel_order": is_trading_session(), + "cancel_allowed": is_trading_session(), + }) + return pending + + def _ctp_active_orders(mode: str) -> list: + try: + return ctp_list_active_orders(mode) + except Exception: + return [] + + def _canonical_position_key(symbol: str, direction: str) -> str: + sym = (symbol or "").strip() + d = (direction or "long").strip().lower() + try: + vnpy_sym, _ = ths_to_vnpy_symbol(sym) + return f"{vnpy_sym.lower()}:{d}" + except Exception: + return f"{sym.lower()}:{d}" + + def _find_active_monitor(conn, symbol: str, direction: str) -> Optional[dict]: + direction = (direction or "long").strip().lower() + for r in conn.execute( + "SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC" + ).fetchall(): + row = dict(r) + if (row.get("direction") or "long") != direction: + continue + if _match_ctp_symbol(symbol, row.get("symbol") or ""): + return row + return None + + def _close_duplicate_monitors(conn, symbol: str, direction: str, keep_id: int) -> None: + direction = (direction or "long").strip().lower() + for r in conn.execute( + "SELECT id, symbol, direction FROM trade_order_monitors WHERE status='active'" + ).fetchall(): + if int(r["id"]) == int(keep_id): + continue + if (r["direction"] or "long") != direction: + continue + if _match_ctp_symbol(symbol, r["symbol"] or ""): + conn.execute( + "UPDATE trade_order_monitors SET status='closed' WHERE id=?", + (r["id"],), + ) + + def _upsert_open_monitor( + conn, + *, + sym: str, + direction: str, + lots: int, + price: float, + sl, + tp, + trailing_be: int, + ctp_open_time: Optional[str] = None, + open_time: Optional[str] = None, + monitor_type: str = "manual", + status: str = "active", + vt_order_id: Optional[str] = None, + order_price: Optional[float] = None, + ) -> int: + ensure_monitor_order_columns(conn) + codes = ths_to_codes(sym) or {} + sl_f = float(sl) if sl not in (None, "") else None + tp_f = float(tp) if tp not in (None, "") else None + now_s = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + status_val = status if status in ("pending", "active") else "active" + order_px = float(order_price if order_price is not None else price) + existing = _find_active_monitor(conn, sym, direction) + if not existing: + for r in conn.execute( + "SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC" + ).fetchall(): + row = dict(r) + if (row.get("direction") or "long") != (direction or "long").strip().lower(): + continue + if _match_ctp_symbol(sym, row.get("symbol") or ""): + existing = row + break + if existing: + mid = int(existing["id"]) + existing_status = (existing.get("status") or "active").strip().lower() + if existing_status == "active" and status_val == "pending": + status_val = "active" + initial_sl = existing.get("initial_stop_loss") + if sl_f is None: + sl_f = float(existing["stop_loss"]) if existing.get("stop_loss") is not None else None + if tp_f is None: + tp_f = float(existing["take_profit"]) if existing.get("take_profit") is not None else None + if sl_f is not None and initial_sl is None: + initial_sl = sl_f + if not trailing_be: + trailing_be = int(existing.get("trailing_be") or 0) + open_time_val = (existing.get("open_time") or "").strip() or now_s + if open_time: + open_time_val = open_time + elif monitor_type == "ctp_sync" and ctp_open_time: + open_time_val = ctp_open_time + vt_val = vt_order_id or existing.get("vt_order_id") + conn.execute( + """UPDATE trade_order_monitors SET + symbol=?, symbol_name=?, market_code=?, lots=?, entry_price=?, + stop_loss=?, take_profit=?, initial_stop_loss=?, trailing_be=?, open_time=?, + monitor_type=?, status=?, vt_order_id=?, order_price=? + WHERE id=?""", + ( + sym, + codes.get("name", sym), + codes.get("market_code", ""), + lots, + price, + sl_f, + tp_f, + initial_sl, + trailing_be, + open_time_val, + monitor_type if monitor_type != "manual" else (existing.get("monitor_type") or "manual"), + status_val, + vt_val, + order_px, + mid, + ), + ) + else: + if open_time: + open_time_val = open_time + elif monitor_type == "ctp_sync" and ctp_open_time: + open_time_val = ctp_open_time + else: + open_time_val = now_s + conn.execute( + """INSERT INTO trade_order_monitors ( + symbol, symbol_name, market_code, direction, lots, entry_price, + stop_loss, take_profit, initial_stop_loss, trailing_be, + open_time, monitor_type, status, vt_order_id, order_price + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + sym, + codes.get("name", sym), + codes.get("market_code", ""), + direction, + lots, + price, + sl_f, + tp_f, + sl_f, + trailing_be, + open_time_val, + monitor_type, + status_val, + vt_order_id, + order_px, + ), + ) + mid = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + if status_val == "active": + _close_duplicate_monitors(conn, sym, direction, mid) + return mid + + def _sync_monitor_from_ctp( + conn, + mid: int, + sym: str, + direction: str, + mode: str, + *, + ctp: Optional[dict] = None, + capital: float = 0.0, + ) -> None: + """CTP 同步:均价、现价、保证金、仓位占比写入数据库;不覆盖期货下单的开仓时间。""" + positions = [ctp] if ctp else _ctp_positions(mode, refresh_if_empty=False, refresh_margin=True) + for p in positions: + if not p or int(p.get("lots") or 0) <= 0: + continue + if (p.get("direction") or "long") != direction: + continue + if not _match_ctp_symbol(p.get("symbol") or "", sym): + continue + row = conn.execute( + "SELECT open_time, monitor_type FROM trade_order_monitors WHERE id=?", (mid,), + ).fetchone() + db_open = (row["open_time"] or "").strip() if row else "" + monitor_type = (row["monitor_type"] or "manual").strip().lower() if row else "manual" + ctp_open = (p.get("open_time") or "").strip() or None + open_time_val = db_open + if monitor_type == "ctp_sync" and ctp_open: + open_time_val = ctp_open + lots = int(p.get("lots") or 0) + entry = float(p.get("avg_price") or 0) + ctp_margin = float(p.get("margin") or 0) + float_pnl = p.get("pnl") + if float_pnl is not None: + float_pnl = round(float(float_pnl), 2) + mark = None + if ctp_status(mode).get("connected"): + mark = ctp_get_tick_price(mode, sym) + if mark is None or mark <= 0: + mark = entry if entry else None + margin = ctp_margin if ctp_margin > 0 else None + position_pct = None + if margin and capital > 0: + position_pct = round(float(margin) / float(capital) * 100, 2) + execute_retry( + conn, + """UPDATE trade_order_monitors SET lots=?, entry_price=?, + open_time=?, margin=?, position_pct=?, mark_price=?, float_pnl=? + WHERE id=?""", + ( + lots, + entry, + open_time_val, + margin, + position_pct, + float(mark) if mark else None, + float_pnl, + mid, + ), + ) + return + + def _sync_monitor_lots_from_ctp( + conn, mid: int, sym: str, direction: str, mode: str, *, ctp: Optional[dict] = None, + ) -> None: + _sync_monitor_from_ctp( + conn, mid, sym, direction, mode, ctp=ctp, capital=_capital(conn), + ) + + def _compose_position_row( + conn, + *, + mon: Optional[dict], + ctp: Optional[dict], + mode: str, + capital: float, + now_iso: str, + fast: bool = False, + ) -> Optional[dict]: + if not mon and not ctp: + return None + + if mon: + sym = (mon.get("symbol") or "").strip() + direction = mon.get("direction") or "long" + lots = int(mon.get("lots") or 0) + entry = float(mon.get("entry_price") or 0) + source_label = monitor_source_label(mon.get("monitor_type")) + open_time = (mon.get("open_time") or "").strip() + open_time_source = "order" + margin = mon.get("margin") + position_pct = mon.get("position_pct") + mark = mon.get("mark_price") + float_pnl = mon.get("float_pnl") + if float_pnl is not None: + float_pnl = round(float(float_pnl), 2) + else: + sym = (ctp.get("symbol") or "").strip() + direction = ctp.get("direction") or "long" + lots = int(ctp.get("lots") or 0) + entry = float(ctp.get("avg_price") or 0) + source_label = "CTP 柜台" + open_time = (ctp.get("open_time") or "").strip() + open_time_source = "ctp" + margin = None + position_pct = None + mark = None + float_pnl = ctp.get("pnl") + if float_pnl is not None: + float_pnl = round(float(float_pnl), 2) + + if lots <= 0: + return None + + if ctp: + if ctp.get("pnl") is not None: + float_pnl = round(float(ctp["pnl"]), 2) + if not mon: + ctp_lots = int(ctp.get("lots") or 0) + if ctp_lots > 0: + lots = ctp_lots + if float(ctp.get("avg_price") or 0) > 0: + entry = float(ctp.get("avg_price") or 0) + ctp_margin = float(ctp.get("margin") or 0) + if (margin is None or float(margin or 0) <= 0) and ctp_margin > 0: + margin = ctp_margin + + codes = ths_to_codes(sym) + tick = calc_order_tick_metrics(sym, lots, entry) + 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 + holding = _holding_duration(open_time, now_iso) if open_time else "" + + if (mark is None or float(mark or 0) <= 0) and not fast and ctp_status(mode).get("connected"): + live_mark = ctp_get_tick_price(mode, sym) + if live_mark and live_mark > 0: + mark = live_mark + if (mark is None or float(mark or 0) <= 0) and not fast and codes: + mark = fetch_price( + sym, + codes.get("market_code", ""), + codes.get("sina_code", ""), + ) + if mark is None or mark <= 0: + mark = entry if entry else None + close_est = float(mark) if mark and mark > 0 else entry + if float_pnl is None and mark and entry: + pos_tmp = calc_position_metrics( + direction, entry, sl or entry, tp or entry, lots, mark, capital, sym, + ) + float_pnl = pos_tmp.get("float_pnl") + + 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, + ) + if margin is None or float(margin or 0) <= 0: + ctp_margin = float(ctp.get("margin") or 0) if ctp else 0.0 + est_margin = pos_metrics.get("margin") + margin = ctp_margin if ctp_margin > 0 else est_margin + margin_source = "ctp" if ctp_margin > 0 else "estimate" + else: + margin_source = "ctp" + if position_pct is None or float(position_pct or 0) <= 0: + position_pct = ( + round(float(margin) / capital * 100, 2) + if capital > 0 and margin + else pos_metrics.get("position_pct") + ) + else: + position_pct = float(position_pct) + order_st = monitor_order_status( + mon or {}, mode=mode, ths_code=sym, direction=direction, + ) + pending_for_row: list[dict] = [] + if sl is not None: + pending_for_row.append({ + "order_kind": "stop_loss", + "label": "止损监控", + "price": sl, + "lots": lots, + "source": "monitor", + "monitor_id": mon["id"] if mon else None, + }) + if tp is not None: + pending_for_row.append({ + "order_kind": "take_profit", + "label": "止盈监控", + "price": tp, + "lots": lots, + "source": "monitor", + "monitor_id": mon["id"] if mon else None, + }) + row_key = _canonical_position_key(sym, direction) + return { + "key": row_key, + "source": "ctp" if ctp else "local", + "source_label": source_label, + "sync_pending": ctp is None and mon is not None, + "monitor_id": mon["id"] if mon else None, + "symbol": codes.get("name", sym) if codes else (mon.get("symbol_name") if mon else sym), + "symbol_code": sym, + "direction": direction, + "direction_label": "做多" if direction == "long" else "做空", + "lots": lots, + "entry_price": entry, + "stop_loss": sl, + "take_profit": tp, + "open_time": open_time or None, + "open_time_source": open_time_source or None, + "holding_duration": holding or None, + "mark_price": mark, + "current_price": mark, + "margin": margin, + "margin_source": margin_source, + "position_pct": position_pct, + "risk_amount": pos_metrics.get("risk_amount") if sl is not None else None, + "reward_amount": pos_metrics.get("reward_amount") if tp is not None else None, + "risk_pct": pos_metrics.get("risk_pct") if sl is not None else None, + "rr_ratio": pos_metrics.get("rr_ratio") if sl is not None and tp is not None else None, + "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"], + "fee_source": fee_info.get("fee_source") or "local", + "est_pnl_net": est_net, + "sl_order_active": order_st.get("sl_monitoring"), + "tp_order_active": order_st.get("tp_monitoring"), + "sl_monitoring": order_st.get("sl_monitoring"), + "tp_monitoring": order_st.get("tp_monitoring"), + "can_place_orders": False, + "tick_value_total": tick.get("tick_value_total"), + "price_precision": tick.get("price_precision"), + "tick_size": tick.get("tick_size"), + "can_close": True, + "close_allowed": is_trading_session(), + "pending_orders": pending_for_row, + "trailing_be": bool(mon.get("trailing_be")) if mon else False, + "trailing_r_locked": int(mon.get("trailing_r_locked") or 0) if mon else 0, + } + + def _compose_pending_row( + mon: dict, + *, + mode: str, + capital: float, + now_iso: str, + ) -> Optional[dict]: + sym = (mon.get("symbol") or "").strip() + direction = (mon.get("direction") or "long").strip().lower() + lots = int(mon.get("lots") or 0) + if not sym or lots <= 0: + return None + order_price = float(mon.get("order_price") or mon.get("entry_price") or 0) + codes = ths_to_codes(sym) + sl = float(mon["stop_loss"]) if mon.get("stop_loss") is not None else None + tp = float(mon["take_profit"]) if mon.get("take_profit") is not None else None + pos_metrics = calc_position_metrics( + direction, order_price, sl or order_price, tp or order_price, lots, order_price, capital, sym, + ) + open_time = (mon.get("open_time") or "").strip() + timeout_sec = get_pending_order_timeout_sec(get_setting) + remain = pending_auto_cancel_remaining(mon, timeout_sec=timeout_sec) + return { + "key": f"{_canonical_position_key(sym, direction)}:pending:{mon.get('id')}", + "order_state": "pending", + "source": "pending", + "source_label": "委托挂单中", + "sync_pending": True, + "monitor_id": mon.get("id"), + "symbol": codes.get("name", sym) if codes else (mon.get("symbol_name") or sym), + "symbol_code": sym, + "direction": direction, + "direction_label": "做多" if direction == "long" else "做空", + "lots": lots, + "entry_price": order_price, + "order_price": order_price, + "stop_loss": sl, + "take_profit": tp, + "open_time": open_time or None, + "holding_duration": _holding_duration(open_time, now_iso) if open_time else None, + "mark_price": order_price, + "current_price": order_price, + "margin": pos_metrics.get("margin"), + "margin_source": "estimate", + "position_pct": pos_metrics.get("position_pct"), + "risk_amount": pos_metrics.get("risk_amount") if sl is not None else None, + "reward_amount": pos_metrics.get("reward_amount") if tp is not None else None, + "rr_ratio": pos_metrics.get("rr_ratio") if sl is not None and tp is not None else None, + "float_pnl": None, + "est_fee": None, + "can_close": False, + "close_allowed": False, + "can_cancel_order": is_trading_session(), + "cancel_allowed": is_trading_session(), + "auto_cancel_sec": remain, + "pending_timeout_sec": timeout_sec, + "pending_timeout_min": max(1, timeout_sec // 60), + "vt_order_id": mon.get("vt_order_id"), + "sl_order_active": False, + "tp_order_active": False, + "sl_monitoring": bool(sl is not None), + "tp_monitoring": bool(tp is not None), + "can_place_orders": False, + "pending_orders": [], + "trailing_be": bool(mon.get("trailing_be")), + "trailing_r_locked": int(mon.get("trailing_r_locked") or 0), + } + + def _reconcile_pending(conn, mode: str, *, capital: float = 0.0) -> None: + reconcile_pending_orders( + conn, + mode, + match_symbol_fn=_match_ctp_symbol, + sync_monitor_fn=_sync_monitor_from_ctp, + capital=capital, + list_positions_fn=_ctp_positions, + timeout_sec=get_pending_order_timeout_sec(get_setting), + ) + + def _build_trading_live_rows(conn, *, fast: bool = False) -> list[dict]: + from zoneinfo import ZoneInfo + tz = ZoneInfo("Asia/Shanghai") + now_iso = datetime.now(tz).strftime("%Y-%m-%dT%H:%M") + mode = get_trading_mode(get_setting) + capital = _capital(conn) + ensure_monitor_order_columns(conn) + + monitors_raw = [ + dict(r) for r in conn.execute( + "SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC" + ).fetchall() + ] + monitor_by_key: dict[str, dict] = {} + for mon in monitors_raw: + key = _canonical_position_key(mon.get("symbol") or "", mon.get("direction") or "long") + if key not in monitor_by_key: + monitor_by_key[key] = mon + + ctp_list: list[dict] = ( + _ctp_positions(mode, refresh_if_empty=not fast, refresh_margin=not fast) + if ctp_status(mode).get("connected") else [] + ) + ctp_by_key: dict[str, dict] = {} + for p in ctp_list: + if int(p.get("lots") or 0) <= 0: + continue + key = _canonical_position_key(p.get("symbol") or "", p.get("direction") or "long") + ctp_by_key[key] = p + + rows: list[dict] = [] + used_ctp_keys: set[str] = set() + + for key, mon in monitor_by_key.items(): + ctp = ctp_by_key.get(key) + if not ctp: + for ck, cp in ctp_by_key.items(): + if ck in used_ctp_keys: + continue + if (cp.get("direction") or "long") != (mon.get("direction") or "long"): + continue + if _match_ctp_symbol(cp.get("symbol") or "", mon.get("symbol") or ""): + ctp = cp + used_ctp_keys.add(ck) + break + elif key in ctp_by_key: + used_ctp_keys.add(key) + if ctp and mon and not fast: + _sync_monitor_from_ctp( + conn, int(mon["id"]), mon.get("symbol") or "", + mon.get("direction") or "long", mode, ctp=ctp, + capital=capital, + ) + mon = _find_active_monitor(conn, mon.get("symbol") or "", mon.get("direction") or "long") or mon + try: + row = _compose_position_row( + conn, mon=mon, ctp=ctp, mode=mode, capital=capital, now_iso=now_iso, + fast=fast, + ) + if row: + rows.append(row) + except Exception as exc: + logger.warning("compose monitor row failed: %s", exc) + + for key, ctp in ctp_by_key.items(): + if key in used_ctp_keys: + continue + matched = False + for uk in used_ctp_keys: + if uk == key: + matched = True + break + if matched: + continue + for existing in rows: + if _match_ctp_symbol( + ctp.get("symbol") or "", existing.get("symbol_code") or "", + ) and (ctp.get("direction") or "long") == (existing.get("direction") or "long"): + matched = True + break + if matched: + continue + mon = _find_active_monitor( + conn, ctp.get("symbol") or "", ctp.get("direction") or "long", + ) + try: + row = _compose_position_row( + conn, mon=mon, ctp=ctp, mode=mode, capital=capital, now_iso=now_iso, + fast=fast, + ) + if row: + rows.append(row) + except Exception as exc: + logger.warning("compose ctp row failed: %s", exc) + + seen: set[str] = set() + deduped: list[dict] = [] + for row in rows: + rk = row.get("key") or f"{row.get('symbol_code')}:{row.get('direction')}" + if rk in seen: + continue + seen.add(rk) + deduped.append(row) + + pending_raw = [ + dict(r) for r in conn.execute( + "SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC" + ).fetchall() + ] + for mon in pending_raw: + try: + prow = _compose_pending_row( + mon, mode=mode, capital=capital, now_iso=now_iso, + ) + if prow: + deduped.insert(0, prow) + except Exception as exc: + logger.warning("compose pending row failed: %s", exc) + return deduped + + def _build_trading_live_payload(conn, *, fast: bool = False) -> dict: + mode = get_trading_mode(get_setting) + ctp_st = ctp_status(mode) + capital = _capital(conn) + if not fast and ctp_st.get("connected"): + _reconcile_pending(conn, mode, capital=capital) + if not fast: + _ensure_monitors_from_ctp(conn, mode) + rows = _build_trading_live_rows(conn, fast=fast) + pending_orders = _build_pending_orders(conn, mode) + risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode)) + return { + "ok": True, + "rows": rows, + "pending_orders": pending_orders, + "capital": capital, + "ctp_status": ctp_st, + "trading_mode_label": trading_mode_label(get_setting), + "risk_status": risk, + "trading_session": is_trading_session(), + "pending_order_timeout_min": get_pending_order_timeout_min(get_setting), + } + + def _refresh_trading_live_snapshot(*, fast: bool = False) -> dict: + mode = get_trading_mode(get_setting) + if not fast and ctp_status(mode).get("connected"): + try: + with _ctp_td_lock: + get_bridge().refresh_positions() + except Exception as exc: + logger.debug("refresh positions before snapshot: %s", exc) + conn = get_db() + try: + init_strategy_tables(conn) + payload = _build_trading_live_payload(conn, fast=fast) + conn.commit() + return payload + finally: + conn.close() + + def _push_position_snapshot_async(*, fast: bool = False) -> None: + def _run() -> None: + try: + payload = _refresh_trading_live_snapshot(fast=fast) + position_hub.broadcast("positions", payload) + except Exception as exc: + logger.debug("push position snapshot: %s", exc) + + threading.Thread(target=_run, daemon=True).start() + + def _bootstrap_trading_runtime() -> None: + """进程启动:立刻读库展示持仓,并异步连 CTP。""" + set_position_refresh_callback( + lambda: _push_position_snapshot_async(fast=False) + ) + + def _warm() -> None: + try: + payload = _refresh_trading_live_snapshot(fast=True) + position_hub.set_snapshot(payload) + position_hub.broadcast("positions", payload) + except Exception as exc: + logger.warning("bootstrap position snapshot: %s", exc) + + threading.Thread(target=_warm, daemon=True, name="position-bootstrap").start() + try: + from vnpy_bridge import ctp_start_connect + mode = get_trading_mode(get_setting) + ctp_start_connect(mode, force=False) + except Exception as exc: + logger.debug("bootstrap ctp connect: %s", exc) + + @app.route("/trade") + @login_required + def trade_page(): + return redirect(url_for("positions")) + + @app.route("/positions") + @login_required + def positions(): + conn = get_db() + try: + init_strategy_tables(conn) + mode = get_trading_mode(get_setting) + ctp_st = ctp_status(mode) + capital = _capital(conn) + risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode)) + ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {} + active_trend = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC LIMIT 1" + ).fetchone() + monitor_count = conn.execute( + "SELECT COUNT(*) AS n FROM trade_order_monitors WHERE status='active'" + ).fetchone()["n"] + roll_count = conn.execute( + "SELECT COUNT(*) AS n FROM roll_groups WHERE status='active'" + ).fetchone()["n"] + conn.commit() + sizing = get_sizing_mode(get_setting) + max_pct = get_max_margin_pct(get_setting) + rec_cache = _recommend_payload(conn) + if rec_cache.get("needs_refresh"): + _schedule_recommend_refresh() + return render_template( + "trade.html", + trading_mode=mode, + trading_mode_label=trading_mode_label(get_setting), + capital=capital, + risk_status=risk, + ctp_status=ctp_st, + ctp_account=ctp_acc, + active_trend=dict(active_trend) if active_trend else None, + monitor_count=monitor_count, + roll_count=roll_count, + sizing_mode=sizing, + sizing_mode_label=_sizing_mode_label(sizing), + fixed_lots=get_fixed_lots(get_setting), + fixed_amount=get_fixed_amount(get_setting), + risk_percent=get_risk_percent(get_setting), + max_margin_pct=get_max_margin_pct(get_setting), + pending_order_timeout_min=get_pending_order_timeout_min(get_setting), + recommend_rows=rec_cache.get("rows") or [], + recommend_updated_at=rec_cache.get("updated_at"), + product_categories=PRODUCT_CATEGORIES, + ) + finally: + conn.close() + + @app.route("/recommend") + @login_required + def recommend_page(): + return redirect(url_for("positions") + "#recommend") + + @app.route("/api/trading/live") + @login_required + def api_trading_live(): + conn = get_db() + try: + init_strategy_tables(conn) + payload = _build_trading_live_payload(conn, fast=True) + position_hub.set_snapshot(payload) + return jsonify(payload) + finally: + conn.close() + + @app.route("/api/trading/stream") + @login_required + def api_trading_stream(): + from queue import Empty + + def generate(): + q = position_hub.subscribe() + try: + snap = position_hub.get_snapshot() + if snap: + yield sse_format("positions", snap) + else: + payload = _refresh_trading_live_snapshot(fast=True) + position_hub.set_snapshot(payload) + yield sse_format("positions", payload) + while True: + try: + msg = q.get(timeout=25) + yield sse_format(msg["event"], msg["data"]) + except Empty: + yield ": heartbeat\n\n" + finally: + position_hub.unsubscribe(q) + + return Response( + generate(), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + + @app.route("/api/trading/monitor/upsert", methods=["POST"]) + @login_required + def api_trading_monitor_upsert(): + """为已有持仓补充/更新本地止盈止损监控。""" + 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) + conn = get_db() + try: + init_strategy_tables(conn) + mon = _find_active_monitor(conn, sym, direction) + has_pos = bool(mon) + ths_sym = sym + if ctp_status(mode).get("connected"): + for p in _ctp_positions(mode, refresh_if_empty=False): + 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) + ths_sym = _ctp_pos_to_ths_code(p) or sym + break + if not has_pos: + return jsonify({"ok": False, "error": "未找到对应持仓"}), 400 + trailing_be = 1 if d.get("trailing_be") else ( + int(mon.get("trailing_be") or 0) if mon else 0 + ) + mid = _upsert_open_monitor( + conn, + sym=ths_sym, + direction=direction, + lots=lots, + price=entry, + sl=sl, + tp=tp, + trailing_be=trailing_be, + ) + conn.commit() + _push_position_snapshot_async() + 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(): + """本地监控模式:清理旧版柜台挂单,不再向交易所挂止盈止损。""" + 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(): + d = request.get_json(silent=True) or {} + try: + monitor_id = int(d.get("monitor_id") or 0) + except (TypeError, ValueError): + monitor_id = 0 + if monitor_id <= 0: + return jsonify({"ok": False, "error": "无效的监控记录"}), 400 + conn = get_db() + try: + init_strategy_tables(conn) + mode = get_trading_mode(get_setting) + row = conn.execute( + "SELECT * FROM trade_order_monitors WHERE id=? AND status IN ('active', 'pending')", + (monitor_id,), + ).fetchone() + if not row: + return jsonify({"ok": False, "error": "记录不存在或已关闭"}), 404 + mon = dict(row) + if (mon.get("status") or "").strip().lower() == "pending": + if not is_trading_session(): + return jsonify({"ok": False, "error": "不在交易时间段,无法撤单"}), 403 + ok, msg = cancel_pending_monitor(conn, mon, mode) + _push_position_snapshot_async(fast=False) + return jsonify({"ok": ok, "message": msg}) + conn.execute( + "UPDATE trade_order_monitors SET status='closed' WHERE id=?", + (monitor_id,), + ) + conn.commit() + _push_position_snapshot_async(fast=False) + return jsonify({"ok": True, "message": "已取消本地止盈止损监控"}) + finally: + conn.close() + + @app.route("/api/trading/monitor/cancel-open", methods=["POST"]) + @login_required + def api_trading_monitor_cancel_open(): + """撤销 pending 开仓委托(柜台撤单 + 关闭本地记录)。""" + d = request.get_json(silent=True) or {} + try: + monitor_id = int(d.get("monitor_id") or 0) + except (TypeError, ValueError): + monitor_id = 0 + if monitor_id <= 0: + return jsonify({"ok": False, "error": "无效的委托记录"}), 400 + conn = get_db() + try: + init_strategy_tables(conn) + mode = get_trading_mode(get_setting) + if not ctp_status(mode).get("connected"): + return jsonify({"ok": False, "error": "请先连接 CTP"}), 400 + if not is_trading_session(): + return jsonify({"ok": False, "error": "不在交易时间段,无法撤单"}), 403 + row = conn.execute( + "SELECT * FROM trade_order_monitors WHERE id=? AND status='pending'", + (monitor_id,), + ).fetchone() + if not row: + return jsonify({"ok": False, "error": "未找到挂单中的开仓委托"}), 404 + ok, msg = cancel_pending_monitor(conn, dict(row), mode) + _push_position_snapshot_async(fast=False) + return jsonify({"ok": ok, "message": msg}) + finally: + conn.close() + + @app.route("/api/trading/order/cancel", methods=["POST"]) + @login_required + def api_trading_order_cancel(): + """撤销柜台未成交委托(按 vt_order_id)。""" + d = request.get_json(silent=True) or {} + order_id = (d.get("order_id") or "").strip() + if not order_id: + 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 + if not is_trading_session(): + return jsonify({"ok": False, "error": "不在交易时间段,无法撤单"}), 403 + ok = ctp_cancel_order(mode, order_id) + _push_position_snapshot_async(fast=False) + if not ok: + return jsonify({"ok": False, "error": "撤单失败,委托可能已成交或已撤销"}), 400 + return jsonify({"ok": True, "message": "撤单已提交"}) + + @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" + capital = _capital(conn) + mon = None + mid = int(d.get("monitor_id") or 0) + if mid: + row = conn.execute( + "SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", + (mid,), + ).fetchone() + if row: + mon = dict(row) + if not mon: + 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 + mid = int(row["id"]) + break + entry = float(mon.get("entry_price") or 0) if mon else 0.0 + if entry <= 0: + 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): + entry = float(p.get("avg_price") or price) + break + try: + execute_order( + conn, mode=mode, offset=offset, symbol=sym, direction=direction, + lots=lots, price=price, settings=_settings_dict(), + order_type="market", + ) + write_manual_close_trade_log( + conn, + mon, + symbol=sym, + direction=direction, + lots=lots, + close_price=price, + entry_price=entry or price, + trading_mode=mode, + capital=capital, + stop_loss=float(mon["stop_loss"]) if mon and mon.get("stop_loss") is not None else None, + take_profit=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 "", + symbol_name=(mon.get("symbol_name") or "") if mon else "", + market_code=(mon.get("market_code") or "") if mon else "", + ) + if mid: + conn.execute( + "UPDATE trade_order_monitors SET status='closed' WHERE id=?", + (mid,), + ) + conn.commit() + try: + from ctp_trade_sync import sync_trade_logs_from_ctp + sync_trade_logs_from_ctp(conn, mode, capital=capital, trading_mode=mode) + conn.commit() + except Exception as exc: + logger.debug("sync trades after close: %s", exc) + conn.close() + _push_position_snapshot_async() + return jsonify({"ok": True, "message": "已平仓;交易记录将按柜台成交同步"}) + except ValueError as exc: + conn.close() + return jsonify({"ok": False, "error": str(exc)}), 400 + + + @app.route("/strategy") + @login_required + @_nav("strategy") + def strategy_page(): + conn = get_db() + init_strategy_tables(conn) + capital = _capital(conn) + active_trend = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC LIMIT 1" + ).fetchone() + monitors = conn.execute( + "SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC" + ).fetchall() + roll_groups = conn.execute( + "SELECT * FROM roll_groups WHERE status='active' ORDER BY id DESC" + ).fetchall() + conn.close() + return render_template( + "strategy.html", + capital=capital, + risk_percent=get_risk_percent(get_setting), + sizing_mode=get_sizing_mode(get_setting), + active_trend=dict(active_trend) if active_trend else None, + monitors=[dict(m) for m in monitors], + roll_groups=[dict(g) for g in roll_groups], + ) + + @app.route("/strategy/records") + @login_required + def strategy_records_page(): + conn = get_db() + init_strategy_tables(conn) + trend, roll = list_snapshots(conn) + conn.close() + return render_template("strategy_records.html", trend_rows=trend, roll_rows=roll) + + @app.route("/api/trade/quote") + @login_required + def api_trade_quote(): + sym = (request.args.get("symbol") or "").strip() + lots = request.args.get("lots") or "1" + if not sym: + return jsonify({"ok": False, "error": "缺少品种"}), 400 + codes = ths_to_codes(sym) + price = fetch_price(sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "") + try: + lots_f = max(1, int(float(lots))) + except (TypeError, ValueError): + lots_f = 1 + metrics = calc_order_tick_metrics(sym, lots_f, price) + spec = get_contract_spec(sym) + name = codes.get("name", sym) if codes else sym + pos_long = pos_short = 0 + mode = get_trading_mode(get_setting) + ctp_st = ctp_status(mode) + if ctp_st.get("connected"): + for p in _ctp_positions(mode): + if not _match_ctp_symbol(p.get("symbol", ""), sym): + continue + if p["direction"] == "long": + pos_long = int(p["lots"]) + else: + pos_short = int(p["lots"]) + max_open = int(_capital(get_db()) / (metrics["margin_per_lot"] or 1)) if metrics.get("margin_per_lot") else 0 + return jsonify({ + "ok": True, + "symbol": sym, + "name": name, + "price": price, + "lots": lots_f, + "metrics": metrics, + "exchange": codes.get("exchange", "") if codes else "", + "pos_long": pos_long, + "pos_short": pos_short, + "max_open_long": max_open, + "max_open_short": max_open, + "footer_text": ( + f"*{name} 每手{spec['mult']}吨/点 最小变动{metrics['tick_size']} " + f"每跳{metrics['tick_value_per_lot']}元/手×{lots_f}={metrics['tick_value_total']}元 " + f"精度{metrics['price_precision']}位小数" + ), + }) + + @app.route("/api/trade/preview", methods=["POST"]) + @login_required + def api_trade_preview(): + d = request.get_json(silent=True) or {} + sym = (d.get("symbol") or "").strip() + direction = (d.get("direction") or "long").strip().lower() + try: + entry = float(d.get("entry") or d.get("price") or 0) + sl = float(d.get("stop_loss") or 0) + tp = float(d.get("take_profit") or 0) + except (TypeError, ValueError): + return jsonify({"ok": False, "error": "价格参数无效"}), 400 + conn = get_db() + capital = _capital(conn) + conn.close() + sizing = get_sizing_mode(get_setting) + margin_pct = get_max_margin_pct(get_setting) + if sizing == MODE_AMOUNT: + lots, err = calc_lots_by_amount( + entry, sl, direction, get_fixed_amount(get_setting), sym, + capital=capital, max_margin_pct=margin_pct, + ) + if err: + return jsonify({"ok": False, "error": err}), 400 + elif sizing == MODE_FIXED: + lots = get_fixed_lots(get_setting) + else: + try: + lots = max(1, int(d.get("lots") or 1)) + except (TypeError, ValueError): + lots = 1 + metrics = calc_position_metrics(direction, entry, sl, tp, lots, entry, capital, sym) + tick = calc_order_tick_metrics(sym, lots, entry) + return jsonify({"ok": True, "lots": lots, "sizing_mode": sizing, "metrics": metrics, "tick": tick, "capital": capital}) + + @app.route("/api/trade/order", methods=["POST"]) + @login_required + def api_trade_order(): + d = request.get_json(silent=True) or {} + sym = (d.get("symbol") or "").strip() + offset = (d.get("offset") or "open").strip().lower() + 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): + 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() + init_strategy_tables(conn) + mode = get_trading_mode(get_setting) + if offset.startswith("open"): + _sync_trade_monitors_with_ctp(conn, mode) + if not is_trading_session(): + conn.close() + return jsonify({"ok": False, "error": "不在交易时间段"}), 403 + if d.get("trailing_be") and not d.get("stop_loss"): + conn.close() + return jsonify({"ok": False, "error": "开启移动保本须填写止损价"}), 400 + err = assert_can_open(conn, active_count=_effective_active_position_count(conn, mode)) + if err: + conn.close() + return jsonify({"ok": False, "error": err}), 403 + ctp_st = ctp_status(mode) + if not ctp_st.get("connected"): + conn.close() + if get_bridge().connect_in_progress(): + return jsonify({"ok": False, "error": "CTP 连接中,请稍候再下单"}), 400 + return jsonify({"ok": False, "error": "请先连接 CTP"}), 400 + sizing = get_sizing_mode(get_setting) + if offset.startswith("open") and sizing == MODE_AMOUNT: + sl = float(d.get("stop_loss") or 0) + if sl <= 0: + conn.close() + return jsonify({"ok": False, "error": "固定金额模式须填写止损价"}), 400 + lots_calc, err = calc_lots_by_amount( + price, sl, direction, get_fixed_amount(get_setting), sym, + capital=_capital(conn), max_margin_pct=get_max_margin_pct(get_setting), + ) + if err: + conn.close() + return jsonify({"ok": False, "error": err}), 400 + lots = lots_calc or lots + elif offset.startswith("open") and sizing == MODE_FIXED: + lots = get_fixed_lots(get_setting) + margin_pct = get_max_margin_pct(get_setting) + usage = calc_margin_usage_pct( + _ctp_positions(mode), + _capital(conn), + extra_symbol=sym if offset.startswith("open") else "", + extra_lots=lots if offset.startswith("open") else 0, + extra_price=price if offset.startswith("open") else 0, + ) + if offset.startswith("open") and usage > margin_pct: + conn.close() + return jsonify({ + "ok": False, + "error": f"保证金占用 {usage:.1f}% 超过上限 {margin_pct:g}%(可在系统设置修改)", + }), 403 + if lots > DEFAULT_MAX_ORDER_LOTS: + conn.close() + return jsonify({ + "ok": False, + "error": f"单笔手数 {lots} 超过上限 {DEFAULT_MAX_ORDER_LOTS},请加大止损距离或改固定手数", + }), 400 + try: + result = execute_order( + conn, + mode=mode, + offset=offset, + symbol=sym, + direction=direction, + lots=lots, + price=price, + settings=_settings_dict(), + order_type=order_type, + ) + if offset.startswith("open") and d.get("trailing_be") and not d.get("stop_loss"): + conn.close() + return jsonify({"ok": False, "error": "开启移动保本须填写止损价"}), 400 + if offset.startswith("open"): + from zoneinfo import ZoneInfo + sl = d.get("stop_loss") + tp = d.get("take_profit") + trailing_be = 1 if d.get("trailing_be") else 0 + open_ts = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") + vt_order_id = str(result.get("order_id") or "") + mid = _upsert_open_monitor( + conn, + sym=sym, + direction=direction, + lots=lots, + price=price, + sl=sl, + tp=tp, + trailing_be=trailing_be, + open_time=open_ts, + monitor_type="manual", + status="pending", + vt_order_id=vt_order_id or None, + order_price=price, + ) + conn.commit() + _reconcile_pending(conn, mode, capital=_capital(conn)) + st_row = conn.execute( + "SELECT status FROM trade_order_monitors WHERE id=?", (mid,), + ).fetchone() + filled = st_row and (st_row["status"] or "").strip().lower() == "active" + if not filled: + try: + get_bridge().refresh_positions() + except Exception: + pass + _reconcile_pending(conn, mode, capital=_capital(conn)) + st_row = conn.execute( + "SELECT status FROM trade_order_monitors WHERE id=?", (mid,), + ).fetchone() + filled = st_row and (st_row["status"] or "").strip().lower() == "active" + if filled: + _sync_monitor_from_ctp( + conn, mid, sym, direction, mode, capital=_capital(conn), + ) + 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) + cancel_monitor_exit_orders(conn, dict(mon_row), mode=mode) + except Exception as exc: + logger.warning("清理旧版止盈止损挂单失败: %s", exc) + conn.commit() + _push_position_snapshot_async(fast=False) + msg = ( + f"开仓成功 · {lots} 手" + if filled + else ( + f"委托已提交 · {lots} 手挂单中" + f"({get_pending_order_timeout_sec(get_setting) // 60} 分钟未成交自动撤单)" + ) + ) + conn.commit() + send_wechat_msg(f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}") + conn.close() + _push_position_snapshot_async() + return jsonify({ + "ok": True, + "result": result, + "lots": lots, + "message": msg if offset.startswith("open") else "委托已提交柜台", + "filled": filled if offset.startswith("open") else None, + }) + except (ValueError, RuntimeError) as exc: + conn.close() + return jsonify({"ok": False, "error": str(exc)}), 400 + except Exception as exc: + conn.close() + return jsonify({"ok": False, "error": str(exc)}), 500 + + @app.route("/api/ctp/connect", methods=["POST"]) + @login_required + def api_ctp_connect(): + from vnpy_bridge import ctp_start_connect + + mode = get_trading_mode(get_setting) + body = request.get_json(silent=True) or {} + force = bool(body.get("force")) + info = ctp_start_connect(mode, force=force) + st = info.get("status") or ctp_status(mode) + acc = _ctp_account(mode) if st.get("connected") else {} + if st.get("connected"): + return jsonify({"ok": True, "status": st, "account": acc}) + if info.get("connecting") or info.get("started"): + return jsonify({ + "ok": True, + "connecting": True, + "status": st, + "account": acc, + }) + if info.get("cooldown"): + return jsonify({ + "ok": False, + "cooldown": True, + "error": st.get("last_error") or "CTP 登录冷却中", + "status": st, + "account": acc, + }), 400 + return jsonify({ + "ok": False, + "error": st.get("last_error") or "CTP 连接未启动", + "status": st, + "account": acc, + }), 400 + + @app.route("/api/ctp/status") + @login_required + def api_ctp_status(): + mode = get_trading_mode(get_setting) + st = ctp_status(mode) + acc = {} + if st.get("connected"): + try: + acc = _ctp_account(mode) + except Exception: + acc = {} + return jsonify({"ok": True, "status": st, "account": acc}) + + @app.route("/api/account_snapshot") + @login_required + def api_account_snapshot(): + conn = get_db() + try: + init_strategy_tables(conn) + mode = get_trading_mode(get_setting) + ctp_st = ctp_status(mode) + capital = _capital(conn) + risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode)) + conn.commit() + ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {} + positions = _ctp_positions(mode) if ctp_st.get("connected") else [] + return jsonify({ + "capital": capital, + "trading_mode": mode, + "trading_mode_label": trading_mode_label(get_setting), + "sizing_mode": get_sizing_mode(get_setting), + "risk_status": risk, + "ctp_status": ctp_st, + "ctp_account": ctp_acc, + "positions": positions, + }) + finally: + conn.close() + + @app.route("/api/recommend/list") + @login_required + def api_recommend_list(): + """只读数据库缓存,不在请求时拉行情。""" + conn = get_db() + try: + payload = _recommend_payload(conn) + return jsonify({"ok": True, **payload}) + finally: + conn.close() + + @app.route("/api/recommend/stream") + @login_required + def api_recommend_stream(): + from queue import Empty + + def generate(): + q = recommend_hub.subscribe() + try: + conn = get_db() + try: + payload = _recommend_payload(conn) + finally: + conn.close() + yield sse_format("recommend", {"ok": True, **payload}) + while True: + try: + msg = q.get(timeout=25) + yield sse_format(msg["event"], msg["data"]) + except Empty: + yield ": heartbeat\n\n" + finally: + recommend_hub.unsubscribe(q) + + return Response( + stream_with_context(generate()), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + @app.route("/api/recommend/refresh", methods=["POST"]) + @login_required + def api_recommend_refresh(): + """手动触发一次后台刷新(仍写入数据库)。""" + conn = get_db() + try: + init_strategy_tables(conn) + capital = _capital(conn) + mode = get_trading_mode(get_setting) + rows = refresh_recommend_cache( + conn, capital, _main_quote, trading_mode=mode, + max_margin_pct=get_max_margin_pct(get_setting), + ) + max_pct = get_max_margin_pct(get_setting) + payload = _recommend_payload(conn) + recommend_hub.broadcast("recommend", {"ok": True, **payload}) + return jsonify({"ok": True, "count": len(rows), **payload}) + finally: + conn.close() + + @app.route("/api/strategy/trend/preview", methods=["POST"]) + @login_required + def api_trend_preview(): + d = request.get_json(silent=True) or {} + sym = (d.get("symbol") or "").strip() + conn = get_db() + if conn.execute("SELECT id FROM trend_pullback_plans WHERE status='active'").fetchone(): + conn.close() + return jsonify({"ok": False, "error": "已有运行中趋势计划"}), 400 + capital = _capital(conn) + codes = ths_to_codes(sym) + price = fetch_price(sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "") + conn.close() + if not price: + return jsonify({"ok": False, "error": "无法获取现价"}), 400 + plan, err = compute_trend_plan_futures( + direction=d.get("direction") or "long", + stop_loss=float(d.get("stop_loss") or 0), + add_upper=float(d.get("add_upper") or 0), + take_profit=float(d.get("take_profit") or 0), + risk_percent=float(d.get("risk_percent") or get_risk_percent(get_setting)), + capital=capital, + live_price=price, + ths_code=sym, + dca_legs=int(d.get("dca_legs") or 5), + ) + if err: + return jsonify({"ok": False, "error": err}), 400 + return jsonify({"ok": True, "plan": plan}) + + @app.route("/api/strategy/trend/execute", methods=["POST"]) + @login_required + def api_trend_execute(): + d = request.get_json(silent=True) or {} + sym = (d.get("symbol") or "").strip() + conn = get_db() + init_strategy_tables(conn) + err = assert_can_open(conn) + if err: + conn.close() + return jsonify({"ok": False, "error": err}), 403 + capital = _capital(conn) + codes = ths_to_codes(sym) + price = fetch_price(sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "") + plan, perr = compute_trend_plan_futures( + direction=d.get("direction") or "long", + stop_loss=float(d.get("stop_loss") or 0), + add_upper=float(d.get("add_upper") or 0), + take_profit=float(d.get("take_profit") or 0), + risk_percent=float(d.get("risk_percent") or get_risk_percent(get_setting)), + capital=capital, + live_price=price or float(d.get("live_price") or 0), + ths_code=sym, + ) + if perr: + conn.close() + return jsonify({"ok": False, "error": perr}), 400 + mode = get_trading_mode(get_setting) + try: + execute_order( + conn, mode=mode, offset="open", symbol=sym, + direction=plan["direction"], lots=plan["first_lots"], price=price, settings=_settings_dict(), + ) + except ValueError as exc: + conn.close() + return jsonify({"ok": False, "error": str(exc)}), 400 + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + cur = conn.execute( + """INSERT INTO trend_pullback_plans ( + status, symbol, symbol_name, direction, stop_loss, add_upper, take_profit, + risk_percent, capital_snapshot, plan_margin, target_lots, first_lots, remainder_lots, + dca_legs, leg_amounts_json, grid_prices_json, first_order_done, avg_entry_price, + lots_open, opened_at + ) VALUES ('active',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?,?)""", + ( + sym, codes.get("name", sym) if codes else sym, plan["direction"], + plan["stop_loss"], plan["add_upper"], plan["take_profit"], + plan["risk_percent"], plan["capital_snapshot"], plan["plan_margin"], + plan["target_lots"], plan["first_lots"], plan["remainder_lots"], + plan["dca_legs"], plan["leg_amounts_json"], plan["grid_prices_json"], + price, plan["first_lots"], now, + ), + ) + plan_id = cur.lastrowid + conn.commit() + conn.close() + send_wechat_msg(f"趋势回调首仓 {sym} {plan['first_lots']}手") + return jsonify({"ok": True, "plan_id": plan_id, "plan": plan}) + + @app.route("/api/strategy/roll/preview", methods=["POST"]) + @login_required + def api_roll_preview(): + d = request.get_json(silent=True) or {} + conn = get_db() + mon_id = int(d.get("monitor_id") or 0) + mon = conn.execute("SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", (mon_id,)).fetchone() + conn.close() + if not mon: + return jsonify({"ok": False, "error": "无有效持仓监控"}), 400 + sym = mon["symbol"] + spec = get_contract_spec(sym) + capital = _capital(get_db()) + preview, err = preview_roll( + direction=mon["direction"], + symbol=sym, + qty_existing=float(mon["lots"]), + entry_existing=float(mon["entry_price"]), + initial_take_profit=float(mon["take_profit"] or 0), + add_mode=d.get("add_mode") or "market", + new_stop_loss=float(d.get("new_stop_loss") or 0), + risk_percent=float(d.get("risk_percent") or 2), + capital_base=capital, + mult=spec["mult"], + add_price=float(d.get("add_price") or mon["entry_price"]), + fib_upper=d.get("fib_upper"), + fib_lower=d.get("fib_lower"), + legs_done=int(d.get("legs_done") or 0), + ) + if err: + return jsonify({"ok": False, "error": err}), 400 + return jsonify({"ok": True, "preview": preview}) + + @app.route("/api/strategy/roll/execute", methods=["POST"]) + @login_required + def api_roll_execute(): + d = request.get_json(silent=True) or {} + conn = get_db() + init_strategy_tables(conn) + mon_id = int(d.get("monitor_id") or 0) + mon = conn.execute("SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", (mon_id,)).fetchone() + if not mon: + conn.close() + return jsonify({"ok": False, "error": "无有效持仓监控"}), 400 + if conn.execute("SELECT id FROM trend_pullback_plans WHERE status='active'").fetchone(): + conn.close() + return jsonify({"ok": False, "error": "趋势回调运行中,不可滚仓"}), 400 + sym = mon["symbol"] + spec = get_contract_spec(sym) + capital = _capital(conn) + prev, err = preview_roll( + direction=mon["direction"], + symbol=sym, + qty_existing=float(mon["lots"]), + entry_existing=float(mon["entry_price"]), + initial_take_profit=float(mon["take_profit"] or 0), + add_mode=d.get("add_mode") or "market", + new_stop_loss=float(d.get("new_stop_loss") or 0), + risk_percent=float(d.get("risk_percent") or 2), + capital_base=capital, + mult=spec["mult"], + add_price=float(d.get("add_price") or mon["entry_price"]), + ) + if err: + conn.close() + return jsonify({"ok": False, "error": err}), 400 + price = float(prev["add_price"]) + mode = get_trading_mode(get_setting) + try: + execute_order( + conn, mode=mode, offset="open", symbol=sym, + direction=mon["direction"], lots=int(prev["add_lots"]), price=price, settings=_settings_dict(), + ) + except ValueError as exc: + conn.close() + return jsonify({"ok": False, "error": str(exc)}), 400 + new_lots = int(mon["lots"]) + int(prev["add_lots"]) + new_avg = prev["avg_entry_after"] + new_sl = prev["new_stop_loss"] + conn.execute( + "UPDATE trade_order_monitors SET lots=?, entry_price=?, stop_loss=? WHERE id=?", + (new_lots, new_avg, new_sl, mon_id), + ) + grp = conn.execute( + "SELECT * FROM roll_groups WHERE order_monitor_id=? AND status='active'", + (mon_id,), + ).fetchone() + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + if grp: + gid = grp["id"] + leg_n = int(grp["leg_count"] or 0) + 1 + conn.execute( + "UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?", + (leg_n, new_sl, now, gid), + ) + else: + cur = conn.execute( + """INSERT INTO roll_groups ( + order_monitor_id, symbol, direction, initial_take_profit, initial_stop_loss, + current_stop_loss, risk_percent, leg_count, status, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,1,'active',?,?)""", + (mon_id, sym, mon["direction"], mon["take_profit"], mon["stop_loss"], new_sl, + float(d.get("risk_percent") or 2), now, now), + ) + gid = cur.lastrowid + leg_n = 1 + conn.execute( + """INSERT INTO roll_legs (roll_group_id, leg_index, add_mode, fill_price, lots, new_stop_loss, status, created_at) + VALUES (?,?,?,?,?,?, 'filled', ?)""", + (gid, leg_n, d.get("add_mode") or "market", price, int(prev["add_lots"]), new_sl, now), + ) + conn.commit() + conn.close() + return jsonify({"ok": True, "preview": prev}) + + @app.route("/api/strategy/trend/stop", methods=["POST"]) + @login_required + def api_trend_stop(): + d = request.get_json(silent=True) or {} + plan_id = int(d.get("plan_id") or 0) + conn = get_db() + plan = conn.execute("SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (plan_id,)).fetchone() + if not plan: + conn.close() + return jsonify({"ok": False, "error": "计划不存在"}), 404 + mode = get_trading_mode(get_setting) + price = fetch_price(plan["symbol"]) or float(plan["avg_entry_price"] or 0) + try: + if int(plan["lots_open"] or 0) > 0: + execute_order( + conn, mode=mode, offset="close", symbol=plan["symbol"], + direction=plan["direction"], lots=int(plan["lots_open"]), price=price, settings=_settings_dict(), + ) + except ValueError: + pass + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + conn.execute( + "UPDATE trend_pullback_plans SET status='stopped_manual', message=?, opened_at=opened_at WHERE id=?", + ("手动结束", plan_id), + ) + save_snapshot( + conn, strategy_type=STRATEGY_TREND, source_id=plan_id, + symbol=plan["symbol"], direction=plan["direction"], result_label="手动结束", + payload=dict(plan), opened_at=plan["opened_at"] or "", + ) + on_user_initiated_close(conn, trading_day=trading_day_label()) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + def check_trend_plans(app_ref): + """后台:趋势补仓与止盈。""" + conn = get_db() + init_strategy_tables(conn) + rows = conn.execute("SELECT * FROM trend_pullback_plans WHERE status='active'").fetchall() + mode = get_trading_mode(get_setting) + for plan in rows: + sym = plan["symbol"] + price = fetch_price(sym) + if not price: + continue + direction = plan["direction"] + tp = float(plan["take_profit"] or 0) + if tp > 0: + hit_tp = (direction == "long" and price >= tp) or (direction == "short" and price <= tp) + if hit_tp: + try: + execute_order( + conn, mode=mode, offset="close", symbol=sym, direction=direction, + lots=int(plan["lots_open"] or 0), price=price, settings=_settings_dict(), + ) + except ValueError: + pass + conn.execute( + "UPDATE trend_pullback_plans SET status='stopped_tp', message=? WHERE id=?", + ("程序止盈", plan["id"]), + ) + save_snapshot( + conn, strategy_type=STRATEGY_TREND, source_id=plan["id"], + symbol=sym, direction=direction, result_label="止盈", + payload=dict(plan), opened_at=plan["opened_at"] or "", + ) + send_wechat_msg(f"趋势回调止盈 {sym}") + continue + try: + grid = json.loads(plan["grid_prices_json"] or "[]") + legs = json.loads(plan["leg_amounts_json"] or "[]") + except Exception: + grid, legs = [], [] + done = int(plan["legs_done"] or 0) + if done < len(grid) and done < len(legs): + level = float(grid[done]) + if trend_dca_level_reached(direction, price, level): + add_lots = int(legs[done]) + try: + execute_order( + conn, mode=mode, offset="open", symbol=sym, direction=direction, + lots=add_lots, price=price, settings=_settings_dict(), + ) + new_open = int(plan["lots_open"] or 0) + add_lots + old_avg = float(plan["avg_entry_price"] or price) + new_avg = (old_avg * int(plan["lots_open"] or 0) + price * add_lots) / new_open if new_open else price + conn.execute( + """UPDATE trend_pullback_plans SET legs_done=?, lots_open=?, avg_entry_price=? WHERE id=?""", + (done + 1, new_open, new_avg, plan["id"]), + ) + send_wechat_msg(f"趋势回调补仓 {sym} +{add_lots}手 @档位{done+1}") + except ValueError: + pass + conn.commit() + conn.close() + + app._check_trend_plans = check_trend_plans + + @app.route("/settings/trading", methods=["POST"]) + @login_required + def settings_trading_post(): + return redirect(url_for("settings")) + + def hook_review_mood(conn, behavior_tags: str, exit_trigger: str, exit_supplement: str): + if parse_mood_issues(behavior_tags): + on_mood_journal_freeze(conn, trading_day=trading_day_label()) + if (exit_trigger or "").strip() == "手动平仓" and (exit_supplement or "").strip(): + reduce_cooloff_after_journal(conn, trading_day=trading_day_label()) + + app._risk_review_hook = hook_review_mood + + from db_conn import DB_PATH + + def _init_tables(conn): + init_strategy_tables(conn) + + start_recommend_worker( + db_path=DB_PATH, + get_capital_fn=_capital, + quote_fn=_main_quote, + init_tables_fn=_init_tables, + get_mode_fn=lambda: get_trading_mode(get_setting), + get_max_margin_pct_fn=lambda: get_max_margin_pct(get_setting), + get_sizing_mode_fn=lambda: get_sizing_mode(get_setting), + get_fixed_lots_fn=lambda: get_fixed_lots(get_setting), + ) + start_ctp_reconnect_worker(get_mode_fn=lambda: get_trading_mode(get_setting)) + start_ctp_premarket_connect_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, + get_capital_fn=_capital, + get_be_tick_buffer_fn=lambda: get_trailing_be_tick_buffer(get_setting), + notify_fn=send_wechat_msg, + interval=1, + ) + _pos_refresh_tick = {"n": 0} + + def _position_worker_refresh() -> dict: + _pos_refresh_tick["n"] += 1 + # 每秒轻量刷新;每 5 秒做一次 CTP 持仓/挂单对账,避免频繁 query 导致 vnctptd 崩溃 + return _refresh_trading_live_snapshot(fast=(_pos_refresh_tick["n"] % 5 != 0)) + + start_position_worker( + refresh_fn=_position_worker_refresh, + interval=1, + ) + _bootstrap_trading_runtime() + start_ctp_fee_worker( + get_mode_fn=lambda: get_trading_mode(get_setting), + get_setting_fn=get_setting, + set_setting_fn=set_setting, + ) diff --git a/kline_chart.py b/kline_chart.py index c5b7be8..8eb9c89 100644 --- a/kline_chart.py +++ b/kline_chart.py @@ -1,557 +1,562 @@ -"""复盘 K 线:新浪拉取 + matplotlib 生成截图。""" -import json -import logging -import os -import re -import sqlite3 -from datetime import datetime -from typing import Optional -from zoneinfo import ZoneInfo - -import requests - -from symbols import ths_to_codes -from db_conn import connect_db -from kline_store import ensure_kline_tables, get_cached_entry, save_bars - -logger = logging.getLogger(__name__) -TZ = ZoneInfo("Asia/Shanghai") - -# CTP tick 聚合 bar 少于此数时,用新浪历史补齐走势 -MIN_CTP_KLINE_BARS = 15 - -PERIOD_MINUTES = { - "1m": "1", - "3m": "3", - "5m": "5", - "15m": "15", - "30m": "30", - "1h": "60", - "4h": "240", -} - -MARKET_PERIODS = [ - {"key": "timeshare", "label": "分时"}, - {"key": "1m", "label": "1分"}, - {"key": "2m", "label": "2分"}, - {"key": "5m", "label": "5分"}, - {"key": "15m", "label": "15分"}, - {"key": "1h", "label": "1小时"}, - {"key": "2h", "label": "2小时"}, - {"key": "4h", "label": "4小时"}, - {"key": "d", "label": "日线"}, - {"key": "w", "label": "周线"}, -] - - -def ths_to_sina_chart_symbol(symbol: str) -> Optional[str]: - """ag2608 -> AG2608(新浪 K 线接口合约代码)。""" - code = (symbol or "").strip() - if not code: - return None - codes = ths_to_codes(code) - if codes: - sina = codes.get("sina_code", "") - if sina.startswith("nf_"): - return sina[3:] - if sina.startswith("CFF_RE_"): - return sina[7:] - ths = codes.get("ths_code", "") - return ths.upper() if ths else None - m = re.match(r"^([A-Za-z]+)(\d+)$", code) - if m: - return m.group(1).upper() + m.group(2) - return None - - -def _parse_jsonp(text: str) -> Optional[list]: - m = re.search(r"\((.*)\)\s*;?\s*$", text.strip(), re.DOTALL) - if not m: - return None - try: - data = json.loads(m.group(1)) - return data if isinstance(data, list) else None - except json.JSONDecodeError: - return None - - -def fetch_sina_klines(symbol: str, period: str) -> list: - """拉取新浪期货 K 线(原始 bar 列表)。""" - chart_sym = ths_to_sina_chart_symbol(symbol) - if not chart_sym: - return [] - p = (period or "").lower() - if p in ("1d", "d"): - return _fetch_sina_daily(chart_sym) - if p == "w": - return _weekly_from_daily(_fetch_sina_daily(chart_sym)) - if p == "timeshare": - bars = _fetch_few_min_line(chart_sym, "1") - return _timeshare_session(bars) - if p == "2m": - return _aggregate_bars(_fetch_few_min_line(chart_sym, "1"), 2) - if p == "2h": - return _aggregate_bars(_fetch_few_min_line(chart_sym, "60"), 2) - typ = PERIOD_MINUTES.get(p) - if typ: - return _fetch_few_min_line(chart_sym, typ) - return [] - - -def _fetch_few_min_line(chart_sym: str, typ: str) -> list: - ts = datetime.now(TZ).strftime("%Y%m%d%H%M%S") - url = ( - "https://stock2.finance.sina.com.cn/futures/api/jsonp.php/" - f"var_{chart_sym}_{typ}_{ts}=/InnerFuturesNewService.getFewMinLine" - f"?symbol={chart_sym}&type={typ}" - ) - try: - resp = requests.get( - url, - timeout=20, - headers={"Referer": "https://finance.sina.com.cn"}, - ) - bars = _parse_jsonp(resp.text) - return _normalize_bars(bars or []) - except Exception as exc: - logger.warning("fetch kline failed %s %s: %s", chart_sym, typ, exc) - return [] - - -def _normalize_bars(raw: list) -> list: - out = [] - for row in raw: - if isinstance(row, list) and len(row) >= 5: - out.append({ - "d": str(row[0]), - "o": float(row[1]), - "h": float(row[2]), - "l": float(row[3]), - "c": float(row[4]), - "v": float(row[5]) if len(row) > 5 and row[5] else 0.0, - }) - elif isinstance(row, dict) and row.get("d"): - out.append({ - "d": str(row["d"]), - "o": float(row.get("o", 0) or 0), - "h": float(row.get("h", 0) or 0), - "l": float(row.get("l", 0) or 0), - "c": float(row.get("c", 0) or 0), - "v": float(row.get("v", 0) or 0), - }) - return out - - -def _aggregate_bars(bars: list, n: int) -> list: - if n <= 1 or not bars: - return bars - out = [] - chunk: list = [] - for bar in bars: - chunk.append(bar) - if len(chunk) >= n: - out.append(_merge_bars(chunk)) - chunk = [] - if chunk: - out.append(_merge_bars(chunk)) - return out - - -def _merge_bars(chunk: list) -> dict: - return { - "d": chunk[0]["d"], - "o": chunk[0]["o"], - "h": max(b["h"] for b in chunk), - "l": min(b["l"] for b in chunk), - "c": chunk[-1]["c"], - "v": sum(b.get("v", 0) for b in chunk), - } - - -def _merge_kline_bars(history: list, live: list) -> list: - """新浪历史 + CTP 实时尾部(去重叠)。""" - if not history: - return list(live or []) - if not live: - return list(history) - first_live = _bar_datetime(live[0]) - if not first_live: - return history + live - trimmed = [] - for bar in history: - dt = _bar_datetime(bar) - if dt and dt < first_live: - trimmed.append(bar) - merged = trimmed + list(live) - return merged if merged else list(history) - - -def _weekly_from_daily(daily: list) -> list: - if not daily: - return [] - buckets: dict[tuple, list] = {} - for bar in daily: - dt = _bar_datetime(bar) - if not dt: - continue - iso = dt.isocalendar() - key = (iso[0], iso[1]) - buckets.setdefault(key, []).append(bar) - out = [] - for key in sorted(buckets.keys()): - chunk = buckets[key] - out.append(_merge_bars(chunk)) - out[-1]["d"] = chunk[-1]["d"] - return out - - -def _timeshare_session(bars: list) -> list: - if not bars: - return [] - today = datetime.now(TZ).date() - session = [] - for bar in bars: - dt = _bar_datetime(bar) - if dt and dt.date() == today: - session.append(bar) - if session: - return session[-480:] - return bars[-480:] - - -def bars_to_api(bars: list) -> list[dict]: - """转为前端图表 JSON(去重、排序、数值规范化)。""" - result: list[dict] = [] - seen: dict[int, dict] = {} - for bar in bars: - dt = _bar_datetime(bar) - ts = int(dt.timestamp() * 1000) if dt else None - try: - o = float(bar.get("o") or 0) - h = float(bar.get("h") or o) - l = float(bar.get("l") or o) - c = float(bar.get("c") or o) - v = float(bar.get("v") or 0) - except (TypeError, ValueError): - continue - if h < l: - h, l = l, h - h = max(h, o, c) - l = min(l, o, c) - row = { - "time": bar["d"], - "timestamp": ts, - "open": o, - "high": h, - "low": l, - "close": c, - "volume": v, - } - if ts is not None: - seen[ts] = row - else: - result.append(row) - if seen: - result = [seen[k] for k in sorted(seen.keys())] - return result - - -def fetch_market_klines( - symbol: str, - period: str, - db_path: Optional[str] = None, - force_remote: bool = False, - *, - trading_mode: Optional[str] = None, - prefer_ctp: bool = True, -) -> dict: - chart_sym = ths_to_sina_chart_symbol(symbol) - p = (period or "15m").lower() - if p == "timeshare": - chart_type = "line" - else: - chart_type = "candle" - - bars: list = [] - source = "remote" - cached_at = None - ctp_connected = False - ctp_bars: list = [] - - if prefer_ctp: - try: - from ctp_kline import fetch_ctp_klines - from vnpy_bridge import ctp_status - - mode = trading_mode - if not mode: - try: - from app import get_setting - from trading_context import get_trading_mode - - mode = get_trading_mode(get_setting) - except Exception: - mode = "simulation" - ctp_connected = bool(ctp_status(mode).get("connected")) - if ctp_connected: - ctp_bars = fetch_ctp_klines(symbol, p, mode) or [] - except Exception as exc: - logger.debug("ctp kline fetch failed %s %s: %s", symbol, p, exc) - - need_sina = ( - force_remote - or not ctp_bars - or len(ctp_bars) < MIN_CTP_KLINE_BARS - ) - - if ctp_bars and len(ctp_bars) >= MIN_CTP_KLINE_BARS: - bars = ctp_bars - source = "ctp" - - if not bars and db_path and chart_sym and not force_remote and need_sina: - try: - conn = connect_db(db_path) - cached = get_cached_entry(conn, chart_sym, p) - conn.close() - if cached and cached.get("fresh"): - bars = cached["bars"] - source = "local" - cached_at = cached.get("updated_at") - except Exception as exc: - logger.warning("kline cache read failed %s %s: %s", chart_sym, p, exc) - - if not bars or len(ctp_bars) < MIN_CTP_KLINE_BARS: - remote_bars = fetch_sina_klines(symbol, p) - if remote_bars: - if ctp_bars and ctp_connected: - bars = _merge_kline_bars(remote_bars, ctp_bars) - source = "ctp+remote" - else: - bars = remote_bars - source = "remote" - if db_path and chart_sym and not ctp_connected: - try: - conn = connect_db(db_path) - ensure_kline_tables(conn) - save_bars(conn, chart_sym, p, remote_bars) - meta = conn.execute( - "SELECT updated_at FROM kline_meta WHERE chart_symbol=? AND period=?", - (chart_sym, p), - ).fetchone() - conn.close() - cached_at = meta[0] if meta else None - except Exception as exc: - logger.warning("kline cache write failed %s %s: %s", chart_sym, p, exc) - elif not bars and db_path and chart_sym: - try: - conn = connect_db(db_path) - cached = get_cached_entry(conn, chart_sym, p) - conn.close() - if cached and cached.get("bars"): - bars = cached["bars"] - source = "local" - cached_at = cached.get("updated_at") - except Exception as exc: - logger.warning("kline cache fallback failed %s %s: %s", chart_sym, p, exc) - - api_bars = bars_to_api(bars) - prev_close = None - if len(api_bars) >= 2: - prev_close = api_bars[-2]["close"] - - return { - "symbol": symbol, - "chart_symbol": chart_sym, - "period": p, - "chart_type": chart_type, - "count": len(bars), - "bars": api_bars, - "prev_close": prev_close, - "source": source, - "cached_at": cached_at, - "ctp_connected": ctp_connected, - } - - -def _fetch_sina_daily(chart_sym: str) -> list: - url = ( - "https://stock2.finance.sina.com.cn/futures/api/json.php/" - f"IndexService.getInnerFuturesDailyKLine?symbol={chart_sym}" - ) - try: - resp = requests.get(url, timeout=20, headers={"Referer": "https://finance.sina.com.cn"}) - raw = resp.json() - if raw and isinstance(raw, list): - bars = _normalize_bars(raw) - if bars: - return bars - except Exception as exc: - logger.warning("fetch daily kline failed %s: %s", chart_sym, exc) - return _daily_from_minutes(chart_sym) - - -def _daily_from_minutes(chart_sym: str) -> list: - """合约日线接口无数据时,由 60 分钟 K 线按日合成。""" - bars_60 = _fetch_few_min_line(chart_sym, "60") - if not bars_60: - bars_60 = _fetch_few_min_line(chart_sym, "240") - buckets: dict[str, list] = {} - for bar in bars_60: - dt = _bar_datetime(bar) - if not dt: - continue - key = dt.strftime("%Y-%m-%d") - buckets.setdefault(key, []).append(bar) - out = [] - for day in sorted(buckets.keys()): - chunk = buckets[day] - merged = _merge_bars(chunk) - merged["d"] = day + " 15:00:00" - out.append(merged) - return out - - -def _parse_dt(value: str) -> Optional[datetime]: - if not value: - return None - v = value.strip().replace("T", " ") - for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): - try: - return datetime.strptime(v, fmt).replace(tzinfo=TZ) - except ValueError: - continue - try: - return datetime.fromisoformat(value.strip()).replace(tzinfo=TZ) - except ValueError: - return None - - -def _bar_datetime(bar: dict) -> Optional[datetime]: - d = bar.get("d") - if not d: - return None - try: - return datetime.strptime(d, "%Y-%m-%d %H:%M:%S").replace(tzinfo=TZ) - except ValueError: - return None - - -def _select_bars( - bars: list, - cutoff: datetime, - count: int, -) -> list: - filtered = [] - for bar in bars: - dt = _bar_datetime(bar) - if dt and dt <= cutoff: - filtered.append(bar) - if not filtered: - filtered = bars - if count > 0 and len(filtered) > count: - filtered = filtered[-count:] - return filtered - - -def generate_review_kline_chart( - symbol: str, - periods: list[str], - count: int, - cutoff_label: str, - open_time: str, - close_time: str, - entry_price: Optional[float], - stop_loss: Optional[float], - take_profit: Optional[float], - close_price: Optional[float], - upload_dir: str, -) -> Optional[str]: - """生成双周期 K 线复盘图,返回 uploads 目录下的文件名。""" - import matplotlib - matplotlib.use("Agg") - import matplotlib.pyplot as plt - import matplotlib.dates as mdates - - now = datetime.now(TZ) - if cutoff_label == "开仓时间": - cutoff = _parse_dt(open_time) or now - elif cutoff_label == "当前时间": - cutoff = now - else: - cutoff = _parse_dt(close_time) or now - - open_dt = _parse_dt(open_time) - close_dt = _parse_dt(close_time) - - valid_periods = [p for p in periods if p] - if not valid_periods: - valid_periods = ["15m", "1h"] - - fig, axes = plt.subplots( - len(valid_periods), 1, - figsize=(14, 4.5 * len(valid_periods)), - facecolor="#0a0a10", - squeeze=False, - ) - - plotted = False - for idx, period in enumerate(valid_periods): - ax = axes[idx, 0] - bars = fetch_sina_klines(symbol, period) - bars = _select_bars(bars, cutoff, count) - if not bars: - ax.set_facecolor("#12121a") - ax.text(0.5, 0.5, f"No {period} data", ha="center", va="center", color="#888") - ax.set_xticks([]) - ax.set_yticks([]) - continue - - times = [_bar_datetime(b) for b in bars] - closes = [float(b["c"]) for b in bars] - highs = [float(b["h"]) for b in bars] - lows = [float(b["l"]) for b in bars] - - ax.set_facecolor("#12121a") - ax.plot(times, closes, color="#4cc2ff", linewidth=1.2) - ax.fill_between( - times, lows, highs, - color="#4cc2ff", alpha=0.12, - ) - - levels = [ - (entry_price, "#eac147", "Entry"), - (stop_loss, "#ff6666", "SL"), - (take_profit, "#4cd97f", "TP"), - (close_price, "#c4c4ff", "Close"), - ] - for price, color, label in levels: - if price is not None: - ax.axhline(price, color=color, linewidth=0.9, linestyle="--", alpha=0.85) - ax.text(times[-1], price, label, color=color, fontsize=8, va="bottom") - - if open_dt: - ax.axvline(open_dt, color="#888", linewidth=0.8, linestyle=":", alpha=0.7) - if close_dt: - ax.axvline(close_dt, color="#aaa", linewidth=0.8, linestyle=":", alpha=0.7) - - chart_sym = ths_to_sina_chart_symbol(symbol) or symbol - ax.set_title(f"{chart_sym} {period}", color="#eaeaea", fontsize=11, pad=8) - ax.tick_params(colors="#888", labelsize=8) - for spine in ax.spines.values(): - spine.set_color("#2e2e45") - ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d %H:%M")) - ax.grid(True, color="#1e1e30", linewidth=0.5) - plotted = True - - if not plotted: - plt.close(fig) - return None - - fig.tight_layout() - ts = datetime.now(TZ).strftime("%Y%m%d%H%M%S") - chart_sym = ths_to_sina_chart_symbol(symbol) or "chart" - filename = f"{ts}_kline_{chart_sym}.png" - path = os.path.join(upload_dir, filename) - fig.savefig(path, dpi=120, facecolor=fig.get_facecolor()) - plt.close(fig) - return filename +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""复盘 K 线:新浪拉取 + matplotlib 生成截图。""" +import json +import logging +import os +import re +import sqlite3 +from datetime import datetime +from typing import Optional +from zoneinfo import ZoneInfo + +import requests + +from symbols import ths_to_codes +from db_conn import connect_db +from kline_store import ensure_kline_tables, get_cached_entry, save_bars + +logger = logging.getLogger(__name__) +TZ = ZoneInfo("Asia/Shanghai") + +# CTP tick 聚合 bar 少于此数时,用新浪历史补齐走势 +MIN_CTP_KLINE_BARS = 15 + +PERIOD_MINUTES = { + "1m": "1", + "3m": "3", + "5m": "5", + "15m": "15", + "30m": "30", + "1h": "60", + "4h": "240", +} + +MARKET_PERIODS = [ + {"key": "timeshare", "label": "分时"}, + {"key": "1m", "label": "1分"}, + {"key": "2m", "label": "2分"}, + {"key": "5m", "label": "5分"}, + {"key": "15m", "label": "15分"}, + {"key": "1h", "label": "1小时"}, + {"key": "2h", "label": "2小时"}, + {"key": "4h", "label": "4小时"}, + {"key": "d", "label": "日线"}, + {"key": "w", "label": "周线"}, +] + + +def ths_to_sina_chart_symbol(symbol: str) -> Optional[str]: + """ag2608 -> AG2608(新浪 K 线接口合约代码)。""" + code = (symbol or "").strip() + if not code: + return None + codes = ths_to_codes(code) + if codes: + sina = codes.get("sina_code", "") + if sina.startswith("nf_"): + return sina[3:] + if sina.startswith("CFF_RE_"): + return sina[7:] + ths = codes.get("ths_code", "") + return ths.upper() if ths else None + m = re.match(r"^([A-Za-z]+)(\d+)$", code) + if m: + return m.group(1).upper() + m.group(2) + return None + + +def _parse_jsonp(text: str) -> Optional[list]: + m = re.search(r"\((.*)\)\s*;?\s*$", text.strip(), re.DOTALL) + if not m: + return None + try: + data = json.loads(m.group(1)) + return data if isinstance(data, list) else None + except json.JSONDecodeError: + return None + + +def fetch_sina_klines(symbol: str, period: str) -> list: + """拉取新浪期货 K 线(原始 bar 列表)。""" + chart_sym = ths_to_sina_chart_symbol(symbol) + if not chart_sym: + return [] + p = (period or "").lower() + if p in ("1d", "d"): + return _fetch_sina_daily(chart_sym) + if p == "w": + return _weekly_from_daily(_fetch_sina_daily(chart_sym)) + if p == "timeshare": + bars = _fetch_few_min_line(chart_sym, "1") + return _timeshare_session(bars) + if p == "2m": + return _aggregate_bars(_fetch_few_min_line(chart_sym, "1"), 2) + if p == "2h": + return _aggregate_bars(_fetch_few_min_line(chart_sym, "60"), 2) + typ = PERIOD_MINUTES.get(p) + if typ: + return _fetch_few_min_line(chart_sym, typ) + return [] + + +def _fetch_few_min_line(chart_sym: str, typ: str) -> list: + ts = datetime.now(TZ).strftime("%Y%m%d%H%M%S") + url = ( + "https://stock2.finance.sina.com.cn/futures/api/jsonp.php/" + f"var_{chart_sym}_{typ}_{ts}=/InnerFuturesNewService.getFewMinLine" + f"?symbol={chart_sym}&type={typ}" + ) + try: + resp = requests.get( + url, + timeout=20, + headers={"Referer": "https://finance.sina.com.cn"}, + ) + bars = _parse_jsonp(resp.text) + return _normalize_bars(bars or []) + except Exception as exc: + logger.warning("fetch kline failed %s %s: %s", chart_sym, typ, exc) + return [] + + +def _normalize_bars(raw: list) -> list: + out = [] + for row in raw: + if isinstance(row, list) and len(row) >= 5: + out.append({ + "d": str(row[0]), + "o": float(row[1]), + "h": float(row[2]), + "l": float(row[3]), + "c": float(row[4]), + "v": float(row[5]) if len(row) > 5 and row[5] else 0.0, + }) + elif isinstance(row, dict) and row.get("d"): + out.append({ + "d": str(row["d"]), + "o": float(row.get("o", 0) or 0), + "h": float(row.get("h", 0) or 0), + "l": float(row.get("l", 0) or 0), + "c": float(row.get("c", 0) or 0), + "v": float(row.get("v", 0) or 0), + }) + return out + + +def _aggregate_bars(bars: list, n: int) -> list: + if n <= 1 or not bars: + return bars + out = [] + chunk: list = [] + for bar in bars: + chunk.append(bar) + if len(chunk) >= n: + out.append(_merge_bars(chunk)) + chunk = [] + if chunk: + out.append(_merge_bars(chunk)) + return out + + +def _merge_bars(chunk: list) -> dict: + return { + "d": chunk[0]["d"], + "o": chunk[0]["o"], + "h": max(b["h"] for b in chunk), + "l": min(b["l"] for b in chunk), + "c": chunk[-1]["c"], + "v": sum(b.get("v", 0) for b in chunk), + } + + +def _merge_kline_bars(history: list, live: list) -> list: + """新浪历史 + CTP 实时尾部(去重叠)。""" + if not history: + return list(live or []) + if not live: + return list(history) + first_live = _bar_datetime(live[0]) + if not first_live: + return history + live + trimmed = [] + for bar in history: + dt = _bar_datetime(bar) + if dt and dt < first_live: + trimmed.append(bar) + merged = trimmed + list(live) + return merged if merged else list(history) + + +def _weekly_from_daily(daily: list) -> list: + if not daily: + return [] + buckets: dict[tuple, list] = {} + for bar in daily: + dt = _bar_datetime(bar) + if not dt: + continue + iso = dt.isocalendar() + key = (iso[0], iso[1]) + buckets.setdefault(key, []).append(bar) + out = [] + for key in sorted(buckets.keys()): + chunk = buckets[key] + out.append(_merge_bars(chunk)) + out[-1]["d"] = chunk[-1]["d"] + return out + + +def _timeshare_session(bars: list) -> list: + if not bars: + return [] + today = datetime.now(TZ).date() + session = [] + for bar in bars: + dt = _bar_datetime(bar) + if dt and dt.date() == today: + session.append(bar) + if session: + return session[-480:] + return bars[-480:] + + +def bars_to_api(bars: list) -> list[dict]: + """转为前端图表 JSON(去重、排序、数值规范化)。""" + result: list[dict] = [] + seen: dict[int, dict] = {} + for bar in bars: + dt = _bar_datetime(bar) + ts = int(dt.timestamp() * 1000) if dt else None + try: + o = float(bar.get("o") or 0) + h = float(bar.get("h") or o) + l = float(bar.get("l") or o) + c = float(bar.get("c") or o) + v = float(bar.get("v") or 0) + except (TypeError, ValueError): + continue + if h < l: + h, l = l, h + h = max(h, o, c) + l = min(l, o, c) + row = { + "time": bar["d"], + "timestamp": ts, + "open": o, + "high": h, + "low": l, + "close": c, + "volume": v, + } + if ts is not None: + seen[ts] = row + else: + result.append(row) + if seen: + result = [seen[k] for k in sorted(seen.keys())] + return result + + +def fetch_market_klines( + symbol: str, + period: str, + db_path: Optional[str] = None, + force_remote: bool = False, + *, + trading_mode: Optional[str] = None, + prefer_ctp: bool = True, +) -> dict: + chart_sym = ths_to_sina_chart_symbol(symbol) + p = (period or "15m").lower() + if p == "timeshare": + chart_type = "line" + else: + chart_type = "candle" + + bars: list = [] + source = "remote" + cached_at = None + ctp_connected = False + ctp_bars: list = [] + + if prefer_ctp: + try: + from ctp_kline import fetch_ctp_klines + from vnpy_bridge import ctp_status + + mode = trading_mode + if not mode: + try: + from app import get_setting + from trading_context import get_trading_mode + + mode = get_trading_mode(get_setting) + except Exception: + mode = "simulation" + ctp_connected = bool(ctp_status(mode).get("connected")) + if ctp_connected: + ctp_bars = fetch_ctp_klines(symbol, p, mode) or [] + except Exception as exc: + logger.debug("ctp kline fetch failed %s %s: %s", symbol, p, exc) + + need_sina = ( + force_remote + or not ctp_bars + or len(ctp_bars) < MIN_CTP_KLINE_BARS + ) + + if ctp_bars and len(ctp_bars) >= MIN_CTP_KLINE_BARS: + bars = ctp_bars + source = "ctp" + + if not bars and db_path and chart_sym and not force_remote and need_sina: + try: + conn = connect_db(db_path) + cached = get_cached_entry(conn, chart_sym, p) + conn.close() + if cached and cached.get("fresh"): + bars = cached["bars"] + source = "local" + cached_at = cached.get("updated_at") + except Exception as exc: + logger.warning("kline cache read failed %s %s: %s", chart_sym, p, exc) + + if not bars or len(ctp_bars) < MIN_CTP_KLINE_BARS: + remote_bars = fetch_sina_klines(symbol, p) + if remote_bars: + if ctp_bars and ctp_connected: + bars = _merge_kline_bars(remote_bars, ctp_bars) + source = "ctp+remote" + else: + bars = remote_bars + source = "remote" + if db_path and chart_sym and not ctp_connected: + try: + conn = connect_db(db_path) + ensure_kline_tables(conn) + save_bars(conn, chart_sym, p, remote_bars) + meta = conn.execute( + "SELECT updated_at FROM kline_meta WHERE chart_symbol=? AND period=?", + (chart_sym, p), + ).fetchone() + conn.close() + cached_at = meta[0] if meta else None + except Exception as exc: + logger.warning("kline cache write failed %s %s: %s", chart_sym, p, exc) + elif not bars and db_path and chart_sym: + try: + conn = connect_db(db_path) + cached = get_cached_entry(conn, chart_sym, p) + conn.close() + if cached and cached.get("bars"): + bars = cached["bars"] + source = "local" + cached_at = cached.get("updated_at") + except Exception as exc: + logger.warning("kline cache fallback failed %s %s: %s", chart_sym, p, exc) + + api_bars = bars_to_api(bars) + prev_close = None + if len(api_bars) >= 2: + prev_close = api_bars[-2]["close"] + + return { + "symbol": symbol, + "chart_symbol": chart_sym, + "period": p, + "chart_type": chart_type, + "count": len(bars), + "bars": api_bars, + "prev_close": prev_close, + "source": source, + "cached_at": cached_at, + "ctp_connected": ctp_connected, + } + + +def _fetch_sina_daily(chart_sym: str) -> list: + url = ( + "https://stock2.finance.sina.com.cn/futures/api/json.php/" + f"IndexService.getInnerFuturesDailyKLine?symbol={chart_sym}" + ) + try: + resp = requests.get(url, timeout=20, headers={"Referer": "https://finance.sina.com.cn"}) + raw = resp.json() + if raw and isinstance(raw, list): + bars = _normalize_bars(raw) + if bars: + return bars + except Exception as exc: + logger.warning("fetch daily kline failed %s: %s", chart_sym, exc) + return _daily_from_minutes(chart_sym) + + +def _daily_from_minutes(chart_sym: str) -> list: + """合约日线接口无数据时,由 60 分钟 K 线按日合成。""" + bars_60 = _fetch_few_min_line(chart_sym, "60") + if not bars_60: + bars_60 = _fetch_few_min_line(chart_sym, "240") + buckets: dict[str, list] = {} + for bar in bars_60: + dt = _bar_datetime(bar) + if not dt: + continue + key = dt.strftime("%Y-%m-%d") + buckets.setdefault(key, []).append(bar) + out = [] + for day in sorted(buckets.keys()): + chunk = buckets[day] + merged = _merge_bars(chunk) + merged["d"] = day + " 15:00:00" + out.append(merged) + return out + + +def _parse_dt(value: str) -> Optional[datetime]: + if not value: + return None + v = value.strip().replace("T", " ") + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + return datetime.strptime(v, fmt).replace(tzinfo=TZ) + except ValueError: + continue + try: + return datetime.fromisoformat(value.strip()).replace(tzinfo=TZ) + except ValueError: + return None + + +def _bar_datetime(bar: dict) -> Optional[datetime]: + d = bar.get("d") + if not d: + return None + try: + return datetime.strptime(d, "%Y-%m-%d %H:%M:%S").replace(tzinfo=TZ) + except ValueError: + return None + + +def _select_bars( + bars: list, + cutoff: datetime, + count: int, +) -> list: + filtered = [] + for bar in bars: + dt = _bar_datetime(bar) + if dt and dt <= cutoff: + filtered.append(bar) + if not filtered: + filtered = bars + if count > 0 and len(filtered) > count: + filtered = filtered[-count:] + return filtered + + +def generate_review_kline_chart( + symbol: str, + periods: list[str], + count: int, + cutoff_label: str, + open_time: str, + close_time: str, + entry_price: Optional[float], + stop_loss: Optional[float], + take_profit: Optional[float], + close_price: Optional[float], + upload_dir: str, +) -> Optional[str]: + """生成双周期 K 线复盘图,返回 uploads 目录下的文件名。""" + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + import matplotlib.dates as mdates + + now = datetime.now(TZ) + if cutoff_label == "开仓时间": + cutoff = _parse_dt(open_time) or now + elif cutoff_label == "当前时间": + cutoff = now + else: + cutoff = _parse_dt(close_time) or now + + open_dt = _parse_dt(open_time) + close_dt = _parse_dt(close_time) + + valid_periods = [p for p in periods if p] + if not valid_periods: + valid_periods = ["15m", "1h"] + + fig, axes = plt.subplots( + len(valid_periods), 1, + figsize=(14, 4.5 * len(valid_periods)), + facecolor="#0a0a10", + squeeze=False, + ) + + plotted = False + for idx, period in enumerate(valid_periods): + ax = axes[idx, 0] + bars = fetch_sina_klines(symbol, period) + bars = _select_bars(bars, cutoff, count) + if not bars: + ax.set_facecolor("#12121a") + ax.text(0.5, 0.5, f"No {period} data", ha="center", va="center", color="#888") + ax.set_xticks([]) + ax.set_yticks([]) + continue + + times = [_bar_datetime(b) for b in bars] + closes = [float(b["c"]) for b in bars] + highs = [float(b["h"]) for b in bars] + lows = [float(b["l"]) for b in bars] + + ax.set_facecolor("#12121a") + ax.plot(times, closes, color="#4cc2ff", linewidth=1.2) + ax.fill_between( + times, lows, highs, + color="#4cc2ff", alpha=0.12, + ) + + levels = [ + (entry_price, "#eac147", "Entry"), + (stop_loss, "#ff6666", "SL"), + (take_profit, "#4cd97f", "TP"), + (close_price, "#c4c4ff", "Close"), + ] + for price, color, label in levels: + if price is not None: + ax.axhline(price, color=color, linewidth=0.9, linestyle="--", alpha=0.85) + ax.text(times[-1], price, label, color=color, fontsize=8, va="bottom") + + if open_dt: + ax.axvline(open_dt, color="#888", linewidth=0.8, linestyle=":", alpha=0.7) + if close_dt: + ax.axvline(close_dt, color="#aaa", linewidth=0.8, linestyle=":", alpha=0.7) + + chart_sym = ths_to_sina_chart_symbol(symbol) or symbol + ax.set_title(f"{chart_sym} {period}", color="#eaeaea", fontsize=11, pad=8) + ax.tick_params(colors="#888", labelsize=8) + for spine in ax.spines.values(): + spine.set_color("#2e2e45") + ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d %H:%M")) + ax.grid(True, color="#1e1e30", linewidth=0.5) + plotted = True + + if not plotted: + plt.close(fig) + return None + + fig.tight_layout() + ts = datetime.now(TZ).strftime("%Y%m%d%H%M%S") + chart_sym = ths_to_sina_chart_symbol(symbol) or "chart" + filename = f"{ts}_kline_{chart_sym}.png" + path = os.path.join(upload_dir, filename) + fig.savefig(path, dpi=120, facecolor=fig.get_facecolor()) + plt.close(fig) + return filename diff --git a/kline_store.py b/kline_store.py index 3321c0f..6f6a066 100644 --- a/kline_store.py +++ b/kline_store.py @@ -1,170 +1,175 @@ -"""K 线本地 SQLite 缓存。""" -from __future__ import annotations - -import sqlite3 -from datetime import datetime, timedelta -from typing import Optional -from zoneinfo import ZoneInfo - -TZ = ZoneInfo("Asia/Shanghai") - -REFRESH_SECONDS = { - "timeshare": 30, - "1m": 30, - "2m": 30, - "5m": 60, - "15m": 60, - "1h": 120, - "2h": 120, - "4h": 180, - "d": 300, - "w": 600, -} - - -def ensure_kline_tables(conn: sqlite3.Connection) -> None: - conn.execute( - """CREATE TABLE IF NOT EXISTS kline_bars ( - chart_symbol TEXT NOT NULL, - period TEXT NOT NULL, - bar_time TEXT NOT NULL, - open REAL NOT NULL, - high REAL NOT NULL, - low REAL NOT NULL, - close REAL NOT NULL, - volume REAL DEFAULT 0, - updated_at TEXT NOT NULL, - PRIMARY KEY (chart_symbol, period, bar_time) - )""" - ) - conn.execute( - """CREATE TABLE IF NOT EXISTS kline_meta ( - chart_symbol TEXT NOT NULL, - period TEXT NOT NULL, - bar_count INTEGER DEFAULT 0, - last_bar_time TEXT, - updated_at TEXT NOT NULL, - PRIMARY KEY (chart_symbol, period) - )""" - ) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_kline_bars_sym_period " - "ON kline_bars(chart_symbol, period, bar_time)" - ) - conn.commit() - - -def _parse_updated_at(value: str) -> Optional[datetime]: - if not value: - return None - try: - return datetime.fromisoformat(value.strip()).replace(tzinfo=TZ) - except ValueError: - return None - - -def is_cache_fresh(period: str, updated_at: str) -> bool: - dt = _parse_updated_at(updated_at) - if not dt: - return False - ttl = REFRESH_SECONDS.get((period or "").lower(), 60) - return datetime.now(TZ) - dt < timedelta(seconds=ttl) - - -def load_bars(conn: sqlite3.Connection, chart_symbol: str, period: str) -> list[dict]: - rows = conn.execute( - """SELECT bar_time, open, high, low, close, volume - FROM kline_bars - WHERE chart_symbol=? AND period=? - ORDER BY bar_time ASC""", - (chart_symbol, period), - ).fetchall() - return [ - { - "d": row[0], - "o": float(row[1]), - "h": float(row[2]), - "l": float(row[3]), - "c": float(row[4]), - "v": float(row[5] or 0), - } - for row in rows - ] - - -def load_meta(conn: sqlite3.Connection, chart_symbol: str, period: str) -> Optional[dict]: - row = conn.execute( - "SELECT bar_count, last_bar_time, updated_at FROM kline_meta " - "WHERE chart_symbol=? AND period=?", - (chart_symbol, period), - ).fetchone() - if not row: - return None - return { - "bar_count": row[0], - "last_bar_time": row[1], - "updated_at": row[2], - } - - -def save_bars(conn: sqlite3.Connection, chart_symbol: str, period: str, bars: list[dict]) -> int: - if not bars: - return 0 - ensure_kline_tables(conn) - now = datetime.now(TZ).isoformat(timespec="seconds") - for bar in bars: - conn.execute( - """INSERT INTO kline_bars - (chart_symbol, period, bar_time, open, high, low, close, volume, updated_at) - VALUES (?,?,?,?,?,?,?,?,?) - ON CONFLICT(chart_symbol, period, bar_time) DO UPDATE SET - open=excluded.open, - high=excluded.high, - low=excluded.low, - close=excluded.close, - volume=excluded.volume, - updated_at=excluded.updated_at""", - ( - chart_symbol, - period, - str(bar["d"]), - float(bar["o"]), - float(bar["h"]), - float(bar["l"]), - float(bar["c"]), - float(bar.get("v") or 0), - now, - ), - ) - last_time = str(bars[-1]["d"]) - conn.execute( - """INSERT INTO kline_meta (chart_symbol, period, bar_count, last_bar_time, updated_at) - VALUES (?,?,?,?,?) - ON CONFLICT(chart_symbol, period) DO UPDATE SET - bar_count=excluded.bar_count, - last_bar_time=excluded.last_bar_time, - updated_at=excluded.updated_at""", - (chart_symbol, period, len(bars), last_time, now), - ) - conn.commit() - return len(bars) - - -def get_cached_entry( - conn: sqlite3.Connection, - chart_symbol: str, - period: str, -) -> Optional[dict]: - if not chart_symbol: - return None - ensure_kline_tables(conn) - meta = load_meta(conn, chart_symbol, period) - bars = load_bars(conn, chart_symbol, period) - if not bars: - return None - updated_at = meta["updated_at"] if meta else "" - return { - "bars": bars, - "updated_at": updated_at, - "fresh": is_cache_fresh(period, updated_at), - } +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""K 线本地 SQLite 缓存。""" +from __future__ import annotations + +import sqlite3 +from datetime import datetime, timedelta +from typing import Optional +from zoneinfo import ZoneInfo + +TZ = ZoneInfo("Asia/Shanghai") + +REFRESH_SECONDS = { + "timeshare": 30, + "1m": 30, + "2m": 30, + "5m": 60, + "15m": 60, + "1h": 120, + "2h": 120, + "4h": 180, + "d": 300, + "w": 600, +} + + +def ensure_kline_tables(conn: sqlite3.Connection) -> None: + conn.execute( + """CREATE TABLE IF NOT EXISTS kline_bars ( + chart_symbol TEXT NOT NULL, + period TEXT NOT NULL, + bar_time TEXT NOT NULL, + open REAL NOT NULL, + high REAL NOT NULL, + low REAL NOT NULL, + close REAL NOT NULL, + volume REAL DEFAULT 0, + updated_at TEXT NOT NULL, + PRIMARY KEY (chart_symbol, period, bar_time) + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS kline_meta ( + chart_symbol TEXT NOT NULL, + period TEXT NOT NULL, + bar_count INTEGER DEFAULT 0, + last_bar_time TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (chart_symbol, period) + )""" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_kline_bars_sym_period " + "ON kline_bars(chart_symbol, period, bar_time)" + ) + conn.commit() + + +def _parse_updated_at(value: str) -> Optional[datetime]: + if not value: + return None + try: + return datetime.fromisoformat(value.strip()).replace(tzinfo=TZ) + except ValueError: + return None + + +def is_cache_fresh(period: str, updated_at: str) -> bool: + dt = _parse_updated_at(updated_at) + if not dt: + return False + ttl = REFRESH_SECONDS.get((period or "").lower(), 60) + return datetime.now(TZ) - dt < timedelta(seconds=ttl) + + +def load_bars(conn: sqlite3.Connection, chart_symbol: str, period: str) -> list[dict]: + rows = conn.execute( + """SELECT bar_time, open, high, low, close, volume + FROM kline_bars + WHERE chart_symbol=? AND period=? + ORDER BY bar_time ASC""", + (chart_symbol, period), + ).fetchall() + return [ + { + "d": row[0], + "o": float(row[1]), + "h": float(row[2]), + "l": float(row[3]), + "c": float(row[4]), + "v": float(row[5] or 0), + } + for row in rows + ] + + +def load_meta(conn: sqlite3.Connection, chart_symbol: str, period: str) -> Optional[dict]: + row = conn.execute( + "SELECT bar_count, last_bar_time, updated_at FROM kline_meta " + "WHERE chart_symbol=? AND period=?", + (chart_symbol, period), + ).fetchone() + if not row: + return None + return { + "bar_count": row[0], + "last_bar_time": row[1], + "updated_at": row[2], + } + + +def save_bars(conn: sqlite3.Connection, chart_symbol: str, period: str, bars: list[dict]) -> int: + if not bars: + return 0 + ensure_kline_tables(conn) + now = datetime.now(TZ).isoformat(timespec="seconds") + for bar in bars: + conn.execute( + """INSERT INTO kline_bars + (chart_symbol, period, bar_time, open, high, low, close, volume, updated_at) + VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT(chart_symbol, period, bar_time) DO UPDATE SET + open=excluded.open, + high=excluded.high, + low=excluded.low, + close=excluded.close, + volume=excluded.volume, + updated_at=excluded.updated_at""", + ( + chart_symbol, + period, + str(bar["d"]), + float(bar["o"]), + float(bar["h"]), + float(bar["l"]), + float(bar["c"]), + float(bar.get("v") or 0), + now, + ), + ) + last_time = str(bars[-1]["d"]) + conn.execute( + """INSERT INTO kline_meta (chart_symbol, period, bar_count, last_bar_time, updated_at) + VALUES (?,?,?,?,?) + ON CONFLICT(chart_symbol, period) DO UPDATE SET + bar_count=excluded.bar_count, + last_bar_time=excluded.last_bar_time, + updated_at=excluded.updated_at""", + (chart_symbol, period, len(bars), last_time, now), + ) + conn.commit() + return len(bars) + + +def get_cached_entry( + conn: sqlite3.Connection, + chart_symbol: str, + period: str, +) -> Optional[dict]: + if not chart_symbol: + return None + ensure_kline_tables(conn) + meta = load_meta(conn, chart_symbol, period) + bars = load_bars(conn, chart_symbol, period) + if not bars: + return None + updated_at = meta["updated_at"] if meta else "" + return { + "bars": bars, + "updated_at": updated_at, + "fresh": is_cache_fresh(period, updated_at), + } diff --git a/kline_stream.py b/kline_stream.py index 56d8fdd..eb92914 100644 --- a/kline_stream.py +++ b/kline_stream.py @@ -1,154 +1,159 @@ -"""K 线 SSE 推送与后台刷新。""" -from __future__ import annotations - -import json -import logging -import queue -import threading -import time -from dataclasses import dataclass, field -from datetime import datetime -from typing import Callable, Optional -from zoneinfo import ZoneInfo - -from kline_chart import fetch_market_klines, ths_to_sina_chart_symbol -from kline_store import is_cache_fresh, load_meta, ensure_kline_tables - -logger = logging.getLogger(__name__) -TZ = ZoneInfo("Asia/Shanghai") - -FAST_PERIODS = frozenset({ - "timeshare", "1m", "2m", "5m", "15m", "1h", "2h", "4h", -}) - - -def is_trading_session() -> bool: - d = datetime.now(TZ) - wd = d.weekday() - if wd == 6: - return False - if wd == 5 and d.hour < 21: - return False - t = d.hour * 60 + d.minute - def in_range(sh: int, sm: int, eh: int, em: int) -> bool: - return t >= sh * 60 + sm and t < eh * 60 + em - if in_range(9, 0, 11, 30): - return True - if in_range(13, 30, 15, 0): - return True - if in_range(21, 0, 24, 0): - return True - if in_range(0, 0, 2, 30): - return True - return False - - -def sse_format(event: str, data: dict) -> str: - return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" - - -@dataclass -class KlineSubscription: - symbol: str - period: str - market_code: str = "" - sina_code: str = "" - queue: queue.Queue = field(default_factory=queue.Queue) - - -class KlineStreamHub: - def __init__(self): - self._lock = threading.Lock() - self._subs: list[KlineSubscription] = [] - - def subscribe( - self, - symbol: str, - period: str, - market_code: str = "", - sina_code: str = "", - ) -> KlineSubscription: - sub = KlineSubscription( - symbol=symbol.strip(), - period=(period or "15m").strip().lower(), - market_code=market_code.strip(), - sina_code=sina_code.strip(), - ) - with self._lock: - self._subs.append(sub) - return sub - - def unsubscribe(self, sub: KlineSubscription) -> None: - with self._lock: - try: - self._subs.remove(sub) - except ValueError: - pass - - def _snapshot_subs(self) -> list[KlineSubscription]: - with self._lock: - return list(self._subs) - - def publish(self, sub: KlineSubscription, event: str, data: dict) -> None: - try: - sub.queue.put_nowait({"event": event, "data": data}) - except queue.Full: - pass - - def _should_refresh(self, sub: KlineSubscription, db_path: str) -> bool: - chart_sym = ths_to_sina_chart_symbol(sub.symbol) - if not chart_sym: - return False - if is_trading_session() and sub.period in FAST_PERIODS: - return True - try: - from db_conn import connect_db - conn = connect_db(db_path) - ensure_kline_tables(conn) - meta = load_meta(conn, chart_sym, sub.period) - conn.close() - if not meta: - return True - return not is_cache_fresh(sub.period, meta.get("updated_at", "")) - except Exception as exc: - logger.warning("kline refresh check failed: %s", exc) - return True - - def worker_loop( - self, - db_path: str, - quote_fn: Callable[..., dict], - get_mode_fn: Optional[Callable[[], str]] = None, - ) -> None: - while True: - try: - subs = self._snapshot_subs() - for sub in subs: - if not self._should_refresh(sub, db_path): - continue - try: - kline_data = fetch_market_klines( - sub.symbol, - sub.period, - db_path, - force_remote=True, - trading_mode=get_mode_fn() if get_mode_fn else None, - ) - if kline_data.get("bars"): - self.publish(sub, "kline", kline_data) - quote_data = quote_fn( - sub.symbol, sub.market_code, sub.sina_code, - ) - if quote_data: - self.publish(sub, "quote", quote_data) - except Exception as exc: - logger.warning( - "kline stream refresh %s %s: %s", - sub.symbol, sub.period, exc, - ) - except Exception as exc: - logger.warning("kline stream worker: %s", exc) - time.sleep(1) - - -kline_hub = KlineStreamHub() +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""K 线 SSE 推送与后台刷新。""" +from __future__ import annotations + +import json +import logging +import queue +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime +from typing import Callable, Optional +from zoneinfo import ZoneInfo + +from kline_chart import fetch_market_klines, ths_to_sina_chart_symbol +from kline_store import is_cache_fresh, load_meta, ensure_kline_tables + +logger = logging.getLogger(__name__) +TZ = ZoneInfo("Asia/Shanghai") + +FAST_PERIODS = frozenset({ + "timeshare", "1m", "2m", "5m", "15m", "1h", "2h", "4h", +}) + + +def is_trading_session() -> bool: + d = datetime.now(TZ) + wd = d.weekday() + if wd == 6: + return False + if wd == 5 and d.hour < 21: + return False + t = d.hour * 60 + d.minute + def in_range(sh: int, sm: int, eh: int, em: int) -> bool: + return t >= sh * 60 + sm and t < eh * 60 + em + if in_range(9, 0, 11, 30): + return True + if in_range(13, 30, 15, 0): + return True + if in_range(21, 0, 24, 0): + return True + if in_range(0, 0, 2, 30): + return True + return False + + +def sse_format(event: str, data: dict) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + +@dataclass +class KlineSubscription: + symbol: str + period: str + market_code: str = "" + sina_code: str = "" + queue: queue.Queue = field(default_factory=queue.Queue) + + +class KlineStreamHub: + def __init__(self): + self._lock = threading.Lock() + self._subs: list[KlineSubscription] = [] + + def subscribe( + self, + symbol: str, + period: str, + market_code: str = "", + sina_code: str = "", + ) -> KlineSubscription: + sub = KlineSubscription( + symbol=symbol.strip(), + period=(period or "15m").strip().lower(), + market_code=market_code.strip(), + sina_code=sina_code.strip(), + ) + with self._lock: + self._subs.append(sub) + return sub + + def unsubscribe(self, sub: KlineSubscription) -> None: + with self._lock: + try: + self._subs.remove(sub) + except ValueError: + pass + + def _snapshot_subs(self) -> list[KlineSubscription]: + with self._lock: + return list(self._subs) + + def publish(self, sub: KlineSubscription, event: str, data: dict) -> None: + try: + sub.queue.put_nowait({"event": event, "data": data}) + except queue.Full: + pass + + def _should_refresh(self, sub: KlineSubscription, db_path: str) -> bool: + chart_sym = ths_to_sina_chart_symbol(sub.symbol) + if not chart_sym: + return False + if is_trading_session() and sub.period in FAST_PERIODS: + return True + try: + from db_conn import connect_db + conn = connect_db(db_path) + ensure_kline_tables(conn) + meta = load_meta(conn, chart_sym, sub.period) + conn.close() + if not meta: + return True + return not is_cache_fresh(sub.period, meta.get("updated_at", "")) + except Exception as exc: + logger.warning("kline refresh check failed: %s", exc) + return True + + def worker_loop( + self, + db_path: str, + quote_fn: Callable[..., dict], + get_mode_fn: Optional[Callable[[], str]] = None, + ) -> None: + while True: + try: + subs = self._snapshot_subs() + for sub in subs: + if not self._should_refresh(sub, db_path): + continue + try: + kline_data = fetch_market_klines( + sub.symbol, + sub.period, + db_path, + force_remote=True, + trading_mode=get_mode_fn() if get_mode_fn else None, + ) + if kline_data.get("bars"): + self.publish(sub, "kline", kline_data) + quote_data = quote_fn( + sub.symbol, sub.market_code, sub.sina_code, + ) + if quote_data: + self.publish(sub, "quote", quote_data) + except Exception as exc: + logger.warning( + "kline stream refresh %s %s: %s", + sub.symbol, sub.period, exc, + ) + except Exception as exc: + logger.warning("kline stream worker: %s", exc) + time.sleep(1) + + +kline_hub = KlineStreamHub() diff --git a/locale_fix.py b/locale_fix.py index e7f58ea..aaf7ed1 100644 --- a/locale_fix.py +++ b/locale_fix.py @@ -1,91 +1,96 @@ -"""Linux 上 vnpy_ctp 连接 CTP 前须设置有效 locale(否则 C++ 层 abort)。""" -from __future__ import annotations - -import locale -import logging -import os -import subprocess - -logger = logging.getLogger(__name__) - -_LOCALE_DONE = False -_LOCALE_NAME = "" - -# CTP C++ API 登录回调依赖中文 locale(见 vnpy/vnpy_ctp#24) -_CTP_REQUIRED_LOCALES = ("zh_CN.GB18030", "zh_CN.gb18030") - - -def _available_locales() -> set[str]: - try: - out = subprocess.check_output(["locale", "-a"], text=True, stderr=subprocess.DEVNULL) - return {line.strip() for line in out.splitlines() if line.strip()} - except (OSError, subprocess.SubprocessError): - return set() - - -def missing_ctp_locales() -> list[str]: - """CTP 所需的 zh_CN.GB18030 是否已安装。""" - avail = {x.lower() for x in _available_locales()} - if any(x.lower() in avail for x in _CTP_REQUIRED_LOCALES): - return [] - return ["zh_CN.GB18030"] - - -def _list_locale_candidates() -> list[str]: - avail = _available_locales() - names: list[str] = [] - # CTP 回调优先尝试中文 locale - for item in ( - "zh_CN.GB18030", - "zh_CN.gb18030", - "zh_CN.UTF-8", - "zh_CN.utf8", - "en_US.UTF-8", - "en_US.utf8", - "C.UTF-8", - "C.utf8", - "POSIX", - "C", - ): - if item in avail and item not in names: - names.append(item) - for loc in sorted(avail): - low = loc.lower() - if "utf" in low and loc not in names: - names.append(loc) - return names - - -def ensure_process_locale() -> str: - """强制设置进程 locale,覆盖系统里无效的旧值。""" - global _LOCALE_DONE, _LOCALE_NAME - if _LOCALE_DONE: - return _LOCALE_NAME - - missing = missing_ctp_locales() - if missing: - raise RuntimeError( - "CTP 需要中文 locale zh_CN.GB18030,当前系统未安装。" - "请执行: sed -i '/^# zh_CN.GB18030/s/^# //' /etc/locale.gen && " - "locale-gen zh_CN.GB18030" - ) - - last_err: locale.Error | None = None - for name in _list_locale_candidates(): - try: - locale.setlocale(locale.LC_ALL, name) - os.environ["LANG"] = name - os.environ["LC_ALL"] = name - os.environ["LC_CTYPE"] = name - _LOCALE_DONE = True - _LOCALE_NAME = name - logger.info("进程 locale 已设置: %s", name) - return name - except locale.Error as exc: - last_err = exc - continue - - raise RuntimeError( - "未找到可用 locale,vnpy_ctp 会在 CTP 登录后崩溃。" - "请执行: apt install -y locales && locale-gen zh_CN.GB18030 en_US.UTF-8" - ) from last_err +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""Linux 上 vnpy_ctp 连接 CTP 前须设置有效 locale(否则 C++ 层 abort)。""" +from __future__ import annotations + +import locale +import logging +import os +import subprocess + +logger = logging.getLogger(__name__) + +_LOCALE_DONE = False +_LOCALE_NAME = "" + +# CTP C++ API 登录回调依赖中文 locale(见 vnpy/vnpy_ctp#24) +_CTP_REQUIRED_LOCALES = ("zh_CN.GB18030", "zh_CN.gb18030") + + +def _available_locales() -> set[str]: + try: + out = subprocess.check_output(["locale", "-a"], text=True, stderr=subprocess.DEVNULL) + return {line.strip() for line in out.splitlines() if line.strip()} + except (OSError, subprocess.SubprocessError): + return set() + + +def missing_ctp_locales() -> list[str]: + """CTP 所需的 zh_CN.GB18030 是否已安装。""" + avail = {x.lower() for x in _available_locales()} + if any(x.lower() in avail for x in _CTP_REQUIRED_LOCALES): + return [] + return ["zh_CN.GB18030"] + + +def _list_locale_candidates() -> list[str]: + avail = _available_locales() + names: list[str] = [] + # CTP 回调优先尝试中文 locale + for item in ( + "zh_CN.GB18030", + "zh_CN.gb18030", + "zh_CN.UTF-8", + "zh_CN.utf8", + "en_US.UTF-8", + "en_US.utf8", + "C.UTF-8", + "C.utf8", + "POSIX", + "C", + ): + if item in avail and item not in names: + names.append(item) + for loc in sorted(avail): + low = loc.lower() + if "utf" in low and loc not in names: + names.append(loc) + return names + + +def ensure_process_locale() -> str: + """强制设置进程 locale,覆盖系统里无效的旧值。""" + global _LOCALE_DONE, _LOCALE_NAME + if _LOCALE_DONE: + return _LOCALE_NAME + + missing = missing_ctp_locales() + if missing: + raise RuntimeError( + "CTP 需要中文 locale zh_CN.GB18030,当前系统未安装。" + "请执行: sed -i '/^# zh_CN.GB18030/s/^# //' /etc/locale.gen && " + "locale-gen zh_CN.GB18030" + ) + + last_err: locale.Error | None = None + for name in _list_locale_candidates(): + try: + locale.setlocale(locale.LC_ALL, name) + os.environ["LANG"] = name + os.environ["LC_ALL"] = name + os.environ["LC_CTYPE"] = name + _LOCALE_DONE = True + _LOCALE_NAME = name + logger.info("进程 locale 已设置: %s", name) + return name + except locale.Error as exc: + last_err = exc + continue + + raise RuntimeError( + "未找到可用 locale,vnpy_ctp 会在 CTP 登录后崩溃。" + "请执行: apt install -y locales && locale-gen zh_CN.GB18030 en_US.UTF-8" + ) from last_err diff --git a/market.py b/market.py index 798b991..9a5b0ad 100644 --- a/market.py +++ b/market.py @@ -1,243 +1,248 @@ -""" -行情拉取:默认新浪(免费,普通用户可用)。 -同花顺 iFinD HTTP 仅面向机构用户,需单独申请 token,可选开启。 -""" -import os -import time -import json -import logging -from typing import Optional - -import requests - -logger = logging.getLogger(__name__) - -THS_TOKEN_URL = "https://quantapi.51ifind.com/api/v1/get_access_token" -THS_QUOTE_URL = "https://quantapi.51ifind.com/api/v1/real_time_quotation" - -# iFinD HTTP 期货交易所后缀 -THS_EX_SUFFIX = { - "SHFE": "SHFE", - "DCE": "DCE", - "CZCE": "CZCE", - "CFFEX": "CFFEX", - "INE": "INE", -} - -_token_cache: dict = {"token": "", "expires": 0.0, "refresh": ""} - - -def _quote_source() -> str: - return os.getenv("QUOTE_SOURCE", "sina").strip().lower() - - -def _has_ths_token() -> bool: - return bool(_get_refresh_token()) - - -def get_quote_source_label(*, ctp_connected: bool = False) -> str: - """界面展示用行情源说明。""" - if ctp_connected: - return "CTP 柜台(已连接)" - source = _quote_source() - if source == "sina": - return "新浪(CTP 未连接时备用)" - if source == "ths": - return "同花顺 iFinD" if _has_ths_token() else "同花顺(未配置 token)" - if _has_ths_token(): - return "同花顺优先,失败回退新浪" - return "新浪(CTP 未连接时备用)" - - -def _sina_headers() -> dict: - return {"Referer": "https://finance.sina.com.cn"} - - -def _parse_sina_futures_quote(parts: list) -> Optional[dict]: - """解析新浪 nf_/CFF_RE_ 期货行情字段。""" - if len(parts) < 9: - return None - price = None - for idx in (8, 7, 6, 5): - if len(parts) > idx and parts[idx]: - try: - val = float(parts[idx]) - if val > 0: - price = val - break - except ValueError: - pass - if price is None: - price = 0.0 - - open_interest = 0.0 - volume = 0.0 - if len(parts) > 13 and parts[13]: - try: - open_interest = float(parts[13]) - except ValueError: - pass - if len(parts) > 14 and parts[14]: - try: - volume = float(parts[14]) - except ValueError: - pass - - prev_close = None - if len(parts) > 9 and parts[9]: - try: - prev_close = float(parts[9]) - except ValueError: - pass - - return { - "name": parts[0], - "price": price, - "volume": volume, - "open_interest": open_interest, - "prev_close": prev_close, - } - - -def _fetch_sina_raw(sina_code: str) -> Optional[dict]: - try: - url = f"https://hq.sinajs.cn/list={sina_code}" - resp = requests.get(url, headers=_sina_headers(), timeout=5) - resp.encoding = "gbk" - if '"' not in resp.text: - return None - body = resp.text.split('"')[1] - if not body: - return None - parts = body.split(",") - return _parse_sina_futures_quote(parts) - except Exception as exc: - logger.debug("sina fetch failed %s: %s", sina_code, exc) - return None - - -def get_sina_price(sina_code: str) -> Optional[float]: - raw = _fetch_sina_raw(sina_code) - return raw["price"] if raw else None - - -_runtime_refresh_token: str = "" - - -def set_ths_refresh_token(token: str): - global _runtime_refresh_token - _runtime_refresh_token = (token or "").strip() - - -def _get_refresh_token() -> str: - if _runtime_refresh_token: - return _runtime_refresh_token - return os.getenv("THS_REFRESH_TOKEN", "").strip() - - -def _get_ths_access_token(refresh_token: str) -> Optional[str]: - if not refresh_token: - return None - now = time.time() - if ( - _token_cache["token"] - and _token_cache["refresh"] == refresh_token - and now < _token_cache["expires"] - ): - return _token_cache["token"] - try: - resp = requests.post( - THS_TOKEN_URL, - headers={"Content-Type": "application/json", "refresh_token": refresh_token}, - timeout=10, - ) - data = resp.json() - if data.get("errorcode") != 0: - logger.warning("THS token error: %s", data.get("errmsg")) - return None - access = data["data"]["access_token"] - _token_cache.update({ - "token": access, - "refresh": refresh_token, - "expires": now + 3600 * 6, - }) - return access - except Exception as exc: - logger.warning("THS token request failed: %s", exc) - return None - - -def _parse_ths_quote(data: dict) -> Optional[float]: - """从同花顺实时行情响应解析最新价。""" - try: - tables = data.get("tables") or [] - for table in tables: - t = table.get("table") or {} - for key in ("latest", "new", "close", "trade", "last"): - val = t.get(key) - if val is None: - continue - if isinstance(val, list) and val: - return float(val[0]) - if isinstance(val, (int, float, str)) and str(val): - return float(val) - # 部分响应嵌套在 data 字段 - if "data" in data and isinstance(data["data"], dict): - return _parse_ths_quote(data["data"]) - except Exception as exc: - logger.debug("parse ths quote failed: %s", exc) - return None - - -def get_ths_price(ths_full_code: str, refresh_token: str = "") -> Optional[float]: - """ths_full_code 如 ag2608.SHFE、IF2606.CFFEX""" - token = refresh_token or _get_refresh_token() - access = _get_ths_access_token(token) - if not access: - return None - try: - resp = requests.post( - THS_QUOTE_URL, - headers={"Content-Type": "application/json", "access_token": access}, - json={"codes": ths_full_code, "indicators": "latest"}, - timeout=10, - ) - data = resp.json() - if data.get("errorcode") != 0: - logger.warning("THS quote error %s: %s", ths_full_code, data.get("errmsg")) - return None - return _parse_ths_quote(data) - except Exception as exc: - logger.warning("THS quote failed %s: %s", ths_full_code, exc) - return None - - -def get_price(market_code: str, sina_fallback: str = "") -> Optional[float]: - """ - 统一取价入口。 - sina_fallback: 新浪代码 nf_AG2608(普通用户默认使用) - market_code: 同花顺完整代码 ag2608.SHFE(仅机构 token 可用时) - """ - source = _quote_source() - - # 仅在有 token 且配置为 ths/auto 时才尝试同花顺 - use_ths = source == "ths" or (source == "auto" and _has_ths_token()) - if use_ths and market_code and "." in market_code: - price = get_ths_price(market_code) - if price is not None: - return price - if source == "ths": - return None - - if sina_fallback: - return get_sina_price(sina_fallback) - - if market_code.startswith("nf_") or market_code.startswith("CFF_RE_"): - return get_sina_price(market_code) - - return None - - -def fetch_raw_for_volume(sina_code: str) -> Optional[dict]: - """主力合约扫描用(成交量),走新浪。""" - return _fetch_sina_raw(sina_code) +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +""" +行情拉取:默认新浪(免费,普通用户可用)。 +同花顺 iFinD HTTP 仅面向机构用户,需单独申请 token,可选开启。 +""" +import os +import time +import json +import logging +from typing import Optional + +import requests + +logger = logging.getLogger(__name__) + +THS_TOKEN_URL = "https://quantapi.51ifind.com/api/v1/get_access_token" +THS_QUOTE_URL = "https://quantapi.51ifind.com/api/v1/real_time_quotation" + +# iFinD HTTP 期货交易所后缀 +THS_EX_SUFFIX = { + "SHFE": "SHFE", + "DCE": "DCE", + "CZCE": "CZCE", + "CFFEX": "CFFEX", + "INE": "INE", +} + +_token_cache: dict = {"token": "", "expires": 0.0, "refresh": ""} + + +def _quote_source() -> str: + return os.getenv("QUOTE_SOURCE", "sina").strip().lower() + + +def _has_ths_token() -> bool: + return bool(_get_refresh_token()) + + +def get_quote_source_label(*, ctp_connected: bool = False) -> str: + """界面展示用行情源说明。""" + if ctp_connected: + return "CTP 柜台(已连接)" + source = _quote_source() + if source == "sina": + return "新浪(CTP 未连接时备用)" + if source == "ths": + return "同花顺 iFinD" if _has_ths_token() else "同花顺(未配置 token)" + if _has_ths_token(): + return "同花顺优先,失败回退新浪" + return "新浪(CTP 未连接时备用)" + + +def _sina_headers() -> dict: + return {"Referer": "https://finance.sina.com.cn"} + + +def _parse_sina_futures_quote(parts: list) -> Optional[dict]: + """解析新浪 nf_/CFF_RE_ 期货行情字段。""" + if len(parts) < 9: + return None + price = None + for idx in (8, 7, 6, 5): + if len(parts) > idx and parts[idx]: + try: + val = float(parts[idx]) + if val > 0: + price = val + break + except ValueError: + pass + if price is None: + price = 0.0 + + open_interest = 0.0 + volume = 0.0 + if len(parts) > 13 and parts[13]: + try: + open_interest = float(parts[13]) + except ValueError: + pass + if len(parts) > 14 and parts[14]: + try: + volume = float(parts[14]) + except ValueError: + pass + + prev_close = None + if len(parts) > 9 and parts[9]: + try: + prev_close = float(parts[9]) + except ValueError: + pass + + return { + "name": parts[0], + "price": price, + "volume": volume, + "open_interest": open_interest, + "prev_close": prev_close, + } + + +def _fetch_sina_raw(sina_code: str) -> Optional[dict]: + try: + url = f"https://hq.sinajs.cn/list={sina_code}" + resp = requests.get(url, headers=_sina_headers(), timeout=5) + resp.encoding = "gbk" + if '"' not in resp.text: + return None + body = resp.text.split('"')[1] + if not body: + return None + parts = body.split(",") + return _parse_sina_futures_quote(parts) + except Exception as exc: + logger.debug("sina fetch failed %s: %s", sina_code, exc) + return None + + +def get_sina_price(sina_code: str) -> Optional[float]: + raw = _fetch_sina_raw(sina_code) + return raw["price"] if raw else None + + +_runtime_refresh_token: str = "" + + +def set_ths_refresh_token(token: str): + global _runtime_refresh_token + _runtime_refresh_token = (token or "").strip() + + +def _get_refresh_token() -> str: + if _runtime_refresh_token: + return _runtime_refresh_token + return os.getenv("THS_REFRESH_TOKEN", "").strip() + + +def _get_ths_access_token(refresh_token: str) -> Optional[str]: + if not refresh_token: + return None + now = time.time() + if ( + _token_cache["token"] + and _token_cache["refresh"] == refresh_token + and now < _token_cache["expires"] + ): + return _token_cache["token"] + try: + resp = requests.post( + THS_TOKEN_URL, + headers={"Content-Type": "application/json", "refresh_token": refresh_token}, + timeout=10, + ) + data = resp.json() + if data.get("errorcode") != 0: + logger.warning("THS token error: %s", data.get("errmsg")) + return None + access = data["data"]["access_token"] + _token_cache.update({ + "token": access, + "refresh": refresh_token, + "expires": now + 3600 * 6, + }) + return access + except Exception as exc: + logger.warning("THS token request failed: %s", exc) + return None + + +def _parse_ths_quote(data: dict) -> Optional[float]: + """从同花顺实时行情响应解析最新价。""" + try: + tables = data.get("tables") or [] + for table in tables: + t = table.get("table") or {} + for key in ("latest", "new", "close", "trade", "last"): + val = t.get(key) + if val is None: + continue + if isinstance(val, list) and val: + return float(val[0]) + if isinstance(val, (int, float, str)) and str(val): + return float(val) + # 部分响应嵌套在 data 字段 + if "data" in data and isinstance(data["data"], dict): + return _parse_ths_quote(data["data"]) + except Exception as exc: + logger.debug("parse ths quote failed: %s", exc) + return None + + +def get_ths_price(ths_full_code: str, refresh_token: str = "") -> Optional[float]: + """ths_full_code 如 ag2608.SHFE、IF2606.CFFEX""" + token = refresh_token or _get_refresh_token() + access = _get_ths_access_token(token) + if not access: + return None + try: + resp = requests.post( + THS_QUOTE_URL, + headers={"Content-Type": "application/json", "access_token": access}, + json={"codes": ths_full_code, "indicators": "latest"}, + timeout=10, + ) + data = resp.json() + if data.get("errorcode") != 0: + logger.warning("THS quote error %s: %s", ths_full_code, data.get("errmsg")) + return None + return _parse_ths_quote(data) + except Exception as exc: + logger.warning("THS quote failed %s: %s", ths_full_code, exc) + return None + + +def get_price(market_code: str, sina_fallback: str = "") -> Optional[float]: + """ + 统一取价入口。 + sina_fallback: 新浪代码 nf_AG2608(普通用户默认使用) + market_code: 同花顺完整代码 ag2608.SHFE(仅机构 token 可用时) + """ + source = _quote_source() + + # 仅在有 token 且配置为 ths/auto 时才尝试同花顺 + use_ths = source == "ths" or (source == "auto" and _has_ths_token()) + if use_ths and market_code and "." in market_code: + price = get_ths_price(market_code) + if price is not None: + return price + if source == "ths": + return None + + if sina_fallback: + return get_sina_price(sina_fallback) + + if market_code.startswith("nf_") or market_code.startswith("CFF_RE_"): + return get_sina_price(market_code) + + return None + + +def fetch_raw_for_volume(sina_code: str) -> Optional[dict]: + """主力合约扫描用(成交量),走新浪。""" + return _fetch_sina_raw(sina_code) diff --git a/market_sessions.py b/market_sessions.py index 0dcf41a..237e242 100644 --- a/market_sessions.py +++ b/market_sessions.py @@ -1,102 +1,107 @@ -"""国内期货交易时段与盘前连接窗口。""" -from __future__ import annotations - -from datetime import datetime, timedelta -from typing import Optional -from zoneinfo import ZoneInfo - -TZ = ZoneInfo("Asia/Shanghai") - -# 各交易段开盘时刻 (时, 分) -SESSION_OPENS = ( - (9, 0), - (13, 30), - (21, 0), -) - - -def is_trading_session(now: Optional[datetime] = None) -> bool: - d = now or datetime.now(TZ) - if d.tzinfo is None: - d = d.replace(tzinfo=TZ) - else: - d = d.astimezone(TZ) - wd = d.weekday() - if wd == 6: - return False - if wd == 5 and d.hour < 21: - return False - t = d.hour * 60 + d.minute - def in_range(sh: int, sm: int, eh: int, em: int) -> bool: - return t >= sh * 60 + sm and t < eh * 60 + em - if in_range(9, 0, 11, 30): - return True - if in_range(13, 30, 15, 0): - return True - if in_range(21, 0, 24, 0): - return True - if in_range(0, 0, 2, 30): - return True - return False - - -def _session_open_allowed(day: datetime, hour: int, minute: int) -> bool: - wd = day.weekday() - if (hour, minute) == (9, 0) or (hour, minute) == (13, 30): - return wd < 5 - if (hour, minute) == (21, 0): - if wd < 5: - return True - return wd == 5 - return False - - -def iter_session_starts( - start: datetime, - *, - hours_ahead: int = 36, -) -> list[datetime]: - """列出 start 之后若干小时内的各段开盘时刻。""" - if start.tzinfo is None: - start = start.replace(tzinfo=TZ) - else: - start = start.astimezone(TZ) - end = start + timedelta(hours=hours_ahead) - out: list[datetime] = [] - day = start.replace(hour=0, minute=0, second=0, microsecond=0) - while day <= end: - for h, m in SESSION_OPENS: - if not _session_open_allowed(day, h, m): - continue - dt = day.replace(hour=h, minute=m) - if dt > start and dt <= end: - out.append(dt) - day += timedelta(days=1) - out.sort() - return out - - -def minutes_until_next_session(now: Optional[datetime] = None) -> Optional[float]: - d = now or datetime.now(TZ) - if d.tzinfo is None: - d = d.replace(tzinfo=TZ) - else: - d = d.astimezone(TZ) - starts = iter_session_starts(d, hours_ahead=48) - if not starts: - return None - return (starts[0] - d).total_seconds() / 60.0 - - -def in_premarket_connect_window( - now: Optional[datetime] = None, - *, - minutes_before: int = 30, -) -> bool: - """距下一段开盘 <= minutes_before 分钟,且当前尚未进入交易时段。""" - if is_trading_session(now): - return False - mins = minutes_until_next_session(now) - if mins is None: - return False - return 0 < mins <= float(minutes_before) +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""国内期货交易时段与盘前连接窗口。""" +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Optional +from zoneinfo import ZoneInfo + +TZ = ZoneInfo("Asia/Shanghai") + +# 各交易段开盘时刻 (时, 分) +SESSION_OPENS = ( + (9, 0), + (13, 30), + (21, 0), +) + + +def is_trading_session(now: Optional[datetime] = None) -> bool: + d = now or datetime.now(TZ) + if d.tzinfo is None: + d = d.replace(tzinfo=TZ) + else: + d = d.astimezone(TZ) + wd = d.weekday() + if wd == 6: + return False + if wd == 5 and d.hour < 21: + return False + t = d.hour * 60 + d.minute + def in_range(sh: int, sm: int, eh: int, em: int) -> bool: + return t >= sh * 60 + sm and t < eh * 60 + em + if in_range(9, 0, 11, 30): + return True + if in_range(13, 30, 15, 0): + return True + if in_range(21, 0, 24, 0): + return True + if in_range(0, 0, 2, 30): + return True + return False + + +def _session_open_allowed(day: datetime, hour: int, minute: int) -> bool: + wd = day.weekday() + if (hour, minute) == (9, 0) or (hour, minute) == (13, 30): + return wd < 5 + if (hour, minute) == (21, 0): + if wd < 5: + return True + return wd == 5 + return False + + +def iter_session_starts( + start: datetime, + *, + hours_ahead: int = 36, +) -> list[datetime]: + """列出 start 之后若干小时内的各段开盘时刻。""" + if start.tzinfo is None: + start = start.replace(tzinfo=TZ) + else: + start = start.astimezone(TZ) + end = start + timedelta(hours=hours_ahead) + out: list[datetime] = [] + day = start.replace(hour=0, minute=0, second=0, microsecond=0) + while day <= end: + for h, m in SESSION_OPENS: + if not _session_open_allowed(day, h, m): + continue + dt = day.replace(hour=h, minute=m) + if dt > start and dt <= end: + out.append(dt) + day += timedelta(days=1) + out.sort() + return out + + +def minutes_until_next_session(now: Optional[datetime] = None) -> Optional[float]: + d = now or datetime.now(TZ) + if d.tzinfo is None: + d = d.replace(tzinfo=TZ) + else: + d = d.astimezone(TZ) + starts = iter_session_starts(d, hours_ahead=48) + if not starts: + return None + return (starts[0] - d).total_seconds() / 60.0 + + +def in_premarket_connect_window( + now: Optional[datetime] = None, + *, + minutes_before: int = 30, +) -> bool: + """距下一段开盘 <= minutes_before 分钟,且当前尚未进入交易时段。""" + if is_trading_session(now): + return False + mins = minutes_until_next_session(now) + if mins is None: + return False + return 0 < mins <= float(minutes_before) diff --git a/nav_settings.py b/nav_settings.py index d3f3010..1a7efe2 100644 --- a/nav_settings.py +++ b/nav_settings.py @@ -1,46 +1,51 @@ -"""顶栏导航项显示开关(系统设置)。""" -from __future__ import annotations - -import json -from typing import Callable - -# 可在系统设置中开关的导航项 -NAV_TOGGLES: dict[str, str] = { - "fees": "手续费配置", - "contract": "品种简介", - "plans": "开单计划", - "market": "行情K线", - "strategy": "策略交易", -} - -DEFAULT_NAV: dict[str, bool] = {k: True for k in NAV_TOGGLES} - - -def get_nav_items(get_setting: Callable[[str, str], str]) -> dict[str, bool]: - raw = (get_setting("nav_items", "") or "").strip() - out = dict(DEFAULT_NAV) - if not raw: - return out - try: - data = json.loads(raw) - if isinstance(data, dict): - for k in NAV_TOGGLES: - if k in data: - out[k] = bool(data[k]) - except json.JSONDecodeError: - pass - return out - - -def save_nav_items(set_setting: Callable[[str, str], None], items: dict[str, bool]) -> None: - merged = dict(DEFAULT_NAV) - for k in NAV_TOGGLES: - if k in items: - merged[k] = bool(items[k]) - set_setting("nav_items", json.dumps(merged, ensure_ascii=False)) - - -def nav_enabled(get_setting: Callable[[str, str], str], key: str) -> bool: - if key not in NAV_TOGGLES: - return True - return get_nav_items(get_setting).get(key, True) +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""顶栏导航项显示开关(系统设置)。""" +from __future__ import annotations + +import json +from typing import Callable + +# 可在系统设置中开关的导航项 +NAV_TOGGLES: dict[str, str] = { + "fees": "手续费配置", + "contract": "品种简介", + "plans": "开单计划", + "market": "行情K线", + "strategy": "策略交易", +} + +DEFAULT_NAV: dict[str, bool] = {k: True for k in NAV_TOGGLES} + + +def get_nav_items(get_setting: Callable[[str, str], str]) -> dict[str, bool]: + raw = (get_setting("nav_items", "") or "").strip() + out = dict(DEFAULT_NAV) + if not raw: + return out + try: + data = json.loads(raw) + if isinstance(data, dict): + for k in NAV_TOGGLES: + if k in data: + out[k] = bool(data[k]) + except json.JSONDecodeError: + pass + return out + + +def save_nav_items(set_setting: Callable[[str, str], None], items: dict[str, bool]) -> None: + merged = dict(DEFAULT_NAV) + for k in NAV_TOGGLES: + if k in items: + merged[k] = bool(items[k]) + set_setting("nav_items", json.dumps(merged, ensure_ascii=False)) + + +def nav_enabled(get_setting: Callable[[str, str], str], key: str) -> bool: + if key not in NAV_TOGGLES: + return True + return get_nav_items(get_setting).get(key, True) diff --git a/order_pending.py b/order_pending.py index a2f0757..c9f807c 100644 --- a/order_pending.py +++ b/order_pending.py @@ -1,175 +1,180 @@ -"""开仓委托:pending 状态跟踪、成交转正、超时撤单。""" -from __future__ import annotations - -import logging -import time -from datetime import datetime -from typing import Any, Callable, Optional -from zoneinfo import ZoneInfo - -from market_sessions import is_trading_session -from vnpy_bridge import ctp_cancel_order, ctp_list_active_orders, ctp_status - -logger = logging.getLogger(__name__) - -TZ = ZoneInfo("Asia/Shanghai") -DEFAULT_PENDING_ORDER_TIMEOUT_SEC = 300 - - -def parse_monitor_ts(raw: str) -> Optional[float]: - s = (raw or "").strip() - if not s: - return None - for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"): - try: - return datetime.strptime(s[:19], fmt).replace(tzinfo=TZ).timestamp() - except ValueError: - continue - return None - - -def pending_age_sec(mon: dict) -> float: - ts = parse_monitor_ts(mon.get("open_time") or "") or parse_monitor_ts( - str(mon.get("created_at") or "") - ) - if ts is None: - return 0.0 - return max(0.0, time.time() - ts) - - -def pending_auto_cancel_remaining( - mon: dict, - *, - timeout_sec: int = DEFAULT_PENDING_ORDER_TIMEOUT_SEC, -) -> int: - limit = max(60, int(timeout_sec or DEFAULT_PENDING_ORDER_TIMEOUT_SEC)) - return max(0, int(limit - pending_age_sec(mon))) - - -def _match_symbol(ctp_sym: str, ths: str) -> bool: - a = (ctp_sym or "").lower() - b = (ths or "").lower() - if a == b: - return True - if a and b and a.split(".")[0] == b.split(".")[0]: - return True - try: - from ctp_symbol import ths_to_vnpy_symbol - vnpy_sym, _ = ths_to_vnpy_symbol(ths) - if a == vnpy_sym.lower(): - return True - except Exception: - pass - return False - - -def _find_ctp_position(positions: list[dict], sym: str, direction: str) -> Optional[dict]: - direction = (direction or "long").strip().lower() - for p in positions or []: - if int(p.get("lots") or 0) <= 0: - continue - if (p.get("direction") or "long") != direction: - continue - if _match_symbol(p.get("symbol") or "", sym): - return p - return None - - -def reconcile_pending_orders( - conn, - mode: str, - *, - match_symbol_fn: Callable[[str, str], bool] | None = None, - sync_monitor_fn: Callable[..., None] | None = None, - capital: float = 0.0, - list_positions_fn: Callable[..., list] | None = None, - timeout_sec: int = DEFAULT_PENDING_ORDER_TIMEOUT_SEC, -) -> dict[str, int]: - """同步 pending 委托:成交→active;超时/已撤→closed。""" - limit_sec = max(60, int(timeout_sec or DEFAULT_PENDING_ORDER_TIMEOUT_SEC)) - stats = {"promoted": 0, "cancelled": 0, "closed": 0} - if not ctp_status(mode).get("connected"): - return stats - - match = match_symbol_fn or _match_symbol - positions = ( - list_positions_fn(mode, refresh_if_empty=False, refresh_margin=False) - if list_positions_fn - else [] - ) - try: - active_orders = { - str(o.get("order_id") or ""): o - for o in ctp_list_active_orders(mode) - if o.get("order_id") - } - except Exception as exc: - logger.debug("list active orders: %s", exc) - active_orders = {} - - rows = conn.execute( - "SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id ASC" - ).fetchall() - - for r in rows: - mon = dict(r) - mid = int(mon["id"]) - sym = mon.get("symbol") or "" - direction = mon.get("direction") or "long" - vt_oid = (mon.get("vt_order_id") or "").strip() - age = pending_age_sec(mon) - - pos = _find_ctp_position(positions, sym, direction) - if pos: - conn.execute( - "UPDATE trade_order_monitors SET status='active' WHERE id=?", - (mid,), - ) - if sync_monitor_fn: - sync_monitor_fn( - conn, mid, sym, direction, mode, ctp=pos, capital=capital, - ) - stats["promoted"] += 1 - continue - - if vt_oid and vt_oid in active_orders: - if age >= limit_sec and is_trading_session(): - if ctp_cancel_order(mode, vt_oid): - conn.execute( - "UPDATE trade_order_monitors SET status='closed' WHERE id=?", - (mid,), - ) - stats["cancelled"] += 1 - else: - logger.warning("pending auto-cancel failed monitor=%s order=%s", mid, vt_oid) - continue - - # 委托已不在活跃列表且无持仓:拒单/撤单/过期 - if age >= 8: - conn.execute( - "UPDATE trade_order_monitors SET status='closed' WHERE id=?", - (mid,), - ) - stats["closed"] += 1 - - if any(stats.values()): - conn.commit() - return stats - - -def cancel_pending_monitor( - conn, - mon: dict, - mode: str, -) -> tuple[bool, str]: - """手动撤销 pending 开仓委托。""" - mid = int(mon.get("id") or 0) - vt_oid = (mon.get("vt_order_id") or "").strip() - if vt_oid and ctp_status(mode).get("connected"): - try: - ctp_cancel_order(mode, vt_oid) - except Exception as exc: - logger.warning("cancel pending order monitor=%s: %s", mid, exc) - conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mid,)) - conn.commit() - return True, "开仓委托已撤销" +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""开仓委托:pending 状态跟踪、成交转正、超时撤单。""" +from __future__ import annotations + +import logging +import time +from datetime import datetime +from typing import Any, Callable, Optional +from zoneinfo import ZoneInfo + +from market_sessions import is_trading_session +from vnpy_bridge import ctp_cancel_order, ctp_list_active_orders, ctp_status + +logger = logging.getLogger(__name__) + +TZ = ZoneInfo("Asia/Shanghai") +DEFAULT_PENDING_ORDER_TIMEOUT_SEC = 300 + + +def parse_monitor_ts(raw: str) -> Optional[float]: + s = (raw or "").strip() + if not s: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"): + try: + return datetime.strptime(s[:19], fmt).replace(tzinfo=TZ).timestamp() + except ValueError: + continue + return None + + +def pending_age_sec(mon: dict) -> float: + ts = parse_monitor_ts(mon.get("open_time") or "") or parse_monitor_ts( + str(mon.get("created_at") or "") + ) + if ts is None: + return 0.0 + return max(0.0, time.time() - ts) + + +def pending_auto_cancel_remaining( + mon: dict, + *, + timeout_sec: int = DEFAULT_PENDING_ORDER_TIMEOUT_SEC, +) -> int: + limit = max(60, int(timeout_sec or DEFAULT_PENDING_ORDER_TIMEOUT_SEC)) + return max(0, int(limit - pending_age_sec(mon))) + + +def _match_symbol(ctp_sym: str, ths: str) -> bool: + a = (ctp_sym or "").lower() + b = (ths or "").lower() + if a == b: + return True + if a and b and a.split(".")[0] == b.split(".")[0]: + return True + try: + from ctp_symbol import ths_to_vnpy_symbol + vnpy_sym, _ = ths_to_vnpy_symbol(ths) + if a == vnpy_sym.lower(): + return True + except Exception: + pass + return False + + +def _find_ctp_position(positions: list[dict], sym: str, direction: str) -> Optional[dict]: + direction = (direction or "long").strip().lower() + for p in positions or []: + if int(p.get("lots") or 0) <= 0: + continue + if (p.get("direction") or "long") != direction: + continue + if _match_symbol(p.get("symbol") or "", sym): + return p + return None + + +def reconcile_pending_orders( + conn, + mode: str, + *, + match_symbol_fn: Callable[[str, str], bool] | None = None, + sync_monitor_fn: Callable[..., None] | None = None, + capital: float = 0.0, + list_positions_fn: Callable[..., list] | None = None, + timeout_sec: int = DEFAULT_PENDING_ORDER_TIMEOUT_SEC, +) -> dict[str, int]: + """同步 pending 委托:成交→active;超时/已撤→closed。""" + limit_sec = max(60, int(timeout_sec or DEFAULT_PENDING_ORDER_TIMEOUT_SEC)) + stats = {"promoted": 0, "cancelled": 0, "closed": 0} + if not ctp_status(mode).get("connected"): + return stats + + match = match_symbol_fn or _match_symbol + positions = ( + list_positions_fn(mode, refresh_if_empty=False, refresh_margin=False) + if list_positions_fn + else [] + ) + try: + active_orders = { + str(o.get("order_id") or ""): o + for o in ctp_list_active_orders(mode) + if o.get("order_id") + } + except Exception as exc: + logger.debug("list active orders: %s", exc) + active_orders = {} + + rows = conn.execute( + "SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id ASC" + ).fetchall() + + for r in rows: + mon = dict(r) + mid = int(mon["id"]) + sym = mon.get("symbol") or "" + direction = mon.get("direction") or "long" + vt_oid = (mon.get("vt_order_id") or "").strip() + age = pending_age_sec(mon) + + pos = _find_ctp_position(positions, sym, direction) + if pos: + conn.execute( + "UPDATE trade_order_monitors SET status='active' WHERE id=?", + (mid,), + ) + if sync_monitor_fn: + sync_monitor_fn( + conn, mid, sym, direction, mode, ctp=pos, capital=capital, + ) + stats["promoted"] += 1 + continue + + if vt_oid and vt_oid in active_orders: + if age >= limit_sec and is_trading_session(): + if ctp_cancel_order(mode, vt_oid): + conn.execute( + "UPDATE trade_order_monitors SET status='closed' WHERE id=?", + (mid,), + ) + stats["cancelled"] += 1 + else: + logger.warning("pending auto-cancel failed monitor=%s order=%s", mid, vt_oid) + continue + + # 委托已不在活跃列表且无持仓:拒单/撤单/过期 + if age >= 8: + conn.execute( + "UPDATE trade_order_monitors SET status='closed' WHERE id=?", + (mid,), + ) + stats["closed"] += 1 + + if any(stats.values()): + conn.commit() + return stats + + +def cancel_pending_monitor( + conn, + mon: dict, + mode: str, +) -> tuple[bool, str]: + """手动撤销 pending 开仓委托。""" + mid = int(mon.get("id") or 0) + vt_oid = (mon.get("vt_order_id") or "").strip() + if vt_oid and ctp_status(mode).get("connected"): + try: + ctp_cancel_order(mode, vt_oid) + except Exception as exc: + logger.warning("cancel pending order monitor=%s: %s", mid, exc) + conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mid,)) + conn.commit() + return True, "开仓委托已撤销" diff --git a/position_sizing.py b/position_sizing.py index 7a27694..c8ad7fc 100644 --- a/position_sizing.py +++ b/position_sizing.py @@ -1,166 +1,171 @@ -"""期货计仓:固定手数 / 固定金额。""" -from __future__ import annotations - -import math -from typing import Optional - -from contract_specs import get_contract_spec - -MODE_FIXED = "fixed" -MODE_AMOUNT = "amount" -MODE_RISK = "amount" # 兼容旧配置「以损定仓」 - -DEFAULT_MAX_ORDER_LOTS = 50 - - -def normalize_sizing_mode(raw: str) -> str: - m = (raw or MODE_FIXED).strip().lower() - if m == "risk": - m = MODE_AMOUNT - return m if m in (MODE_FIXED, MODE_AMOUNT) else MODE_FIXED - - -def price_precision_from_tick(tick_size: float) -> int: - if tick_size <= 0: - return 0 - s = f"{tick_size:.10f}".rstrip("0").rstrip(".") - if "." not in s: - return 0 - return len(s.split(".")[1]) - - -def _per_lot_risk(entry: float, stop_loss: float, direction: str, ths_code: str) -> tuple[float, Optional[str]]: - spec = get_contract_spec(ths_code) - mult = spec["mult"] - d = (direction or "long").strip().lower() - if d == "short": - per_lot = (stop_loss - entry) * mult - else: - per_lot = (entry - stop_loss) * mult - if per_lot <= 0: - return 0.0, "止损方向与入场价不匹配" - return per_lot, None - - -def calc_lots_by_amount( - entry: float, - stop_loss: float, - direction: str, - amount: float, - ths_code: str, - *, - capital: float = 0.0, - max_lots: Optional[int] = None, - max_margin_pct: float = 30.0, -) -> tuple[Optional[int], Optional[str]]: - """固定金额:按止损距离将金额换算为手数。""" - try: - entry_f = float(entry) - sl_f = float(stop_loss) - budget = float(amount) - cap = float(capital or 0) - except (TypeError, ValueError): - return None, "参数格式错误" - if entry_f <= 0 or budget <= 0: - return None, "入场价或固定金额无效" - per_lot_risk, err = _per_lot_risk(entry_f, sl_f, direction, ths_code) - if err: - return None, err - lots = int(math.floor(budget / per_lot_risk)) - if lots < 1: - return None, f"按固定金额 {budget:.0f} 元,当前止损距离下不足 1 手" - if cap > 0: - spec = get_contract_spec(ths_code) - margin_per_lot = entry_f * spec["mult"] * spec["margin_rate"] - margin_cap = max(1.0, min(100.0, float(max_margin_pct or 30.0))) - max_by_margin = ( - int(math.floor(cap * margin_cap / 100.0 / margin_per_lot)) - if margin_per_lot > 0 else lots - ) - if max_by_margin < 1: - return None, f"按保证金上限 {margin_cap:g}%,当前不足 1 手" - lots = min(lots, max_by_margin) - cap_lots = max_lots if max_lots is not None else DEFAULT_MAX_ORDER_LOTS - lots = min(lots, cap_lots) - return lots, None - - -def calc_lots_by_risk( - entry: float, - stop_loss: float, - direction: str, - capital: float, - risk_percent: float, - ths_code: str, - *, - max_lots: Optional[int] = None, - max_margin_pct: float = 30.0, -) -> tuple[Optional[int], Optional[str]]: - """策略等场景:按权益百分比风险预算换算手数。""" - try: - cap = float(capital) - rp = float(risk_percent) - except (TypeError, ValueError): - return None, "参数格式错误" - if cap <= 0 or rp <= 0: - return None, "资金或风险比例无效" - budget = cap * rp / 100.0 - return calc_lots_by_amount( - entry, stop_loss, direction, budget, ths_code, - capital=cap, max_lots=max_lots, max_margin_pct=max_margin_pct, - ) - - -def calc_order_tick_metrics(ths_code: str, lots: float, price: Optional[float] = None) -> dict: - """下单区展示:最小变动价位、每跳盈亏、保证金等。""" - spec = get_contract_spec(ths_code) - mult = int(spec["mult"]) - tick = float(spec.get("tick_size") or 1.0) - margin_rate = float(spec["margin_rate"]) - lots_i = max(1, int(lots or 1)) - tick_value_per_lot = round(tick * mult, 4) - tick_value_total = round(tick_value_per_lot * lots_i, 2) - prec = price_precision_from_tick(tick) - mark = float(price) if price else 0.0 - margin_per_lot = round(mark * mult * margin_rate, 2) if mark > 0 else None - margin_total = round(margin_per_lot * lots_i, 2) if margin_per_lot else None - return { - "mult": mult, - "tick_size": tick, - "price_precision": prec, - "tick_value_per_lot": tick_value_per_lot, - "tick_value_total": tick_value_total, - "lots": lots_i, - "margin_per_lot": margin_per_lot, - "margin_total": margin_total, - "margin_rate": margin_rate, - } - - -def calc_margin_usage_pct( - positions: list[dict], - capital: float, - *, - extra_symbol: str = "", - extra_lots: int = 0, - extra_price: float = 0, -) -> float: - """当前持仓 + 拟开仓占权益的保证金比例(%)。""" - cap = float(capital or 0) - if cap <= 0: - return 999.0 - total = 0.0 - for p in positions: - lots = int(p.get("lots") or 0) - if lots <= 0: - continue - sym = (p.get("symbol") or "").strip() - entry = float(p.get("avg_price") or p.get("entry_price") or 0) - if entry <= 0: - continue - spec = get_contract_spec(sym) - total += entry * spec["mult"] * lots * spec["margin_rate"] - if extra_symbol and extra_lots > 0 and extra_price > 0: - spec = get_contract_spec(extra_symbol) - total += extra_price * spec["mult"] * extra_lots * spec["margin_rate"] - return round(total / cap * 100.0, 2) +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""期货计仓:固定手数 / 固定金额。""" +from __future__ import annotations + +import math +from typing import Optional + +from contract_specs import get_contract_spec + +MODE_FIXED = "fixed" +MODE_AMOUNT = "amount" +MODE_RISK = "amount" # 兼容旧配置「以损定仓」 + +DEFAULT_MAX_ORDER_LOTS = 50 + + +def normalize_sizing_mode(raw: str) -> str: + m = (raw or MODE_FIXED).strip().lower() + if m == "risk": + m = MODE_AMOUNT + return m if m in (MODE_FIXED, MODE_AMOUNT) else MODE_FIXED + + +def price_precision_from_tick(tick_size: float) -> int: + if tick_size <= 0: + return 0 + s = f"{tick_size:.10f}".rstrip("0").rstrip(".") + if "." not in s: + return 0 + return len(s.split(".")[1]) + + +def _per_lot_risk(entry: float, stop_loss: float, direction: str, ths_code: str) -> tuple[float, Optional[str]]: + spec = get_contract_spec(ths_code) + mult = spec["mult"] + d = (direction or "long").strip().lower() + if d == "short": + per_lot = (stop_loss - entry) * mult + else: + per_lot = (entry - stop_loss) * mult + if per_lot <= 0: + return 0.0, "止损方向与入场价不匹配" + return per_lot, None + + +def calc_lots_by_amount( + entry: float, + stop_loss: float, + direction: str, + amount: float, + ths_code: str, + *, + capital: float = 0.0, + max_lots: Optional[int] = None, + max_margin_pct: float = 30.0, +) -> tuple[Optional[int], Optional[str]]: + """固定金额:按止损距离将金额换算为手数。""" + try: + entry_f = float(entry) + sl_f = float(stop_loss) + budget = float(amount) + cap = float(capital or 0) + except (TypeError, ValueError): + return None, "参数格式错误" + if entry_f <= 0 or budget <= 0: + return None, "入场价或固定金额无效" + per_lot_risk, err = _per_lot_risk(entry_f, sl_f, direction, ths_code) + if err: + return None, err + lots = int(math.floor(budget / per_lot_risk)) + if lots < 1: + return None, f"按固定金额 {budget:.0f} 元,当前止损距离下不足 1 手" + if cap > 0: + spec = get_contract_spec(ths_code) + margin_per_lot = entry_f * spec["mult"] * spec["margin_rate"] + margin_cap = max(1.0, min(100.0, float(max_margin_pct or 30.0))) + max_by_margin = ( + int(math.floor(cap * margin_cap / 100.0 / margin_per_lot)) + if margin_per_lot > 0 else lots + ) + if max_by_margin < 1: + return None, f"按保证金上限 {margin_cap:g}%,当前不足 1 手" + lots = min(lots, max_by_margin) + cap_lots = max_lots if max_lots is not None else DEFAULT_MAX_ORDER_LOTS + lots = min(lots, cap_lots) + return lots, None + + +def calc_lots_by_risk( + entry: float, + stop_loss: float, + direction: str, + capital: float, + risk_percent: float, + ths_code: str, + *, + max_lots: Optional[int] = None, + max_margin_pct: float = 30.0, +) -> tuple[Optional[int], Optional[str]]: + """策略等场景:按权益百分比风险预算换算手数。""" + try: + cap = float(capital) + rp = float(risk_percent) + except (TypeError, ValueError): + return None, "参数格式错误" + if cap <= 0 or rp <= 0: + return None, "资金或风险比例无效" + budget = cap * rp / 100.0 + return calc_lots_by_amount( + entry, stop_loss, direction, budget, ths_code, + capital=cap, max_lots=max_lots, max_margin_pct=max_margin_pct, + ) + + +def calc_order_tick_metrics(ths_code: str, lots: float, price: Optional[float] = None) -> dict: + """下单区展示:最小变动价位、每跳盈亏、保证金等。""" + spec = get_contract_spec(ths_code) + mult = int(spec["mult"]) + tick = float(spec.get("tick_size") or 1.0) + margin_rate = float(spec["margin_rate"]) + lots_i = max(1, int(lots or 1)) + tick_value_per_lot = round(tick * mult, 4) + tick_value_total = round(tick_value_per_lot * lots_i, 2) + prec = price_precision_from_tick(tick) + mark = float(price) if price else 0.0 + margin_per_lot = round(mark * mult * margin_rate, 2) if mark > 0 else None + margin_total = round(margin_per_lot * lots_i, 2) if margin_per_lot else None + return { + "mult": mult, + "tick_size": tick, + "price_precision": prec, + "tick_value_per_lot": tick_value_per_lot, + "tick_value_total": tick_value_total, + "lots": lots_i, + "margin_per_lot": margin_per_lot, + "margin_total": margin_total, + "margin_rate": margin_rate, + } + + +def calc_margin_usage_pct( + positions: list[dict], + capital: float, + *, + extra_symbol: str = "", + extra_lots: int = 0, + extra_price: float = 0, +) -> float: + """当前持仓 + 拟开仓占权益的保证金比例(%)。""" + cap = float(capital or 0) + if cap <= 0: + return 999.0 + total = 0.0 + for p in positions: + lots = int(p.get("lots") or 0) + if lots <= 0: + continue + sym = (p.get("symbol") or "").strip() + entry = float(p.get("avg_price") or p.get("entry_price") or 0) + if entry <= 0: + continue + spec = get_contract_spec(sym) + total += entry * spec["mult"] * lots * spec["margin_rate"] + if extra_symbol and extra_lots > 0 and extra_price > 0: + spec = get_contract_spec(extra_symbol) + total += extra_price * spec["mult"] * extra_lots * spec["margin_rate"] + return round(total / cap * 100.0, 2) diff --git a/position_stream.py b/position_stream.py index e8561a1..0653e96 100644 --- a/position_stream.py +++ b/position_stream.py @@ -1,101 +1,106 @@ -"""持仓监控:后台拉取 CTP 并 SSE 推送给前端(避免每次刷新阻塞读柜台)。""" -from __future__ import annotations - -import logging -import queue -import threading -import time -from typing import Callable, Optional - -from kline_stream import sse_format -from market_sessions import is_trading_session - -logger = logging.getLogger(__name__) - -PUSH_INTERVAL_SEC = 1 -IDLE_INTERVAL_SEC = 5 - - -class PositionStreamHub: - def __init__(self) -> None: - self._lock = threading.Lock() - self._subs: list[queue.Queue] = [] - self._snapshot: Optional[dict] = None - self._snapshot_ts: float = 0.0 - - def subscribe(self) -> queue.Queue: - q: queue.Queue = queue.Queue(maxsize=16) - with self._lock: - self._subs.append(q) - return q - - def unsubscribe(self, q: queue.Queue) -> None: - with self._lock: - try: - self._subs.remove(q) - except ValueError: - pass - - def get_snapshot(self) -> Optional[dict]: - with self._lock: - return dict(self._snapshot) if self._snapshot else None - - def set_snapshot(self, data: dict) -> None: - with self._lock: - self._snapshot = dict(data) - self._snapshot_ts = time.time() - - def broadcast(self, event: str, data: dict) -> None: - self.set_snapshot(data) - msg = {"event": event, "data": data} - with self._lock: - subs = list(self._subs) - for q in subs: - try: - q.put_nowait(msg) - except queue.Full: - try: - q.get_nowait() - except queue.Empty: - pass - try: - q.put_nowait(msg) - except queue.Full: - pass - - -position_hub = PositionStreamHub() - - -def start_position_worker( - *, - refresh_fn: Callable[[], dict], - interval: int = PUSH_INTERVAL_SEC, - idle_interval: int = IDLE_INTERVAL_SEC, -) -> None: - """后台定时刷新持仓快照并 SSE 广播。""" - - def _loop() -> None: - while True: - sleep_sec = idle_interval - try: - payload = refresh_fn() - if payload: - position_hub.broadcast("positions", payload) - ctp_st = (payload or {}).get("ctp_status") or {} - connected = bool(ctp_st.get("connected")) - in_session = bool((payload or {}).get("trading_session")) - rows = (payload or {}).get("rows") or [] - has_sl_tp = any( - r.get("stop_loss") is not None or r.get("take_profit") is not None - for r in rows - ) - if connected and in_session: - sleep_sec = max(1, interval) - elif connected: - sleep_sec = max(2, min(idle_interval, 3)) - except Exception as exc: - logger.warning("position worker failed: %s", exc) - time.sleep(sleep_sec) - - threading.Thread(target=_loop, daemon=True, name="position-stream").start() +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""持仓监控:后台拉取 CTP 并 SSE 推送给前端(避免每次刷新阻塞读柜台)。""" +from __future__ import annotations + +import logging +import queue +import threading +import time +from typing import Callable, Optional + +from kline_stream import sse_format +from market_sessions import is_trading_session + +logger = logging.getLogger(__name__) + +PUSH_INTERVAL_SEC = 1 +IDLE_INTERVAL_SEC = 5 + + +class PositionStreamHub: + def __init__(self) -> None: + self._lock = threading.Lock() + self._subs: list[queue.Queue] = [] + self._snapshot: Optional[dict] = None + self._snapshot_ts: float = 0.0 + + def subscribe(self) -> queue.Queue: + q: queue.Queue = queue.Queue(maxsize=16) + with self._lock: + self._subs.append(q) + return q + + def unsubscribe(self, q: queue.Queue) -> None: + with self._lock: + try: + self._subs.remove(q) + except ValueError: + pass + + def get_snapshot(self) -> Optional[dict]: + with self._lock: + return dict(self._snapshot) if self._snapshot else None + + def set_snapshot(self, data: dict) -> None: + with self._lock: + self._snapshot = dict(data) + self._snapshot_ts = time.time() + + def broadcast(self, event: str, data: dict) -> None: + self.set_snapshot(data) + msg = {"event": event, "data": data} + with self._lock: + subs = list(self._subs) + for q in subs: + try: + q.put_nowait(msg) + except queue.Full: + try: + q.get_nowait() + except queue.Empty: + pass + try: + q.put_nowait(msg) + except queue.Full: + pass + + +position_hub = PositionStreamHub() + + +def start_position_worker( + *, + refresh_fn: Callable[[], dict], + interval: int = PUSH_INTERVAL_SEC, + idle_interval: int = IDLE_INTERVAL_SEC, +) -> None: + """后台定时刷新持仓快照并 SSE 广播。""" + + def _loop() -> None: + while True: + sleep_sec = idle_interval + try: + payload = refresh_fn() + if payload: + position_hub.broadcast("positions", payload) + ctp_st = (payload or {}).get("ctp_status") or {} + connected = bool(ctp_st.get("connected")) + in_session = bool((payload or {}).get("trading_session")) + rows = (payload or {}).get("rows") or [] + has_sl_tp = any( + r.get("stop_loss") is not None or r.get("take_profit") is not None + for r in rows + ) + if connected and in_session: + sleep_sec = max(1, interval) + elif connected: + sleep_sec = max(2, min(idle_interval, 3)) + except Exception as exc: + logger.warning("position worker failed: %s", exc) + time.sleep(sleep_sec) + + threading.Thread(target=_loop, daemon=True, name="position-stream").start() diff --git a/product_recommend.py b/product_recommend.py index e5f726c..c7be387 100644 --- a/product_recommend.py +++ b/product_recommend.py @@ -1,164 +1,169 @@ -"""按账户资金推荐可交易品种(期货核心筛选)。""" -from __future__ import annotations - -import logging -import math -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 recommend_trend import analyze_product_daily, sort_recommend_by_trend -from symbols import PRODUCTS, product_category - -logger = logging.getLogger(__name__) - - -def _attach_turnover(row: dict) -> None: - """成交额 = 昨日成交量(手) × 昨收 × 合约乘数。""" - try: - vol = float(row.get("volume") or 0) - price = float(row.get("prev_close") or row.get("price") or 0) - mult = float(row.get("mult") or 0) - except (TypeError, ValueError): - return - if vol > 0 and price > 0 and mult > 0: - row["turnover"] = round(vol * price * mult, 2) - - -def _letters_from_ths(ths_code: str) -> str: - import re - m = re.match(r"^([A-Za-z]+)", (ths_code or "").strip()) - return m.group(1) if m else "" - - -def assess_product_for_capital( - product: dict, - capital: float, - price: Optional[float], - *, - max_margin_pct: float = 30.0, - default_stop_ticks: int = 20, - reward_risk_ratio: float = 2.0, - trading_mode: str = "simulation", -) -> dict: - """评估单品种在当前资金下是否可交易。""" - ths = product.get("ths") or "" - name = product.get("name") or ths - exchange = product.get("exchange") or "" - category = product.get("category") or product_category(ths) - spec = get_contract_spec(ths + "8888") - mult = spec["mult"] - margin_rate = spec["margin_rate"] - tick = float(spec.get("tick_size") or 1.0) - p = float(price) if price and price > 0 else 0.0 - cap = float(capital or 0) - margin_pct = max(1.0, min(100.0, float(max_margin_pct or 30.0))) - - if p <= 0: - return { - "ths": ths, - "name": name, - "exchange": exchange, - "category": category, - "status": "no_price", - "status_label": "暂无行情", - "min_capital_one_lot": None, - "margin_one_lot": None, - "max_lots": 0, - "risk_one_lot_1pct": None, - } - - margin_one = p * mult * margin_rate - min_capital = margin_one / (margin_pct / 100.0) if margin_pct > 0 else margin_one - margin_budget = cap * margin_pct / 100.0 if cap > 0 else 0.0 - max_lots = int(math.floor(margin_budget / margin_one)) if margin_one > 0 and margin_budget > 0 else 0 - 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" - try: - fee_info = calc_fee_breakdown( - fee_ths, p, p, 1.0, open_time="", close_time="", trading_mode=trading_mode, - ) - except Exception as exc: - logger.debug("recommend fee calc failed %s: %s", ths, exc) - fee_info = {"open_fee": 0.0, "total_fee": 0.0} - - can_margin = max_lots >= 1 - can_risk = cap > 0 and risk_one_lot <= cap * 0.01 - - if can_margin and can_risk: - status, label = "ok", f"最大 {max_lots} 手" - elif can_margin: - status, label = "margin_ok", f"最大 {max_lots} 手·止损偏宽" - else: - status, label = "blocked", "资金不足" - - return { - "ths": ths, - "name": name, - "exchange": exchange, - "category": category, - "price": round(p, 4), - "mult": mult, - "tick_size": tick, - "margin_one_lot": round(margin_one, 2), - "min_capital_one_lot": round(min_capital, 2), - "max_lots": max_lots, - "margin_budget": round(margin_budget, 2), - "max_margin_pct": margin_pct, - "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, - } - - -def list_product_recommendations( - capital: float, - quote_fn: Callable[[str], Optional[dict]], - *, - max_margin_pct: float = 30.0, - trading_mode: str = "simulation", -) -> list[dict]: - """扫描全部品种并排序:推荐 > 可开 > 不足。quote_fn(品种代码) -> {price, ths_code, ...}""" - - def _one(product: dict) -> dict: - ths = product["ths"] - try: - quote = quote_fn(ths) or {} - price = quote.get("price") - row = assess_product_for_capital( - product, capital, price, - max_margin_pct=max_margin_pct, - trading_mode=trading_mode, - ) - main_code = (quote.get("ths_code") or "").strip() - row["main_code"] = main_code - if main_code: - row.update(analyze_product_daily(main_code)) - _attach_turnover(row) - return row - except Exception as exc: - logger.warning("recommend product failed %s: %s", ths, exc) - return { - "ths": ths, - "name": product.get("name") or ths, - "exchange": product.get("exchange") or "", - "category": product.get("category") or product_category(ths), - "status": "no_price", - "status_label": "计算失败", - "main_code": "", - "max_lots": 0, - } - - with ThreadPoolExecutor(max_workers=10) as pool: - rows = list(pool.map(_one, PRODUCTS)) - return sort_recommend_by_trend(rows) +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""按账户资金筛选可开仓品种(保证金与仓位纪律)。""" +from __future__ import annotations + +import logging +import math +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 recommend_trend import analyze_product_daily, sort_recommend_by_trend +from symbols import PRODUCTS, product_category + +logger = logging.getLogger(__name__) + + +def _attach_turnover(row: dict) -> None: + """成交额 = 昨日成交量(手) × 昨收 × 合约乘数。""" + try: + vol = float(row.get("volume") or 0) + price = float(row.get("prev_close") or row.get("price") or 0) + mult = float(row.get("mult") or 0) + except (TypeError, ValueError): + return + if vol > 0 and price > 0 and mult > 0: + row["turnover"] = round(vol * price * mult, 2) + + +def _letters_from_ths(ths_code: str) -> str: + import re + m = re.match(r"^([A-Za-z]+)", (ths_code or "").strip()) + return m.group(1) if m else "" + + +def assess_product_for_capital( + product: dict, + capital: float, + price: Optional[float], + *, + max_margin_pct: float = 30.0, + default_stop_ticks: int = 20, + reward_risk_ratio: float = 2.0, + trading_mode: str = "simulation", +) -> dict: + """评估单品种在当前资金下是否可交易。""" + ths = product.get("ths") or "" + name = product.get("name") or ths + exchange = product.get("exchange") or "" + category = product.get("category") or product_category(ths) + spec = get_contract_spec(ths + "8888") + mult = spec["mult"] + margin_rate = spec["margin_rate"] + tick = float(spec.get("tick_size") or 1.0) + p = float(price) if price and price > 0 else 0.0 + cap = float(capital or 0) + margin_pct = max(1.0, min(100.0, float(max_margin_pct or 30.0))) + + if p <= 0: + return { + "ths": ths, + "name": name, + "exchange": exchange, + "category": category, + "status": "no_price", + "status_label": "暂无行情", + "min_capital_one_lot": None, + "margin_one_lot": None, + "max_lots": 0, + "risk_one_lot_1pct": None, + } + + margin_one = p * mult * margin_rate + min_capital = margin_one / (margin_pct / 100.0) if margin_pct > 0 else margin_one + margin_budget = cap * margin_pct / 100.0 if cap > 0 else 0.0 + max_lots = int(math.floor(margin_budget / margin_one)) if margin_one > 0 and margin_budget > 0 else 0 + 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" + try: + fee_info = calc_fee_breakdown( + fee_ths, p, p, 1.0, open_time="", close_time="", trading_mode=trading_mode, + ) + except Exception as exc: + logger.debug("recommend fee calc failed %s: %s", ths, exc) + fee_info = {"open_fee": 0.0, "total_fee": 0.0} + + can_margin = max_lots >= 1 + can_risk = cap > 0 and risk_one_lot <= cap * 0.01 + + if can_margin and can_risk: + status, label = "ok", f"最大 {max_lots} 手" + elif can_margin: + status, label = "margin_ok", f"最大 {max_lots} 手·止损偏宽" + else: + status, label = "blocked", "资金不足" + + return { + "ths": ths, + "name": name, + "exchange": exchange, + "category": category, + "price": round(p, 4), + "mult": mult, + "tick_size": tick, + "margin_one_lot": round(margin_one, 2), + "min_capital_one_lot": round(min_capital, 2), + "max_lots": max_lots, + "margin_budget": round(margin_budget, 2), + "max_margin_pct": margin_pct, + "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, + } + + +def list_product_recommendations( + capital: float, + quote_fn: Callable[[str], Optional[dict]], + *, + max_margin_pct: float = 30.0, + trading_mode: str = "simulation", +) -> list[dict]: + """扫描全部品种并排序:可开且纪律友好 > 可开 > 不足。quote_fn(品种代码) -> {price, ths_code, ...}""" + + def _one(product: dict) -> dict: + ths = product["ths"] + try: + quote = quote_fn(ths) or {} + price = quote.get("price") + row = assess_product_for_capital( + product, capital, price, + max_margin_pct=max_margin_pct, + trading_mode=trading_mode, + ) + main_code = (quote.get("ths_code") or "").strip() + row["main_code"] = main_code + if main_code: + row.update(analyze_product_daily(main_code)) + _attach_turnover(row) + return row + except Exception as exc: + logger.warning("recommend product failed %s: %s", ths, exc) + return { + "ths": ths, + "name": product.get("name") or ths, + "exchange": product.get("exchange") or "", + "category": product.get("category") or product_category(ths), + "status": "no_price", + "status_label": "计算失败", + "main_code": "", + "max_lots": 0, + } + + with ThreadPoolExecutor(max_workers=10) as pool: + rows = list(pool.map(_one, PRODUCTS)) + return sort_recommend_by_trend(rows) diff --git a/recommend_store.py b/recommend_store.py index b9f745c..3438e8b 100644 --- a/recommend_store.py +++ b/recommend_store.py @@ -1,259 +1,264 @@ -"""品种推荐:计算、按资金过滤、SQLite 缓存。""" -from __future__ import annotations - -import json -import logging -import math -from datetime import datetime -from typing import Callable, Optional - -from fee_specs import ensure_fee_rates_schema -from product_recommend import _attach_turnover, list_product_recommendations -from recommend_trend import sort_recommend_by_trend -from symbols import product_category - -logger = logging.getLogger(__name__) - -RECOMMEND_CACHE_SQL = """ -CREATE TABLE IF NOT EXISTS product_recommend_cache ( - id INTEGER PRIMARY KEY CHECK (id = 1), - capital REAL NOT NULL DEFAULT 0, - rows_json TEXT NOT NULL DEFAULT '[]', - updated_at TEXT -) -""" - - -def ensure_recommend_tables(conn) -> None: - conn.execute(RECOMMEND_CACHE_SQL) - - -def filter_affordable_recommendations(rows: list[dict]) -> list[dict]: - """仅保留当前资金可开 1 手的品种(不含资金不足、无行情)。""" - return [r for r in rows if r.get("status") in ("ok", "margin_ok")] - - -def rows_missing_max_lots(rows: list[dict]) -> bool: - """缓存是否为旧版(缺少最大手数字段)。""" - if not rows: - return False - return any("max_lots" not in r for r in rows) - - -def rows_missing_trend(rows: list[dict]) -> bool: - """缓存是否为旧版(缺少走势字段)。""" - if not rows: - return False - return any("trend" not in r for r in rows) - - -def rows_missing_daily_stats(rows: list[dict]) -> bool: - """缓存是否为旧版(缺少跳空/量价字段)。""" - if not rows: - return False - return any("gap" not in r for r in rows) - - -def rows_missing_category(rows: list[dict]) -> bool: - if not rows: - return False - return any("category" not in r for r in rows) - - -def rows_missing_turnover(rows: list[dict]) -> bool: - if not rows: - return False - return any("turnover" not in r for r in rows) - - -def recommend_cache_needs_refresh( - cached: dict, - *, - capital: float = 0.0, -) -> bool: - """是否需要重新拉行情计算推荐列表。""" - if recommend_cache_stale(cached.get("updated_at")): - return True - rows = cached.get("rows") or [] - if rows_missing_max_lots(rows): - return True - if rows_missing_trend(rows): - return True - if rows_missing_daily_stats(rows): - return True - if rows_missing_category(rows): - return True - if rows_missing_turnover(rows): - return True - if float(capital or 0) > 0 and not rows: - return True - return False - - -def enrich_recommend_rows( - rows: list[dict], - capital: float, - *, - max_margin_pct: float = 30.0, - trading_mode: str = "simulation", -) -> list[dict]: - """用当前权益与保证金比例补算最大可开手数(兼容旧缓存)。""" - cap = float(capital or 0) - pct = max(1.0, min(100.0, float(max_margin_pct or 30.0))) - budget = cap * pct / 100.0 if cap > 0 else 0.0 - ctp_connected = False - try: - from vnpy_bridge import ctp_estimate_margin_one_lot, ctp_status - ctp_connected = bool(ctp_status(trading_mode).get("connected")) - except Exception: - pass - enriched: list[dict] = [] - for raw in rows: - row = dict(raw) - margin_one = 0.0 - try: - margin_one = float(row.get("margin_one_lot") or 0) - except (TypeError, ValueError): - margin_one = 0.0 - price = float(row.get("price") or 0) - main_code = (row.get("main_code") or "").strip() - if ctp_connected and main_code and price > 0: - ctp_margin = ctp_estimate_margin_one_lot(trading_mode, main_code, price) - if ctp_margin and ctp_margin > 0: - margin_one = ctp_margin - row["margin_one_lot"] = ctp_margin - row["margin_source"] = "ctp" - if margin_one > 0 and budget > 0: - lots = int(math.floor(budget / margin_one)) - else: - try: - lots = int(row.get("max_lots") or row.get("recommended_lots") or 0) - except (TypeError, ValueError): - lots = 0 - row["max_lots"] = lots - row.pop("recommended_lots", None) - row["margin_budget"] = round(budget, 2) - row["max_margin_pct"] = pct - status = row.get("status") or "" - if lots >= 1 and status in ("ok", "margin_ok"): - src = "柜台" if row.get("margin_source") == "ctp" else "估算" - row["status_label"] = ( - f"最大 {lots} 手" if status == "ok" else f"最大 {lots} 手·止损偏宽" - ) - if row.get("margin_source") == "ctp": - row["status_label"] += f"({src}保证金)" - elif lots < 1 and status in ("ok", "margin_ok"): - row["status"] = "blocked" - row["status_label"] = "资金不足" - if not row.get("category"): - row["category"] = product_category(row.get("ths") or "") - _attach_turnover(row) - enriched.append(row) - return enriched - - -def filter_recommend_by_sizing( - rows: list[dict], - *, - sizing_mode: str, - fixed_lots: int = 1, -) -> list[dict]: - """固定手数模式下:最大手数低于设定值的品种不展示。""" - if (sizing_mode or "").strip().lower() != "fixed": - return rows - fl = max(1, int(fixed_lots or 1)) - return [r for r in rows if int(r.get("max_lots") or 0) >= fl] - - -def refresh_recommend_cache( - conn, - capital: float, - quote_fn: Callable[[str], Optional[dict]], - *, - trading_mode: str = "simulation", - max_margin_pct: float = 30.0, -) -> list[dict]: - """后台拉行情、筛选并写入数据库。""" - ensure_recommend_tables(conn) - ensure_fee_rates_schema(conn) - all_rows = list_product_recommendations( - capital, quote_fn, max_margin_pct=max_margin_pct, trading_mode=trading_mode, - ) - rows = filter_affordable_recommendations(all_rows) - if not rows and float(capital or 0) > 0: - logger.warning( - "recommend refresh: 0 affordable rows capital=%.2f total=%d no_price=%d blocked=%d", - float(capital or 0), - len(all_rows), - sum(1 for r in all_rows if r.get("status") == "no_price"), - sum(1 for r in all_rows if r.get("status") == "blocked"), - ) - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - conn.execute( - """INSERT INTO product_recommend_cache (id, capital, rows_json, updated_at) - VALUES (1, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - capital=excluded.capital, - rows_json=excluded.rows_json, - updated_at=excluded.updated_at""", - (float(capital or 0), json.dumps(rows, ensure_ascii=False), now), - ) - conn.commit() - 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, "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": updated_at, - "stale": recommend_cache_stale(updated_at), - } - - -def recommend_payload( - conn, - *, - live_capital: float, - max_margin_pct: float = 30.0, - trading_mode: str = "simulation", - sizing_mode: str = "fixed", - fixed_lots: int = 1, -) -> dict: - """读取缓存并附带当前权益(展示用,可能与缓存计算时不同)。""" - payload = load_recommend_cache(conn) - cap = float(live_capital or 0) - pct = max(1.0, min(100.0, float(max_margin_pct or 30.0))) - payload["capital"] = cap - payload["max_margin_pct"] = pct - rows = payload.get("rows") or [] - rows = enrich_recommend_rows( - rows, cap, max_margin_pct=pct, trading_mode=trading_mode, - ) - rows = filter_recommend_by_sizing(rows, sizing_mode=sizing_mode, fixed_lots=fixed_lots) - rows = sort_recommend_by_trend(rows) - payload["rows"] = rows - payload["needs_refresh"] = recommend_cache_needs_refresh(payload, capital=cap) - return payload +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""可开仓品种:计算、按资金过滤、SQLite 缓存。""" +from __future__ import annotations + +import json +import logging +import math +from datetime import datetime +from typing import Callable, Optional + +from fee_specs import ensure_fee_rates_schema +from product_recommend import _attach_turnover, list_product_recommendations +from recommend_trend import sort_recommend_by_trend +from symbols import product_category + +logger = logging.getLogger(__name__) + +RECOMMEND_CACHE_SQL = """ +CREATE TABLE IF NOT EXISTS product_recommend_cache ( + id INTEGER PRIMARY KEY CHECK (id = 1), + capital REAL NOT NULL DEFAULT 0, + rows_json TEXT NOT NULL DEFAULT '[]', + updated_at TEXT +) +""" + + +def ensure_recommend_tables(conn) -> None: + conn.execute(RECOMMEND_CACHE_SQL) + + +def filter_affordable_recommendations(rows: list[dict]) -> list[dict]: + """仅保留当前资金可开 1 手的品种(不含资金不足、无行情)。""" + return [r for r in rows if r.get("status") in ("ok", "margin_ok")] + + +def rows_missing_max_lots(rows: list[dict]) -> bool: + """缓存是否为旧版(缺少最大手数字段)。""" + if not rows: + return False + return any("max_lots" not in r for r in rows) + + +def rows_missing_trend(rows: list[dict]) -> bool: + """缓存是否为旧版(缺少走势字段)。""" + if not rows: + return False + return any("trend" not in r for r in rows) + + +def rows_missing_daily_stats(rows: list[dict]) -> bool: + """缓存是否为旧版(缺少跳空/量价字段)。""" + if not rows: + return False + return any("gap" not in r for r in rows) + + +def rows_missing_category(rows: list[dict]) -> bool: + if not rows: + return False + return any("category" not in r for r in rows) + + +def rows_missing_turnover(rows: list[dict]) -> bool: + if not rows: + return False + return any("turnover" not in r for r in rows) + + +def recommend_cache_needs_refresh( + cached: dict, + *, + capital: float = 0.0, +) -> bool: + """是否需要重新拉行情计算可开仓列表。""" + if recommend_cache_stale(cached.get("updated_at")): + return True + rows = cached.get("rows") or [] + if rows_missing_max_lots(rows): + return True + if rows_missing_trend(rows): + return True + if rows_missing_daily_stats(rows): + return True + if rows_missing_category(rows): + return True + if rows_missing_turnover(rows): + return True + if float(capital or 0) > 0 and not rows: + return True + return False + + +def enrich_recommend_rows( + rows: list[dict], + capital: float, + *, + max_margin_pct: float = 30.0, + trading_mode: str = "simulation", +) -> list[dict]: + """用当前权益与保证金比例补算最大可开手数(兼容旧缓存)。""" + cap = float(capital or 0) + pct = max(1.0, min(100.0, float(max_margin_pct or 30.0))) + budget = cap * pct / 100.0 if cap > 0 else 0.0 + ctp_connected = False + try: + from vnpy_bridge import ctp_estimate_margin_one_lot, ctp_status + ctp_connected = bool(ctp_status(trading_mode).get("connected")) + except Exception: + pass + enriched: list[dict] = [] + for raw in rows: + row = dict(raw) + margin_one = 0.0 + try: + margin_one = float(row.get("margin_one_lot") or 0) + except (TypeError, ValueError): + margin_one = 0.0 + price = float(row.get("price") or 0) + main_code = (row.get("main_code") or "").strip() + if ctp_connected and main_code and price > 0: + ctp_margin = ctp_estimate_margin_one_lot(trading_mode, main_code, price) + if ctp_margin and ctp_margin > 0: + margin_one = ctp_margin + row["margin_one_lot"] = ctp_margin + row["margin_source"] = "ctp" + if margin_one > 0 and budget > 0: + lots = int(math.floor(budget / margin_one)) + else: + try: + lots = int(row.get("max_lots") or row.get("recommended_lots") or 0) + except (TypeError, ValueError): + lots = 0 + row["max_lots"] = lots + row.pop("recommended_lots", None) + row["margin_budget"] = round(budget, 2) + row["max_margin_pct"] = pct + status = row.get("status") or "" + if lots >= 1 and status in ("ok", "margin_ok"): + src = "柜台" if row.get("margin_source") == "ctp" else "估算" + row["status_label"] = ( + f"最大 {lots} 手" if status == "ok" else f"最大 {lots} 手·止损偏宽" + ) + if row.get("margin_source") == "ctp": + row["status_label"] += f"({src}保证金)" + elif lots < 1 and status in ("ok", "margin_ok"): + row["status"] = "blocked" + row["status_label"] = "资金不足" + if not row.get("category"): + row["category"] = product_category(row.get("ths") or "") + _attach_turnover(row) + enriched.append(row) + return enriched + + +def filter_recommend_by_sizing( + rows: list[dict], + *, + sizing_mode: str, + fixed_lots: int = 1, +) -> list[dict]: + """固定手数模式下:最大手数低于设定值的品种不展示。""" + if (sizing_mode or "").strip().lower() != "fixed": + return rows + fl = max(1, int(fixed_lots or 1)) + return [r for r in rows if int(r.get("max_lots") or 0) >= fl] + + +def refresh_recommend_cache( + conn, + capital: float, + quote_fn: Callable[[str], Optional[dict]], + *, + trading_mode: str = "simulation", + max_margin_pct: float = 30.0, +) -> list[dict]: + """后台拉行情、筛选并写入数据库。""" + ensure_recommend_tables(conn) + ensure_fee_rates_schema(conn) + all_rows = list_product_recommendations( + capital, quote_fn, max_margin_pct=max_margin_pct, trading_mode=trading_mode, + ) + rows = filter_affordable_recommendations(all_rows) + if not rows and float(capital or 0) > 0: + logger.warning( + "recommend refresh: 0 affordable rows capital=%.2f total=%d no_price=%d blocked=%d", + float(capital or 0), + len(all_rows), + sum(1 for r in all_rows if r.get("status") == "no_price"), + sum(1 for r in all_rows if r.get("status") == "blocked"), + ) + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + conn.execute( + """INSERT INTO product_recommend_cache (id, capital, rows_json, updated_at) + VALUES (1, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + capital=excluded.capital, + rows_json=excluded.rows_json, + updated_at=excluded.updated_at""", + (float(capital or 0), json.dumps(rows, ensure_ascii=False), now), + ) + conn.commit() + 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, "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": updated_at, + "stale": recommend_cache_stale(updated_at), + } + + +def recommend_payload( + conn, + *, + live_capital: float, + max_margin_pct: float = 30.0, + trading_mode: str = "simulation", + sizing_mode: str = "fixed", + fixed_lots: int = 1, +) -> dict: + """读取缓存并附带当前权益(展示用,可能与缓存计算时不同)。""" + payload = load_recommend_cache(conn) + cap = float(live_capital or 0) + pct = max(1.0, min(100.0, float(max_margin_pct or 30.0))) + payload["capital"] = cap + payload["max_margin_pct"] = pct + rows = payload.get("rows") or [] + rows = enrich_recommend_rows( + rows, cap, max_margin_pct=pct, trading_mode=trading_mode, + ) + rows = filter_recommend_by_sizing(rows, sizing_mode=sizing_mode, fixed_lots=fixed_lots) + rows = sort_recommend_by_trend(rows) + payload["rows"] = rows + payload["needs_refresh"] = recommend_cache_needs_refresh(payload, capital=cap) + return payload diff --git a/recommend_stream.py b/recommend_stream.py index c15ae51..294ce17 100644 --- a/recommend_stream.py +++ b/recommend_stream.py @@ -1,158 +1,163 @@ -"""品种推荐 SSE 推送与后台刷新。""" -from __future__ import annotations - -import json -import logging -import queue -import threading -import time -from typing import Callable, Optional - -from db_conn import connect_db -from kline_stream import sse_format -from recommend_store import ( - load_recommend_cache, - recommend_cache_needs_refresh, - recommend_payload, - refresh_recommend_cache, -) - -logger = logging.getLogger(__name__) - -CHECK_INTERVAL_SEC = 3600 -_refresh_lock = threading.Lock() -_refresh_running = False - - -def schedule_recommend_refresh( - *, - db_path: str, - get_capital_fn: Callable, - quote_fn: Callable[[str], Optional[dict]], - init_tables_fn: Callable | None = None, - get_mode_fn: Callable[[], str] | None = None, - get_max_margin_pct_fn: Callable[[], float] | None = None, - get_sizing_mode_fn: Callable[[], str] | None = None, - get_fixed_lots_fn: Callable[[], int] | None = None, -) -> None: - """后台刷新推荐缓存(不阻塞页面请求)。""" - global _refresh_running - with _refresh_lock: - if _refresh_running: - return - _refresh_running = True - - def _run() -> None: - global _refresh_running - try: - conn = connect_db(db_path) - try: - if init_tables_fn: - init_tables_fn(conn) - capital = float(get_capital_fn(conn) or 0) - mode = get_mode_fn() if get_mode_fn else "simulation" - max_pct = float(get_max_margin_pct_fn()) if get_max_margin_pct_fn else 30.0 - cached = load_recommend_cache(conn) - if not recommend_cache_needs_refresh(cached, capital=capital): - payload = recommend_payload( - conn, - live_capital=capital, - max_margin_pct=max_pct, - trading_mode=mode, - sizing_mode=get_sizing_mode_fn() if get_sizing_mode_fn else "fixed", - fixed_lots=get_fixed_lots_fn() if get_fixed_lots_fn else 1, - ) - recommend_hub.broadcast("recommend", {"ok": True, **payload}) - return - refresh_recommend_cache( - conn, capital, quote_fn, trading_mode=mode, max_margin_pct=max_pct, - ) - cached = load_recommend_cache(conn) - logger.info( - "品种推荐后台刷新完成,capital=%.2f rows=%d", - capital, len(cached.get("rows") or []), - ) - payload = recommend_payload( - conn, - live_capital=capital, - max_margin_pct=max_pct, - trading_mode=mode, - sizing_mode=get_sizing_mode_fn() if get_sizing_mode_fn else "fixed", - fixed_lots=get_fixed_lots_fn() if get_fixed_lots_fn else 1, - ) - finally: - conn.close() - recommend_hub.broadcast("recommend", {"ok": True, **payload}) - except Exception as exc: - logger.warning("recommend background refresh failed: %s", exc) - finally: - with _refresh_lock: - _refresh_running = False - - threading.Thread(target=_run, daemon=True, name="recommend-refresh").start() - - -class RecommendStreamHub: - def __init__(self) -> None: - self._lock = threading.Lock() - self._subs: list[queue.Queue] = [] - - def subscribe(self) -> queue.Queue: - q: queue.Queue = queue.Queue(maxsize=8) - with self._lock: - self._subs.append(q) - return q - - def unsubscribe(self, q: queue.Queue) -> None: - with self._lock: - try: - self._subs.remove(q) - except ValueError: - pass - - def broadcast(self, event: str, data: dict) -> None: - msg = {"event": event, "data": data} - with self._lock: - subs = list(self._subs) - for q in subs: - try: - q.put_nowait(msg) - except queue.Full: - pass - - -recommend_hub = RecommendStreamHub() - - -def start_recommend_worker( - *, - db_path: str, - get_capital_fn: Callable, - quote_fn: Callable[[str], Optional[dict]], - init_tables_fn: Callable | None = None, - get_mode_fn: Callable[[], str] | None = None, - get_max_margin_pct_fn: Callable[[], float] | None = None, - get_sizing_mode_fn: Callable[[], str] | None = None, - get_fixed_lots_fn: Callable[[], int] | None = None, - interval: int = CHECK_INTERVAL_SEC, -) -> None: - """后台每日刷新推荐(每小时检查一次是否需更新),并推送给 SSE 订阅者。""" - - def _loop() -> None: - while True: - try: - schedule_recommend_refresh( - db_path=db_path, - get_capital_fn=get_capital_fn, - quote_fn=quote_fn, - init_tables_fn=init_tables_fn, - get_mode_fn=get_mode_fn, - get_max_margin_pct_fn=get_max_margin_pct_fn, - get_sizing_mode_fn=get_sizing_mode_fn, - get_fixed_lots_fn=get_fixed_lots_fn, - ) - except Exception as exc: - logger.warning("recommend worker failed: %s", exc) - time.sleep(max(300, interval)) - - threading.Thread(target=_loop, daemon=True, name="recommend-worker").start() +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""可开仓品种 SSE 推送与后台刷新。""" +from __future__ import annotations + +import json +import logging +import queue +import threading +import time +from typing import Callable, Optional + +from db_conn import connect_db +from kline_stream import sse_format +from recommend_store import ( + load_recommend_cache, + recommend_cache_needs_refresh, + recommend_payload, + refresh_recommend_cache, +) + +logger = logging.getLogger(__name__) + +CHECK_INTERVAL_SEC = 3600 +_refresh_lock = threading.Lock() +_refresh_running = False + + +def schedule_recommend_refresh( + *, + db_path: str, + get_capital_fn: Callable, + quote_fn: Callable[[str], Optional[dict]], + init_tables_fn: Callable | None = None, + get_mode_fn: Callable[[], str] | None = None, + get_max_margin_pct_fn: Callable[[], float] | None = None, + get_sizing_mode_fn: Callable[[], str] | None = None, + get_fixed_lots_fn: Callable[[], int] | None = None, +) -> None: + """后台刷新可开仓品种缓存(不阻塞页面请求)。""" + global _refresh_running + with _refresh_lock: + if _refresh_running: + return + _refresh_running = True + + def _run() -> None: + global _refresh_running + try: + conn = connect_db(db_path) + try: + if init_tables_fn: + init_tables_fn(conn) + capital = float(get_capital_fn(conn) or 0) + mode = get_mode_fn() if get_mode_fn else "simulation" + max_pct = float(get_max_margin_pct_fn()) if get_max_margin_pct_fn else 30.0 + cached = load_recommend_cache(conn) + if not recommend_cache_needs_refresh(cached, capital=capital): + payload = recommend_payload( + conn, + live_capital=capital, + max_margin_pct=max_pct, + trading_mode=mode, + sizing_mode=get_sizing_mode_fn() if get_sizing_mode_fn else "fixed", + fixed_lots=get_fixed_lots_fn() if get_fixed_lots_fn else 1, + ) + recommend_hub.broadcast("recommend", {"ok": True, **payload}) + return + refresh_recommend_cache( + conn, capital, quote_fn, trading_mode=mode, max_margin_pct=max_pct, + ) + cached = load_recommend_cache(conn) + logger.info( + "可开仓品种后台刷新完成,capital=%.2f rows=%d", + capital, len(cached.get("rows") or []), + ) + payload = recommend_payload( + conn, + live_capital=capital, + max_margin_pct=max_pct, + trading_mode=mode, + sizing_mode=get_sizing_mode_fn() if get_sizing_mode_fn else "fixed", + fixed_lots=get_fixed_lots_fn() if get_fixed_lots_fn else 1, + ) + finally: + conn.close() + recommend_hub.broadcast("recommend", {"ok": True, **payload}) + except Exception as exc: + logger.warning("recommend background refresh failed: %s", exc) + finally: + with _refresh_lock: + _refresh_running = False + + threading.Thread(target=_run, daemon=True, name="recommend-refresh").start() + + +class RecommendStreamHub: + def __init__(self) -> None: + self._lock = threading.Lock() + self._subs: list[queue.Queue] = [] + + def subscribe(self) -> queue.Queue: + q: queue.Queue = queue.Queue(maxsize=8) + with self._lock: + self._subs.append(q) + return q + + def unsubscribe(self, q: queue.Queue) -> None: + with self._lock: + try: + self._subs.remove(q) + except ValueError: + pass + + def broadcast(self, event: str, data: dict) -> None: + msg = {"event": event, "data": data} + with self._lock: + subs = list(self._subs) + for q in subs: + try: + q.put_nowait(msg) + except queue.Full: + pass + + +recommend_hub = RecommendStreamHub() + + +def start_recommend_worker( + *, + db_path: str, + get_capital_fn: Callable, + quote_fn: Callable[[str], Optional[dict]], + init_tables_fn: Callable | None = None, + get_mode_fn: Callable[[], str] | None = None, + get_max_margin_pct_fn: Callable[[], float] | None = None, + get_sizing_mode_fn: Callable[[], str] | None = None, + get_fixed_lots_fn: Callable[[], int] | None = None, + interval: int = CHECK_INTERVAL_SEC, +) -> None: + """后台每日刷新可开仓列表(每小时检查一次是否需更新),并推送给 SSE 订阅者。""" + + def _loop() -> None: + while True: + try: + schedule_recommend_refresh( + db_path=db_path, + get_capital_fn=get_capital_fn, + quote_fn=quote_fn, + init_tables_fn=init_tables_fn, + get_mode_fn=get_mode_fn, + get_max_margin_pct_fn=get_max_margin_pct_fn, + get_sizing_mode_fn=get_sizing_mode_fn, + get_fixed_lots_fn=get_fixed_lots_fn, + ) + except Exception as exc: + logger.warning("recommend worker failed: %s", exc) + time.sleep(max(300, interval)) + + threading.Thread(target=_loop, daemon=True, name="recommend-worker").start() diff --git a/recommend_trend.py b/recommend_trend.py index 3885c09..a0cc99a 100644 --- a/recommend_trend.py +++ b/recommend_trend.py @@ -1,334 +1,339 @@ -"""品种推荐:近一周日线走势(多头 / 空头 / 震荡 / 转多 / 转空)。""" -from __future__ import annotations - -import logging -from typing import Callable, Optional - -import requests - -from kline_chart import fetch_sina_klines, ths_to_sina_chart_symbol - -logger = logging.getLogger(__name__) - -DAILY_LOOKBACK = 7 -OVERLAP_WINDOW = 3 -OVERLAP_RANGE_THRESHOLD = 0.70 -KLINE_FETCH_TIMEOUT = 5 - -TREND_LONG = "long" -TREND_SHORT = "short" -TREND_RANGE = "range" -TREND_BREAK_LONG = "break_long" -TREND_BREAK_SHORT = "break_short" - - -def _bar_ohlc(bar: dict) -> tuple[float, float, float, float]: - o = float(bar.get("o") or bar.get("open") or 0) - h = float(bar.get("h") or bar.get("high") or o) - l = float(bar.get("l") or bar.get("low") or o) - c = float(bar.get("c") or bar.get("close") or o) - return o, h, l, c - - -def kline_overlap_ratio(bars: list) -> float: - """三根 K 线高低价区间的重叠度 = 交集 / 并集(0~1)。""" - if len(bars) < OVERLAP_WINDOW: - return 0.0 - chunk = bars[-OVERLAP_WINDOW:] - lows, highs = [], [] - for bar in chunk: - _, h, l, _ = _bar_ohlc(bar) - if h <= 0 and l <= 0: - continue - lows.append(l) - highs.append(h) - if len(lows) < OVERLAP_WINDOW: - return 0.0 - overlap = max(0.0, min(highs) - max(lows)) - union = max(highs) - min(lows) - if union <= 0: - return 1.0 if overlap > 0 else 0.0 - return overlap / union - - -def _direction_from_closes(bars: list) -> str: - if len(bars) < 2: - return TREND_RANGE - closes = [_bar_ohlc(b)[3] for b in bars if _bar_ohlc(b)[3] > 0] - if len(closes) < 2: - return TREND_RANGE - if closes[-1] > closes[0]: - return TREND_LONG - if closes[-1] < closes[0]: - return TREND_SHORT - return TREND_RANGE - - -def _bar_ohlcv(bar: dict) -> tuple[float, float, float, float, float]: - o, h, l, c = _bar_ohlc(bar) - v = float(bar.get("v") or bar.get("volume") or 0) - return o, h, l, c, v - - -def compute_daily_quote_stats(bars: list) -> dict: - """从日线提取:跳空、昨收、今开、昨涨跌、昨振幅、成交量。""" - empty = { - "gap": "", - "gap_label": "—", - "gap_pct": None, - "prev_close": None, - "today_open": None, - "yesterday_change": None, - "yesterday_change_pct": None, - "yesterday_amplitude_pct": None, - "volume": None, - } - if len(bars) < 2: - return empty - - t_o, _, _, _, t_v = _bar_ohlcv(bars[-1]) - y_o, y_h, y_l, y_c, y_v = _bar_ohlcv(bars[-2]) - if y_c <= 0: - return empty - - prev_close = round(y_c, 4) - today_open = round(t_o, 4) if t_o > 0 else None - - gap, gap_label, gap_pct = "none", "否", 0.0 - if today_open is not None and today_open > y_c: - gap, gap_label = "up", "跳空高开" - gap_pct = (today_open - y_c) / y_c * 100 - elif today_open is not None and today_open < y_c: - gap, gap_label = "down", "跳空低开" - gap_pct = (today_open - y_c) / y_c * 100 - - if len(bars) >= 3: - _, _, _, p_c, _ = _bar_ohlcv(bars[-3]) - base = p_c if p_c > 0 else y_o - else: - base = y_o if y_o > 0 else y_c - - y_change = y_c - base if base > 0 else None - y_change_pct = (y_change / base * 100) if y_change is not None and base > 0 else None - y_amp = ((y_h - y_l) / base * 100) if base > 0 and y_h >= y_l else None - vol = y_v if y_v > 0 else (t_v if t_v > 0 else None) - - return { - "gap": gap, - "gap_label": gap_label, - "gap_pct": round(gap_pct, 2) if gap != "none" else 0.0, - "prev_close": prev_close, - "today_open": today_open, - "yesterday_change": round(y_change, 4) if y_change is not None else None, - "yesterday_change_pct": round(y_change_pct, 2) if y_change_pct is not None else None, - "yesterday_amplitude_pct": round(y_amp, 2) if y_amp is not None else None, - "volume": int(vol) if vol is not None else None, - "volume_unit": "lot", - } - - -def analyze_daily_trend(bars: list, *, overlap_threshold: float = OVERLAP_RANGE_THRESHOLD) -> dict: - """根据近一周日线判断走势;最近三天重叠度≥阈值视为震荡。""" - empty = { - "trend": "", - "trend_label": "—", - "trend_transition": False, - "trend_overlap_pct": None, - "trend_prev_overlap_pct": None, - } - if len(bars) < OVERLAP_WINDOW: - return empty - - recent = bars[-DAILY_LOOKBACK:] if len(bars) > DAILY_LOOKBACK else bars - curr_overlap = kline_overlap_ratio(recent) - prev_overlap = kline_overlap_ratio(recent[:-OVERLAP_WINDOW]) if len(recent) >= OVERLAP_WINDOW * 2 else 0.0 - - curr_range = curr_overlap >= overlap_threshold - prev_range = prev_overlap >= overlap_threshold - - if curr_range: - trend, label = TREND_RANGE, "震荡" - transition = False - else: - direction = _direction_from_closes(recent[-OVERLAP_WINDOW:]) - if direction == TREND_LONG: - trend, label = TREND_LONG, "多头" - elif direction == TREND_SHORT: - trend, label = TREND_SHORT, "空头" - else: - trend, label = TREND_RANGE, "震荡" - transition = prev_range and trend in (TREND_LONG, TREND_SHORT) - if transition: - if trend == TREND_LONG: - trend, label = TREND_BREAK_LONG, "转多" - else: - trend, label = TREND_BREAK_SHORT, "转空" - - return { - "trend": trend, - "trend_label": label, - "trend_transition": transition, - "trend_overlap_pct": round(curr_overlap * 100, 1), - "trend_prev_overlap_pct": round(prev_overlap * 100, 1) if prev_overlap else None, - } - - -def _normalize_daily_bars(raw: list) -> list: - out = [] - for row in raw: - if isinstance(row, list) and len(row) >= 5: - out.append({ - "d": str(row[0]), - "o": float(row[1]), - "h": float(row[2]), - "l": float(row[3]), - "c": float(row[4]), - "v": float(row[5]) if len(row) > 5 and row[5] else 0.0, - }) - elif isinstance(row, dict) and row.get("d"): - out.append({ - "d": str(row["d"]), - "o": float(row.get("o", 0) or 0), - "h": float(row.get("h", 0) or 0), - "l": float(row.get("l", 0) or 0), - "c": float(row.get("c", 0) or 0), - "v": float(row.get("v", 0) or 0), - }) - return out - - -def _fetch_sina_daily_quick(chart_sym: str) -> list: - url = ( - "https://stock2.finance.sina.com.cn/futures/api/json.php/" - f"IndexService.getInnerFuturesDailyKLine?symbol={chart_sym}" - ) - try: - resp = requests.get( - url, timeout=KLINE_FETCH_TIMEOUT, - headers={"Referer": "https://finance.sina.com.cn"}, - ) - raw = resp.json() - if raw and isinstance(raw, list): - bars = _normalize_daily_bars(raw) - if bars: - return bars - except Exception as exc: - logger.debug("quick daily kline failed %s: %s", chart_sym, exc) - return [] - - -def fetch_week_daily_bars( - symbol: str, - *, - fetch_fn: Callable[[str, str], list] | None = None, -) -> list: - sym = (symbol or "").strip() - if not sym: - return [] - if fetch_fn: - try: - bars = fetch_fn(sym, "d") or [] - except Exception as exc: - logger.debug("fetch week daily failed %s: %s", sym, exc) - return [] - return bars[-DAILY_LOOKBACK:] if bars else [] - - chart_sym = ths_to_sina_chart_symbol(sym) - if not chart_sym: - return [] - bars = _fetch_sina_daily_quick(chart_sym) - if not bars: - try: - bars = fetch_sina_klines(sym, "d") or [] - except Exception as exc: - logger.debug("fetch week daily fallback failed %s: %s", sym, exc) - return [] - return bars[-DAILY_LOOKBACK:] if bars else [] - - -def analyze_product_daily( - symbol: str, - *, - fetch_fn: Callable[[str, str], list] | None = None, -) -> dict: - """拉取主力合约一周日线:走势 + 跳空/量价统计。""" - sym = (symbol or "").strip() - if not sym: - out = analyze_daily_trend([]) - out.update(compute_daily_quote_stats([])) - return out - bars = fetch_week_daily_bars(sym, fetch_fn=fetch_fn) - out = analyze_daily_trend(bars) - out.update(compute_daily_quote_stats(bars)) - return out - - -def analyze_product_trend( - symbol: str, - *, - fetch_fn: Callable[[str, str], list] | None = None, -) -> dict: - return analyze_product_daily(symbol, fetch_fn=fetch_fn) - - -GAP_SORT_RANK = {"up": 2, "down": 1, "none": 0, "": -1} -TREND_SORT_RANK = { - TREND_BREAK_LONG: 0, - TREND_BREAK_SHORT: 0, - TREND_LONG: 1, - TREND_SHORT: 2, - TREND_RANGE: 3, - "": 9, -} - - -def recommend_sort_key(row: dict, sort_by: str = "trend", *, desc: bool = True) -> tuple: - """可排序字段:trend / gap / volume / amplitude。""" - key = (sort_by or "trend").strip().lower() - if key == "gap": - primary = GAP_SORT_RANK.get(row.get("gap") or "", -1) - secondary = abs(float(row.get("gap_pct") or 0)) - elif key == "volume": - primary = float(row.get("volume") or 0) - secondary = 0.0 - elif key == "amplitude": - primary = float(row.get("yesterday_amplitude_pct") or 0) - secondary = 0.0 - else: - primary = TREND_SORT_RANK.get(row.get("trend") or "", 9) - secondary = -(int(row.get("max_lots") or 0)) - - if desc: - return (-primary, -secondary, row.get("name") or "") - return (primary, secondary, row.get("name") or "") - - -def sort_recommend_rows( - rows: list[dict], - *, - sort_by: str = "trend", - desc: bool = True, -) -> list[dict]: - return sorted(rows, key=lambda r: recommend_sort_key(r, sort_by, desc=desc)) - - -def trend_sort_key(row: dict) -> tuple: - """转多/转空优先,其次多头/空头,震荡靠后。""" - trend = (row.get("trend") or "").strip() - priority = { - TREND_BREAK_LONG: 0, - TREND_BREAK_SHORT: 0, - TREND_LONG: 1, - TREND_SHORT: 1, - TREND_RANGE: 2, - } - status_order = {"ok": 0, "margin_ok": 1, "blocked": 2, "no_price": 3} - return ( - priority.get(trend, 3), - status_order.get(row.get("status") or "", 9), - -(int(row.get("max_lots") or 0)), - ) - - -def sort_recommend_by_trend(rows: list[dict]) -> list[dict]: - return sort_recommend_rows(rows, sort_by="trend", desc=True) +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""可开仓品种:近一周日线走势(多头 / 空头 / 震荡 / 转多 / 转空)。""" +from __future__ import annotations + +import logging +from typing import Callable, Optional + +import requests + +from kline_chart import fetch_sina_klines, ths_to_sina_chart_symbol + +logger = logging.getLogger(__name__) + +DAILY_LOOKBACK = 7 +OVERLAP_WINDOW = 3 +OVERLAP_RANGE_THRESHOLD = 0.70 +KLINE_FETCH_TIMEOUT = 5 + +TREND_LONG = "long" +TREND_SHORT = "short" +TREND_RANGE = "range" +TREND_BREAK_LONG = "break_long" +TREND_BREAK_SHORT = "break_short" + + +def _bar_ohlc(bar: dict) -> tuple[float, float, float, float]: + o = float(bar.get("o") or bar.get("open") or 0) + h = float(bar.get("h") or bar.get("high") or o) + l = float(bar.get("l") or bar.get("low") or o) + c = float(bar.get("c") or bar.get("close") or o) + return o, h, l, c + + +def kline_overlap_ratio(bars: list) -> float: + """三根 K 线高低价区间的重叠度 = 交集 / 并集(0~1)。""" + if len(bars) < OVERLAP_WINDOW: + return 0.0 + chunk = bars[-OVERLAP_WINDOW:] + lows, highs = [], [] + for bar in chunk: + _, h, l, _ = _bar_ohlc(bar) + if h <= 0 and l <= 0: + continue + lows.append(l) + highs.append(h) + if len(lows) < OVERLAP_WINDOW: + return 0.0 + overlap = max(0.0, min(highs) - max(lows)) + union = max(highs) - min(lows) + if union <= 0: + return 1.0 if overlap > 0 else 0.0 + return overlap / union + + +def _direction_from_closes(bars: list) -> str: + if len(bars) < 2: + return TREND_RANGE + closes = [_bar_ohlc(b)[3] for b in bars if _bar_ohlc(b)[3] > 0] + if len(closes) < 2: + return TREND_RANGE + if closes[-1] > closes[0]: + return TREND_LONG + if closes[-1] < closes[0]: + return TREND_SHORT + return TREND_RANGE + + +def _bar_ohlcv(bar: dict) -> tuple[float, float, float, float, float]: + o, h, l, c = _bar_ohlc(bar) + v = float(bar.get("v") or bar.get("volume") or 0) + return o, h, l, c, v + + +def compute_daily_quote_stats(bars: list) -> dict: + """从日线提取:跳空、昨收、今开、昨涨跌、昨振幅、成交量。""" + empty = { + "gap": "", + "gap_label": "—", + "gap_pct": None, + "prev_close": None, + "today_open": None, + "yesterday_change": None, + "yesterday_change_pct": None, + "yesterday_amplitude_pct": None, + "volume": None, + } + if len(bars) < 2: + return empty + + t_o, _, _, _, t_v = _bar_ohlcv(bars[-1]) + y_o, y_h, y_l, y_c, y_v = _bar_ohlcv(bars[-2]) + if y_c <= 0: + return empty + + prev_close = round(y_c, 4) + today_open = round(t_o, 4) if t_o > 0 else None + + gap, gap_label, gap_pct = "none", "否", 0.0 + if today_open is not None and today_open > y_c: + gap, gap_label = "up", "跳空高开" + gap_pct = (today_open - y_c) / y_c * 100 + elif today_open is not None and today_open < y_c: + gap, gap_label = "down", "跳空低开" + gap_pct = (today_open - y_c) / y_c * 100 + + if len(bars) >= 3: + _, _, _, p_c, _ = _bar_ohlcv(bars[-3]) + base = p_c if p_c > 0 else y_o + else: + base = y_o if y_o > 0 else y_c + + y_change = y_c - base if base > 0 else None + y_change_pct = (y_change / base * 100) if y_change is not None and base > 0 else None + y_amp = ((y_h - y_l) / base * 100) if base > 0 and y_h >= y_l else None + vol = y_v if y_v > 0 else (t_v if t_v > 0 else None) + + return { + "gap": gap, + "gap_label": gap_label, + "gap_pct": round(gap_pct, 2) if gap != "none" else 0.0, + "prev_close": prev_close, + "today_open": today_open, + "yesterday_change": round(y_change, 4) if y_change is not None else None, + "yesterday_change_pct": round(y_change_pct, 2) if y_change_pct is not None else None, + "yesterday_amplitude_pct": round(y_amp, 2) if y_amp is not None else None, + "volume": int(vol) if vol is not None else None, + "volume_unit": "lot", + } + + +def analyze_daily_trend(bars: list, *, overlap_threshold: float = OVERLAP_RANGE_THRESHOLD) -> dict: + """根据近一周日线判断走势;最近三天重叠度≥阈值视为震荡。""" + empty = { + "trend": "", + "trend_label": "—", + "trend_transition": False, + "trend_overlap_pct": None, + "trend_prev_overlap_pct": None, + } + if len(bars) < OVERLAP_WINDOW: + return empty + + recent = bars[-DAILY_LOOKBACK:] if len(bars) > DAILY_LOOKBACK else bars + curr_overlap = kline_overlap_ratio(recent) + prev_overlap = kline_overlap_ratio(recent[:-OVERLAP_WINDOW]) if len(recent) >= OVERLAP_WINDOW * 2 else 0.0 + + curr_range = curr_overlap >= overlap_threshold + prev_range = prev_overlap >= overlap_threshold + + if curr_range: + trend, label = TREND_RANGE, "震荡" + transition = False + else: + direction = _direction_from_closes(recent[-OVERLAP_WINDOW:]) + if direction == TREND_LONG: + trend, label = TREND_LONG, "多头" + elif direction == TREND_SHORT: + trend, label = TREND_SHORT, "空头" + else: + trend, label = TREND_RANGE, "震荡" + transition = prev_range and trend in (TREND_LONG, TREND_SHORT) + if transition: + if trend == TREND_LONG: + trend, label = TREND_BREAK_LONG, "转多" + else: + trend, label = TREND_BREAK_SHORT, "转空" + + return { + "trend": trend, + "trend_label": label, + "trend_transition": transition, + "trend_overlap_pct": round(curr_overlap * 100, 1), + "trend_prev_overlap_pct": round(prev_overlap * 100, 1) if prev_overlap else None, + } + + +def _normalize_daily_bars(raw: list) -> list: + out = [] + for row in raw: + if isinstance(row, list) and len(row) >= 5: + out.append({ + "d": str(row[0]), + "o": float(row[1]), + "h": float(row[2]), + "l": float(row[3]), + "c": float(row[4]), + "v": float(row[5]) if len(row) > 5 and row[5] else 0.0, + }) + elif isinstance(row, dict) and row.get("d"): + out.append({ + "d": str(row["d"]), + "o": float(row.get("o", 0) or 0), + "h": float(row.get("h", 0) or 0), + "l": float(row.get("l", 0) or 0), + "c": float(row.get("c", 0) or 0), + "v": float(row.get("v", 0) or 0), + }) + return out + + +def _fetch_sina_daily_quick(chart_sym: str) -> list: + url = ( + "https://stock2.finance.sina.com.cn/futures/api/json.php/" + f"IndexService.getInnerFuturesDailyKLine?symbol={chart_sym}" + ) + try: + resp = requests.get( + url, timeout=KLINE_FETCH_TIMEOUT, + headers={"Referer": "https://finance.sina.com.cn"}, + ) + raw = resp.json() + if raw and isinstance(raw, list): + bars = _normalize_daily_bars(raw) + if bars: + return bars + except Exception as exc: + logger.debug("quick daily kline failed %s: %s", chart_sym, exc) + return [] + + +def fetch_week_daily_bars( + symbol: str, + *, + fetch_fn: Callable[[str, str], list] | None = None, +) -> list: + sym = (symbol or "").strip() + if not sym: + return [] + if fetch_fn: + try: + bars = fetch_fn(sym, "d") or [] + except Exception as exc: + logger.debug("fetch week daily failed %s: %s", sym, exc) + return [] + return bars[-DAILY_LOOKBACK:] if bars else [] + + chart_sym = ths_to_sina_chart_symbol(sym) + if not chart_sym: + return [] + bars = _fetch_sina_daily_quick(chart_sym) + if not bars: + try: + bars = fetch_sina_klines(sym, "d") or [] + except Exception as exc: + logger.debug("fetch week daily fallback failed %s: %s", sym, exc) + return [] + return bars[-DAILY_LOOKBACK:] if bars else [] + + +def analyze_product_daily( + symbol: str, + *, + fetch_fn: Callable[[str, str], list] | None = None, +) -> dict: + """拉取主力合约一周日线:走势 + 跳空/量价统计。""" + sym = (symbol or "").strip() + if not sym: + out = analyze_daily_trend([]) + out.update(compute_daily_quote_stats([])) + return out + bars = fetch_week_daily_bars(sym, fetch_fn=fetch_fn) + out = analyze_daily_trend(bars) + out.update(compute_daily_quote_stats(bars)) + return out + + +def analyze_product_trend( + symbol: str, + *, + fetch_fn: Callable[[str, str], list] | None = None, +) -> dict: + return analyze_product_daily(symbol, fetch_fn=fetch_fn) + + +GAP_SORT_RANK = {"up": 2, "down": 1, "none": 0, "": -1} +TREND_SORT_RANK = { + TREND_BREAK_LONG: 0, + TREND_BREAK_SHORT: 0, + TREND_LONG: 1, + TREND_SHORT: 2, + TREND_RANGE: 3, + "": 9, +} + + +def recommend_sort_key(row: dict, sort_by: str = "trend", *, desc: bool = True) -> tuple: + """可排序字段:trend / gap / volume / amplitude。""" + key = (sort_by or "trend").strip().lower() + if key == "gap": + primary = GAP_SORT_RANK.get(row.get("gap") or "", -1) + secondary = abs(float(row.get("gap_pct") or 0)) + elif key == "volume": + primary = float(row.get("volume") or 0) + secondary = 0.0 + elif key == "amplitude": + primary = float(row.get("yesterday_amplitude_pct") or 0) + secondary = 0.0 + else: + primary = TREND_SORT_RANK.get(row.get("trend") or "", 9) + secondary = -(int(row.get("max_lots") or 0)) + + if desc: + return (-primary, -secondary, row.get("name") or "") + return (primary, secondary, row.get("name") or "") + + +def sort_recommend_rows( + rows: list[dict], + *, + sort_by: str = "trend", + desc: bool = True, +) -> list[dict]: + return sorted(rows, key=lambda r: recommend_sort_key(r, sort_by, desc=desc)) + + +def trend_sort_key(row: dict) -> tuple: + """转多/转空优先,其次多头/空头,震荡靠后。""" + trend = (row.get("trend") or "").strip() + priority = { + TREND_BREAK_LONG: 0, + TREND_BREAK_SHORT: 0, + TREND_LONG: 1, + TREND_SHORT: 1, + TREND_RANGE: 2, + } + status_order = {"ok": 0, "margin_ok": 1, "blocked": 2, "no_price": 3} + return ( + priority.get(trend, 3), + status_order.get(row.get("status") or "", 9), + -(int(row.get("max_lots") or 0)), + ) + + +def sort_recommend_by_trend(rows: list[dict]) -> list[dict]: + return sort_recommend_rows(rows, sort_by="trend", desc=True) diff --git a/reset_admin.py b/reset_admin.py index 0960d61..08fd28f 100644 --- a/reset_admin.py +++ b/reset_admin.py @@ -1,24 +1,29 @@ -#!/usr/bin/env python3 -"""从 .env 重置管理员账号(服务器上忘记密码时使用)""" -import os -import sys - -from dotenv import load_dotenv -from werkzeug.security import generate_password_hash - -BASE = os.path.dirname(os.path.abspath(__file__)) -load_dotenv(os.path.join(BASE, ".env")) - -sys.path.insert(0, BASE) -from app import set_setting, get_setting # noqa: E402 - -username = os.getenv("ADMIN_USERNAME", "admin").strip() or "admin" -password = os.getenv("ADMIN_PASSWORD", "").strip() -if not password or password == "change-me-on-first-login": - print("请在 .env 中设置 ADMIN_PASSWORD 后再运行此脚本") - sys.exit(1) - -old_username = get_setting("admin_username") -set_setting("admin_username", username) -set_setting("admin_password_hash", generate_password_hash(password)) -print(f"已重置管理员: {username}(原账号: {old_username or '无'})") +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +#!/usr/bin/env python3 +"""从 .env 重置管理员账号(服务器上忘记密码时使用)""" +import os +import sys + +from dotenv import load_dotenv +from werkzeug.security import generate_password_hash + +BASE = os.path.dirname(os.path.abspath(__file__)) +load_dotenv(os.path.join(BASE, ".env")) + +sys.path.insert(0, BASE) +from app import set_setting, get_setting # noqa: E402 + +username = os.getenv("ADMIN_USERNAME", "admin").strip() or "admin" +password = os.getenv("ADMIN_PASSWORD", "").strip() +if not password or password == "change-me-on-first-login": + print("请在 .env 中设置 ADMIN_PASSWORD 后再运行此脚本") + sys.exit(1) + +old_username = get_setting("admin_username") +set_setting("admin_username", username) +set_setting("admin_password_hash", generate_password_hash(password)) +print(f"已重置管理员: {username}(原账号: {old_username or '无'})") diff --git a/risk/__init__.py b/risk/__init__.py index e69de29..fd5971f 100644 --- a/risk/__init__.py +++ b/risk/__init__.py @@ -0,0 +1,5 @@ +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + diff --git a/risk/account_risk_lib.py b/risk/account_risk_lib.py index 26e0b73..0ba3b4c 100644 --- a/risk/account_risk_lib.py +++ b/risk/account_risk_lib.py @@ -1,302 +1,307 @@ -"""账户冷静期 / 日冻结(自 crypto_monitor 复制并简化为单账户期货版)。""" -from __future__ import annotations - -import os -import sqlite3 -import time -from datetime import datetime -from typing import Any, Callable, Optional, TypeVar -from zoneinfo import ZoneInfo - -T = TypeVar("T") - -STATUS_NORMAL = "normal" -STATUS_FREEZE_1H = "freeze_1h" -STATUS_FREEZE_4H = "freeze_4h" -STATUS_DAILY = "freeze_daily" -STATUS_FREEZE_POSITION = "freeze_position" - -STATUS_LABELS = { - STATUS_NORMAL: "正常", - STATUS_FREEZE_1H: "1h冻结", - STATUS_FREEZE_4H: "4h冻结", - STATUS_DAILY: "日冻结", - STATUS_FREEZE_POSITION: "仓位上限冻结", -} - -MOOD_ISSUE_OPTIONS = ( - "怕踏空", "报复开仓", "盈利飘了", "拿不住单", "扛单", "重仓违规", -) - -CLOSE_SOURCE_USER = "user_instance" -CLOSE_SOURCE_TREND_STOP = "user_trend_stop" - - -def _app_tz(): - name = (os.getenv("APP_TIMEZONE") or "Asia/Shanghai").strip() - try: - return ZoneInfo(name) - except Exception: - return ZoneInfo("Asia/Shanghai") - - -def risk_control_enabled() -> bool: - raw = (os.getenv("RISK_CONTROL_ENABLED") or "true").strip().lower() - return raw in ("1", "true", "yes", "on") - - -def cooling_hours_manual() -> float: - try: - return max(0.0, float(os.getenv("RISK_COOLING_HOURS_MANUAL", "4"))) - except (TypeError, ValueError): - return 4.0 - - -def cooling_hours_manual_journal() -> float: - try: - return max(0.0, float(os.getenv("RISK_COOLING_HOURS_MANUAL_JOURNAL", "1"))) - except (TypeError, ValueError): - return 1.0 - - -def manual_close_daily_limit() -> int: - try: - return max(1, int(os.getenv("RISK_MANUAL_CLOSE_DAILY_LIMIT", "2"))) - except (TypeError, ValueError): - return 2 - - -def max_active_positions() -> int: - try: - return max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))) - except (TypeError, ValueError): - return 1 - - -def trading_day_reset_hour() -> int: - try: - return max(0, min(23, int(os.getenv("TRADING_DAY_RESET_HOUR", "8")))) - except (TypeError, ValueError): - return 8 - - -_SCHEMA_READY = False - - -def _db_retry(action: Callable[[], T], *, retries: int = 8, base_delay: float = 0.03) -> T: - last: sqlite3.OperationalError | None = None - for i in range(retries): - try: - return action() - except sqlite3.OperationalError as exc: - if "locked" not in str(exc).lower(): - raise - last = exc - time.sleep(base_delay * (2 ** i)) - if last is not None: - raise last - raise RuntimeError("db retry failed") - - -def ensure_account_risk_schema(conn) -> None: - global _SCHEMA_READY - if _SCHEMA_READY: - return - conn.execute( - """CREATE TABLE IF NOT EXISTS account_risk_state ( - id INTEGER PRIMARY KEY CHECK (id = 1), - trading_day TEXT, - manual_close_count INTEGER DEFAULT 0, - cooloff_until_ms INTEGER, - cooloff_hours INTEGER, - daily_frozen INTEGER DEFAULT 0, - last_close_at_ms INTEGER, - updated_at TEXT - )""" - ) - if not conn.execute("SELECT id FROM account_risk_state WHERE id=1").fetchone(): - conn.execute( - "INSERT INTO account_risk_state (id, trading_day, manual_close_count, daily_frozen) VALUES (1, '', 0, 0)" - ) - conn.commit() - _SCHEMA_READY = True - - -def _row_get(row, key, default=None): - if row is None: - return default - try: - return row[key] - except (KeyError, IndexError, TypeError): - return default - - -def _now_ms(now: Optional[datetime] = None) -> int: - dt = now or datetime.now(_app_tz()) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=_app_tz()) - return int(dt.timestamp() * 1000) - - -def trading_day_label(now: Optional[datetime] = None) -> str: - dt = now or datetime.now(_app_tz()) - if dt.hour < trading_day_reset_hour(): - from datetime import timedelta - dt = dt - timedelta(days=1) - return dt.date().isoformat() - - -def count_active_trade_monitors(conn) -> int: - try: - n = conn.execute( - "SELECT COUNT(*) FROM trade_order_monitors WHERE status='active'" - ).fetchone()[0] - return int(n or 0) - except Exception: - return 0 - - -def parse_mood_issues(raw: Any) -> list[str]: - if raw is None: - return [] - if isinstance(raw, (list, tuple)): - parts = [str(x).strip() for x in raw if str(x).strip()] - else: - parts = [x.strip() for x in str(raw).split(",") if x.strip()] - return [p for p in parts if p in MOOD_ISSUE_OPTIONS] - - -def on_user_initiated_close(conn, *, trading_day: str, now: Optional[datetime] = None) -> None: - if not risk_control_enabled(): - return - ensure_account_risk_schema(conn) - row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone() - td = (trading_day or trading_day_label(now)).strip() - stored = str(_row_get(row, "trading_day") or "") - count = int(_row_get(row, "manual_close_count") or 0) - if stored != td: - count = 0 - count += 1 - close_ms = _now_ms(now) - if count >= manual_close_daily_limit(): - conn.execute( - """UPDATE account_risk_state SET trading_day=?, manual_close_count=?, - daily_frozen=1, cooloff_until_ms=NULL, last_close_at_ms=?, updated_at=? WHERE id=1""", - (td, count, close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")), - ) - return - until = close_ms + int(cooling_hours_manual() * 3600 * 1000) - conn.execute( - """UPDATE account_risk_state SET trading_day=?, manual_close_count=?, - daily_frozen=0, cooloff_until_ms=?, cooloff_hours=?, last_close_at_ms=?, updated_at=? WHERE id=1""", - (td, count, until, int(cooling_hours_manual()), close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")), - ) - - -def on_mood_journal_freeze(conn, *, trading_day: str) -> None: - if not risk_control_enabled(): - return - ensure_account_risk_schema(conn) - td = (trading_day or trading_day_label()).strip() - conn.execute( - "UPDATE account_risk_state SET trading_day=?, daily_frozen=1, updated_at=? WHERE id=1", - (td, datetime.now().strftime("%Y-%m-%d %H:%M:%S")), - ) - - -def reduce_cooloff_after_journal(conn, *, trading_day: str, now: Optional[datetime] = None) -> None: - """复盘手动平仓说明后,4h 冷静期降为 1h。""" - if not risk_control_enabled(): - return - ensure_account_risk_schema(conn) - row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone() - if int(_row_get(row, "daily_frozen") or 0): - return - until = _row_get(row, "cooloff_until_ms") - if not until: - return - now_ms = _now_ms(now) - if int(until) <= now_ms: - return - last = int(_row_get(row, "last_close_at_ms") or now_ms) - journal_ms = int(cooling_hours_manual_journal() * 3600 * 1000) - new_until = max(now_ms, last + journal_ms) - conn.execute( - """UPDATE account_risk_state SET cooloff_until_ms=?, cooloff_hours=?, updated_at=? WHERE id=1""", - (new_until, int(cooling_hours_manual_journal()), datetime.now().strftime("%Y-%m-%d %H:%M:%S")), - ) - - -def get_risk_status(conn, *, now: Optional[datetime] = None, active_count: Optional[int] = None) -> dict: - def _load() -> dict: - ensure_account_risk_schema(conn) - row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone() - td = trading_day_label(now) - stored = str(_row_get(row, "trading_day") or "") - if stored != td: - conn.execute( - "UPDATE account_risk_state SET trading_day=?, manual_close_count=0, daily_frozen=0 WHERE id=1 AND trading_day<>?", - (td, td), - ) - conn.commit() - row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone() - - now_ms = _now_ms(now) - daily = int(_row_get(row, "daily_frozen") or 0) == 1 - until = _row_get(row, "cooloff_until_ms") - active = count_active_trade_monitors(conn) if active_count is None else int(active_count) - mx = max_active_positions() - pos_limit = active >= mx - - if daily: - return { - "status": STATUS_DAILY, - "status_label": STATUS_LABELS[STATUS_DAILY], - "can_trade": False, - "can_roll": False, - "reason": "当日日冻结,禁止新开仓", - "active_count": active, - "max_active_positions": mx, - } - if until and int(until) > now_ms: - rem = int((int(until) - now_ms) / 1000) - hours = float(_row_get(row, "cooloff_hours") or cooling_hours_manual()) - st = STATUS_FREEZE_1H if hours <= cooling_hours_manual_journal() + 0.01 else STATUS_FREEZE_4H - return { - "status": st, - "status_label": STATUS_LABELS[st], - "can_trade": False, - "can_roll": pos_limit, - "reason": f"冷静期中,剩余约 {rem // 3600}h {(rem % 3600) // 60}m", - "freeze_remaining_sec": rem, - "active_count": active, - "max_active_positions": mx, - } - if pos_limit: - return { - "status": STATUS_FREEZE_POSITION, - "status_label": STATUS_LABELS[STATUS_FREEZE_POSITION], - "can_trade": False, - "can_roll": True, - "reason": f"已达仓位上限 {active}/{mx}", - "active_count": active, - "max_active_positions": mx, - } - return { - "status": STATUS_NORMAL, - "status_label": STATUS_LABELS[STATUS_NORMAL], - "can_trade": True, - "can_roll": True, - "reason": "可新开仓", - "active_count": active, - "max_active_positions": mx, - } - - return _db_retry(_load) - - -def assert_can_open(conn, *, active_count: Optional[int] = None) -> Optional[str]: - rs = get_risk_status(conn, active_count=active_count) - if not rs.get("can_trade"): - return rs.get("reason") or "当前不可开仓" - return None +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""账户冷静期 / 日冻结(自 crypto_monitor 复制并简化为单账户期货版)。""" +from __future__ import annotations + +import os +import sqlite3 +import time +from datetime import datetime +from typing import Any, Callable, Optional, TypeVar +from zoneinfo import ZoneInfo + +T = TypeVar("T") + +STATUS_NORMAL = "normal" +STATUS_FREEZE_1H = "freeze_1h" +STATUS_FREEZE_4H = "freeze_4h" +STATUS_DAILY = "freeze_daily" +STATUS_FREEZE_POSITION = "freeze_position" + +STATUS_LABELS = { + STATUS_NORMAL: "正常", + STATUS_FREEZE_1H: "1h冻结", + STATUS_FREEZE_4H: "4h冻结", + STATUS_DAILY: "日冻结", + STATUS_FREEZE_POSITION: "仓位上限冻结", +} + +MOOD_ISSUE_OPTIONS = ( + "怕踏空", "报复开仓", "盈利飘了", "拿不住单", "扛单", "重仓违规", +) + +CLOSE_SOURCE_USER = "user_instance" +CLOSE_SOURCE_TREND_STOP = "user_trend_stop" + + +def _app_tz(): + name = (os.getenv("APP_TIMEZONE") or "Asia/Shanghai").strip() + try: + return ZoneInfo(name) + except Exception: + return ZoneInfo("Asia/Shanghai") + + +def risk_control_enabled() -> bool: + raw = (os.getenv("RISK_CONTROL_ENABLED") or "true").strip().lower() + return raw in ("1", "true", "yes", "on") + + +def cooling_hours_manual() -> float: + try: + return max(0.0, float(os.getenv("RISK_COOLING_HOURS_MANUAL", "4"))) + except (TypeError, ValueError): + return 4.0 + + +def cooling_hours_manual_journal() -> float: + try: + return max(0.0, float(os.getenv("RISK_COOLING_HOURS_MANUAL_JOURNAL", "1"))) + except (TypeError, ValueError): + return 1.0 + + +def manual_close_daily_limit() -> int: + try: + return max(1, int(os.getenv("RISK_MANUAL_CLOSE_DAILY_LIMIT", "2"))) + except (TypeError, ValueError): + return 2 + + +def max_active_positions() -> int: + try: + return max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))) + except (TypeError, ValueError): + return 1 + + +def trading_day_reset_hour() -> int: + try: + return max(0, min(23, int(os.getenv("TRADING_DAY_RESET_HOUR", "8")))) + except (TypeError, ValueError): + return 8 + + +_SCHEMA_READY = False + + +def _db_retry(action: Callable[[], T], *, retries: int = 8, base_delay: float = 0.03) -> T: + last: sqlite3.OperationalError | None = None + for i in range(retries): + try: + return action() + except sqlite3.OperationalError as exc: + if "locked" not in str(exc).lower(): + raise + last = exc + time.sleep(base_delay * (2 ** i)) + if last is not None: + raise last + raise RuntimeError("db retry failed") + + +def ensure_account_risk_schema(conn) -> None: + global _SCHEMA_READY + if _SCHEMA_READY: + return + conn.execute( + """CREATE TABLE IF NOT EXISTS account_risk_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + trading_day TEXT, + manual_close_count INTEGER DEFAULT 0, + cooloff_until_ms INTEGER, + cooloff_hours INTEGER, + daily_frozen INTEGER DEFAULT 0, + last_close_at_ms INTEGER, + updated_at TEXT + )""" + ) + if not conn.execute("SELECT id FROM account_risk_state WHERE id=1").fetchone(): + conn.execute( + "INSERT INTO account_risk_state (id, trading_day, manual_close_count, daily_frozen) VALUES (1, '', 0, 0)" + ) + conn.commit() + _SCHEMA_READY = True + + +def _row_get(row, key, default=None): + if row is None: + return default + try: + return row[key] + except (KeyError, IndexError, TypeError): + return default + + +def _now_ms(now: Optional[datetime] = None) -> int: + dt = now or datetime.now(_app_tz()) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=_app_tz()) + return int(dt.timestamp() * 1000) + + +def trading_day_label(now: Optional[datetime] = None) -> str: + dt = now or datetime.now(_app_tz()) + if dt.hour < trading_day_reset_hour(): + from datetime import timedelta + dt = dt - timedelta(days=1) + return dt.date().isoformat() + + +def count_active_trade_monitors(conn) -> int: + try: + n = conn.execute( + "SELECT COUNT(*) FROM trade_order_monitors WHERE status='active'" + ).fetchone()[0] + return int(n or 0) + except Exception: + return 0 + + +def parse_mood_issues(raw: Any) -> list[str]: + if raw is None: + return [] + if isinstance(raw, (list, tuple)): + parts = [str(x).strip() for x in raw if str(x).strip()] + else: + parts = [x.strip() for x in str(raw).split(",") if x.strip()] + return [p for p in parts if p in MOOD_ISSUE_OPTIONS] + + +def on_user_initiated_close(conn, *, trading_day: str, now: Optional[datetime] = None) -> None: + if not risk_control_enabled(): + return + ensure_account_risk_schema(conn) + row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone() + td = (trading_day or trading_day_label(now)).strip() + stored = str(_row_get(row, "trading_day") or "") + count = int(_row_get(row, "manual_close_count") or 0) + if stored != td: + count = 0 + count += 1 + close_ms = _now_ms(now) + if count >= manual_close_daily_limit(): + conn.execute( + """UPDATE account_risk_state SET trading_day=?, manual_close_count=?, + daily_frozen=1, cooloff_until_ms=NULL, last_close_at_ms=?, updated_at=? WHERE id=1""", + (td, count, close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + ) + return + until = close_ms + int(cooling_hours_manual() * 3600 * 1000) + conn.execute( + """UPDATE account_risk_state SET trading_day=?, manual_close_count=?, + daily_frozen=0, cooloff_until_ms=?, cooloff_hours=?, last_close_at_ms=?, updated_at=? WHERE id=1""", + (td, count, until, int(cooling_hours_manual()), close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + ) + + +def on_mood_journal_freeze(conn, *, trading_day: str) -> None: + if not risk_control_enabled(): + return + ensure_account_risk_schema(conn) + td = (trading_day or trading_day_label()).strip() + conn.execute( + "UPDATE account_risk_state SET trading_day=?, daily_frozen=1, updated_at=? WHERE id=1", + (td, datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + ) + + +def reduce_cooloff_after_journal(conn, *, trading_day: str, now: Optional[datetime] = None) -> None: + """复盘手动平仓说明后,4h 冷静期降为 1h。""" + if not risk_control_enabled(): + return + ensure_account_risk_schema(conn) + row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone() + if int(_row_get(row, "daily_frozen") or 0): + return + until = _row_get(row, "cooloff_until_ms") + if not until: + return + now_ms = _now_ms(now) + if int(until) <= now_ms: + return + last = int(_row_get(row, "last_close_at_ms") or now_ms) + journal_ms = int(cooling_hours_manual_journal() * 3600 * 1000) + new_until = max(now_ms, last + journal_ms) + conn.execute( + """UPDATE account_risk_state SET cooloff_until_ms=?, cooloff_hours=?, updated_at=? WHERE id=1""", + (new_until, int(cooling_hours_manual_journal()), datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + ) + + +def get_risk_status(conn, *, now: Optional[datetime] = None, active_count: Optional[int] = None) -> dict: + def _load() -> dict: + ensure_account_risk_schema(conn) + row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone() + td = trading_day_label(now) + stored = str(_row_get(row, "trading_day") or "") + if stored != td: + conn.execute( + "UPDATE account_risk_state SET trading_day=?, manual_close_count=0, daily_frozen=0 WHERE id=1 AND trading_day<>?", + (td, td), + ) + conn.commit() + row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone() + + now_ms = _now_ms(now) + daily = int(_row_get(row, "daily_frozen") or 0) == 1 + until = _row_get(row, "cooloff_until_ms") + active = count_active_trade_monitors(conn) if active_count is None else int(active_count) + mx = max_active_positions() + pos_limit = active >= mx + + if daily: + return { + "status": STATUS_DAILY, + "status_label": STATUS_LABELS[STATUS_DAILY], + "can_trade": False, + "can_roll": False, + "reason": "当日日冻结,禁止新开仓", + "active_count": active, + "max_active_positions": mx, + } + if until and int(until) > now_ms: + rem = int((int(until) - now_ms) / 1000) + hours = float(_row_get(row, "cooloff_hours") or cooling_hours_manual()) + st = STATUS_FREEZE_1H if hours <= cooling_hours_manual_journal() + 0.01 else STATUS_FREEZE_4H + return { + "status": st, + "status_label": STATUS_LABELS[st], + "can_trade": False, + "can_roll": pos_limit, + "reason": f"冷静期中,剩余约 {rem // 3600}h {(rem % 3600) // 60}m", + "freeze_remaining_sec": rem, + "active_count": active, + "max_active_positions": mx, + } + if pos_limit: + return { + "status": STATUS_FREEZE_POSITION, + "status_label": STATUS_LABELS[STATUS_FREEZE_POSITION], + "can_trade": False, + "can_roll": True, + "reason": f"已达仓位上限 {active}/{mx}", + "active_count": active, + "max_active_positions": mx, + } + return { + "status": STATUS_NORMAL, + "status_label": STATUS_LABELS[STATUS_NORMAL], + "can_trade": True, + "can_roll": True, + "reason": "可新开仓", + "active_count": active, + "max_active_positions": mx, + } + + return _db_retry(_load) + + +def assert_can_open(conn, *, active_count: Optional[int] = None) -> Optional[str]: + rs = get_risk_status(conn, active_count=active_count) + if not rs.get("can_trade"): + return rs.get("reason") or "当前不可开仓" + return None diff --git a/sl_tp_guard.py b/sl_tp_guard.py index 80ab22d..55a8fd6 100644 --- a/sl_tp_guard.py +++ b/sl_tp_guard.py @@ -1,807 +1,812 @@ -"""止盈止损守护:程序本地监控价位,触发后向 CTP 发平仓单(不向交易所挂 SL/TP 限价单)。""" -from __future__ import annotations - -import logging -import threading -import time -from datetime import datetime -from typing import Any, Callable, Optional -from zoneinfo import ZoneInfo - -from contract_specs import calc_position_metrics -from ctp_symbol import ths_to_vnpy_symbol -from fee_specs import calc_round_trip_fee -from trade_log_lib import calc_equity_after -from market_sessions import is_trading_session -from symbols import ths_to_codes -from vnpy_bridge import ( - ctp_cancel_order, - ctp_get_tick_price, - ctp_list_active_orders, - ctp_list_positions, - ctp_status, - execute_order, - get_bridge, -) - -logger = logging.getLogger(__name__) - -TZ = ZoneInfo("Asia/Shanghai") -CHECK_INTERVAL_SEC = 1 -CLOSED_MARKET_SLEEP_SEC = 30 -DISCONNECTED_SLEEP_SEC = 5 -PLACE_COOLDOWN_SEC = 3 - -_last_close_attempt: dict[int, float] = {} -_closing_monitors: set[int] = set() -_closing_lock = threading.Lock() - -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", - "ALTER TABLE trade_order_monitors ADD COLUMN trailing_be INTEGER DEFAULT 0", - "ALTER TABLE trade_order_monitors ADD COLUMN initial_stop_loss REAL", - "ALTER TABLE trade_order_monitors ADD COLUMN trailing_r_locked INTEGER DEFAULT 0", - "ALTER TABLE trade_order_monitors ADD COLUMN margin REAL", - "ALTER TABLE trade_order_monitors ADD COLUMN position_pct REAL", - "ALTER TABLE trade_order_monitors ADD COLUMN mark_price REAL", - "ALTER TABLE trade_order_monitors ADD COLUMN float_pnl REAL", - "ALTER TABLE trade_order_monitors ADD COLUMN vt_order_id TEXT", - "ALTER TABLE trade_order_monitors ADD COLUMN order_price REAL", -) - -TRADE_RESULTS = ("止损", "止盈", "移动止盈", "保本止盈", "手动平仓") - - -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: - from contract_specs import get_contract_spec - 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 - if a and b and a.split(".")[0] == b.split(".")[0]: - return True - try: - vnpy_sym, _ = ths_to_vnpy_symbol(ths) - if a == vnpy_sym.lower(): - return True - except Exception: - pass - try: - vnpy_sym, _ = ths_to_vnpy_symbol(ctp_sym) - if vnpy_sym.lower() == b.split(".")[0]: - return True - except Exception: - pass - 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 _can_close_now(monitor_id: int, *, cooldown: int = PLACE_COOLDOWN_SEC) -> bool: - last = _last_close_attempt.get(monitor_id, 0.0) - return (time.time() - last) >= cooldown - - -def _mark_close_attempt(monitor_id: int) -> None: - _last_close_attempt[monitor_id] = time.time() - - -def _try_acquire_close(monitor_id: int) -> bool: - with _closing_lock: - if monitor_id in _closing_monitors: - return False - _closing_monitors.add(monitor_id) - return True - - -def _release_close(monitor_id: int) -> None: - with _closing_lock: - _closing_monitors.discard(monitor_id) - - -def monitor_source_label(raw: str) -> str: - """持仓展示用来源文案。""" - mapping = { - "manual": "期货下单", - "trend": "趋势回调", - "roll": "顺势加仓", - "ctp_sync": "CTP 柜台", - } - key = (raw or "manual").strip().lower() - return mapping.get(key, raw or "期货下单") - - -def _result_for_close(mon: dict, reason: str) -> str: - """平仓结果:止损 / 止盈 / 移动止盈 / 保本止盈 / 手动平仓。""" - if reason == "manual": - return "手动平仓" - if reason == "take_profit": - return "止盈" - if not mon.get("trailing_be"): - return "止损" - locked = int(mon.get("trailing_r_locked") or 0) - if locked >= 2: - return "移动止盈" - if locked >= 1: - return "保本止盈" - return "止损" - - -def write_trade_log( - conn, - *, - symbol: str, - direction: str, - entry_price: float, - close_price: float, - lots: float, - result: str, - trading_mode: str, - stop_loss: Optional[float] = None, - take_profit: Optional[float] = None, - open_time: str = "", - symbol_name: str = "", - market_code: str = "", - sina_code: str = "", - monitor_type: str = "期货下单", - capital: float = 0.0, -) -> None: - """写入 trade_logs(程序平仓 / 手动平仓)。""" - sym = (symbol or "").strip() - direction = (direction or "long").strip().lower() - entry = float(entry_price or close_price) - sl = float(stop_loss) if stop_loss is not None else entry - tp = float(take_profit) if take_profit is not None else entry - close_time = datetime.now(TZ).strftime("%Y-%m-%dT%H:%M") - - if not sina_code or not market_code: - codes = ths_to_codes(sym) or {} - sina_code = sina_code or codes.get("sina_code") or "" - market_code = market_code or codes.get("market_code") or "" - if not symbol_name: - symbol_name = sym - - metrics = calc_position_metrics( - direction, entry, sl, tp, lots, close_price, capital, sym, - ) - pnl = metrics.get("float_pnl") or 0.0 - fee = calc_round_trip_fee( - sym, entry, close_price, lots, open_time, close_time, trading_mode=trading_mode, - ) - pnl_net = round(pnl - fee, 2) - margin_pct = metrics.get("position_pct") - equity_after = calc_equity_after(capital, pnl_net) - - try: - from app import holding_to_minutes - minutes = holding_to_minutes(open_time, close_time) - except Exception: - minutes = 0 - - conn.execute( - """INSERT INTO trade_logs - (symbol, symbol_name, market_code, sina_code, monitor_type, direction, - entry_price, stop_loss, take_profit, close_price, lots, margin, - margin_pct, holding_minutes, open_time, close_time, pnl, fee, pnl_net, - equity_after, result) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - sym, - symbol_name, - market_code, - sina_code, - monitor_type, - direction, - entry, - stop_loss if stop_loss is not None else sl, - take_profit if take_profit is not None else tp, - close_price, - lots, - metrics.get("margin"), - margin_pct, - minutes, - open_time, - close_time, - pnl, - fee, - pnl_net, - equity_after, - result if result in TRADE_RESULTS else "手动平仓", - ), - ) - try: - from stats_engine import refresh_stats_cache - refresh_stats_cache(conn, capital) - except Exception as exc: - logger.debug("stats refresh after close: %s", exc) - - -def _write_trade_log( - conn, - mon: dict, - *, - close_price: float, - reason: str, - trading_mode: str, - capital: float = 0.0, -) -> None: - sym = (mon.get("symbol") or "").strip() - sl_raw = mon.get("stop_loss") - tp_raw = mon.get("take_profit") - initial_sl = mon.get("initial_stop_loss") - write_trade_log( - conn, - symbol=sym, - direction=mon.get("direction") or "long", - entry_price=float(mon.get("entry_price") or close_price), - close_price=close_price, - lots=float(mon.get("lots") or 1), - result=_result_for_close(mon, reason), - trading_mode=trading_mode, - stop_loss=float(initial_sl) if initial_sl is not None else ( - float(sl_raw) if sl_raw is not None else None - ), - take_profit=float(tp_raw) if tp_raw is not None else None, - open_time=(mon.get("open_time") or "").strip(), - symbol_name=mon.get("symbol_name") or sym, - market_code=mon.get("market_code") or "", - monitor_type=monitor_source_label(mon.get("monitor_type") or ""), - capital=capital, - ) - - -def write_manual_close_trade_log( - conn, - mon: Optional[dict], - *, - symbol: str, - direction: str, - lots: float, - close_price: float, - entry_price: float, - trading_mode: str, - capital: float = 0.0, - stop_loss: Optional[float] = None, - take_profit: Optional[float] = None, - open_time: str = "", - symbol_name: str = "", - market_code: str = "", -) -> None: - """程序内点击平仓按钮 → 手动平仓。""" - if mon: - write_trade_log( - conn, - symbol=(mon.get("symbol") or symbol).strip(), - direction=mon.get("direction") or direction, - entry_price=float(mon.get("entry_price") or entry_price), - close_price=close_price, - lots=float(mon.get("lots") or lots), - result="手动平仓", - trading_mode=trading_mode, - stop_loss=float(mon["initial_stop_loss"]) if mon.get("initial_stop_loss") is not None else ( - float(mon["stop_loss"]) if mon.get("stop_loss") is not None else stop_loss - ), - take_profit=float(mon["take_profit"]) if mon.get("take_profit") is not None else take_profit, - open_time=(mon.get("open_time") or open_time).strip(), - symbol_name=mon.get("symbol_name") or symbol_name, - market_code=mon.get("market_code") or market_code, - monitor_type=monitor_source_label(mon.get("monitor_type") or ""), - capital=capital, - ) - return - write_trade_log( - conn, - symbol=symbol, - direction=direction, - entry_price=entry_price, - close_price=close_price, - lots=lots, - result="手动平仓", - trading_mode=trading_mode, - stop_loss=stop_loss, - take_profit=take_profit, - open_time=open_time, - symbol_name=symbol_name, - market_code=market_code, - capital=capital, - ) - - -def _update_trailing_stop_loss( - conn, - mon: dict, - mark: float, - *, - be_tick_mult: int, -) -> dict: - """达 1R 移保本(开仓±N跳),达 2R 移 1R,依次类推。""" - if not mon.get("trailing_be"): - return mon - entry = float(mon.get("entry_price") or 0) - initial_sl = mon.get("initial_stop_loss") - if initial_sl is None: - initial_sl = mon.get("stop_loss") - try: - initial_sl_f = float(initial_sl) if initial_sl is not None else None - except (TypeError, ValueError): - return mon - if not entry or initial_sl_f is None: - return mon - - direction = (mon.get("direction") or "long").strip().lower() - sym = (mon.get("symbol") or "").strip() - tick = _tick_size(sym) - r = abs(entry - initial_sl_f) - if r < tick * 0.5: - return mon - - profit_r = (mark - entry) / r if direction == "long" else (entry - mark) / r - if profit_r < 1.0: - return mon - - level = int(profit_r) - locked = int(mon.get("trailing_r_locked") or 0) - if level <= locked: - return mon - - if level == 1: - new_sl = entry + be_tick_mult * tick if direction == "long" else entry - be_tick_mult * tick - else: - new_sl = entry + (level - 1) * r if direction == "long" else entry - (level - 1) * r - new_sl = round(new_sl, 4) - - try: - current_sl = float(mon.get("stop_loss") or 0) - except (TypeError, ValueError): - current_sl = 0.0 - if direction == "long" and new_sl <= current_sl + tick * 0.01: - return mon - if direction == "short" and new_sl >= current_sl - tick * 0.01: - return mon - - mid = mon.get("id") - conn.execute( - "UPDATE trade_order_monitors SET stop_loss=?, trailing_r_locked=? WHERE id=?", - (new_sl, level, mid), - ) - conn.commit() - mon["stop_loss"] = new_sl - mon["trailing_r_locked"] = level - logger.info("移动保本 monitor=%s %dR 止损→%s", mid, level, new_sl) - return mon - - -def _sl_triggered(direction: str, sl: float, mark: float, tick: float) -> bool: - buf = max(tick * 0.01, 1e-9) - if direction == "long": - return mark <= sl + buf - return mark >= sl - buf - - -def _tp_triggered(direction: str, tp: float, mark: float, tick: float) -> bool: - buf = max(tick * 0.01, 1e-9) - if direction == "long": - return mark >= tp - buf - return mark <= tp + buf - - -def cancel_monitor_exit_orders( - conn, - mon: dict, - *, - mode: str, -) -> int: - """撤销该监控在交易所残留的旧版止盈止损平仓挂单。""" - ensure_monitor_order_columns(conn) - if not ctp_status(mode).get("connected"): - return 0 - sym = (mon.get("symbol") or "").strip() - direction = (mon.get("direction") or "long").strip().lower() - tick = _tick_size(sym) - active = ctp_list_active_orders(mode) - cancelled = 0 - seen: set[str] = set() - - def _try_cancel(vt_id: str) -> None: - nonlocal cancelled - oid = str(vt_id or "").strip() - if not oid or oid in seen: - return - seen.add(oid) - if ctp_cancel_order(mode, oid): - cancelled += 1 - - for kind, price_key in (("sl", "stop_loss"), ("tp", "take_profit")): - raw = mon.get(price_key) - try: - px = float(raw) if raw is not None else None - except (TypeError, ValueError): - px = None - stored = str(mon.get(f"{kind}_vt_order_id") or "") - if stored: - _try_cancel(stored) - if px is not None: - found = _find_close_order( - active, ths_code=sym, hold_direction=direction, price=px, tick=tick, - ) - if found: - _try_cancel(str(found.get("order_id") or "")) - - if cancelled: - conn.execute( - "UPDATE trade_order_monitors SET sl_vt_order_id=NULL, tp_vt_order_id=NULL WHERE id=?", - (mon["id"],), - ) - conn.commit() - return cancelled - - -def reconcile_monitors_without_position(conn, mode: str, *, grace_sec: int = 120) -> int: - """持仓已平时:关闭监控并撤销残留止盈止损挂单(新开仓 grace_sec 内不清理)。""" - if not ctp_status(mode).get("connected"): - return 0 - positions = ctp_list_positions(mode, refresh_if_empty=False, refresh_margin=False) - position_keys: set[tuple[str, str]] = set() - for p in positions: - if int(p.get("lots") or 0) <= 0: - continue - sym = (p.get("symbol") or "").lower() - direction = p.get("direction") or "long" - position_keys.add((sym, direction)) - - if not position_keys: - try: - acc = get_bridge().get_account() - margin_used = float(acc.get("balance") or 0) - float(acc.get("available") or 0) - if margin_used > 500: - return 0 - except Exception: - return 0 - - now_ts = time.time() - - def _monitor_within_grace(mon: dict) -> bool: - raw = (mon.get("open_time") or mon.get("created_at") or "").strip() - if not raw: - return True - for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"): - try: - dt = datetime.strptime(raw[:19], fmt) - if (now_ts - dt.timestamp()) <= grace_sec: - return True - except ValueError: - continue - return False - - closed = 0 - for r in conn.execute("SELECT * FROM trade_order_monitors WHERE status='active'").fetchall(): - mon = dict(r) - if _monitor_within_grace(mon): - continue - ms = mon.get("symbol") or "" - md = mon.get("direction") or "long" - matched = False - for ps, pd in position_keys: - if pd != md: - continue - if _match_symbol(ps, ms): - matched = True - break - if matched: - continue - try: - cancel_monitor_exit_orders(conn, mon, mode=mode) - except Exception as exc: - logger.warning("cancel exit orders monitor=%s: %s", mon.get("id"), exc) - conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mon["id"],)) - closed += 1 - if closed: - conn.commit() - return closed - - -def _execute_local_close( - conn, - mon: dict, - *, - mode: str, - mark: float, - reason: str, - capital: float = 0.0, - notify_fn: Callable[[str], None] | None = None, -) -> None: - sym = (mon.get("symbol") or "").strip() - direction = (mon.get("direction") or "long").strip().lower() - positions = ctp_list_positions(mode) - pos = _find_position(positions, sym, direction) - if not pos: - reconcile_monitors_without_position(conn, mode) - return - lots = int(pos.get("lots") or mon.get("lots") or 1) - offset = "close_long" if direction == "long" else "close_short" - cancel_monitor_exit_orders(conn, mon, mode=mode) - execute_order( - conn, - mode=mode, - offset=offset, - symbol=sym, - direction=direction, - lots=lots, - price=mark, - order_type="market", - ) - _write_trade_log( - conn, - mon, - close_price=mark, - reason=reason, - trading_mode=mode, - capital=capital, - ) - conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mon["id"],)) - conn.commit() - result_label = _result_for_close(mon, reason) - logger.info( - "止盈止损本地触发 monitor=%s result=%s %s %s %d手 @%s", - mon.get("id"), result_label, sym, direction, lots, mark, - ) - if notify_fn: - try: - notify_fn(f"{result_label} {sym} {direction} {lots}手 @{mark},已记入交易记录") - except Exception as exc: - logger.debug("SL/TP notify failed: %s", exc) - - -def check_monitors_locally( - conn, - mode: str, - *, - capital: float = 0.0, - notify_fn: Callable[[str], None] | None = None, - be_tick_mult: int = 2, -) -> int: - """扫描 active 监控,本地比对行情;触发止盈/止损(含跳空穿透)后立刻市价平仓并记交易记录。""" - ensure_monitor_order_columns(conn) - if not ctp_status(mode).get("connected"): - return 0 - if not is_trading_session(): - return 0 - reconcile_monitors_without_position(conn, mode) - conn.commit() - closed = 0 - rows = [dict(r) for r in conn.execute( - "SELECT * FROM trade_order_monitors WHERE status='active'" - ).fetchall()] - conn.commit() - for mon in rows: - mid = int(mon.get("id") or 0) - sym = (mon.get("symbol") or "").strip() - direction = (mon.get("direction") or "long").strip().lower() - - if mon.get("sl_vt_order_id") or mon.get("tp_vt_order_id"): - cancel_monitor_exit_orders(conn, mon, mode=mode) - - 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: - continue - - positions = ctp_list_positions(mode) - if not _find_position(positions, sym, direction): - continue - - mark = ctp_get_tick_price(mode, sym) - if mark is None or mark <= 0: - continue - - tick = _tick_size(sym) - if mon.get("trailing_be"): - mon = _update_trailing_stop_loss(conn, mon, mark, be_tick_mult=be_tick_mult) - try: - sl_f = float(mon["stop_loss"]) if mon.get("stop_loss") is not None else sl_f - except (TypeError, ValueError): - pass - - reason = None - if tp_f is not None and _tp_triggered(direction, tp_f, mark, tick): - reason = "take_profit" - elif sl_f is not None and _sl_triggered(direction, sl_f, mark, tick): - reason = "stop_loss" - - if not reason: - continue - if mid > 0 and not _can_close_now(mid): - continue - if mid > 0 and not _try_acquire_close(mid): - continue - try: - _execute_local_close( - conn, - mon, - mode=mode, - mark=mark, - reason=reason, - capital=capital, - notify_fn=notify_fn, - ) - if mid > 0: - _mark_close_attempt(mid) - closed += 1 - except Exception as exc: - logger.warning("SL/TP local close failed monitor=%s: %s", mid, exc) - finally: - if mid > 0: - _release_close(mid) - return closed - - -def place_monitor_exit_orders( - conn, - mon: dict, - *, - mode: str, - force: bool = False, -) -> dict[str, Any]: - """兼容旧 API:本地监控模式不再向交易所挂 SL/TP 单,仅清理旧挂单。""" - del force - ensure_monitor_order_columns(conn) - if not ctp_status(mode).get("connected"): - return {"ok": False, "error": "CTP 未连接", "placed": []} - cancelled = cancel_monitor_exit_orders(conn, mon, mode=mode) - msg = "程序本地监控中,不向交易所挂止盈止损单" - if cancelled: - msg += f";已撤销旧版柜台挂单 {cancelled} 笔" - return {"ok": True, "message": msg, "placed": [], "local_monitor": True} - - -def monitor_order_status( - mon: dict, - *, - mode: str, - ths_code: str, - direction: str, -) -> dict[str, bool]: - """返回本地监控状态(非交易所挂单状态)。""" - del mode, ths_code, direction - 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 - return { - "sl_order_active": sl_f is not None, - "tp_order_active": tp_f is not None, - "sl_monitoring": sl_f is not None, - "tp_monitoring": tp_f is not None, - "needs_sl_order": False, - "needs_tp_order": False, - } - - -def sync_all_sl_tp_orders(conn, mode: str) -> int: - """兼容旧 worker 入口:执行本地监控检查。""" - del mode - return 0 - - -def start_sl_tp_guard_worker( - *, - db_path: str, - get_mode_fn: Callable[[], str], - init_tables_fn: Callable | None = None, - get_capital_fn: Callable | None = None, - get_be_tick_buffer_fn: Callable[[], int] | None = None, - notify_fn: Callable[[str], None] | None = None, - interval: int = CHECK_INTERVAL_SEC, -) -> None: - from db_conn import connect_db - - def _loop() -> None: - time.sleep(20) - while True: - sleep_sec = max(1, interval) - try: - if not is_trading_session(): - time.sleep(CLOSED_MARKET_SLEEP_SEC) - continue - mode = get_mode_fn() - if not ctp_status(mode).get("connected"): - time.sleep(DISCONNECTED_SLEEP_SEC) - continue - conn = connect_db(db_path) - try: - if init_tables_fn: - init_tables_fn(conn) - has_monitors = conn.execute( - """SELECT COUNT(*) AS n FROM trade_order_monitors - WHERE status='active' - AND (stop_loss IS NOT NULL OR take_profit IS NOT NULL)""" - ).fetchone()["n"] - if not has_monitors: - sleep_sec = max(sleep_sec, 5) - else: - capital = 0.0 - if get_capital_fn: - try: - capital = float(get_capital_fn(conn) or 0) - except Exception: - capital = 0.0 - n = check_monitors_locally( - conn, - mode, - capital=capital, - notify_fn=notify_fn, - be_tick_mult=( - get_be_tick_buffer_fn() if get_be_tick_buffer_fn else 2 - ), - ) - if n: - logger.info("止盈止损本地监控: 触发平仓 %d 笔", n) - finally: - conn.close() - except Exception as exc: - logger.warning("sl_tp_guard worker: %s", exc) - time.sleep(sleep_sec) - - threading.Thread(target=_loop, daemon=True, name="sl-tp-guard").start() +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""止盈止损守护:程序本地监控价位,触发后向 CTP 发平仓单(不向交易所挂 SL/TP 限价单)。""" +from __future__ import annotations + +import logging +import threading +import time +from datetime import datetime +from typing import Any, Callable, Optional +from zoneinfo import ZoneInfo + +from contract_specs import calc_position_metrics +from ctp_symbol import ths_to_vnpy_symbol +from fee_specs import calc_round_trip_fee +from trade_log_lib import calc_equity_after +from market_sessions import is_trading_session +from symbols import ths_to_codes +from vnpy_bridge import ( + ctp_cancel_order, + ctp_get_tick_price, + ctp_list_active_orders, + ctp_list_positions, + ctp_status, + execute_order, + get_bridge, +) + +logger = logging.getLogger(__name__) + +TZ = ZoneInfo("Asia/Shanghai") +CHECK_INTERVAL_SEC = 1 +CLOSED_MARKET_SLEEP_SEC = 30 +DISCONNECTED_SLEEP_SEC = 5 +PLACE_COOLDOWN_SEC = 3 + +_last_close_attempt: dict[int, float] = {} +_closing_monitors: set[int] = set() +_closing_lock = threading.Lock() + +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", + "ALTER TABLE trade_order_monitors ADD COLUMN trailing_be INTEGER DEFAULT 0", + "ALTER TABLE trade_order_monitors ADD COLUMN initial_stop_loss REAL", + "ALTER TABLE trade_order_monitors ADD COLUMN trailing_r_locked INTEGER DEFAULT 0", + "ALTER TABLE trade_order_monitors ADD COLUMN margin REAL", + "ALTER TABLE trade_order_monitors ADD COLUMN position_pct REAL", + "ALTER TABLE trade_order_monitors ADD COLUMN mark_price REAL", + "ALTER TABLE trade_order_monitors ADD COLUMN float_pnl REAL", + "ALTER TABLE trade_order_monitors ADD COLUMN vt_order_id TEXT", + "ALTER TABLE trade_order_monitors ADD COLUMN order_price REAL", +) + +TRADE_RESULTS = ("止损", "止盈", "移动止盈", "保本止盈", "手动平仓") + + +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: + from contract_specs import get_contract_spec + 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 + if a and b and a.split(".")[0] == b.split(".")[0]: + return True + try: + vnpy_sym, _ = ths_to_vnpy_symbol(ths) + if a == vnpy_sym.lower(): + return True + except Exception: + pass + try: + vnpy_sym, _ = ths_to_vnpy_symbol(ctp_sym) + if vnpy_sym.lower() == b.split(".")[0]: + return True + except Exception: + pass + 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 _can_close_now(monitor_id: int, *, cooldown: int = PLACE_COOLDOWN_SEC) -> bool: + last = _last_close_attempt.get(monitor_id, 0.0) + return (time.time() - last) >= cooldown + + +def _mark_close_attempt(monitor_id: int) -> None: + _last_close_attempt[monitor_id] = time.time() + + +def _try_acquire_close(monitor_id: int) -> bool: + with _closing_lock: + if monitor_id in _closing_monitors: + return False + _closing_monitors.add(monitor_id) + return True + + +def _release_close(monitor_id: int) -> None: + with _closing_lock: + _closing_monitors.discard(monitor_id) + + +def monitor_source_label(raw: str) -> str: + """持仓展示用来源文案。""" + mapping = { + "manual": "期货下单", + "trend": "趋势回调", + "roll": "顺势加仓", + "ctp_sync": "CTP 柜台", + } + key = (raw or "manual").strip().lower() + return mapping.get(key, raw or "期货下单") + + +def _result_for_close(mon: dict, reason: str) -> str: + """平仓结果:止损 / 止盈 / 移动止盈 / 保本止盈 / 手动平仓。""" + if reason == "manual": + return "手动平仓" + if reason == "take_profit": + return "止盈" + if not mon.get("trailing_be"): + return "止损" + locked = int(mon.get("trailing_r_locked") or 0) + if locked >= 2: + return "移动止盈" + if locked >= 1: + return "保本止盈" + return "止损" + + +def write_trade_log( + conn, + *, + symbol: str, + direction: str, + entry_price: float, + close_price: float, + lots: float, + result: str, + trading_mode: str, + stop_loss: Optional[float] = None, + take_profit: Optional[float] = None, + open_time: str = "", + symbol_name: str = "", + market_code: str = "", + sina_code: str = "", + monitor_type: str = "期货下单", + capital: float = 0.0, +) -> None: + """写入 trade_logs(程序平仓 / 手动平仓)。""" + sym = (symbol or "").strip() + direction = (direction or "long").strip().lower() + entry = float(entry_price or close_price) + sl = float(stop_loss) if stop_loss is not None else entry + tp = float(take_profit) if take_profit is not None else entry + close_time = datetime.now(TZ).strftime("%Y-%m-%dT%H:%M") + + if not sina_code or not market_code: + codes = ths_to_codes(sym) or {} + sina_code = sina_code or codes.get("sina_code") or "" + market_code = market_code or codes.get("market_code") or "" + if not symbol_name: + symbol_name = sym + + metrics = calc_position_metrics( + direction, entry, sl, tp, lots, close_price, capital, sym, + ) + pnl = metrics.get("float_pnl") or 0.0 + fee = calc_round_trip_fee( + sym, entry, close_price, lots, open_time, close_time, trading_mode=trading_mode, + ) + pnl_net = round(pnl - fee, 2) + margin_pct = metrics.get("position_pct") + equity_after = calc_equity_after(capital, pnl_net) + + try: + from app import holding_to_minutes + minutes = holding_to_minutes(open_time, close_time) + except Exception: + minutes = 0 + + conn.execute( + """INSERT INTO trade_logs + (symbol, symbol_name, market_code, sina_code, monitor_type, direction, + entry_price, stop_loss, take_profit, close_price, lots, margin, + margin_pct, holding_minutes, open_time, close_time, pnl, fee, pnl_net, + equity_after, result) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + sym, + symbol_name, + market_code, + sina_code, + monitor_type, + direction, + entry, + stop_loss if stop_loss is not None else sl, + take_profit if take_profit is not None else tp, + close_price, + lots, + metrics.get("margin"), + margin_pct, + minutes, + open_time, + close_time, + pnl, + fee, + pnl_net, + equity_after, + result if result in TRADE_RESULTS else "手动平仓", + ), + ) + try: + from stats_engine import refresh_stats_cache + refresh_stats_cache(conn, capital) + except Exception as exc: + logger.debug("stats refresh after close: %s", exc) + + +def _write_trade_log( + conn, + mon: dict, + *, + close_price: float, + reason: str, + trading_mode: str, + capital: float = 0.0, +) -> None: + sym = (mon.get("symbol") or "").strip() + sl_raw = mon.get("stop_loss") + tp_raw = mon.get("take_profit") + initial_sl = mon.get("initial_stop_loss") + write_trade_log( + conn, + symbol=sym, + direction=mon.get("direction") or "long", + entry_price=float(mon.get("entry_price") or close_price), + close_price=close_price, + lots=float(mon.get("lots") or 1), + result=_result_for_close(mon, reason), + trading_mode=trading_mode, + stop_loss=float(initial_sl) if initial_sl is not None else ( + float(sl_raw) if sl_raw is not None else None + ), + take_profit=float(tp_raw) if tp_raw is not None else None, + open_time=(mon.get("open_time") or "").strip(), + symbol_name=mon.get("symbol_name") or sym, + market_code=mon.get("market_code") or "", + monitor_type=monitor_source_label(mon.get("monitor_type") or ""), + capital=capital, + ) + + +def write_manual_close_trade_log( + conn, + mon: Optional[dict], + *, + symbol: str, + direction: str, + lots: float, + close_price: float, + entry_price: float, + trading_mode: str, + capital: float = 0.0, + stop_loss: Optional[float] = None, + take_profit: Optional[float] = None, + open_time: str = "", + symbol_name: str = "", + market_code: str = "", +) -> None: + """程序内点击平仓按钮 → 手动平仓。""" + if mon: + write_trade_log( + conn, + symbol=(mon.get("symbol") or symbol).strip(), + direction=mon.get("direction") or direction, + entry_price=float(mon.get("entry_price") or entry_price), + close_price=close_price, + lots=float(mon.get("lots") or lots), + result="手动平仓", + trading_mode=trading_mode, + stop_loss=float(mon["initial_stop_loss"]) if mon.get("initial_stop_loss") is not None else ( + float(mon["stop_loss"]) if mon.get("stop_loss") is not None else stop_loss + ), + take_profit=float(mon["take_profit"]) if mon.get("take_profit") is not None else take_profit, + open_time=(mon.get("open_time") or open_time).strip(), + symbol_name=mon.get("symbol_name") or symbol_name, + market_code=mon.get("market_code") or market_code, + monitor_type=monitor_source_label(mon.get("monitor_type") or ""), + capital=capital, + ) + return + write_trade_log( + conn, + symbol=symbol, + direction=direction, + entry_price=entry_price, + close_price=close_price, + lots=lots, + result="手动平仓", + trading_mode=trading_mode, + stop_loss=stop_loss, + take_profit=take_profit, + open_time=open_time, + symbol_name=symbol_name, + market_code=market_code, + capital=capital, + ) + + +def _update_trailing_stop_loss( + conn, + mon: dict, + mark: float, + *, + be_tick_mult: int, +) -> dict: + """达 1R 移保本(开仓±N跳),达 2R 移 1R,依次类推。""" + if not mon.get("trailing_be"): + return mon + entry = float(mon.get("entry_price") or 0) + initial_sl = mon.get("initial_stop_loss") + if initial_sl is None: + initial_sl = mon.get("stop_loss") + try: + initial_sl_f = float(initial_sl) if initial_sl is not None else None + except (TypeError, ValueError): + return mon + if not entry or initial_sl_f is None: + return mon + + direction = (mon.get("direction") or "long").strip().lower() + sym = (mon.get("symbol") or "").strip() + tick = _tick_size(sym) + r = abs(entry - initial_sl_f) + if r < tick * 0.5: + return mon + + profit_r = (mark - entry) / r if direction == "long" else (entry - mark) / r + if profit_r < 1.0: + return mon + + level = int(profit_r) + locked = int(mon.get("trailing_r_locked") or 0) + if level <= locked: + return mon + + if level == 1: + new_sl = entry + be_tick_mult * tick if direction == "long" else entry - be_tick_mult * tick + else: + new_sl = entry + (level - 1) * r if direction == "long" else entry - (level - 1) * r + new_sl = round(new_sl, 4) + + try: + current_sl = float(mon.get("stop_loss") or 0) + except (TypeError, ValueError): + current_sl = 0.0 + if direction == "long" and new_sl <= current_sl + tick * 0.01: + return mon + if direction == "short" and new_sl >= current_sl - tick * 0.01: + return mon + + mid = mon.get("id") + conn.execute( + "UPDATE trade_order_monitors SET stop_loss=?, trailing_r_locked=? WHERE id=?", + (new_sl, level, mid), + ) + conn.commit() + mon["stop_loss"] = new_sl + mon["trailing_r_locked"] = level + logger.info("移动保本 monitor=%s %dR 止损→%s", mid, level, new_sl) + return mon + + +def _sl_triggered(direction: str, sl: float, mark: float, tick: float) -> bool: + buf = max(tick * 0.01, 1e-9) + if direction == "long": + return mark <= sl + buf + return mark >= sl - buf + + +def _tp_triggered(direction: str, tp: float, mark: float, tick: float) -> bool: + buf = max(tick * 0.01, 1e-9) + if direction == "long": + return mark >= tp - buf + return mark <= tp + buf + + +def cancel_monitor_exit_orders( + conn, + mon: dict, + *, + mode: str, +) -> int: + """撤销该监控在交易所残留的旧版止盈止损平仓挂单。""" + ensure_monitor_order_columns(conn) + if not ctp_status(mode).get("connected"): + return 0 + sym = (mon.get("symbol") or "").strip() + direction = (mon.get("direction") or "long").strip().lower() + tick = _tick_size(sym) + active = ctp_list_active_orders(mode) + cancelled = 0 + seen: set[str] = set() + + def _try_cancel(vt_id: str) -> None: + nonlocal cancelled + oid = str(vt_id or "").strip() + if not oid or oid in seen: + return + seen.add(oid) + if ctp_cancel_order(mode, oid): + cancelled += 1 + + for kind, price_key in (("sl", "stop_loss"), ("tp", "take_profit")): + raw = mon.get(price_key) + try: + px = float(raw) if raw is not None else None + except (TypeError, ValueError): + px = None + stored = str(mon.get(f"{kind}_vt_order_id") or "") + if stored: + _try_cancel(stored) + if px is not None: + found = _find_close_order( + active, ths_code=sym, hold_direction=direction, price=px, tick=tick, + ) + if found: + _try_cancel(str(found.get("order_id") or "")) + + if cancelled: + conn.execute( + "UPDATE trade_order_monitors SET sl_vt_order_id=NULL, tp_vt_order_id=NULL WHERE id=?", + (mon["id"],), + ) + conn.commit() + return cancelled + + +def reconcile_monitors_without_position(conn, mode: str, *, grace_sec: int = 120) -> int: + """持仓已平时:关闭监控并撤销残留止盈止损挂单(新开仓 grace_sec 内不清理)。""" + if not ctp_status(mode).get("connected"): + return 0 + positions = ctp_list_positions(mode, refresh_if_empty=False, refresh_margin=False) + position_keys: set[tuple[str, str]] = set() + for p in positions: + if int(p.get("lots") or 0) <= 0: + continue + sym = (p.get("symbol") or "").lower() + direction = p.get("direction") or "long" + position_keys.add((sym, direction)) + + if not position_keys: + try: + acc = get_bridge().get_account() + margin_used = float(acc.get("balance") or 0) - float(acc.get("available") or 0) + if margin_used > 500: + return 0 + except Exception: + return 0 + + now_ts = time.time() + + def _monitor_within_grace(mon: dict) -> bool: + raw = (mon.get("open_time") or mon.get("created_at") or "").strip() + if not raw: + return True + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"): + try: + dt = datetime.strptime(raw[:19], fmt) + if (now_ts - dt.timestamp()) <= grace_sec: + return True + except ValueError: + continue + return False + + closed = 0 + for r in conn.execute("SELECT * FROM trade_order_monitors WHERE status='active'").fetchall(): + mon = dict(r) + if _monitor_within_grace(mon): + continue + ms = mon.get("symbol") or "" + md = mon.get("direction") or "long" + matched = False + for ps, pd in position_keys: + if pd != md: + continue + if _match_symbol(ps, ms): + matched = True + break + if matched: + continue + try: + cancel_monitor_exit_orders(conn, mon, mode=mode) + except Exception as exc: + logger.warning("cancel exit orders monitor=%s: %s", mon.get("id"), exc) + conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mon["id"],)) + closed += 1 + if closed: + conn.commit() + return closed + + +def _execute_local_close( + conn, + mon: dict, + *, + mode: str, + mark: float, + reason: str, + capital: float = 0.0, + notify_fn: Callable[[str], None] | None = None, +) -> None: + sym = (mon.get("symbol") or "").strip() + direction = (mon.get("direction") or "long").strip().lower() + positions = ctp_list_positions(mode) + pos = _find_position(positions, sym, direction) + if not pos: + reconcile_monitors_without_position(conn, mode) + return + lots = int(pos.get("lots") or mon.get("lots") or 1) + offset = "close_long" if direction == "long" else "close_short" + cancel_monitor_exit_orders(conn, mon, mode=mode) + execute_order( + conn, + mode=mode, + offset=offset, + symbol=sym, + direction=direction, + lots=lots, + price=mark, + order_type="market", + ) + _write_trade_log( + conn, + mon, + close_price=mark, + reason=reason, + trading_mode=mode, + capital=capital, + ) + conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mon["id"],)) + conn.commit() + result_label = _result_for_close(mon, reason) + logger.info( + "止盈止损本地触发 monitor=%s result=%s %s %s %d手 @%s", + mon.get("id"), result_label, sym, direction, lots, mark, + ) + if notify_fn: + try: + notify_fn(f"{result_label} {sym} {direction} {lots}手 @{mark},已记入交易记录") + except Exception as exc: + logger.debug("SL/TP notify failed: %s", exc) + + +def check_monitors_locally( + conn, + mode: str, + *, + capital: float = 0.0, + notify_fn: Callable[[str], None] | None = None, + be_tick_mult: int = 2, +) -> int: + """扫描 active 监控,本地比对行情;触发止盈/止损(含跳空穿透)后立刻市价平仓并记交易记录。""" + ensure_monitor_order_columns(conn) + if not ctp_status(mode).get("connected"): + return 0 + if not is_trading_session(): + return 0 + reconcile_monitors_without_position(conn, mode) + conn.commit() + closed = 0 + rows = [dict(r) for r in conn.execute( + "SELECT * FROM trade_order_monitors WHERE status='active'" + ).fetchall()] + conn.commit() + for mon in rows: + mid = int(mon.get("id") or 0) + sym = (mon.get("symbol") or "").strip() + direction = (mon.get("direction") or "long").strip().lower() + + if mon.get("sl_vt_order_id") or mon.get("tp_vt_order_id"): + cancel_monitor_exit_orders(conn, mon, mode=mode) + + 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: + continue + + positions = ctp_list_positions(mode) + if not _find_position(positions, sym, direction): + continue + + mark = ctp_get_tick_price(mode, sym) + if mark is None or mark <= 0: + continue + + tick = _tick_size(sym) + if mon.get("trailing_be"): + mon = _update_trailing_stop_loss(conn, mon, mark, be_tick_mult=be_tick_mult) + try: + sl_f = float(mon["stop_loss"]) if mon.get("stop_loss") is not None else sl_f + except (TypeError, ValueError): + pass + + reason = None + if tp_f is not None and _tp_triggered(direction, tp_f, mark, tick): + reason = "take_profit" + elif sl_f is not None and _sl_triggered(direction, sl_f, mark, tick): + reason = "stop_loss" + + if not reason: + continue + if mid > 0 and not _can_close_now(mid): + continue + if mid > 0 and not _try_acquire_close(mid): + continue + try: + _execute_local_close( + conn, + mon, + mode=mode, + mark=mark, + reason=reason, + capital=capital, + notify_fn=notify_fn, + ) + if mid > 0: + _mark_close_attempt(mid) + closed += 1 + except Exception as exc: + logger.warning("SL/TP local close failed monitor=%s: %s", mid, exc) + finally: + if mid > 0: + _release_close(mid) + return closed + + +def place_monitor_exit_orders( + conn, + mon: dict, + *, + mode: str, + force: bool = False, +) -> dict[str, Any]: + """兼容旧 API:本地监控模式不再向交易所挂 SL/TP 单,仅清理旧挂单。""" + del force + ensure_monitor_order_columns(conn) + if not ctp_status(mode).get("connected"): + return {"ok": False, "error": "CTP 未连接", "placed": []} + cancelled = cancel_monitor_exit_orders(conn, mon, mode=mode) + msg = "程序本地监控中,不向交易所挂止盈止损单" + if cancelled: + msg += f";已撤销旧版柜台挂单 {cancelled} 笔" + return {"ok": True, "message": msg, "placed": [], "local_monitor": True} + + +def monitor_order_status( + mon: dict, + *, + mode: str, + ths_code: str, + direction: str, +) -> dict[str, bool]: + """返回本地监控状态(非交易所挂单状态)。""" + del mode, ths_code, direction + 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 + return { + "sl_order_active": sl_f is not None, + "tp_order_active": tp_f is not None, + "sl_monitoring": sl_f is not None, + "tp_monitoring": tp_f is not None, + "needs_sl_order": False, + "needs_tp_order": False, + } + + +def sync_all_sl_tp_orders(conn, mode: str) -> int: + """兼容旧 worker 入口:执行本地监控检查。""" + del mode + return 0 + + +def start_sl_tp_guard_worker( + *, + db_path: str, + get_mode_fn: Callable[[], str], + init_tables_fn: Callable | None = None, + get_capital_fn: Callable | None = None, + get_be_tick_buffer_fn: Callable[[], int] | None = None, + notify_fn: Callable[[str], None] | None = None, + interval: int = CHECK_INTERVAL_SEC, +) -> None: + from db_conn import connect_db + + def _loop() -> None: + time.sleep(20) + while True: + sleep_sec = max(1, interval) + try: + if not is_trading_session(): + time.sleep(CLOSED_MARKET_SLEEP_SEC) + continue + mode = get_mode_fn() + if not ctp_status(mode).get("connected"): + time.sleep(DISCONNECTED_SLEEP_SEC) + continue + conn = connect_db(db_path) + try: + if init_tables_fn: + init_tables_fn(conn) + has_monitors = conn.execute( + """SELECT COUNT(*) AS n FROM trade_order_monitors + WHERE status='active' + AND (stop_loss IS NOT NULL OR take_profit IS NOT NULL)""" + ).fetchone()["n"] + if not has_monitors: + sleep_sec = max(sleep_sec, 5) + else: + capital = 0.0 + if get_capital_fn: + try: + capital = float(get_capital_fn(conn) or 0) + except Exception: + capital = 0.0 + n = check_monitors_locally( + conn, + mode, + capital=capital, + notify_fn=notify_fn, + be_tick_mult=( + get_be_tick_buffer_fn() if get_be_tick_buffer_fn else 2 + ), + ) + if n: + logger.info("止盈止损本地监控: 触发平仓 %d 笔", n) + finally: + conn.close() + except Exception as exc: + logger.warning("sl_tp_guard worker: %s", exc) + time.sleep(sleep_sec) + + threading.Thread(target=_loop, daemon=True, name="sl-tp-guard").start() diff --git a/static/css/responsive.css b/static/css/responsive.css index fc01c27..59475b3 100644 --- a/static/css/responsive.css +++ b/static/css/responsive.css @@ -1,548 +1,549 @@ -/* 响应式布局 — 电脑 / 平板 / 手机 + PWA 独立窗口 */ - -:root { - --safe-top: env(safe-area-inset-top, 0px); - --safe-right: env(safe-area-inset-right, 0px); - --safe-bottom: env(safe-area-inset-bottom, 0px); - --safe-left: env(safe-area-inset-left, 0px); - --touch-min: 44px; -} - -html { - -webkit-text-size-adjust: 100%; - text-size-adjust: 100%; -} - -body { - padding-left: var(--safe-left); - padding-right: var(--safe-right); - padding-bottom: var(--safe-bottom); -} - -.page-wrap { - padding-top: var(--safe-top); -} - -.header-bar { - display: grid; - grid-template-columns: auto 1fr auto; - align-items: center; - gap: .5rem .75rem; - margin-bottom: .85rem; - min-height: var(--touch-min); -} - -.nav-toggle { - display: none; - width: var(--touch-min); - height: var(--touch-min); - border: 1px solid var(--toggle-border); - border-radius: 10px; - background: var(--toggle-bg); - cursor: pointer; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 5px; - padding: 0; - flex-shrink: 0; -} - -.nav-toggle span { - display: block; - width: 18px; - height: 2px; - background: var(--text-primary); - border-radius: 2px; - transition: transform .2s, opacity .2s; -} - -.nav-toggle[aria-expanded="true"] span:nth-child(1) { - transform: translateY(7px) rotate(45deg); -} - -.nav-toggle[aria-expanded="true"] span:nth-child(2) { - opacity: 0; -} - -.nav-toggle[aria-expanded="true"] span:nth-child(3) { - transform: translateY(-7px) rotate(-45deg); -} - -.header-tools { - position: static; - justify-content: flex-start; - flex-wrap: wrap; -} - -.user-bar { - position: static; - text-align: right; - justify-self: end; -} - -.pwa-install-btn { - padding: .38rem .7rem; - border-radius: 999px; - border: 1px solid var(--accent); - background: transparent; - color: var(--accent); - font-size: .72rem; - cursor: pointer; - white-space: nowrap; - width: auto; - flex-shrink: 0; - min-height: 32px; -} - -.pwa-install-btn:hover { - background: var(--dir-bg); -} - -.pwa-ios-hint { - display: none; - font-size: .72rem; - color: var(--text-muted); - padding: .5rem .75rem; - margin: 0 0 .75rem; - border-radius: 10px; - border: 1px dashed var(--card-border); - background: var(--card-inner); - line-height: 1.5; -} - -.pwa-ios-hint.show { - display: block; -} - -.nav-backdrop { - display: none; - position: fixed; - inset: 0; - background: var(--modal-mask); - z-index: 90; - border: none; - padding: 0; - cursor: pointer; -} - -.nav-backdrop.show { - display: block; -} - -@media (min-width: 1025px) { - .site-header { - padding: 1.5rem 1.5rem 1.25rem; - } - - .site-nav { - justify-content: center; - } - - .main { - padding: 1.5rem 1.75rem; - } -} - -@media (min-width: 768px) and (max-width: 1024px) { - .site-header { - padding: 1.25rem 1rem 1rem; - } - - .site-title { - font-size: 1.5rem; - margin-bottom: 1rem; - } - - .site-nav { - gap: .4rem; - justify-content: center; - } - - .site-nav a { - padding: .5rem .85rem; - font-size: .82rem; - } - - .main { - padding: 1.25rem 1rem; - } - - .card { - padding: 1.25rem; - } - - .split-grid { - grid-template-columns: 1fr; - } - - .trade-split .card { - min-height: auto; - } - - .trade-form-line.line-3 { - grid-template-columns: 1fr 1fr; - } - - .trade-form-line.line-3 .trade-field:first-child { - grid-column: 1 / -1; - } - - .form-compact .line-4, - .form-compact .line-5 { - grid-template-columns: repeat(2, 1fr); - } - - .form-compact .line-plan-2 { - grid-template-columns: repeat(2, 1fr); - } - - .pos-metrics { - grid-template-columns: repeat(2, 1fr); - } - - .review-detail-grid { - grid-template-columns: repeat(2, 1fr); - } - - .stat-grid-summary { - display: flex; - flex-wrap: nowrap; - overflow-x: auto; - } -} - -@media (max-width: 767px) { - .nav-toggle { - display: inline-flex; - } - - .header-bar { - grid-template-columns: auto 1fr; - grid-template-areas: - "toggle tools" - "user user"; - } - - .nav-toggle { grid-area: toggle; } - .header-tools { grid-area: tools; justify-content: flex-end; } - .user-bar { - grid-area: user; - text-align: center; - width: 100%; - } - - .site-header { - padding: .85rem .75rem .75rem; - text-align: left; - } - - .site-title { - font-size: 1.15rem; - margin-bottom: .65rem; - text-align: center; - } - - .site-title-sub { - font-size: .58rem; - letter-spacing: .14em; - } - - .site-nav { - position: fixed; - top: 0; - left: 0; - width: min(86vw, 320px); - height: 100dvh; - flex-direction: column; - align-items: stretch; - justify-content: flex-start; - gap: .35rem; - padding: calc(var(--safe-top) + 3.5rem) 1rem 1.5rem; - background: var(--card-bg); - border-right: 1px solid var(--card-border); - box-shadow: var(--shadow-card-hover); - z-index: 100; - transform: translateX(-105%); - transition: transform .28s ease; - overflow-y: auto; - -webkit-overflow-scrolling: touch; - } - - .site-nav.open { - transform: translateX(0); - } - - .site-nav a { - width: 100%; - text-align: left; - padding: .75rem 1rem; - font-size: .9rem; - min-height: var(--touch-min); - display: flex; - align-items: center; - border-radius: 10px; - } - - .main { - padding: .85rem .75rem 1.25rem; - } - - .card { - padding: 1rem; - border-radius: 12px; - margin-bottom: 1rem; - } - - .card h2 { - font-size: 1rem; - } - - .form-compact .line-2, - .form-compact .line-3, - .form-compact .line-4, - .form-compact .line-5, - .form-compact .line-plan-1, - .form-compact .line-plan-2 { - grid-template-columns: 1fr; - } - - .form-compact-review .tag-grid { - grid-template-columns: repeat(2, 1fr); - } - - .form-compact-review .kline-row { - grid-template-columns: 1fr 1fr; - } - - .form-grid { - grid-template-columns: 1fr; - } - - .split-grid { - grid-template-columns: 1fr; - gap: 1rem; - } - - .split-grid .card { - min-height: auto; - } - - .trade-split .card { - min-height: auto; - } - - .trade-top-bar { - flex-direction: column; - align-items: stretch; - } - - .trade-top-bar-actions { - width: 100%; - } - - .trade-top-bar-actions .btn-ctp-sm { - width: 100%; - min-height: var(--touch-min); - } - - .pos-metrics { - grid-template-columns: repeat(2, 1fr); - } - - .review-detail-grid { - grid-template-columns: 1fr; - } - - .review-detail-item.wide { - grid-column: span 1; - } - - .stat-grid, - .stat-grid:not(.stat-grid-summary) { - grid-template-columns: repeat(2, 1fr); - gap: .65rem; - } - - .stat-grid-summary { - display: flex; - flex-wrap: nowrap; - overflow-x: auto; - } - - .stat-grid-summary .stat-item { - flex: 0 0 auto; - min-width: 4.25rem; - padding: .35rem .15rem; - } - - .stat-grid-summary .stat-item .label { - font-size: .58rem; - } - - .stat-grid-summary .stat-item .value { - font-size: .72rem; - } - - .stat-item { - padding: .75rem .5rem; - } - - .stat-grid-summary .stat-item { - padding: .35rem .15rem; - } - - .stat-item .value { - font-size: 1.1rem; - } - - .filter-row .field { - width: 100%; - min-width: 0; - } - - .trade-toolbar { - flex-direction: column; - align-items: stretch; - } - - .profile-row { - grid-template-columns: 1fr; - gap: .25rem; - } - - .modal-box { - padding: 1rem; - border-radius: 12px; - max-height: calc(100dvh - 1rem); - } - - .modal-box.review-modal-fullscreen { - width: 100%; - height: 100dvh; - border-radius: 0; - } - - th, td { - padding: .55rem .45rem; - font-size: .8rem; - } - - .card-scroll { - max-height: none; - } - - .stats-card-head { - flex-direction: column; - align-items: stretch; - } - - .stats-view-field { - width: 100%; - } - - input, select, textarea, button { - font-size: 16px; - } - - .form-compact input, - .form-compact select { - font-size: 16px; - } -} - -@media (max-width: 479px) { - .stat-grid:not(.stat-grid-summary) { - grid-template-columns: 1fr 1fr; - } - - .stat-grid-summary .stat-item { - min-width: 3.75rem; - } - - .theme-switch-btn { - padding: .35rem .55rem; - font-size: .7rem; - } - - .pos-metrics { - grid-template-columns: 1fr; - } -} - -@media (display-mode: standalone) { - .site-header { - padding-top: max(.75rem, var(--safe-top)); - } - - .pwa-install-btn, - .pwa-ios-hint { - display: none !important; - } -} - -@media (max-width: 767px) and (orientation: landscape) { - .site-nav { - width: min(50vw, 280px); - padding-top: calc(var(--safe-top) + 2.5rem); - } -} - -@media (hover: none) and (pointer: coarse) { - .site-nav a, - .btn-del, - .trade-actions a, - .trade-actions button, - .preset-tabs a { - min-height: var(--touch-min); - } - - .card:hover { - transform: none; - } - - .list-item:hover { - box-shadow: none; - } - - .stat-item:hover { - transform: none; - } -} - -.table-responsive { - width: 100%; - overflow-x: auto; - -webkit-overflow-scrolling: touch; -} - -.table-responsive table { - min-width: 560px; -} - -body.login-page { - display: flex; - align-items: center; - justify-content: center; - min-height: 100vh; - min-height: 100dvh; - padding: 1rem; - padding-top: max(1rem, var(--safe-top)); - padding-bottom: max(1rem, var(--safe-bottom)); -} - -@media (max-width: 767px) { - body.login-page { - align-items: flex-start; - padding: .75rem; - padding-top: max(.75rem, var(--safe-top)); - } - - body.login-page .login-wrap { - max-width: 100%; - } - - body.login-page .login-box { - padding: 1.75rem 1.25rem 1.5rem; - } -} +/* Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt */ +/* 响应式布局 — 电脑 / 平板 / 手机 + PWA 独立窗口 */ + +:root { + --safe-top: env(safe-area-inset-top, 0px); + --safe-right: env(safe-area-inset-right, 0px); + --safe-bottom: env(safe-area-inset-bottom, 0px); + --safe-left: env(safe-area-inset-left, 0px); + --touch-min: 44px; +} + +html { + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +body { + padding-left: var(--safe-left); + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); +} + +.page-wrap { + padding-top: var(--safe-top); +} + +.header-bar { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: .5rem .75rem; + margin-bottom: .85rem; + min-height: var(--touch-min); +} + +.nav-toggle { + display: none; + width: var(--touch-min); + height: var(--touch-min); + border: 1px solid var(--toggle-border); + border-radius: 10px; + background: var(--toggle-bg); + cursor: pointer; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + padding: 0; + flex-shrink: 0; +} + +.nav-toggle span { + display: block; + width: 18px; + height: 2px; + background: var(--text-primary); + border-radius: 2px; + transition: transform .2s, opacity .2s; +} + +.nav-toggle[aria-expanded="true"] span:nth-child(1) { + transform: translateY(7px) rotate(45deg); +} + +.nav-toggle[aria-expanded="true"] span:nth-child(2) { + opacity: 0; +} + +.nav-toggle[aria-expanded="true"] span:nth-child(3) { + transform: translateY(-7px) rotate(-45deg); +} + +.header-tools { + position: static; + justify-content: flex-start; + flex-wrap: wrap; +} + +.user-bar { + position: static; + text-align: right; + justify-self: end; +} + +.pwa-install-btn { + padding: .38rem .7rem; + border-radius: 999px; + border: 1px solid var(--accent); + background: transparent; + color: var(--accent); + font-size: .72rem; + cursor: pointer; + white-space: nowrap; + width: auto; + flex-shrink: 0; + min-height: 32px; +} + +.pwa-install-btn:hover { + background: var(--dir-bg); +} + +.pwa-ios-hint { + display: none; + font-size: .72rem; + color: var(--text-muted); + padding: .5rem .75rem; + margin: 0 0 .75rem; + border-radius: 10px; + border: 1px dashed var(--card-border); + background: var(--card-inner); + line-height: 1.5; +} + +.pwa-ios-hint.show { + display: block; +} + +.nav-backdrop { + display: none; + position: fixed; + inset: 0; + background: var(--modal-mask); + z-index: 90; + border: none; + padding: 0; + cursor: pointer; +} + +.nav-backdrop.show { + display: block; +} + +@media (min-width: 1025px) { + .site-header { + padding: 1.5rem 1.5rem 1.25rem; + } + + .site-nav { + justify-content: center; + } + + .main { + padding: 1.5rem 1.75rem; + } +} + +@media (min-width: 768px) and (max-width: 1024px) { + .site-header { + padding: 1.25rem 1rem 1rem; + } + + .site-title { + font-size: 1.5rem; + margin-bottom: 1rem; + } + + .site-nav { + gap: .4rem; + justify-content: center; + } + + .site-nav a { + padding: .5rem .85rem; + font-size: .82rem; + } + + .main { + padding: 1.25rem 1rem; + } + + .card { + padding: 1.25rem; + } + + .split-grid { + grid-template-columns: 1fr; + } + + .trade-split .card { + min-height: auto; + } + + .trade-form-line.line-3 { + grid-template-columns: 1fr 1fr; + } + + .trade-form-line.line-3 .trade-field:first-child { + grid-column: 1 / -1; + } + + .form-compact .line-4, + .form-compact .line-5 { + grid-template-columns: repeat(2, 1fr); + } + + .form-compact .line-plan-2 { + grid-template-columns: repeat(2, 1fr); + } + + .pos-metrics { + grid-template-columns: repeat(2, 1fr); + } + + .review-detail-grid { + grid-template-columns: repeat(2, 1fr); + } + + .stat-grid-summary { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + } +} + +@media (max-width: 767px) { + .nav-toggle { + display: inline-flex; + } + + .header-bar { + grid-template-columns: auto 1fr; + grid-template-areas: + "toggle tools" + "user user"; + } + + .nav-toggle { grid-area: toggle; } + .header-tools { grid-area: tools; justify-content: flex-end; } + .user-bar { + grid-area: user; + text-align: center; + width: 100%; + } + + .site-header { + padding: .85rem .75rem .75rem; + text-align: left; + } + + .site-title { + font-size: 1.15rem; + margin-bottom: .65rem; + text-align: center; + } + + .site-title-sub { + font-size: .58rem; + letter-spacing: .14em; + } + + .site-nav { + position: fixed; + top: 0; + left: 0; + width: min(86vw, 320px); + height: 100dvh; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + gap: .35rem; + padding: calc(var(--safe-top) + 3.5rem) 1rem 1.5rem; + background: var(--card-bg); + border-right: 1px solid var(--card-border); + box-shadow: var(--shadow-card-hover); + z-index: 100; + transform: translateX(-105%); + transition: transform .28s ease; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + } + + .site-nav.open { + transform: translateX(0); + } + + .site-nav a { + width: 100%; + text-align: left; + padding: .75rem 1rem; + font-size: .9rem; + min-height: var(--touch-min); + display: flex; + align-items: center; + border-radius: 10px; + } + + .main { + padding: .85rem .75rem 1.25rem; + } + + .card { + padding: 1rem; + border-radius: 12px; + margin-bottom: 1rem; + } + + .card h2 { + font-size: 1rem; + } + + .form-compact .line-2, + .form-compact .line-3, + .form-compact .line-4, + .form-compact .line-5, + .form-compact .line-plan-1, + .form-compact .line-plan-2 { + grid-template-columns: 1fr; + } + + .form-compact-review .tag-grid { + grid-template-columns: repeat(2, 1fr); + } + + .form-compact-review .kline-row { + grid-template-columns: 1fr 1fr; + } + + .form-grid { + grid-template-columns: 1fr; + } + + .split-grid { + grid-template-columns: 1fr; + gap: 1rem; + } + + .split-grid .card { + min-height: auto; + } + + .trade-split .card { + min-height: auto; + } + + .trade-top-bar { + flex-direction: column; + align-items: stretch; + } + + .trade-top-bar-actions { + width: 100%; + } + + .trade-top-bar-actions .btn-ctp-sm { + width: 100%; + min-height: var(--touch-min); + } + + .pos-metrics { + grid-template-columns: repeat(2, 1fr); + } + + .review-detail-grid { + grid-template-columns: 1fr; + } + + .review-detail-item.wide { + grid-column: span 1; + } + + .stat-grid, + .stat-grid:not(.stat-grid-summary) { + grid-template-columns: repeat(2, 1fr); + gap: .65rem; + } + + .stat-grid-summary { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + } + + .stat-grid-summary .stat-item { + flex: 0 0 auto; + min-width: 4.25rem; + padding: .35rem .15rem; + } + + .stat-grid-summary .stat-item .label { + font-size: .58rem; + } + + .stat-grid-summary .stat-item .value { + font-size: .72rem; + } + + .stat-item { + padding: .75rem .5rem; + } + + .stat-grid-summary .stat-item { + padding: .35rem .15rem; + } + + .stat-item .value { + font-size: 1.1rem; + } + + .filter-row .field { + width: 100%; + min-width: 0; + } + + .trade-toolbar { + flex-direction: column; + align-items: stretch; + } + + .profile-row { + grid-template-columns: 1fr; + gap: .25rem; + } + + .modal-box { + padding: 1rem; + border-radius: 12px; + max-height: calc(100dvh - 1rem); + } + + .modal-box.review-modal-fullscreen { + width: 100%; + height: 100dvh; + border-radius: 0; + } + + th, td { + padding: .55rem .45rem; + font-size: .8rem; + } + + .card-scroll { + max-height: none; + } + + .stats-card-head { + flex-direction: column; + align-items: stretch; + } + + .stats-view-field { + width: 100%; + } + + input, select, textarea, button { + font-size: 16px; + } + + .form-compact input, + .form-compact select { + font-size: 16px; + } +} + +@media (max-width: 479px) { + .stat-grid:not(.stat-grid-summary) { + grid-template-columns: 1fr 1fr; + } + + .stat-grid-summary .stat-item { + min-width: 3.75rem; + } + + .theme-switch-btn { + padding: .35rem .55rem; + font-size: .7rem; + } + + .pos-metrics { + grid-template-columns: 1fr; + } +} + +@media (display-mode: standalone) { + .site-header { + padding-top: max(.75rem, var(--safe-top)); + } + + .pwa-install-btn, + .pwa-ios-hint { + display: none !important; + } +} + +@media (max-width: 767px) and (orientation: landscape) { + .site-nav { + width: min(50vw, 280px); + padding-top: calc(var(--safe-top) + 2.5rem); + } +} + +@media (hover: none) and (pointer: coarse) { + .site-nav a, + .btn-del, + .trade-actions a, + .trade-actions button, + .preset-tabs a { + min-height: var(--touch-min); + } + + .card:hover { + transform: none; + } + + .list-item:hover { + box-shadow: none; + } + + .stat-item:hover { + transform: none; + } +} + +.table-responsive { + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.table-responsive table { + min-width: 560px; +} + +body.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + min-height: 100dvh; + padding: 1rem; + padding-top: max(1rem, var(--safe-top)); + padding-bottom: max(1rem, var(--safe-bottom)); +} + +@media (max-width: 767px) { + body.login-page { + align-items: flex-start; + padding: .75rem; + padding-top: max(.75rem, var(--safe-top)); + } + + body.login-page .login-wrap { + max-width: 100%; + } + + body.login-page .login-box { + padding: 1.75rem 1.25rem 1.5rem; + } +} diff --git a/static/css/tech.css b/static/css/tech.css index c439bec..f0333f1 100644 --- a/static/css/tech.css +++ b/static/css/tech.css @@ -1,204 +1,205 @@ -/* 科技感增强层 — 与 base.html 变量配合 */ - -.tech-bg{ - position:fixed;inset:0;z-index:0;pointer-events:none;overflow:hidden; -} -.tech-grid{ - position:absolute;inset:0; - background-image: - linear-gradient(var(--bg-grid) 1px,transparent 1px), - linear-gradient(90deg,var(--bg-grid) 1px,transparent 1px); - background-size:32px 32px; - mask-image:radial-gradient(ellipse 85% 75% at 50% 35%,#000 20%,transparent 75%); -} -.tech-glow{ - position:absolute;width:70vmax;height:70vmax; - top:-25%;left:50%;transform:translateX(-50%); - background:radial-gradient(circle,var(--ambient-glow) 0%,transparent 65%); - animation:tech-pulse 8s ease-in-out infinite; -} -.tech-glow-2{ - position:absolute;width:50vmax;height:50vmax; - bottom:-20%;right:-10%; - background:radial-gradient(circle,var(--ambient-glow-2) 0%,transparent 70%); - animation:tech-pulse 10s ease-in-out infinite reverse; -} -.tech-scanline{ - position:absolute;inset:0; - background:repeating-linear-gradient( - 0deg, - transparent, - transparent 2px, - var(--scanline) 2px, - var(--scanline) 3px - ); - opacity:.35; - animation:tech-scan 12s linear infinite; -} -@keyframes tech-pulse{ - 0%,100%{opacity:.55;transform:translateX(-50%) scale(1)} - 50%{opacity:.85;transform:translateX(-50%) scale(1.05)} -} -@keyframes tech-scan{ - 0%{transform:translateY(0)} - 100%{transform:translateY(32px)} -} -@keyframes tech-shine{ - 0%,100%{opacity:.45} - 50%{opacity:.9} -} - -@media (prefers-reduced-motion: reduce){ - .tech-glow,.tech-glow-2,.tech-scanline,.card::after,.site-header::after{animation:none} -} - -.page-wrap{position:relative;z-index:1} - -.site-header{ - border-bottom:1px solid var(--border-header); - background:transparent; - backdrop-filter:none; -} -.site-header::after{ - content:"";display:block;height:1px;margin-top:-1px; - background:linear-gradient(90deg,transparent,var(--accent),var(--accent-2),transparent); - opacity:.7;animation:tech-shine 4s ease-in-out infinite; -} -.site-title{ - letter-spacing:.04em; - background:linear-gradient(135deg,var(--text-title) 0%,var(--accent) 45%,var(--accent-2) 100%); - -webkit-background-clip:text;-webkit-text-fill-color:transparent; - background-clip:text; - filter:drop-shadow(0 0 24px var(--title-glow)); -} -.site-title-sub{ - display:block;font-size:.72rem;font-weight:500; - letter-spacing:.22em;text-transform:uppercase; - color:var(--text-muted);margin-top:.35rem; - -webkit-text-fill-color:var(--text-muted); -} - -.site-nav a{ - border-radius:999px; - letter-spacing:.02em; - position:relative;overflow:hidden; - transition:transform .2s,box-shadow .2s,border-color .2s,background .2s; -} -.site-nav a::before{ - content:"";position:absolute;inset:0; - background:linear-gradient(120deg,transparent,rgba(255,255,255,.06),transparent); - opacity:0;transition:opacity .25s; -} -.site-nav a:hover::before{opacity:1} -.site-nav a:hover{ - transform:translateY(-1px); - box-shadow:0 4px 20px var(--nav-hover-glow); -} -.site-nav a.active{ - background:linear-gradient(135deg,var(--nav-active),var(--accent-2)); - border-color:transparent; - box-shadow:0 0 20px var(--nav-active-glow),inset 0 1px 0 rgba(255,255,255,.15); -} - -.theme-switch-btn:hover{ - color:var(--text-primary); -} -.theme-switch-btn.active{ - box-shadow:0 0 12px var(--btn-glow); -} - -.card{ - border-radius:14px; - transition:transform .25s,box-shadow .25s,border-color .25s; -} -.card:hover{ - transform:translateY(-2px); - border-color:var(--card-border-hover); - box-shadow:var(--shadow-card-hover); -} -.card::after{ - animation:tech-shine 5s ease-in-out infinite; -} -.card h2{letter-spacing:.03em} -.card h2:before{ - box-shadow:0 0 12px var(--accent),0 0 4px var(--accent-2); -} - -input:focus,select:focus,textarea:focus{ - box-shadow:0 0 0 3px var(--focus-ring),0 0 16px var(--focus-glow); -} -button.btn-primary{ - font-weight:600;letter-spacing:.04em; - box-shadow:0 4px 20px var(--btn-glow); - transition:transform .15s,box-shadow .2s,opacity .2s; -} -button.btn-primary:hover{ - transform:translateY(-1px); - box-shadow:0 6px 28px var(--btn-glow-strong); - opacity:1; -} - -.list-item{ - transition:border-color .2s,box-shadow .2s,transform .2s; -} -.list-item:hover{ - border-color:var(--card-border-hover); - box-shadow:0 4px 16px var(--card-glow); -} -table tbody tr{transition:background .15s} -table tbody tr:hover{background:var(--row-hover)} - -.stat-item{ - backdrop-filter:blur(8px); - transition:transform .2s,box-shadow .2s; -} -.stat-item:hover{ - transform:translateY(-2px); - box-shadow:0 8px 24px var(--card-glow); -} -.stat-item .value{ - font-variant-numeric:tabular-nums; - letter-spacing:.02em; -} - -.pos-card{ - position:relative;overflow:hidden; - transition:border-color .2s,box-shadow .2s; -} -.pos-card::before{ - content:"";position:absolute;top:0;left:0;right:0;height:2px; - background:linear-gradient(90deg,var(--accent),var(--accent-2)); - opacity:.5; -} -.pos-card:hover{ - border-color:var(--card-border-hover); - box-shadow:0 6px 24px var(--card-glow); -} - -.badge{letter-spacing:.02em;border:1px solid transparent} -.badge.dir{border-color:rgba(76,194,255,.25)} -.badge.profit{border-color:rgba(76,217,127,.3)} -.badge.loss{border-color:rgba(255,102,102,.3)} - -.modal-box{ - border:1px solid var(--card-border-hover); - box-shadow:var(--shadow-card-hover),0 0 60px var(--card-glow); -} - -.flash{ - box-shadow:0 0 24px var(--focus-glow); - letter-spacing:.02em; -} - -.profile-spec{ - border:1px solid var(--card-border-hover); - box-shadow:inset 0 0 40px var(--card-glow); -} - -.key-live .live-price-line,.live-price{ - text-shadow:0 0 12px var(--focus-glow); -} - -.preset-tabs a.active{ - box-shadow:0 0 12px var(--focus-glow); -} +/* Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt */ +/* 科技感增强层 — 与 base.html 变量配合 */ + +.tech-bg{ + position:fixed;inset:0;z-index:0;pointer-events:none;overflow:hidden; +} +.tech-grid{ + position:absolute;inset:0; + background-image: + linear-gradient(var(--bg-grid) 1px,transparent 1px), + linear-gradient(90deg,var(--bg-grid) 1px,transparent 1px); + background-size:32px 32px; + mask-image:radial-gradient(ellipse 85% 75% at 50% 35%,#000 20%,transparent 75%); +} +.tech-glow{ + position:absolute;width:70vmax;height:70vmax; + top:-25%;left:50%;transform:translateX(-50%); + background:radial-gradient(circle,var(--ambient-glow) 0%,transparent 65%); + animation:tech-pulse 8s ease-in-out infinite; +} +.tech-glow-2{ + position:absolute;width:50vmax;height:50vmax; + bottom:-20%;right:-10%; + background:radial-gradient(circle,var(--ambient-glow-2) 0%,transparent 70%); + animation:tech-pulse 10s ease-in-out infinite reverse; +} +.tech-scanline{ + position:absolute;inset:0; + background:repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + var(--scanline) 2px, + var(--scanline) 3px + ); + opacity:.35; + animation:tech-scan 12s linear infinite; +} +@keyframes tech-pulse{ + 0%,100%{opacity:.55;transform:translateX(-50%) scale(1)} + 50%{opacity:.85;transform:translateX(-50%) scale(1.05)} +} +@keyframes tech-scan{ + 0%{transform:translateY(0)} + 100%{transform:translateY(32px)} +} +@keyframes tech-shine{ + 0%,100%{opacity:.45} + 50%{opacity:.9} +} + +@media (prefers-reduced-motion: reduce){ + .tech-glow,.tech-glow-2,.tech-scanline,.card::after,.site-header::after{animation:none} +} + +.page-wrap{position:relative;z-index:1} + +.site-header{ + border-bottom:1px solid var(--border-header); + background:transparent; + backdrop-filter:none; +} +.site-header::after{ + content:"";display:block;height:1px;margin-top:-1px; + background:linear-gradient(90deg,transparent,var(--accent),var(--accent-2),transparent); + opacity:.7;animation:tech-shine 4s ease-in-out infinite; +} +.site-title{ + letter-spacing:.04em; + background:linear-gradient(135deg,var(--text-title) 0%,var(--accent) 45%,var(--accent-2) 100%); + -webkit-background-clip:text;-webkit-text-fill-color:transparent; + background-clip:text; + filter:drop-shadow(0 0 24px var(--title-glow)); +} +.site-title-sub{ + display:block;font-size:.72rem;font-weight:500; + letter-spacing:.22em;text-transform:uppercase; + color:var(--text-muted);margin-top:.35rem; + -webkit-text-fill-color:var(--text-muted); +} + +.site-nav a{ + border-radius:999px; + letter-spacing:.02em; + position:relative;overflow:hidden; + transition:transform .2s,box-shadow .2s,border-color .2s,background .2s; +} +.site-nav a::before{ + content:"";position:absolute;inset:0; + background:linear-gradient(120deg,transparent,rgba(255,255,255,.06),transparent); + opacity:0;transition:opacity .25s; +} +.site-nav a:hover::before{opacity:1} +.site-nav a:hover{ + transform:translateY(-1px); + box-shadow:0 4px 20px var(--nav-hover-glow); +} +.site-nav a.active{ + background:linear-gradient(135deg,var(--nav-active),var(--accent-2)); + border-color:transparent; + box-shadow:0 0 20px var(--nav-active-glow),inset 0 1px 0 rgba(255,255,255,.15); +} + +.theme-switch-btn:hover{ + color:var(--text-primary); +} +.theme-switch-btn.active{ + box-shadow:0 0 12px var(--btn-glow); +} + +.card{ + border-radius:14px; + transition:transform .25s,box-shadow .25s,border-color .25s; +} +.card:hover{ + transform:translateY(-2px); + border-color:var(--card-border-hover); + box-shadow:var(--shadow-card-hover); +} +.card::after{ + animation:tech-shine 5s ease-in-out infinite; +} +.card h2{letter-spacing:.03em} +.card h2:before{ + box-shadow:0 0 12px var(--accent),0 0 4px var(--accent-2); +} + +input:focus,select:focus,textarea:focus{ + box-shadow:0 0 0 3px var(--focus-ring),0 0 16px var(--focus-glow); +} +button.btn-primary{ + font-weight:600;letter-spacing:.04em; + box-shadow:0 4px 20px var(--btn-glow); + transition:transform .15s,box-shadow .2s,opacity .2s; +} +button.btn-primary:hover{ + transform:translateY(-1px); + box-shadow:0 6px 28px var(--btn-glow-strong); + opacity:1; +} + +.list-item{ + transition:border-color .2s,box-shadow .2s,transform .2s; +} +.list-item:hover{ + border-color:var(--card-border-hover); + box-shadow:0 4px 16px var(--card-glow); +} +table tbody tr{transition:background .15s} +table tbody tr:hover{background:var(--row-hover)} + +.stat-item{ + backdrop-filter:blur(8px); + transition:transform .2s,box-shadow .2s; +} +.stat-item:hover{ + transform:translateY(-2px); + box-shadow:0 8px 24px var(--card-glow); +} +.stat-item .value{ + font-variant-numeric:tabular-nums; + letter-spacing:.02em; +} + +.pos-card{ + position:relative;overflow:hidden; + transition:border-color .2s,box-shadow .2s; +} +.pos-card::before{ + content:"";position:absolute;top:0;left:0;right:0;height:2px; + background:linear-gradient(90deg,var(--accent),var(--accent-2)); + opacity:.5; +} +.pos-card:hover{ + border-color:var(--card-border-hover); + box-shadow:0 6px 24px var(--card-glow); +} + +.badge{letter-spacing:.02em;border:1px solid transparent} +.badge.dir{border-color:rgba(76,194,255,.25)} +.badge.profit{border-color:rgba(76,217,127,.3)} +.badge.loss{border-color:rgba(255,102,102,.3)} + +.modal-box{ + border:1px solid var(--card-border-hover); + box-shadow:var(--shadow-card-hover),0 0 60px var(--card-glow); +} + +.flash{ + box-shadow:0 0 24px var(--focus-glow); + letter-spacing:.02em; +} + +.profile-spec{ + border:1px solid var(--card-border-hover); + box-shadow:inset 0 0 40px var(--card-glow); +} + +.key-live .live-price-line,.live-price{ + text-shadow:0 0 12px var(--focus-glow); +} + +.preset-tabs a.active{ + box-shadow:0 0 12px var(--focus-glow); +} diff --git a/static/css/trade.css b/static/css/trade.css index 2e5b115..da67f57 100644 --- a/static/css/trade.css +++ b/static/css/trade.css @@ -1,106 +1,107 @@ -/* 持仓监控页 — 与 split-grid(关键位监控)同宽,全端自适应 */ -.trade-page{width:100%} -.trade-split{margin-bottom:1.25rem} -.trade-split .card{min-height:480px} -.trade-top-bar{ - display:flex;flex-wrap:wrap;gap:.65rem 1rem; - align-items:center;justify-content:space-between; - margin-bottom:1.25rem; -} -.trade-top-bar-main{display:flex;flex-wrap:wrap;gap:.5rem .65rem;align-items:center;flex:1;min-width:0} -.trade-top-bar-actions{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center} -.trade-top-hint{font-size:.72rem;white-space:nowrap} -.btn-ctp-sm{padding:.4rem .9rem;font-size:.8rem;width:auto;white-space:nowrap} -.trade-card{margin-bottom:0;height:100%;display:flex;flex-direction:column} -.trade-card h2{margin-bottom:.35rem;flex-shrink:0} -.trade-card .card-body{flex:1;min-height:0;display:flex;flex-direction:column} -.trade-card-full{margin-bottom:1.5rem} -.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-form-rows{display:flex;flex-direction:column;gap:.75rem;margin-bottom:.85rem} -.trade-form-line{display:grid;gap:.65rem;align-items:end} -.trade-form-line.line-3{grid-template-columns:1.4fr 0.8fr 0.8fr} -.trade-field label{display:block;font-size:.72rem;margin-bottom:.28rem;color:var(--text-label)} -.trade-field select,.trade-field input{width:100%;box-sizing:border-box} -.trade-field .lots-auto{color:var(--accent);font-weight:600;background:var(--card-inner);cursor:default} -.lots-warn{font-size:.7rem;margin-top:.25rem;margin-bottom:0} -.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:.28rem .7rem;border-radius:6px;font-size:.75rem;cursor:pointer;flex:1;text-align:center;width:auto} -.price-tab.active{border-color:var(--accent);color:var(--accent);font-weight:600;background:rgba(56,189,248,.08)} -.market-hint{font-size:.7rem;margin-top:.25rem} -.trade-action-row{display:flex;flex-direction:column;gap:.45rem;margin:.85rem 0 .55rem} -.trade-action-row .btn-open{padding:.65rem .75rem;font-size:.9rem;width:100%} -.trade-action-row .btn-open:disabled{opacity:.45;cursor:not-allowed;filter:grayscale(.25)} -.trade-action-row .btn-open.btn-session-off{background:var(--text-muted);border-color:var(--text-muted)} -.trailing-be-toggle{display:flex;align-items:center;gap:.4rem;font-size:.78rem;color:var(--text-label);margin-bottom:.45rem;cursor:pointer;user-select:none} -.trailing-be-toggle input{width:auto;margin:0} -.trade-rr-hint{font-size:.78rem;color:var(--text-accent);margin:0} -.session-hint{font-size:.72rem;margin:.35rem 0 0;text-align:center} -.trade-order-msg{font-size:.82rem;text-align:center;margin:0;padding:.35rem} -.trade-order-msg.ok{color:var(--profit)} -.trade-order-msg.err{color:var(--loss)} -.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} -.rec-trend-break td:first-child .trend-name{font-weight:700} -.trend-badge{font-size:.72rem;white-space:nowrap} -.trend-badge.break{color:var(--accent);font-weight:700;border:1px solid var(--accent);background:rgba(56,189,248,.12)} -.trend-hint{font-size:.72rem;color:var(--text-muted);margin:.35rem 0 .65rem;line-height:1.5} -.rec-sort-bar{display:flex;flex-wrap:wrap;align-items:center;gap:.45rem .65rem;margin-bottom:.55rem;font-size:.78rem} -.rec-sort-bar label{color:var(--text-muted);white-space:nowrap} -.rec-sort-bar select{padding:.35rem .5rem;font-size:.78rem;min-width:7rem} -.rec-stats{ - font-size:.78rem;color:var(--text-muted);margin-bottom:.45rem;line-height:1.5; -} -.rec-stats strong{color:var(--accent);font-weight:600} -.rec-sort-dir-btn{ - border:1px solid var(--card-border);background:var(--card-inner);color:var(--text-muted); - padding:.3rem .55rem;border-radius:6px;cursor:pointer;font-size:.78rem;min-width:2rem; -} -.rec-sort-dir-btn:hover{border-color:var(--accent);color:var(--accent)} -.gap-badge{font-size:.72rem} -.rec-change-up{color:var(--profit)} -.rec-change-down{color:var(--loss)} -#recommend .trade-table-wrap{max-height:min(70vh,520px)} -#positions .card-body.card-scroll{flex:1;max-height:none;overflow-y:auto} -.pos-pending-orders{margin-top:.55rem;padding-top:.55rem;border-top:1px dashed var(--table-border)} -.pos-pending-orders .pending-title{font-size:.68rem;color:var(--text-muted);margin-bottom:.35rem} -.pos-pending-item{display:flex;justify-content:space-between;align-items:center;gap:.5rem;font-size:.75rem;padding:.35rem .5rem;border-radius:6px;margin-bottom:.25rem;background:var(--list-item-bg)} -.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-card.is-pending{border:1px dashed var(--accent);opacity:.95} -.pos-card.is-pending .badge.pending{background:rgba(56,189,248,.15);color:var(--accent)} -.pos-card.is-pending .pos-metrics .cell.pnl-pending label{color: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,.pos-close-btn.is-session-off{opacity:.45;cursor:not-allowed;border-color:var(--text-muted);background:var(--card-inner);color:var(--text-muted)} -.pos-dismiss-btn:disabled,.pos-dismiss-btn.is-session-off{opacity:.45;cursor:not-allowed;color:var(--text-muted)} -.pos-card-meta-line{font-size:.78rem;line-height:1.65;color:var(--text-muted);margin-bottom:.55rem} -.pos-card-meta-line strong{color:var(--text)} -.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} - .trade-form-line.line-3{grid-template-columns:1fr 1fr} - .trade-form-line.line-3 .trade-field:first-child{grid-column:1/-1} -} - -@media (max-width:767px){ - .trade-top-bar{flex-direction:column;align-items:stretch} - .trade-top-bar-actions{width:100%} - .btn-ctp-sm{width:100%;min-height:44px} - .trade-split .card{min-height:auto} - .trade-form-line.line-3{grid-template-columns:1fr} - .trade-card-full{margin-bottom:1rem} - .trade-table-wrap{max-height:320px} -} +/* Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt */ +/* 持仓监控页 — 与 split-grid(关键位监控)同宽,全端自适应 */ +.trade-page{width:100%} +.trade-split{margin-bottom:1.25rem} +.trade-split .card{min-height:480px} +.trade-top-bar{ + display:flex;flex-wrap:wrap;gap:.65rem 1rem; + align-items:center;justify-content:space-between; + margin-bottom:1.25rem; +} +.trade-top-bar-main{display:flex;flex-wrap:wrap;gap:.5rem .65rem;align-items:center;flex:1;min-width:0} +.trade-top-bar-actions{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center} +.trade-top-hint{font-size:.72rem;white-space:nowrap} +.btn-ctp-sm{padding:.4rem .9rem;font-size:.8rem;width:auto;white-space:nowrap} +.trade-card{margin-bottom:0;height:100%;display:flex;flex-direction:column} +.trade-card h2{margin-bottom:.35rem;flex-shrink:0} +.trade-card .card-body{flex:1;min-height:0;display:flex;flex-direction:column} +.trade-card-full{margin-bottom:1.5rem} +.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-form-rows{display:flex;flex-direction:column;gap:.75rem;margin-bottom:.85rem} +.trade-form-line{display:grid;gap:.65rem;align-items:end} +.trade-form-line.line-3{grid-template-columns:1.4fr 0.8fr 0.8fr} +.trade-field label{display:block;font-size:.72rem;margin-bottom:.28rem;color:var(--text-label)} +.trade-field select,.trade-field input{width:100%;box-sizing:border-box} +.trade-field .lots-auto{color:var(--accent);font-weight:600;background:var(--card-inner);cursor:default} +.lots-warn{font-size:.7rem;margin-top:.25rem;margin-bottom:0} +.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:.28rem .7rem;border-radius:6px;font-size:.75rem;cursor:pointer;flex:1;text-align:center;width:auto} +.price-tab.active{border-color:var(--accent);color:var(--accent);font-weight:600;background:rgba(56,189,248,.08)} +.market-hint{font-size:.7rem;margin-top:.25rem} +.trade-action-row{display:flex;flex-direction:column;gap:.45rem;margin:.85rem 0 .55rem} +.trade-action-row .btn-open{padding:.65rem .75rem;font-size:.9rem;width:100%} +.trade-action-row .btn-open:disabled{opacity:.45;cursor:not-allowed;filter:grayscale(.25)} +.trade-action-row .btn-open.btn-session-off{background:var(--text-muted);border-color:var(--text-muted)} +.trailing-be-toggle{display:flex;align-items:center;gap:.4rem;font-size:.78rem;color:var(--text-label);margin-bottom:.45rem;cursor:pointer;user-select:none} +.trailing-be-toggle input{width:auto;margin:0} +.trade-rr-hint{font-size:.78rem;color:var(--text-accent);margin:0} +.session-hint{font-size:.72rem;margin:.35rem 0 0;text-align:center} +.trade-order-msg{font-size:.82rem;text-align:center;margin:0;padding:.35rem} +.trade-order-msg.ok{color:var(--profit)} +.trade-order-msg.err{color:var(--loss)} +.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} +.rec-trend-break td:first-child .trend-name{font-weight:700} +.trend-badge{font-size:.72rem;white-space:nowrap} +.trend-badge.break{color:var(--accent);font-weight:700;border:1px solid var(--accent);background:rgba(56,189,248,.12)} +.trend-hint{font-size:.72rem;color:var(--text-muted);margin:.35rem 0 .65rem;line-height:1.5} +.rec-sort-bar{display:flex;flex-wrap:wrap;align-items:center;gap:.45rem .65rem;margin-bottom:.55rem;font-size:.78rem} +.rec-sort-bar label{color:var(--text-muted);white-space:nowrap} +.rec-sort-bar select{padding:.35rem .5rem;font-size:.78rem;min-width:7rem} +.rec-stats{ + font-size:.78rem;color:var(--text-muted);margin-bottom:.45rem;line-height:1.5; +} +.rec-stats strong{color:var(--accent);font-weight:600} +.rec-sort-dir-btn{ + border:1px solid var(--card-border);background:var(--card-inner);color:var(--text-muted); + padding:.3rem .55rem;border-radius:6px;cursor:pointer;font-size:.78rem;min-width:2rem; +} +.rec-sort-dir-btn:hover{border-color:var(--accent);color:var(--accent)} +.gap-badge{font-size:.72rem} +.rec-change-up{color:var(--profit)} +.rec-change-down{color:var(--loss)} +#recommend .trade-table-wrap{max-height:min(70vh,520px)} +#positions .card-body.card-scroll{flex:1;max-height:none;overflow-y:auto} +.pos-pending-orders{margin-top:.55rem;padding-top:.55rem;border-top:1px dashed var(--table-border)} +.pos-pending-orders .pending-title{font-size:.68rem;color:var(--text-muted);margin-bottom:.35rem} +.pos-pending-item{display:flex;justify-content:space-between;align-items:center;gap:.5rem;font-size:.75rem;padding:.35rem .5rem;border-radius:6px;margin-bottom:.25rem;background:var(--list-item-bg)} +.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-card.is-pending{border:1px dashed var(--accent);opacity:.95} +.pos-card.is-pending .badge.pending{background:rgba(56,189,248,.15);color:var(--accent)} +.pos-card.is-pending .pos-metrics .cell.pnl-pending label{color: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,.pos-close-btn.is-session-off{opacity:.45;cursor:not-allowed;border-color:var(--text-muted);background:var(--card-inner);color:var(--text-muted)} +.pos-dismiss-btn:disabled,.pos-dismiss-btn.is-session-off{opacity:.45;cursor:not-allowed;color:var(--text-muted)} +.pos-card-meta-line{font-size:.78rem;line-height:1.65;color:var(--text-muted);margin-bottom:.55rem} +.pos-card-meta-line strong{color:var(--text)} +.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} + .trade-form-line.line-3{grid-template-columns:1fr 1fr} + .trade-form-line.line-3 .trade-field:first-child{grid-column:1/-1} +} + +@media (max-width:767px){ + .trade-top-bar{flex-direction:column;align-items:stretch} + .trade-top-bar-actions{width:100%} + .btn-ctp-sm{width:100%;min-height:44px} + .trade-split .card{min-height:auto} + .trade-form-line.line-3{grid-template-columns:1fr} + .trade-card-full{margin-bottom:1rem} + .trade-table-wrap{max-height:320px} +} diff --git a/static/js/contract.js b/static/js/contract.js index bc2ae49..b448643 100644 --- a/static/js/contract.js +++ b/static/js/contract.js @@ -1,23 +1,27 @@ -(function () { - var form = document.getElementById('contract-search-form'); - if (!form) return; - - var wrap = form.querySelector('.symbol-wrap'); - var hidden = wrap && wrap.querySelector('input[name="symbol"]'); - var visible = form.querySelector('#contract-symbol-input'); - - // 带 symbol 参数进入时,显示合约代码 - if (hidden && hidden.value && visible && !visible.value) { - visible.value = hidden.value; - } - - form.addEventListener('submit', function () { - if (!hidden || !visible) return; - var v = visible.value.trim(); - // 若未从下拉选择,尝试用输入框内容(支持直接输入 rb2510) - if (!hidden.value && v) { - var m = v.match(/([A-Za-z]+\d{3,4})/); - hidden.value = m ? m[1] : v; - } - }); -})(); +/* Copyright (c) 2025-2026 马建军. All rights reserved. + * 专有软件 — 未经授权禁止复制、传播、转售。 + * 详见 LICENSE.zh-CN.txt + */ +(function () { + var form = document.getElementById('contract-search-form'); + if (!form) return; + + var wrap = form.querySelector('.symbol-wrap'); + var hidden = wrap && wrap.querySelector('input[name="symbol"]'); + var visible = form.querySelector('#contract-symbol-input'); + + // 带 symbol 参数进入时,显示合约代码 + if (hidden && hidden.value && visible && !visible.value) { + visible.value = hidden.value; + } + + form.addEventListener('submit', function () { + if (!hidden || !visible) return; + var v = visible.value.trim(); + // 若未从下拉选择,尝试用输入框内容(支持直接输入 rb2510) + if (!hidden.value && v) { + var m = v.match(/([A-Za-z]+\d{3,4})/); + hidden.value = m ? m[1] : v; + } + }); +})(); diff --git a/static/js/equity_curve.js b/static/js/equity_curve.js index fcd5ee8..a8dd947 100644 --- a/static/js/equity_curve.js +++ b/static/js/equity_curve.js @@ -1,123 +1,127 @@ -(function () { - var el = document.getElementById('equity-curve-chart'); - var raw = window.__EQUITY_CURVE__; - if (!el || !raw || !raw.length || !window.LightweightCharts) return; - - var chart = null; - var series = null; - var chartData = []; - - function cssVar(name, fallback) { - var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); - return v || fallback; - } - - function themeColors() { - return { - bg: 'transparent', - text: cssVar('--text-muted', '#7a82a0'), - grid: cssVar('--table-border', 'rgba(76,194,255,.1)'), - border: cssVar('--card-border', 'rgba(76,194,255,.22)'), - line: cssVar('--accent', '#4cc2ff'), - }; - } - - function parseTime(s) { - if (!s) return null; - var t = String(s).trim().replace(' ', 'T'); - if (t.length === 16) t += ':00'; - var d = new Date(t); - if (isNaN(d.getTime())) return null; - return Math.floor(d.getTime() / 1000); - } - - function buildData() { - var data = []; - var lastTs = 0; - raw.forEach(function (p) { - var ts = parseTime(p.time); - if (ts == null) return; - if (ts <= lastTs) ts = lastTs + 1; - lastTs = ts; - data.push({ time: ts, value: Number(p.value) }); - }); - return data; - } - - function applyChartTheme() { - if (!chart || !series) return; - var c = themeColors(); - chart.applyOptions({ - layout: { - background: { type: 'solid', color: c.bg }, - textColor: c.text, - }, - grid: { - vertLines: { color: c.grid }, - horzLines: { color: c.grid }, - }, - rightPriceScale: { borderColor: c.border }, - timeScale: { borderColor: c.border }, - }); - series.applyOptions({ color: c.line }); - } - - function renderChart() { - chartData = buildData(); - if (!chartData.length) { - el.innerHTML = '

暂无资金曲线数据

'; - return; - } - - var c = themeColors(); - if (chart) { - chart.remove(); - chart = null; - series = null; - } - - chart = LightweightCharts.createChart(el, { - width: el.clientWidth || 800, - height: 220, - layout: { - background: { type: 'solid', color: c.bg }, - textColor: c.text, - fontSize: 11, - }, - grid: { - vertLines: { color: c.grid }, - horzLines: { color: c.grid }, - }, - rightPriceScale: { borderColor: c.border }, - timeScale: { borderColor: c.border, timeVisible: true, secondsVisible: false }, - }); - series = chart.addLineSeries({ - color: c.line, - lineWidth: 2, - priceFormat: { type: 'price', precision: 2, minMove: 0.01 }, - }); - series.setData(chartData); - chart.timeScale().fitContent(); - } - - renderChart(); - - window.addEventListener('resize', function () { - if (chart) chart.applyOptions({ width: el.clientWidth || 800 }); - }); - - document.addEventListener('click', function (e) { - if (e.target.closest('[data-theme-pick]')) { - setTimeout(applyChartTheme, 50); - } - }); - - if (typeof MutationObserver !== 'undefined') { - var obs = new MutationObserver(function (mutations) { - mutations.forEach(function (m) { - if (m.attributeName === 'data-theme') applyChartTheme(); - }); - }); - obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); - } -})(); +/* Copyright (c) 2025-2026 马建军. All rights reserved. + * 专有软件 — 未经授权禁止复制、传播、转售。 + * 详见 LICENSE.zh-CN.txt + */ +(function () { + var el = document.getElementById('equity-curve-chart'); + var raw = window.__EQUITY_CURVE__; + if (!el || !raw || !raw.length || !window.LightweightCharts) return; + + var chart = null; + var series = null; + var chartData = []; + + function cssVar(name, fallback) { + var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return v || fallback; + } + + function themeColors() { + return { + bg: 'transparent', + text: cssVar('--text-muted', '#7a82a0'), + grid: cssVar('--table-border', 'rgba(76,194,255,.1)'), + border: cssVar('--card-border', 'rgba(76,194,255,.22)'), + line: cssVar('--accent', '#4cc2ff'), + }; + } + + function parseTime(s) { + if (!s) return null; + var t = String(s).trim().replace(' ', 'T'); + if (t.length === 16) t += ':00'; + var d = new Date(t); + if (isNaN(d.getTime())) return null; + return Math.floor(d.getTime() / 1000); + } + + function buildData() { + var data = []; + var lastTs = 0; + raw.forEach(function (p) { + var ts = parseTime(p.time); + if (ts == null) return; + if (ts <= lastTs) ts = lastTs + 1; + lastTs = ts; + data.push({ time: ts, value: Number(p.value) }); + }); + return data; + } + + function applyChartTheme() { + if (!chart || !series) return; + var c = themeColors(); + chart.applyOptions({ + layout: { + background: { type: 'solid', color: c.bg }, + textColor: c.text, + }, + grid: { + vertLines: { color: c.grid }, + horzLines: { color: c.grid }, + }, + rightPriceScale: { borderColor: c.border }, + timeScale: { borderColor: c.border }, + }); + series.applyOptions({ color: c.line }); + } + + function renderChart() { + chartData = buildData(); + if (!chartData.length) { + el.innerHTML = '

暂无资金曲线数据

'; + return; + } + + var c = themeColors(); + if (chart) { + chart.remove(); + chart = null; + series = null; + } + + chart = LightweightCharts.createChart(el, { + width: el.clientWidth || 800, + height: 220, + layout: { + background: { type: 'solid', color: c.bg }, + textColor: c.text, + fontSize: 11, + }, + grid: { + vertLines: { color: c.grid }, + horzLines: { color: c.grid }, + }, + rightPriceScale: { borderColor: c.border }, + timeScale: { borderColor: c.border, timeVisible: true, secondsVisible: false }, + }); + series = chart.addLineSeries({ + color: c.line, + lineWidth: 2, + priceFormat: { type: 'price', precision: 2, minMove: 0.01 }, + }); + series.setData(chartData); + chart.timeScale().fitContent(); + } + + renderChart(); + + window.addEventListener('resize', function () { + if (chart) chart.applyOptions({ width: el.clientWidth || 800 }); + }); + + document.addEventListener('click', function (e) { + if (e.target.closest('[data-theme-pick]')) { + setTimeout(applyChartTheme, 50); + } + }); + + if (typeof MutationObserver !== 'undefined') { + var obs = new MutationObserver(function (mutations) { + mutations.forEach(function (m) { + if (m.attributeName === 'data-theme') applyChartTheme(); + }); + }); + obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + } +})(); diff --git a/static/js/keys.js b/static/js/keys.js index 8671edf..c99bf87 100644 --- a/static/js/keys.js +++ b/static/js/keys.js @@ -1,34 +1,38 @@ -(function () { - var keyTimer = null; - - function fmtDist(v) { - if (v === null || v === undefined) return '--'; - return Number(v).toFixed(2); - } - - function pollKeyPrices() { - var list = document.getElementById('key-monitor-list'); - if (!list || !list.querySelector('.key-item')) return; - - fetch('/api/key_prices') - .then(function (r) { return r.json(); }) - .then(function (rows) { - rows.forEach(function (row) { - var el = list.querySelector('.key-item[data-key-id="' + row.id + '"]'); - if (!el) return; - var priceEl = el.querySelector('.live-price'); - var upEl = el.querySelector('.dist-up'); - var downEl = el.querySelector('.dist-down'); - if (priceEl) priceEl.textContent = row.price != null ? row.price : '--'; - if (upEl) upEl.textContent = fmtDist(row.dist_upper); - if (downEl) downEl.textContent = fmtDist(row.dist_lower); - }); - }) - .catch(function () { /* ignore */ }); - } - - document.addEventListener('DOMContentLoaded', function () { - pollKeyPrices(); - keyTimer = setInterval(pollKeyPrices, 1000); - }); -})(); +/* Copyright (c) 2025-2026 马建军. All rights reserved. + * 专有软件 — 未经授权禁止复制、传播、转售。 + * 详见 LICENSE.zh-CN.txt + */ +(function () { + var keyTimer = null; + + function fmtDist(v) { + if (v === null || v === undefined) return '--'; + return Number(v).toFixed(2); + } + + function pollKeyPrices() { + var list = document.getElementById('key-monitor-list'); + if (!list || !list.querySelector('.key-item')) return; + + fetch('/api/key_prices') + .then(function (r) { return r.json(); }) + .then(function (rows) { + rows.forEach(function (row) { + var el = list.querySelector('.key-item[data-key-id="' + row.id + '"]'); + if (!el) return; + var priceEl = el.querySelector('.live-price'); + var upEl = el.querySelector('.dist-up'); + var downEl = el.querySelector('.dist-down'); + if (priceEl) priceEl.textContent = row.price != null ? row.price : '--'; + if (upEl) upEl.textContent = fmtDist(row.dist_upper); + if (downEl) downEl.textContent = fmtDist(row.dist_lower); + }); + }) + .catch(function () { /* ignore */ }); + } + + document.addEventListener('DOMContentLoaded', function () { + pollKeyPrices(); + keyTimer = setInterval(pollKeyPrices, 1000); + }); +})(); diff --git a/static/js/market.js b/static/js/market.js index 58d2fa8..6f42943 100644 --- a/static/js/market.js +++ b/static/js/market.js @@ -1,656 +1,660 @@ -(function () { - var chartEl = document.getElementById('market-chart'); - var emptyEl = document.getElementById('market-chart-empty'); - var wrapEl = document.getElementById('market-chart-wrap'); - var chart = null; - var candleSeries = null; - var volumeSeries = null; - var areaSeries = null; - var ma21Series = null; - var ma55Series = null; - var prevCloseLine = null; - var resizeObs = null; - var currentPeriod = '15m'; - var currentChartMode = ''; - var klineSource = null; - var streamActive = false; - var reconnectTimer = null; - var lastData = null; - var lastPrevClose = null; - var chartOpts = { prevClose: false, ma: false, gapDay: false }; - var followingLatest = true; - var DEFAULT_VISIBLE_BARS = 80; - - var PERIOD_SECONDS = { - timeshare: 60, - '1m': 60, - '2m': 120, - '5m': 300, - '15m': 900, - '1h': 3600, - '2h': 7200, - '4h': 14400, - d: 86400, - w: 604800, - }; - - function getSymbol() { - var hidden = document.getElementById('market-symbol-hidden'); - var input = document.getElementById('market-symbol-input'); - if (hidden && hidden.value) return hidden.value.trim(); - if (input && input.value) return input.value.trim(); - return ''; - } - - function getMarketCodes() { - return { - symbol: getSymbol(), - market_code: (document.getElementById('market-market-code') || {}).value || '', - sina_code: (document.getElementById('market-sina-code') || {}).value || '', - }; - } - - function themeColors() { - var dark = document.documentElement.getAttribute('data-theme') !== 'light'; - return { - bg: dark ? '#0a0c14' : '#ffffff', - text: dark ? '#a8b0c8' : '#5c6578', - grid: dark ? '#1e2640' : '#e8edf5', - up: dark ? '#26a69a' : '#089981', - down: dark ? '#ef5350' : '#f23645', - line: dark ? '#4cc2ff' : '#2962ff', - areaTop: dark ? 'rgba(76,194,255,0.28)' : 'rgba(41,98,255,0.22)', - ma21: dark ? '#ffb347' : '#f7931a', - ma55: dark ? '#c084fc' : '#7c3aed', - prevClose: dark ? '#fbbf24' : '#b45309', - }; - } - - function isTradingSession() { - var d = new Date(); - var wd = d.getDay(); - if (wd === 0) return false; - if (wd === 6 && d.getHours() < 21) return false; - var t = d.getHours() * 60 + d.getMinutes(); - function inRange(sh, sm, eh, em) { - return t >= sh * 60 + sm && t < eh * 60 + em; - } - if (inRange(9, 0, 11, 30)) return true; - if (inRange(13, 30, 15, 0)) return true; - if (inRange(21, 0, 24, 0)) return true; - if (inRange(0, 0, 2, 30)) return true; - return false; - } - - function barUnixTime(bar) { - if (bar.timestamp) return Math.floor(bar.timestamp / 1000); - if (bar.time) { - var d = new Date(String(bar.time).replace(' ', 'T')); - if (!isNaN(d.getTime())) return Math.floor(d.getTime() / 1000); - } - return null; - } - - function prepareBars(bars, periodKey) { - var out = []; - var gapDay = chartOpts.gapDay; - var seen = {}; - var gapBase = null; - var step = PERIOD_SECONDS[periodKey] || 60; - for (var i = 0; i < bars.length; i++) { - var b = bars[i]; - var o = Number(b.open); - var h = Number(b.high); - var l = Number(b.low); - var c = Number(b.close); - if (!isFinite(o) || !isFinite(c)) continue; - if (!isFinite(h)) h = Math.max(o, c); - if (!isFinite(l)) l = Math.min(o, c); - h = Math.max(h, o, c); - l = Math.min(l, o, c); - var t; - if (gapDay) { - if (gapBase == null) { - gapBase = b.timestamp ? Math.floor(b.timestamp / 1000) : 946684800; - } - t = gapBase + out.length * step; - } else { - t = barUnixTime(b); - } - if (t == null || seen[t]) continue; - seen[t] = true; - out.push({ - time: t, - open: o, - high: h, - low: l, - close: c, - volume: Number(b.volume) || 0, - rawTime: b.time, - }); - } - return out; - } - - function calcMA(period, bars) { - var result = []; - for (var i = 0; i < bars.length; i++) { - if (i < period - 1) continue; - var sum = 0; - for (var j = 0; j < period; j++) sum += bars[i - j].close; - result.push({ time: bars[i].time, value: +(sum / period).toFixed(4) }); - } - return result; - } - - function destroyChart() { - if (resizeObs) { - resizeObs.disconnect(); - resizeObs = null; - } - if (chart) { - chart.remove(); - chart = null; - } - candleSeries = null; - volumeSeries = null; - areaSeries = null; - ma21Series = null; - ma55Series = null; - prevCloseLine = null; - currentChartMode = ''; - } - - function buildChart(mode) { - destroyChart(); - if (!chartEl || !window.LightweightCharts) return; - var c = themeColors(); - var w = chartEl.clientWidth || 600; - var h = chartEl.clientHeight || 400; - chart = LightweightCharts.createChart(chartEl, { - width: w, - height: h, - layout: { - background: { type: 'solid', color: c.bg }, - textColor: c.text, - fontSize: 11, - }, - grid: { - vertLines: { color: c.grid, style: 1 }, - horzLines: { color: c.grid, style: 1 }, - }, - crosshair: { - mode: LightweightCharts.CrosshairMode.Normal, - vertLine: { width: 1, color: c.text, style: 2, labelBackgroundColor: c.grid }, - horzLine: { width: 1, color: c.text, style: 2, labelBackgroundColor: c.grid }, - }, - rightPriceScale: { - borderColor: c.grid, - scaleMargins: mode === 'line' ? { top: 0.08, bottom: 0.08 } : { top: 0.05, bottom: 0.22 }, - }, - timeScale: { - borderColor: c.grid, - timeVisible: true, - secondsVisible: false, - rightOffset: 8, - barSpacing: 10, - minBarSpacing: 4, - fixLeftEdge: false, - fixRightEdge: false, - }, - handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: true }, - handleScale: { axisPressedMouseMove: true, mouseWheel: true, pinch: true }, - localization: { locale: 'zh-CN' }, - }); - - if (mode === 'line') { - areaSeries = chart.addAreaSeries({ - lineColor: c.line, - topColor: c.areaTop, - bottomColor: 'rgba(0,0,0,0)', - lineWidth: 2, - priceLineVisible: false, - lastValueVisible: true, - }); - } else { - candleSeries = chart.addCandlestickSeries({ - upColor: c.up, - downColor: c.down, - borderVisible: true, - borderUpColor: c.up, - borderDownColor: c.down, - wickUpColor: c.up, - wickDownColor: c.down, - priceLineVisible: false, - lastValueVisible: true, - }); - volumeSeries = chart.addHistogramSeries({ - priceFormat: { type: 'volume' }, - priceScaleId: 'volume', - lastValueVisible: false, - priceLineVisible: false, - }); - chart.priceScale('volume').applyOptions({ - scaleMargins: { top: 0.82, bottom: 0 }, - borderVisible: false, - }); - if (chartOpts.ma) { - ma21Series = chart.addLineSeries({ - color: c.ma21, - lineWidth: 1, - priceLineVisible: false, - lastValueVisible: false, - crosshairMarkerVisible: false, - }); - ma55Series = chart.addLineSeries({ - color: c.ma55, - lineWidth: 1, - priceLineVisible: false, - lastValueVisible: false, - crosshairMarkerVisible: false, - }); - } - } - - chart.timeScale().subscribeVisibleLogicalRangeChange(function () { - if (!chart) return; - var range = chart.timeScale().getVisibleLogicalRange(); - if (!range || !lastData || !lastData.preparedBars) return; - var total = lastData.preparedBars.length; - followingLatest = range.to >= total - 2; - }); - - resizeObs = new ResizeObserver(function () { - if (!chart || !chartEl) return; - chart.applyOptions({ width: chartEl.clientWidth, height: chartEl.clientHeight }); - }); - resizeObs.observe(chartEl); - currentChartMode = mode; - } - - function applyPrevCloseLine(price) { - if (!candleSeries || currentChartMode !== 'candle') return; - if (prevCloseLine) { - candleSeries.removePriceLine(prevCloseLine); - prevCloseLine = null; - } - if (!chartOpts.prevClose || price == null || !isFinite(Number(price))) return; - var c = themeColors(); - prevCloseLine = candleSeries.createPriceLine({ - price: Number(price), - color: c.prevClose, - lineWidth: 1, - lineStyle: LightweightCharts.LineStyle.Dashed, - axisLabelVisible: true, - title: '昨收', - }); - } - - function setVisibleRange(prepared, preserve) { - if (!chart || !prepared.length) return; - var ts = chart.timeScale(); - if (preserve && followingLatest) { - var span = DEFAULT_VISIBLE_BARS; - try { - var cur = ts.getVisibleLogicalRange(); - if (cur) span = Math.max(20, cur.to - cur.from); - } catch (e) { /* ignore */ } - ts.setVisibleLogicalRange({ - from: Math.max(0, prepared.length - span), - to: prepared.length + 4, - }); - return; - } - if (preserve) return; - var show = Math.min(DEFAULT_VISIBLE_BARS, prepared.length); - ts.setVisibleLogicalRange({ - from: Math.max(0, prepared.length - show), - to: prepared.length + 4, - }); - } - - function renderChart(data, preserveRange) { - if (!chartEl || !window.LightweightCharts) return; - lastData = data; - if (data.prev_close != null) lastPrevClose = data.prev_close; - - var isLine = data.chart_type === 'line' || data.period === 'timeshare'; - var mode = isLine ? 'line' : 'candle'; - if (!chart || currentChartMode !== mode) buildChart(mode); - if (!chart) return; - - var prepared = prepareBars(data.bars || [], data.period || currentPeriod); - data.preparedBars = prepared; - if (!prepared.length) return; - - if (mode === 'line') { - areaSeries.setData(prepared.map(function (b) { - return { time: b.time, value: b.close }; - })); - } else { - candleSeries.setData(prepared.map(function (b) { - return { time: b.time, open: b.open, high: b.high, low: b.low, close: b.close }; - })); - volumeSeries.setData(prepared.map(function (b) { - var up = b.close >= b.open; - var c = themeColors(); - return { - time: b.time, - value: b.volume, - color: up ? c.up : c.down, - }; - })); - if (chartOpts.ma && ma21Series && ma55Series) { - ma21Series.setData(calcMA(21, prepared)); - ma55Series.setData(calcMA(55, prepared)); - } - applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : data.prev_close); - } - - setVisibleRange(prepared, !!preserveRange); - } - - function periodLabel(key) { - var tabs = document.querySelectorAll('.period-tab'); - for (var i = 0; i < tabs.length; i++) { - if (tabs[i].getAttribute('data-period') === key) return tabs[i].textContent; - } - return key; - } - - function hideEmptyOverlay() { - if (wrapEl) wrapEl.classList.add('has-data'); - } - - function showEmptyOverlay(text) { - if (emptyEl) emptyEl.textContent = text; - if (wrapEl) wrapEl.classList.remove('has-data'); - } - - function setLoading(on) { - var btn = document.getElementById('market-load-btn'); - if (btn) { - btn.disabled = on; - btn.textContent = on ? '连接中…' : '查看'; - } - if (!wrapEl) return; - if (on && !lastData) { - wrapEl.classList.add('loading'); - showEmptyOverlay('请选择合约并点击「查看」'); - } else { - wrapEl.classList.remove('loading'); - if (lastData) hideEmptyOverlay(); - } - } - - function klineSourceLabel(src) { - if (src === 'ctp') return 'CTP'; - if (src === 'ctp+remote') return '新浪+CTP'; - if (src === 'local') return '本地缓存'; - return '新浪'; - } - - function updateRefreshHint(disconnected) { - var el = document.getElementById('market-refresh-hint'); - if (!el) return; - if (!getSymbol()) { - el.textContent = ''; - return; - } - if (disconnected) { - el.textContent = 'SSE 连接中断,正在重连…'; - return; - } - if (!streamActive) { - el.textContent = ''; - return; - } - var src = ''; - if (lastData && lastData.source) { - src = ' · ' + klineSourceLabel(lastData.source); - } - if (isTradingSession()) { - el.textContent = 'TradingView 图表 · 交易中 SSE 推送' + src; - } else { - el.textContent = 'TradingView 图表 · 非交易时段低频刷新' + src; - } - } - - function updatePrevCloseDisplay(val) { - var prevEl = document.getElementById('market-quote-prev'); - if (!prevEl) return; - if (val != null && !isNaN(Number(val))) { - prevEl.textContent = '昨收 ' + Number(val).toFixed(2); - } else { - prevEl.textContent = ''; - } - } - - function applyQuote(data) { - var priceEl = document.getElementById('market-quote-price'); - var nameEl = document.getElementById('market-quote-name'); - if (nameEl && data.name) nameEl.textContent = data.name + ' ' + (data.symbol || ''); - if (priceEl) { - priceEl.textContent = data.price != null ? Number(data.price).toFixed(2) : '—'; - } - if (data.quote_source && lastData) { - updateQuoteMeta(Object.assign({}, lastData, { quote_source: data.quote_source })); - } - if (data.prev_close != null) { - lastPrevClose = data.prev_close; - updatePrevCloseDisplay(data.prev_close); - if (chartOpts.prevClose && lastData) { - applyPrevCloseLine(data.prev_close); - } - } - } - - function stopKlineStream() { - streamActive = false; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - if (klineSource) { - klineSource.close(); - klineSource = null; - } - } - - function scheduleReconnect() { - if (reconnectTimer) return; - updateRefreshHint(true); - reconnectTimer = setTimeout(function () { - reconnectTimer = null; - if (getSymbol()) startKlineStream(false); - }, 3000); - } - - function startKlineStream(showLoading) { - stopKlineStream(); - var symbol = getSymbol(); - if (!symbol) { - alert('请先选择或输入合约代码'); - return; - } - if (showLoading) setLoading(true); - - var codes = getMarketCodes(); - var q = 'symbol=' + encodeURIComponent(symbol) + - '&period=' + encodeURIComponent(currentPeriod); - if (codes.market_code) q += '&market_code=' + encodeURIComponent(codes.market_code); - if (codes.sina_code) q += '&sina_code=' + encodeURIComponent(codes.sina_code); - - klineSource = new EventSource('/api/kline/stream?' + q); - streamActive = true; - followingLatest = true; - updateRefreshHint(false); - - klineSource.addEventListener('kline', function (e) { - try { - var data = JSON.parse(e.data); - if (!data.bars || !data.bars.length) return; - hideEmptyOverlay(); - renderChart(data, lastData !== null); - updateQuoteMeta(data); - if (data.prev_close != null) updatePrevCloseDisplay(data.prev_close); - updateRefreshHint(false); - setLoading(false); - } catch (err) { /* ignore */ } - }); - - klineSource.addEventListener('quote', function (e) { - try { - applyQuote(JSON.parse(e.data)); - } catch (err) { /* ignore */ } - }); - - klineSource.onerror = function () { - stopKlineStream(); - scheduleReconnect(); - }; - } - - function updateQuoteMeta(data) { - var meta = document.getElementById('market-quote-meta'); - if (meta) { - var parts = []; - if (data.count) parts.push('共 ' + data.count + ' 根 · ' + periodLabel(data.period)); - if (data.source) parts.push('K线 ' + klineSourceLabel(data.source)); - if (data.quote_source) { - parts.push('报价 ' + (data.quote_source === 'ctp' ? 'CTP' : '新浪')); - } - meta.textContent = parts.join(' · '); - } - var nameEl = document.getElementById('market-quote-name'); - var hiddenName = document.getElementById('market-symbol-name'); - if (nameEl && !(nameEl.textContent && nameEl.textContent.trim())) { - nameEl.textContent = (hiddenName && hiddenName.value) || data.symbol || '—'; - } - } - - function loadKline(showLoading) { - startKlineStream(showLoading); - } - - function shiftDataZoom(delta) { - if (!chart) return; - var ts = chart.timeScale(); - var range = ts.getVisibleLogicalRange(); - if (!range) return; - var span = range.to - range.from; - var newSpan = Math.max(15, span + delta); - var center = (range.from + range.to) / 2; - ts.setVisibleLogicalRange({ - from: center - newSpan / 2, - to: center + newSpan / 2, - }); - } - - function resetDataZoom() { - if (!chart || !lastData || !lastData.preparedBars) return; - followingLatest = true; - setVisibleRange(lastData.preparedBars, false); - } - - function bindPeriodTabs() { - var tabs = document.getElementById('market-period-tabs'); - if (!tabs) return; - tabs.addEventListener('click', function (e) { - var btn = e.target.closest('.period-tab'); - if (!btn) return; - tabs.querySelectorAll('.period-tab').forEach(function (el) { el.classList.remove('active'); }); - btn.classList.add('active'); - currentPeriod = btn.getAttribute('data-period') || '15m'; - followingLatest = true; - if (getSymbol()) loadKline(true); - }); - } - - function bindZoomButtons() { - var zoomIn = document.getElementById('chart-zoom-in'); - var zoomOut = document.getElementById('chart-zoom-out'); - var zoomReset = document.getElementById('chart-zoom-reset'); - if (zoomIn) zoomIn.addEventListener('click', function () { shiftDataZoom(-20); }); - if (zoomOut) zoomOut.addEventListener('click', function () { shiftDataZoom(20); }); - if (zoomReset) zoomReset.addEventListener('click', resetDataZoom); - } - - function bindChartOptions() { - var prevCb = document.getElementById('chart-opt-prev-close'); - var maCb = document.getElementById('chart-opt-ma'); - var gapCb = document.getElementById('chart-opt-gap-day'); - if (prevCb) { - prevCb.addEventListener('change', function () { - chartOpts.prevClose = prevCb.checked; - if (lastData) { - applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : lastData.prev_close); - } - }); - } - if (maCb) { - maCb.addEventListener('change', function () { - chartOpts.ma = maCb.checked; - if (lastData) { - destroyChart(); - renderChart(lastData, false); - } - }); - } - if (gapCb) { - gapCb.addEventListener('change', function () { - chartOpts.gapDay = gapCb.checked; - followingLatest = true; - if (lastData) renderChart(lastData, false); - }); - } - } - - document.addEventListener('DOMContentLoaded', function () { - if (!window.LightweightCharts) { - if (emptyEl) emptyEl.textContent = '图表库加载失败,请刷新页面'; - return; - } - bindPeriodTabs(); - bindZoomButtons(); - bindChartOptions(); - - document.addEventListener('click', function (e) { - if (e.target.closest('[data-theme-pick]') && lastData) { - setTimeout(function () { - destroyChart(); - renderChart(lastData, false); - }, 80); - } - }); - - var active = document.querySelector('.period-tab.active'); - if (active) currentPeriod = active.getAttribute('data-period') || '15m'; - - var loadBtn = document.getElementById('market-load-btn'); - if (loadBtn) loadBtn.addEventListener('click', function () { loadKline(true); }); - - var hidden = document.getElementById('market-symbol-hidden'); - var input = document.getElementById('market-symbol-input'); - if (input) { - input.addEventListener('symbol-selected', function () { - lastPrevClose = null; - lastData = null; - destroyChart(); - updatePrevCloseDisplay(null); - loadKline(true); - }); - } - if (hidden && hidden.value) { - if (input && !input.value) input.value = hidden.value; - loadKline(true); - } else { - updateRefreshHint(false); - } - - window.addEventListener('beforeunload', function () { - stopKlineStream(); - destroyChart(); - }); - }); -})(); +/* Copyright (c) 2025-2026 马建军. All rights reserved. + * 专有软件 — 未经授权禁止复制、传播、转售。 + * 详见 LICENSE.zh-CN.txt + */ +(function () { + var chartEl = document.getElementById('market-chart'); + var emptyEl = document.getElementById('market-chart-empty'); + var wrapEl = document.getElementById('market-chart-wrap'); + var chart = null; + var candleSeries = null; + var volumeSeries = null; + var areaSeries = null; + var ma21Series = null; + var ma55Series = null; + var prevCloseLine = null; + var resizeObs = null; + var currentPeriod = '15m'; + var currentChartMode = ''; + var klineSource = null; + var streamActive = false; + var reconnectTimer = null; + var lastData = null; + var lastPrevClose = null; + var chartOpts = { prevClose: false, ma: false, gapDay: false }; + var followingLatest = true; + var DEFAULT_VISIBLE_BARS = 80; + + var PERIOD_SECONDS = { + timeshare: 60, + '1m': 60, + '2m': 120, + '5m': 300, + '15m': 900, + '1h': 3600, + '2h': 7200, + '4h': 14400, + d: 86400, + w: 604800, + }; + + function getSymbol() { + var hidden = document.getElementById('market-symbol-hidden'); + var input = document.getElementById('market-symbol-input'); + if (hidden && hidden.value) return hidden.value.trim(); + if (input && input.value) return input.value.trim(); + return ''; + } + + function getMarketCodes() { + return { + symbol: getSymbol(), + market_code: (document.getElementById('market-market-code') || {}).value || '', + sina_code: (document.getElementById('market-sina-code') || {}).value || '', + }; + } + + function themeColors() { + var dark = document.documentElement.getAttribute('data-theme') !== 'light'; + return { + bg: dark ? '#0a0c14' : '#ffffff', + text: dark ? '#a8b0c8' : '#5c6578', + grid: dark ? '#1e2640' : '#e8edf5', + up: dark ? '#26a69a' : '#089981', + down: dark ? '#ef5350' : '#f23645', + line: dark ? '#4cc2ff' : '#2962ff', + areaTop: dark ? 'rgba(76,194,255,0.28)' : 'rgba(41,98,255,0.22)', + ma21: dark ? '#ffb347' : '#f7931a', + ma55: dark ? '#c084fc' : '#7c3aed', + prevClose: dark ? '#fbbf24' : '#b45309', + }; + } + + function isTradingSession() { + var d = new Date(); + var wd = d.getDay(); + if (wd === 0) return false; + if (wd === 6 && d.getHours() < 21) return false; + var t = d.getHours() * 60 + d.getMinutes(); + function inRange(sh, sm, eh, em) { + return t >= sh * 60 + sm && t < eh * 60 + em; + } + if (inRange(9, 0, 11, 30)) return true; + if (inRange(13, 30, 15, 0)) return true; + if (inRange(21, 0, 24, 0)) return true; + if (inRange(0, 0, 2, 30)) return true; + return false; + } + + function barUnixTime(bar) { + if (bar.timestamp) return Math.floor(bar.timestamp / 1000); + if (bar.time) { + var d = new Date(String(bar.time).replace(' ', 'T')); + if (!isNaN(d.getTime())) return Math.floor(d.getTime() / 1000); + } + return null; + } + + function prepareBars(bars, periodKey) { + var out = []; + var gapDay = chartOpts.gapDay; + var seen = {}; + var gapBase = null; + var step = PERIOD_SECONDS[periodKey] || 60; + for (var i = 0; i < bars.length; i++) { + var b = bars[i]; + var o = Number(b.open); + var h = Number(b.high); + var l = Number(b.low); + var c = Number(b.close); + if (!isFinite(o) || !isFinite(c)) continue; + if (!isFinite(h)) h = Math.max(o, c); + if (!isFinite(l)) l = Math.min(o, c); + h = Math.max(h, o, c); + l = Math.min(l, o, c); + var t; + if (gapDay) { + if (gapBase == null) { + gapBase = b.timestamp ? Math.floor(b.timestamp / 1000) : 946684800; + } + t = gapBase + out.length * step; + } else { + t = barUnixTime(b); + } + if (t == null || seen[t]) continue; + seen[t] = true; + out.push({ + time: t, + open: o, + high: h, + low: l, + close: c, + volume: Number(b.volume) || 0, + rawTime: b.time, + }); + } + return out; + } + + function calcMA(period, bars) { + var result = []; + for (var i = 0; i < bars.length; i++) { + if (i < period - 1) continue; + var sum = 0; + for (var j = 0; j < period; j++) sum += bars[i - j].close; + result.push({ time: bars[i].time, value: +(sum / period).toFixed(4) }); + } + return result; + } + + function destroyChart() { + if (resizeObs) { + resizeObs.disconnect(); + resizeObs = null; + } + if (chart) { + chart.remove(); + chart = null; + } + candleSeries = null; + volumeSeries = null; + areaSeries = null; + ma21Series = null; + ma55Series = null; + prevCloseLine = null; + currentChartMode = ''; + } + + function buildChart(mode) { + destroyChart(); + if (!chartEl || !window.LightweightCharts) return; + var c = themeColors(); + var w = chartEl.clientWidth || 600; + var h = chartEl.clientHeight || 400; + chart = LightweightCharts.createChart(chartEl, { + width: w, + height: h, + layout: { + background: { type: 'solid', color: c.bg }, + textColor: c.text, + fontSize: 11, + }, + grid: { + vertLines: { color: c.grid, style: 1 }, + horzLines: { color: c.grid, style: 1 }, + }, + crosshair: { + mode: LightweightCharts.CrosshairMode.Normal, + vertLine: { width: 1, color: c.text, style: 2, labelBackgroundColor: c.grid }, + horzLine: { width: 1, color: c.text, style: 2, labelBackgroundColor: c.grid }, + }, + rightPriceScale: { + borderColor: c.grid, + scaleMargins: mode === 'line' ? { top: 0.08, bottom: 0.08 } : { top: 0.05, bottom: 0.22 }, + }, + timeScale: { + borderColor: c.grid, + timeVisible: true, + secondsVisible: false, + rightOffset: 8, + barSpacing: 10, + minBarSpacing: 4, + fixLeftEdge: false, + fixRightEdge: false, + }, + handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: true }, + handleScale: { axisPressedMouseMove: true, mouseWheel: true, pinch: true }, + localization: { locale: 'zh-CN' }, + }); + + if (mode === 'line') { + areaSeries = chart.addAreaSeries({ + lineColor: c.line, + topColor: c.areaTop, + bottomColor: 'rgba(0,0,0,0)', + lineWidth: 2, + priceLineVisible: false, + lastValueVisible: true, + }); + } else { + candleSeries = chart.addCandlestickSeries({ + upColor: c.up, + downColor: c.down, + borderVisible: true, + borderUpColor: c.up, + borderDownColor: c.down, + wickUpColor: c.up, + wickDownColor: c.down, + priceLineVisible: false, + lastValueVisible: true, + }); + volumeSeries = chart.addHistogramSeries({ + priceFormat: { type: 'volume' }, + priceScaleId: 'volume', + lastValueVisible: false, + priceLineVisible: false, + }); + chart.priceScale('volume').applyOptions({ + scaleMargins: { top: 0.82, bottom: 0 }, + borderVisible: false, + }); + if (chartOpts.ma) { + ma21Series = chart.addLineSeries({ + color: c.ma21, + lineWidth: 1, + priceLineVisible: false, + lastValueVisible: false, + crosshairMarkerVisible: false, + }); + ma55Series = chart.addLineSeries({ + color: c.ma55, + lineWidth: 1, + priceLineVisible: false, + lastValueVisible: false, + crosshairMarkerVisible: false, + }); + } + } + + chart.timeScale().subscribeVisibleLogicalRangeChange(function () { + if (!chart) return; + var range = chart.timeScale().getVisibleLogicalRange(); + if (!range || !lastData || !lastData.preparedBars) return; + var total = lastData.preparedBars.length; + followingLatest = range.to >= total - 2; + }); + + resizeObs = new ResizeObserver(function () { + if (!chart || !chartEl) return; + chart.applyOptions({ width: chartEl.clientWidth, height: chartEl.clientHeight }); + }); + resizeObs.observe(chartEl); + currentChartMode = mode; + } + + function applyPrevCloseLine(price) { + if (!candleSeries || currentChartMode !== 'candle') return; + if (prevCloseLine) { + candleSeries.removePriceLine(prevCloseLine); + prevCloseLine = null; + } + if (!chartOpts.prevClose || price == null || !isFinite(Number(price))) return; + var c = themeColors(); + prevCloseLine = candleSeries.createPriceLine({ + price: Number(price), + color: c.prevClose, + lineWidth: 1, + lineStyle: LightweightCharts.LineStyle.Dashed, + axisLabelVisible: true, + title: '昨收', + }); + } + + function setVisibleRange(prepared, preserve) { + if (!chart || !prepared.length) return; + var ts = chart.timeScale(); + if (preserve && followingLatest) { + var span = DEFAULT_VISIBLE_BARS; + try { + var cur = ts.getVisibleLogicalRange(); + if (cur) span = Math.max(20, cur.to - cur.from); + } catch (e) { /* ignore */ } + ts.setVisibleLogicalRange({ + from: Math.max(0, prepared.length - span), + to: prepared.length + 4, + }); + return; + } + if (preserve) return; + var show = Math.min(DEFAULT_VISIBLE_BARS, prepared.length); + ts.setVisibleLogicalRange({ + from: Math.max(0, prepared.length - show), + to: prepared.length + 4, + }); + } + + function renderChart(data, preserveRange) { + if (!chartEl || !window.LightweightCharts) return; + lastData = data; + if (data.prev_close != null) lastPrevClose = data.prev_close; + + var isLine = data.chart_type === 'line' || data.period === 'timeshare'; + var mode = isLine ? 'line' : 'candle'; + if (!chart || currentChartMode !== mode) buildChart(mode); + if (!chart) return; + + var prepared = prepareBars(data.bars || [], data.period || currentPeriod); + data.preparedBars = prepared; + if (!prepared.length) return; + + if (mode === 'line') { + areaSeries.setData(prepared.map(function (b) { + return { time: b.time, value: b.close }; + })); + } else { + candleSeries.setData(prepared.map(function (b) { + return { time: b.time, open: b.open, high: b.high, low: b.low, close: b.close }; + })); + volumeSeries.setData(prepared.map(function (b) { + var up = b.close >= b.open; + var c = themeColors(); + return { + time: b.time, + value: b.volume, + color: up ? c.up : c.down, + }; + })); + if (chartOpts.ma && ma21Series && ma55Series) { + ma21Series.setData(calcMA(21, prepared)); + ma55Series.setData(calcMA(55, prepared)); + } + applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : data.prev_close); + } + + setVisibleRange(prepared, !!preserveRange); + } + + function periodLabel(key) { + var tabs = document.querySelectorAll('.period-tab'); + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].getAttribute('data-period') === key) return tabs[i].textContent; + } + return key; + } + + function hideEmptyOverlay() { + if (wrapEl) wrapEl.classList.add('has-data'); + } + + function showEmptyOverlay(text) { + if (emptyEl) emptyEl.textContent = text; + if (wrapEl) wrapEl.classList.remove('has-data'); + } + + function setLoading(on) { + var btn = document.getElementById('market-load-btn'); + if (btn) { + btn.disabled = on; + btn.textContent = on ? '连接中…' : '查看'; + } + if (!wrapEl) return; + if (on && !lastData) { + wrapEl.classList.add('loading'); + showEmptyOverlay('请选择合约并点击「查看」'); + } else { + wrapEl.classList.remove('loading'); + if (lastData) hideEmptyOverlay(); + } + } + + function klineSourceLabel(src) { + if (src === 'ctp') return 'CTP'; + if (src === 'ctp+remote') return '新浪+CTP'; + if (src === 'local') return '本地缓存'; + return '新浪'; + } + + function updateRefreshHint(disconnected) { + var el = document.getElementById('market-refresh-hint'); + if (!el) return; + if (!getSymbol()) { + el.textContent = ''; + return; + } + if (disconnected) { + el.textContent = 'SSE 连接中断,正在重连…'; + return; + } + if (!streamActive) { + el.textContent = ''; + return; + } + var src = ''; + if (lastData && lastData.source) { + src = ' · ' + klineSourceLabel(lastData.source); + } + if (isTradingSession()) { + el.textContent = 'TradingView 图表 · 交易中 SSE 推送' + src; + } else { + el.textContent = 'TradingView 图表 · 非交易时段低频刷新' + src; + } + } + + function updatePrevCloseDisplay(val) { + var prevEl = document.getElementById('market-quote-prev'); + if (!prevEl) return; + if (val != null && !isNaN(Number(val))) { + prevEl.textContent = '昨收 ' + Number(val).toFixed(2); + } else { + prevEl.textContent = ''; + } + } + + function applyQuote(data) { + var priceEl = document.getElementById('market-quote-price'); + var nameEl = document.getElementById('market-quote-name'); + if (nameEl && data.name) nameEl.textContent = data.name + ' ' + (data.symbol || ''); + if (priceEl) { + priceEl.textContent = data.price != null ? Number(data.price).toFixed(2) : '—'; + } + if (data.quote_source && lastData) { + updateQuoteMeta(Object.assign({}, lastData, { quote_source: data.quote_source })); + } + if (data.prev_close != null) { + lastPrevClose = data.prev_close; + updatePrevCloseDisplay(data.prev_close); + if (chartOpts.prevClose && lastData) { + applyPrevCloseLine(data.prev_close); + } + } + } + + function stopKlineStream() { + streamActive = false; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (klineSource) { + klineSource.close(); + klineSource = null; + } + } + + function scheduleReconnect() { + if (reconnectTimer) return; + updateRefreshHint(true); + reconnectTimer = setTimeout(function () { + reconnectTimer = null; + if (getSymbol()) startKlineStream(false); + }, 3000); + } + + function startKlineStream(showLoading) { + stopKlineStream(); + var symbol = getSymbol(); + if (!symbol) { + alert('请先选择或输入合约代码'); + return; + } + if (showLoading) setLoading(true); + + var codes = getMarketCodes(); + var q = 'symbol=' + encodeURIComponent(symbol) + + '&period=' + encodeURIComponent(currentPeriod); + if (codes.market_code) q += '&market_code=' + encodeURIComponent(codes.market_code); + if (codes.sina_code) q += '&sina_code=' + encodeURIComponent(codes.sina_code); + + klineSource = new EventSource('/api/kline/stream?' + q); + streamActive = true; + followingLatest = true; + updateRefreshHint(false); + + klineSource.addEventListener('kline', function (e) { + try { + var data = JSON.parse(e.data); + if (!data.bars || !data.bars.length) return; + hideEmptyOverlay(); + renderChart(data, lastData !== null); + updateQuoteMeta(data); + if (data.prev_close != null) updatePrevCloseDisplay(data.prev_close); + updateRefreshHint(false); + setLoading(false); + } catch (err) { /* ignore */ } + }); + + klineSource.addEventListener('quote', function (e) { + try { + applyQuote(JSON.parse(e.data)); + } catch (err) { /* ignore */ } + }); + + klineSource.onerror = function () { + stopKlineStream(); + scheduleReconnect(); + }; + } + + function updateQuoteMeta(data) { + var meta = document.getElementById('market-quote-meta'); + if (meta) { + var parts = []; + if (data.count) parts.push('共 ' + data.count + ' 根 · ' + periodLabel(data.period)); + if (data.source) parts.push('K线 ' + klineSourceLabel(data.source)); + if (data.quote_source) { + parts.push('报价 ' + (data.quote_source === 'ctp' ? 'CTP' : '新浪')); + } + meta.textContent = parts.join(' · '); + } + var nameEl = document.getElementById('market-quote-name'); + var hiddenName = document.getElementById('market-symbol-name'); + if (nameEl && !(nameEl.textContent && nameEl.textContent.trim())) { + nameEl.textContent = (hiddenName && hiddenName.value) || data.symbol || '—'; + } + } + + function loadKline(showLoading) { + startKlineStream(showLoading); + } + + function shiftDataZoom(delta) { + if (!chart) return; + var ts = chart.timeScale(); + var range = ts.getVisibleLogicalRange(); + if (!range) return; + var span = range.to - range.from; + var newSpan = Math.max(15, span + delta); + var center = (range.from + range.to) / 2; + ts.setVisibleLogicalRange({ + from: center - newSpan / 2, + to: center + newSpan / 2, + }); + } + + function resetDataZoom() { + if (!chart || !lastData || !lastData.preparedBars) return; + followingLatest = true; + setVisibleRange(lastData.preparedBars, false); + } + + function bindPeriodTabs() { + var tabs = document.getElementById('market-period-tabs'); + if (!tabs) return; + tabs.addEventListener('click', function (e) { + var btn = e.target.closest('.period-tab'); + if (!btn) return; + tabs.querySelectorAll('.period-tab').forEach(function (el) { el.classList.remove('active'); }); + btn.classList.add('active'); + currentPeriod = btn.getAttribute('data-period') || '15m'; + followingLatest = true; + if (getSymbol()) loadKline(true); + }); + } + + function bindZoomButtons() { + var zoomIn = document.getElementById('chart-zoom-in'); + var zoomOut = document.getElementById('chart-zoom-out'); + var zoomReset = document.getElementById('chart-zoom-reset'); + if (zoomIn) zoomIn.addEventListener('click', function () { shiftDataZoom(-20); }); + if (zoomOut) zoomOut.addEventListener('click', function () { shiftDataZoom(20); }); + if (zoomReset) zoomReset.addEventListener('click', resetDataZoom); + } + + function bindChartOptions() { + var prevCb = document.getElementById('chart-opt-prev-close'); + var maCb = document.getElementById('chart-opt-ma'); + var gapCb = document.getElementById('chart-opt-gap-day'); + if (prevCb) { + prevCb.addEventListener('change', function () { + chartOpts.prevClose = prevCb.checked; + if (lastData) { + applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : lastData.prev_close); + } + }); + } + if (maCb) { + maCb.addEventListener('change', function () { + chartOpts.ma = maCb.checked; + if (lastData) { + destroyChart(); + renderChart(lastData, false); + } + }); + } + if (gapCb) { + gapCb.addEventListener('change', function () { + chartOpts.gapDay = gapCb.checked; + followingLatest = true; + if (lastData) renderChart(lastData, false); + }); + } + } + + document.addEventListener('DOMContentLoaded', function () { + if (!window.LightweightCharts) { + if (emptyEl) emptyEl.textContent = '图表库加载失败,请刷新页面'; + return; + } + bindPeriodTabs(); + bindZoomButtons(); + bindChartOptions(); + + document.addEventListener('click', function (e) { + if (e.target.closest('[data-theme-pick]') && lastData) { + setTimeout(function () { + destroyChart(); + renderChart(lastData, false); + }, 80); + } + }); + + var active = document.querySelector('.period-tab.active'); + if (active) currentPeriod = active.getAttribute('data-period') || '15m'; + + var loadBtn = document.getElementById('market-load-btn'); + if (loadBtn) loadBtn.addEventListener('click', function () { loadKline(true); }); + + var hidden = document.getElementById('market-symbol-hidden'); + var input = document.getElementById('market-symbol-input'); + if (input) { + input.addEventListener('symbol-selected', function () { + lastPrevClose = null; + lastData = null; + destroyChart(); + updatePrevCloseDisplay(null); + loadKline(true); + }); + } + if (hidden && hidden.value) { + if (input && !input.value) input.value = hidden.value; + loadKline(true); + } else { + updateRefreshHint(false); + } + + window.addEventListener('beforeunload', function () { + stopKlineStream(); + destroyChart(); + }); + }); +})(); diff --git a/static/js/nav.js b/static/js/nav.js index b1f7c1d..71bb9df 100644 --- a/static/js/nav.js +++ b/static/js/nav.js @@ -1,53 +1,57 @@ -(function () { - var toggle = document.getElementById('nav-toggle'); - var nav = document.getElementById('site-nav'); - var backdrop = document.getElementById('nav-backdrop'); - if (!toggle || !nav) return; - - function openNav() { - nav.classList.add('open'); - if (backdrop) { - backdrop.hidden = false; - backdrop.classList.add('show'); - } - toggle.setAttribute('aria-expanded', 'true'); - document.body.style.overflow = 'hidden'; - } - - function closeNav() { - nav.classList.remove('open'); - if (backdrop) { - backdrop.classList.remove('show'); - backdrop.hidden = true; - } - toggle.setAttribute('aria-expanded', 'false'); - document.body.style.overflow = ''; - } - - function isMobileNav() { - return window.matchMedia('(max-width: 767px)').matches; - } - - toggle.addEventListener('click', function () { - if (nav.classList.contains('open')) closeNav(); - else openNav(); - }); - - if (backdrop) { - backdrop.addEventListener('click', closeNav); - } - - nav.querySelectorAll('a').forEach(function (link) { - link.addEventListener('click', function () { - if (isMobileNav()) closeNav(); - }); - }); - - window.addEventListener('resize', function () { - if (!isMobileNav()) closeNav(); - }); - - document.addEventListener('keydown', function (e) { - if (e.key === 'Escape') closeNav(); - }); -})(); +/* Copyright (c) 2025-2026 马建军. All rights reserved. + * 专有软件 — 未经授权禁止复制、传播、转售。 + * 详见 LICENSE.zh-CN.txt + */ +(function () { + var toggle = document.getElementById('nav-toggle'); + var nav = document.getElementById('site-nav'); + var backdrop = document.getElementById('nav-backdrop'); + if (!toggle || !nav) return; + + function openNav() { + nav.classList.add('open'); + if (backdrop) { + backdrop.hidden = false; + backdrop.classList.add('show'); + } + toggle.setAttribute('aria-expanded', 'true'); + document.body.style.overflow = 'hidden'; + } + + function closeNav() { + nav.classList.remove('open'); + if (backdrop) { + backdrop.classList.remove('show'); + backdrop.hidden = true; + } + toggle.setAttribute('aria-expanded', 'false'); + document.body.style.overflow = ''; + } + + function isMobileNav() { + return window.matchMedia('(max-width: 767px)').matches; + } + + toggle.addEventListener('click', function () { + if (nav.classList.contains('open')) closeNav(); + else openNav(); + }); + + if (backdrop) { + backdrop.addEventListener('click', closeNav); + } + + nav.querySelectorAll('a').forEach(function (link) { + link.addEventListener('click', function () { + if (isMobileNav()) closeNav(); + }); + }); + + window.addEventListener('resize', function () { + if (!isMobileNav()) closeNav(); + }); + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') closeNav(); + }); +})(); diff --git a/static/js/plans.js b/static/js/plans.js index 7aac03f..cd7d2be 100644 --- a/static/js/plans.js +++ b/static/js/plans.js @@ -1,45 +1,49 @@ -(function () { - var timer = null; - - function fmtDist(v) { - if (v === null || v === undefined) return '--'; - return v.toFixed(2); - } - - function pollPrices() { - var list = document.getElementById('plan-monitor-list'); - if (!list || !list.querySelector('.plan-item')) return; - - fetch('/api/plan_prices') - .then(function (r) { return r.json(); }) - .then(function (rows) { - rows.forEach(function (row) { - var el = list.querySelector('.plan-item[data-plan-id="' + row.id + '"]'); - if (!el) return; - var priceEl = el.querySelector('.live-price'); - var distEl = el.querySelector('.live-dist'); - var upEl = el.querySelector('.dist-up'); - var downEl = el.querySelector('.dist-down'); - if (priceEl) { - priceEl.textContent = row.price != null ? row.price : '--'; - } - if (row.in_zone && distEl) { - distEl.innerHTML = '在区间内'; - } else if (distEl) { - distEl.innerHTML = - '距上' + fmtDist(row.dist_upper) + ' ' + - '距下' + fmtDist(row.dist_lower) + ''; - } - }); - }) - .catch(function () { /* ignore */ }); - } - - function startPolling() { - if (timer) clearInterval(timer); - pollPrices(); - timer = setInterval(pollPrices, 1000); - } - - document.addEventListener('DOMContentLoaded', startPolling); -})(); +/* Copyright (c) 2025-2026 马建军. All rights reserved. + * 专有软件 — 未经授权禁止复制、传播、转售。 + * 详见 LICENSE.zh-CN.txt + */ +(function () { + var timer = null; + + function fmtDist(v) { + if (v === null || v === undefined) return '--'; + return v.toFixed(2); + } + + function pollPrices() { + var list = document.getElementById('plan-monitor-list'); + if (!list || !list.querySelector('.plan-item')) return; + + fetch('/api/plan_prices') + .then(function (r) { return r.json(); }) + .then(function (rows) { + rows.forEach(function (row) { + var el = list.querySelector('.plan-item[data-plan-id="' + row.id + '"]'); + if (!el) return; + var priceEl = el.querySelector('.live-price'); + var distEl = el.querySelector('.live-dist'); + var upEl = el.querySelector('.dist-up'); + var downEl = el.querySelector('.dist-down'); + if (priceEl) { + priceEl.textContent = row.price != null ? row.price : '--'; + } + if (row.in_zone && distEl) { + distEl.innerHTML = '在区间内'; + } else if (distEl) { + distEl.innerHTML = + '距上' + fmtDist(row.dist_upper) + ' ' + + '距下' + fmtDist(row.dist_lower) + ''; + } + }); + }) + .catch(function () { /* ignore */ }); + } + + function startPolling() { + if (timer) clearInterval(timer); + pollPrices(); + timer = setInterval(pollPrices, 1000); + } + + document.addEventListener('DOMContentLoaded', startPolling); +})(); diff --git a/static/js/positions.js b/static/js/positions.js index 1f3d1d9..1c7acfb 100644 --- a/static/js/positions.js +++ b/static/js/positions.js @@ -1,75 +1,79 @@ -(function () { - var posTimer = null; - - function fmtNum(v, digits) { - if (v === null || v === undefined) return '--'; - return Number(v).toFixed(digits === undefined ? 2 : digits); - } - - 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); - - return ( - '
' + - '
' + - '
' + row.symbol + ' ' + row.direction + '
' + - '
' + - '
' + - '
' + - '
来源 手动输入 · 风险 ' + - fmtNum(row.risk_pct) + '%≈' + fmtNum(row.risk_amount) + '元
' + - '
' + - '
' + fmtNum(row.entry_price) + '
' + - '
' + fmtNum(row.stop_loss) + '
' + - '
' + fmtNum(row.take_profit) + '
' + - '
' + rr + '
' + - '
' + (row.mark_price != null ? fmtNum(row.mark_price) : '--') + '
' + - '
' + pnlText + '
' + - '
' + fmtNum(row.est_fee) + '元
' + - '
' + - '
' + (row.est_pnl_net != null ? fmtNum(row.est_pnl_net) + '元' : '--') + '
' + - '
' + - '
' - ); - } - - function pollPositions() { - var list = document.getElementById('position-live-list'); - if (!list) return; - - fetch('/api/position_live') - .then(function (r) { return r.json(); }) - .then(function (rows) { - if (!rows.length) { - list.innerHTML = '
暂无持仓,左侧录入后显示
'; - return; - } - list.innerHTML = rows.map(buildPosCard).join(''); - }) - .catch(function () { /* ignore */ }); - } - - document.addEventListener('DOMContentLoaded', function () { - pollPositions(); - posTimer = setInterval(pollPositions, 1000); - }); -})(); +/* Copyright (c) 2025-2026 马建军. All rights reserved. + * 专有软件 — 未经授权禁止复制、传播、转售。 + * 详见 LICENSE.zh-CN.txt + */ +(function () { + var posTimer = null; + + function fmtNum(v, digits) { + if (v === null || v === undefined) return '--'; + return Number(v).toFixed(digits === undefined ? 2 : digits); + } + + 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); + + return ( + '
' + + '
' + + '
' + row.symbol + ' ' + row.direction + '
' + + '
' + + '
' + + '
' + + '
来源 手动输入 · 风险 ' + + fmtNum(row.risk_pct) + '%≈' + fmtNum(row.risk_amount) + '元
' + + '
' + + '
' + fmtNum(row.entry_price) + '
' + + '
' + fmtNum(row.stop_loss) + '
' + + '
' + fmtNum(row.take_profit) + '
' + + '
' + rr + '
' + + '
' + (row.mark_price != null ? fmtNum(row.mark_price) : '--') + '
' + + '
' + pnlText + '
' + + '
' + fmtNum(row.est_fee) + '元
' + + '
' + + '
' + (row.est_pnl_net != null ? fmtNum(row.est_pnl_net) + '元' : '--') + '
' + + '
' + + '
' + ); + } + + function pollPositions() { + var list = document.getElementById('position-live-list'); + if (!list) return; + + fetch('/api/position_live') + .then(function (r) { return r.json(); }) + .then(function (rows) { + if (!rows.length) { + list.innerHTML = '
暂无持仓,左侧录入后显示
'; + return; + } + list.innerHTML = rows.map(buildPosCard).join(''); + }) + .catch(function () { /* ignore */ }); + } + + document.addEventListener('DOMContentLoaded', function () { + pollPositions(); + posTimer = setInterval(pollPositions, 1000); + }); +})(); diff --git a/static/js/pwa.js b/static/js/pwa.js index 7cb4cd0..d98389e 100644 --- a/static/js/pwa.js +++ b/static/js/pwa.js @@ -1,89 +1,93 @@ -(function () { - var deferredPrompt = null; - var installBtn = document.getElementById('pwa-install-btn'); - var iosHint = document.getElementById('pwa-ios-hint'); - - function isStandalone() { - return window.matchMedia('(display-mode: standalone)').matches - || window.navigator.standalone === true; - } - - function isIOS() { - return /iPad|iPhone|iPod/.test(navigator.userAgent) - && !window.MSStream; - } - - function isTouchDevice() { - return window.matchMedia('(hover: none) and (pointer: coarse)').matches - || window.matchMedia('(max-width: 1024px)').matches; - } - - function updateThemeColor() { - var meta = document.getElementById('meta-theme-color'); - if (!meta) return; - var theme = document.documentElement.getAttribute('data-theme'); - meta.setAttribute('content', theme === 'light' ? '#e8eef8' : '#050508'); - } - - function showInstallBtn() { - if (installBtn && !isStandalone()) { - installBtn.hidden = false; - } - } - - function showIosHint() { - if (iosHint && isIOS() && !isStandalone()) { - iosHint.classList.add('show'); - } - } - - if ('serviceWorker' in navigator) { - window.addEventListener('load', function () { - navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(function () { /* ignore */ }); - }); - } - - window.addEventListener('beforeinstallprompt', function (e) { - e.preventDefault(); - deferredPrompt = e; - showInstallBtn(); - }); - - if (installBtn) { - installBtn.addEventListener('click', function () { - if (!deferredPrompt) { - if (isIOS()) showIosHint(); - return; - } - deferredPrompt.prompt(); - deferredPrompt.userChoice.then(function () { - deferredPrompt = null; - installBtn.hidden = true; - }); - }); - } - - window.addEventListener('appinstalled', function () { - deferredPrompt = null; - if (installBtn) installBtn.hidden = true; - if (iosHint) iosHint.classList.remove('show'); - }); - - document.addEventListener('DOMContentLoaded', function () { - updateThemeColor(); - showIosHint(); - if (isStandalone()) { - if (installBtn) installBtn.hidden = true; - if (iosHint) iosHint.classList.remove('show'); - return; - } - if (isTouchDevice() && installBtn && deferredPrompt) { - showInstallBtn(); - } - }); - - document.addEventListener('click', function (e) { - var pick = e.target.closest('[data-theme-pick]'); - if (pick) setTimeout(updateThemeColor, 80); - }); -})(); +/* Copyright (c) 2025-2026 马建军. All rights reserved. + * 专有软件 — 未经授权禁止复制、传播、转售。 + * 详见 LICENSE.zh-CN.txt + */ +(function () { + var deferredPrompt = null; + var installBtn = document.getElementById('pwa-install-btn'); + var iosHint = document.getElementById('pwa-ios-hint'); + + function isStandalone() { + return window.matchMedia('(display-mode: standalone)').matches + || window.navigator.standalone === true; + } + + function isIOS() { + return /iPad|iPhone|iPod/.test(navigator.userAgent) + && !window.MSStream; + } + + function isTouchDevice() { + return window.matchMedia('(hover: none) and (pointer: coarse)').matches + || window.matchMedia('(max-width: 1024px)').matches; + } + + function updateThemeColor() { + var meta = document.getElementById('meta-theme-color'); + if (!meta) return; + var theme = document.documentElement.getAttribute('data-theme'); + meta.setAttribute('content', theme === 'light' ? '#e8eef8' : '#050508'); + } + + function showInstallBtn() { + if (installBtn && !isStandalone()) { + installBtn.hidden = false; + } + } + + function showIosHint() { + if (iosHint && isIOS() && !isStandalone()) { + iosHint.classList.add('show'); + } + } + + if ('serviceWorker' in navigator) { + window.addEventListener('load', function () { + navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(function () { /* ignore */ }); + }); + } + + window.addEventListener('beforeinstallprompt', function (e) { + e.preventDefault(); + deferredPrompt = e; + showInstallBtn(); + }); + + if (installBtn) { + installBtn.addEventListener('click', function () { + if (!deferredPrompt) { + if (isIOS()) showIosHint(); + return; + } + deferredPrompt.prompt(); + deferredPrompt.userChoice.then(function () { + deferredPrompt = null; + installBtn.hidden = true; + }); + }); + } + + window.addEventListener('appinstalled', function () { + deferredPrompt = null; + if (installBtn) installBtn.hidden = true; + if (iosHint) iosHint.classList.remove('show'); + }); + + document.addEventListener('DOMContentLoaded', function () { + updateThemeColor(); + showIosHint(); + if (isStandalone()) { + if (installBtn) installBtn.hidden = true; + if (iosHint) iosHint.classList.remove('show'); + return; + } + if (isTouchDevice() && installBtn && deferredPrompt) { + showInstallBtn(); + } + }); + + document.addEventListener('click', function (e) { + var pick = e.target.closest('[data-theme-pick]'); + if (pick) setTimeout(updateThemeColor, 80); + }); +})(); diff --git a/static/js/review.js b/static/js/review.js index 2cbc3b1..1ef08ed 100644 --- a/static/js/review.js +++ b/static/js/review.js @@ -1,3 +1,7 @@ +/* Copyright (c) 2025-2026 马建军. All rights reserved. + * 专有软件 — 未经授权禁止复制、传播、转售。 + * 详见 LICENSE.zh-CN.txt + */ (function () { function parseNum(v) { var n = parseFloat(v); diff --git a/static/js/stats.js b/static/js/stats.js index 5f71cda..132f3b8 100644 --- a/static/js/stats.js +++ b/static/js/stats.js @@ -1,155 +1,159 @@ -(function () { - var cache = null; - - function fmtNum(v, suffix) { - if (v === null || v === undefined || v === '') return '-'; - var n = Number(v); - if (isNaN(n)) return String(v); - var s = Number.isInteger(n) ? String(n) : n.toFixed(2); - return suffix ? s + suffix : s; - } - - function fmtMoney(v) { - if (v === null || v === undefined) return '-'; - return fmtNum(v) + ' 元'; - } - - function fmtPct(v) { - if (v === null || v === undefined) return '-'; - return fmtNum(v) + '%'; - } - - function setSummary(s) { - var map = { - total_trades: function () { return fmtNum(s.total_trades); }, - win_rate: function () { return fmtPct(s.win_rate); }, - avg_profit: function () { return fmtMoney(s.avg_profit); }, - avg_loss: function () { return fmtMoney(s.avg_loss); }, - profit_loss_ratio: function () { return fmtNum(s.profit_loss_ratio); }, - consecutive_losses: function () { return fmtNum(s.consecutive_losses); }, - max_drawdown: function () { - var amt = fmtMoney(s.max_drawdown); - var pct = s.max_drawdown_pct ? ' (' + fmtPct(s.max_drawdown_pct) + ')' : ''; - return amt + pct; - }, - max_loss_amount: function () { return fmtMoney(s.max_loss_amount); }, - max_loss_pct: function () { return fmtPct(s.max_loss_pct); }, - max_profit_amount: function () { return fmtMoney(s.max_profit_amount); }, - max_profit_pct: function () { return fmtPct(s.max_profit_pct); }, - total_fee: function () { return fmtMoney(s.total_fee); }, - emotion_count: function () { return fmtNum(s.emotion_count); }, - emotion_ratio: function () { return fmtPct(s.emotion_ratio); }, - }; - document.querySelectorAll('#stats-summary [data-k]').forEach(function (el) { - var key = el.getAttribute('data-k'); - el.textContent = map[key] ? map[key]() : '-'; - }); - } - - function fillViewSelect(views, selected) { - var sel = document.getElementById('stats-view-select'); - if (!sel) return; - sel.innerHTML = ''; - views.forEach(function (v) { - var opt = document.createElement('option'); - opt.value = v.key; - opt.textContent = v.label; - if (v.key === selected) opt.selected = true; - sel.appendChild(opt); - }); - } - - function cellClass(key, val) { - if (key === 'total_net' || key === 'max_profit' || key === 'avg_profit') { - if (val > 0) return 'text-profit'; - if (val < 0) return 'text-loss'; - } - if (key === 'max_loss' || key === 'avg_loss' || key === 'total_fee') { - return 'text-loss'; - } - return ''; - } - - function renderBreakdown(key) { - if (!cache || !cache.breakdowns) return; - var block = cache.breakdowns[key]; - var head = document.getElementById('stats-breakdown-head'); - var body = document.getElementById('stats-breakdown-body'); - if (!block || !head || !body) return; - - head.innerHTML = ''; - block.columns.forEach(function (col) { - var th = document.createElement('th'); - th.textContent = col.label; - head.appendChild(th); - }); - - body.innerHTML = ''; - if (!block.rows || !block.rows.length) { - var tr = document.createElement('tr'); - var td = document.createElement('td'); - td.colSpan = block.columns.length; - td.className = 'text-muted'; - td.textContent = '暂无数据'; - tr.appendChild(td); - body.appendChild(tr); - return; - } - - block.rows.forEach(function (row) { - var tr = document.createElement('tr'); - block.columns.forEach(function (col) { - var td = document.createElement('td'); - var val = row[col.key]; - if (col.key === 'win_rate') { - td.textContent = fmtPct(val); - } else if (col.key === 'label') { - td.textContent = val || '-'; - } else if (typeof val === 'number') { - td.textContent = fmtNum(val); - td.className = cellClass(col.key, val); - } else { - td.textContent = val != null ? val : '-'; - } - tr.appendChild(td); - }); - body.appendChild(tr); - }); - } - - function applyData(data) { - cache = data; - setSummary(data.summary || {}); - var views = data.views || []; - var sel = document.getElementById('stats-view-select'); - var current = sel && sel.value ? sel.value : (views[0] && views[0].key); - fillViewSelect(views, current); - renderBreakdown(current); - var updated = document.getElementById('stats-updated'); - if (updated) { - updated.textContent = data.updated_at - ? '统计更新于 ' + data.updated_at.replace('T', ' ') - : '统计已加载'; - } - } - - function loadStats() { - fetch('/api/stats') - .then(function (r) { return r.json(); }) - .then(applyData) - .catch(function () { - var updated = document.getElementById('stats-updated'); - if (updated) updated.textContent = '加载失败,请刷新页面'; - }); - } - - document.addEventListener('DOMContentLoaded', function () { - var viewSel = document.getElementById('stats-view-select'); - if (viewSel) { - viewSel.addEventListener('change', function () { - renderBreakdown(this.value); - }); - } - loadStats(); - }); -})(); +/* Copyright (c) 2025-2026 马建军. All rights reserved. + * 专有软件 — 未经授权禁止复制、传播、转售。 + * 详见 LICENSE.zh-CN.txt + */ +(function () { + var cache = null; + + function fmtNum(v, suffix) { + if (v === null || v === undefined || v === '') return '-'; + var n = Number(v); + if (isNaN(n)) return String(v); + var s = Number.isInteger(n) ? String(n) : n.toFixed(2); + return suffix ? s + suffix : s; + } + + function fmtMoney(v) { + if (v === null || v === undefined) return '-'; + return fmtNum(v) + ' 元'; + } + + function fmtPct(v) { + if (v === null || v === undefined) return '-'; + return fmtNum(v) + '%'; + } + + function setSummary(s) { + var map = { + total_trades: function () { return fmtNum(s.total_trades); }, + win_rate: function () { return fmtPct(s.win_rate); }, + avg_profit: function () { return fmtMoney(s.avg_profit); }, + avg_loss: function () { return fmtMoney(s.avg_loss); }, + profit_loss_ratio: function () { return fmtNum(s.profit_loss_ratio); }, + consecutive_losses: function () { return fmtNum(s.consecutive_losses); }, + max_drawdown: function () { + var amt = fmtMoney(s.max_drawdown); + var pct = s.max_drawdown_pct ? ' (' + fmtPct(s.max_drawdown_pct) + ')' : ''; + return amt + pct; + }, + max_loss_amount: function () { return fmtMoney(s.max_loss_amount); }, + max_loss_pct: function () { return fmtPct(s.max_loss_pct); }, + max_profit_amount: function () { return fmtMoney(s.max_profit_amount); }, + max_profit_pct: function () { return fmtPct(s.max_profit_pct); }, + total_fee: function () { return fmtMoney(s.total_fee); }, + emotion_count: function () { return fmtNum(s.emotion_count); }, + emotion_ratio: function () { return fmtPct(s.emotion_ratio); }, + }; + document.querySelectorAll('#stats-summary [data-k]').forEach(function (el) { + var key = el.getAttribute('data-k'); + el.textContent = map[key] ? map[key]() : '-'; + }); + } + + function fillViewSelect(views, selected) { + var sel = document.getElementById('stats-view-select'); + if (!sel) return; + sel.innerHTML = ''; + views.forEach(function (v) { + var opt = document.createElement('option'); + opt.value = v.key; + opt.textContent = v.label; + if (v.key === selected) opt.selected = true; + sel.appendChild(opt); + }); + } + + function cellClass(key, val) { + if (key === 'total_net' || key === 'max_profit' || key === 'avg_profit') { + if (val > 0) return 'text-profit'; + if (val < 0) return 'text-loss'; + } + if (key === 'max_loss' || key === 'avg_loss' || key === 'total_fee') { + return 'text-loss'; + } + return ''; + } + + function renderBreakdown(key) { + if (!cache || !cache.breakdowns) return; + var block = cache.breakdowns[key]; + var head = document.getElementById('stats-breakdown-head'); + var body = document.getElementById('stats-breakdown-body'); + if (!block || !head || !body) return; + + head.innerHTML = ''; + block.columns.forEach(function (col) { + var th = document.createElement('th'); + th.textContent = col.label; + head.appendChild(th); + }); + + body.innerHTML = ''; + if (!block.rows || !block.rows.length) { + var tr = document.createElement('tr'); + var td = document.createElement('td'); + td.colSpan = block.columns.length; + td.className = 'text-muted'; + td.textContent = '暂无数据'; + tr.appendChild(td); + body.appendChild(tr); + return; + } + + block.rows.forEach(function (row) { + var tr = document.createElement('tr'); + block.columns.forEach(function (col) { + var td = document.createElement('td'); + var val = row[col.key]; + if (col.key === 'win_rate') { + td.textContent = fmtPct(val); + } else if (col.key === 'label') { + td.textContent = val || '-'; + } else if (typeof val === 'number') { + td.textContent = fmtNum(val); + td.className = cellClass(col.key, val); + } else { + td.textContent = val != null ? val : '-'; + } + tr.appendChild(td); + }); + body.appendChild(tr); + }); + } + + function applyData(data) { + cache = data; + setSummary(data.summary || {}); + var views = data.views || []; + var sel = document.getElementById('stats-view-select'); + var current = sel && sel.value ? sel.value : (views[0] && views[0].key); + fillViewSelect(views, current); + renderBreakdown(current); + var updated = document.getElementById('stats-updated'); + if (updated) { + updated.textContent = data.updated_at + ? '统计更新于 ' + data.updated_at.replace('T', ' ') + : '统计已加载'; + } + } + + function loadStats() { + fetch('/api/stats') + .then(function (r) { return r.json(); }) + .then(applyData) + .catch(function () { + var updated = document.getElementById('stats-updated'); + if (updated) updated.textContent = '加载失败,请刷新页面'; + }); + } + + document.addEventListener('DOMContentLoaded', function () { + var viewSel = document.getElementById('stats-view-select'); + if (viewSel) { + viewSel.addEventListener('change', function () { + renderBreakdown(this.value); + }); + } + loadStats(); + }); +})(); diff --git a/static/js/strategy.js b/static/js/strategy.js index 14f84ed..9495223 100644 --- a/static/js/strategy.js +++ b/static/js/strategy.js @@ -1,136 +1,140 @@ -(function () { - var trendPayload = null; - - function jsonPost(url, body) { - return fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body || {}) - }).then(function (r) { return r.json(); }); - } - - function formData(form) { - var fd = new FormData(form); - var o = {}; - fd.forEach(function (v, k) { o[k] = v; }); - return o; - } - - function showPreview(el, text, ok) { - if (!el) return; - if (!text) { - el.hidden = true; - el.textContent = ''; - return; - } - el.hidden = false; - el.textContent = text; - el.style.color = ok === false ? 'var(--loss)' : ''; - } - - function formatPlan(plan) { - if (!plan) return ''; - var lines = []; - if (plan.symbol) lines.push('品种:' + plan.symbol); - if (plan.target_lots != null) lines.push('目标手数:' + plan.target_lots); - if (plan.first_lots != null) lines.push('首仓:' + plan.first_lots + ' 手'); - if (plan.grid && plan.grid.length) { - lines.push('补仓档位:' + plan.grid.map(function (g) { return g.price; }).join(' → ')); - } - if (plan.message) lines.push(plan.message); - return lines.length ? lines.join('\n') : JSON.stringify(plan, null, 2); - } - - function formatRoll(preview) { - if (!preview) return ''; - var lines = []; - if (preview.add_lots != null) lines.push('加仓手数:' + preview.add_lots); - if (preview.new_stop_loss != null) lines.push('新止损:' + preview.new_stop_loss); - if (preview.total_lots != null) lines.push('合计手数:' + preview.total_lots); - if (preview.worst_loss != null) lines.push('最坏亏损:' + preview.worst_loss + ' 元'); - if (preview.message) lines.push(preview.message); - return lines.length ? lines.join('\n') : JSON.stringify(preview, null, 2); - } - - var trendForm = document.getElementById('trend-form'); - var btnPreview = document.getElementById('btn-trend-preview'); - var btnExec = document.getElementById('btn-trend-exec'); - var previewEl = document.getElementById('trend-preview'); - - if (btnPreview && trendForm) { - btnPreview.addEventListener('click', function () { - btnPreview.disabled = true; - jsonPost('/api/strategy/trend/preview', formData(trendForm)).then(function (d) { - if (!d.ok) { - showPreview(previewEl, d.error || '预览失败', false); - btnExec.hidden = true; - return; - } - trendPayload = formData(trendForm); - showPreview(previewEl, formatPlan(d.plan), true); - btnExec.hidden = false; - }).finally(function () { - btnPreview.disabled = false; - }); - }); - } - if (btnExec) { - btnExec.addEventListener('click', function () { - if (!trendPayload) return; - btnExec.disabled = true; - btnExec.textContent = '执行中…'; - jsonPost('/api/strategy/trend/execute', trendPayload).then(function (d) { - if (!d.ok) { alert(d.error); return; } - location.reload(); - }).finally(function () { - btnExec.disabled = false; - btnExec.textContent = '确认执行首仓'; - }); - }); - } - - var rollForm = document.getElementById('roll-form'); - var btnRollP = document.getElementById('btn-roll-preview'); - var btnRollE = document.getElementById('btn-roll-exec'); - var rollPrev = document.getElementById('roll-preview'); - if (btnRollP && rollForm) { - btnRollP.addEventListener('click', function () { - btnRollP.disabled = true; - jsonPost('/api/strategy/roll/preview', formData(rollForm)).then(function (d) { - if (!d.ok) { - showPreview(rollPrev, d.error, false); - btnRollE.hidden = true; - return; - } - showPreview(rollPrev, formatRoll(d.preview), true); - btnRollE.hidden = false; - }).finally(function () { - btnRollP.disabled = false; - }); - }); - } - if (btnRollE && rollForm) { - btnRollE.addEventListener('click', function () { - btnRollE.disabled = true; - btnRollE.textContent = '执行中…'; - jsonPost('/api/strategy/roll/execute', formData(rollForm)).then(function (d) { - if (!d.ok) { alert(d.error); return; } - location.reload(); - }).finally(function () { - btnRollE.disabled = false; - btnRollE.textContent = '执行滚仓'; - }); - }); - } - - var btnStop = document.getElementById('btn-trend-stop'); - if (btnStop) { - btnStop.addEventListener('click', function () { - var pid = document.querySelector('#trend-stop-form input[name=plan_id]'); - jsonPost('/api/strategy/trend/stop', { plan_id: pid ? pid.value : 0 }).then(function (d) { - if (!d.ok) { alert(d.error); return; } - location.reload(); - }); - }); - } -})(); +/* Copyright (c) 2025-2026 马建军. All rights reserved. + * 专有软件 — 未经授权禁止复制、传播、转售。 + * 详见 LICENSE.zh-CN.txt + */ +(function () { + var trendPayload = null; + + function jsonPost(url, body) { + return fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body || {}) + }).then(function (r) { return r.json(); }); + } + + function formData(form) { + var fd = new FormData(form); + var o = {}; + fd.forEach(function (v, k) { o[k] = v; }); + return o; + } + + function showPreview(el, text, ok) { + if (!el) return; + if (!text) { + el.hidden = true; + el.textContent = ''; + return; + } + el.hidden = false; + el.textContent = text; + el.style.color = ok === false ? 'var(--loss)' : ''; + } + + function formatPlan(plan) { + if (!plan) return ''; + var lines = []; + if (plan.symbol) lines.push('品种:' + plan.symbol); + if (plan.target_lots != null) lines.push('目标手数:' + plan.target_lots); + if (plan.first_lots != null) lines.push('首仓:' + plan.first_lots + ' 手'); + if (plan.grid && plan.grid.length) { + lines.push('补仓档位:' + plan.grid.map(function (g) { return g.price; }).join(' → ')); + } + if (plan.message) lines.push(plan.message); + return lines.length ? lines.join('\n') : JSON.stringify(plan, null, 2); + } + + function formatRoll(preview) { + if (!preview) return ''; + var lines = []; + if (preview.add_lots != null) lines.push('加仓手数:' + preview.add_lots); + if (preview.new_stop_loss != null) lines.push('新止损:' + preview.new_stop_loss); + if (preview.total_lots != null) lines.push('合计手数:' + preview.total_lots); + if (preview.worst_loss != null) lines.push('最坏亏损:' + preview.worst_loss + ' 元'); + if (preview.message) lines.push(preview.message); + return lines.length ? lines.join('\n') : JSON.stringify(preview, null, 2); + } + + var trendForm = document.getElementById('trend-form'); + var btnPreview = document.getElementById('btn-trend-preview'); + var btnExec = document.getElementById('btn-trend-exec'); + var previewEl = document.getElementById('trend-preview'); + + if (btnPreview && trendForm) { + btnPreview.addEventListener('click', function () { + btnPreview.disabled = true; + jsonPost('/api/strategy/trend/preview', formData(trendForm)).then(function (d) { + if (!d.ok) { + showPreview(previewEl, d.error || '预览失败', false); + btnExec.hidden = true; + return; + } + trendPayload = formData(trendForm); + showPreview(previewEl, formatPlan(d.plan), true); + btnExec.hidden = false; + }).finally(function () { + btnPreview.disabled = false; + }); + }); + } + if (btnExec) { + btnExec.addEventListener('click', function () { + if (!trendPayload) return; + btnExec.disabled = true; + btnExec.textContent = '执行中…'; + jsonPost('/api/strategy/trend/execute', trendPayload).then(function (d) { + if (!d.ok) { alert(d.error); return; } + location.reload(); + }).finally(function () { + btnExec.disabled = false; + btnExec.textContent = '确认执行首仓'; + }); + }); + } + + var rollForm = document.getElementById('roll-form'); + var btnRollP = document.getElementById('btn-roll-preview'); + var btnRollE = document.getElementById('btn-roll-exec'); + var rollPrev = document.getElementById('roll-preview'); + if (btnRollP && rollForm) { + btnRollP.addEventListener('click', function () { + btnRollP.disabled = true; + jsonPost('/api/strategy/roll/preview', formData(rollForm)).then(function (d) { + if (!d.ok) { + showPreview(rollPrev, d.error, false); + btnRollE.hidden = true; + return; + } + showPreview(rollPrev, formatRoll(d.preview), true); + btnRollE.hidden = false; + }).finally(function () { + btnRollP.disabled = false; + }); + }); + } + if (btnRollE && rollForm) { + btnRollE.addEventListener('click', function () { + btnRollE.disabled = true; + btnRollE.textContent = '执行中…'; + jsonPost('/api/strategy/roll/execute', formData(rollForm)).then(function (d) { + if (!d.ok) { alert(d.error); return; } + location.reload(); + }).finally(function () { + btnRollE.disabled = false; + btnRollE.textContent = '执行滚仓'; + }); + }); + } + + var btnStop = document.getElementById('btn-trend-stop'); + if (btnStop) { + btnStop.addEventListener('click', function () { + var pid = document.querySelector('#trend-stop-form input[name=plan_id]'); + jsonPost('/api/strategy/trend/stop', { plan_id: pid ? pid.value : 0 }).then(function (d) { + if (!d.ok) { alert(d.error); return; } + location.reload(); + }); + }); + } +})(); diff --git a/static/js/symbol.js b/static/js/symbol.js index 596bcc3..78242d5 100644 --- a/static/js/symbol.js +++ b/static/js/symbol.js @@ -1,282 +1,286 @@ -(function () { - var recommendedGroupsCache = null; - var recommendedGroupsPromise = null; - - function loadRecommendedGroups() { - if (recommendedGroupsCache) { - return Promise.resolve(recommendedGroupsCache); - } - if (recommendedGroupsPromise) { - return recommendedGroupsPromise; - } - recommendedGroupsPromise = fetch('/api/symbols/recommended') - .then(function (r) { - if (!r.ok) { - throw new Error('HTTP ' + r.status); - } - return r.json(); - }) - .then(function (groups) { - recommendedGroupsCache = Array.isArray(groups) ? groups : []; - return recommendedGroupsCache; - }) - .catch(function () { - recommendedGroupsCache = null; - throw new Error('load failed'); - }) - .finally(function () { - recommendedGroupsPromise = null; - }); - return recommendedGroupsPromise; - } - - function formatSub(item) { - var sub = '同花顺 ' + item.ths_code + - (item.market_code ? ' · ' + item.market_code : '') + - ' · ' + (item.exchange || ''); - if (item.max_lots != null && item.max_lots > 0) { - sub += ' · 最大 ' + item.max_lots + ' 手'; - } - return sub; - } - - function formatInputLabel(item) { - return item.input_label || (item.name + ' ' + item.ths_code); - } - - function itemMatchesQuery(item, qLower) { - if (!qLower) return true; - var hay = ( - item.name + ' ' + item.ths_code + ' ' + - (item.display || '') + ' ' + (item.contract || '') + ' ' + - (item.exchange || '') - ).toLowerCase(); - return hay.indexOf(qLower) >= 0; - } - - function groupedHasMatch(groups, qLower) { - if (!qLower) return true; - return groups.some(function (group) { - return group.items.some(function (item) { - return itemMatchesQuery(item, qLower); - }); - }); - } - - function initSymbolInput(wrapper) { - const input = wrapper.querySelector('.symbol-input'); - const hiddenThs = wrapper.querySelector('input[name="symbol"]') - || wrapper.querySelector('.symbol-ths-code'); - const hiddenName = wrapper.querySelector('input[name="symbol_name"]'); - const hiddenMarket = wrapper.querySelector('input[name="market_code"]'); - const hiddenSina = wrapper.querySelector('input[name="sina_code"]'); - const dropdown = wrapper.querySelector('.symbol-dropdown'); - const selectedEl = wrapper.querySelector('.symbol-selected'); - const isMarketPicker = wrapper.classList.contains('market-symbol-wrap'); - const useMainsPicker = isMarketPicker || wrapper.classList.contains('symbol-mains'); - let timer = null; - let abortCtrl = null; - const cache = new Map(); - let mainsCache = null; - - function hideDropdown() { - dropdown.classList.remove('show'); - } - - function selectItem(item) { - const label = formatInputLabel(item); - input.value = label; - if (hiddenThs) hiddenThs.value = item.ths_code; - if (hiddenName) hiddenName.value = item.name; - if (hiddenMarket) hiddenMarket.value = item.market_code || ''; - if (hiddenSina) hiddenSina.value = item.sina_code || ''; - if (selectedEl) selectedEl.textContent = formatSub(item); - hideDropdown(); - input.dispatchEvent(new CustomEvent('symbol-selected', { detail: item, bubbles: true })); - } - - function buildOptionEl(item) { - const div = document.createElement('div'); - div.className = 'symbol-option'; - if (item.near_expiry) { - div.classList.add('near-expiry'); - } - var label = item.display || (item.name + ' ' + item.ths_code); - if (item.near_expiry) { - label += ' 临期'; - } - div.innerHTML = label + - '
' + formatSub(item) + '
'; - div.addEventListener('mousedown', function (e) { - e.preventDefault(); - selectItem(item); - }); - return div; - } - - function renderItems(items) { - dropdown.innerHTML = ''; - if (!items.length) { - dropdown.innerHTML = '
无匹配,可输入同花顺代码如 ag2608
'; - } else { - items.forEach(function (item) { - dropdown.appendChild(buildOptionEl(item)); - }); - } - dropdown.classList.add('show'); - } - - function renderGrouped(groups, filterQ) { - dropdown.innerHTML = ''; - const qLower = (filterQ || '').trim().toLowerCase(); - let any = false; - groups.forEach(function (group) { - const items = group.items.filter(function (item) { - return itemMatchesQuery(item, qLower); - }); - if (!items.length) return; - any = true; - const head = document.createElement('div'); - head.className = 'symbol-group-head'; - head.textContent = group.category; - dropdown.appendChild(head); - items.forEach(function (item) { - dropdown.appendChild(buildOptionEl(item)); - }); - }); - if (!any) { - dropdown.innerHTML = '
无匹配品种,可输入合约代码如 ag2608
'; - } - dropdown.classList.add('show'); - } - - function showMarketMains(filterQ, onEmpty) { - const q = (filterQ || '').trim(); - const qLower = q.toLowerCase(); - if (mainsCache) { - if (!q || groupedHasMatch(mainsCache, qLower)) { - renderGrouped(mainsCache, q); - return; - } - if (typeof onEmpty === 'function') { - onEmpty(q); - return; - } - renderGrouped(mainsCache, q); - return; - } - dropdown.innerHTML = '
正在加载推荐品种…
'; - dropdown.classList.add('show'); - loadRecommendedGroups() - .then(function (groups) { - mainsCache = groups; - if (!groups.length) { - dropdown.innerHTML = - '
当前资金下暂无推荐品种,可输入合约代码搜索
'; - dropdown.classList.add('show'); - return; - } - showMarketMains(filterQ, onEmpty); - }) - .catch(function () { - dropdown.innerHTML = - '
推荐品种加载失败,请刷新页面或输入合约代码搜索
'; - dropdown.classList.add('show'); - }); - } - - function search(q) { - if (cache.has(q)) { - renderItems(cache.get(q)); - return; - } - if (abortCtrl) { - abortCtrl.abort(); - } - abortCtrl = new AbortController(); - fetch('/api/symbols/search?q=' + encodeURIComponent(q), { - signal: abortCtrl.signal, - }) - .then(function (r) { return r.json(); }) - .then(function (items) { - cache.set(q, items); - renderItems(items); - }) - .catch(function (err) { - if (err && err.name === 'AbortError') return; - hideDropdown(); - }); - } - - function handleQuery(q) { - if (useMainsPicker) { - showMarketMains(q, function (query) { - search(query); - }); - } else { - search(q); - } - } - - input.addEventListener('input', function () { - if (hiddenThs) hiddenThs.value = ''; - if (hiddenName) hiddenName.value = ''; - if (hiddenMarket) hiddenMarket.value = ''; - if (hiddenSina) hiddenSina.value = ''; - if (selectedEl) selectedEl.textContent = ''; - const q = input.value.trim(); - if (!q) { - if (useMainsPicker) { - showMarketMains(''); - } else { - hideDropdown(); - } - return; - } - clearTimeout(timer); - timer = setTimeout(function () { - handleQuery(q); - }, 120); - }); - - input.addEventListener('blur', function () { - setTimeout(hideDropdown, 150); - }); - - input.addEventListener('focus', function () { - const q = input.value.trim(); - if (useMainsPicker) { - showMarketMains(q, function (query) { - if (query) search(query); - }); - return; - } - if (q && hiddenThs && !hiddenThs.value) { - search(q); - } - }); - } - - document.addEventListener('DOMContentLoaded', function () { - document.querySelectorAll('.symbol-wrap').forEach(initSymbolInput); - - document.querySelectorAll('form').forEach(function (form) { - if (!form.querySelector('.symbol-wrap')) return; - if (form.id === 'market-form') return; - form.addEventListener('submit', function (e) { - const ths = form.querySelector('input[name="symbol"]') - || form.querySelector('.symbol-ths-code'); - const market = form.querySelector('input[name="market_code"]'); - if (ths && !ths.value.trim()) { - e.preventDefault(); - alert('请从下拉列表选择品种'); - return; - } - if (market && !market.value.trim()) { - e.preventDefault(); - alert('请从下拉列表选择品种(需含同花顺行情代码)'); - } - }); - }); - }); -})(); +/* Copyright (c) 2025-2026 马建军. All rights reserved. + * 专有软件 — 未经授权禁止复制、传播、转售。 + * 详见 LICENSE.zh-CN.txt + */ +(function () { + var recommendedGroupsCache = null; + var recommendedGroupsPromise = null; + + function loadRecommendedGroups() { + if (recommendedGroupsCache) { + return Promise.resolve(recommendedGroupsCache); + } + if (recommendedGroupsPromise) { + return recommendedGroupsPromise; + } + recommendedGroupsPromise = fetch('/api/symbols/recommended') + .then(function (r) { + if (!r.ok) { + throw new Error('HTTP ' + r.status); + } + return r.json(); + }) + .then(function (groups) { + recommendedGroupsCache = Array.isArray(groups) ? groups : []; + return recommendedGroupsCache; + }) + .catch(function () { + recommendedGroupsCache = null; + throw new Error('load failed'); + }) + .finally(function () { + recommendedGroupsPromise = null; + }); + return recommendedGroupsPromise; + } + + function formatSub(item) { + var sub = '同花顺 ' + item.ths_code + + (item.market_code ? ' · ' + item.market_code : '') + + ' · ' + (item.exchange || ''); + if (item.max_lots != null && item.max_lots > 0) { + sub += ' · 最大 ' + item.max_lots + ' 手'; + } + return sub; + } + + function formatInputLabel(item) { + return item.input_label || (item.name + ' ' + item.ths_code); + } + + function itemMatchesQuery(item, qLower) { + if (!qLower) return true; + var hay = ( + item.name + ' ' + item.ths_code + ' ' + + (item.display || '') + ' ' + (item.contract || '') + ' ' + + (item.exchange || '') + ).toLowerCase(); + return hay.indexOf(qLower) >= 0; + } + + function groupedHasMatch(groups, qLower) { + if (!qLower) return true; + return groups.some(function (group) { + return group.items.some(function (item) { + return itemMatchesQuery(item, qLower); + }); + }); + } + + function initSymbolInput(wrapper) { + const input = wrapper.querySelector('.symbol-input'); + const hiddenThs = wrapper.querySelector('input[name="symbol"]') + || wrapper.querySelector('.symbol-ths-code'); + const hiddenName = wrapper.querySelector('input[name="symbol_name"]'); + const hiddenMarket = wrapper.querySelector('input[name="market_code"]'); + const hiddenSina = wrapper.querySelector('input[name="sina_code"]'); + const dropdown = wrapper.querySelector('.symbol-dropdown'); + const selectedEl = wrapper.querySelector('.symbol-selected'); + const isMarketPicker = wrapper.classList.contains('market-symbol-wrap'); + const useMainsPicker = isMarketPicker || wrapper.classList.contains('symbol-mains'); + let timer = null; + let abortCtrl = null; + const cache = new Map(); + let mainsCache = null; + + function hideDropdown() { + dropdown.classList.remove('show'); + } + + function selectItem(item) { + const label = formatInputLabel(item); + input.value = label; + if (hiddenThs) hiddenThs.value = item.ths_code; + if (hiddenName) hiddenName.value = item.name; + if (hiddenMarket) hiddenMarket.value = item.market_code || ''; + if (hiddenSina) hiddenSina.value = item.sina_code || ''; + if (selectedEl) selectedEl.textContent = formatSub(item); + hideDropdown(); + input.dispatchEvent(new CustomEvent('symbol-selected', { detail: item, bubbles: true })); + } + + function buildOptionEl(item) { + const div = document.createElement('div'); + div.className = 'symbol-option'; + if (item.near_expiry) { + div.classList.add('near-expiry'); + } + var label = item.display || (item.name + ' ' + item.ths_code); + if (item.near_expiry) { + label += ' 临期'; + } + div.innerHTML = label + + '
' + formatSub(item) + '
'; + div.addEventListener('mousedown', function (e) { + e.preventDefault(); + selectItem(item); + }); + return div; + } + + function renderItems(items) { + dropdown.innerHTML = ''; + if (!items.length) { + dropdown.innerHTML = '
无匹配,可输入同花顺代码如 ag2608
'; + } else { + items.forEach(function (item) { + dropdown.appendChild(buildOptionEl(item)); + }); + } + dropdown.classList.add('show'); + } + + function renderGrouped(groups, filterQ) { + dropdown.innerHTML = ''; + const qLower = (filterQ || '').trim().toLowerCase(); + let any = false; + groups.forEach(function (group) { + const items = group.items.filter(function (item) { + return itemMatchesQuery(item, qLower); + }); + if (!items.length) return; + any = true; + const head = document.createElement('div'); + head.className = 'symbol-group-head'; + head.textContent = group.category; + dropdown.appendChild(head); + items.forEach(function (item) { + dropdown.appendChild(buildOptionEl(item)); + }); + }); + if (!any) { + dropdown.innerHTML = '
无匹配品种,可输入合约代码如 ag2608
'; + } + dropdown.classList.add('show'); + } + + function showMarketMains(filterQ, onEmpty) { + const q = (filterQ || '').trim(); + const qLower = q.toLowerCase(); + if (mainsCache) { + if (!q || groupedHasMatch(mainsCache, qLower)) { + renderGrouped(mainsCache, q); + return; + } + if (typeof onEmpty === 'function') { + onEmpty(q); + return; + } + renderGrouped(mainsCache, q); + return; + } + dropdown.innerHTML = '
正在加载推荐品种…
'; + dropdown.classList.add('show'); + loadRecommendedGroups() + .then(function (groups) { + mainsCache = groups; + if (!groups.length) { + dropdown.innerHTML = + '
当前资金下暂无推荐品种,可输入合约代码搜索
'; + dropdown.classList.add('show'); + return; + } + showMarketMains(filterQ, onEmpty); + }) + .catch(function () { + dropdown.innerHTML = + '
推荐品种加载失败,请刷新页面或输入合约代码搜索
'; + dropdown.classList.add('show'); + }); + } + + function search(q) { + if (cache.has(q)) { + renderItems(cache.get(q)); + return; + } + if (abortCtrl) { + abortCtrl.abort(); + } + abortCtrl = new AbortController(); + fetch('/api/symbols/search?q=' + encodeURIComponent(q), { + signal: abortCtrl.signal, + }) + .then(function (r) { return r.json(); }) + .then(function (items) { + cache.set(q, items); + renderItems(items); + }) + .catch(function (err) { + if (err && err.name === 'AbortError') return; + hideDropdown(); + }); + } + + function handleQuery(q) { + if (useMainsPicker) { + showMarketMains(q, function (query) { + search(query); + }); + } else { + search(q); + } + } + + input.addEventListener('input', function () { + if (hiddenThs) hiddenThs.value = ''; + if (hiddenName) hiddenName.value = ''; + if (hiddenMarket) hiddenMarket.value = ''; + if (hiddenSina) hiddenSina.value = ''; + if (selectedEl) selectedEl.textContent = ''; + const q = input.value.trim(); + if (!q) { + if (useMainsPicker) { + showMarketMains(''); + } else { + hideDropdown(); + } + return; + } + clearTimeout(timer); + timer = setTimeout(function () { + handleQuery(q); + }, 120); + }); + + input.addEventListener('blur', function () { + setTimeout(hideDropdown, 150); + }); + + input.addEventListener('focus', function () { + const q = input.value.trim(); + if (useMainsPicker) { + showMarketMains(q, function (query) { + if (query) search(query); + }); + return; + } + if (q && hiddenThs && !hiddenThs.value) { + search(q); + } + }); + } + + document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('.symbol-wrap').forEach(initSymbolInput); + + document.querySelectorAll('form').forEach(function (form) { + if (!form.querySelector('.symbol-wrap')) return; + if (form.id === 'market-form') return; + form.addEventListener('submit', function (e) { + const ths = form.querySelector('input[name="symbol"]') + || form.querySelector('.symbol-ths-code'); + const market = form.querySelector('input[name="market_code"]'); + if (ths && !ths.value.trim()) { + e.preventDefault(); + alert('请从下拉列表选择品种'); + return; + } + if (market && !market.value.trim()) { + e.preventDefault(); + alert('请从下拉列表选择品种(需含同花顺行情代码)'); + } + }); + }); + }); +})(); diff --git a/static/js/theme.js b/static/js/theme.js index 532d9cd..0b5396c 100644 --- a/static/js/theme.js +++ b/static/js/theme.js @@ -1,53 +1,57 @@ -(function () { - var KEY = 'qihuo-theme'; - - function updateButtons(theme) { - document.querySelectorAll('[data-theme-pick]').forEach(function (btn) { - var pick = btn.getAttribute('data-theme-pick'); - var on = pick === theme; - btn.classList.toggle('active', on); - btn.setAttribute('aria-pressed', on ? 'true' : 'false'); - }); - } - - function apply(theme) { - if (theme !== 'light' && theme !== 'dark') { - theme = 'dark'; - } - document.documentElement.setAttribute('data-theme', theme); - try { - localStorage.setItem(KEY, theme); - } catch (e) { /* ignore */ } - updateButtons(theme); - } - - var saved = null; - try { - saved = localStorage.getItem(KEY); - } catch (e) { /* ignore */ } - - if (saved === 'light' || saved === 'dark') { - apply(saved); - } else { - var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches; - apply(prefersLight ? 'light' : 'dark'); - } - - document.addEventListener('click', function (e) { - var btn = e.target.closest('[data-theme-pick]'); - if (!btn) return; - e.preventDefault(); - apply(btn.getAttribute('data-theme-pick')); - }); - - function syncButtons() { - var cur = document.documentElement.getAttribute('data-theme') || 'dark'; - updateButtons(cur); - } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', syncButtons); - } else { - syncButtons(); - } -})(); +/* Copyright (c) 2025-2026 马建军. All rights reserved. + * 专有软件 — 未经授权禁止复制、传播、转售。 + * 详见 LICENSE.zh-CN.txt + */ +(function () { + var KEY = 'qihuo-theme'; + + function updateButtons(theme) { + document.querySelectorAll('[data-theme-pick]').forEach(function (btn) { + var pick = btn.getAttribute('data-theme-pick'); + var on = pick === theme; + btn.classList.toggle('active', on); + btn.setAttribute('aria-pressed', on ? 'true' : 'false'); + }); + } + + function apply(theme) { + if (theme !== 'light' && theme !== 'dark') { + theme = 'dark'; + } + document.documentElement.setAttribute('data-theme', theme); + try { + localStorage.setItem(KEY, theme); + } catch (e) { /* ignore */ } + updateButtons(theme); + } + + var saved = null; + try { + saved = localStorage.getItem(KEY); + } catch (e) { /* ignore */ } + + if (saved === 'light' || saved === 'dark') { + apply(saved); + } else { + var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches; + apply(prefersLight ? 'light' : 'dark'); + } + + document.addEventListener('click', function (e) { + var btn = e.target.closest('[data-theme-pick]'); + if (!btn) return; + e.preventDefault(); + apply(btn.getAttribute('data-theme-pick')); + }); + + function syncButtons() { + var cur = document.documentElement.getAttribute('data-theme') || 'dark'; + updateButtons(cur); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', syncButtons); + } else { + syncButtons(); + } +})(); diff --git a/static/js/trade.js b/static/js/trade.js index 58c3ad3..25383f0 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -1,1483 +1,1487 @@ -(function () { - var sizingMode = window.TRADE_SIZING_MODE || 'fixed'; - if (sizingMode === 'risk') sizingMode = 'amount'; - 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 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 recommendSource = null; - var positionSource = null; - var quoteTimer = null; - var calcTimer = null; - var lastQuotePrice = null; - var priceType = 'limit'; - var lastCtpReconnectAt = 0; - var lastCtpUnreachableAt = 0; - var lastCtpLoginBanAt = 0; - var ctpReconnecting = false; - var ctpConnectInflight = false; - var isTradingSession = false; - var hasSlTpMonitoring = false; - var ctpConnected = false; - var positionsRendered = false; - var selectedMaxLots = null; - var recommendMaxByProduct = {}; - var recommendMaxByCode = {}; - var recRowsRaw = []; - var recSortKey = 'trend'; - var recSortDesc = true; - var recIndustryFilter = ''; - var REC_SORT_CACHE = 'qihuo_rec_sort_v2'; - var REC_INDUSTRY_CACHE = 'qihuo_rec_industry_v1'; - var REC_COLSPAN = 16; - var productCategories = window.PRODUCT_CATEGORIES || []; - var POS_CACHE_KEY = 'qihuo_trading_live_v3'; - - function runWhenReady(fn) { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', fn); - } else { - fn(); - } - } - - function fmtNum(v, digits) { - if (v === null || v === undefined) return '--'; - return Number(v).toFixed(digits === undefined ? 2 : digits); - } - - function selectedSymbol() { - var codeEl = document.getElementById('trade-symbol-code'); - var code = codeEl && codeEl.value ? codeEl.value.trim() : ''; - if (code) return code; - return (symInput && symInput.value || '').trim(); - } - - function isFixedMode() { - return sizingMode === 'fixed'; - } - - function isAmountMode() { - return sizingMode === 'amount'; - } - - function effectiveLots() { - var v = parseInt(lotsCalc && lotsCalc.value, 10); - return v > 0 ? v : 0; - } - - function updateRecommendMaxMaps(data) { - recommendMaxByProduct = {}; - recommendMaxByCode = {}; - (data && data.rows || []).forEach(function (r) { - if (!r || r.max_lots <= 0) return; - if (r.status !== 'ok' && r.status !== 'margin_ok') return; - if (r.ths) recommendMaxByProduct[String(r.ths).toLowerCase()] = r.max_lots; - if (r.main_code) recommendMaxByCode[String(r.main_code).toLowerCase()] = r.max_lots; - }); - checkLotsLimit(); - } - - function maxLotsForSymbol(sym) { - if (selectedMaxLots > 0) return selectedMaxLots; - var code = (sym || '').trim().toLowerCase(); - if (!code) return 0; - if (recommendMaxByCode[code]) return recommendMaxByCode[code]; - var m = code.match(/^([a-z]+)/i); - if (m && recommendMaxByProduct[m[1].toLowerCase()]) { - return recommendMaxByProduct[m[1].toLowerCase()]; - } - return 0; - } - - function checkLotsLimit() { - var warn = document.getElementById('lots-warn'); - if (!warn) return; - var sym = selectedSymbol(); - var maxLots = maxLotsForSymbol(sym); - var lots = effectiveLots(); - if (maxLots > 0 && lots > maxLots) { - warn.hidden = false; - warn.textContent = '已超过最大手数 ' + maxLots + ' 手,请调整手数'; - } else { - warn.hidden = true; - warn.textContent = ''; - } - } - - function loadPosCache() { - try { - var raw = sessionStorage.getItem(POS_CACHE_KEY); - if (!raw) return null; - return JSON.parse(raw); - } catch (e) { - return null; - } - } - - function savePosCache(data) { - try { - if (!data || !data.rows || !data.rows.length) { - var prev = loadPosCache(); - if (prev && prev.rows && prev.rows.length) return; - } - sessionStorage.setItem(POS_CACHE_KEY, JSON.stringify(data)); - } catch (e) { /* quota */ } - } - - function showCtpError(msg) { - var hint = document.querySelector('.ctp-install-hint'); - if (hint) hint.textContent = msg || ''; - } - - function isCtpLoginBanError(msg) { - return !!(msg && ( - msg.indexOf('登录被禁止') >= 0 || - msg.indexOf('连续登录失败') >= 0 || - msg.indexOf('登录冷却') >= 0 || - msg.indexOf('错误码 75') >= 0 - )); - } - - function isCtpUnreachableError(msg) { - return !!(msg && (msg.indexOf('不可达') >= 0 || msg.indexOf('Connection refused') >= 0 || msg.indexOf('timed out') >= 0)); - } - - function applyPositionsData(data) { - if (!list || !data) return; - var cap = document.getElementById('cap-display'); - if (cap && data.capital != null) cap.textContent = Number(data.capital).toFixed(2); - var connected = data.ctp_status && data.ctp_status.connected; - var connecting = data.ctp_status && data.ctp_status.connecting; - var cooldownSec = (data.ctp_status && data.ctp_status.login_cooldown_sec) || 0; - if (cooldownSec > 0) connecting = false; - ctpConnected = !!connected; - isTradingSession = !!data.trading_session; - syncCtpBadgeFromStatus(data.ctp_status || { connected: connected, connecting: connecting }); - if (!connected && !connecting && data.ctp_status && data.ctp_status.last_error) { - showCtpError(data.ctp_status.last_error); - if (isCtpLoginBanError(data.ctp_status.last_error)) { - lastCtpLoginBanAt = Date.now(); - } else if (isCtpUnreachableError(data.ctp_status.last_error)) { - lastCtpUnreachableAt = Date.now(); - } - } - var riskBadge = document.getElementById('risk-badge'); - if (riskBadge && data.risk_status) { - riskBadge.textContent = data.risk_status.status_label || ''; - riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss'); - } - var rows = data.rows || []; - var seenKeys = {}; - rows = rows.filter(function (row) { - var k = row.key || ((row.symbol_code || '') + ':' + (row.direction || '')); - if (seenKeys[k]) return false; - seenKeys[k] = true; - return true; - }); - hasSlTpMonitoring = rows.some(function (row) { - return row.stop_loss != null || row.take_profit != null; - }); - updateSessionUi(); - savePosCache(data); - positionsRendered = true; - if (!rows.length) { - if (!connected) { - if (connecting) { - list.innerHTML = '
CTP 连接中,请稍候…
'; - return; - } - if (cooldownSec > 0 || (data.ctp_status && data.ctp_status.last_error)) { - var err = (data.ctp_status && data.ctp_status.last_error) || 'CTP 未连接'; - list.innerHTML = '
' + err + '
'; - return; - } - list.innerHTML = '
CTP 未连接,正在尝试自动重连…
'; - tryAutoCtpReconnect(); - return; - } - var pendingOnly = data.pending_orders || []; - if (pendingOnly.length) { - list.innerHTML = '
暂无持仓
' + - pendingOnly.map(function (p) { - var cancelAllowed = p.cancel_allowed !== false && isTradingSession; - var actionBtn = ''; - if (p.monitor_id) { - actionBtn = ''; - } else if (p.order_id && p.source === 'ctp') { - actionBtn = ''; - } - return ( - '
' + (p.label || '挂单') + ' · ' + (p.symbol || p.symbol_code) + '' + - '' + fmtNum(p.price) + ' · ' + - (p.lots || 1) + ' 手' + actionBtn + '
' - ); - }).join(''); - bindPendingDismiss(list); - bindCancelOrderButtons(list); - } else { - list.innerHTML = '
暂无持仓。
'; - } - return; - } - if (!connected) { - tryAutoCtpReconnect(); - } - list.innerHTML = rows.map(buildPosCard).join(''); - bindPendingDismiss(list); - bindCancelOpenButtons(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); - }); - }); - } - - function schedulePositionPoll() { - /* 持仓改由后台 SSE 推送,保留空函数兼容旧调用 */ - } - - function updateSessionUi() { - var btnOpen = document.getElementById('btn-open'); - var sessionHint = document.getElementById('session-hint'); - if (btnOpen) { - btnOpen.disabled = !isTradingSession; - btnOpen.classList.toggle('btn-session-off', !isTradingSession); - } - if (sessionHint) { - sessionHint.hidden = !!isTradingSession; - } - } - - function entryPrice() { - if (priceType === 'market') return lastQuotePrice; - return parseFloat(priceInput && priceInput.value) || 0; - } - - function calcRR(direction, entry, sl, tp) { - entry = parseFloat(entry); - sl = parseFloat(sl); - tp = parseFloat(tp); - if (!entry || !sl || !tp) return null; - var risk, reward; - if (direction === 'long') { - risk = entry - sl; - reward = tp - entry; - } else if (direction === 'short') { - risk = sl - entry; - reward = entry - tp; - } else { - return null; - } - if (risk <= 0 || reward <= 0) return null; - return (reward / risk).toFixed(2); - } - - function updateRRDisplay() { - var el = document.getElementById('trade-rr-hint'); - if (!el) return; - var dir = dirSelect ? dirSelect.value : 'long'; - var entry = entryPrice(); - var sl = slInput && slInput.value ? parseFloat(slInput.value) : 0; - var tp = tpInput && tpInput.value ? parseFloat(tpInput.value) : 0; - var lots = effectiveLots(); - var parts = []; - var rr = calcRR(dir, entry, sl, tp); - if (rr) parts.push('盈亏比 ' + rr + ':1'); - if (sl > 0 && entry > 0 && lots > 0 && lastPreviewMetrics) { - if (lastPreviewMetrics.risk_amount != null) { - parts.push('止损金额 ' + fmtNum(lastPreviewMetrics.risk_amount) + ' 元'); - } - if (lastPreviewMetrics.reward_amount != null && tp > 0) { - parts.push('止盈金额 ' + fmtNum(lastPreviewMetrics.reward_amount) + ' 元'); - } - } - if (parts.length) { - el.textContent = parts.join(' · '); - el.hidden = false; - } else { - el.textContent = ''; - el.hidden = true; - } - } - - var lastPreviewMetrics = null; - - 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'; - updateRRDisplay(); - } - - function syncCtpBadgeFromStatus(st) { - if (!st) return; - var connected = !!st.connected; - var connecting = !!st.connecting; - if ((st.login_cooldown_sec || 0) > 0) { - connecting = false; - } - updateCtpBadge(connected, connecting); - } - - function updateCtpBadge(connected, connecting) { - var ctpBadge = document.getElementById('ctp-badge'); - var btnConnect = document.getElementById('btn-ctp-connect'); - if (ctpBadge) { - if (connecting) { - ctpBadge.textContent = 'CTP 连接中'; - ctpBadge.className = 'badge planned'; - } else { - ctpBadge.textContent = connected ? 'CTP 已连接' : 'CTP 未连接'; - ctpBadge.className = 'badge ' + (connected ? 'profit' : 'planned'); - } - } - if (btnConnect) { - if (connecting) { - btnConnect.textContent = '连接中…'; - btnConnect.disabled = true; - } else { - btnConnect.disabled = false; - btnConnect.textContent = connected ? '重连 CTP' : '连接 CTP'; - } - } - } - - function waitForCtpConnected(maxMs) { - var deadline = Date.now() + (maxMs || 70000); - function tick() { - return fetch('/api/ctp/status') - .then(function (r) { return r.json(); }) - .then(function (d) { - var st = d.status || {}; - if (st.connected) { - syncCtpBadgeFromStatus(st); - showCtpError(''); - if (d.account && d.account.available != null) { - var avail = document.getElementById('avail-display'); - if (avail) avail.textContent = Number(d.account.available).toFixed(2); - } - pollPositions(); - return true; - } - if ((st.login_cooldown_sec || 0) > 0) { - syncCtpBadgeFromStatus(st); - if (st.last_error) showCtpError(st.last_error); - return false; - } - if (st.connecting && Date.now() < deadline) { - syncCtpBadgeFromStatus(st); - return new Promise(function (resolve) { - setTimeout(function () { resolve(tick()); }, 2000); - }); - } - syncCtpBadgeFromStatus(st); - if (st.last_error) { - showCtpError(st.last_error); - if (isCtpLoginBanError(st.last_error)) { - lastCtpLoginBanAt = Date.now(); - } else if (isCtpUnreachableError(st.last_error)) { - lastCtpUnreachableAt = Date.now(); - } - } - return false; - }) - .catch(function () { updateCtpBadge(false, false); return false; }); - } - return tick(); - } - - function requestCtpConnect(force) { - if (!force && ctpConnectInflight) { - return Promise.resolve({}); - } - ctpConnectInflight = true; - return fetch('/api/ctp/connect', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ force: !!force, auto: !force }) - }) - .then(function (r) { return r.json(); }) - .then(function (d) { - var st = d.status || {}; - if (st.connected) { - syncCtpBadgeFromStatus(st); - showCtpError(''); - pollPositions(); - return d; - } - if ((st.login_cooldown_sec || 0) > 0 || d.cooldown) { - syncCtpBadgeFromStatus(st); - showCtpError(st.last_error || d.error || 'CTP 登录冷却中'); - return d; - } - if (d.connecting || st.connecting) { - updateCtpBadge(false, true); - return waitForCtpConnected(70000).then(function (ok) { - if (!ok && d.error) showCtpError(d.error); - else if (!ok && st.last_error) showCtpError(st.last_error); - return d; - }); - } - if (!d.ok) { - syncCtpBadgeFromStatus(st); - var err = d.error || st.last_error || '连接失败'; - showCtpError(err); - } - return d; - }) - .catch(function () { - updateCtpBadge(false, false); - }) - .finally(function () { - ctpConnectInflight = false; - }); - } - - function refreshQuote() { - var sym = selectedSymbol(); - var lots = effectiveLots() || (isFixedMode() ? (window.TRADE_FIXED_LOTS || 1) : 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; - 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; - } - if (metricsHint && data.metrics) { - var m = data.metrics; - metricsHint.innerHTML = - '' + (data.name || sym) + ' 精度 ' + m.price_precision + - ' 位 · 每跳 ' + m.tick_value_total + ' 元(' + lots + ' 手)'; - } - scheduleAutoCalc(); - }).catch(function () {}); - } - - function scheduleQuote() { - clearTimeout(quoteTimer); - quoteTimer = setTimeout(refreshQuote, 400); - } - - function scheduleAutoCalc() { - clearTimeout(calcTimer); - calcTimer = setTimeout(autoCalcLots, 450); - } - - function autoCalcLots() { - if (!lotsCalc) return; - var sym = selectedSymbol(); - var entry = entryPrice() || parseFloat(priceInput && priceInput.value) || 0; - var sl = parseFloat(slInput && slInput.value) || 0; - var tp = parseFloat(tpInput && tpInput.value) || 0; - if (isFixedMode()) { - var fixedLots = parseInt(window.TRADE_FIXED_LOTS, 10) || 1; - lotsCalc.value = String(fixedLots); - if (lotsInput) lotsInput.value = String(fixedLots); - if (!sym || !entry) { - lastPreviewMetrics = null; - updateRRDisplay(); - checkLotsLimit(); - return; - } - } else if (isAmountMode()) { - if (!sym || !entry || !sl) { - lotsCalc.value = ''; - lotsCalc.placeholder = '填写止损后自动计算'; - lastPreviewMetrics = null; - updateRRDisplay(); - checkLotsLimit(); - return; - } - lotsCalc.placeholder = '计算中…'; - } else { - 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: tp - }) - }).then(function (r) { return r.json(); }).then(function (data) { - if (!data.ok) { - if (isAmountMode()) { - lotsCalc.value = ''; - lotsCalc.placeholder = data.error || '无法计算'; - } - lastPreviewMetrics = null; - updateRRDisplay(); - checkLotsLimit(); - return; - } - lotsCalc.value = String(data.lots || ''); - if (lotsInput) lotsInput.value = String(data.lots || ''); - lotsCalc.placeholder = isAmountMode() ? '填写止损后自动计算' : '—'; - lastPreviewMetrics = data.metrics || null; - updateRRDisplay(); - checkLotsLimit(); - scheduleQuote(); - }).catch(function () { - if (isAmountMode()) lotsCalc.placeholder = '计算失败'; - lastPreviewMetrics = null; - updateRRDisplay(); - }); - } - - function tryAutoCtpReconnect() { - if (ctpReconnecting || ctpConnectInflight) return; - var now = Date.now(); - if (now - lastCtpReconnectAt < 60000) return; - if (lastCtpLoginBanAt && now - lastCtpLoginBanAt < 2700000) return; - if (lastCtpUnreachableAt && now - lastCtpUnreachableAt < 300000) return; - lastCtpReconnectAt = now; - ctpReconnecting = true; - requestCtpConnect(false).finally(function () { - ctpReconnecting = false; - }); - } - - function showOrderMsg(text, ok) { - var el = document.getElementById('order-msg'); - if (!el) return; - if (!text) { - el.hidden = true; - el.textContent = ''; - el.className = 'trade-order-msg'; - return; - } - el.hidden = false; - el.textContent = text; - el.className = 'trade-order-msg ' + (ok ? 'ok' : 'err'); - } - - function postOrder(offset) { - var sym = selectedSymbol(); - if (!sym) { showOrderMsg('请选择品种', false); return; } - var direction = dirSelect ? dirSelect.value : 'long'; - var price = entryPrice(); - if (!price || price <= 0) { - showOrderMsg('无法获取有效价格,请先填写或刷新行情', false); - return; - } - var lots = effectiveLots(); - var trailingBeEl = document.getElementById('trailing-be'); - if (offset === 'open') { - if (!isTradingSession) { - showOrderMsg('不在交易时间段', false); - return; - } - var trailingOn = !!(trailingBeEl && trailingBeEl.checked); - if (trailingOn && !(slInput && slInput.value)) { - showOrderMsg('开启移动保本须填写止损价', false); - return; - } - if (isAmountMode() && lots <= 0) { - showOrderMsg('请填写止损,系统将按固定金额自动计算手数', false); - return; - } - if (isFixedMode() && lots <= 0) { - showOrderMsg('手数无效,请检查系统设置中的固定手数', false); - return; - } - if (lots <= 0) { - showOrderMsg('请填写有效手数', false); - return; - } - var maxLots = maxLotsForSymbol(sym); - if (maxLots > 0 && lots > maxLots) { - showOrderMsg('手数 ' + lots + ' 超过最大手数 ' + maxLots + ' 手', false); - return; - } - } - var btnOpen = document.getElementById('btn-open'); - if (btnOpen) { - btnOpen.disabled = true; - btnOpen.textContent = '开仓中…'; - } - showOrderMsg('开仓中…', true); - var body = { - symbol: sym, - offset: offset, - direction: direction, - 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, - trailing_be: !!(trailingBeEl && trailingBeEl.checked) - }; - fetch('/api/trade/order', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }).then(function (r) { return r.json(); }).then(function (data) { - if (!data.ok) { - showOrderMsg(data.error || '下单失败', false); - return; - } - var msg = data.message || ( - data.filled ? ('开仓成功 · ' + (data.lots || lots) + ' 手') : - ('委托已提交 · ' + (data.lots || lots) + ' 手挂单中') - ); - showOrderMsg(msg, true); - pollPositions(); - refreshQuote(); - setTimeout(function () { showOrderMsg(''); }, 4000); - }).catch(function () { - showOrderMsg('网络错误,请重试', false); - }).finally(function () { - if (btnOpen) { - btnOpen.textContent = '开仓'; - updateSessionUi(); - } - }); - } - - function buildPendingHtml(items) { - if (!items || !items.length) return ''; - var rows = items.map(function (p) { - var cls = p.order_kind === 'stop_loss' ? 'sl' : (p.order_kind === 'take_profit' ? 'tp' : 'ctp'); - var dismissBtn = p.monitor_id ? - '' : ''; - return ( - '
' + - '' + (p.label || '挂单') + '' + - '' + - '' + fmtNum(p.price) + ' · ' + (p.lots || 1) + ' 手' + - dismissBtn + - '' + - '
' - ); - }).join(''); - return '
止盈止损监控
' + rows + '
'; - } - - function dismissMonitor(monitorId, btn, opts) { - opts = opts || {}; - if (!monitorId) return; - var isPending = !!opts.pending; - if (isPending && !isTradingSession) { - alert('不在交易时间段,无法撤单'); - return; - } - var confirmMsg = isPending - ? '撤销该开仓委托?(将向柜台发送撤单)' - : '取消该本地止盈止损监控?(不影响柜台委托)'; - if (!confirm(confirmMsg)) return; - if (btn) { - btn.disabled = true; - btn.textContent = '取消中…'; - } - var url = isPending ? '/api/trading/monitor/cancel-open' : '/api/trading/monitor/dismiss'; - fetch(url, { - 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 || '取消失败'); - pollPositions(); - }) - .catch(function (e) { - alert(e.message || '取消失败'); - if (btn) { - btn.disabled = false; - btn.textContent = isPending ? '撤单' : '取消'; - } - }); - } - - function bindCancelOpenButtons(root) { - if (!root) return; - root.querySelectorAll('[data-cancel-open]').forEach(function (btn) { - btn.addEventListener('click', function () { - if (!isTradingSession) { - alert('不在交易时间段,无法撤单'); - return; - } - dismissMonitor(parseInt(btn.getAttribute('data-cancel-open'), 10), btn, { pending: true }); - }); - }); - } - - function bindPendingDismiss(root) { - if (!root) return; - root.querySelectorAll('[data-monitor-id]').forEach(function (btn) { - btn.addEventListener('click', function () { - var isPendingCancel = btn.getAttribute('data-pending-cancel') === '1'; - dismissMonitor( - parseInt(btn.getAttribute('data-monitor-id'), 10), - btn, - isPendingCancel ? { pending: true } : {} - ); - }); - }); - } - - function bindCancelOrderButtons(root) { - if (!root) return; - root.querySelectorAll('[data-cancel-order]').forEach(function (btn) { - btn.addEventListener('click', function () { - if (!isTradingSession) { - alert('不在交易时间段,无法撤单'); - return; - } - var orderId = decodeURIComponent(btn.getAttribute('data-cancel-order') || ''); - if (!orderId) return; - if (!confirm('撤销该柜台委托?')) return; - btn.disabled = true; - btn.textContent = '撤单中…'; - fetch('/api/trading/order/cancel', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ order_id: orderId }) - }).then(function (r) { return r.json(); }).then(function (d) { - if (!d.ok) throw new Error(d.error || d.message || '撤单失败'); - pollPositions(); - }).catch(function (e) { - alert(e.message || '撤单失败'); - btn.disabled = false; - btn.textContent = '撤单'; - }); - }); - }); - } - - function slTpStatusHtml(row) { - var parts = []; - if (row.sl_order_active || row.sl_monitoring) { - parts.push('止损监控中'); - } else if (row.stop_loss != null) { - parts.push('止损已设'); - } - if (row.tp_order_active || row.tp_monitoring) { - parts.push('止盈监控中'); - } else if (row.take_profit != null) { - parts.push('止盈已设'); - } - if (!parts.length) return '未设置'; - return parts.join(' · '); - } - - function trailingStatusHtml(row) { - if (row.trailing_be) { - return '已开启' + - (row.trailing_r_locked ? '(锁' + row.trailing_r_locked + 'R)' : '') + ''; - } - return '未开启'; - } - - function buildPendingOrderCard(row) { - var dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空'); - var openT = (row.open_time || '').replace('T', ' ').slice(0, 16); - var orderPx = row.order_price != null ? row.order_price : row.entry_price; - var remainMin = row.pending_timeout_min != null - ? row.pending_timeout_min - : (row.auto_cancel_sec != null ? Math.max(1, Math.ceil(row.auto_cancel_sec / 60)) : 5); - var cancelAllowed = row.cancel_allowed !== false && isTradingSession; - var cancelBtn = row.can_cancel_order ? - '' : ''; - var metaLine = - '状态 挂单中' + - ' · 委托价 ' + fmtNum(orderPx) + '' + - (row.rr_ratio != null ? ' · 盈亏比 ' + row.rr_ratio + ':1' : '') + - ' · ' + slTpStatusHtml(row) + - ' · 移动保本 ' + trailingStatusHtml(row) + - ' · 约 ' + remainMin + ' 分钟内未成交自动撤单'; - return ( - '
' + - '
' + row.symbol + - ' ' + dirBadge + '' + - ' 挂单中
' + - '
' + (row.symbol_code || '') + '
' + - '
' + cancelBtn + '
' + - '
' + metaLine + '
' + - '
' + - '
' + row.lots + ' 手
' + - '
' + fmtNum(orderPx) + '
' + - '
' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '
' + - '
等待成交
' + - '
' + (openT || '--') + '
' + - '
' + - buildPendingHtml(row.pending_orders) + - '
' - ); - } - - function buildPosCard(row) { - if (row.order_state === 'pending') { - return buildPendingOrderCard(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 dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空'); - var openT = (row.open_time || '').replace('T', ' ').slice(0, 16); - var closeAllowed = row.close_allowed !== false && isTradingSession; - var slTpBtn = (!row.stop_loss && !row.take_profit && row.can_close) ? - '' : ''; - var editPayload = encodeURIComponent(JSON.stringify({ - symbol_code: row.symbol_code, direction: row.direction, - lots: row.lots, entry_price: row.entry_price, monitor_id: row.monitor_id || null, - stop_loss: row.stop_loss, take_profit: row.take_profit - })); - var entrustBtn = row.can_close ? - '' : ''; - var orderBtn = ''; - if (row.monitor_id && (row.stop_loss != null || row.take_profit != null) && row.can_place_orders) { - orderBtn = ''; - } - 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 ? - '' : ''; - var actionBtns = (entrustBtn || orderBtn || closeBtn) ? - '
' + entrustBtn + orderBtn + closeBtn + '
' : ''; - var metaLine = - '来源 ' + (row.source_label || 'CTP') + '' + - (row.rr_ratio != null ? ' · 盈亏比 ' + row.rr_ratio + ':1' : '') + - ' · 止损金额 ' + - (row.risk_amount != null ? fmtNum(row.risk_amount) + ' 元' : '--') + '' + - ' · 盈利金额 ' + - (row.reward_amount != null ? fmtNum(row.reward_amount) + ' 元' : '--') + '' + - ' · ' + slTpStatusHtml(row) + - ' · 移动保本 ' + trailingStatusHtml(row) + - (slTpBtn ? ' · ' + slTpBtn : '') + - (row.sync_pending ? ' · 同步柜台中…' : ''); - var feeLabel = row.fee_source === 'ctp' ? '手续费(柜台)' : '手续费'; - var marginLabel = row.margin_source === 'ctp' ? '占用保证金(柜台)' : '占用保证金'; - var openLabel = '开仓'; - return ( - '
' + - '
' + row.symbol + ' ' + dirBadge + '
' + - '
' + (row.symbol_code || '') + '
' + - actionBtns + '
' + - '
' + metaLine + '
' + - '
' + - '
' + row.lots + ' 手
' + - '
' + fmtNum(row.entry_price) + '
' + - '
' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '
' + - '
' + (row.margin != null ? fmtNum(row.margin) + ' 元' : '--') + '
' + - '
' + (row.position_pct != null ? fmtNum(row.position_pct) + '%' : '--') + '
' + - '
' + pnlText + '
' + - '
' + (row.est_fee != null ? fmtNum(row.est_fee) + ' 元' : '--') + '
' + - '
' + (openT || '--') + '
' + - '
' + (row.holding_duration || '--') + '
' + - '
' + buildPendingHtml(row.pending_orders) + - '
' - ); - } - - 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 || '委托失败'); - var msg = d.message || '委托已提交'; - if (d.skipped && d.skipped.length) msg += '\n' + d.skipped.join('\n'); - alert(msg); - 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, btnLabel) { - btnLabel = btnLabel || '设置止盈止损'; - var slDefault = payload.stop_loss != null && payload.stop_loss !== '' ? String(payload.stop_loss) : ''; - var tpDefault = payload.take_profit != null && payload.take_profit !== '' ? String(payload.take_profit) : ''; - var slRaw = prompt('止损价(可留空)', slDefault); - if (slRaw === null) return; - var tpRaw = prompt('止盈价(可留空)', tpDefault); - 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' }, - credentials: 'same-origin', - body: JSON.stringify({ - symbol_code: payload.symbol_code, - direction: payload.direction, - lots: payload.lots, - entry_price: payload.entry_price, - monitor_id: payload.monitor_id || null, - stop_loss: sl, - take_profit: tp - }) - }) - .then(function (r) { - if (!r.ok) { - return r.json().catch(function () { return {}; }).then(function (d) { - throw new Error(d.error || ('HTTP ' + r.status)); - }); - } - return r.json(); - }) - .then(function (d) { - if (!d.ok) throw new Error(d.error || '保存失败'); - pollPositions(); - }) - .catch(function (e) { - var msg = e.message || '保存失败'; - if (msg === 'Failed to fetch') msg = '网络请求失败,请检查服务是否运行'; - alert(msg); - if (btn) { - btn.disabled = false; - btn.textContent = btnLabel; - } - }); - } - - 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, '设置止盈止损' - ); - }); - }); - root.querySelectorAll('[data-edit-sl-tp]').forEach(function (btn) { - btn.addEventListener('click', function () { - promptStopTakeProfit( - JSON.parse(decodeURIComponent(btn.getAttribute('data-edit-sl-tp'))), btn, '委托' - ); - }); - }); - } - - function closePosition(payload, btn) { - if (!isTradingSession) { - alert('不在交易时间段,无法平仓'); - return; - } - function doClose(price) { - if (!price || price <= 0) { alert('无法获取现价'); return; } - if (!confirm('确认平仓 ' + payload.lots + ' 手?')) return; - if (btn) { - btn.disabled = true; - btn.textContent = '平仓中…'; - } - fetch('/api/trading/close', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(Object.assign({}, payload, { price: price })) - }).then(function (r) { return r.json(); }).then(function (d) { - if (!d.ok) { - alert(d.error || '平仓失败'); - if (btn) { - btn.disabled = false; - btn.textContent = '平仓'; - } - return; - } - if (btn) btn.textContent = '已平仓'; - pollPositions(); - }).catch(function () { - if (btn) { - btn.disabled = false; - btn.textContent = '平仓'; - } - }); - } - if (payload.mark_price > 0) { - doClose(payload.mark_price); - return; - } - fetch('/api/trade/quote?symbol=' + encodeURIComponent(payload.symbol_code) + '&lots=' + payload.lots) - .then(function (r) { return r.json(); }) - .then(function (d) { doClose(d.price); }); - } - - function pollPositions() { - if (!list) return; - fetch('/api/trading/live') - .then(function (r) { - if (!r.ok) throw new Error('HTTP ' + r.status); - return r.json(); - }) - .then(function (data) { - applyPositionsData(data); - }) - .catch(function () { - if (!positionsRendered && list.innerHTML.indexOf('pos-card') < 0) { - list.innerHTML = '
持仓加载失败
'; - } - }); - } - - function connectPositionStream() { - if (positionSource) { - positionSource.close(); - positionSource = null; - } - positionSource = new EventSource('/api/trading/stream'); - positionSource.addEventListener('positions', function (ev) { - try { - applyPositionsData(JSON.parse(ev.data)); - } catch (e) { /* ignore */ } - }); - positionSource.onerror = function () { - if (positionSource) { - positionSource.close(); - positionSource = null; - } - setTimeout(connectPositionStream, 3000); - }; - } - - function loadRecSortPrefs() { - try { - var raw = sessionStorage.getItem(REC_SORT_CACHE); - if (!raw) return; - var p = JSON.parse(raw); - if (p.key) recSortKey = p.key; - if (typeof p.desc === 'boolean') recSortDesc = p.desc; - } catch (e) { /* ignore */ } - try { - var ind = sessionStorage.getItem(REC_INDUSTRY_CACHE); - if (ind != null) recIndustryFilter = ind; - } catch (e2) { /* ignore */ } - } - - function saveRecSortPrefs() { - try { - sessionStorage.setItem(REC_SORT_CACHE, JSON.stringify({ key: recSortKey, desc: recSortDesc })); - } catch (e) { /* ignore */ } - } - - function saveRecIndustryPref() { - try { - sessionStorage.setItem(REC_INDUSTRY_CACHE, recIndustryFilter || ''); - } catch (e) { /* ignore */ } - } - - function syncRecSortUi() { - var sel = document.getElementById('rec-sort-key'); - var btn = document.getElementById('rec-sort-dir'); - var indSel = document.getElementById('rec-industry-filter'); - if (sel) sel.value = recSortKey; - if (btn) btn.textContent = recSortDesc ? '↓' : '↑'; - if (indSel) indSel.value = recIndustryFilter || ''; - } - - function filterRecommendRows(rows) { - if (!recIndustryFilter) return (rows || []).slice(); - return (rows || []).filter(function (r) { - return (r.category || '') === recIndustryFilter; - }); - } - - function countByCategory(rows) { - var counts = {}; - (rows || []).forEach(function (r) { - var cat = r.category || '其他'; - counts[cat] = (counts[cat] || 0) + 1; - }); - return counts; - } - - function updateRecStats(allRows, visibleRows) { - var el = document.getElementById('rec-stats'); - if (!el) return; - var total = (allRows || []).length; - var shown = (visibleRows || []).length; - if (!total) { - el.textContent = ''; - return; - } - var parts = []; - if (recIndustryFilter) { - parts.push('筛选 ' + shown + ' / 共 ' + total + ' 个品种'); - } else { - parts.push('共 ' + total + ' 个品种'); - } - var order = productCategories.length ? productCategories.slice() : []; - var counts = countByCategory(recIndustryFilter ? visibleRows : allRows); - Object.keys(counts).forEach(function (k) { - if (order.indexOf(k) < 0) order.push(k); - }); - var breakdown = order.filter(function (cat) { return counts[cat]; }).map(function (cat) { - return cat + ' ' + counts[cat]; - }); - if (breakdown.length) parts.push(breakdown.join(' · ')); - el.innerHTML = parts.join(' · '); - } - - var TREND_SORT_RANK = { break_long: 0, break_short: 0, long: 1, short: 2, range: 3, '': 9 }; - var GAP_SORT_RANK = { up: 2, down: 1, none: 0, '': -1 }; - - function sortRecommendRows(rows) { - var list = (rows || []).slice(); - var key = recSortKey || 'trend'; - var desc = recSortDesc; - list.sort(function (a, b) { - var av, bv, as, bs; - if (key === 'gap') { - av = GAP_SORT_RANK[a.gap || ''] !== undefined ? GAP_SORT_RANK[a.gap || ''] : -1; - bv = GAP_SORT_RANK[b.gap || ''] !== undefined ? GAP_SORT_RANK[b.gap || ''] : -1; - as = Math.abs(Number(a.gap_pct) || 0); - bs = Math.abs(Number(b.gap_pct) || 0); - } else if (key === 'volume') { - av = Number(a.volume) || 0; - bv = Number(b.volume) || 0; - as = bs = 0; - } else if (key === 'amplitude') { - av = Number(a.yesterday_amplitude_pct) || 0; - bv = Number(b.yesterday_amplitude_pct) || 0; - as = bs = 0; - } else { - av = TREND_SORT_RANK[a.trend || ''] !== undefined ? TREND_SORT_RANK[a.trend || ''] : 9; - bv = TREND_SORT_RANK[b.trend || ''] !== undefined ? TREND_SORT_RANK[b.trend || ''] : 9; - as = Number(a.max_lots) || 0; - bs = Number(b.max_lots) || 0; - } - if (av !== bv) return desc ? bv - av : av - bv; - if (as !== bs) return desc ? bs - as : as - bs; - return String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN'); - }); - return list; - } - - function fmtRecVolume(v) { - if (v === null || v === undefined) return '—'; - var n = Number(v); - if (!isFinite(n)) return '—'; - if (n >= 10000) return (n / 10000).toFixed(1) + '万'; - return String(Math.round(n)); - } - - function fmtRecTurnover(v) { - if (v === null || v === undefined) return '—'; - var n = Number(v); - if (!isFinite(n)) return '—'; - if (n >= 1e8) return (n / 1e8).toFixed(2) + '亿'; - if (n >= 1e4) return (n / 1e4).toFixed(1) + '万'; - return String(Math.round(n)); - } - - function changeCellHtml(r) { - if (r.yesterday_change == null) return '—'; - var ch = Number(r.yesterday_change); - var cls = ch > 0 ? 'rec-change-up' : (ch < 0 ? 'rec-change-down' : ''); - var txt = (ch > 0 ? '+' : '') + ch; - if (r.yesterday_change_pct != null) { - var pct = Number(r.yesterday_change_pct); - txt += ' (' + (pct > 0 ? '+' : '') + pct + '%)'; - } - return '' + txt + ''; - } - - function trendBadgeHtml(r) { - var label = r.trend_label || ''; - if (!label || label === '—') return '—'; - var cls = 'planned'; - if (r.trend === 'break_long' || r.trend === 'break_short') cls = 'break'; - else if (r.trend === 'long') cls = 'profit'; - else if (r.trend === 'short') cls = 'loss'; - var title = ''; - if (r.trend_overlap_pct != null) title = ' title="近3日重叠 ' + r.trend_overlap_pct + '%"'; - var prefix = r.trend_transition ? '★ ' : ''; - return '' + prefix + label + ''; - } - - function gapBadgeHtml(r) { - var label = r.gap_label || ''; - if (!label || label === '—') return '—'; - var cls = 'planned'; - if (r.gap === 'up') cls = 'profit'; - else if (r.gap === 'down') cls = 'loss'; - var title = ''; - if (r.gap_pct != null && r.gap !== 'none') { - title = ' title="跳空 ' + (Number(r.gap_pct) > 0 ? '+' : '') + r.gap_pct + '%"'; - } - return '' + label + ''; - } - - function renderRecommendRows(rows) { - if (!recommendList) return; - if (!rows.length) { - var emptyMsg = recIndustryFilter - ? '当前行业下暂无推荐品种' - : '当前资金下暂无推荐品种(每日后台刷新)'; - recommendList.innerHTML = '' + emptyMsg + ''; - return; - } - recommendList.innerHTML = rows.map(function (r) { - var rowCls = 'rec-' + (r.status || ''); - if (r.trend_transition) rowCls += ' rec-trend-break'; - var nameCls = r.trend_transition ? ' class="trend-name"' : ''; - return ( - '' + - '' + (r.name || '') + ' ' + (r.main_code || r.ths || '') + '' + - '' + (r.exchange || '') + '' + - '' + (r.category || '—') + '' + - '' + trendBadgeHtml(r) + '' + - '' + gapBadgeHtml(r) + '' + - '' + (r.price != null ? r.price : '—') + '' + - '' + (r.prev_close != null ? r.prev_close : '—') + '' + - '' + (r.today_open != null ? r.today_open : '—') + '' + - '' + changeCellHtml(r) + '' + - '' + (r.yesterday_amplitude_pct != null ? r.yesterday_amplitude_pct + '%' : '—') + '' + - '' + fmtRecVolume(r.volume) + '' + - '' + fmtRecTurnover(r.turnover) + '' + - '' + (r.margin_one_lot != null ? r.margin_one_lot + (r.margin_source === 'ctp' ? ' (柜台)' : '') : '—') + '' + - '' + (r.open_fee_one_lot != null ? r.open_fee_one_lot : '—') + '' + - '' + (r.max_lots != null && r.max_lots > 0 ? r.max_lots : '—') + '' + - '' + (r.status_label || '') + '' + - '' - ); - }).join(''); - } - - function renderRecommendTable() { - var filtered = filterRecommendRows(recRowsRaw); - var sorted = sortRecommendRows(filtered); - updateRecStats(recRowsRaw, sorted); - renderRecommendRows(sorted); - } - - function renderRecommendations(data) { - if (!recommendList || !data) return; - updateRecommendMaxMaps(data); - 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 || []; - recRowsRaw = rows.slice(); - if (!rows.length) { - recommendList.innerHTML = '当前资金下暂无推荐品种(每日后台刷新)'; - updateRecStats([], []); - return; - } - renderRecommendTable(); - } - - function initRecommendSortControls() { - loadRecSortPrefs(); - syncRecSortUi(); - var sel = document.getElementById('rec-sort-key'); - var btn = document.getElementById('rec-sort-dir'); - var indSel = document.getElementById('rec-industry-filter'); - if (indSel) { - indSel.addEventListener('change', function () { - recIndustryFilter = indSel.value || ''; - saveRecIndustryPref(); - renderRecommendTable(); - }); - } - if (sel) { - sel.addEventListener('change', function () { - recSortKey = sel.value || 'trend'; - saveRecSortPrefs(); - renderRecommendTable(); - }); - } - if (btn) { - btn.addEventListener('click', function () { - recSortDesc = !recSortDesc; - saveRecSortPrefs(); - syncRecSortUi(); - renderRecommendTable(); - }); - } - if (recRowsRaw.length) updateRecStats(recRowsRaw, filterRecommendRows(recRowsRaw)); - } - - function connectRecommendStream() { - if (recommendSource) { recommendSource.close(); recommendSource = null; } - recommendSource = new EventSource('/api/recommend/stream'); - recommendSource.addEventListener('recommend', function (ev) { - try { renderRecommendations(JSON.parse(ev.data)); } catch (e) { /* ignore */ } - }); - recommendSource.onerror = function () { - if (recommendSource) { recommendSource.close(); recommendSource = null; } - setTimeout(connectRecommendStream, 5000); - }; - } - - document.querySelectorAll('.price-tab').forEach(function (btn) { - btn.addEventListener('click', function () { - setPriceType(btn.getAttribute('data-type')); - scheduleQuote(); - }); - }); - - if (symInput) { - symInput.addEventListener('input', function () { - selectedMaxLots = null; - scheduleQuote(); - scheduleAutoCalc(); - checkLotsLimit(); - }); - symInput.addEventListener('symbol-selected', function (ev) { - var item = ev.detail || {}; - selectedMaxLots = item.max_lots > 0 ? item.max_lots : null; - scheduleQuote(); - scheduleAutoCalc(); - checkLotsLimit(); - }); - } - if (lotsCalc) lotsCalc.addEventListener('input', checkLotsLimit); - if (slInput) { - slInput.addEventListener('input', function () { - scheduleAutoCalc(); - updateRRDisplay(); - }); - } - if (tpInput) { - tpInput.addEventListener('input', function () { - scheduleAutoCalc(); - updateRRDisplay(); - }); - } - if (dirSelect) dirSelect.addEventListener('change', function () { - scheduleAutoCalc(); - updateRRDisplay(); - }); - if (priceInput) { - priceInput.addEventListener('input', function () { - if (priceType === 'limit') priceInput.dataset.manual = '1'; - scheduleAutoCalc(); - updateRRDisplay(); - }); - } - - var btnOpen = document.getElementById('btn-open'); - if (btnOpen) btnOpen.addEventListener('click', function () { postOrder('open'); }); - - var btnConnect = document.getElementById('btn-ctp-connect'); - if (btnConnect) { - btnConnect.addEventListener('click', function () { - requestCtpConnect(true); - }); - } - - function initCtpOnLoad() { - fetch('/api/ctp/status') - .then(function (r) { return r.json(); }) - .then(function (d) { - var st = d.status || {}; - syncCtpBadgeFromStatus(st); - if (st.last_error) showCtpError(st.last_error); - if (st.connected) pollPositions(); - }) - .catch(function () {}); - } - - runWhenReady(function () { - setPriceType('limit'); - if (isFixedMode() && lotsCalc) { - lotsCalc.value = String(window.TRADE_FIXED_LOTS || 1); - if (lotsInput) lotsInput.value = lotsCalc.value; - } - var cached = loadPosCache(); - if (cached) { - if (cached.ctp_status) { - cached.ctp_status = Object.assign({}, cached.ctp_status, { connecting: false }); - } - applyPositionsData(cached); - } - pollPositions(); - connectPositionStream(); - initCtpOnLoad(); - connectRecommendStream(); - initRecommendSortControls(); - if (window.__RECOMMEND_ROWS__ && window.__RECOMMEND_ROWS__.length) { - recRowsRaw = window.__RECOMMEND_ROWS__.slice(); - renderRecommendTable(); - } - fetch('/api/recommend/list') - .then(function (r) { return r.json(); }) - .then(function (data) { if (data.ok) renderRecommendations(data); }) - .catch(function () {}); - document.addEventListener('visibilitychange', function () { - if (document.visibilityState === 'visible' && !positionSource) { - connectPositionStream(); - } - }); - updateSessionUi(); - updateRRDisplay(); - scheduleQuote(); - scheduleAutoCalc(); - }); -})(); +/* Copyright (c) 2025-2026 马建军. All rights reserved. + * 专有软件 — 未经授权禁止复制、传播、转售。 + * 详见 LICENSE.zh-CN.txt + */ +(function () { + var sizingMode = window.TRADE_SIZING_MODE || 'fixed'; + if (sizingMode === 'risk') sizingMode = 'amount'; + 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 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 recommendSource = null; + var positionSource = null; + var quoteTimer = null; + var calcTimer = null; + var lastQuotePrice = null; + var priceType = 'limit'; + var lastCtpReconnectAt = 0; + var lastCtpUnreachableAt = 0; + var lastCtpLoginBanAt = 0; + var ctpReconnecting = false; + var ctpConnectInflight = false; + var isTradingSession = false; + var hasSlTpMonitoring = false; + var ctpConnected = false; + var positionsRendered = false; + var selectedMaxLots = null; + var recommendMaxByProduct = {}; + var recommendMaxByCode = {}; + var recRowsRaw = []; + var recSortKey = 'trend'; + var recSortDesc = true; + var recIndustryFilter = ''; + var REC_SORT_CACHE = 'qihuo_rec_sort_v2'; + var REC_INDUSTRY_CACHE = 'qihuo_rec_industry_v1'; + var REC_COLSPAN = 16; + var productCategories = window.PRODUCT_CATEGORIES || []; + var POS_CACHE_KEY = 'qihuo_trading_live_v3'; + + function runWhenReady(fn) { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', fn); + } else { + fn(); + } + } + + function fmtNum(v, digits) { + if (v === null || v === undefined) return '--'; + return Number(v).toFixed(digits === undefined ? 2 : digits); + } + + function selectedSymbol() { + var codeEl = document.getElementById('trade-symbol-code'); + var code = codeEl && codeEl.value ? codeEl.value.trim() : ''; + if (code) return code; + return (symInput && symInput.value || '').trim(); + } + + function isFixedMode() { + return sizingMode === 'fixed'; + } + + function isAmountMode() { + return sizingMode === 'amount'; + } + + function effectiveLots() { + var v = parseInt(lotsCalc && lotsCalc.value, 10); + return v > 0 ? v : 0; + } + + function updateRecommendMaxMaps(data) { + recommendMaxByProduct = {}; + recommendMaxByCode = {}; + (data && data.rows || []).forEach(function (r) { + if (!r || r.max_lots <= 0) return; + if (r.status !== 'ok' && r.status !== 'margin_ok') return; + if (r.ths) recommendMaxByProduct[String(r.ths).toLowerCase()] = r.max_lots; + if (r.main_code) recommendMaxByCode[String(r.main_code).toLowerCase()] = r.max_lots; + }); + checkLotsLimit(); + } + + function maxLotsForSymbol(sym) { + if (selectedMaxLots > 0) return selectedMaxLots; + var code = (sym || '').trim().toLowerCase(); + if (!code) return 0; + if (recommendMaxByCode[code]) return recommendMaxByCode[code]; + var m = code.match(/^([a-z]+)/i); + if (m && recommendMaxByProduct[m[1].toLowerCase()]) { + return recommendMaxByProduct[m[1].toLowerCase()]; + } + return 0; + } + + function checkLotsLimit() { + var warn = document.getElementById('lots-warn'); + if (!warn) return; + var sym = selectedSymbol(); + var maxLots = maxLotsForSymbol(sym); + var lots = effectiveLots(); + if (maxLots > 0 && lots > maxLots) { + warn.hidden = false; + warn.textContent = '已超过最大手数 ' + maxLots + ' 手,请调整手数'; + } else { + warn.hidden = true; + warn.textContent = ''; + } + } + + function loadPosCache() { + try { + var raw = sessionStorage.getItem(POS_CACHE_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch (e) { + return null; + } + } + + function savePosCache(data) { + try { + if (!data || !data.rows || !data.rows.length) { + var prev = loadPosCache(); + if (prev && prev.rows && prev.rows.length) return; + } + sessionStorage.setItem(POS_CACHE_KEY, JSON.stringify(data)); + } catch (e) { /* quota */ } + } + + function showCtpError(msg) { + var hint = document.querySelector('.ctp-install-hint'); + if (hint) hint.textContent = msg || ''; + } + + function isCtpLoginBanError(msg) { + return !!(msg && ( + msg.indexOf('登录被禁止') >= 0 || + msg.indexOf('连续登录失败') >= 0 || + msg.indexOf('登录冷却') >= 0 || + msg.indexOf('错误码 75') >= 0 + )); + } + + function isCtpUnreachableError(msg) { + return !!(msg && (msg.indexOf('不可达') >= 0 || msg.indexOf('Connection refused') >= 0 || msg.indexOf('timed out') >= 0)); + } + + function applyPositionsData(data) { + if (!list || !data) return; + var cap = document.getElementById('cap-display'); + if (cap && data.capital != null) cap.textContent = Number(data.capital).toFixed(2); + var connected = data.ctp_status && data.ctp_status.connected; + var connecting = data.ctp_status && data.ctp_status.connecting; + var cooldownSec = (data.ctp_status && data.ctp_status.login_cooldown_sec) || 0; + if (cooldownSec > 0) connecting = false; + ctpConnected = !!connected; + isTradingSession = !!data.trading_session; + syncCtpBadgeFromStatus(data.ctp_status || { connected: connected, connecting: connecting }); + if (!connected && !connecting && data.ctp_status && data.ctp_status.last_error) { + showCtpError(data.ctp_status.last_error); + if (isCtpLoginBanError(data.ctp_status.last_error)) { + lastCtpLoginBanAt = Date.now(); + } else if (isCtpUnreachableError(data.ctp_status.last_error)) { + lastCtpUnreachableAt = Date.now(); + } + } + var riskBadge = document.getElementById('risk-badge'); + if (riskBadge && data.risk_status) { + riskBadge.textContent = data.risk_status.status_label || ''; + riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss'); + } + var rows = data.rows || []; + var seenKeys = {}; + rows = rows.filter(function (row) { + var k = row.key || ((row.symbol_code || '') + ':' + (row.direction || '')); + if (seenKeys[k]) return false; + seenKeys[k] = true; + return true; + }); + hasSlTpMonitoring = rows.some(function (row) { + return row.stop_loss != null || row.take_profit != null; + }); + updateSessionUi(); + savePosCache(data); + positionsRendered = true; + if (!rows.length) { + if (!connected) { + if (connecting) { + list.innerHTML = '
CTP 连接中,请稍候…
'; + return; + } + if (cooldownSec > 0 || (data.ctp_status && data.ctp_status.last_error)) { + var err = (data.ctp_status && data.ctp_status.last_error) || 'CTP 未连接'; + list.innerHTML = '
' + err + '
'; + return; + } + list.innerHTML = '
CTP 未连接,正在尝试自动重连…
'; + tryAutoCtpReconnect(); + return; + } + var pendingOnly = data.pending_orders || []; + if (pendingOnly.length) { + list.innerHTML = '
暂无持仓
' + + pendingOnly.map(function (p) { + var cancelAllowed = p.cancel_allowed !== false && isTradingSession; + var actionBtn = ''; + if (p.monitor_id) { + actionBtn = ''; + } else if (p.order_id && p.source === 'ctp') { + actionBtn = ''; + } + return ( + '
' + (p.label || '挂单') + ' · ' + (p.symbol || p.symbol_code) + '' + + '' + fmtNum(p.price) + ' · ' + + (p.lots || 1) + ' 手' + actionBtn + '
' + ); + }).join(''); + bindPendingDismiss(list); + bindCancelOrderButtons(list); + } else { + list.innerHTML = '
暂无持仓。
'; + } + return; + } + if (!connected) { + tryAutoCtpReconnect(); + } + list.innerHTML = rows.map(buildPosCard).join(''); + bindPendingDismiss(list); + bindCancelOpenButtons(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); + }); + }); + } + + function schedulePositionPoll() { + /* 持仓改由后台 SSE 推送,保留空函数兼容旧调用 */ + } + + function updateSessionUi() { + var btnOpen = document.getElementById('btn-open'); + var sessionHint = document.getElementById('session-hint'); + if (btnOpen) { + btnOpen.disabled = !isTradingSession; + btnOpen.classList.toggle('btn-session-off', !isTradingSession); + } + if (sessionHint) { + sessionHint.hidden = !!isTradingSession; + } + } + + function entryPrice() { + if (priceType === 'market') return lastQuotePrice; + return parseFloat(priceInput && priceInput.value) || 0; + } + + function calcRR(direction, entry, sl, tp) { + entry = parseFloat(entry); + sl = parseFloat(sl); + tp = parseFloat(tp); + if (!entry || !sl || !tp) return null; + var risk, reward; + if (direction === 'long') { + risk = entry - sl; + reward = tp - entry; + } else if (direction === 'short') { + risk = sl - entry; + reward = entry - tp; + } else { + return null; + } + if (risk <= 0 || reward <= 0) return null; + return (reward / risk).toFixed(2); + } + + function updateRRDisplay() { + var el = document.getElementById('trade-rr-hint'); + if (!el) return; + var dir = dirSelect ? dirSelect.value : 'long'; + var entry = entryPrice(); + var sl = slInput && slInput.value ? parseFloat(slInput.value) : 0; + var tp = tpInput && tpInput.value ? parseFloat(tpInput.value) : 0; + var lots = effectiveLots(); + var parts = []; + var rr = calcRR(dir, entry, sl, tp); + if (rr) parts.push('盈亏比 ' + rr + ':1'); + if (sl > 0 && entry > 0 && lots > 0 && lastPreviewMetrics) { + if (lastPreviewMetrics.risk_amount != null) { + parts.push('止损金额 ' + fmtNum(lastPreviewMetrics.risk_amount) + ' 元'); + } + if (lastPreviewMetrics.reward_amount != null && tp > 0) { + parts.push('止盈金额 ' + fmtNum(lastPreviewMetrics.reward_amount) + ' 元'); + } + } + if (parts.length) { + el.textContent = parts.join(' · '); + el.hidden = false; + } else { + el.textContent = ''; + el.hidden = true; + } + } + + var lastPreviewMetrics = null; + + 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'; + updateRRDisplay(); + } + + function syncCtpBadgeFromStatus(st) { + if (!st) return; + var connected = !!st.connected; + var connecting = !!st.connecting; + if ((st.login_cooldown_sec || 0) > 0) { + connecting = false; + } + updateCtpBadge(connected, connecting); + } + + function updateCtpBadge(connected, connecting) { + var ctpBadge = document.getElementById('ctp-badge'); + var btnConnect = document.getElementById('btn-ctp-connect'); + if (ctpBadge) { + if (connecting) { + ctpBadge.textContent = 'CTP 连接中'; + ctpBadge.className = 'badge planned'; + } else { + ctpBadge.textContent = connected ? 'CTP 已连接' : 'CTP 未连接'; + ctpBadge.className = 'badge ' + (connected ? 'profit' : 'planned'); + } + } + if (btnConnect) { + if (connecting) { + btnConnect.textContent = '连接中…'; + btnConnect.disabled = true; + } else { + btnConnect.disabled = false; + btnConnect.textContent = connected ? '重连 CTP' : '连接 CTP'; + } + } + } + + function waitForCtpConnected(maxMs) { + var deadline = Date.now() + (maxMs || 70000); + function tick() { + return fetch('/api/ctp/status') + .then(function (r) { return r.json(); }) + .then(function (d) { + var st = d.status || {}; + if (st.connected) { + syncCtpBadgeFromStatus(st); + showCtpError(''); + if (d.account && d.account.available != null) { + var avail = document.getElementById('avail-display'); + if (avail) avail.textContent = Number(d.account.available).toFixed(2); + } + pollPositions(); + return true; + } + if ((st.login_cooldown_sec || 0) > 0) { + syncCtpBadgeFromStatus(st); + if (st.last_error) showCtpError(st.last_error); + return false; + } + if (st.connecting && Date.now() < deadline) { + syncCtpBadgeFromStatus(st); + return new Promise(function (resolve) { + setTimeout(function () { resolve(tick()); }, 2000); + }); + } + syncCtpBadgeFromStatus(st); + if (st.last_error) { + showCtpError(st.last_error); + if (isCtpLoginBanError(st.last_error)) { + lastCtpLoginBanAt = Date.now(); + } else if (isCtpUnreachableError(st.last_error)) { + lastCtpUnreachableAt = Date.now(); + } + } + return false; + }) + .catch(function () { updateCtpBadge(false, false); return false; }); + } + return tick(); + } + + function requestCtpConnect(force) { + if (!force && ctpConnectInflight) { + return Promise.resolve({}); + } + ctpConnectInflight = true; + return fetch('/api/ctp/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ force: !!force, auto: !force }) + }) + .then(function (r) { return r.json(); }) + .then(function (d) { + var st = d.status || {}; + if (st.connected) { + syncCtpBadgeFromStatus(st); + showCtpError(''); + pollPositions(); + return d; + } + if ((st.login_cooldown_sec || 0) > 0 || d.cooldown) { + syncCtpBadgeFromStatus(st); + showCtpError(st.last_error || d.error || 'CTP 登录冷却中'); + return d; + } + if (d.connecting || st.connecting) { + updateCtpBadge(false, true); + return waitForCtpConnected(70000).then(function (ok) { + if (!ok && d.error) showCtpError(d.error); + else if (!ok && st.last_error) showCtpError(st.last_error); + return d; + }); + } + if (!d.ok) { + syncCtpBadgeFromStatus(st); + var err = d.error || st.last_error || '连接失败'; + showCtpError(err); + } + return d; + }) + .catch(function () { + updateCtpBadge(false, false); + }) + .finally(function () { + ctpConnectInflight = false; + }); + } + + function refreshQuote() { + var sym = selectedSymbol(); + var lots = effectiveLots() || (isFixedMode() ? (window.TRADE_FIXED_LOTS || 1) : 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; + 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; + } + if (metricsHint && data.metrics) { + var m = data.metrics; + metricsHint.innerHTML = + '' + (data.name || sym) + ' 精度 ' + m.price_precision + + ' 位 · 每跳 ' + m.tick_value_total + ' 元(' + lots + ' 手)'; + } + scheduleAutoCalc(); + }).catch(function () {}); + } + + function scheduleQuote() { + clearTimeout(quoteTimer); + quoteTimer = setTimeout(refreshQuote, 400); + } + + function scheduleAutoCalc() { + clearTimeout(calcTimer); + calcTimer = setTimeout(autoCalcLots, 450); + } + + function autoCalcLots() { + if (!lotsCalc) return; + var sym = selectedSymbol(); + var entry = entryPrice() || parseFloat(priceInput && priceInput.value) || 0; + var sl = parseFloat(slInput && slInput.value) || 0; + var tp = parseFloat(tpInput && tpInput.value) || 0; + if (isFixedMode()) { + var fixedLots = parseInt(window.TRADE_FIXED_LOTS, 10) || 1; + lotsCalc.value = String(fixedLots); + if (lotsInput) lotsInput.value = String(fixedLots); + if (!sym || !entry) { + lastPreviewMetrics = null; + updateRRDisplay(); + checkLotsLimit(); + return; + } + } else if (isAmountMode()) { + if (!sym || !entry || !sl) { + lotsCalc.value = ''; + lotsCalc.placeholder = '填写止损后自动计算'; + lastPreviewMetrics = null; + updateRRDisplay(); + checkLotsLimit(); + return; + } + lotsCalc.placeholder = '计算中…'; + } else { + 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: tp + }) + }).then(function (r) { return r.json(); }).then(function (data) { + if (!data.ok) { + if (isAmountMode()) { + lotsCalc.value = ''; + lotsCalc.placeholder = data.error || '无法计算'; + } + lastPreviewMetrics = null; + updateRRDisplay(); + checkLotsLimit(); + return; + } + lotsCalc.value = String(data.lots || ''); + if (lotsInput) lotsInput.value = String(data.lots || ''); + lotsCalc.placeholder = isAmountMode() ? '填写止损后自动计算' : '—'; + lastPreviewMetrics = data.metrics || null; + updateRRDisplay(); + checkLotsLimit(); + scheduleQuote(); + }).catch(function () { + if (isAmountMode()) lotsCalc.placeholder = '计算失败'; + lastPreviewMetrics = null; + updateRRDisplay(); + }); + } + + function tryAutoCtpReconnect() { + if (ctpReconnecting || ctpConnectInflight) return; + var now = Date.now(); + if (now - lastCtpReconnectAt < 60000) return; + if (lastCtpLoginBanAt && now - lastCtpLoginBanAt < 2700000) return; + if (lastCtpUnreachableAt && now - lastCtpUnreachableAt < 300000) return; + lastCtpReconnectAt = now; + ctpReconnecting = true; + requestCtpConnect(false).finally(function () { + ctpReconnecting = false; + }); + } + + function showOrderMsg(text, ok) { + var el = document.getElementById('order-msg'); + if (!el) return; + if (!text) { + el.hidden = true; + el.textContent = ''; + el.className = 'trade-order-msg'; + return; + } + el.hidden = false; + el.textContent = text; + el.className = 'trade-order-msg ' + (ok ? 'ok' : 'err'); + } + + function postOrder(offset) { + var sym = selectedSymbol(); + if (!sym) { showOrderMsg('请选择品种', false); return; } + var direction = dirSelect ? dirSelect.value : 'long'; + var price = entryPrice(); + if (!price || price <= 0) { + showOrderMsg('无法获取有效价格,请先填写或刷新行情', false); + return; + } + var lots = effectiveLots(); + var trailingBeEl = document.getElementById('trailing-be'); + if (offset === 'open') { + if (!isTradingSession) { + showOrderMsg('不在交易时间段', false); + return; + } + var trailingOn = !!(trailingBeEl && trailingBeEl.checked); + if (trailingOn && !(slInput && slInput.value)) { + showOrderMsg('开启移动保本须填写止损价', false); + return; + } + if (isAmountMode() && lots <= 0) { + showOrderMsg('请填写止损,系统将按固定金额自动计算手数', false); + return; + } + if (isFixedMode() && lots <= 0) { + showOrderMsg('手数无效,请检查系统设置中的固定手数', false); + return; + } + if (lots <= 0) { + showOrderMsg('请填写有效手数', false); + return; + } + var maxLots = maxLotsForSymbol(sym); + if (maxLots > 0 && lots > maxLots) { + showOrderMsg('手数 ' + lots + ' 超过最大手数 ' + maxLots + ' 手', false); + return; + } + } + var btnOpen = document.getElementById('btn-open'); + if (btnOpen) { + btnOpen.disabled = true; + btnOpen.textContent = '开仓中…'; + } + showOrderMsg('开仓中…', true); + var body = { + symbol: sym, + offset: offset, + direction: direction, + 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, + trailing_be: !!(trailingBeEl && trailingBeEl.checked) + }; + fetch('/api/trade/order', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }).then(function (r) { return r.json(); }).then(function (data) { + if (!data.ok) { + showOrderMsg(data.error || '下单失败', false); + return; + } + var msg = data.message || ( + data.filled ? ('开仓成功 · ' + (data.lots || lots) + ' 手') : + ('委托已提交 · ' + (data.lots || lots) + ' 手挂单中') + ); + showOrderMsg(msg, true); + pollPositions(); + refreshQuote(); + setTimeout(function () { showOrderMsg(''); }, 4000); + }).catch(function () { + showOrderMsg('网络错误,请重试', false); + }).finally(function () { + if (btnOpen) { + btnOpen.textContent = '开仓'; + updateSessionUi(); + } + }); + } + + function buildPendingHtml(items) { + if (!items || !items.length) return ''; + var rows = items.map(function (p) { + var cls = p.order_kind === 'stop_loss' ? 'sl' : (p.order_kind === 'take_profit' ? 'tp' : 'ctp'); + var dismissBtn = p.monitor_id ? + '' : ''; + return ( + '
' + + '' + (p.label || '挂单') + '' + + '' + + '' + fmtNum(p.price) + ' · ' + (p.lots || 1) + ' 手' + + dismissBtn + + '' + + '
' + ); + }).join(''); + return '
止盈止损监控
' + rows + '
'; + } + + function dismissMonitor(monitorId, btn, opts) { + opts = opts || {}; + if (!monitorId) return; + var isPending = !!opts.pending; + if (isPending && !isTradingSession) { + alert('不在交易时间段,无法撤单'); + return; + } + var confirmMsg = isPending + ? '撤销该开仓委托?(将向柜台发送撤单)' + : '取消该本地止盈止损监控?(不影响柜台委托)'; + if (!confirm(confirmMsg)) return; + if (btn) { + btn.disabled = true; + btn.textContent = '取消中…'; + } + var url = isPending ? '/api/trading/monitor/cancel-open' : '/api/trading/monitor/dismiss'; + fetch(url, { + 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 || '取消失败'); + pollPositions(); + }) + .catch(function (e) { + alert(e.message || '取消失败'); + if (btn) { + btn.disabled = false; + btn.textContent = isPending ? '撤单' : '取消'; + } + }); + } + + function bindCancelOpenButtons(root) { + if (!root) return; + root.querySelectorAll('[data-cancel-open]').forEach(function (btn) { + btn.addEventListener('click', function () { + if (!isTradingSession) { + alert('不在交易时间段,无法撤单'); + return; + } + dismissMonitor(parseInt(btn.getAttribute('data-cancel-open'), 10), btn, { pending: true }); + }); + }); + } + + function bindPendingDismiss(root) { + if (!root) return; + root.querySelectorAll('[data-monitor-id]').forEach(function (btn) { + btn.addEventListener('click', function () { + var isPendingCancel = btn.getAttribute('data-pending-cancel') === '1'; + dismissMonitor( + parseInt(btn.getAttribute('data-monitor-id'), 10), + btn, + isPendingCancel ? { pending: true } : {} + ); + }); + }); + } + + function bindCancelOrderButtons(root) { + if (!root) return; + root.querySelectorAll('[data-cancel-order]').forEach(function (btn) { + btn.addEventListener('click', function () { + if (!isTradingSession) { + alert('不在交易时间段,无法撤单'); + return; + } + var orderId = decodeURIComponent(btn.getAttribute('data-cancel-order') || ''); + if (!orderId) return; + if (!confirm('撤销该柜台委托?')) return; + btn.disabled = true; + btn.textContent = '撤单中…'; + fetch('/api/trading/order/cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ order_id: orderId }) + }).then(function (r) { return r.json(); }).then(function (d) { + if (!d.ok) throw new Error(d.error || d.message || '撤单失败'); + pollPositions(); + }).catch(function (e) { + alert(e.message || '撤单失败'); + btn.disabled = false; + btn.textContent = '撤单'; + }); + }); + }); + } + + function slTpStatusHtml(row) { + var parts = []; + if (row.sl_order_active || row.sl_monitoring) { + parts.push('止损监控中'); + } else if (row.stop_loss != null) { + parts.push('止损已设'); + } + if (row.tp_order_active || row.tp_monitoring) { + parts.push('止盈监控中'); + } else if (row.take_profit != null) { + parts.push('止盈已设'); + } + if (!parts.length) return '未设置'; + return parts.join(' · '); + } + + function trailingStatusHtml(row) { + if (row.trailing_be) { + return '已开启' + + (row.trailing_r_locked ? '(锁' + row.trailing_r_locked + 'R)' : '') + ''; + } + return '未开启'; + } + + function buildPendingOrderCard(row) { + var dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空'); + var openT = (row.open_time || '').replace('T', ' ').slice(0, 16); + var orderPx = row.order_price != null ? row.order_price : row.entry_price; + var remainMin = row.pending_timeout_min != null + ? row.pending_timeout_min + : (row.auto_cancel_sec != null ? Math.max(1, Math.ceil(row.auto_cancel_sec / 60)) : 5); + var cancelAllowed = row.cancel_allowed !== false && isTradingSession; + var cancelBtn = row.can_cancel_order ? + '' : ''; + var metaLine = + '状态 挂单中' + + ' · 委托价 ' + fmtNum(orderPx) + '' + + (row.rr_ratio != null ? ' · 盈亏比 ' + row.rr_ratio + ':1' : '') + + ' · ' + slTpStatusHtml(row) + + ' · 移动保本 ' + trailingStatusHtml(row) + + ' · 约 ' + remainMin + ' 分钟内未成交自动撤单'; + return ( + '
' + + '
' + row.symbol + + ' ' + dirBadge + '' + + ' 挂单中
' + + '
' + (row.symbol_code || '') + '
' + + '
' + cancelBtn + '
' + + '
' + metaLine + '
' + + '
' + + '
' + row.lots + ' 手
' + + '
' + fmtNum(orderPx) + '
' + + '
' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '
' + + '
等待成交
' + + '
' + (openT || '--') + '
' + + '
' + + buildPendingHtml(row.pending_orders) + + '
' + ); + } + + function buildPosCard(row) { + if (row.order_state === 'pending') { + return buildPendingOrderCard(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 dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空'); + var openT = (row.open_time || '').replace('T', ' ').slice(0, 16); + var closeAllowed = row.close_allowed !== false && isTradingSession; + var slTpBtn = (!row.stop_loss && !row.take_profit && row.can_close) ? + '' : ''; + var editPayload = encodeURIComponent(JSON.stringify({ + symbol_code: row.symbol_code, direction: row.direction, + lots: row.lots, entry_price: row.entry_price, monitor_id: row.monitor_id || null, + stop_loss: row.stop_loss, take_profit: row.take_profit + })); + var entrustBtn = row.can_close ? + '' : ''; + var orderBtn = ''; + if (row.monitor_id && (row.stop_loss != null || row.take_profit != null) && row.can_place_orders) { + orderBtn = ''; + } + 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 ? + '' : ''; + var actionBtns = (entrustBtn || orderBtn || closeBtn) ? + '
' + entrustBtn + orderBtn + closeBtn + '
' : ''; + var metaLine = + '来源 ' + (row.source_label || 'CTP') + '' + + (row.rr_ratio != null ? ' · 盈亏比 ' + row.rr_ratio + ':1' : '') + + ' · 止损金额 ' + + (row.risk_amount != null ? fmtNum(row.risk_amount) + ' 元' : '--') + '' + + ' · 盈利金额 ' + + (row.reward_amount != null ? fmtNum(row.reward_amount) + ' 元' : '--') + '' + + ' · ' + slTpStatusHtml(row) + + ' · 移动保本 ' + trailingStatusHtml(row) + + (slTpBtn ? ' · ' + slTpBtn : '') + + (row.sync_pending ? ' · 同步柜台中…' : ''); + var feeLabel = row.fee_source === 'ctp' ? '手续费(柜台)' : '手续费'; + var marginLabel = row.margin_source === 'ctp' ? '占用保证金(柜台)' : '占用保证金'; + var openLabel = '开仓'; + return ( + '
' + + '
' + row.symbol + ' ' + dirBadge + '
' + + '
' + (row.symbol_code || '') + '
' + + actionBtns + '
' + + '
' + metaLine + '
' + + '
' + + '
' + row.lots + ' 手
' + + '
' + fmtNum(row.entry_price) + '
' + + '
' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '
' + + '
' + (row.margin != null ? fmtNum(row.margin) + ' 元' : '--') + '
' + + '
' + (row.position_pct != null ? fmtNum(row.position_pct) + '%' : '--') + '
' + + '
' + pnlText + '
' + + '
' + (row.est_fee != null ? fmtNum(row.est_fee) + ' 元' : '--') + '
' + + '
' + (openT || '--') + '
' + + '
' + (row.holding_duration || '--') + '
' + + '
' + buildPendingHtml(row.pending_orders) + + '
' + ); + } + + 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 || '委托失败'); + var msg = d.message || '委托已提交'; + if (d.skipped && d.skipped.length) msg += '\n' + d.skipped.join('\n'); + alert(msg); + 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, btnLabel) { + btnLabel = btnLabel || '设置止盈止损'; + var slDefault = payload.stop_loss != null && payload.stop_loss !== '' ? String(payload.stop_loss) : ''; + var tpDefault = payload.take_profit != null && payload.take_profit !== '' ? String(payload.take_profit) : ''; + var slRaw = prompt('止损价(可留空)', slDefault); + if (slRaw === null) return; + var tpRaw = prompt('止盈价(可留空)', tpDefault); + 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' }, + credentials: 'same-origin', + body: JSON.stringify({ + symbol_code: payload.symbol_code, + direction: payload.direction, + lots: payload.lots, + entry_price: payload.entry_price, + monitor_id: payload.monitor_id || null, + stop_loss: sl, + take_profit: tp + }) + }) + .then(function (r) { + if (!r.ok) { + return r.json().catch(function () { return {}; }).then(function (d) { + throw new Error(d.error || ('HTTP ' + r.status)); + }); + } + return r.json(); + }) + .then(function (d) { + if (!d.ok) throw new Error(d.error || '保存失败'); + pollPositions(); + }) + .catch(function (e) { + var msg = e.message || '保存失败'; + if (msg === 'Failed to fetch') msg = '网络请求失败,请检查服务是否运行'; + alert(msg); + if (btn) { + btn.disabled = false; + btn.textContent = btnLabel; + } + }); + } + + 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, '设置止盈止损' + ); + }); + }); + root.querySelectorAll('[data-edit-sl-tp]').forEach(function (btn) { + btn.addEventListener('click', function () { + promptStopTakeProfit( + JSON.parse(decodeURIComponent(btn.getAttribute('data-edit-sl-tp'))), btn, '委托' + ); + }); + }); + } + + function closePosition(payload, btn) { + if (!isTradingSession) { + alert('不在交易时间段,无法平仓'); + return; + } + function doClose(price) { + if (!price || price <= 0) { alert('无法获取现价'); return; } + if (!confirm('确认平仓 ' + payload.lots + ' 手?')) return; + if (btn) { + btn.disabled = true; + btn.textContent = '平仓中…'; + } + fetch('/api/trading/close', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(Object.assign({}, payload, { price: price })) + }).then(function (r) { return r.json(); }).then(function (d) { + if (!d.ok) { + alert(d.error || '平仓失败'); + if (btn) { + btn.disabled = false; + btn.textContent = '平仓'; + } + return; + } + if (btn) btn.textContent = '已平仓'; + pollPositions(); + }).catch(function () { + if (btn) { + btn.disabled = false; + btn.textContent = '平仓'; + } + }); + } + if (payload.mark_price > 0) { + doClose(payload.mark_price); + return; + } + fetch('/api/trade/quote?symbol=' + encodeURIComponent(payload.symbol_code) + '&lots=' + payload.lots) + .then(function (r) { return r.json(); }) + .then(function (d) { doClose(d.price); }); + } + + function pollPositions() { + if (!list) return; + fetch('/api/trading/live') + .then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }) + .then(function (data) { + applyPositionsData(data); + }) + .catch(function () { + if (!positionsRendered && list.innerHTML.indexOf('pos-card') < 0) { + list.innerHTML = '
持仓加载失败
'; + } + }); + } + + function connectPositionStream() { + if (positionSource) { + positionSource.close(); + positionSource = null; + } + positionSource = new EventSource('/api/trading/stream'); + positionSource.addEventListener('positions', function (ev) { + try { + applyPositionsData(JSON.parse(ev.data)); + } catch (e) { /* ignore */ } + }); + positionSource.onerror = function () { + if (positionSource) { + positionSource.close(); + positionSource = null; + } + setTimeout(connectPositionStream, 3000); + }; + } + + function loadRecSortPrefs() { + try { + var raw = sessionStorage.getItem(REC_SORT_CACHE); + if (!raw) return; + var p = JSON.parse(raw); + if (p.key) recSortKey = p.key; + if (typeof p.desc === 'boolean') recSortDesc = p.desc; + } catch (e) { /* ignore */ } + try { + var ind = sessionStorage.getItem(REC_INDUSTRY_CACHE); + if (ind != null) recIndustryFilter = ind; + } catch (e2) { /* ignore */ } + } + + function saveRecSortPrefs() { + try { + sessionStorage.setItem(REC_SORT_CACHE, JSON.stringify({ key: recSortKey, desc: recSortDesc })); + } catch (e) { /* ignore */ } + } + + function saveRecIndustryPref() { + try { + sessionStorage.setItem(REC_INDUSTRY_CACHE, recIndustryFilter || ''); + } catch (e) { /* ignore */ } + } + + function syncRecSortUi() { + var sel = document.getElementById('rec-sort-key'); + var btn = document.getElementById('rec-sort-dir'); + var indSel = document.getElementById('rec-industry-filter'); + if (sel) sel.value = recSortKey; + if (btn) btn.textContent = recSortDesc ? '↓' : '↑'; + if (indSel) indSel.value = recIndustryFilter || ''; + } + + function filterRecommendRows(rows) { + if (!recIndustryFilter) return (rows || []).slice(); + return (rows || []).filter(function (r) { + return (r.category || '') === recIndustryFilter; + }); + } + + function countByCategory(rows) { + var counts = {}; + (rows || []).forEach(function (r) { + var cat = r.category || '其他'; + counts[cat] = (counts[cat] || 0) + 1; + }); + return counts; + } + + function updateRecStats(allRows, visibleRows) { + var el = document.getElementById('rec-stats'); + if (!el) return; + var total = (allRows || []).length; + var shown = (visibleRows || []).length; + if (!total) { + el.textContent = ''; + return; + } + var parts = []; + if (recIndustryFilter) { + parts.push('筛选 ' + shown + ' / 共 ' + total + ' 个品种'); + } else { + parts.push('共 ' + total + ' 个品种'); + } + var order = productCategories.length ? productCategories.slice() : []; + var counts = countByCategory(recIndustryFilter ? visibleRows : allRows); + Object.keys(counts).forEach(function (k) { + if (order.indexOf(k) < 0) order.push(k); + }); + var breakdown = order.filter(function (cat) { return counts[cat]; }).map(function (cat) { + return cat + ' ' + counts[cat]; + }); + if (breakdown.length) parts.push(breakdown.join(' · ')); + el.innerHTML = parts.join(' · '); + } + + var TREND_SORT_RANK = { break_long: 0, break_short: 0, long: 1, short: 2, range: 3, '': 9 }; + var GAP_SORT_RANK = { up: 2, down: 1, none: 0, '': -1 }; + + function sortRecommendRows(rows) { + var list = (rows || []).slice(); + var key = recSortKey || 'trend'; + var desc = recSortDesc; + list.sort(function (a, b) { + var av, bv, as, bs; + if (key === 'gap') { + av = GAP_SORT_RANK[a.gap || ''] !== undefined ? GAP_SORT_RANK[a.gap || ''] : -1; + bv = GAP_SORT_RANK[b.gap || ''] !== undefined ? GAP_SORT_RANK[b.gap || ''] : -1; + as = Math.abs(Number(a.gap_pct) || 0); + bs = Math.abs(Number(b.gap_pct) || 0); + } else if (key === 'volume') { + av = Number(a.volume) || 0; + bv = Number(b.volume) || 0; + as = bs = 0; + } else if (key === 'amplitude') { + av = Number(a.yesterday_amplitude_pct) || 0; + bv = Number(b.yesterday_amplitude_pct) || 0; + as = bs = 0; + } else { + av = TREND_SORT_RANK[a.trend || ''] !== undefined ? TREND_SORT_RANK[a.trend || ''] : 9; + bv = TREND_SORT_RANK[b.trend || ''] !== undefined ? TREND_SORT_RANK[b.trend || ''] : 9; + as = Number(a.max_lots) || 0; + bs = Number(b.max_lots) || 0; + } + if (av !== bv) return desc ? bv - av : av - bv; + if (as !== bs) return desc ? bs - as : as - bs; + return String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN'); + }); + return list; + } + + function fmtRecVolume(v) { + if (v === null || v === undefined) return '—'; + var n = Number(v); + if (!isFinite(n)) return '—'; + if (n >= 10000) return (n / 10000).toFixed(1) + '万'; + return String(Math.round(n)); + } + + function fmtRecTurnover(v) { + if (v === null || v === undefined) return '—'; + var n = Number(v); + if (!isFinite(n)) return '—'; + if (n >= 1e8) return (n / 1e8).toFixed(2) + '亿'; + if (n >= 1e4) return (n / 1e4).toFixed(1) + '万'; + return String(Math.round(n)); + } + + function changeCellHtml(r) { + if (r.yesterday_change == null) return '—'; + var ch = Number(r.yesterday_change); + var cls = ch > 0 ? 'rec-change-up' : (ch < 0 ? 'rec-change-down' : ''); + var txt = (ch > 0 ? '+' : '') + ch; + if (r.yesterday_change_pct != null) { + var pct = Number(r.yesterday_change_pct); + txt += ' (' + (pct > 0 ? '+' : '') + pct + '%)'; + } + return '' + txt + ''; + } + + function trendBadgeHtml(r) { + var label = r.trend_label || ''; + if (!label || label === '—') return '—'; + var cls = 'planned'; + if (r.trend === 'break_long' || r.trend === 'break_short') cls = 'break'; + else if (r.trend === 'long') cls = 'profit'; + else if (r.trend === 'short') cls = 'loss'; + var title = ''; + if (r.trend_overlap_pct != null) title = ' title="近3日重叠 ' + r.trend_overlap_pct + '%"'; + var prefix = r.trend_transition ? '★ ' : ''; + return '' + prefix + label + ''; + } + + function gapBadgeHtml(r) { + var label = r.gap_label || ''; + if (!label || label === '—') return '—'; + var cls = 'planned'; + if (r.gap === 'up') cls = 'profit'; + else if (r.gap === 'down') cls = 'loss'; + var title = ''; + if (r.gap_pct != null && r.gap !== 'none') { + title = ' title="跳空 ' + (Number(r.gap_pct) > 0 ? '+' : '') + r.gap_pct + '%"'; + } + return '' + label + ''; + } + + function renderRecommendRows(rows) { + if (!recommendList) return; + if (!rows.length) { + var emptyMsg = recIndustryFilter + ? '当前行业下暂无推荐品种' + : '当前资金下暂无推荐品种(每日后台刷新)'; + recommendList.innerHTML = '' + emptyMsg + ''; + return; + } + recommendList.innerHTML = rows.map(function (r) { + var rowCls = 'rec-' + (r.status || ''); + if (r.trend_transition) rowCls += ' rec-trend-break'; + var nameCls = r.trend_transition ? ' class="trend-name"' : ''; + return ( + '' + + '' + (r.name || '') + ' ' + (r.main_code || r.ths || '') + '' + + '' + (r.exchange || '') + '' + + '' + (r.category || '—') + '' + + '' + trendBadgeHtml(r) + '' + + '' + gapBadgeHtml(r) + '' + + '' + (r.price != null ? r.price : '—') + '' + + '' + (r.prev_close != null ? r.prev_close : '—') + '' + + '' + (r.today_open != null ? r.today_open : '—') + '' + + '' + changeCellHtml(r) + '' + + '' + (r.yesterday_amplitude_pct != null ? r.yesterday_amplitude_pct + '%' : '—') + '' + + '' + fmtRecVolume(r.volume) + '' + + '' + fmtRecTurnover(r.turnover) + '' + + '' + (r.margin_one_lot != null ? r.margin_one_lot + (r.margin_source === 'ctp' ? ' (柜台)' : '') : '—') + '' + + '' + (r.open_fee_one_lot != null ? r.open_fee_one_lot : '—') + '' + + '' + (r.max_lots != null && r.max_lots > 0 ? r.max_lots : '—') + '' + + '' + (r.status_label || '') + '' + + '' + ); + }).join(''); + } + + function renderRecommendTable() { + var filtered = filterRecommendRows(recRowsRaw); + var sorted = sortRecommendRows(filtered); + updateRecStats(recRowsRaw, sorted); + renderRecommendRows(sorted); + } + + function renderRecommendations(data) { + if (!recommendList || !data) return; + updateRecommendMaxMaps(data); + 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 || []; + recRowsRaw = rows.slice(); + if (!rows.length) { + recommendList.innerHTML = '当前资金下暂无推荐品种(每日后台刷新)'; + updateRecStats([], []); + return; + } + renderRecommendTable(); + } + + function initRecommendSortControls() { + loadRecSortPrefs(); + syncRecSortUi(); + var sel = document.getElementById('rec-sort-key'); + var btn = document.getElementById('rec-sort-dir'); + var indSel = document.getElementById('rec-industry-filter'); + if (indSel) { + indSel.addEventListener('change', function () { + recIndustryFilter = indSel.value || ''; + saveRecIndustryPref(); + renderRecommendTable(); + }); + } + if (sel) { + sel.addEventListener('change', function () { + recSortKey = sel.value || 'trend'; + saveRecSortPrefs(); + renderRecommendTable(); + }); + } + if (btn) { + btn.addEventListener('click', function () { + recSortDesc = !recSortDesc; + saveRecSortPrefs(); + syncRecSortUi(); + renderRecommendTable(); + }); + } + if (recRowsRaw.length) updateRecStats(recRowsRaw, filterRecommendRows(recRowsRaw)); + } + + function connectRecommendStream() { + if (recommendSource) { recommendSource.close(); recommendSource = null; } + recommendSource = new EventSource('/api/recommend/stream'); + recommendSource.addEventListener('recommend', function (ev) { + try { renderRecommendations(JSON.parse(ev.data)); } catch (e) { /* ignore */ } + }); + recommendSource.onerror = function () { + if (recommendSource) { recommendSource.close(); recommendSource = null; } + setTimeout(connectRecommendStream, 5000); + }; + } + + document.querySelectorAll('.price-tab').forEach(function (btn) { + btn.addEventListener('click', function () { + setPriceType(btn.getAttribute('data-type')); + scheduleQuote(); + }); + }); + + if (symInput) { + symInput.addEventListener('input', function () { + selectedMaxLots = null; + scheduleQuote(); + scheduleAutoCalc(); + checkLotsLimit(); + }); + symInput.addEventListener('symbol-selected', function (ev) { + var item = ev.detail || {}; + selectedMaxLots = item.max_lots > 0 ? item.max_lots : null; + scheduleQuote(); + scheduleAutoCalc(); + checkLotsLimit(); + }); + } + if (lotsCalc) lotsCalc.addEventListener('input', checkLotsLimit); + if (slInput) { + slInput.addEventListener('input', function () { + scheduleAutoCalc(); + updateRRDisplay(); + }); + } + if (tpInput) { + tpInput.addEventListener('input', function () { + scheduleAutoCalc(); + updateRRDisplay(); + }); + } + if (dirSelect) dirSelect.addEventListener('change', function () { + scheduleAutoCalc(); + updateRRDisplay(); + }); + if (priceInput) { + priceInput.addEventListener('input', function () { + if (priceType === 'limit') priceInput.dataset.manual = '1'; + scheduleAutoCalc(); + updateRRDisplay(); + }); + } + + var btnOpen = document.getElementById('btn-open'); + if (btnOpen) btnOpen.addEventListener('click', function () { postOrder('open'); }); + + var btnConnect = document.getElementById('btn-ctp-connect'); + if (btnConnect) { + btnConnect.addEventListener('click', function () { + requestCtpConnect(true); + }); + } + + function initCtpOnLoad() { + fetch('/api/ctp/status') + .then(function (r) { return r.json(); }) + .then(function (d) { + var st = d.status || {}; + syncCtpBadgeFromStatus(st); + if (st.last_error) showCtpError(st.last_error); + if (st.connected) pollPositions(); + }) + .catch(function () {}); + } + + runWhenReady(function () { + setPriceType('limit'); + if (isFixedMode() && lotsCalc) { + lotsCalc.value = String(window.TRADE_FIXED_LOTS || 1); + if (lotsInput) lotsInput.value = lotsCalc.value; + } + var cached = loadPosCache(); + if (cached) { + if (cached.ctp_status) { + cached.ctp_status = Object.assign({}, cached.ctp_status, { connecting: false }); + } + applyPositionsData(cached); + } + pollPositions(); + connectPositionStream(); + initCtpOnLoad(); + connectRecommendStream(); + initRecommendSortControls(); + if (window.__RECOMMEND_ROWS__ && window.__RECOMMEND_ROWS__.length) { + recRowsRaw = window.__RECOMMEND_ROWS__.slice(); + renderRecommendTable(); + } + fetch('/api/recommend/list') + .then(function (r) { return r.json(); }) + .then(function (data) { if (data.ok) renderRecommendations(data); }) + .catch(function () {}); + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible' && !positionSource) { + connectPositionStream(); + } + }); + updateSessionUi(); + updateRRDisplay(); + scheduleQuote(); + scheduleAutoCalc(); + }); +})(); diff --git a/static/js/trades.js b/static/js/trades.js index 07b2df4..b440111 100644 --- a/static/js/trades.js +++ b/static/js/trades.js @@ -1,42 +1,46 @@ -(function () { - var switchEl = document.getElementById('trade-edit-switch'); - if (!switchEl) return; - - function setEditMode(on) { - document.querySelectorAll('.cell-edit-hide').forEach(function (el) { - el.style.display = on ? 'none' : ''; - }); - document.querySelectorAll('.cell-edit-show').forEach(function (el) { - if (el.type === 'hidden') return; - el.style.display = on ? '' : 'none'; - }); - document.querySelectorAll('.trade-save-btn').forEach(function (btn) { - btn.disabled = !on; - }); - } - - switchEl.addEventListener('change', function () { - setEditMode(switchEl.checked); - }); - - document.querySelectorAll('.trade-save-btn').forEach(function (btn) { - btn.addEventListener('click', function () { - var row = btn.closest('tr[data-trade-id]'); - if (!row) return; - var id = row.getAttribute('data-trade-id'); - var form = document.createElement('form'); - form.method = 'POST'; - form.action = '/update_trade/' + id; - row.querySelectorAll('.cell-edit-show').forEach(function (el) { - if (!el.name) return; - var input = document.createElement('input'); - input.type = 'hidden'; - input.name = el.name; - input.value = el.value; - form.appendChild(input); - }); - document.body.appendChild(form); - form.submit(); - }); - }); -})(); +/* Copyright (c) 2025-2026 马建军. All rights reserved. + * 专有软件 — 未经授权禁止复制、传播、转售。 + * 详见 LICENSE.zh-CN.txt + */ +(function () { + var switchEl = document.getElementById('trade-edit-switch'); + if (!switchEl) return; + + function setEditMode(on) { + document.querySelectorAll('.cell-edit-hide').forEach(function (el) { + el.style.display = on ? 'none' : ''; + }); + document.querySelectorAll('.cell-edit-show').forEach(function (el) { + if (el.type === 'hidden') return; + el.style.display = on ? '' : 'none'; + }); + document.querySelectorAll('.trade-save-btn').forEach(function (btn) { + btn.disabled = !on; + }); + } + + switchEl.addEventListener('change', function () { + setEditMode(switchEl.checked); + }); + + document.querySelectorAll('.trade-save-btn').forEach(function (btn) { + btn.addEventListener('click', function () { + var row = btn.closest('tr[data-trade-id]'); + if (!row) return; + var id = row.getAttribute('data-trade-id'); + var form = document.createElement('form'); + form.method = 'POST'; + form.action = '/update_trade/' + id; + row.querySelectorAll('.cell-edit-show').forEach(function (el) { + if (!el.name) return; + var input = document.createElement('input'); + input.type = 'hidden'; + input.name = el.name; + input.value = el.value; + form.appendChild(input); + }); + document.body.appendChild(form); + form.submit(); + }); + }); +})(); diff --git a/static/sw.js b/static/sw.js index e46b1f0..fb5f74d 100644 --- a/static/sw.js +++ b/static/sw.js @@ -1,64 +1,68 @@ -var CACHE_VERSION = 'qihuo-v3'; -var STATIC_CACHE = CACHE_VERSION + '-static'; -var STATIC_ASSETS = [ - '/static/css/tech.css', - '/static/css/responsive.css', - '/static/css/trade.css', - '/static/js/theme.js', - '/static/js/nav.js', - '/static/js/pwa.js', - '/static/js/symbol.js', - '/static/js/trade.js', - '/static/icons/icon-192.png', - '/static/icons/icon-512.png', - '/static/icons/icon.svg', - '/static/manifest.json', - '/login' -]; - -self.addEventListener('install', function (event) { - event.waitUntil( - caches.open(STATIC_CACHE).then(function (cache) { - return cache.addAll(STATIC_ASSETS).catch(function () { /* ignore partial */ }); - }).then(function () { return self.skipWaiting(); }) - ); -}); - -self.addEventListener('activate', function (event) { - event.waitUntil( - caches.keys().then(function (keys) { - return Promise.all(keys.filter(function (k) { - return k.startsWith('qihuo-') && k !== STATIC_CACHE; - }).map(function (k) { return caches.delete(k); })); - }).then(function () { return self.clients.claim(); }) - ); -}); - -self.addEventListener('fetch', function (event) { - var req = event.request; - if (req.method !== 'GET') return; - - var url = new URL(req.url); - if (url.origin !== self.location.origin) return; - - if (url.pathname.indexOf('/static/') === 0) { - event.respondWith( - caches.match(req).then(function (cached) { - return cached || fetch(req).then(function (res) { - var copy = res.clone(); - caches.open(STATIC_CACHE).then(function (cache) { cache.put(req, copy); }); - return res; - }); - }) - ); - return; - } - - if (req.mode === 'navigate' || (req.headers.get('accept') || '').indexOf('text/html') !== -1) { - event.respondWith( - fetch(req).catch(function () { - return caches.match('/login'); - }) - ); - } -}); +/* Copyright (c) 2025-2026 马建军. All rights reserved. + * 专有软件 — 未经授权禁止复制、传播、转售。 + * 详见 LICENSE.zh-CN.txt + */ +var CACHE_VERSION = 'qihuo-v3'; +var STATIC_CACHE = CACHE_VERSION + '-static'; +var STATIC_ASSETS = [ + '/static/css/tech.css', + '/static/css/responsive.css', + '/static/css/trade.css', + '/static/js/theme.js', + '/static/js/nav.js', + '/static/js/pwa.js', + '/static/js/symbol.js', + '/static/js/trade.js', + '/static/icons/icon-192.png', + '/static/icons/icon-512.png', + '/static/icons/icon.svg', + '/static/manifest.json', + '/login' +]; + +self.addEventListener('install', function (event) { + event.waitUntil( + caches.open(STATIC_CACHE).then(function (cache) { + return cache.addAll(STATIC_ASSETS).catch(function () { /* ignore partial */ }); + }).then(function () { return self.skipWaiting(); }) + ); +}); + +self.addEventListener('activate', function (event) { + event.waitUntil( + caches.keys().then(function (keys) { + return Promise.all(keys.filter(function (k) { + return k.startsWith('qihuo-') && k !== STATIC_CACHE; + }).map(function (k) { return caches.delete(k); })); + }).then(function () { return self.clients.claim(); }) + ); +}); + +self.addEventListener('fetch', function (event) { + var req = event.request; + if (req.method !== 'GET') return; + + var url = new URL(req.url); + if (url.origin !== self.location.origin) return; + + if (url.pathname.indexOf('/static/') === 0) { + event.respondWith( + caches.match(req).then(function (cached) { + return cached || fetch(req).then(function (res) { + var copy = res.clone(); + caches.open(STATIC_CACHE).then(function (cache) { cache.put(req, copy); }); + return res; + }); + }) + ); + return; + } + + if (req.mode === 'navigate' || (req.headers.get('accept') || '').indexOf('text/html') !== -1) { + event.respondWith( + fetch(req).catch(function () { + return caches.match('/login'); + }) + ); + } +}); diff --git a/stats_engine.py b/stats_engine.py index 246a562..ee0eef7 100644 --- a/stats_engine.py +++ b/stats_engine.py @@ -1,310 +1,315 @@ -"""交易统计计算与缓存结构。""" -from __future__ import annotations - -import json -from datetime import datetime -from typing import Any, Optional - -from zoneinfo import ZoneInfo - -TZ = ZoneInfo("Asia/Shanghai") - -STATS_VIEWS = [ - {"key": "by_time", "label": "按时间统计"}, - {"key": "by_week", "label": "周统计"}, - {"key": "by_month", "label": "月统计"}, - {"key": "by_symbol", "label": "按品种统计"}, - {"key": "by_fee", "label": "按手续费统计"}, - {"key": "by_direction", "label": "按方向统计"}, - {"key": "by_trade_type", "label": "按交易类型统计"}, - {"key": "by_emotion", "label": "情绪单统计"}, -] - -BREAKDOWN_COLUMNS = [ - {"key": "label", "label": "维度"}, - {"key": "count", "label": "交易次数"}, - {"key": "wins", "label": "盈利笔数"}, - {"key": "losses", "label": "亏损笔数"}, - {"key": "win_rate", "label": "胜率(%)"}, - {"key": "avg_profit", "label": "平均盈利"}, - {"key": "avg_loss", "label": "平均亏损"}, - {"key": "profit_loss_ratio", "label": "盈亏比"}, - {"key": "total_fee", "label": "累计手续费"}, - {"key": "total_net", "label": "净盈亏合计"}, - {"key": "max_loss", "label": "最大亏损"}, - {"key": "max_profit", "label": "最大盈利"}, -] - - -def _parse_dt(value: str) -> Optional[datetime]: - if not value: - return None - text = value.strip().replace(" ", "T") - try: - return datetime.fromisoformat(text) - except ValueError: - return None - - -def _row_dict(row) -> dict: - return dict(row) if row is not None else {} - - -def _net_pnl(row: dict) -> float: - if row.get("pnl_net") is not None: - return float(row["pnl_net"]) - pnl = float(row.get("pnl") or 0) - fee = float(row.get("fee") or 0) - return round(pnl - fee, 2) - - -def _fee(row: dict) -> float: - return float(row.get("fee") or 0) - - -def _margin_pct(pnl_net: float, margin: Optional[float]) -> Optional[float]: - if margin and margin > 0: - return round(pnl_net / margin * 100, 2) - return None - - -def _agg_group(rows: list[dict], key_fn) -> list[dict]: - groups: dict[str, list[dict]] = {} - for row in rows: - key = key_fn(row) or "未知" - groups.setdefault(key, []).append(row) - result = [] - for label, items in sorted(groups.items(), key=lambda x: x[0]): - result.append(_agg_metrics(label, items)) - return result - - -def _agg_metrics(label: str, items: list[dict]) -> dict: - nets = [_net_pnl(r) for r in items] - wins = [n for n in nets if n > 0] - losses = [n for n in nets if n < 0] - count = len(items) - win_cnt = len(wins) - loss_cnt = len(losses) - avg_profit = round(sum(wins) / len(wins), 2) if wins else 0.0 - avg_loss = round(sum(losses) / len(losses), 2) if losses else 0.0 - pl_ratio = round(avg_profit / abs(avg_loss), 2) if wins and losses and avg_loss != 0 else 0.0 - total_fee = round(sum(_fee(r) for r in items), 2) - total_net = round(sum(nets), 2) - max_loss = round(min(nets), 2) if nets else 0.0 - max_profit = round(max(nets), 2) if nets else 0.0 - win_rate = round(win_cnt / count * 100, 2) if count else 0.0 - return { - "label": label, - "count": count, - "wins": win_cnt, - "losses": loss_cnt, - "win_rate": win_rate, - "avg_profit": avg_profit, - "avg_loss": avg_loss, - "profit_loss_ratio": pl_ratio, - "total_fee": total_fee, - "total_net": total_net, - "max_loss": max_loss, - "max_profit": max_profit, - } - - -def _max_consecutive_losses(nets: list[float]) -> int: - streak = 0 - best = 0 - for n in nets: - if n < 0: - streak += 1 - best = max(best, streak) - else: - streak = 0 - return best - - -def _max_drawdown(nets: list[float], initial_capital: float) -> tuple[float, float]: - equity = initial_capital - peak = initial_capital - max_dd = 0.0 - max_dd_pct = 0.0 - for n in nets: - equity += n - if equity > peak: - peak = equity - dd = peak - equity - if dd > max_dd: - max_dd = dd - if peak > 0: - pct = dd / peak * 100 - if pct > max_dd_pct: - max_dd_pct = pct - return round(max_dd, 2), round(max_dd_pct, 2) - - -def fetch_trade_rows(conn) -> list[dict]: - rows = conn.execute( - "SELECT * FROM trade_logs ORDER BY close_time ASC, id ASC" - ).fetchall() - return [_row_dict(r) for r in rows] - - -def fetch_review_rows(conn) -> list[dict]: - rows = conn.execute( - "SELECT * FROM review_records ORDER BY close_time ASC, id ASC" - ).fetchall() - return [_row_dict(r) for r in rows] - - -def compute_summary(trades: list[dict], reviews: list[dict], live_capital: float) -> dict: - nets = [_net_pnl(t) for t in trades] - count = len(trades) - wins = [n for n in nets if n > 0] - losses = [n for n in nets if n < 0] - win_cnt = len(wins) - loss_cnt = len(losses) - avg_profit = round(sum(wins) / len(wins), 2) if wins else 0.0 - avg_loss = round(sum(losses) / len(losses), 2) if losses else 0.0 - pl_ratio = round(avg_profit / abs(avg_loss), 2) if wins and losses and avg_loss != 0 else 0.0 - total_fee = round(sum(_fee(t) for t in trades) + sum(_fee(r) for r in reviews), 2) - max_loss_amt = round(min(nets), 2) if nets else 0.0 - max_profit_amt = round(max(nets), 2) if nets else 0.0 - - margins_loss = [ - _margin_pct(_net_pnl(t), t.get("margin")) - for t in trades - if _net_pnl(t) < 0 and t.get("margin") - ] - margins_profit = [ - _margin_pct(_net_pnl(t), t.get("margin")) - for t in trades - if _net_pnl(t) > 0 and t.get("margin") - ] - max_loss_pct = round(min(margins_loss), 2) if margins_loss else 0.0 - max_profit_pct = round(max(margins_profit), 2) if margins_profit else 0.0 - - consec_loss = _max_consecutive_losses(nets) - max_dd, max_dd_pct = _max_drawdown(nets, live_capital) - - emotion_cnt = sum(1 for r in reviews if r.get("is_emotion")) - review_cnt = len(reviews) - denom = count if count else review_cnt - emotion_ratio = round(emotion_cnt / denom * 100, 2) if denom else 0.0 - - return { - "total_trades": count, - "win_rate": round(win_cnt / count * 100, 2) if count else 0.0, - "avg_profit": avg_profit, - "avg_loss": avg_loss, - "profit_loss_ratio": pl_ratio, - "consecutive_losses": consec_loss, - "max_drawdown": max_dd, - "max_drawdown_pct": max_dd_pct, - "max_loss_amount": max_loss_amt, - "max_loss_pct": max_loss_pct, - "max_profit_amount": max_profit_amt, - "max_profit_pct": max_profit_pct, - "total_fee": total_fee, - "emotion_count": emotion_cnt, - "emotion_ratio": emotion_ratio, - "review_count": review_cnt, - "win_count": win_cnt, - "loss_count": loss_cnt, - } - - -def compute_breakdowns(trades: list[dict], reviews: list[dict]) -> dict[str, dict]: - def day_key(row: dict) -> str: - dt = _parse_dt(row.get("close_time") or row.get("created_at") or "") - return dt.date().isoformat() if dt else "未知" - - def week_key(row: dict) -> str: - dt = _parse_dt(row.get("close_time") or row.get("created_at") or "") - if not dt: - return "未知" - iso = dt.isocalendar() - return f"{iso.year}-W{iso.week:02d}" - - def month_key(row: dict) -> str: - dt = _parse_dt(row.get("close_time") or row.get("created_at") or "") - return dt.strftime("%Y-%m") if dt else "未知" - - def symbol_key(row: dict) -> str: - return row.get("symbol_name") or row.get("symbol") or "未知" - - def direction_key(row: dict) -> str: - d = row.get("direction") or "" - return "做多" if d == "long" else ("做空" if d == "short" else d or "未知") - - def type_key(row: dict) -> str: - return row.get("monitor_type") or "未知" - - by_fee_rows = [] - fee_groups = {} - for t in trades: - key = symbol_key(t) - fee_groups.setdefault(key, []).append(t) - for label, items in sorted(fee_groups.items()): - row = _agg_metrics(label, items) - row["avg_fee"] = round(row["total_fee"] / row["count"], 2) if row["count"] else 0.0 - by_fee_rows.append(row) - - emotion_trades = [r for r in reviews if r.get("is_emotion")] - non_emotion = [r for r in reviews if not r.get("is_emotion")] - emotion_rows = [ - _agg_metrics("情绪单", emotion_trades), - _agg_metrics("非情绪单", non_emotion), - ] - - fee_columns = BREAKDOWN_COLUMNS + [{"key": "avg_fee", "label": "平均手续费"}] - - return { - "by_time": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, day_key)}, - "by_week": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, week_key)}, - "by_month": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, month_key)}, - "by_symbol": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, symbol_key)}, - "by_fee": {"columns": fee_columns, "rows": by_fee_rows}, - "by_direction": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, direction_key)}, - "by_trade_type": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, type_key)}, - "by_emotion": {"columns": BREAKDOWN_COLUMNS, "rows": emotion_rows}, - } - - -def build_all_stats(conn, live_capital: float = 0.0) -> dict: - trades = fetch_trade_rows(conn) - reviews = fetch_review_rows(conn) - summary = compute_summary(trades, reviews, live_capital) - breakdowns = compute_breakdowns(trades, reviews) - return { - "updated_at": datetime.now(TZ).isoformat(timespec="seconds"), - "summary": summary, - "views": STATS_VIEWS, - "breakdowns": breakdowns, - } - - -def save_stats_cache(conn, data: dict) -> None: - conn.execute( - """INSERT INTO stats_cache (key, data_json, updated_at) - VALUES ('all', ?, ?) - ON CONFLICT(key) DO UPDATE SET data_json=excluded.data_json, updated_at=excluded.updated_at""", - (json.dumps(data, ensure_ascii=False), data["updated_at"]), - ) - conn.commit() - - -def load_stats_cache(conn) -> Optional[dict]: - row = conn.execute( - "SELECT data_json FROM stats_cache WHERE key='all'" - ).fetchone() - if not row: - return None - try: - return json.loads(row["data_json"]) - except json.JSONDecodeError: - return None - - -def refresh_stats_cache(conn, live_capital: float = 0.0) -> dict: - data = build_all_stats(conn, live_capital) - save_stats_cache(conn, data) - return data +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""交易统计计算与缓存结构。""" +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any, Optional + +from zoneinfo import ZoneInfo + +TZ = ZoneInfo("Asia/Shanghai") + +STATS_VIEWS = [ + {"key": "by_time", "label": "按时间统计"}, + {"key": "by_week", "label": "周统计"}, + {"key": "by_month", "label": "月统计"}, + {"key": "by_symbol", "label": "按品种统计"}, + {"key": "by_fee", "label": "按手续费统计"}, + {"key": "by_direction", "label": "按方向统计"}, + {"key": "by_trade_type", "label": "按交易类型统计"}, + {"key": "by_emotion", "label": "情绪单统计"}, +] + +BREAKDOWN_COLUMNS = [ + {"key": "label", "label": "维度"}, + {"key": "count", "label": "交易次数"}, + {"key": "wins", "label": "盈利笔数"}, + {"key": "losses", "label": "亏损笔数"}, + {"key": "win_rate", "label": "胜率(%)"}, + {"key": "avg_profit", "label": "平均盈利"}, + {"key": "avg_loss", "label": "平均亏损"}, + {"key": "profit_loss_ratio", "label": "盈亏比"}, + {"key": "total_fee", "label": "累计手续费"}, + {"key": "total_net", "label": "净盈亏合计"}, + {"key": "max_loss", "label": "最大亏损"}, + {"key": "max_profit", "label": "最大盈利"}, +] + + +def _parse_dt(value: str) -> Optional[datetime]: + if not value: + return None + text = value.strip().replace(" ", "T") + try: + return datetime.fromisoformat(text) + except ValueError: + return None + + +def _row_dict(row) -> dict: + return dict(row) if row is not None else {} + + +def _net_pnl(row: dict) -> float: + if row.get("pnl_net") is not None: + return float(row["pnl_net"]) + pnl = float(row.get("pnl") or 0) + fee = float(row.get("fee") or 0) + return round(pnl - fee, 2) + + +def _fee(row: dict) -> float: + return float(row.get("fee") or 0) + + +def _margin_pct(pnl_net: float, margin: Optional[float]) -> Optional[float]: + if margin and margin > 0: + return round(pnl_net / margin * 100, 2) + return None + + +def _agg_group(rows: list[dict], key_fn) -> list[dict]: + groups: dict[str, list[dict]] = {} + for row in rows: + key = key_fn(row) or "未知" + groups.setdefault(key, []).append(row) + result = [] + for label, items in sorted(groups.items(), key=lambda x: x[0]): + result.append(_agg_metrics(label, items)) + return result + + +def _agg_metrics(label: str, items: list[dict]) -> dict: + nets = [_net_pnl(r) for r in items] + wins = [n for n in nets if n > 0] + losses = [n for n in nets if n < 0] + count = len(items) + win_cnt = len(wins) + loss_cnt = len(losses) + avg_profit = round(sum(wins) / len(wins), 2) if wins else 0.0 + avg_loss = round(sum(losses) / len(losses), 2) if losses else 0.0 + pl_ratio = round(avg_profit / abs(avg_loss), 2) if wins and losses and avg_loss != 0 else 0.0 + total_fee = round(sum(_fee(r) for r in items), 2) + total_net = round(sum(nets), 2) + max_loss = round(min(nets), 2) if nets else 0.0 + max_profit = round(max(nets), 2) if nets else 0.0 + win_rate = round(win_cnt / count * 100, 2) if count else 0.0 + return { + "label": label, + "count": count, + "wins": win_cnt, + "losses": loss_cnt, + "win_rate": win_rate, + "avg_profit": avg_profit, + "avg_loss": avg_loss, + "profit_loss_ratio": pl_ratio, + "total_fee": total_fee, + "total_net": total_net, + "max_loss": max_loss, + "max_profit": max_profit, + } + + +def _max_consecutive_losses(nets: list[float]) -> int: + streak = 0 + best = 0 + for n in nets: + if n < 0: + streak += 1 + best = max(best, streak) + else: + streak = 0 + return best + + +def _max_drawdown(nets: list[float], initial_capital: float) -> tuple[float, float]: + equity = initial_capital + peak = initial_capital + max_dd = 0.0 + max_dd_pct = 0.0 + for n in nets: + equity += n + if equity > peak: + peak = equity + dd = peak - equity + if dd > max_dd: + max_dd = dd + if peak > 0: + pct = dd / peak * 100 + if pct > max_dd_pct: + max_dd_pct = pct + return round(max_dd, 2), round(max_dd_pct, 2) + + +def fetch_trade_rows(conn) -> list[dict]: + rows = conn.execute( + "SELECT * FROM trade_logs ORDER BY close_time ASC, id ASC" + ).fetchall() + return [_row_dict(r) for r in rows] + + +def fetch_review_rows(conn) -> list[dict]: + rows = conn.execute( + "SELECT * FROM review_records ORDER BY close_time ASC, id ASC" + ).fetchall() + return [_row_dict(r) for r in rows] + + +def compute_summary(trades: list[dict], reviews: list[dict], live_capital: float) -> dict: + nets = [_net_pnl(t) for t in trades] + count = len(trades) + wins = [n for n in nets if n > 0] + losses = [n for n in nets if n < 0] + win_cnt = len(wins) + loss_cnt = len(losses) + avg_profit = round(sum(wins) / len(wins), 2) if wins else 0.0 + avg_loss = round(sum(losses) / len(losses), 2) if losses else 0.0 + pl_ratio = round(avg_profit / abs(avg_loss), 2) if wins and losses and avg_loss != 0 else 0.0 + total_fee = round(sum(_fee(t) for t in trades) + sum(_fee(r) for r in reviews), 2) + max_loss_amt = round(min(nets), 2) if nets else 0.0 + max_profit_amt = round(max(nets), 2) if nets else 0.0 + + margins_loss = [ + _margin_pct(_net_pnl(t), t.get("margin")) + for t in trades + if _net_pnl(t) < 0 and t.get("margin") + ] + margins_profit = [ + _margin_pct(_net_pnl(t), t.get("margin")) + for t in trades + if _net_pnl(t) > 0 and t.get("margin") + ] + max_loss_pct = round(min(margins_loss), 2) if margins_loss else 0.0 + max_profit_pct = round(max(margins_profit), 2) if margins_profit else 0.0 + + consec_loss = _max_consecutive_losses(nets) + max_dd, max_dd_pct = _max_drawdown(nets, live_capital) + + emotion_cnt = sum(1 for r in reviews if r.get("is_emotion")) + review_cnt = len(reviews) + denom = count if count else review_cnt + emotion_ratio = round(emotion_cnt / denom * 100, 2) if denom else 0.0 + + return { + "total_trades": count, + "win_rate": round(win_cnt / count * 100, 2) if count else 0.0, + "avg_profit": avg_profit, + "avg_loss": avg_loss, + "profit_loss_ratio": pl_ratio, + "consecutive_losses": consec_loss, + "max_drawdown": max_dd, + "max_drawdown_pct": max_dd_pct, + "max_loss_amount": max_loss_amt, + "max_loss_pct": max_loss_pct, + "max_profit_amount": max_profit_amt, + "max_profit_pct": max_profit_pct, + "total_fee": total_fee, + "emotion_count": emotion_cnt, + "emotion_ratio": emotion_ratio, + "review_count": review_cnt, + "win_count": win_cnt, + "loss_count": loss_cnt, + } + + +def compute_breakdowns(trades: list[dict], reviews: list[dict]) -> dict[str, dict]: + def day_key(row: dict) -> str: + dt = _parse_dt(row.get("close_time") or row.get("created_at") or "") + return dt.date().isoformat() if dt else "未知" + + def week_key(row: dict) -> str: + dt = _parse_dt(row.get("close_time") or row.get("created_at") or "") + if not dt: + return "未知" + iso = dt.isocalendar() + return f"{iso.year}-W{iso.week:02d}" + + def month_key(row: dict) -> str: + dt = _parse_dt(row.get("close_time") or row.get("created_at") or "") + return dt.strftime("%Y-%m") if dt else "未知" + + def symbol_key(row: dict) -> str: + return row.get("symbol_name") or row.get("symbol") or "未知" + + def direction_key(row: dict) -> str: + d = row.get("direction") or "" + return "做多" if d == "long" else ("做空" if d == "short" else d or "未知") + + def type_key(row: dict) -> str: + return row.get("monitor_type") or "未知" + + by_fee_rows = [] + fee_groups = {} + for t in trades: + key = symbol_key(t) + fee_groups.setdefault(key, []).append(t) + for label, items in sorted(fee_groups.items()): + row = _agg_metrics(label, items) + row["avg_fee"] = round(row["total_fee"] / row["count"], 2) if row["count"] else 0.0 + by_fee_rows.append(row) + + emotion_trades = [r for r in reviews if r.get("is_emotion")] + non_emotion = [r for r in reviews if not r.get("is_emotion")] + emotion_rows = [ + _agg_metrics("情绪单", emotion_trades), + _agg_metrics("非情绪单", non_emotion), + ] + + fee_columns = BREAKDOWN_COLUMNS + [{"key": "avg_fee", "label": "平均手续费"}] + + return { + "by_time": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, day_key)}, + "by_week": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, week_key)}, + "by_month": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, month_key)}, + "by_symbol": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, symbol_key)}, + "by_fee": {"columns": fee_columns, "rows": by_fee_rows}, + "by_direction": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, direction_key)}, + "by_trade_type": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, type_key)}, + "by_emotion": {"columns": BREAKDOWN_COLUMNS, "rows": emotion_rows}, + } + + +def build_all_stats(conn, live_capital: float = 0.0) -> dict: + trades = fetch_trade_rows(conn) + reviews = fetch_review_rows(conn) + summary = compute_summary(trades, reviews, live_capital) + breakdowns = compute_breakdowns(trades, reviews) + return { + "updated_at": datetime.now(TZ).isoformat(timespec="seconds"), + "summary": summary, + "views": STATS_VIEWS, + "breakdowns": breakdowns, + } + + +def save_stats_cache(conn, data: dict) -> None: + conn.execute( + """INSERT INTO stats_cache (key, data_json, updated_at) + VALUES ('all', ?, ?) + ON CONFLICT(key) DO UPDATE SET data_json=excluded.data_json, updated_at=excluded.updated_at""", + (json.dumps(data, ensure_ascii=False), data["updated_at"]), + ) + conn.commit() + + +def load_stats_cache(conn) -> Optional[dict]: + row = conn.execute( + "SELECT data_json FROM stats_cache WHERE key='all'" + ).fetchone() + if not row: + return None + try: + return json.loads(row["data_json"]) + except json.JSONDecodeError: + return None + + +def refresh_stats_cache(conn, live_capital: float = 0.0) -> dict: + data = build_all_stats(conn, live_capital) + save_stats_cache(conn, data) + return data diff --git a/strategy/__init__.py b/strategy/__init__.py index e69de29..fd5971f 100644 --- a/strategy/__init__.py +++ b/strategy/__init__.py @@ -0,0 +1,5 @@ +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + diff --git a/strategy/fib_lib.py b/strategy/fib_lib.py index 80f8085..2106a04 100644 --- a/strategy/fib_lib.py +++ b/strategy/fib_lib.py @@ -1,18 +1,23 @@ -"""斐波计算(自 crypto_monitor 复制,期货共用)。""" - -def calc_fib_plan(direction, upper, lower, ratio): - try: - h = float(upper) - l = float(lower) - r = float(ratio) - except (TypeError, ValueError): - return None - if h <= l or r <= 0 or r >= 1: - return None - span = h - l - direction = (direction or "long").strip().lower() - if direction == "short": - entry = l + r * span - return entry, h, l - entry = h - r * span - return entry, l, h +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""斐波计算(自 crypto_monitor 复制,期货共用)。""" + +def calc_fib_plan(direction, upper, lower, ratio): + try: + h = float(upper) + l = float(lower) + r = float(ratio) + except (TypeError, ValueError): + return None + if h <= l or r <= 0 or r >= 1: + return None + span = h - l + direction = (direction or "long").strip().lower() + if direction == "short": + entry = l + r * span + return entry, h, l + entry = h - r * span + return entry, l, h diff --git a/strategy/strategy_db.py b/strategy/strategy_db.py index 06cbd22..418a5f7 100644 --- a/strategy/strategy_db.py +++ b/strategy/strategy_db.py @@ -1,139 +1,144 @@ -"""策略相关表结构。""" -from __future__ import annotations - -ROLL_GROUPS_SQL = """ -CREATE TABLE IF NOT EXISTS roll_groups ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - order_monitor_id INTEGER, - symbol TEXT NOT NULL, - direction TEXT NOT NULL, - initial_take_profit REAL, - initial_stop_loss REAL, - current_stop_loss REAL, - risk_percent REAL DEFAULT 2, - leg_count INTEGER DEFAULT 0, - status TEXT DEFAULT 'active', - created_at TEXT, - updated_at TEXT -) -""" - -ROLL_LEGS_SQL = """ -CREATE TABLE IF NOT EXISTS roll_legs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - roll_group_id INTEGER NOT NULL, - leg_index INTEGER NOT NULL, - add_mode TEXT NOT NULL, - fill_price REAL, - lots INTEGER, - new_stop_loss REAL, - status TEXT DEFAULT 'filled', - created_at TEXT -) -""" - -TREND_PLANS_SQL = """ -CREATE TABLE IF NOT EXISTS trend_pullback_plans ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - status TEXT DEFAULT 'active', - symbol TEXT NOT NULL, - symbol_name TEXT, - direction TEXT NOT NULL DEFAULT 'long', - stop_loss REAL NOT NULL, - add_upper REAL NOT NULL, - take_profit REAL NOT NULL, - risk_percent REAL DEFAULT 5, - capital_snapshot REAL, - plan_margin REAL, - target_lots INTEGER, - first_lots INTEGER, - remainder_lots INTEGER, - dca_legs INTEGER DEFAULT 5, - leg_amounts_json TEXT, - grid_prices_json TEXT, - legs_done INTEGER DEFAULT 0, - first_order_done INTEGER DEFAULT 0, - avg_entry_price REAL, - lots_open INTEGER DEFAULT 0, - opened_at TEXT, - message TEXT -) -""" - -STRATEGY_SNAPSHOTS_SQL = """ -CREATE TABLE IF NOT EXISTS strategy_trade_snapshots ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - strategy_type TEXT NOT NULL, - source_id INTEGER, - symbol TEXT, - direction TEXT, - result_label TEXT, - opened_at TEXT, - closed_at TEXT, - pnl_amount REAL, - snapshot_json TEXT NOT NULL, - created_at TEXT -) -""" - -TRADE_ORDER_MONITORS_SQL = """ -CREATE TABLE IF NOT EXISTS trade_order_monitors ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - symbol TEXT NOT NULL, - symbol_name TEXT, - market_code TEXT, - direction TEXT NOT NULL, - lots INTEGER NOT NULL, - entry_price REAL, - stop_loss REAL, - take_profit REAL, - open_time TEXT, - monitor_type TEXT DEFAULT 'manual', - status TEXT DEFAULT 'active', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -) -""" - -CTP_SIM_ACCOUNT_SQL = """ -CREATE TABLE IF NOT EXISTS ctp_sim_account ( - id INTEGER PRIMARY KEY CHECK (id = 1), - balance REAL DEFAULT 100000, - available REAL DEFAULT 100000, - updated_at TEXT -) -""" - -CTP_SIM_POSITIONS_SQL = """ -CREATE TABLE IF NOT EXISTS ctp_sim_positions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - symbol TEXT NOT NULL, - direction TEXT NOT NULL, - lots INTEGER NOT NULL, - avg_price REAL NOT NULL, - updated_at TEXT, - UNIQUE(symbol, direction) -) -""" - - -_TABLES_READY = False - - -def init_strategy_tables(conn) -> None: - global _TABLES_READY - if _TABLES_READY: - return - for sql in ( - ROLL_GROUPS_SQL, - ROLL_LEGS_SQL, - TREND_PLANS_SQL, - STRATEGY_SNAPSHOTS_SQL, - TRADE_ORDER_MONITORS_SQL, - CTP_SIM_ACCOUNT_SQL, - CTP_SIM_POSITIONS_SQL, - ): - conn.execute(sql) - if not conn.execute("SELECT id FROM ctp_sim_account WHERE id=1").fetchone(): - conn.execute("INSERT INTO ctp_sim_account (id, balance, available) VALUES (1, 100000, 100000)") - conn.commit() - _TABLES_READY = True +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""策略相关表结构。""" +from __future__ import annotations + +ROLL_GROUPS_SQL = """ +CREATE TABLE IF NOT EXISTS roll_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_monitor_id INTEGER, + symbol TEXT NOT NULL, + direction TEXT NOT NULL, + initial_take_profit REAL, + initial_stop_loss REAL, + current_stop_loss REAL, + risk_percent REAL DEFAULT 2, + leg_count INTEGER DEFAULT 0, + status TEXT DEFAULT 'active', + created_at TEXT, + updated_at TEXT +) +""" + +ROLL_LEGS_SQL = """ +CREATE TABLE IF NOT EXISTS roll_legs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + roll_group_id INTEGER NOT NULL, + leg_index INTEGER NOT NULL, + add_mode TEXT NOT NULL, + fill_price REAL, + lots INTEGER, + new_stop_loss REAL, + status TEXT DEFAULT 'filled', + created_at TEXT +) +""" + +TREND_PLANS_SQL = """ +CREATE TABLE IF NOT EXISTS trend_pullback_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + status TEXT DEFAULT 'active', + symbol TEXT NOT NULL, + symbol_name TEXT, + direction TEXT NOT NULL DEFAULT 'long', + stop_loss REAL NOT NULL, + add_upper REAL NOT NULL, + take_profit REAL NOT NULL, + risk_percent REAL DEFAULT 5, + capital_snapshot REAL, + plan_margin REAL, + target_lots INTEGER, + first_lots INTEGER, + remainder_lots INTEGER, + dca_legs INTEGER DEFAULT 5, + leg_amounts_json TEXT, + grid_prices_json TEXT, + legs_done INTEGER DEFAULT 0, + first_order_done INTEGER DEFAULT 0, + avg_entry_price REAL, + lots_open INTEGER DEFAULT 0, + opened_at TEXT, + message TEXT +) +""" + +STRATEGY_SNAPSHOTS_SQL = """ +CREATE TABLE IF NOT EXISTS strategy_trade_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + strategy_type TEXT NOT NULL, + source_id INTEGER, + symbol TEXT, + direction TEXT, + result_label TEXT, + opened_at TEXT, + closed_at TEXT, + pnl_amount REAL, + snapshot_json TEXT NOT NULL, + created_at TEXT +) +""" + +TRADE_ORDER_MONITORS_SQL = """ +CREATE TABLE IF NOT EXISTS trade_order_monitors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL, + symbol_name TEXT, + market_code TEXT, + direction TEXT NOT NULL, + lots INTEGER NOT NULL, + entry_price REAL, + stop_loss REAL, + take_profit REAL, + open_time TEXT, + monitor_type TEXT DEFAULT 'manual', + status TEXT DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) +""" + +CTP_SIM_ACCOUNT_SQL = """ +CREATE TABLE IF NOT EXISTS ctp_sim_account ( + id INTEGER PRIMARY KEY CHECK (id = 1), + balance REAL DEFAULT 100000, + available REAL DEFAULT 100000, + updated_at TEXT +) +""" + +CTP_SIM_POSITIONS_SQL = """ +CREATE TABLE IF NOT EXISTS ctp_sim_positions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL, + direction TEXT NOT NULL, + lots INTEGER NOT NULL, + avg_price REAL NOT NULL, + updated_at TEXT, + UNIQUE(symbol, direction) +) +""" + + +_TABLES_READY = False + + +def init_strategy_tables(conn) -> None: + global _TABLES_READY + if _TABLES_READY: + return + for sql in ( + ROLL_GROUPS_SQL, + ROLL_LEGS_SQL, + TREND_PLANS_SQL, + STRATEGY_SNAPSHOTS_SQL, + TRADE_ORDER_MONITORS_SQL, + CTP_SIM_ACCOUNT_SQL, + CTP_SIM_POSITIONS_SQL, + ): + conn.execute(sql) + if not conn.execute("SELECT id FROM ctp_sim_account WHERE id=1").fetchone(): + conn.execute("INSERT INTO ctp_sim_account (id, balance, available) VALUES (1, 100000, 100000)") + conn.commit() + _TABLES_READY = True diff --git a/strategy/strategy_roll_lib.py b/strategy/strategy_roll_lib.py index d207033..243c76f 100644 --- a/strategy/strategy_roll_lib.py +++ b/strategy/strategy_roll_lib.py @@ -1,159 +1,164 @@ -"""顺势加仓(滚仓):纯计算,期货版(手数整数、乘数计入盈亏)。""" -from __future__ import annotations - -import math -from typing import Any, Optional, Tuple - -from strategy.fib_lib import calc_fib_plan - -ROLL_MAX_LEGS_LONG = 3 -ROLL_MAX_LEGS_SHORT = 3 -ROLL_STOP_OFFSET_PCT_DEFAULT = 1.0 -FIB_MODES = frozenset({"fib_618", "fib_786"}) - - -def fib_ratio_from_mode(mode: str) -> Optional[float]: - m = (mode or "").strip().lower() - if m in ("fib_618", "618", "0.618"): - return 0.618 - if m in ("fib_786", "786", "0.786"): - return 0.786 - return None - - -def fib_limit_entry(direction: str, upper: float, lower: float, mode: str) -> Tuple[Optional[float], Optional[str]]: - ratio = fib_ratio_from_mode(mode) - if ratio is None: - return None, "斐波档位无效" - h, l = float(upper), float(lower) - if h <= l: - return None, "上沿须大于下沿" - direction = (direction or "long").strip().lower() - plan = calc_fib_plan(direction, h, l, ratio) - if not plan: - return None, "无法计算斐波限价" - entry, _sl, _tp = plan - return float(entry), None - - -def max_roll_legs(direction: str) -> int: - return ROLL_MAX_LEGS_LONG if (direction or "long").strip().lower() == "long" else ROLL_MAX_LEGS_SHORT - - -def lots_precise(raw: float) -> int: - if raw is None or raw < 1: - return 0 - return max(1, int(math.floor(float(raw)))) - - -def unified_stop_from_avg(direction: str, avg: float, offset_pct: float) -> float: - avg_f = float(avg) - pct = float(offset_pct) / 100.0 - direction = (direction or "long").strip().lower() - if direction == "short": - return avg_f * (1.0 + pct) - return avg_f * (1.0 - pct) - - -def avg_entry_after_add(qty_existing: float, entry_existing: float, add_qty: float, add_price: float) -> float: - q1, e1, q2, e2 = float(qty_existing), float(entry_existing), float(add_qty), float(add_price) - total = q1 + q2 - return (q1 * e1 + q2 * e2) / total if total > 0 else 0.0 - - -def solve_add_lots_for_total_risk( - direction: str, - qty_existing: float, - entry_existing: float, - add_price: float, - new_stop: float, - risk_budget: float, - mult: int, -) -> Tuple[Optional[int], Optional[str]]: - q1, e1, e2, sl, b = float(qty_existing), float(entry_existing), float(add_price), float(new_stop), float(risk_budget) - m = float(mult) - direction = (direction or "long").strip().lower() - if direction == "short": - denom = (sl - e2) * m - numer = b - q1 * (sl - e1) * m - else: - denom = (e2 - sl) * m - numer = b - q1 * (e1 - sl) * m - if denom <= 0: - return None, "止损与加仓价关系无效" - q2 = numer / denom - lots = lots_precise(q2) - if lots < 1: - return None, "按总风险%无需再加仓或无法再加" - return lots, None - - -def preview_roll( - *, - direction: str, - symbol: str, - qty_existing: float, - entry_existing: float, - initial_take_profit: float, - add_mode: str, - new_stop_loss: float, - risk_percent: float, - capital_base: float, - mult: int, - add_price: Optional[float] = None, - fib_upper: Optional[float] = None, - fib_lower: Optional[float] = None, - legs_done: int = 0, -) -> Tuple[Optional[dict[str, Any]], Optional[str]]: - direction = (direction or "long").strip().lower() - if legs_done >= max_roll_legs(direction): - return None, f"滚仓已达 {max_roll_legs(direction)} 次上限" - mode = (add_mode or "market").strip().lower() - if mode == "market": - if not add_price or add_price <= 0: - return None, "需要有效参考价" - entry_add = float(add_price) - mode_label = "市价" - elif mode in FIB_MODES: - if fib_upper is None or fib_lower is None: - return None, "斐波须填上沿/下沿" - entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode) - if err: - return None, err - mode_label = "斐波0.618" if "618" in mode else "斐波0.786" - else: - return None, "加仓方式无效" - sl = float(new_stop_loss) - tp = float(initial_take_profit) - if sl <= 0 or tp <= 0: - return None, "止损/止盈无效" - risk_budget = float(capital_base) * float(risk_percent) / 100.0 - q2, err = solve_add_lots_for_total_risk( - direction, qty_existing, entry_existing, entry_add, sl, risk_budget, mult - ) - if err: - return None, err - new_qty = qty_existing + q2 - new_avg = avg_entry_after_add(qty_existing, entry_existing, q2, entry_add) - m = float(mult) - if direction == "long": - loss_at_sl = (new_avg - sl) * new_qty * m - reward_at_tp = (tp - new_avg) * new_qty * m - else: - loss_at_sl = (sl - new_avg) * new_qty * m - reward_at_tp = (new_avg - tp) * new_qty * m - return { - "symbol": symbol, - "direction": direction, - "add_mode_label": mode_label, - "add_price": round(entry_add, 4), - "new_stop_loss": round(sl, 4), - "initial_take_profit": tp, - "risk_percent": float(risk_percent), - "add_lots": q2, - "qty_after": int(new_qty), - "avg_entry_after": round(new_avg, 4), - "loss_at_sl": round(loss_at_sl, 2), - "reward_at_tp": round(reward_at_tp, 2), - "legs_done": legs_done, - }, None +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""顺势加仓(滚仓):纯计算,期货版(手数整数、乘数计入盈亏)。""" +from __future__ import annotations + +import math +from typing import Any, Optional, Tuple + +from strategy.fib_lib import calc_fib_plan + +ROLL_MAX_LEGS_LONG = 3 +ROLL_MAX_LEGS_SHORT = 3 +ROLL_STOP_OFFSET_PCT_DEFAULT = 1.0 +FIB_MODES = frozenset({"fib_618", "fib_786"}) + + +def fib_ratio_from_mode(mode: str) -> Optional[float]: + m = (mode or "").strip().lower() + if m in ("fib_618", "618", "0.618"): + return 0.618 + if m in ("fib_786", "786", "0.786"): + return 0.786 + return None + + +def fib_limit_entry(direction: str, upper: float, lower: float, mode: str) -> Tuple[Optional[float], Optional[str]]: + ratio = fib_ratio_from_mode(mode) + if ratio is None: + return None, "斐波档位无效" + h, l = float(upper), float(lower) + if h <= l: + return None, "上沿须大于下沿" + direction = (direction or "long").strip().lower() + plan = calc_fib_plan(direction, h, l, ratio) + if not plan: + return None, "无法计算斐波限价" + entry, _sl, _tp = plan + return float(entry), None + + +def max_roll_legs(direction: str) -> int: + return ROLL_MAX_LEGS_LONG if (direction or "long").strip().lower() == "long" else ROLL_MAX_LEGS_SHORT + + +def lots_precise(raw: float) -> int: + if raw is None or raw < 1: + return 0 + return max(1, int(math.floor(float(raw)))) + + +def unified_stop_from_avg(direction: str, avg: float, offset_pct: float) -> float: + avg_f = float(avg) + pct = float(offset_pct) / 100.0 + direction = (direction or "long").strip().lower() + if direction == "short": + return avg_f * (1.0 + pct) + return avg_f * (1.0 - pct) + + +def avg_entry_after_add(qty_existing: float, entry_existing: float, add_qty: float, add_price: float) -> float: + q1, e1, q2, e2 = float(qty_existing), float(entry_existing), float(add_qty), float(add_price) + total = q1 + q2 + return (q1 * e1 + q2 * e2) / total if total > 0 else 0.0 + + +def solve_add_lots_for_total_risk( + direction: str, + qty_existing: float, + entry_existing: float, + add_price: float, + new_stop: float, + risk_budget: float, + mult: int, +) -> Tuple[Optional[int], Optional[str]]: + q1, e1, e2, sl, b = float(qty_existing), float(entry_existing), float(add_price), float(new_stop), float(risk_budget) + m = float(mult) + direction = (direction or "long").strip().lower() + if direction == "short": + denom = (sl - e2) * m + numer = b - q1 * (sl - e1) * m + else: + denom = (e2 - sl) * m + numer = b - q1 * (e1 - sl) * m + if denom <= 0: + return None, "止损与加仓价关系无效" + q2 = numer / denom + lots = lots_precise(q2) + if lots < 1: + return None, "按总风险%无需再加仓或无法再加" + return lots, None + + +def preview_roll( + *, + direction: str, + symbol: str, + qty_existing: float, + entry_existing: float, + initial_take_profit: float, + add_mode: str, + new_stop_loss: float, + risk_percent: float, + capital_base: float, + mult: int, + add_price: Optional[float] = None, + fib_upper: Optional[float] = None, + fib_lower: Optional[float] = None, + legs_done: int = 0, +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + direction = (direction or "long").strip().lower() + if legs_done >= max_roll_legs(direction): + return None, f"滚仓已达 {max_roll_legs(direction)} 次上限" + mode = (add_mode or "market").strip().lower() + if mode == "market": + if not add_price or add_price <= 0: + return None, "需要有效参考价" + entry_add = float(add_price) + mode_label = "市价" + elif mode in FIB_MODES: + if fib_upper is None or fib_lower is None: + return None, "斐波须填上沿/下沿" + entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode) + if err: + return None, err + mode_label = "斐波0.618" if "618" in mode else "斐波0.786" + else: + return None, "加仓方式无效" + sl = float(new_stop_loss) + tp = float(initial_take_profit) + if sl <= 0 or tp <= 0: + return None, "止损/止盈无效" + risk_budget = float(capital_base) * float(risk_percent) / 100.0 + q2, err = solve_add_lots_for_total_risk( + direction, qty_existing, entry_existing, entry_add, sl, risk_budget, mult + ) + if err: + return None, err + new_qty = qty_existing + q2 + new_avg = avg_entry_after_add(qty_existing, entry_existing, q2, entry_add) + m = float(mult) + if direction == "long": + loss_at_sl = (new_avg - sl) * new_qty * m + reward_at_tp = (tp - new_avg) * new_qty * m + else: + loss_at_sl = (sl - new_avg) * new_qty * m + reward_at_tp = (new_avg - tp) * new_qty * m + return { + "symbol": symbol, + "direction": direction, + "add_mode_label": mode_label, + "add_price": round(entry_add, 4), + "new_stop_loss": round(sl, 4), + "initial_take_profit": tp, + "risk_percent": float(risk_percent), + "add_lots": q2, + "qty_after": int(new_qty), + "avg_entry_after": round(new_avg, 4), + "loss_at_sl": round(loss_at_sl, 2), + "reward_at_tp": round(reward_at_tp, 2), + "legs_done": legs_done, + }, None diff --git a/strategy/strategy_snapshot_lib.py b/strategy/strategy_snapshot_lib.py index d8d3093..466a990 100644 --- a/strategy/strategy_snapshot_lib.py +++ b/strategy/strategy_snapshot_lib.py @@ -1,70 +1,75 @@ -"""策略结束快照。""" -from __future__ import annotations - -import json -from datetime import datetime -from typing import Any - -STRATEGY_TREND = "trend_pullback" -STRATEGY_ROLL = "roll" -MAX_ROWS = 100 - - -def save_snapshot( - conn, - *, - strategy_type: str, - source_id: int, - symbol: str, - direction: str, - result_label: str, - payload: dict, - pnl: float | None = None, - opened_at: str = "", -) -> None: - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - conn.execute( - """INSERT INTO strategy_trade_snapshots ( - strategy_type, source_id, symbol, direction, result_label, - opened_at, closed_at, pnl_amount, snapshot_json, created_at - ) VALUES (?,?,?,?,?,?,?,?,?,?)""", - ( - strategy_type, - source_id, - symbol, - direction, - result_label, - opened_at, - now, - pnl, - json.dumps(payload, ensure_ascii=False), - now, - ), - ) - conn.execute( - """DELETE FROM strategy_trade_snapshots WHERE id NOT IN ( - SELECT id FROM strategy_trade_snapshots ORDER BY id DESC LIMIT ? - )""", - (MAX_ROWS,), - ) - - -def list_snapshots(conn, limit: int = 100) -> tuple[list[dict], list[dict]]: - rows = conn.execute( - "SELECT * FROM strategy_trade_snapshots ORDER BY id DESC LIMIT ?", - (max(1, min(limit, 200)),), - ).fetchall() - trend, roll = [], [] - for r in rows: - d = dict(r) - try: - d["snapshot"] = json.loads(d.get("snapshot_json") or "{}") - except Exception: - d["snapshot"] = {} - st = d.get("strategy_type") - d["strategy_label"] = "趋势回调" if st == STRATEGY_TREND else "顺势加仓" - if st == STRATEGY_TREND: - trend.append(d) - else: - roll.append(d) - return trend, roll +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""策略结束快照。""" +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any + +STRATEGY_TREND = "trend_pullback" +STRATEGY_ROLL = "roll" +MAX_ROWS = 100 + + +def save_snapshot( + conn, + *, + strategy_type: str, + source_id: int, + symbol: str, + direction: str, + result_label: str, + payload: dict, + pnl: float | None = None, + opened_at: str = "", +) -> None: + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + conn.execute( + """INSERT INTO strategy_trade_snapshots ( + strategy_type, source_id, symbol, direction, result_label, + opened_at, closed_at, pnl_amount, snapshot_json, created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?)""", + ( + strategy_type, + source_id, + symbol, + direction, + result_label, + opened_at, + now, + pnl, + json.dumps(payload, ensure_ascii=False), + now, + ), + ) + conn.execute( + """DELETE FROM strategy_trade_snapshots WHERE id NOT IN ( + SELECT id FROM strategy_trade_snapshots ORDER BY id DESC LIMIT ? + )""", + (MAX_ROWS,), + ) + + +def list_snapshots(conn, limit: int = 100) -> tuple[list[dict], list[dict]]: + rows = conn.execute( + "SELECT * FROM strategy_trade_snapshots ORDER BY id DESC LIMIT ?", + (max(1, min(limit, 200)),), + ).fetchall() + trend, roll = [], [] + for r in rows: + d = dict(r) + try: + d["snapshot"] = json.loads(d.get("snapshot_json") or "{}") + except Exception: + d["snapshot"] = {} + st = d.get("strategy_type") + d["strategy_label"] = "趋势回调" if st == STRATEGY_TREND else "顺势加仓" + if st == STRATEGY_TREND: + trend.append(d) + else: + roll.append(d) + return trend, roll diff --git a/strategy/strategy_trend_lib.py b/strategy/strategy_trend_lib.py index 060774c..50aa3ad 100644 --- a/strategy/strategy_trend_lib.py +++ b/strategy/strategy_trend_lib.py @@ -1,108 +1,113 @@ -"""趋势回调:纯计算(期货整数手)。""" -from __future__ import annotations - -import json -import math -from typing import Any, Optional, Tuple - -from contract_specs import get_contract_spec - - -def validate_trend_bounds(direction: str, stop_loss: float, add_upper: float) -> Optional[str]: - direction = (direction or "long").strip().lower() - if direction == "long": - if not (float(stop_loss) < float(add_upper)): - return "做多:止损须低于补仓上沿" - else: - if not (float(stop_loss) > float(add_upper)): - return "做空:止损须高于补仓下沿" - return None - - -def build_grid_prices(direction: str, sl: float, upper: float, n_legs: int) -> list[float]: - sl, upper = float(sl), float(upper) - out: list[float] = [] - if n_legs <= 0: - return out - direction = (direction or "long").strip().lower() - if direction == "long": - if upper <= sl: - return out - span = upper - sl - for i in range(1, n_legs + 1): - out.append(sl + (i / float(n_legs + 1)) * span) - out.sort(reverse=True) - else: - if sl <= upper: - return out - span = sl - upper - for i in range(1, n_legs + 1): - out.append(upper + (i / float(n_legs + 1)) * span) - out.sort() - return [round(p, 4) for p in out] - - -def compute_trend_plan_futures( - *, - direction: str, - stop_loss: float, - add_upper: float, - take_profit: float, - risk_percent: float, - capital: float, - live_price: float, - ths_code: str, - dca_legs: int = 5, -) -> Tuple[Optional[dict[str, Any]], Optional[str]]: - err = validate_trend_bounds(direction, stop_loss, add_upper) - if err: - return None, err - spec = get_contract_spec(ths_code) - mult = spec["mult"] - d = (direction or "long").strip().lower() - if d == "short": - worst_per_lot = (float(stop_loss) - float(add_upper)) * mult - else: - worst_per_lot = (float(add_upper) - float(stop_loss)) * mult - if worst_per_lot <= 0: - return None, "止损与补仓边界无法计算风险" - budget = float(capital) * float(risk_percent) / 100.0 - total_lots = int(math.floor(budget / worst_per_lot)) - if total_lots < 3: - return None, f"按 {risk_percent}% 风险,总手数至少需 3 手才能拆分首仓+补仓(当前 {total_lots} 手)" - first_lots = total_lots // 2 - remainder = total_lots - first_lots - legs = max(1, min(int(dca_legs), remainder)) - per_leg = remainder // legs - leg_amounts = [per_leg] * (legs - 1) + [remainder - per_leg * (legs - 1)] - if any(x < 1 for x in leg_amounts): - legs = 1 - leg_amounts = [remainder] - grid = build_grid_prices(d, stop_loss, add_upper, len(leg_amounts)) - margin_rate = spec["margin_rate"] - plan_margin = float(live_price) * mult * total_lots * margin_rate - return { - "direction": d, - "stop_loss": float(stop_loss), - "add_upper": float(add_upper), - "take_profit": float(take_profit), - "risk_percent": float(risk_percent), - "capital_snapshot": float(capital), - "live_price_ref": float(live_price), - "target_lots": total_lots, - "first_lots": first_lots, - "remainder_lots": remainder, - "dca_legs": len(leg_amounts), - "leg_amounts": leg_amounts, - "leg_amounts_json": json.dumps(leg_amounts), - "grid_prices_json": json.dumps(grid), - "grid": grid, - "plan_margin": round(plan_margin, 2), - "mult": mult, - }, None - - -def trend_dca_level_reached(direction: str, mark_price: float, level: float) -> bool: - d = (direction or "long").strip().lower() - pf, lv = float(mark_price), float(level) - return pf <= lv if d == "long" else pf >= lv +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""趋势回调:纯计算(期货整数手)。""" +from __future__ import annotations + +import json +import math +from typing import Any, Optional, Tuple + +from contract_specs import get_contract_spec + + +def validate_trend_bounds(direction: str, stop_loss: float, add_upper: float) -> Optional[str]: + direction = (direction or "long").strip().lower() + if direction == "long": + if not (float(stop_loss) < float(add_upper)): + return "做多:止损须低于补仓上沿" + else: + if not (float(stop_loss) > float(add_upper)): + return "做空:止损须高于补仓下沿" + return None + + +def build_grid_prices(direction: str, sl: float, upper: float, n_legs: int) -> list[float]: + sl, upper = float(sl), float(upper) + out: list[float] = [] + if n_legs <= 0: + return out + direction = (direction or "long").strip().lower() + if direction == "long": + if upper <= sl: + return out + span = upper - sl + for i in range(1, n_legs + 1): + out.append(sl + (i / float(n_legs + 1)) * span) + out.sort(reverse=True) + else: + if sl <= upper: + return out + span = sl - upper + for i in range(1, n_legs + 1): + out.append(upper + (i / float(n_legs + 1)) * span) + out.sort() + return [round(p, 4) for p in out] + + +def compute_trend_plan_futures( + *, + direction: str, + stop_loss: float, + add_upper: float, + take_profit: float, + risk_percent: float, + capital: float, + live_price: float, + ths_code: str, + dca_legs: int = 5, +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + err = validate_trend_bounds(direction, stop_loss, add_upper) + if err: + return None, err + spec = get_contract_spec(ths_code) + mult = spec["mult"] + d = (direction or "long").strip().lower() + if d == "short": + worst_per_lot = (float(stop_loss) - float(add_upper)) * mult + else: + worst_per_lot = (float(add_upper) - float(stop_loss)) * mult + if worst_per_lot <= 0: + return None, "止损与补仓边界无法计算风险" + budget = float(capital) * float(risk_percent) / 100.0 + total_lots = int(math.floor(budget / worst_per_lot)) + if total_lots < 3: + return None, f"按 {risk_percent}% 风险,总手数至少需 3 手才能拆分首仓+补仓(当前 {total_lots} 手)" + first_lots = total_lots // 2 + remainder = total_lots - first_lots + legs = max(1, min(int(dca_legs), remainder)) + per_leg = remainder // legs + leg_amounts = [per_leg] * (legs - 1) + [remainder - per_leg * (legs - 1)] + if any(x < 1 for x in leg_amounts): + legs = 1 + leg_amounts = [remainder] + grid = build_grid_prices(d, stop_loss, add_upper, len(leg_amounts)) + margin_rate = spec["margin_rate"] + plan_margin = float(live_price) * mult * total_lots * margin_rate + return { + "direction": d, + "stop_loss": float(stop_loss), + "add_upper": float(add_upper), + "take_profit": float(take_profit), + "risk_percent": float(risk_percent), + "capital_snapshot": float(capital), + "live_price_ref": float(live_price), + "target_lots": total_lots, + "first_lots": first_lots, + "remainder_lots": remainder, + "dca_legs": len(leg_amounts), + "leg_amounts": leg_amounts, + "leg_amounts_json": json.dumps(leg_amounts), + "grid_prices_json": json.dumps(grid), + "grid": grid, + "plan_margin": round(plan_margin, 2), + "mult": mult, + }, None + + +def trend_dca_level_reached(direction: str, mark_price: float, level: float) -> bool: + d = (direction or "long").strip().lower() + pf, lv = float(mark_price), float(level) + return pf <= lv if d == "long" else pf >= lv diff --git a/symbols.py b/symbols.py index 154812e..a7450fc 100644 --- a/symbols.py +++ b/symbols.py @@ -1,556 +1,561 @@ -""" -期货品种与同花顺代码映射。 -展示同花顺合约代码(ag2608);行情默认新浪,机构用户可通过环境变量启用同花顺 iFinD。 -""" -import re -import threading -import time -from collections import defaultdict -from concurrent.futures import ThreadPoolExecutor, as_completed -from datetime import date -from typing import Optional - -from market import fetch_raw_for_volume, get_price as market_get_price, THS_EX_SUFFIX - -PRODUCTS = [ - {"name": "白银", "ths": "ag", "sina": "AG", "exchange": "上期所", "ex": "SHFE"}, - {"name": "黄金", "ths": "au", "sina": "AU", "exchange": "上期所", "ex": "SHFE"}, - {"name": "铜", "ths": "cu", "sina": "CU", "exchange": "上期所", "ex": "SHFE"}, - {"name": "铝", "ths": "al", "sina": "AL", "exchange": "上期所", "ex": "SHFE"}, - {"name": "锌", "ths": "zn", "sina": "ZN", "exchange": "上期所", "ex": "SHFE"}, - {"name": "铅", "ths": "pb", "sina": "PB", "exchange": "上期所", "ex": "SHFE"}, - {"name": "镍", "ths": "ni", "sina": "NI", "exchange": "上期所", "ex": "SHFE"}, - {"name": "锡", "ths": "sn", "sina": "SN", "exchange": "上期所", "ex": "SHFE"}, - {"name": "螺纹钢", "ths": "rb", "sina": "RB", "exchange": "上期所", "ex": "SHFE"}, - {"name": "热卷", "ths": "hc", "sina": "HC", "exchange": "上期所", "ex": "SHFE"}, - {"name": "不锈钢", "ths": "ss", "sina": "SS", "exchange": "上期所", "ex": "SHFE"}, - {"name": "原油", "ths": "sc", "sina": "SC", "exchange": "上期能源", "ex": "INE"}, - {"name": "燃油", "ths": "fu", "sina": "FU", "exchange": "上期所", "ex": "SHFE"}, - {"name": "沥青", "ths": "bu", "sina": "BU", "exchange": "上期所", "ex": "SHFE"}, - {"name": "橡胶", "ths": "ru", "sina": "RU", "exchange": "上期所", "ex": "SHFE"}, - {"name": "纸浆", "ths": "sp", "sina": "SP", "exchange": "上期所", "ex": "SHFE"}, - {"name": "铁矿石", "ths": "i", "sina": "I", "exchange": "大商所", "ex": "DCE"}, - {"name": "焦炭", "ths": "j", "sina": "J", "exchange": "大商所", "ex": "DCE"}, - {"name": "焦煤", "ths": "jm", "sina": "JM", "exchange": "大商所", "ex": "DCE"}, - {"name": "豆粕", "ths": "m", "sina": "M", "exchange": "大商所", "ex": "DCE"}, - {"name": "豆油", "ths": "y", "sina": "Y", "exchange": "大商所", "ex": "DCE"}, - {"name": "棕榈油", "ths": "p", "sina": "P", "exchange": "大商所", "ex": "DCE"}, - {"name": "玉米", "ths": "c", "sina": "C", "exchange": "大商所", "ex": "DCE"}, - {"name": "淀粉", "ths": "cs", "sina": "CS", "exchange": "大商所", "ex": "DCE"}, - {"name": "鸡蛋", "ths": "jd", "sina": "JD", "exchange": "大商所", "ex": "DCE"}, - {"name": "生猪", "ths": "lh", "sina": "LH", "exchange": "大商所", "ex": "DCE"}, - {"name": "聚乙烯", "ths": "l", "sina": "L", "exchange": "大商所", "ex": "DCE"}, - {"name": "聚丙烯", "ths": "pp", "sina": "PP", "exchange": "大商所", "ex": "DCE"}, - {"name": "PVC", "ths": "v", "sina": "V", "exchange": "大商所", "ex": "DCE"}, - {"name": "乙二醇", "ths": "eg", "sina": "EG", "exchange": "大商所", "ex": "DCE"}, - {"name": "苯乙烯", "ths": "eb", "sina": "EB", "exchange": "大商所", "ex": "DCE"}, - {"name": "液化气", "ths": "pg", "sina": "PG", "exchange": "大商所", "ex": "DCE"}, - {"name": "菜粕", "ths": "RM", "sina": "RM", "exchange": "郑商所", "ex": "CZCE"}, - {"name": "菜油", "ths": "OI", "sina": "OI", "exchange": "郑商所", "ex": "CZCE"}, - {"name": "白糖", "ths": "SR", "sina": "SR", "exchange": "郑商所", "ex": "CZCE"}, - {"name": "棉花", "ths": "CF", "sina": "CF", "exchange": "郑商所", "ex": "CZCE"}, - {"name": "甲醇", "ths": "MA", "sina": "MA", "exchange": "郑商所", "ex": "CZCE"}, - {"name": "PTA", "ths": "TA", "sina": "TA", "exchange": "郑商所", "ex": "CZCE"}, - {"name": "玻璃", "ths": "FG", "sina": "FG", "exchange": "郑商所", "ex": "CZCE"}, - {"name": "纯碱", "ths": "SA", "sina": "SA", "exchange": "郑商所", "ex": "CZCE"}, - {"name": "尿素", "ths": "UR", "sina": "UR", "exchange": "郑商所", "ex": "CZCE"}, - {"name": "硅铁", "ths": "SF", "sina": "SF", "exchange": "郑商所", "ex": "CZCE"}, - {"name": "锰硅", "ths": "SM", "sina": "SM", "exchange": "郑商所", "ex": "CZCE"}, - {"name": "苹果", "ths": "AP", "sina": "AP", "exchange": "郑商所", "ex": "CZCE"}, - {"name": "红枣", "ths": "CJ", "sina": "CJ", "exchange": "郑商所", "ex": "CZCE"}, - {"name": "花生", "ths": "PK", "sina": "PK", "exchange": "郑商所", "ex": "CZCE"}, - {"name": "沪深300", "ths": "IF", "sina": "IF", "exchange": "中金所", "ex": "CFFEX"}, - {"name": "上证50", "ths": "IH", "sina": "IH", "exchange": "中金所", "ex": "CFFEX"}, - {"name": "中证500", "ths": "IC", "sina": "IC", "exchange": "中金所", "ex": "CFFEX"}, - {"name": "中证1000", "ths": "IM", "sina": "IM", "exchange": "中金所", "ex": "CFFEX"}, -] - -PRODUCT_CATEGORY_MAP = { - "ag": "贵金属", "au": "贵金属", - "cu": "有色金属", "al": "有色金属", "zn": "有色金属", "pb": "有色金属", "ni": "有色金属", "sn": "有色金属", - "rb": "黑色金属", "hc": "黑色金属", "ss": "黑色金属", "i": "黑色金属", "j": "黑色金属", "jm": "黑色金属", - "SF": "黑色金属", "SM": "黑色金属", - "sc": "能源化工", "fu": "能源化工", "bu": "能源化工", "ru": "能源化工", "sp": "能源化工", - "l": "能源化工", "pp": "能源化工", "v": "能源化工", "eg": "能源化工", "eb": "能源化工", "pg": "能源化工", - "MA": "能源化工", "TA": "能源化工", "SA": "能源化工", "UR": "能源化工", "FG": "能源化工", - "m": "农产品", "y": "农产品", "p": "农产品", "c": "农产品", "cs": "农产品", "jd": "农产品", "lh": "农产品", - "RM": "农产品", "OI": "农产品", "SR": "农产品", "CF": "农产品", "AP": "农产品", "CJ": "农产品", "PK": "农产品", - "IF": "金融期货", "IH": "金融期货", "IC": "金融期货", "IM": "金融期货", -} -PRODUCT_CATEGORIES = ["贵金属", "有色金属", "黑色金属", "能源化工", "农产品", "金融期货"] - -for _p in PRODUCTS: - _p["category"] = PRODUCT_CATEGORY_MAP.get(_p["ths"], "其他") - - -def product_category(ths: str) -> str: - return PRODUCT_CATEGORY_MAP.get((ths or "").strip(), "其他") - - -EXCHANGE_ORDER = ["上期所", "上期能源", "大商所", "郑商所", "中金所"] -_MAIN_CACHE: dict[str, tuple[float, dict]] = {} -_CACHE_TTL = 300 -_main_index_lock = threading.Lock() -_main_index: dict[str, dict] = {} -_main_index_ts = 0.0 -_index_refresh_lock = threading.Lock() - - -def build_ths_code(product: dict, year: int, month: int) -> str: - """同花顺软件内显示的合约代码。""" - ex = product["ex"] - letters = product["ths"] - if ex == "CZCE": - return f"{letters}{year % 10}{month:02d}" - return f"{letters}{year % 100:02d}{month:02d}" - - -def build_ths_full_code(product: dict, year: int, month: int) -> str: - """同花顺 iFinD HTTP API 代码,如 ag2608.SHFE""" - ths = build_ths_code(product, year, month) - suffix = THS_EX_SUFFIX.get(product["ex"], product["ex"]) - return f"{ths}.{suffix}" - - -def build_sina_code(product: dict, year: int, month: int) -> str: - letters = product["sina"] - suffix = f"{year % 100:02d}{month:02d}" - if product["ex"] == "CFFEX": - return f"CFF_RE_{letters}{suffix}" - return f"nf_{letters}{suffix}" - - -def build_sina_main_code(product: dict) -> str: - letters = product["sina"] - if product["ex"] == "CFFEX": - return f"CFF_RE_{letters}0" - return f"nf_{letters}0" - - -def _find_product_by_letters(letters: str) -> Optional[dict]: - letters_up = letters.upper() - for p in PRODUCTS: - if p["ths"].upper() == letters_up or p["sina"] == letters_up: - return p - return None - - -def ths_to_codes(ths_code: str) -> Optional[dict]: - """同花顺合约代码 -> ths_full + sina 回退代码。""" - code = ths_code.strip() - if not code: - return None - - m4 = re.match(r"^([A-Za-z]+)(\d{4})$", code) - if m4: - letters, digits = m4.group(1), m4.group(2) - year = 2000 + int(digits[:2]) - month = int(digits[2:]) - if not 1 <= month <= 12: - return None - product = _find_product_by_letters(letters) - if product: - return { - "ths_code": build_ths_code(product, year, month), - "market_code": build_ths_full_code(product, year, month), - "sina_code": build_sina_code(product, year, month), - } - letters_up = letters.upper() - if letters_up in ("IF", "IH", "IC", "IM", "T", "TF", "TS"): - ths = f"{letters_up}{digits}" - return { - "ths_code": ths, - "market_code": f"{ths}.CFFEX", - "sina_code": f"CFF_RE_{letters_up}{digits}", - } - - m3 = re.match(r"^([A-Za-z]+)(\d{3})$", code) - if m3: - letters, digits = m3.group(1), m3.group(2) - y_digit = int(digits[0]) - month = int(digits[1:]) - if not 1 <= month <= 12: - return None - year = date.today().year - decade = year // 10 * 10 - candidate = decade + y_digit - if candidate < year - 1: - candidate += 10 - product = _find_product_by_letters(letters) - if product: - return { - "ths_code": build_ths_code(product, candidate, month), - "market_code": build_ths_full_code(product, candidate, month), - "sina_code": build_sina_code(product, candidate, month), - } - - return None - - -def ths_to_sina_code(ths_code: str) -> Optional[str]: - codes = ths_to_codes(ths_code) - return codes["sina_code"] if codes else None - - -def parse_contract_year_month(ths_code: str) -> Optional[tuple[int, int]]: - """从同花顺合约代码解析交割年月。""" - code = (ths_code or "").strip() - if not code or "888" in code: - return None - m4 = re.match(r"^([A-Za-z]+)(\d{4})$", code) - if m4: - digits = m4.group(2) - year = 2000 + int(digits[:2]) - month = int(digits[2:]) - if 1 <= month <= 12: - return year, month - m3 = re.match(r"^([A-Za-z]+)(\d{3})$", code) - if m3: - letters, digits = m3.group(1), m3.group(2) - month = int(digits[1:]) - if not 1 <= month <= 12: - return None - y_digit = int(digits[0]) - year = date.today().year - decade = year // 10 * 10 - candidate = decade + y_digit - if candidate < year - 1: - candidate += 10 - product = _find_product_by_letters(letters) - if product: - return candidate, month - return None - - -def is_near_expiry_main(ths_code: str) -> bool: - """主力合约交割月为当月或下月时视为临期。""" - ym = parse_contract_year_month(ths_code) - if not ym: - return False - cy, cm = ym - today = date.today() - months_ahead = (cy - today.year) * 12 + (cm - today.month) - return months_ahead <= 1 - - -def _main_contract_score(raw: dict) -> float: - """主力判定:优先持仓量,其次成交量。""" - oi = float(raw.get("open_interest") or 0) - vol = float(raw.get("volume") or 0) - return oi if oi > 0 else vol - - -def _make_symbol_item( - product: dict, - year: int, - month: int, - volume: float, - open_interest: float = 0, -) -> dict: - ths = build_ths_code(product, year, month) - name = product["name"] - return { - "name": name, - "ths_code": ths, - "market_code": build_ths_full_code(product, year, month), - "sina_code": build_sina_code(product, year, month), - "exchange": product["exchange"], - "contract": f"主力 {ths}", - "display": f"{name} 主力 {ths}", - "input_label": f"{name} {ths}", - "volume": volume, - "open_interest": open_interest, - } - - -def resolve_main_contract(product: dict) -> Optional[dict]: - cache_key = product["sina"] - now = time.time() - cached = _MAIN_CACHE.get(cache_key) - if cached and now - cached[0] < _CACHE_TTL: - return cached[1] - - today = date.today() - y, m = today.year, today.month - best = None - best_score = 0.0 - - for i in range(14): - cy, cm = y, m + i - while cm > 12: - cm -= 12 - cy += 1 - sina = build_sina_code(product, cy, cm) - raw = fetch_raw_for_volume(sina) - if not raw: - continue - score = _main_contract_score(raw) - if score <= 0: - continue - item = _make_symbol_item( - product, cy, cm, raw["volume"], raw.get("open_interest", 0), - ) - if score > best_score: - best_score = score - best = item - - if best is None: - sina_main = build_sina_main_code(product) - raw = fetch_raw_for_volume(sina_main) - if raw: - ths_letters = product["ths"] - ths_main = ( - f"{ths_letters}888" - if product["ex"] != "CFFEX" - else f"{ths_letters.upper()}888" - ) - suffix = THS_EX_SUFFIX.get(product["ex"], product["ex"]) - best = { - "name": product["name"], - "ths_code": ths_main, - "market_code": f"{ths_main}.{suffix}", - "sina_code": sina_main, - "exchange": product["exchange"], - "contract": f"主力连续 {ths_main}", - "display": f"{product['name']} 主力连续 {ths_main}", - "input_label": f"{product['name']} {ths_main}", - "volume": raw.get("volume", 0), - } - - if best: - _MAIN_CACHE[cache_key] = (now, best) - return best - - -def _enrich_item(item: dict) -> dict: - out = dict(item) - if not out.get("input_label"): - out["input_label"] = f"{out.get('name', '')} {out.get('ths_code', '')}".strip() - out["near_expiry"] = is_near_expiry_main(out.get("ths_code", "")) - return out - - -def refresh_main_index(): - """后台预热全部品种主力合约,搜索时只读本地缓存。""" - global _main_index, _main_index_ts - with _index_refresh_lock: - new_idx: dict[str, dict] = {} - with ThreadPoolExecutor(max_workers=10) as pool: - futures = {pool.submit(resolve_main_contract, p): p for p in PRODUCTS} - for fut in as_completed(futures): - product = futures[fut] - try: - main = fut.result() - if main: - new_idx[product["sina"]] = _enrich_item(main) - except Exception: - pass - with _main_index_lock: - _main_index = new_idx - _main_index_ts = time.time() - - -def _warm_loop(): - while True: - try: - refresh_main_index() - except Exception: - pass - time.sleep(_CACHE_TTL) - - -def _start_warm_thread(): - threading.Thread(target=_warm_loop, daemon=True).start() - - -def _stub_main_contract(product: dict) -> dict: - """缓存未就绪时的快速占位(当月合约),避免首次打开搜索为空。""" - today = date.today() - return _enrich_item(_make_symbol_item(product, today.year, today.month, 0)) - - -def _product_matches(product: dict, q_lower: str) -> bool: - name_lower = product["name"].lower() - if q_lower in name_lower: - return True - if len(q_lower) >= 2: - ths_lower = product["ths"].lower() - sina_lower = product["sina"].lower() - if q_lower in ths_lower or q_lower in sina_lower: - return True - return False - - -def _match_score(product: dict, q_lower: str) -> int: - name_lower = product["name"].lower() - if name_lower == q_lower: - return 200 - if name_lower.startswith(q_lower): - return 150 - if q_lower in name_lower: - return 100 - ths_lower = product["ths"].lower() - if ths_lower == q_lower: - return 90 - if ths_lower.startswith(q_lower): - return 70 - if product["sina"].lower() == q_lower: - return 80 - return 10 - - -def search_symbols(query: str) -> list: - q = query.strip() - if not q: - return [] - - q_lower = q.lower() - with _main_index_lock: - index = dict(_main_index) - index_ready = bool(index) - - scored: list[tuple[int, dict]] = [] - for p in PRODUCTS: - if not _product_matches(p, q_lower): - continue - main = index.get(p["sina"]) - if not main and not index_ready: - main = _stub_main_contract(p) - if main: - scored.append((_match_score(p, q_lower), main)) - - scored.sort(key=lambda x: -x[0]) - results = [item for _, item in scored[:12]] - - if not results and len(q) >= 3: - codes = ths_to_codes(q) - if codes: - raw = fetch_raw_for_volume(codes["sina_code"]) - name = raw["name"] if raw else q - results.append(_enrich_item({ - "name": name, - "ths_code": codes["ths_code"], - "market_code": codes["market_code"], - "sina_code": codes["sina_code"], - "exchange": "", - "contract": codes["ths_code"], - "display": f"{name} ({codes['ths_code']})", - "volume": raw.get("volume", 0) if raw else 0, - })) - - return results - - -_THS_TO_PRODUCT = {p["ths"]: p for p in PRODUCTS} -for _p in PRODUCTS: - _THS_TO_PRODUCT.setdefault(_p["ths"].lower(), _p) - - -def _product_for_ths(ths: str) -> Optional[dict]: - key = (ths or "").strip() - if not key: - return None - return _THS_TO_PRODUCT.get(key) or _THS_TO_PRODUCT.get(key.lower()) - - -def _item_from_recommend_row(row: dict, product: dict) -> Optional[dict]: - """由推荐缓存行快速构造下拉项(不在 HTTP 请求中解析主力)。""" - name = row.get("name") or product["name"] - main_code = (row.get("main_code") or "").strip() - max_lots = row.get("max_lots") - - if main_code: - codes = ths_to_codes(main_code) - if codes: - ths = codes["ths_code"] - item = { - "name": name, - "ths_code": ths, - "market_code": codes.get("market_code") or "", - "sina_code": codes.get("sina_code") or "", - "exchange": product["exchange"], - "contract": f"主力 {ths}", - "display": f"{name} 主力 {ths}", - "input_label": f"{name} {ths}", - } - if max_lots is not None: - item["max_lots"] = max_lots - return _enrich_item(item) - - with _main_index_lock: - main = _main_index.get(product["sina"]) - if main: - item = dict(main) - if max_lots is not None: - item["max_lots"] = max_lots - return _enrich_item(item) - - item = _stub_main_contract(product) - if max_lots is not None: - item["max_lots"] = max_lots - return item - - -def list_recommended_symbols_grouped(recommend_rows: list[dict]) -> list[dict]: - """按交易所分类返回推荐品种对应的主力合约(品种选择下拉用)。""" - if not recommend_rows: - return [] - - buckets: dict[str, list] = defaultdict(list) - seen: set[str] = set() - for row in recommend_rows: - if row.get("status") not in ("ok", "margin_ok"): - continue - ths_key = (row.get("ths") or "").strip() - if not ths_key or ths_key in seen: - continue - product = _product_for_ths(ths_key) - if not product: - continue - seen.add(ths_key) - item = _item_from_recommend_row(row, product) - if not item: - continue - buckets[product["exchange"]].append(item) - - groups: list[dict] = [] - for cat in EXCHANGE_ORDER: - items = buckets.get(cat) - if items: - groups.append({"category": cat, "items": items}) - return groups - - -def list_main_contracts_grouped() -> list[dict]: - """按交易所分类返回全部品种主力合约(行情页下拉用)。""" - with _main_index_lock: - index = dict(_main_index) - - if len(index) < len(PRODUCTS) // 2: - refresh_main_index() - with _main_index_lock: - index = dict(_main_index) - - buckets: dict[str, list] = defaultdict(list) - for p in PRODUCTS: - main = index.get(p["sina"]) - if not main: - resolved = resolve_main_contract(p) - if resolved: - main = _enrich_item(resolved) - if main: - buckets[p["exchange"]].append(main) - - groups: list[dict] = [] - for cat in EXCHANGE_ORDER: - items = buckets.get(cat) - if items: - groups.append({"category": cat, "items": items}) - return groups - - -_start_warm_thread() - - -def get_price(market_code: str, sina_code: str = "") -> Optional[float]: - return market_get_price(market_code, sina_code) +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +""" +期货品种与同花顺代码映射。 +展示同花顺合约代码(ag2608);行情默认新浪,机构用户可通过环境变量启用同花顺 iFinD。 +""" +import re +import threading +import time +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import date +from typing import Optional + +from market import fetch_raw_for_volume, get_price as market_get_price, THS_EX_SUFFIX + +PRODUCTS = [ + {"name": "白银", "ths": "ag", "sina": "AG", "exchange": "上期所", "ex": "SHFE"}, + {"name": "黄金", "ths": "au", "sina": "AU", "exchange": "上期所", "ex": "SHFE"}, + {"name": "铜", "ths": "cu", "sina": "CU", "exchange": "上期所", "ex": "SHFE"}, + {"name": "铝", "ths": "al", "sina": "AL", "exchange": "上期所", "ex": "SHFE"}, + {"name": "锌", "ths": "zn", "sina": "ZN", "exchange": "上期所", "ex": "SHFE"}, + {"name": "铅", "ths": "pb", "sina": "PB", "exchange": "上期所", "ex": "SHFE"}, + {"name": "镍", "ths": "ni", "sina": "NI", "exchange": "上期所", "ex": "SHFE"}, + {"name": "锡", "ths": "sn", "sina": "SN", "exchange": "上期所", "ex": "SHFE"}, + {"name": "螺纹钢", "ths": "rb", "sina": "RB", "exchange": "上期所", "ex": "SHFE"}, + {"name": "热卷", "ths": "hc", "sina": "HC", "exchange": "上期所", "ex": "SHFE"}, + {"name": "不锈钢", "ths": "ss", "sina": "SS", "exchange": "上期所", "ex": "SHFE"}, + {"name": "原油", "ths": "sc", "sina": "SC", "exchange": "上期能源", "ex": "INE"}, + {"name": "燃油", "ths": "fu", "sina": "FU", "exchange": "上期所", "ex": "SHFE"}, + {"name": "沥青", "ths": "bu", "sina": "BU", "exchange": "上期所", "ex": "SHFE"}, + {"name": "橡胶", "ths": "ru", "sina": "RU", "exchange": "上期所", "ex": "SHFE"}, + {"name": "纸浆", "ths": "sp", "sina": "SP", "exchange": "上期所", "ex": "SHFE"}, + {"name": "铁矿石", "ths": "i", "sina": "I", "exchange": "大商所", "ex": "DCE"}, + {"name": "焦炭", "ths": "j", "sina": "J", "exchange": "大商所", "ex": "DCE"}, + {"name": "焦煤", "ths": "jm", "sina": "JM", "exchange": "大商所", "ex": "DCE"}, + {"name": "豆粕", "ths": "m", "sina": "M", "exchange": "大商所", "ex": "DCE"}, + {"name": "豆油", "ths": "y", "sina": "Y", "exchange": "大商所", "ex": "DCE"}, + {"name": "棕榈油", "ths": "p", "sina": "P", "exchange": "大商所", "ex": "DCE"}, + {"name": "玉米", "ths": "c", "sina": "C", "exchange": "大商所", "ex": "DCE"}, + {"name": "淀粉", "ths": "cs", "sina": "CS", "exchange": "大商所", "ex": "DCE"}, + {"name": "鸡蛋", "ths": "jd", "sina": "JD", "exchange": "大商所", "ex": "DCE"}, + {"name": "生猪", "ths": "lh", "sina": "LH", "exchange": "大商所", "ex": "DCE"}, + {"name": "聚乙烯", "ths": "l", "sina": "L", "exchange": "大商所", "ex": "DCE"}, + {"name": "聚丙烯", "ths": "pp", "sina": "PP", "exchange": "大商所", "ex": "DCE"}, + {"name": "PVC", "ths": "v", "sina": "V", "exchange": "大商所", "ex": "DCE"}, + {"name": "乙二醇", "ths": "eg", "sina": "EG", "exchange": "大商所", "ex": "DCE"}, + {"name": "苯乙烯", "ths": "eb", "sina": "EB", "exchange": "大商所", "ex": "DCE"}, + {"name": "液化气", "ths": "pg", "sina": "PG", "exchange": "大商所", "ex": "DCE"}, + {"name": "菜粕", "ths": "RM", "sina": "RM", "exchange": "郑商所", "ex": "CZCE"}, + {"name": "菜油", "ths": "OI", "sina": "OI", "exchange": "郑商所", "ex": "CZCE"}, + {"name": "白糖", "ths": "SR", "sina": "SR", "exchange": "郑商所", "ex": "CZCE"}, + {"name": "棉花", "ths": "CF", "sina": "CF", "exchange": "郑商所", "ex": "CZCE"}, + {"name": "甲醇", "ths": "MA", "sina": "MA", "exchange": "郑商所", "ex": "CZCE"}, + {"name": "PTA", "ths": "TA", "sina": "TA", "exchange": "郑商所", "ex": "CZCE"}, + {"name": "玻璃", "ths": "FG", "sina": "FG", "exchange": "郑商所", "ex": "CZCE"}, + {"name": "纯碱", "ths": "SA", "sina": "SA", "exchange": "郑商所", "ex": "CZCE"}, + {"name": "尿素", "ths": "UR", "sina": "UR", "exchange": "郑商所", "ex": "CZCE"}, + {"name": "硅铁", "ths": "SF", "sina": "SF", "exchange": "郑商所", "ex": "CZCE"}, + {"name": "锰硅", "ths": "SM", "sina": "SM", "exchange": "郑商所", "ex": "CZCE"}, + {"name": "苹果", "ths": "AP", "sina": "AP", "exchange": "郑商所", "ex": "CZCE"}, + {"name": "红枣", "ths": "CJ", "sina": "CJ", "exchange": "郑商所", "ex": "CZCE"}, + {"name": "花生", "ths": "PK", "sina": "PK", "exchange": "郑商所", "ex": "CZCE"}, + {"name": "沪深300", "ths": "IF", "sina": "IF", "exchange": "中金所", "ex": "CFFEX"}, + {"name": "上证50", "ths": "IH", "sina": "IH", "exchange": "中金所", "ex": "CFFEX"}, + {"name": "中证500", "ths": "IC", "sina": "IC", "exchange": "中金所", "ex": "CFFEX"}, + {"name": "中证1000", "ths": "IM", "sina": "IM", "exchange": "中金所", "ex": "CFFEX"}, +] + +PRODUCT_CATEGORY_MAP = { + "ag": "贵金属", "au": "贵金属", + "cu": "有色金属", "al": "有色金属", "zn": "有色金属", "pb": "有色金属", "ni": "有色金属", "sn": "有色金属", + "rb": "黑色金属", "hc": "黑色金属", "ss": "黑色金属", "i": "黑色金属", "j": "黑色金属", "jm": "黑色金属", + "SF": "黑色金属", "SM": "黑色金属", + "sc": "能源化工", "fu": "能源化工", "bu": "能源化工", "ru": "能源化工", "sp": "能源化工", + "l": "能源化工", "pp": "能源化工", "v": "能源化工", "eg": "能源化工", "eb": "能源化工", "pg": "能源化工", + "MA": "能源化工", "TA": "能源化工", "SA": "能源化工", "UR": "能源化工", "FG": "能源化工", + "m": "农产品", "y": "农产品", "p": "农产品", "c": "农产品", "cs": "农产品", "jd": "农产品", "lh": "农产品", + "RM": "农产品", "OI": "农产品", "SR": "农产品", "CF": "农产品", "AP": "农产品", "CJ": "农产品", "PK": "农产品", + "IF": "金融期货", "IH": "金融期货", "IC": "金融期货", "IM": "金融期货", +} +PRODUCT_CATEGORIES = ["贵金属", "有色金属", "黑色金属", "能源化工", "农产品", "金融期货"] + +for _p in PRODUCTS: + _p["category"] = PRODUCT_CATEGORY_MAP.get(_p["ths"], "其他") + + +def product_category(ths: str) -> str: + return PRODUCT_CATEGORY_MAP.get((ths or "").strip(), "其他") + + +EXCHANGE_ORDER = ["上期所", "上期能源", "大商所", "郑商所", "中金所"] +_MAIN_CACHE: dict[str, tuple[float, dict]] = {} +_CACHE_TTL = 300 +_main_index_lock = threading.Lock() +_main_index: dict[str, dict] = {} +_main_index_ts = 0.0 +_index_refresh_lock = threading.Lock() + + +def build_ths_code(product: dict, year: int, month: int) -> str: + """同花顺软件内显示的合约代码。""" + ex = product["ex"] + letters = product["ths"] + if ex == "CZCE": + return f"{letters}{year % 10}{month:02d}" + return f"{letters}{year % 100:02d}{month:02d}" + + +def build_ths_full_code(product: dict, year: int, month: int) -> str: + """同花顺 iFinD HTTP API 代码,如 ag2608.SHFE""" + ths = build_ths_code(product, year, month) + suffix = THS_EX_SUFFIX.get(product["ex"], product["ex"]) + return f"{ths}.{suffix}" + + +def build_sina_code(product: dict, year: int, month: int) -> str: + letters = product["sina"] + suffix = f"{year % 100:02d}{month:02d}" + if product["ex"] == "CFFEX": + return f"CFF_RE_{letters}{suffix}" + return f"nf_{letters}{suffix}" + + +def build_sina_main_code(product: dict) -> str: + letters = product["sina"] + if product["ex"] == "CFFEX": + return f"CFF_RE_{letters}0" + return f"nf_{letters}0" + + +def _find_product_by_letters(letters: str) -> Optional[dict]: + letters_up = letters.upper() + for p in PRODUCTS: + if p["ths"].upper() == letters_up or p["sina"] == letters_up: + return p + return None + + +def ths_to_codes(ths_code: str) -> Optional[dict]: + """同花顺合约代码 -> ths_full + sina 回退代码。""" + code = ths_code.strip() + if not code: + return None + + m4 = re.match(r"^([A-Za-z]+)(\d{4})$", code) + if m4: + letters, digits = m4.group(1), m4.group(2) + year = 2000 + int(digits[:2]) + month = int(digits[2:]) + if not 1 <= month <= 12: + return None + product = _find_product_by_letters(letters) + if product: + return { + "ths_code": build_ths_code(product, year, month), + "market_code": build_ths_full_code(product, year, month), + "sina_code": build_sina_code(product, year, month), + } + letters_up = letters.upper() + if letters_up in ("IF", "IH", "IC", "IM", "T", "TF", "TS"): + ths = f"{letters_up}{digits}" + return { + "ths_code": ths, + "market_code": f"{ths}.CFFEX", + "sina_code": f"CFF_RE_{letters_up}{digits}", + } + + m3 = re.match(r"^([A-Za-z]+)(\d{3})$", code) + if m3: + letters, digits = m3.group(1), m3.group(2) + y_digit = int(digits[0]) + month = int(digits[1:]) + if not 1 <= month <= 12: + return None + year = date.today().year + decade = year // 10 * 10 + candidate = decade + y_digit + if candidate < year - 1: + candidate += 10 + product = _find_product_by_letters(letters) + if product: + return { + "ths_code": build_ths_code(product, candidate, month), + "market_code": build_ths_full_code(product, candidate, month), + "sina_code": build_sina_code(product, candidate, month), + } + + return None + + +def ths_to_sina_code(ths_code: str) -> Optional[str]: + codes = ths_to_codes(ths_code) + return codes["sina_code"] if codes else None + + +def parse_contract_year_month(ths_code: str) -> Optional[tuple[int, int]]: + """从同花顺合约代码解析交割年月。""" + code = (ths_code or "").strip() + if not code or "888" in code: + return None + m4 = re.match(r"^([A-Za-z]+)(\d{4})$", code) + if m4: + digits = m4.group(2) + year = 2000 + int(digits[:2]) + month = int(digits[2:]) + if 1 <= month <= 12: + return year, month + m3 = re.match(r"^([A-Za-z]+)(\d{3})$", code) + if m3: + letters, digits = m3.group(1), m3.group(2) + month = int(digits[1:]) + if not 1 <= month <= 12: + return None + y_digit = int(digits[0]) + year = date.today().year + decade = year // 10 * 10 + candidate = decade + y_digit + if candidate < year - 1: + candidate += 10 + product = _find_product_by_letters(letters) + if product: + return candidate, month + return None + + +def is_near_expiry_main(ths_code: str) -> bool: + """主力合约交割月为当月或下月时视为临期。""" + ym = parse_contract_year_month(ths_code) + if not ym: + return False + cy, cm = ym + today = date.today() + months_ahead = (cy - today.year) * 12 + (cm - today.month) + return months_ahead <= 1 + + +def _main_contract_score(raw: dict) -> float: + """主力判定:优先持仓量,其次成交量。""" + oi = float(raw.get("open_interest") or 0) + vol = float(raw.get("volume") or 0) + return oi if oi > 0 else vol + + +def _make_symbol_item( + product: dict, + year: int, + month: int, + volume: float, + open_interest: float = 0, +) -> dict: + ths = build_ths_code(product, year, month) + name = product["name"] + return { + "name": name, + "ths_code": ths, + "market_code": build_ths_full_code(product, year, month), + "sina_code": build_sina_code(product, year, month), + "exchange": product["exchange"], + "contract": f"主力 {ths}", + "display": f"{name} 主力 {ths}", + "input_label": f"{name} {ths}", + "volume": volume, + "open_interest": open_interest, + } + + +def resolve_main_contract(product: dict) -> Optional[dict]: + cache_key = product["sina"] + now = time.time() + cached = _MAIN_CACHE.get(cache_key) + if cached and now - cached[0] < _CACHE_TTL: + return cached[1] + + today = date.today() + y, m = today.year, today.month + best = None + best_score = 0.0 + + for i in range(14): + cy, cm = y, m + i + while cm > 12: + cm -= 12 + cy += 1 + sina = build_sina_code(product, cy, cm) + raw = fetch_raw_for_volume(sina) + if not raw: + continue + score = _main_contract_score(raw) + if score <= 0: + continue + item = _make_symbol_item( + product, cy, cm, raw["volume"], raw.get("open_interest", 0), + ) + if score > best_score: + best_score = score + best = item + + if best is None: + sina_main = build_sina_main_code(product) + raw = fetch_raw_for_volume(sina_main) + if raw: + ths_letters = product["ths"] + ths_main = ( + f"{ths_letters}888" + if product["ex"] != "CFFEX" + else f"{ths_letters.upper()}888" + ) + suffix = THS_EX_SUFFIX.get(product["ex"], product["ex"]) + best = { + "name": product["name"], + "ths_code": ths_main, + "market_code": f"{ths_main}.{suffix}", + "sina_code": sina_main, + "exchange": product["exchange"], + "contract": f"主力连续 {ths_main}", + "display": f"{product['name']} 主力连续 {ths_main}", + "input_label": f"{product['name']} {ths_main}", + "volume": raw.get("volume", 0), + } + + if best: + _MAIN_CACHE[cache_key] = (now, best) + return best + + +def _enrich_item(item: dict) -> dict: + out = dict(item) + if not out.get("input_label"): + out["input_label"] = f"{out.get('name', '')} {out.get('ths_code', '')}".strip() + out["near_expiry"] = is_near_expiry_main(out.get("ths_code", "")) + return out + + +def refresh_main_index(): + """后台预热全部品种主力合约,搜索时只读本地缓存。""" + global _main_index, _main_index_ts + with _index_refresh_lock: + new_idx: dict[str, dict] = {} + with ThreadPoolExecutor(max_workers=10) as pool: + futures = {pool.submit(resolve_main_contract, p): p for p in PRODUCTS} + for fut in as_completed(futures): + product = futures[fut] + try: + main = fut.result() + if main: + new_idx[product["sina"]] = _enrich_item(main) + except Exception: + pass + with _main_index_lock: + _main_index = new_idx + _main_index_ts = time.time() + + +def _warm_loop(): + while True: + try: + refresh_main_index() + except Exception: + pass + time.sleep(_CACHE_TTL) + + +def _start_warm_thread(): + threading.Thread(target=_warm_loop, daemon=True).start() + + +def _stub_main_contract(product: dict) -> dict: + """缓存未就绪时的快速占位(当月合约),避免首次打开搜索为空。""" + today = date.today() + return _enrich_item(_make_symbol_item(product, today.year, today.month, 0)) + + +def _product_matches(product: dict, q_lower: str) -> bool: + name_lower = product["name"].lower() + if q_lower in name_lower: + return True + if len(q_lower) >= 2: + ths_lower = product["ths"].lower() + sina_lower = product["sina"].lower() + if q_lower in ths_lower or q_lower in sina_lower: + return True + return False + + +def _match_score(product: dict, q_lower: str) -> int: + name_lower = product["name"].lower() + if name_lower == q_lower: + return 200 + if name_lower.startswith(q_lower): + return 150 + if q_lower in name_lower: + return 100 + ths_lower = product["ths"].lower() + if ths_lower == q_lower: + return 90 + if ths_lower.startswith(q_lower): + return 70 + if product["sina"].lower() == q_lower: + return 80 + return 10 + + +def search_symbols(query: str) -> list: + q = query.strip() + if not q: + return [] + + q_lower = q.lower() + with _main_index_lock: + index = dict(_main_index) + index_ready = bool(index) + + scored: list[tuple[int, dict]] = [] + for p in PRODUCTS: + if not _product_matches(p, q_lower): + continue + main = index.get(p["sina"]) + if not main and not index_ready: + main = _stub_main_contract(p) + if main: + scored.append((_match_score(p, q_lower), main)) + + scored.sort(key=lambda x: -x[0]) + results = [item for _, item in scored[:12]] + + if not results and len(q) >= 3: + codes = ths_to_codes(q) + if codes: + raw = fetch_raw_for_volume(codes["sina_code"]) + name = raw["name"] if raw else q + results.append(_enrich_item({ + "name": name, + "ths_code": codes["ths_code"], + "market_code": codes["market_code"], + "sina_code": codes["sina_code"], + "exchange": "", + "contract": codes["ths_code"], + "display": f"{name} ({codes['ths_code']})", + "volume": raw.get("volume", 0) if raw else 0, + })) + + return results + + +_THS_TO_PRODUCT = {p["ths"]: p for p in PRODUCTS} +for _p in PRODUCTS: + _THS_TO_PRODUCT.setdefault(_p["ths"].lower(), _p) + + +def _product_for_ths(ths: str) -> Optional[dict]: + key = (ths or "").strip() + if not key: + return None + return _THS_TO_PRODUCT.get(key) or _THS_TO_PRODUCT.get(key.lower()) + + +def _item_from_recommend_row(row: dict, product: dict) -> Optional[dict]: + """由可开仓缓存行快速构造下拉项(不在 HTTP 请求中解析主力)。""" + name = row.get("name") or product["name"] + main_code = (row.get("main_code") or "").strip() + max_lots = row.get("max_lots") + + if main_code: + codes = ths_to_codes(main_code) + if codes: + ths = codes["ths_code"] + item = { + "name": name, + "ths_code": ths, + "market_code": codes.get("market_code") or "", + "sina_code": codes.get("sina_code") or "", + "exchange": product["exchange"], + "contract": f"主力 {ths}", + "display": f"{name} 主力 {ths}", + "input_label": f"{name} {ths}", + } + if max_lots is not None: + item["max_lots"] = max_lots + return _enrich_item(item) + + with _main_index_lock: + main = _main_index.get(product["sina"]) + if main: + item = dict(main) + if max_lots is not None: + item["max_lots"] = max_lots + return _enrich_item(item) + + item = _stub_main_contract(product) + if max_lots is not None: + item["max_lots"] = max_lots + return item + + +def list_recommended_symbols_grouped(recommend_rows: list[dict]) -> list[dict]: + """按交易所分类返回可开仓品种对应的主力合约(品种选择下拉用)。""" + if not recommend_rows: + return [] + + buckets: dict[str, list] = defaultdict(list) + seen: set[str] = set() + for row in recommend_rows: + if row.get("status") not in ("ok", "margin_ok"): + continue + ths_key = (row.get("ths") or "").strip() + if not ths_key or ths_key in seen: + continue + product = _product_for_ths(ths_key) + if not product: + continue + seen.add(ths_key) + item = _item_from_recommend_row(row, product) + if not item: + continue + buckets[product["exchange"]].append(item) + + groups: list[dict] = [] + for cat in EXCHANGE_ORDER: + items = buckets.get(cat) + if items: + groups.append({"category": cat, "items": items}) + return groups + + +def list_main_contracts_grouped() -> list[dict]: + """按交易所分类返回全部品种主力合约(行情页下拉用)。""" + with _main_index_lock: + index = dict(_main_index) + + if len(index) < len(PRODUCTS) // 2: + refresh_main_index() + with _main_index_lock: + index = dict(_main_index) + + buckets: dict[str, list] = defaultdict(list) + for p in PRODUCTS: + main = index.get(p["sina"]) + if not main: + resolved = resolve_main_contract(p) + if resolved: + main = _enrich_item(resolved) + if main: + buckets[p["exchange"]].append(main) + + groups: list[dict] = [] + for cat in EXCHANGE_ORDER: + items = buckets.get(cat) + if items: + groups.append({"category": cat, "items": items}) + return groups + + +_start_warm_thread() + + +def get_price(market_code: str, sina_code: str = "") -> Optional[float]: + return market_get_price(market_code, sina_code) diff --git a/templates/base.html b/templates/base.html index 7ebcb5f..876fa87 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,3 +1,4 @@ +{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #} diff --git a/templates/contract.html b/templates/contract.html index 76d52c6..49dd7f5 100644 --- a/templates/contract.html +++ b/templates/contract.html @@ -1,47 +1,48 @@ -{% extends "base.html" %} -{% block title %}品种简介 - 国内期货监控系统{% endblock %} -{% block content %} -
-

品种简介

-
-
-
- - -
-
-
- -
-

展示交易所合约规格:交易单位、最小变动、保证金、交割规则等(数据来源:东方财富 / 新浪)。

- - {% if error %} -
{{ error }}
- {% elif profile %} -
- {{ profile.symbol_name or profile.ths_code }} - {{ profile.ths_code }} - {% if profile.exchange %}{{ profile.exchange }}{% endif %} - 来源:{{ profile.source }} -
-
- {% for row in profile.rows %} -
-
{{ row.label }}
-
- {{ row.value }} - {% if row.hint %}
{{ row.hint }}
{% endif %} -
-
- {% endfor %} -
- {% elif symbol %} -

未查询到该合约简介,请检查合约代码是否正确。

- {% endif %} -
-
-{% endblock %} -{% block extra_js %} - -{% endblock %} +{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #} +{% extends "base.html" %} +{% block title %}品种简介 - 国内期货监控系统{% endblock %} +{% block content %} +
+

品种简介

+
+
+
+ + +
+
+
+ +
+

展示交易所合约规格:交易单位、最小变动、保证金、交割规则等(数据来源:东方财富 / 新浪)。

+ + {% if error %} +
{{ error }}
+ {% elif profile %} +
+ {{ profile.symbol_name or profile.ths_code }} + {{ profile.ths_code }} + {% if profile.exchange %}{{ profile.exchange }}{% endif %} + 来源:{{ profile.source }} +
+
+ {% for row in profile.rows %} +
+
{{ row.label }}
+
+ {{ row.value }} + {% if row.hint %}
{{ row.hint }}
{% endif %} +
+
+ {% endfor %} +
+ {% elif symbol %} +

未查询到该合约简介,请检查合约代码是否正确。

+ {% endif %} +
+
+{% endblock %} +{% block extra_js %} + +{% endblock %} diff --git a/templates/fees.html b/templates/fees.html index 4f10d28..9122b79 100644 --- a/templates/fees.html +++ b/templates/fees.html @@ -1,120 +1,121 @@ -{% extends "base.html" %} -{% block title %}手续费配置 - 国内期货监控系统{% endblock %} -{% block extra_css %} - -{% endblock %} -{% block content %} -
-

CTP 手续费

-
-

- 费率由后台从 CTP 柜台 同步写入数据库,每日自动更新一次,本页只读展示。 -

- {% if ctp_connected %} - CTP 已连接 - {% else %} - CTP 未连接 - {% endif %} - {% if fee_synced_today %} - 今日已同步 - {% else %} - 今日未同步 - {% endif %} - {% if fee_last_sync %} - 上次:{{ fee_last_sync[:16] }} - {% endif %} - {% if fee_sync_running %} - 同步中… - {% endif %} - {% if fee_counts.get('ctp') %} - 共 {{ fee_counts.ctp }} 个品种 - {% endif %} -
- - - -
-
-
- -
-

品种费率表

-
-
- - - - - - - - - - - - {% for r in rates %} - - - - - - - - - - - - - {% else %} - - {% endfor %} - -
品种交易所乘数开仓(元/手)开仓(比例)平昨(元/手)平昨(比例)平今(元/手)平今(比例)更新
{{ r.product }}{{ r.exchange or '—' }}{{ r.mult }}{{ r.open_fixed }}{{ r.open_ratio }}{{ r.close_yesterday_fixed }}{{ r.close_yesterday_ratio }}{{ r.close_today_fixed }}{{ r.close_today_ratio }}{{ (r.updated_at or '')[:16] }}
暂无 CTP 费率,请连接 CTP 后等待自动同步或点击「立即同步」
-
-
-

- 公式:单边 = 固定(元/手)×手数 + 比例×价格×乘数×手数;往返 = 开仓 + 平仓(平今/平昨自动判断)。 - {% if ctp_connected and not fee_counts.get('ctp') %} -
数据库尚无 CTP 费率,请点击「立即同步」或等待后台每日任务。 - {% endif %} -

-
-{% endblock %} +{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #} +{% extends "base.html" %} +{% block title %}手续费配置 - 国内期货监控系统{% endblock %} +{% block extra_css %} + +{% endblock %} +{% block content %} +
+

CTP 手续费

+
+

+ 费率由后台从 CTP 柜台 同步写入数据库,每日自动更新一次,本页只读展示。 +

+ {% if ctp_connected %} + CTP 已连接 + {% else %} + CTP 未连接 + {% endif %} + {% if fee_synced_today %} + 今日已同步 + {% else %} + 今日未同步 + {% endif %} + {% if fee_last_sync %} + 上次:{{ fee_last_sync[:16] }} + {% endif %} + {% if fee_sync_running %} + 同步中… + {% endif %} + {% if fee_counts.get('ctp') %} + 共 {{ fee_counts.ctp }} 个品种 + {% endif %} +
+ + + +
+
+
+ +
+

品种费率表

+
+
+ + + + + + + + + + + + {% for r in rates %} + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
品种交易所乘数开仓(元/手)开仓(比例)平昨(元/手)平昨(比例)平今(元/手)平今(比例)更新
{{ r.product }}{{ r.exchange or '—' }}{{ r.mult }}{{ r.open_fixed }}{{ r.open_ratio }}{{ r.close_yesterday_fixed }}{{ r.close_yesterday_ratio }}{{ r.close_today_fixed }}{{ r.close_today_ratio }}{{ (r.updated_at or '')[:16] }}
暂无 CTP 费率,请连接 CTP 后等待自动同步或点击「立即同步」
+
+
+

+ 公式:单边 = 固定(元/手)×手数 + 比例×价格×乘数×手数;往返 = 开仓 + 平仓(平今/平昨自动判断)。 + {% if ctp_connected and not fee_counts.get('ctp') %} +
数据库尚无 CTP 费率,请点击「立即同步」或等待后台每日任务。 + {% endif %} +

+
+{% endblock %} diff --git a/templates/keys.html b/templates/keys.html index 403a82e..e24a991 100644 --- a/templates/keys.html +++ b/templates/keys.html @@ -1,85 +1,86 @@ -{% extends "base.html" %} -{% block title %}关键位监控 - 国内期货监控系统{% endblock %} -{% block content %} -
-
-

新增监控

-
-
-
-
- - - - - -
-
-
- - -
-
- - - -
-
- -
- {% for k in keys %} -
-
- {{ k.symbol_name or k.symbol }} {{ k.monitor_type }} - {{ '多' if k.direction == 'long' else '空' }} -
-
- 现价:-- - 距上-- 距下-- -
-
上{{ k.upper }} 下{{ k.lower }}
- -
- {% else %} -
暂无监控
- {% endfor %} -
-
-
- -
-

监控历史

-
- - - - {% for k in history %} - - - - - - - - - {% else %} - - {% endfor %} - -
品种类型方向上沿下沿归档
{{ k.symbol_name or k.symbol }}{{ k.monitor_type }}{{ '多' if k.direction == 'long' else '空' }}{{ k.upper }}{{ k.lower }}{{ k.archived_at[:16] if k.archived_at else '' }}
暂无历史
-
-
-
-{% endblock %} -{% block extra_js %} - -{% endblock %} +{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #} +{% extends "base.html" %} +{% block title %}关键位监控 - 国内期货监控系统{% endblock %} +{% block content %} +
+
+

新增监控

+
+
+
+
+ + + + + +
+
+
+ + +
+
+ + + +
+
+ +
+ {% for k in keys %} +
+
+ {{ k.symbol_name or k.symbol }} {{ k.monitor_type }} + {{ '多' if k.direction == 'long' else '空' }} +
+
+ 现价:-- + 距上-- 距下-- +
+
上{{ k.upper }} 下{{ k.lower }}
+ +
+ {% else %} +
暂无监控
+ {% endfor %} +
+
+
+ +
+

监控历史

+
+ + + + {% for k in history %} + + + + + + + + + {% else %} + + {% endfor %} + +
品种类型方向上沿下沿归档
{{ k.symbol_name or k.symbol }}{{ k.monitor_type }}{{ '多' if k.direction == 'long' else '空' }}{{ k.upper }}{{ k.lower }}{{ k.archived_at[:16] if k.archived_at else '' }}
暂无历史
+
+
+
+{% endblock %} +{% block extra_js %} + +{% endblock %} diff --git a/templates/login.html b/templates/login.html index aeb265f..6074ca5 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,3 +1,4 @@ +{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #} diff --git a/templates/market.html b/templates/market.html index dfc43ee..86bbf7d 100644 --- a/templates/market.html +++ b/templates/market.html @@ -1,139 +1,140 @@ -{% extends "base.html" %} -{% block title %}行情K线 - 国内期货监控系统{% endblock %} -{% block content %} - -
-

行情 K 线

-
-
- - - - - -
-
-
-
- {% for p in market_periods %} - - {% endfor %} -
- -
-
- - - - -
-
-
- - - -
-
- - - -
- -
-
-
-
请选择合约并点击「查看」
-
连接中…
-
-

图表引擎:TradingView Lightweight Charts(红跌绿涨)。数据来源:{% if ctp_connected %}报价 CTP;K 线历史新浪补齐、最新 bar 由 CTP tick 更新{% else %}CTP 未连接时回退新浪{% endif %}。滚轮缩放、拖拽平移;勾选「间隔日」可压缩夜盘空白。

-
- - -{% endblock %} - -{% block extra_js %} - - -{% endblock %} +{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #} +{% extends "base.html" %} +{% block title %}行情K线 - 国内期货监控系统{% endblock %} +{% block content %} + +
+

行情 K 线

+
+
+ + + + + +
+
+
+
+ {% for p in market_periods %} + + {% endfor %} +
+ +
+
+ + + + +
+
+
+ + + +
+
+ + + +
+ +
+
+
+
请选择合约并点击「查看」
+
连接中…
+
+

图表引擎:TradingView Lightweight Charts(红跌绿涨)。数据来源:{% if ctp_connected %}报价 CTP;K 线历史新浪补齐、最新 bar 由 CTP tick 更新{% else %}CTP 未连接时回退新浪{% endif %}。滚轮缩放、拖拽平移;勾选「间隔日」可压缩夜盘空白。

+
+ + +{% endblock %} + +{% block extra_js %} + + +{% endblock %} diff --git a/templates/plans.html b/templates/plans.html index b72aaad..b378664 100644 --- a/templates/plans.html +++ b/templates/plans.html @@ -1,3 +1,4 @@ +{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #} {% extends "base.html" %} {% block title %}开单计划 - 国内期货监控系统{% endblock %} {% block content %} diff --git a/templates/positions.html b/templates/positions.html index 549124f..e778605 100644 --- a/templates/positions.html +++ b/templates/positions.html @@ -1,47 +1,48 @@ -{% extends "base.html" %} -{% block title %}持仓监控 - 国内期货监控系统{% endblock %} -{% block content %} -
-
-

持仓录入

-
-
-
-
- - - - - -
-
-
- - -
-
- - - -
-
- -
-
-

方向根据止损与成交价自动判断;风险比例依赖系统设置中的实盘资金。

-
-
- -
-

实时持仓

-
- {% if not positions %} -
暂无持仓,左侧录入后显示
- {% endif %} -
-
-
-{% endblock %} -{% block extra_js %} - -{% endblock %} +{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #} +{% extends "base.html" %} +{% block title %}持仓监控 - 国内期货监控系统{% endblock %} +{% block content %} +
+
+

持仓录入

+
+
+
+
+ + + + + +
+
+
+ + +
+
+ + + +
+
+ +
+
+

方向根据止损与成交价自动判断;风险比例依赖系统设置中的实盘资金。

+
+
+ +
+

实时持仓

+
+ {% if not positions %} +
暂无持仓,左侧录入后显示
+ {% endif %} +
+
+
+{% endblock %} +{% block extra_js %} + +{% endblock %} diff --git a/templates/recommend.html b/templates/recommend.html index 3597a61..9a97e4f 100644 --- a/templates/recommend.html +++ b/templates/recommend.html @@ -1,32 +1,33 @@ -{% extends "base.html" %} -{% block title %}品种推荐 - 国内期货监控系统{% endblock %} -{% block content %} -
-

品种推荐 · 按资金筛选

-

当前权益 {{ '%.2f'|format(capital) }} 元({{ trading_mode_label }})。 - 优先展示可开 1 手且 1% 风险规则下较友好的品种;灰色为保证金不足。

-
-
-
- - - - - - - - {% for r in rows %} - - - - - - - - - {% endfor %} - -
品种交易所参考价1手保证金建议最低资金状态
{{ r.name }} {{ r.ths }}{{ r.exchange }}{% if r.price %}{{ r.price }}{% else %}—{% endif %}{% if r.margin_one_lot %}{{ r.margin_one_lot }}{% else %}—{% endif %}{% if r.min_capital_one_lot %}{{ r.min_capital_one_lot }}{% else %}—{% endif %}{{ r.status_label }}
-
-
-{% endblock %} +{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #} +{% extends "base.html" %} +{% block title %}可开仓品种 - 国内期货监控系统{% endblock %} +{% block content %} +
+

可开仓品种 · 按资金筛选

+

当前权益 {{ '%.2f'|format(capital) }} 元({{ trading_mode_label }})。 + 优先展示可开 1 手且 1% 风险规则下较友好的品种;灰色为保证金不足。

+
+
+
+ + + + + + + + {% for r in rows %} + + + + + + + + + {% endfor %} + +
品种交易所参考价1手保证金建议最低资金状态
{{ r.name }} {{ r.ths }}{{ r.exchange }}{% if r.price %}{{ r.price }}{% else %}—{% endif %}{% if r.margin_one_lot %}{{ r.margin_one_lot }}{% else %}—{% endif %}{% if r.min_capital_one_lot %}{{ r.min_capital_one_lot }}{% else %}—{% endif %}{{ r.status_label }}
+
+
+{% endblock %} diff --git a/templates/records.html b/templates/records.html index f047a52..d0d98db 100644 --- a/templates/records.html +++ b/templates/records.html @@ -1,3 +1,4 @@ +{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #} {% extends "base.html" %} {% block title %}交易记录与复盘 - 国内期货监控系统{% endblock %} {% block content %} diff --git a/templates/settings.html b/templates/settings.html index 883a8f1..ed6bafc 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -1,406 +1,407 @@ -{% extends "base.html" %} -{% block title %}系统设置 - 国内期货监控系统{% endblock %} -{% block extra_css %} - -{% endblock %} -{% block content %} -
- -
-
-

导航显示

-
- -

关闭后顶栏隐藏对应入口,直接访问 URL 也会跳转回下单监控。

-
- {% for key, label in nav_toggles.items() %} - - {% endfor %} -
- -
-
- -
-

交易模式

-
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -

- 保证金上限用于开仓校验与品种最大手数估算(默认 30%)。移动保本:达 1R 后止损移至开仓价 ± N 跳。 - 挂单超时:限价开仓未成交时,超过设定分钟数自动向柜台撤单(1~60 分钟)。CTP 账号与前置在下方「CTP 连接」中配置。 -

-
-
-
- -
-

CTP 连接

-
-

- 投资者代码、密码、前置地址在此维护(优先于 .env)。保存后将自动断开并用新地址重连 CTP。 - {% if ctp_status.connected %} - 已连接 - {% elif ctp_status.connecting %} - 连接中 - {% elif ctp_status.last_error %} - {{ ctp_status.last_error }} - {% endif %} -

- -
- - -
-
- -
-
-
- - -
-
- - -

- 与快期相同密码,保存前须在此手打;留空则不改。下方「修改密码」是网页登录密码,不是 SimNow。 -

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- -
- -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
-
- - -

- 官方第一套:180.168.146.187:10201/10211; - 第二套(云服务器常用):182.254.243.31:30001/30011; - 7×24:182.254.243.31:40001/40011(部分账号在 40001 会报「不合法登录」,与快期前置保持一致)。 - 详见 docs/SIMNOW.md。 -

-
-
-
- -
-
-

行情说明

-
-

- 当前行情源:{{ quote_label }}
- CTP 已连接时使用柜台行情;未连接时回退新浪接口。
- 合约代码按同花顺格式(如 ag2608、IF2606)。 -

-
-
- -
-

企业微信推送

-
- -
- - -
- -

在企业微信群添加机器人后,粘贴 Webhook 地址保存。

-
-
-
- -
-
-

修改密码

-
- -
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-
- -
-

使用提示

-
    -
  • 下单监控:连接 CTP 后下单、看持仓与品种推荐
  • -
  • 策略交易:趋势回调自动补仓;顺势加仓需先开仓
  • -
  • 手续费:默认 CTP 柜台费率,连接后点同步
  • -
  • 手机端:浏览器菜单可「添加到主屏幕」安装 App
  • -
-
-
- -
-{% endblock %} -{% block extra_js %} - -{% endblock %} +{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #} +{% extends "base.html" %} +{% block title %}系统设置 - 国内期货监控系统{% endblock %} +{% block extra_css %} + +{% endblock %} +{% block content %} +
+ +
+
+

导航显示

+
+ +

关闭后顶栏隐藏对应入口,直接访问 URL 也会跳转回下单监控。

+
+ {% for key, label in nav_toggles.items() %} + + {% endfor %} +
+ +
+
+ +
+

交易模式

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

+ 保证金上限用于开仓校验与品种最大手数估算(默认 30%)。移动保本:达 1R 后止损移至开仓价 ± N 跳。 + 挂单超时:限价开仓未成交时,超过设定分钟数自动向柜台撤单(1~60 分钟)。CTP 账号与前置在下方「CTP 连接」中配置。 +

+
+
+
+ +
+

CTP 连接

+
+

+ 投资者代码、密码、前置地址在此维护(优先于 .env)。保存后将自动断开并用新地址重连 CTP。 + {% if ctp_status.connected %} + 已连接 + {% elif ctp_status.connecting %} + 连接中 + {% elif ctp_status.last_error %} + {{ ctp_status.last_error }} + {% endif %} +

+ +
+ + +
+
+ +
+
+
+ + +
+
+ + +

+ 与快期相同密码,保存前须在此手打;留空则不改。下方「修改密码」是网页登录密码,不是 SimNow。 +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ + +

+ 官方第一套:180.168.146.187:10201/10211; + 第二套(云服务器常用):182.254.243.31:30001/30011; + 7×24:182.254.243.31:40001/40011(部分账号在 40001 会报「不合法登录」,与快期前置保持一致)。 + 详见 docs/SIMNOW.md。 +

+
+
+
+ +
+
+

行情说明

+
+

+ 当前行情源:{{ quote_label }}
+ CTP 已连接时使用柜台行情;未连接时回退新浪接口。
+ 合约代码按同花顺格式(如 ag2608、IF2606)。 +

+
+
+ +
+

企业微信推送

+
+ +
+ + +
+ +

在企业微信群添加机器人后,粘贴 Webhook 地址保存。

+
+
+
+ +
+
+

修改密码

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+

使用提示

+
    +
  • 下单监控:连接 CTP 后下单、看持仓与可开仓品种
  • +
  • 策略交易:趋势回调自动补仓;顺势加仓需先开仓
  • +
  • 手续费:默认 CTP 柜台费率,连接后点同步
  • +
  • 手机端:浏览器菜单可「添加到主屏幕」安装 App
  • +
+
+
+ +
+{% endblock %} +{% block extra_js %} + +{% endblock %} diff --git a/templates/stats.html b/templates/stats.html index 821cda8..3368f1b 100644 --- a/templates/stats.html +++ b/templates/stats.html @@ -1,76 +1,77 @@ -{% extends "base.html" %} -{% block title %}统计分析 - 国内期货监控系统{% endblock %} -{% block content %} - -
-
- 正在加载统计… -
-
-
总交易次数
-
-
胜率
-
-
平均盈利
-
-
平均亏损
-
-
盈亏比
-
-
连续亏损次数
-
-
最大回撤
-
-
最大亏损金额
-
-
最大亏损占比
-
-
最大盈利金额
-
-
最大盈利占比
-
-
累计手续费
-
-
情绪单数量
-
-
情绪单占比
-
-
-
- -
-
-

分项统计

-
- - -
-
-
- - - - - -
加载中…
-
-
- - -{% endblock %} - -{% block extra_js %} - -{% endblock %} +{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #} +{% extends "base.html" %} +{% block title %}统计分析 - 国内期货监控系统{% endblock %} +{% block content %} + +
+
+ 正在加载统计… +
+
+
总交易次数
-
+
胜率
-
+
平均盈利
-
+
平均亏损
-
+
盈亏比
-
+
连续亏损次数
-
+
最大回撤
-
+
最大亏损金额
-
+
最大亏损占比
-
+
最大盈利金额
-
+
最大盈利占比
-
+
累计手续费
-
+
情绪单数量
-
+
情绪单占比
-
+
+
+ +
+
+

分项统计

+
+ + +
+
+
+ + + + + +
加载中…
+
+
+ + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/strategy.html b/templates/strategy.html index 5ab9745..af0ff27 100644 --- a/templates/strategy.html +++ b/templates/strategy.html @@ -1,101 +1,102 @@ -{% extends "base.html" %} -{% block title %}策略交易 - 国内期货监控系统{% endblock %} -{% block extra_css %} - -{% endblock %} -{% block content %} -
-
-
-

趋势回调

-
- {% if active_trend %} -

运行中 #{{ active_trend.id }} · {{ active_trend.symbol }} · {{ '做多' if active_trend.direction == 'long' else '做空' }}

-

已开 {{ active_trend.lots_open or 0 }} / {{ active_trend.target_lots }} 手 · 止损 {{ active_trend.stop_loss }} · 止盈 {{ active_trend.take_profit }}

-
- - -
-

后台按档位自动补仓,触及止盈或手动结束。

- {% else %} -

设置止损/补仓边界/止盈 → 预览 → 确认执行首仓;后续自动分档加仓。

-
-
-
- -
-
- -
-
- - - -
-
- - -
-
- - - {% endif %} -
-
- -
-

顺势加仓(滚仓)

-
-

在已有持仓上扩大仓位,统一抬高止损;最多 3 腿,止盈锁定首仓。

- {% if roll_groups %} - {% for g in roll_groups %} -
- 运行中 · 监控 #{{ g.order_monitor_id }} · {{ g.leg_count or 1 }} 腿 · 止损 {{ g.current_stop_loss }} -
- {% endfor %} - {% endif %} - {% if monitors %} -
-
- - -
-
- - -
-
- - -
- - -
- {% else %} -

暂无可用持仓监控

-
    -
  1. 打开 持仓监控,连接 CTP
  2. -
  3. 在「期货下单」填写品种、止损/止盈并开仓
  4. -
  5. 开仓成功后会生成本页可选的监控记录,即可滚仓
  6. -
- {% endif %} -
-
-
-

策略交易记录 →

-
-{% endblock %} -{% block extra_js %} - -{% endblock %} +{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #} +{% extends "base.html" %} +{% block title %}策略交易 - 国内期货监控系统{% endblock %} +{% block extra_css %} + +{% endblock %} +{% block content %} +
+
+
+

趋势回调

+
+ {% if active_trend %} +

运行中 #{{ active_trend.id }} · {{ active_trend.symbol }} · {{ '做多' if active_trend.direction == 'long' else '做空' }}

+

已开 {{ active_trend.lots_open or 0 }} / {{ active_trend.target_lots }} 手 · 止损 {{ active_trend.stop_loss }} · 止盈 {{ active_trend.take_profit }}

+
+ + +
+

后台按档位自动补仓,触及止盈或手动结束。

+ {% else %} +

设置止损/补仓边界/止盈 → 预览 → 确认执行首仓;后续自动分档加仓。

+
+
+
+ +
+
+ +
+
+ + + +
+
+ + +
+
+ + + {% endif %} +
+
+ +
+

顺势加仓(滚仓)

+
+

在已有持仓上扩大仓位,统一抬高止损;最多 3 腿,止盈锁定首仓。

+ {% if roll_groups %} + {% for g in roll_groups %} +
+ 运行中 · 监控 #{{ g.order_monitor_id }} · {{ g.leg_count or 1 }} 腿 · 止损 {{ g.current_stop_loss }} +
+ {% endfor %} + {% endif %} + {% if monitors %} +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ {% else %} +

暂无可用持仓监控

+
    +
  1. 打开 持仓监控,连接 CTP
  2. +
  3. 在「期货下单」填写品种、止损/止盈并开仓
  4. +
  5. 开仓成功后会生成本页可选的监控记录,即可滚仓
  6. +
+ {% endif %} +
+
+
+

策略交易记录 →

+
+{% endblock %} +{% block extra_js %} + +{% endblock %} diff --git a/templates/strategy_records.html b/templates/strategy_records.html index 79351c6..c0273b6 100644 --- a/templates/strategy_records.html +++ b/templates/strategy_records.html @@ -1,22 +1,23 @@ -{% extends "base.html" %} -{% block title %}策略记录 - 国内期货监控系统{% endblock %} -{% block content %} -
-
-

趋势回调

- {% if trend_rows %} -
    {% for r in trend_rows %} -
  • {{ r.symbol }} {{ r.result_label }} · {{ r.closed_at or r.created_at }}
  • - {% endfor %}
- {% else %}

暂无记录

{% endif %} -
-
-

顺势加仓

- {% if roll_rows %} -
    {% for r in roll_rows %} -
  • {{ r.symbol }} {{ r.result_label }} · {{ r.closed_at or r.created_at }}
  • - {% endfor %}
- {% else %}

暂无记录

{% endif %} -
-
-{% endblock %} +{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #} +{% extends "base.html" %} +{% block title %}策略记录 - 国内期货监控系统{% endblock %} +{% block content %} +
+
+

趋势回调

+ {% if trend_rows %} +
    {% for r in trend_rows %} +
  • {{ r.symbol }} {{ r.result_label }} · {{ r.closed_at or r.created_at }}
  • + {% endfor %}
+ {% else %}

暂无记录

{% endif %} +
+
+

顺势加仓

+ {% if roll_rows %} +
    {% for r in roll_rows %} +
  • {{ r.symbol }} {{ r.result_label }} · {{ r.closed_at or r.created_at }}
  • + {% endfor %}
+ {% else %}

暂无记录

{% endif %} +
+
+{% endblock %} diff --git a/templates/trade.html b/templates/trade.html index 48db1d0..da51b6e 100644 --- a/templates/trade.html +++ b/templates/trade.html @@ -1,210 +1,211 @@ -{% extends "base.html" %} -{% block title %}下单监控 - 国内期货监控系统{% endblock %} -{% block extra_css %} - -{% endblock %} -{% block content %} -
-
-
- {{ trading_mode_label }} - - {% if ctp_status.connected %}CTP 已连接{% else %}CTP 未连接{% endif %} - - {{ risk_status.status_label }} - 权益 {{ '%.2f'|format(capital) }} - {% if ctp_account.available is defined and ctp_status.connected %} - 可用 {{ '%.2f'|format(ctp_account.available) }} - {% endif %} -
-
- - 断线自动重连 · 开盘前 30 分钟自动连接 -
-
- -
-
-

期货下单

-
-
-
- 计仓 - {{ sizing_mode_label }} - {% if sizing_mode == 'fixed' %} - · {{ fixed_lots }} 手 - {% elif sizing_mode in ('amount', 'risk') %} - · {{ '%.0f'|format(fixed_amount) }} 元 - {% endif %} -
-
- -
-
-
- - - -
-
-
-
- - -
-
- - - - -
-
- -
-
- -
- - -
- - -
-
- - -
-
- - -
-
-
- -
- - - - - -
- - -
-
- -
-

当前持仓

-

开仓委托先显示「挂单中」,柜台成交后写入监控;超过 {{ pending_order_timeout_min }} 分钟未成交自动撤单,可手动撤单。

-
-
加载本地持仓…
-
-
-
- -
-

品种推荐

-
-

最大手数 = floor(权益 × 保证金上限 {{ max_margin_pct }}% ÷ 1手保证金);当前权益 {{ '%.2f'|format(capital) }} 元。 - {% if sizing_mode == 'fixed' %}仅显示最大手数 ≥ {{ fixed_lots }} 手的品种。{% endif %} - 保证金优先读取 CTP 柜台合约信息。 - {% if recommend_updated_at %}每日后台更新 · 最近 {{ recommend_updated_at }}{% else %}等待今日后台刷新…{% endif %} -

-

走势:近一周日线,近3日重叠≥70%为震荡;跳空=今日开盘 vs 昨日收盘。成交量为昨日成交手数,成交额=成交量×昨收×合约乘数。支持按走势/跳空/成交量/振幅排序,可按行业筛选。

-
-
- - - - - -
-
- - - - - - - - - - - {% if recommend_rows %} - {% for r in recommend_rows %} - - - - - - - - - - - - - - - - - - - {% endfor %} - {% else %} - - {% endif %} - -
品种交易所行业走势是否跳空参考价昨日收盘今日开盘昨日涨跌昨日振幅成交量(手)成交额1手保证金1手手续费最大手数状态
{{ r.name }} {{ r.main_code or r.ths }}{{ r.exchange }}{{ r.category or '—' }} - {% if r.trend_label and r.trend_label != '—' %} - - {% if r.trend_transition %}★ {% endif %}{{ r.trend_label }} - - {% else %}—{% endif %} - - {% if r.gap_label and r.gap_label != '—' %} - {{ r.gap_label }} - {% else %}—{% endif %} - {% if r.price %}{{ r.price }}{% else %}—{% endif %}{% if r.prev_close is not none %}{{ r.prev_close }}{% else %}—{% endif %}{% if r.today_open is not none %}{{ r.today_open }}{% else %}—{% endif %} - {% if r.yesterday_change is not none %} - - {{ '%+.4f'|format(r.yesterday_change) }}{% if r.yesterday_change_pct is not none %} ({{ '%+.2f'|format(r.yesterday_change_pct) }}%){% endif %} - - {% else %}—{% endif %} - {% if r.yesterday_amplitude_pct is not none %}{{ '%.2f'|format(r.yesterday_amplitude_pct) }}%{% else %}—{% endif %}{% if r.volume is not none %}{{ r.volume }}{% else %}—{% endif %}{% if r.turnover is not none %}{{ '%.0f'|format(r.turnover) }}{% else %}—{% endif %}{% if r.margin_one_lot %}{{ r.margin_one_lot }}{% if r.margin_source == 'ctp' %} (柜台){% endif %}{% else %}—{% endif %}{% if r.open_fee_one_lot is defined and r.open_fee_one_lot is not none %}{{ r.open_fee_one_lot }}{% else %}—{% endif %}{% if r.max_lots is not none and r.max_lots > 0 %}{{ r.max_lots }}{% else %}—{% endif %}{{ r.status_label }}
等待今日后台刷新推荐…
-
-
-
-
-{% endblock %} -{% block extra_js %} - - -{% endblock %} +{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #} +{% extends "base.html" %} +{% block title %}下单监控 - 国内期货监控系统{% endblock %} +{% block extra_css %} + +{% endblock %} +{% block content %} +
+
+
+ {{ trading_mode_label }} + + {% if ctp_status.connected %}CTP 已连接{% else %}CTP 未连接{% endif %} + + {{ risk_status.status_label }} + 权益 {{ '%.2f'|format(capital) }} + {% if ctp_account.available is defined and ctp_status.connected %} + 可用 {{ '%.2f'|format(ctp_account.available) }} + {% endif %} +
+
+ + 断线自动重连 · 开盘前 30 分钟自动连接 +
+
+ +
+
+

期货下单

+
+
+
+ 计仓 + {{ sizing_mode_label }} + {% if sizing_mode == 'fixed' %} + · {{ fixed_lots }} 手 + {% elif sizing_mode in ('amount', 'risk') %} + · {{ '%.0f'|format(fixed_amount) }} 元 + {% endif %} +
+
+ +
+
+
+ + + +
+
+
+
+ + +
+
+ + + + +
+
+ +
+
+ +
+ + +
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + + + + +
+ + +
+
+ +
+

当前持仓

+

开仓委托先显示「挂单中」,柜台成交后写入监控;超过 {{ pending_order_timeout_min }} 分钟未成交自动撤单,可手动撤单。

+
+
加载本地持仓…
+
+
+
+ +
+

可开仓品种

+
+

最大手数 = floor(权益 × 保证金上限 {{ max_margin_pct }}% ÷ 1手保证金);当前权益 {{ '%.2f'|format(capital) }} 元。 + {% if sizing_mode == 'fixed' %}仅显示最大手数 ≥ {{ fixed_lots }} 手的品种。{% endif %} + 保证金优先读取 CTP 柜台合约信息。 + {% if recommend_updated_at %}每日后台更新 · 最近 {{ recommend_updated_at }}{% else %}等待今日后台刷新…{% endif %} +

+

走势:近一周日线,近3日重叠≥70%为震荡;跳空=今日开盘 vs 昨日收盘。成交量为昨日成交手数,成交额=成交量×昨收×合约乘数。支持按走势/跳空/成交量/振幅排序,可按行业筛选。

+
+
+ + + + + +
+
+ + + + + + + + + + + {% if recommend_rows %} + {% for r in recommend_rows %} + + + + + + + + + + + + + + + + + + + {% endfor %} + {% else %} + + {% endif %} + +
品种交易所行业走势是否跳空参考价昨日收盘今日开盘昨日涨跌昨日振幅成交量(手)成交额1手保证金1手手续费最大手数状态
{{ r.name }} {{ r.main_code or r.ths }}{{ r.exchange }}{{ r.category or '—' }} + {% if r.trend_label and r.trend_label != '—' %} + + {% if r.trend_transition %}★ {% endif %}{{ r.trend_label }} + + {% else %}—{% endif %} + + {% if r.gap_label and r.gap_label != '—' %} + {{ r.gap_label }} + {% else %}—{% endif %} + {% if r.price %}{{ r.price }}{% else %}—{% endif %}{% if r.prev_close is not none %}{{ r.prev_close }}{% else %}—{% endif %}{% if r.today_open is not none %}{{ r.today_open }}{% else %}—{% endif %} + {% if r.yesterday_change is not none %} + + {{ '%+.4f'|format(r.yesterday_change) }}{% if r.yesterday_change_pct is not none %} ({{ '%+.2f'|format(r.yesterday_change_pct) }}%){% endif %} + + {% else %}—{% endif %} + {% if r.yesterday_amplitude_pct is not none %}{{ '%.2f'|format(r.yesterday_amplitude_pct) }}%{% else %}—{% endif %}{% if r.volume is not none %}{{ r.volume }}{% else %}—{% endif %}{% if r.turnover is not none %}{{ '%.0f'|format(r.turnover) }}{% else %}—{% endif %}{% if r.margin_one_lot %}{{ r.margin_one_lot }}{% if r.margin_source == 'ctp' %} (柜台){% endif %}{% else %}—{% endif %}{% if r.open_fee_one_lot is defined and r.open_fee_one_lot is not none %}{{ r.open_fee_one_lot }}{% else %}—{% endif %}{% if r.max_lots is not none and r.max_lots > 0 %}{{ r.max_lots }}{% else %}—{% endif %}{{ r.status_label }}
等待今日后台刷新推荐…
+
+
+
+
+{% endblock %} +{% block extra_js %} + + +{% endblock %} diff --git a/trade_log_lib.py b/trade_log_lib.py index 6980fc9..0fb5957 100644 --- a/trade_log_lib.py +++ b/trade_log_lib.py @@ -1,69 +1,74 @@ -"""交易记录:字段补全、资金曲线数据。""" -from __future__ import annotations - -from typing import Any - - -TRADE_LOG_EXTRA_COLUMNS = ( - "ALTER TABLE trade_logs ADD COLUMN margin_pct REAL", - "ALTER TABLE trade_logs ADD COLUMN equity_after REAL", - "ALTER TABLE trade_logs ADD COLUMN source TEXT DEFAULT 'local'", - "ALTER TABLE trade_logs ADD COLUMN ctp_trade_key TEXT", -) - - -def ensure_trade_log_columns(conn) -> None: - for sql in TRADE_LOG_EXTRA_COLUMNS: - try: - conn.execute(sql) - except Exception: - pass - - -def calc_equity_after(capital: float, pnl_net: float) -> float | None: - cap = float(capital or 0) - if cap <= 0: - return None - return round(cap + float(pnl_net or 0), 2) - - -def enrich_trades_for_records( - trades: list[dict[str, Any]], - *, - initial_capital: float = 0.0, -) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - """表格仍按 id 降序;资金曲线按平仓时间升序用最新资金绘制。""" - rows = [dict(t) for t in trades] - chrono = sorted( - rows, - key=lambda t: ((t.get("close_time") or ""), int(t.get("id") or 0)), - ) - running = float(initial_capital or 0) - curve: list[dict[str, Any]] = [] - - for t in chrono: - pnl_net = float(t.get("pnl_net") or 0) - eq = t.get("equity_after") - if eq is None: - if running > 0: - eq = round(running + pnl_net, 2) - else: - eq = None - t["equity_after"] = eq - if eq is not None: - running = float(eq) - - if t.get("margin_pct") is None: - margin = float(t.get("margin") or 0) - cap_before = float(eq or 0) - pnl_net if eq is not None else 0.0 - if margin > 0 and cap_before > 0: - t["margin_pct"] = round(margin / cap_before * 100, 2) - - if eq is not None: - curve.append({ - "time": (t.get("close_time") or "")[:19], - "value": float(eq), - "id": int(t.get("id") or 0), - }) - - return rows, curve +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""交易记录:字段补全、资金曲线数据。""" +from __future__ import annotations + +from typing import Any + + +TRADE_LOG_EXTRA_COLUMNS = ( + "ALTER TABLE trade_logs ADD COLUMN margin_pct REAL", + "ALTER TABLE trade_logs ADD COLUMN equity_after REAL", + "ALTER TABLE trade_logs ADD COLUMN source TEXT DEFAULT 'local'", + "ALTER TABLE trade_logs ADD COLUMN ctp_trade_key TEXT", +) + + +def ensure_trade_log_columns(conn) -> None: + for sql in TRADE_LOG_EXTRA_COLUMNS: + try: + conn.execute(sql) + except Exception: + pass + + +def calc_equity_after(capital: float, pnl_net: float) -> float | None: + cap = float(capital or 0) + if cap <= 0: + return None + return round(cap + float(pnl_net or 0), 2) + + +def enrich_trades_for_records( + trades: list[dict[str, Any]], + *, + initial_capital: float = 0.0, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """表格仍按 id 降序;资金曲线按平仓时间升序用最新资金绘制。""" + rows = [dict(t) for t in trades] + chrono = sorted( + rows, + key=lambda t: ((t.get("close_time") or ""), int(t.get("id") or 0)), + ) + running = float(initial_capital or 0) + curve: list[dict[str, Any]] = [] + + for t in chrono: + pnl_net = float(t.get("pnl_net") or 0) + eq = t.get("equity_after") + if eq is None: + if running > 0: + eq = round(running + pnl_net, 2) + else: + eq = None + t["equity_after"] = eq + if eq is not None: + running = float(eq) + + if t.get("margin_pct") is None: + margin = float(t.get("margin") or 0) + cap_before = float(eq or 0) - pnl_net if eq is not None else 0.0 + if margin > 0 and cap_before > 0: + t["margin_pct"] = round(margin / cap_before * 100, 2) + + if eq is not None: + curve.append({ + "time": (t.get("close_time") or "")[:19], + "value": float(eq), + "id": int(t.get("id") or 0), + }) + + return rows, curve diff --git a/trading_context.py b/trading_context.py index e687069..88f2b0d 100644 --- a/trading_context.py +++ b/trading_context.py @@ -1,90 +1,95 @@ -"""交易上下文:设置读取、资金、模式。""" -from __future__ import annotations - -from typing import Callable, Optional - -TRADING_MODE_SIM = "simulation" # SimNow CTP -TRADING_MODE_LIVE = "live" # 期货公司 CTP - - -def get_trading_mode(get_setting: Callable[[str, str], str]) -> str: - m = (get_setting("trading_mode", TRADING_MODE_SIM) or TRADING_MODE_SIM).strip().lower() - return m if m in (TRADING_MODE_SIM, TRADING_MODE_LIVE) else TRADING_MODE_SIM - - -def get_sizing_mode(get_setting: Callable[[str, str], str]) -> str: - from position_sizing import normalize_sizing_mode - return normalize_sizing_mode(get_setting("position_sizing_mode", "fixed")) - - -def get_fixed_lots(get_setting: Callable[[str, str], str]) -> int: - try: - return max(1, int(float(get_setting("fixed_lots", "1") or 1))) - except (TypeError, ValueError): - return 1 - - -def get_fixed_amount(get_setting: Callable[[str, str], str]) -> float: - try: - return max(1.0, float(get_setting("fixed_amount", "5000") or 5000)) - except (TypeError, ValueError): - return 5000.0 - - -def get_risk_percent(get_setting: Callable[[str, str], str]) -> float: - try: - return max(0.1, float(get_setting("risk_percent", "1") or 1)) - except (TypeError, ValueError): - return 1.0 - - -def get_max_margin_pct(get_setting: Callable[[str, str], str]) -> float: - """单笔/总仓位保证金占权益上限(%),默认 30。""" - try: - return max(1.0, min(100.0, float(get_setting("max_margin_pct", "30") or 30))) - except (TypeError, ValueError): - return 30.0 - - -def get_trailing_be_tick_buffer(get_setting: Callable[[str, str], str]) -> int: - """移动保本:止损移至开仓价 ± N 个最小变动价位(默认 2)。""" - try: - return max(1, min(20, int(float(get_setting("trailing_be_tick_buffer", "2") or 2)))) - except (TypeError, ValueError): - return 2 - - -def get_pending_order_timeout_min(get_setting: Callable[[str, str], str]) -> int: - """开仓限价委托未成交自动撤单时间(分钟),默认 5。""" - try: - return max(1, min(60, int(float(get_setting("pending_order_timeout_min", "5") or 5)))) - except (TypeError, ValueError): - return 5 - - -def get_pending_order_timeout_sec(get_setting: Callable[[str, str], str]) -> int: - return get_pending_order_timeout_min(get_setting) * 60 - - -def get_account_capital(conn, get_setting: Callable[[str, str], str]) -> float: - """优先 SimNow/期货公司 CTP 权益;未连接时用设置中的参考资金。""" - del conn - mode = get_trading_mode(get_setting) - try: - from vnpy_bridge import ctp_status, get_ctp_balance - - st = ctp_status(mode) - if st.get("connected"): - bal = get_ctp_balance(mode) - if bal and bal > 0: - return float(bal) - except Exception: - pass - try: - return float(get_setting("live_capital", "0") or 0) - except (TypeError, ValueError): - return 0.0 - - -def trading_mode_label(get_setting: Callable[[str, str], str]) -> str: - return "SimNow" if get_trading_mode(get_setting) == TRADING_MODE_SIM else "期货公司实盘" +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""交易上下文:设置读取、资金、模式。""" +from __future__ import annotations + +from typing import Callable, Optional + +TRADING_MODE_SIM = "simulation" # SimNow CTP +TRADING_MODE_LIVE = "live" # 期货公司 CTP + + +def get_trading_mode(get_setting: Callable[[str, str], str]) -> str: + m = (get_setting("trading_mode", TRADING_MODE_SIM) or TRADING_MODE_SIM).strip().lower() + return m if m in (TRADING_MODE_SIM, TRADING_MODE_LIVE) else TRADING_MODE_SIM + + +def get_sizing_mode(get_setting: Callable[[str, str], str]) -> str: + from position_sizing import normalize_sizing_mode + return normalize_sizing_mode(get_setting("position_sizing_mode", "fixed")) + + +def get_fixed_lots(get_setting: Callable[[str, str], str]) -> int: + try: + return max(1, int(float(get_setting("fixed_lots", "1") or 1))) + except (TypeError, ValueError): + return 1 + + +def get_fixed_amount(get_setting: Callable[[str, str], str]) -> float: + try: + return max(1.0, float(get_setting("fixed_amount", "5000") or 5000)) + except (TypeError, ValueError): + return 5000.0 + + +def get_risk_percent(get_setting: Callable[[str, str], str]) -> float: + try: + return max(0.1, float(get_setting("risk_percent", "1") or 1)) + except (TypeError, ValueError): + return 1.0 + + +def get_max_margin_pct(get_setting: Callable[[str, str], str]) -> float: + """单笔/总仓位保证金占权益上限(%),默认 30。""" + try: + return max(1.0, min(100.0, float(get_setting("max_margin_pct", "30") or 30))) + except (TypeError, ValueError): + return 30.0 + + +def get_trailing_be_tick_buffer(get_setting: Callable[[str, str], str]) -> int: + """移动保本:止损移至开仓价 ± N 个最小变动价位(默认 2)。""" + try: + return max(1, min(20, int(float(get_setting("trailing_be_tick_buffer", "2") or 2)))) + except (TypeError, ValueError): + return 2 + + +def get_pending_order_timeout_min(get_setting: Callable[[str, str], str]) -> int: + """开仓限价委托未成交自动撤单时间(分钟),默认 5。""" + try: + return max(1, min(60, int(float(get_setting("pending_order_timeout_min", "5") or 5)))) + except (TypeError, ValueError): + return 5 + + +def get_pending_order_timeout_sec(get_setting: Callable[[str, str], str]) -> int: + return get_pending_order_timeout_min(get_setting) * 60 + + +def get_account_capital(conn, get_setting: Callable[[str, str], str]) -> float: + """优先 SimNow/期货公司 CTP 权益;未连接时用设置中的参考资金。""" + del conn + mode = get_trading_mode(get_setting) + try: + from vnpy_bridge import ctp_status, get_ctp_balance + + st = ctp_status(mode) + if st.get("connected"): + bal = get_ctp_balance(mode) + if bal and bal > 0: + return float(bal) + except Exception: + pass + try: + return float(get_setting("live_capital", "0") or 0) + except (TypeError, ValueError): + return 0.0 + + +def trading_mode_label(get_setting: Callable[[str, str], str]) -> str: + return "SimNow" if get_trading_mode(get_setting) == TRADING_MODE_SIM else "期货公司实盘" diff --git a/vnpy_bridge.py b/vnpy_bridge.py index 4d0ca8d..f26ae84 100644 --- a/vnpy_bridge.py +++ b/vnpy_bridge.py @@ -1,1466 +1,1471 @@ -"""CTP 执行层:模拟盘 → SimNow;实盘 → 期货公司(vnpy_ctp)。""" -from __future__ import annotations - -import logging -import os -import threading -import time -from collections import deque -from typing import Any, Callable, Optional - -from locale_fix import ensure_process_locale - -ensure_process_locale() - -from ctp_settings import live_setting_dict, simnow_setting_dict -from ctp_symbol import ths_to_vnpy_symbol, to_vnpy_exchange -from contract_specs import get_contract_spec - -logger = logging.getLogger(__name__) - -GATEWAY_NAME = "CTP" - -CONNECT_WAIT_SEC = 60 -CONNECT_POLL_INTERVAL_SEC = 0.5 -LOGIN_BAN_COOLDOWN_SEC = 45 * 60 -LOGIN_FAIL_COOLDOWN_SEC = 5 * 60 -CTP_COOLDOWN_UNTIL_KEY = "ctp_login_cooldown_until" -CTP_LAST_ERROR_KEY = "ctp_last_error" - - -def _persist_login_cooldown(seconds: float) -> None: - from fee_specs import get_setting, set_setting - - new_until = time.time() + max(0.0, seconds) - try: - old = float(get_setting(CTP_COOLDOWN_UNTIL_KEY, "0") or 0) - except (TypeError, ValueError): - old = 0.0 - if new_until > old: - set_setting(CTP_COOLDOWN_UNTIL_KEY, str(new_until)) - - -def _persisted_login_cooldown_remaining() -> int: - from fee_specs import get_setting - - try: - until = float(get_setting(CTP_COOLDOWN_UNTIL_KEY, "0") or 0) - return max(0, int(until - time.time())) - except (TypeError, ValueError): - return 0 - - -def _clear_persisted_login_cooldown() -> None: - from fee_specs import set_setting - - set_setting(CTP_COOLDOWN_UNTIL_KEY, "0") - - -def _persist_last_error(msg: str) -> None: - from fee_specs import set_setting - - set_setting(CTP_LAST_ERROR_KEY, (msg or "").strip()) - - -def _load_persisted_last_error() -> str: - from fee_specs import get_setting - - return (get_setting(CTP_LAST_ERROR_KEY, "") or "").strip() - -_position_refresh_callback: Optional[Callable[[], None]] = None - - -def set_position_refresh_callback(fn: Optional[Callable[[], None]]) -> None: - global _position_refresh_callback - _position_refresh_callback = fn - - -def _fire_position_refresh_callback() -> None: - fn = _position_refresh_callback - if not fn: - return - try: - threading.Thread(target=fn, daemon=True, name="ctp-position-refresh").start() - except Exception as exc: - logger.debug("position refresh callback: %s", exc) - -_bridge: Optional["CtpBridge"] = None -_bridge_lock = threading.Lock() -_ctp_td_lock = threading.RLock() -POSITION_QUERY_MIN_INTERVAL_SEC = 5.0 -TRADE_QUERY_MIN_INTERVAL_SEC = 10.0 - - -def _simnow_setting() -> dict[str, str]: - """SimNow 仿真前置(系统设置优先,.env 兜底)。""" - return simnow_setting_dict() - - -def _live_setting() -> dict[str, str]: - return live_setting_dict() - - -def _setting_for_mode(mode: str) -> dict[str, str]: - return _simnow_setting() if mode == "simulation" else _live_setting() - - -def _mode_label(mode: str) -> str: - return "SimNow" if mode == "simulation" else "期货公司实盘" - - -def _parse_tcp_address(address: str) -> tuple[str, int]: - raw = (address or "").strip() - if raw.startswith("tcp://"): - raw = raw[6:] - if ":" not in raw: - raise ValueError(f"无效 TCP 地址: {address}") - host, port_s = raw.rsplit(":", 1) - return host, int(port_s) - - -def probe_tcp_address(address: str, timeout: float = 5.0) -> tuple[bool, str]: - """探测 CTP 前置 TCP 是否可达。""" - import socket - - try: - host, port = _parse_tcp_address(address) - with socket.create_connection((host, port), timeout=timeout): - return True, "" - except Exception as exc: - return False, str(exc) - - -def _format_ctp_failure(ctp_logs: list[str], *, td_address: str = "") -> str: - """根据 CTP 网关日志拼出可读错误。""" - if td_address: - ok, err = probe_tcp_address(td_address, timeout=4.0) - if not ok: - return ( - f"SimNow 交易前置不可达:{td_address}({err})。" - "182.254.243.31 已停用,请改 .env 为官方前置 " - "tcp://180.168.146.187:10201 / 10211,并确认服务器能访问该地址。" - ) - text = "\n".join(ctp_logs) - if "连续登录失败" in text or "登录被禁止" in text or "代码:75" in text: - return ( - "CTP 登录被临时禁止:连续失败次数过多(错误码 75)。" - "请等待约 30~60 分钟后再试,先用快期确认投资者代码与密码正确,期间勿反复点「连接」。" - ) - if "4097" in text or "Decrypt handshake" in text or "shake hand" in text.lower(): - return ( - "CTP 握手失败(4097):vnpy_ctp 与 SimNow 前置加密不匹配。" - "请执行 pip install -U vnpy vnpy_ctp 后重启,并确认 .env 中 SIMNOW_ENV=实盘" - ) - if "不合法的登录" in text or "密码" in text or "账号" in text: - tail = ctp_logs[-1] if ctp_logs else "" - return f"CTP 登录被拒:{tail or '请检查投资者代码与密码(快期能否登录)'}" - if "连接断开" in text or "disconnect" in text.lower(): - tail = ctp_logs[-1] if ctp_logs else "" - return f"CTP 连接断开:{tail or '请检查前置地址与网络'}" - if ctp_logs: - return f"CTP 连接失败:{ctp_logs[-1]}" - return "CTP 连接超时:未收到柜台回报。请检查 SimNow 账号、前置地址、网络(nc 测端口),并用快期验证账号" - - -def round_to_tick(price: float, tick: float) -> float: - if tick <= 0: - return float(price) - steps = round(float(price) / tick) - return round(steps * tick, 10) - - -def _is_long_direction(direction_obj: Any) -> bool: - s = str(direction_obj or "") - return "LONG" in s.upper() or "多" in s - - -class CtpBridge: - def __init__(self) -> None: - self._engine = None - self._ee = None - self._connected_mode: Optional[str] = None - self._last_error: str = "" - self._connect_lock = threading.Lock() - self._connect_in_progress = False - self._login_cooldown_until: float = 0.0 - self._restore_persisted_state() - self._commission_waiters: dict[int, threading.Event] = {} - self._commission_lists: dict[int, list] = {} - self._commission_hooked = False - self._subscribed: set[str] = set() - self._last_position_query_ts: float = 0.0 - self._position_margins: dict[str, float] = {} - self._position_open_times: dict[str, str] = {} - self._margin_hooked = False - self._trade_hooked = False - self._trade_query_results: list[dict[str, Any]] = [] - self._trade_query_event = threading.Event() - self._last_trade_query_ts: float = 0.0 - self._last_connect_ok_ts: float = 0.0 - self._tick_hooked = False - self._bar_generators: dict[str, Any] = {} - self._bars_1m: dict[str, deque] = {} - self._init_engine() - - def _init_engine(self) -> None: - ensure_process_locale() - try: - from vnpy.event import EventEngine - from vnpy.trader.engine import MainEngine - from vnpy_ctp import CtpGateway - - self._ee = EventEngine() - self._engine = MainEngine(self._ee) - self._engine.add_gateway(CtpGateway) - except ImportError: - self._last_error = "未安装 vnpy / vnpy_ctp,请 pip install vnpy vnpy_ctp" - except Exception as exc: - self._last_error = str(exc) - - def available(self) -> bool: - return self._engine is not None - - @property - def last_error(self) -> str: - return self._last_error - - @property - def connected_mode(self) -> Optional[str]: - return self._connected_mode - - def connect_in_progress(self) -> bool: - return self._connect_in_progress - - def _restore_persisted_state(self) -> None: - err = _load_persisted_last_error() - if err: - self._last_error = err - db_remain = _persisted_login_cooldown_remaining() - if db_remain > 0: - self._login_cooldown_until = time.monotonic() + db_remain - - def login_cooldown_remaining(self) -> int: - """距允许再次登录的剩余秒数(内存 + 数据库,重启后仍有效)。""" - mem = max(0, int(self._login_cooldown_until - time.monotonic())) - return max(mem, _persisted_login_cooldown_remaining()) - - def _is_login_cooldown_active(self) -> bool: - return self.login_cooldown_remaining() > 0 - - def _set_login_cooldown(self, seconds: float) -> None: - until = time.monotonic() + max(0.0, seconds) - if until > self._login_cooldown_until: - self._login_cooldown_until = until - _persist_login_cooldown(seconds) - - def _clear_login_cooldown(self) -> None: - self._login_cooldown_until = 0.0 - _clear_persisted_login_cooldown() - - def _apply_login_failure_cooldown(self, ctp_logs: list[str]) -> None: - text = "\n".join(ctp_logs) - if "连续登录失败" in text or "登录被禁止" in text or "代码:75" in text: - self._set_login_cooldown(LOGIN_BAN_COOLDOWN_SEC) - elif any("登录失败" in m or "不合法的登录" in m for m in ctp_logs): - self._set_login_cooldown(LOGIN_FAIL_COOLDOWN_SEC) - - def _login_cooldown_message(self) -> str: - remain = self.login_cooldown_remaining() - return ( - f"CTP 登录冷却中,请 {remain // 60} 分 {remain % 60} 秒后再试" - f"(避免连续失败被 SimNow 封禁)" - ) - - def _close_gateway(self) -> None: - """关闭 CTP 网关,避免半连接状态下重连卡在「连接登录」。""" - if not self._engine: - return - try: - gw = self._engine.get_gateway(GATEWAY_NAME) - if gw: - gw.close() - except Exception as exc: - logger.debug("gateway close: %s", exc) - self._connected_mode = None - time.sleep(0.6) - - def _login_rejected(self, ctp_logs: list[str]) -> bool: - return any( - kw in m - for m in ctp_logs - for kw in ("登录失败", "不合法的登录", "登录被禁止", "连续登录失败") - ) - - def _wait_connected(self, mode: str, ctp_logs: list[str] | None = None) -> bool: - """等待账户回报或交易通道登录成功。""" - if not self._engine: - return False - logs = ctp_logs or [] - loops = max(1, int(CONNECT_WAIT_SEC / CONNECT_POLL_INTERVAL_SEC)) - for _ in range(loops): - if self._login_rejected(logs): - return False - try: - if self._engine.get_all_accounts(): - return True - except Exception: - pass - if self._td_logged_in(): - return True - time.sleep(CONNECT_POLL_INTERVAL_SEC) - return False - - def status(self, mode: str) -> dict[str, Any]: - if self._connected_mode == mode: - self.ping() - st = _setting_for_mode(mode) - missing = [k for k in ("用户名", "密码", "交易服务器") if not st.get(k)] - cooldown = self.login_cooldown_remaining() - connecting = bool(self._connect_in_progress and cooldown <= 0) - last_error = self._last_error or _load_persisted_last_error() - return { - "vnpy_installed": self.available(), - "connected": self._connected_mode == mode, - "connecting": connecting, - "connected_mode": self._connected_mode, - "mode_label": _mode_label(mode), - "missing_config": missing, - "last_error": last_error, - "login_cooldown_sec": cooldown, - "broker_id": st.get("经纪商代码", ""), - "td_address": st.get("交易服务器", ""), - } - - def connect(self, mode: str, *, force: bool = False) -> None: - if self._connect_in_progress: - raise RuntimeError("CTP 正在连接中,请稍候") - if self._is_login_cooldown_active() and not force: - msg = self._login_cooldown_message() - self._last_error = msg - raise RuntimeError(msg) - if not self._engine: - raise RuntimeError(self._last_error or "vnpy 引擎未初始化") - if self._connected_mode == mode and not force: - if self.ping(): - return - self._connected_mode = None - setting = _setting_for_mode(mode) - if not setting.get("用户名") or not setting.get("密码"): - raise ValueError( - f"{_mode_label(mode)}:请在 .env 配置 " - f"{'SIMNOW_USER / SIMNOW_PASSWORD' if mode == 'simulation' else 'CTP_LIVE_USER / CTP_LIVE_PASSWORD'}" - ) - if not setting.get("交易服务器"): - raise ValueError(f"{_mode_label(mode)}:未配置交易服务器地址") - - self._connect_in_progress = True - try: - with _ctp_td_lock: - with self._connect_lock: - if force and self._connected_mode: - self._close_gateway() - elif self._connected_mode and self._connected_mode != mode: - try: - self._engine.close() - except Exception: - pass - self._connected_mode = None - time.sleep(1) - elif not (self._connected_mode == mode and self.ping()): - self._close_gateway() - - ctp_logs: list[str] = [] - from vnpy.trader.event import EVENT_LOG - - def _on_log(event) -> None: - msg = getattr(event.data, "msg", "") or str(event.data) - if msg: - ctp_logs.append(str(msg)) - if len(ctp_logs) > 40: - ctp_logs.pop(0) - logger.info("CTP | %s", msg) - - self._ee.register(EVENT_LOG, _on_log) - try: - ensure_process_locale() - logger.info( - "CTP 连接 [%s] user=%s td=%s env=%s", - mode, - setting.get("用户名"), - setting.get("交易服务器"), - setting.get("柜台环境", "实盘"), - ) - td_addr = setting.get("交易服务器", "") - ok, err = probe_tcp_address(td_addr, timeout=5.0) - if not ok: - raise RuntimeError( - f"SimNow 交易前置不可达:{td_addr}({err})。" - "请更新 .env 中 SIMNOW_TD_ADDRESS 为官网最新地址," - "并在服务器执行 nc -zv 验证出网。" - ) - self._engine.connect(setting, GATEWAY_NAME) - if self._wait_connected(mode, ctp_logs): - self._connected_mode = mode - self._last_connect_ok_ts = time.time() - self._last_error = "" - _persist_last_error("") - self._clear_login_cooldown() - logger.info("CTP 已连接 [%s] td_login=%s accounts=%s", - mode, self._td_logged_in(), - len(self._engine.get_all_accounts() or [])) - self._schedule_fee_sync(mode) - _fire_position_refresh_callback() - return - finally: - self._ee.unregister(EVENT_LOG, _on_log) - - self._close_gateway() - self._apply_login_failure_cooldown(ctp_logs) - hint = _format_ctp_failure(ctp_logs, td_address=setting.get("交易服务器", "")) - self._last_error = hint - _persist_last_error(hint) - logger.warning("CTP 连接失败 [%s]: %s | logs=%s", mode, hint, ctp_logs[-5:]) - raise RuntimeError(hint) - finally: - self._connect_in_progress = False - - def start_connect_async(self, mode: str, *, force: bool = False) -> dict[str, Any]: - """后台连接,不阻塞 HTTP 请求。""" - if self._connected_mode == mode and self.ping() and not force: - return {"started": False, "connecting": False, "connected": True} - if self._connect_in_progress: - return {"started": False, "connecting": True, "connected": False} - if self._is_login_cooldown_active() and not force: - self._last_error = self._login_cooldown_message() - return { - "started": False, - "connecting": False, - "connected": False, - "cooldown": True, - } - - def _run() -> None: - try: - self.connect(mode, force=force) - except Exception as exc: - logger.warning("CTP 后台连接失败: %s", exc) - - threading.Thread(target=_run, daemon=True, name="ctp-connect-async").start() - return {"started": True, "connecting": True, "connected": False} - - def ensure_connected(self, mode: str) -> None: - if self._connected_mode == mode and self.ping(): - return - self.connect(mode) - - def require_connected(self, mode: str) -> None: - """报单前检查:须已连接,不在此发起阻塞式 connect。""" - if self._connect_in_progress: - raise RuntimeError("CTP 连接中,请稍候再下单") - if self._connected_mode != mode or not self.ping(): - raise RuntimeError("请先连接 CTP(持仓监控页点击「连接 CTP」)") - if not self._td_logged_in(): - raise RuntimeError("CTP 交易通道未登录,请重连 CTP 后再下单") - - def _td_logged_in(self) -> bool: - try: - gw = self._engine.get_gateway(GATEWAY_NAME) - td = gw.td_api - return bool(getattr(td, "login_status", False)) - except Exception: - return False - - def _find_position(self, sym: str, ex_name: str, hold_direction: str) -> Any: - if not self._engine: - return None - sym_l = sym.lower() - ex_u = ex_name.upper() - want_long = hold_direction == "long" - try: - for pos in self._engine.get_all_positions(): - ps = (getattr(pos, "symbol", "") or "").lower() - pe = getattr(pos, "exchange", None) - pe_s = str(pe.value if hasattr(pe, "value") else pe or "").upper() - if ps != sym_l or pe_s != ex_u: - continue - vol = int(getattr(pos, "volume", 0) or 0) - if vol <= 0: - continue - is_long = _is_long_direction(getattr(pos, "direction", None)) - if is_long == want_long: - return pos - except Exception as exc: - logger.debug("find position: %s", exc) - return None - - def _resolve_close_offset(self, sym: str, ex_name: str, hold_direction: str, lots: int) -> Any: - from vnpy.trader.constant import Offset - - ex_u = (ex_name or "").upper() - # 上期所/能源中心/郑商所/中金所须区分平今/平昨;大商所等可用通用 CLOSE - if ex_u not in ("CZCE", "CFFEX", "SHFE", "INE"): - return Offset.CLOSE - pos = self._find_position(sym, ex_u, hold_direction) - if not pos: - # 找不到持仓明细时,日盘新开仓优先平今(避免 SHFE「平昨仓位不足」) - if ex_u in ("SHFE", "INE", "CZCE"): - return Offset.CLOSETODAY - return Offset.CLOSE - vol = int(getattr(pos, "volume", 0) or 0) - yd = int(getattr(pos, "yd_volume", 0) or 0) - today = max(0, vol - yd) - if today >= lots: - return Offset.CLOSETODAY - return Offset.CLOSEYESTERDAY - - def _aggressive_limit_price( - self, - ths_code: str, - sym: str, - ex_name: str, - direction: Any, - tick: float, - fallback: float, - ) -> float: - from vnpy.trader.constant import Direction - - self.subscribe_symbol(ths_code) - lp = fallback - detail = self.get_tick_detail(ths_code, mode=self._connected_mode or "") - if detail.get("price"): - lp = float(detail["price"]) - slip = max(tick, tick * 3) - if direction == Direction.LONG: - lp = lp + slip - else: - lp = max(tick, lp - slip) - return round_to_tick(lp, tick) - - def ping(self) -> bool: - """检测连接是否仍有效;无效则清除 connected 状态。""" - if not self._engine or not self._connected_mode: - return False - if self._td_logged_in(): - return True - try: - if self._engine.get_all_accounts(): - return True - except Exception as exc: - logger.debug("CTP ping failed: %s", exc) - self._connected_mode = None - return False - - def mark_disconnected(self) -> None: - self._connected_mode = None - - def reconnect_after_settings_saved(self, mode: str) -> dict[str, Any]: - """保存前置/账号后关闭旧连接,并用数据库中的新配置重连。""" - self._close_gateway() - self._last_error = "" - _persist_last_error("") - return self.start_connect_async(mode, force=True) - - def _schedule_fee_sync(self, mode: str) -> None: - """连接成功后触发每日同步检查(非每次全量)。""" - - def _run() -> None: - time.sleep(45) - try: - from ctp_fee_worker import try_daily_ctp_fee_sync - - def _gs(key: str, default: str = "") -> str: - from fee_specs import get_setting - return get_setting(key, default) - - def _ss(key: str, val: str) -> None: - from fee_specs import set_setting - set_setting(key, val) - - try_daily_ctp_fee_sync( - mode, - get_setting=_gs, - set_setting=_ss, - force=False, - ) - except Exception as exc: - logger.debug("CTP 手续费连接后检查: %s", exc) - - threading.Thread(target=_run, daemon=True, name="ctp-fee-sync-check").start() - - def _ensure_commission_callback(self) -> None: - if self._commission_hooked or not self._engine: - return - try: - gw = self._engine.get_gateway(GATEWAY_NAME) - td = gw.td_api - except Exception: - return - bridge = self - - def on_rsp(data: dict, error: dict, reqid: int, last: bool) -> None: - if error and int(error.get("ErrorID") or 0) != 0: - logger.debug( - "CTP commission error reqid=%s: %s", - reqid, - error.get("ErrorMsg") or error, - ) - if data and data.get("InstrumentID"): - bridge._commission_lists.setdefault(reqid, []).append(dict(data)) - ev = bridge._commission_waiters.get(reqid) - if last and ev: - ev.set() - - td.onRspQryInstrumentCommissionRate = on_rsp # type: ignore[method-assign] - self._commission_hooked = True - - def _query_commission( - self, - *, - mode: str, - instrument_id: str = "", - exchange_id: str = "", - timeout: float = 8, - ) -> list[dict]: - if self._connected_mode != mode or not self._engine: - return [] - try: - gw = self._engine.get_gateway(GATEWAY_NAME) - td = gw.td_api - except Exception as exc: - logger.debug("commission query init: %s", exc) - return [] - if not getattr(td, "login_status", False): - return [] - if not hasattr(td, "reqQryInstrumentCommissionRate"): - return [] - self._ensure_commission_callback() - reqid = int(getattr(td, "reqid", 0)) + 1 - td.reqid = reqid - ev = threading.Event() - self._commission_waiters[reqid] = ev - req = { - "BrokerID": td.brokerid, - "InvestorID": td.userid, - "InstrumentID": instrument_id or "", - "ExchangeID": exchange_id or "", - } - ret = td.reqQryInstrumentCommissionRate(req, reqid) - if ret != 0: - self._commission_waiters.pop(reqid, None) - return [] - ev.wait(timeout=timeout) - self._commission_waiters.pop(reqid, None) - return self._commission_lists.pop(reqid, []) - - def query_instrument_commission(self, ths_code: str, *, mode: str) -> dict: - """查询单合约 CTP 手续费率(需已连接)。""" - try: - sym, ex_name = ths_to_vnpy_symbol(ths_code) - except Exception: - return {} - rows = self._query_commission( - mode=mode, - instrument_id=sym, - exchange_id=ex_name, - ) - return rows[-1] if rows else {} - - def query_all_commissions(self, *, mode: str) -> list[dict]: - """批量查询全部合约手续费(InstrumentID 留空)。""" - return self._query_commission(mode=mode, timeout=45) - - def _tick_key(self, symbol: str, ex_name: str) -> str: - return f"{symbol.lower()}:{ex_name.upper()}" - - def _price_from_tick(self, tick: Any) -> Optional[float]: - for attr in ("last_price", "bid_price_1", "ask_price_1", "pre_close"): - try: - v = float(getattr(tick, attr, 0) or 0) - except (TypeError, ValueError): - v = 0.0 - if v > 0: - return v - return None - - def _lookup_tick(self, symbol: str, ex_name: str) -> Optional[float]: - if not self._engine: - return None - sym_l = symbol.lower() - ex_u = ex_name.upper() - try: - for tick in self._engine.get_all_ticks(): - ts = (getattr(tick, "symbol", "") or "").lower() - te = getattr(tick, "exchange", None) - te_s = str(te.value if hasattr(te, "value") else te or "").upper() - if ts == sym_l and te_s == ex_u: - p = self._price_from_tick(tick) - if p: - return p - except Exception as exc: - logger.debug("lookup tick: %s", exc) - return None - - def _bar_to_dict(self, bar: Any) -> dict: - dt = getattr(bar, "datetime", None) - d_str = dt.strftime("%Y-%m-%d %H:%M:%S") if dt else "" - return { - "d": d_str, - "o": float(getattr(bar, "open_price", 0) or 0), - "h": float(getattr(bar, "high_price", 0) or 0), - "l": float(getattr(bar, "low_price", 0) or 0), - "c": float(getattr(bar, "close_price", 0) or 0), - "v": float(getattr(bar, "volume", 0) or 0), - } - - def _ensure_bar_generator(self, sym: str, ex_name: str) -> None: - key = self._tick_key(sym, ex_name) - if key in self._bar_generators: - return - self._bars_1m[key] = deque(maxlen=4000) - - def on_bar(bar: Any) -> None: - row = self._bar_to_dict(bar) - if row.get("d"): - self._bars_1m[key].append(row) - - try: - from vnpy.trader.utility import BarGenerator - - self._bar_generators[key] = BarGenerator(on_bar=on_bar) - except ImportError: - logger.debug("BarGenerator unavailable") - - def _find_tick(self, symbol: str, ex_name: str) -> Any: - if not self._engine: - return None - sym_l = symbol.lower() - ex_u = ex_name.upper() - try: - for tick in self._engine.get_all_ticks(): - ts = (getattr(tick, "symbol", "") or "").lower() - te = getattr(tick, "exchange", None) - te_s = str(te.value if hasattr(te, "value") else te or "").upper() - if ts == sym_l and te_s == ex_u: - return tick - except Exception as exc: - logger.debug("find tick: %s", exc) - return None - - def _tick_to_bar(self, symbol: str, ex_name: str) -> Optional[dict]: - tick = self._find_tick(symbol, ex_name) - if not tick: - return None - lp = self._price_from_tick(tick) - if not lp or lp <= 0: - return None - dt = getattr(tick, "datetime", None) - d_str = dt.strftime("%Y-%m-%d %H:%M:%S") if dt else "" - if not d_str: - from datetime import datetime - from zoneinfo import ZoneInfo - - d_str = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") - o = float(getattr(tick, "open_price", 0) or lp) - h = float(getattr(tick, "high_price", 0) or lp) - lo = float(getattr(tick, "low_price", 0) or lp) - return { - "d": d_str, - "o": o, - "h": h, - "l": lo, - "c": lp, - "v": float(getattr(tick, "volume", 0) or 0), - } - - def _on_tick(self, tick: Any) -> None: - sym = (getattr(tick, "symbol", "") or "").lower() - te = getattr(tick, "exchange", None) - ex_s = str(te.value if hasattr(te, "value") else te or "").upper() - key = self._tick_key(sym, ex_s) - bg = self._bar_generators.get(key) - if not bg: - return - try: - bg.update_tick(tick) - except Exception as exc: - logger.debug("bar gen tick: %s", exc) - - def _ensure_tick_handler(self) -> None: - if self._tick_hooked or not self._ee: - return - try: - from vnpy.trader.event import EVENT_TICK - except ImportError: - return - - def process_tick(event: Any) -> None: - self._on_tick(event.data) - - self._ee.register(EVENT_TICK, process_tick) - self._tick_hooked = True - - def get_kline_bars_1m(self, ths_code: str, *, mode: str) -> list[dict]: - """订阅合约并返回 1 分钟 K 线(含正在形成的 bar)。""" - if self._connected_mode != mode or not self._engine: - return [] - try: - sym, ex_name = ths_to_vnpy_symbol(ths_code) - except Exception: - return [] - key = self._tick_key(sym, ex_name) - self._ensure_bar_generator(sym, ex_name) - self.subscribe_symbol(ths_code) - for _ in range(12): - if self._bars_1m.get(key) and len(self._bars_1m[key]) > 0: - break - if self._lookup_tick(sym, ex_name): - break - time.sleep(0.2) - bars_1m = list(self._bars_1m.get(key, [])) - bg = self._bar_generators.get(key) - if bg and getattr(bg, "bar", None): - forming = self._bar_to_dict(bg.bar) - if forming.get("d"): - if not bars_1m or bars_1m[-1]["d"] != forming["d"]: - bars_1m.append(forming) - else: - bars_1m[-1] = forming - if not bars_1m: - tick_bar = self._tick_to_bar(sym, ex_name) - if tick_bar: - bars_1m = [tick_bar] - return bars_1m - - def get_tick_detail(self, ths_code: str, *, mode: str) -> dict[str, Any]: - if self._connected_mode != mode or not self._engine: - return {} - try: - sym, ex_name = ths_to_vnpy_symbol(ths_code) - except Exception: - return {} - self.subscribe_symbol(ths_code) - for _ in range(8): - tick = self._find_tick(sym, ex_name) - if tick: - price = self._price_from_tick(tick) - try: - pre_close = float(getattr(tick, "pre_close", 0) or 0) - except (TypeError, ValueError): - pre_close = 0.0 - return { - "price": price, - "pre_close": pre_close if pre_close > 0 else None, - } - time.sleep(0.2) - return {} - - def subscribe_symbol(self, ths_code: str) -> None: - if not self._engine or not self._connected_mode: - return - try: - from vnpy.trader.object import SubscribeRequest - - sym, ex_name = ths_to_vnpy_symbol(ths_code) - key = self._tick_key(sym, ex_name) - self._ensure_bar_generator(sym, ex_name) - if key in self._subscribed: - return - exchange = to_vnpy_exchange(ex_name) - self._ensure_tick_handler() - req = SubscribeRequest(symbol=sym, exchange=exchange) - self._engine.subscribe(req, GATEWAY_NAME) - self._subscribed.add(key) - except Exception as exc: - logger.debug("CTP subscribe %s: %s", ths_code, exc) - - def get_tick_price(self, ths_code: str, *, mode: str) -> Optional[float]: - if self._connected_mode != mode or not self._engine: - return None - try: - sym, ex_name = ths_to_vnpy_symbol(ths_code) - except Exception: - return None - price = self._lookup_tick(sym, ex_name) - if price: - return price - self.subscribe_symbol(ths_code) - for _ in range(8): - time.sleep(0.2) - price = self._lookup_tick(sym, ex_name) - if price: - return price - return None - - def get_account(self) -> dict[str, Any]: - if not self._engine: - return {} - accounts = self._engine.get_all_accounts() - if not accounts: - return {} - acc = accounts[0] - return { - "balance": float(getattr(acc, "balance", 0) or 0), - "available": float(getattr(acc, "available", 0) or 0), - "frozen": float(getattr(acc, "frozen", 0) or 0), - "accountid": getattr(acc, "accountid", ""), - } - - def _position_margin_key(self, sym: str, direction: str) -> str: - return f"{(sym or '').lower()}:{(direction or 'long').strip().lower()}" - - def _lookup_position_open_time(self, sym: str, direction: str) -> str: - return (self._position_open_times.get(self._position_margin_key(sym, direction)) or "").strip() - - @staticmethod - def _parse_ctp_open_datetime(date_raw: str, time_raw: str = "") -> str: - """CTP OpenDate + OpenTime → YYYY-MM-DD HH:MM[:SS]。""" - d = (date_raw or "").strip() - if len(d) >= 8 and d[:8].isdigit(): - date_part = f"{d[:4]}-{d[4:6]}-{d[6:8]}" - else: - return "" - t = (time_raw or "").strip().replace(":", "") - if len(t) >= 6 and t[:6].isdigit(): - return f"{date_part} {t[0:2]}:{t[2:4]}:{t[4:6]}" - if len(t) >= 4 and t.isdigit(): - return f"{date_part} {t[0:2]}:{t[2:4]}" - return date_part - - def _parse_ctp_open_date(raw: str) -> str: - return CtpBridge._parse_ctp_open_datetime(raw, "") - - def _install_position_margin_hook(self) -> None: - """已禁用:monkey-patch CTP 持仓回调在并发下会触发 vnctptd 段错误。""" - return - - def _lookup_position_margin(self, sym: str, direction: str) -> float: - return float(self._position_margins.get(self._position_margin_key(sym, direction), 0) or 0) - - def estimate_margin_one_lot(self, ths_code: str, price: float) -> Optional[float]: - """用 CTP 合约信息估算 1 手保证金(需已连接并完成合约查询)。""" - if not self._engine or not price or price <= 0: - return None - try: - sym, ex_name = ths_to_vnpy_symbol(ths_code) - exchange = to_vnpy_exchange(ex_name) - vt_symbol = f"{sym}.{exchange.value}" - contract = self._engine.get_contract(vt_symbol) - if not contract: - return None - mult = float(getattr(contract, "size", 0) or 0) - long_r = float(getattr(contract, "long_margin_ratio", 0) or 0) - short_r = float(getattr(contract, "short_margin_ratio", 0) or 0) - ratio = max(long_r, short_r) - if mult <= 0 or ratio <= 0: - return None - return round(float(price) * mult * ratio, 2) - except Exception as exc: - logger.debug("estimate_margin_one_lot %s: %s", ths_code, exc) - return None - - def _collect_positions(self) -> list[dict[str, Any]]: - if not self._engine: - return [] - out: list[dict[str, Any]] = [] - for pos in self._engine.get_all_positions(): - vol = int(getattr(pos, "volume", 0) or 0) - if vol <= 0: - continue - d = "long" if _is_long_direction(getattr(pos, "direction", None)) else "short" - sym = getattr(pos, "symbol", "") or "" - exchange = getattr(pos, "exchange", None) - ex_name = str(exchange.value if hasattr(exchange, "value") else exchange or "") - margin = self._lookup_position_margin(sym, d) - open_time = self._lookup_position_open_time(sym, d) or None - out.append({ - "symbol": sym, - "exchange": ex_name, - "direction": d, - "lots": vol, - "avg_price": float(getattr(pos, "price", 0) or 0), - "pnl": float(getattr(pos, "pnl", 0) or 0), - "frozen": int(getattr(pos, "frozen", 0) or 0), - "margin": round(margin, 2) if margin > 0 else None, - "open_time": open_time, - }) - return out - - def refresh_positions(self) -> None: - """vnpy 内存缓存持仓;禁止 query_position(vnctptd 并发查询会段错误)。""" - return - - def list_positions(self, *, refresh_if_empty: bool = True, refresh_margin: bool = False) -> list[dict[str, Any]]: - del refresh_if_empty, refresh_margin - with _ctp_td_lock: - return self._collect_positions() - - @staticmethod - def _parse_trade_offset(offset_obj: Any) -> str: - s = str(offset_obj or "").upper() - if "OPEN" in s: - return "open" - return "close" - - @staticmethod - def _parse_trade_direction(direction_obj: Any) -> str: - return "long" if _is_long_direction(direction_obj) else "short" - - @staticmethod - def _position_direction_from_trade(trade_direction: str, offset: str) -> str: - td = (trade_direction or "long").strip().lower() - if (offset or "open").strip().lower() == "open": - return td - return "short" if td == "long" else "long" - - def _format_trade_datetime(self, dt_obj: Any, date_raw: str = "", time_raw: str = "") -> str: - if dt_obj is not None: - try: - if hasattr(dt_obj, "strftime"): - return dt_obj.strftime("%Y-%m-%d %H:%M:%S") - text = str(dt_obj).strip() - if text: - return text[:19].replace("T", " ") - except Exception: - pass - parsed = self._parse_ctp_open_datetime(date_raw, time_raw) - return parsed or "" - - def _trade_row_from_vnpy(self, trade: Any) -> Optional[dict[str, Any]]: - try: - sym = (getattr(trade, "symbol", "") or "").strip() - vol = int(getattr(trade, "volume", 0) or 0) - if not sym or vol <= 0: - return None - direction = self._parse_trade_direction(getattr(trade, "direction", None)) - offset = self._parse_trade_offset(getattr(trade, "offset", None)) - exchange = getattr(trade, "exchange", None) - ex_name = str(exchange.value if hasattr(exchange, "value") else exchange or "") - dt = self._format_trade_datetime(getattr(trade, "datetime", None)) - trade_id = str(getattr(trade, "tradeid", "") or getattr(trade, "vt_tradeid", "") or "") - order_id = str(getattr(trade, "orderid", "") or getattr(trade, "vt_orderid", "") or "") - if not trade_id: - trade_id = f"{order_id}:{sym}:{offset}:{direction}:{vol}:{getattr(trade, 'price', 0)}:{dt}" - return { - "trade_id": trade_id, - "order_id": order_id, - "symbol": sym, - "exchange": ex_name, - "direction": direction, - "offset": offset, - "position_direction": self._position_direction_from_trade(direction, offset), - "lots": vol, - "price": float(getattr(trade, "price", 0) or 0), - "datetime": dt, - } - except Exception as exc: - logger.debug("trade_row_from_vnpy: %s", exc) - return None - - def _trade_row_from_ctp_dict(self, data: dict) -> Optional[dict[str, Any]]: - try: - sym = (data.get("InstrumentID") or data.get("instrument_id") or "").strip() - vol = int(float(data.get("Volume") or data.get("volume") or 0)) - if not sym or vol <= 0: - return None - dir_raw = str(data.get("Direction") or data.get("direction") or "") - direction = "long" if dir_raw in ("0", "2") or "LONG" in dir_raw.upper() or dir_raw == "多" else "short" - off_raw = str(data.get("OffsetFlag") or data.get("offset") or "") - if off_raw in ("0",) or "OPEN" in off_raw.upper(): - offset = "open" - else: - offset = "close" - price = float(data.get("Price") or data.get("price") or 0) - trade_id = str(data.get("TradeID") or data.get("tradeid") or "").strip() - order_sys = str(data.get("OrderSysID") or data.get("orderid") or "").strip() - dt = self._format_trade_datetime( - None, - str(data.get("TradeDate") or data.get("trade_date") or ""), - str(data.get("TradeTime") or data.get("trade_time") or ""), - ) - if not trade_id: - trade_id = f"{order_sys}:{sym}:{offset}:{direction}:{vol}:{price}:{dt}" - return { - "trade_id": trade_id, - "order_id": order_sys, - "symbol": sym, - "exchange": str(data.get("ExchangeID") or data.get("exchange") or ""), - "direction": direction, - "offset": offset, - "position_direction": self._position_direction_from_trade(direction, offset), - "lots": vol, - "price": price, - "datetime": dt, - } - except Exception as exc: - logger.debug("trade_row_from_ctp_dict: %s", exc) - return None - - def _install_trade_query_hook(self) -> None: - """不再 monkey-patch CTP 成交回调(易与并发查询冲突导致 vnctptd 段错误)。""" - return - - @staticmethod - def _engine_collection_items(raw: Any) -> list[Any]: - """vnpy 不同版本可能返回 dict 或 list。""" - if raw is None: - return [] - if isinstance(raw, dict): - return list(raw.values()) - if isinstance(raw, (list, tuple)): - return list(raw) - return [raw] - - def _collect_engine_trades(self) -> list[dict[str, Any]]: - if not self._engine: - return [] - out: list[dict[str, Any]] = [] - seen: set[str] = set() - try: - trades = self._engine.get_all_trades() - except Exception: - trades = None - for trade in self._engine_collection_items(trades): - row = self._trade_row_from_vnpy(trade) - if not row: - continue - key = row["trade_id"] - if key in seen: - continue - seen.add(key) - out.append(row) - return out - - def refresh_trades(self) -> None: - """成交仅读 vnpy 内存回报;不调用 query_trade(避免 CTP 段错误)。""" - return - - def list_trades(self, *, refresh: bool = False) -> list[dict[str, Any]]: - with _ctp_td_lock: - out = self._collect_engine_trades() - out.sort(key=lambda r: (r.get("datetime") or "", r.get("trade_id") or "")) - return out - - def list_active_orders(self) -> list[dict[str, Any]]: - if not self._engine: - return [] - out: list[dict[str, Any]] = [] - try: - orders = self._engine.get_all_active_orders() - except Exception: - return [] - for order in orders or []: - status = getattr(order, "status", None) - status_s = str(status) - if status_s and not any(x in status_s for x in ("NOTTRADED", "PARTTRADED", "SUBMITTING")): - continue - vol = int(getattr(order, "volume", 0) or 0) - traded = int(getattr(order, "traded", 0) or 0) - remain = max(0, vol - traded) - if remain <= 0: - continue - direction = getattr(order, "direction", None) - d = "long" - if direction is not None and str(direction).endswith("SHORT"): - d = "short" - offset = getattr(order, "offset", None) - offset_s = str(offset or "") - sym = getattr(order, "symbol", "") or "" - exchange = getattr(order, "exchange", None) - ex_name = str(exchange.value if hasattr(exchange, "value") else exchange or "") - out.append({ - "symbol": sym, - "exchange": ex_name, - "direction": d, - "lots": remain, - "price": float(getattr(order, "price", 0) or 0), - "offset": offset_s, - "order_id": str(getattr(order, "orderid", "") or ""), - "status": status_s, - }) - return out - - def send_order( - self, - *, - ths_code: str, - offset: str, - direction: str, - lots: int, - price: float, - order_type: str = "limit", - ) -> str: - from vnpy.trader.constant import Direction, Offset, OrderType - from vnpy.trader.object import OrderRequest - - if not self._engine: - raise RuntimeError("CTP 未初始化") - if not self._td_logged_in(): - raise RuntimeError("CTP 交易通道未登录,请重连后再下单") - - sym, ex_name = ths_to_vnpy_symbol(ths_code) - exchange = to_vnpy_exchange(ex_name) - lots = max(1, int(lots)) - tick = float(get_contract_spec(ths_code).get("tick_size") or 1.0) - - offset = (offset or "open").lower() - direction = (direction or "long").lower() - - if offset in ("open", "open_long", "open_short"): - d = Direction.LONG if direction == "long" or offset == "open_long" else Direction.SHORT - off = Offset.OPEN - elif offset in ("close", "close_long", "close_short"): - hold = "long" if direction == "long" or offset == "close_long" else "short" - if hold == "long": - d = Direction.SHORT - else: - d = Direction.LONG - off = self._resolve_close_offset(sym, ex_name, hold, lots) - else: - raise ValueError(f"未知开平: {offset}") - - use_market = (order_type or "limit").lower() == "market" - if use_market: - ot = OrderType.FAK - price = self._aggressive_limit_price(ths_code, sym, ex_name, d, tick, price) - else: - ot = OrderType.LIMIT - price = round_to_tick(float(price), tick) - if price <= 0: - raise ValueError("委托价格无效,请检查行情或手动填写价格") - - req = OrderRequest( - symbol=sym, - exchange=exchange, - direction=d, - type=ot, - volume=lots, - price=price, - offset=off, - ) - logger.info( - "CTP 报单 %s %s %s %s手 @%s offset=%s type=%s", - sym, ex_name, d, lots, price, off, ot, - ) - with _ctp_td_lock: - vt_orderid = self._engine.send_order(req, GATEWAY_NAME) - if not vt_orderid: - raise RuntimeError("CTP 拒单或未返回委托号(请检查合约代码、价格是否为最小变动价位整数倍)") - return str(vt_orderid) - - def cancel_order(self, vt_orderid: str) -> bool: - if not self._engine or not vt_orderid: - return False - try: - with _ctp_td_lock: - order = self._engine.get_order(vt_orderid) - if order is None: - return False - req = order.create_cancel_request() - self._engine.cancel_order(req, GATEWAY_NAME) - logger.info("CTP 撤单 %s", vt_orderid) - return True - except Exception as exc: - logger.warning("CTP 撤单失败 %s: %s", vt_orderid, exc) - return False - - -def get_bridge() -> CtpBridge: - global _bridge - with _bridge_lock: - if _bridge is None: - _bridge = CtpBridge() - return _bridge - - -def try_init_vnpy(_settings: dict | None = None) -> bool: - return get_bridge().available() - - -def vnpy_available() -> bool: - return get_bridge().available() - - -def ctp_connect(mode: str, *, force: bool = False) -> dict[str, Any]: - b = get_bridge() - b.connect(mode, force=force) - return b.status(mode) - - -def ctp_start_connect(mode: str, *, force: bool = False) -> dict[str, Any]: - """非阻塞发起连接,供 Web API 使用。""" - b = get_bridge() - info = b.start_connect_async(mode, force=force) - st = b.status(mode) - return {**info, "status": st} - - -def ctp_try_auto_reconnect(mode: str) -> bool: - """断线时静默异步重连;已连接且交易通道正常则不再重复 connect。""" - b = get_bridge() - if not b.available(): - return False - if b.connect_in_progress(): - return False - if b.login_cooldown_remaining() > 0: - return False - st = _setting_for_mode(mode) - if not st.get("用户名") or not st.get("密码") or not st.get("交易服务器"): - return False - if b.connected_mode == mode: - if b._td_logged_in() or b.ping(): - return True - recent = time.time() - float(getattr(b, "_last_connect_ok_ts", 0) or 0) - if recent < 120: - logger.debug("CTP 跳过自动重连:刚连接 %.0fs", recent) - return True - td = st.get("交易服务器", "") - ok, err = probe_tcp_address(td, timeout=4.0) - if not ok: - b._last_error = ( - f"SimNow 交易前置不可达:{td}({err})。" - "请更新 SIMNOW_TD_ADDRESS 并确认服务器出网。" - ) - return False - info = b.start_connect_async(mode, force=False) - return bool( - info.get("connected") - or info.get("connecting") - or info.get("started") - ) - - -def ctp_status(mode: str) -> dict[str, Any]: - st = get_bridge().status(mode) - if not st.get("connected") and not st.get("connecting"): - setting = _setting_for_mode(mode) - td = setting.get("交易服务器", "") - if td: - ok, err = probe_tcp_address(td, timeout=3.0) - st["td_reachable"] = ok - if not ok and not st.get("last_error"): - st["last_error"] = ( - f"SimNow 交易前置不可达:{td}({err})" - ) - return st - - -def ctp_get_account(mode: str) -> dict[str, Any]: - b = get_bridge() - b.ensure_connected(mode) - return b.get_account() - - -def ctp_list_positions( - mode: str, - *, - refresh_if_empty: bool = True, - refresh_margin: bool = False, -) -> list[dict[str, Any]]: - b = get_bridge() - if b.connected_mode != mode or not b.ping(): - return [] - return b.list_positions(refresh_if_empty=refresh_if_empty, refresh_margin=refresh_margin) - - -def ctp_list_active_orders(mode: str) -> list[dict[str, Any]]: - b = get_bridge() - b.ensure_connected(mode) - return b.list_active_orders() - - -def ctp_cancel_order(mode: str, vt_orderid: str) -> bool: - b = get_bridge() - b.ensure_connected(mode) - return b.cancel_order(vt_orderid) - - -def ctp_list_trades(mode: str, *, refresh: bool = False) -> list[dict[str, Any]]: - b = get_bridge() - if b.connected_mode != mode or not b.ping(): - return [] - return b.list_trades(refresh=refresh) - - -def ctp_get_tick_price(mode: str, ths_code: str) -> Optional[float]: - """CTP 柜台最新价(需已连接并订阅)。""" - b = get_bridge() - if b.connected_mode != mode: - return None - try: - return b.get_tick_price(ths_code, mode=mode) - except Exception as exc: - logger.debug("ctp_get_tick_price: %s", exc) - return None - - -def ctp_get_tick_detail(mode: str, ths_code: str) -> dict[str, Any]: - b = get_bridge() - if b.connected_mode != mode: - return {} - try: - return b.get_tick_detail(ths_code, mode=mode) - except Exception as exc: - logger.debug("ctp_get_tick_detail: %s", exc) - return {} - - -def ctp_estimate_margin_one_lot(mode: str, ths_code: str, price: float) -> Optional[float]: - b = get_bridge() - if b.connected_mode != mode or not b.ping(): - return None - try: - return b.estimate_margin_one_lot(ths_code, price) - except Exception as exc: - logger.debug("ctp_estimate_margin_one_lot: %s", exc) - return None - - -def get_ctp_balance(mode: str) -> Optional[float]: - try: - acc = ctp_get_account(mode) - bal = acc.get("balance") - return float(bal) if bal else None - except Exception as exc: - logger.debug("get_ctp_balance: %s", exc) - return None - - -def execute_order( - conn, - *, - mode: str, - offset: str, - symbol: str, - direction: str, - lots: int, - price: float, - settings: dict | None = None, - order_type: str = "limit", -) -> dict[str, Any]: - """统一下单:simulation=SimNow,live=期货公司 CTP。""" - del conn, settings - if mode not in ("simulation", "live"): - raise ValueError("未知交易模式") - if not vnpy_available(): - raise ValueError( - "请先安装 vnpy 与 vnpy_ctp:pip install vnpy vnpy_ctp\n" - f"模拟盘需配置 .env 中 SIMNOW_USER / SIMNOW_PASSWORD 等" - ) - b = get_bridge() - b.require_connected(mode) - order_id = b.send_order( - ths_code=symbol, - offset=offset, - direction=direction, - lots=lots, - price=price, - order_type=order_type, - ) - return { - "order_id": order_id, - "mode": mode, - "mode_label": _mode_label(mode), - "symbol": symbol, - "lots": lots, - "price": price, - } +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""CTP 执行层:模拟盘 → SimNow;实盘 → 期货公司(vnpy_ctp)。""" +from __future__ import annotations + +import logging +import os +import threading +import time +from collections import deque +from typing import Any, Callable, Optional + +from locale_fix import ensure_process_locale + +ensure_process_locale() + +from ctp_settings import live_setting_dict, simnow_setting_dict +from ctp_symbol import ths_to_vnpy_symbol, to_vnpy_exchange +from contract_specs import get_contract_spec + +logger = logging.getLogger(__name__) + +GATEWAY_NAME = "CTP" + +CONNECT_WAIT_SEC = 60 +CONNECT_POLL_INTERVAL_SEC = 0.5 +LOGIN_BAN_COOLDOWN_SEC = 45 * 60 +LOGIN_FAIL_COOLDOWN_SEC = 5 * 60 +CTP_COOLDOWN_UNTIL_KEY = "ctp_login_cooldown_until" +CTP_LAST_ERROR_KEY = "ctp_last_error" + + +def _persist_login_cooldown(seconds: float) -> None: + from fee_specs import get_setting, set_setting + + new_until = time.time() + max(0.0, seconds) + try: + old = float(get_setting(CTP_COOLDOWN_UNTIL_KEY, "0") or 0) + except (TypeError, ValueError): + old = 0.0 + if new_until > old: + set_setting(CTP_COOLDOWN_UNTIL_KEY, str(new_until)) + + +def _persisted_login_cooldown_remaining() -> int: + from fee_specs import get_setting + + try: + until = float(get_setting(CTP_COOLDOWN_UNTIL_KEY, "0") or 0) + return max(0, int(until - time.time())) + except (TypeError, ValueError): + return 0 + + +def _clear_persisted_login_cooldown() -> None: + from fee_specs import set_setting + + set_setting(CTP_COOLDOWN_UNTIL_KEY, "0") + + +def _persist_last_error(msg: str) -> None: + from fee_specs import set_setting + + set_setting(CTP_LAST_ERROR_KEY, (msg or "").strip()) + + +def _load_persisted_last_error() -> str: + from fee_specs import get_setting + + return (get_setting(CTP_LAST_ERROR_KEY, "") or "").strip() + +_position_refresh_callback: Optional[Callable[[], None]] = None + + +def set_position_refresh_callback(fn: Optional[Callable[[], None]]) -> None: + global _position_refresh_callback + _position_refresh_callback = fn + + +def _fire_position_refresh_callback() -> None: + fn = _position_refresh_callback + if not fn: + return + try: + threading.Thread(target=fn, daemon=True, name="ctp-position-refresh").start() + except Exception as exc: + logger.debug("position refresh callback: %s", exc) + +_bridge: Optional["CtpBridge"] = None +_bridge_lock = threading.Lock() +_ctp_td_lock = threading.RLock() +POSITION_QUERY_MIN_INTERVAL_SEC = 5.0 +TRADE_QUERY_MIN_INTERVAL_SEC = 10.0 + + +def _simnow_setting() -> dict[str, str]: + """SimNow 仿真前置(系统设置优先,.env 兜底)。""" + return simnow_setting_dict() + + +def _live_setting() -> dict[str, str]: + return live_setting_dict() + + +def _setting_for_mode(mode: str) -> dict[str, str]: + return _simnow_setting() if mode == "simulation" else _live_setting() + + +def _mode_label(mode: str) -> str: + return "SimNow" if mode == "simulation" else "期货公司实盘" + + +def _parse_tcp_address(address: str) -> tuple[str, int]: + raw = (address or "").strip() + if raw.startswith("tcp://"): + raw = raw[6:] + if ":" not in raw: + raise ValueError(f"无效 TCP 地址: {address}") + host, port_s = raw.rsplit(":", 1) + return host, int(port_s) + + +def probe_tcp_address(address: str, timeout: float = 5.0) -> tuple[bool, str]: + """探测 CTP 前置 TCP 是否可达。""" + import socket + + try: + host, port = _parse_tcp_address(address) + with socket.create_connection((host, port), timeout=timeout): + return True, "" + except Exception as exc: + return False, str(exc) + + +def _format_ctp_failure(ctp_logs: list[str], *, td_address: str = "") -> str: + """根据 CTP 网关日志拼出可读错误。""" + if td_address: + ok, err = probe_tcp_address(td_address, timeout=4.0) + if not ok: + return ( + f"SimNow 交易前置不可达:{td_address}({err})。" + "182.254.243.31 已停用,请改 .env 为官方前置 " + "tcp://180.168.146.187:10201 / 10211,并确认服务器能访问该地址。" + ) + text = "\n".join(ctp_logs) + if "连续登录失败" in text or "登录被禁止" in text or "代码:75" in text: + return ( + "CTP 登录被临时禁止:连续失败次数过多(错误码 75)。" + "请等待约 30~60 分钟后再试,先用快期确认投资者代码与密码正确,期间勿反复点「连接」。" + ) + if "4097" in text or "Decrypt handshake" in text or "shake hand" in text.lower(): + return ( + "CTP 握手失败(4097):vnpy_ctp 与 SimNow 前置加密不匹配。" + "请执行 pip install -U vnpy vnpy_ctp 后重启,并确认 .env 中 SIMNOW_ENV=实盘" + ) + if "不合法的登录" in text or "密码" in text or "账号" in text: + tail = ctp_logs[-1] if ctp_logs else "" + return f"CTP 登录被拒:{tail or '请检查投资者代码与密码(快期能否登录)'}" + if "连接断开" in text or "disconnect" in text.lower(): + tail = ctp_logs[-1] if ctp_logs else "" + return f"CTP 连接断开:{tail or '请检查前置地址与网络'}" + if ctp_logs: + return f"CTP 连接失败:{ctp_logs[-1]}" + return "CTP 连接超时:未收到柜台回报。请检查 SimNow 账号、前置地址、网络(nc 测端口),并用快期验证账号" + + +def round_to_tick(price: float, tick: float) -> float: + if tick <= 0: + return float(price) + steps = round(float(price) / tick) + return round(steps * tick, 10) + + +def _is_long_direction(direction_obj: Any) -> bool: + s = str(direction_obj or "") + return "LONG" in s.upper() or "多" in s + + +class CtpBridge: + def __init__(self) -> None: + self._engine = None + self._ee = None + self._connected_mode: Optional[str] = None + self._last_error: str = "" + self._connect_lock = threading.Lock() + self._connect_in_progress = False + self._login_cooldown_until: float = 0.0 + self._restore_persisted_state() + self._commission_waiters: dict[int, threading.Event] = {} + self._commission_lists: dict[int, list] = {} + self._commission_hooked = False + self._subscribed: set[str] = set() + self._last_position_query_ts: float = 0.0 + self._position_margins: dict[str, float] = {} + self._position_open_times: dict[str, str] = {} + self._margin_hooked = False + self._trade_hooked = False + self._trade_query_results: list[dict[str, Any]] = [] + self._trade_query_event = threading.Event() + self._last_trade_query_ts: float = 0.0 + self._last_connect_ok_ts: float = 0.0 + self._tick_hooked = False + self._bar_generators: dict[str, Any] = {} + self._bars_1m: dict[str, deque] = {} + self._init_engine() + + def _init_engine(self) -> None: + ensure_process_locale() + try: + from vnpy.event import EventEngine + from vnpy.trader.engine import MainEngine + from vnpy_ctp import CtpGateway + + self._ee = EventEngine() + self._engine = MainEngine(self._ee) + self._engine.add_gateway(CtpGateway) + except ImportError: + self._last_error = "未安装 vnpy / vnpy_ctp,请 pip install vnpy vnpy_ctp" + except Exception as exc: + self._last_error = str(exc) + + def available(self) -> bool: + return self._engine is not None + + @property + def last_error(self) -> str: + return self._last_error + + @property + def connected_mode(self) -> Optional[str]: + return self._connected_mode + + def connect_in_progress(self) -> bool: + return self._connect_in_progress + + def _restore_persisted_state(self) -> None: + err = _load_persisted_last_error() + if err: + self._last_error = err + db_remain = _persisted_login_cooldown_remaining() + if db_remain > 0: + self._login_cooldown_until = time.monotonic() + db_remain + + def login_cooldown_remaining(self) -> int: + """距允许再次登录的剩余秒数(内存 + 数据库,重启后仍有效)。""" + mem = max(0, int(self._login_cooldown_until - time.monotonic())) + return max(mem, _persisted_login_cooldown_remaining()) + + def _is_login_cooldown_active(self) -> bool: + return self.login_cooldown_remaining() > 0 + + def _set_login_cooldown(self, seconds: float) -> None: + until = time.monotonic() + max(0.0, seconds) + if until > self._login_cooldown_until: + self._login_cooldown_until = until + _persist_login_cooldown(seconds) + + def _clear_login_cooldown(self) -> None: + self._login_cooldown_until = 0.0 + _clear_persisted_login_cooldown() + + def _apply_login_failure_cooldown(self, ctp_logs: list[str]) -> None: + text = "\n".join(ctp_logs) + if "连续登录失败" in text or "登录被禁止" in text or "代码:75" in text: + self._set_login_cooldown(LOGIN_BAN_COOLDOWN_SEC) + elif any("登录失败" in m or "不合法的登录" in m for m in ctp_logs): + self._set_login_cooldown(LOGIN_FAIL_COOLDOWN_SEC) + + def _login_cooldown_message(self) -> str: + remain = self.login_cooldown_remaining() + return ( + f"CTP 登录冷却中,请 {remain // 60} 分 {remain % 60} 秒后再试" + f"(避免连续失败被 SimNow 封禁)" + ) + + def _close_gateway(self) -> None: + """关闭 CTP 网关,避免半连接状态下重连卡在「连接登录」。""" + if not self._engine: + return + try: + gw = self._engine.get_gateway(GATEWAY_NAME) + if gw: + gw.close() + except Exception as exc: + logger.debug("gateway close: %s", exc) + self._connected_mode = None + time.sleep(0.6) + + def _login_rejected(self, ctp_logs: list[str]) -> bool: + return any( + kw in m + for m in ctp_logs + for kw in ("登录失败", "不合法的登录", "登录被禁止", "连续登录失败") + ) + + def _wait_connected(self, mode: str, ctp_logs: list[str] | None = None) -> bool: + """等待账户回报或交易通道登录成功。""" + if not self._engine: + return False + logs = ctp_logs or [] + loops = max(1, int(CONNECT_WAIT_SEC / CONNECT_POLL_INTERVAL_SEC)) + for _ in range(loops): + if self._login_rejected(logs): + return False + try: + if self._engine.get_all_accounts(): + return True + except Exception: + pass + if self._td_logged_in(): + return True + time.sleep(CONNECT_POLL_INTERVAL_SEC) + return False + + def status(self, mode: str) -> dict[str, Any]: + if self._connected_mode == mode: + self.ping() + st = _setting_for_mode(mode) + missing = [k for k in ("用户名", "密码", "交易服务器") if not st.get(k)] + cooldown = self.login_cooldown_remaining() + connecting = bool(self._connect_in_progress and cooldown <= 0) + last_error = self._last_error or _load_persisted_last_error() + return { + "vnpy_installed": self.available(), + "connected": self._connected_mode == mode, + "connecting": connecting, + "connected_mode": self._connected_mode, + "mode_label": _mode_label(mode), + "missing_config": missing, + "last_error": last_error, + "login_cooldown_sec": cooldown, + "broker_id": st.get("经纪商代码", ""), + "td_address": st.get("交易服务器", ""), + } + + def connect(self, mode: str, *, force: bool = False) -> None: + if self._connect_in_progress: + raise RuntimeError("CTP 正在连接中,请稍候") + if self._is_login_cooldown_active() and not force: + msg = self._login_cooldown_message() + self._last_error = msg + raise RuntimeError(msg) + if not self._engine: + raise RuntimeError(self._last_error or "vnpy 引擎未初始化") + if self._connected_mode == mode and not force: + if self.ping(): + return + self._connected_mode = None + setting = _setting_for_mode(mode) + if not setting.get("用户名") or not setting.get("密码"): + raise ValueError( + f"{_mode_label(mode)}:请在 .env 配置 " + f"{'SIMNOW_USER / SIMNOW_PASSWORD' if mode == 'simulation' else 'CTP_LIVE_USER / CTP_LIVE_PASSWORD'}" + ) + if not setting.get("交易服务器"): + raise ValueError(f"{_mode_label(mode)}:未配置交易服务器地址") + + self._connect_in_progress = True + try: + with _ctp_td_lock: + with self._connect_lock: + if force and self._connected_mode: + self._close_gateway() + elif self._connected_mode and self._connected_mode != mode: + try: + self._engine.close() + except Exception: + pass + self._connected_mode = None + time.sleep(1) + elif not (self._connected_mode == mode and self.ping()): + self._close_gateway() + + ctp_logs: list[str] = [] + from vnpy.trader.event import EVENT_LOG + + def _on_log(event) -> None: + msg = getattr(event.data, "msg", "") or str(event.data) + if msg: + ctp_logs.append(str(msg)) + if len(ctp_logs) > 40: + ctp_logs.pop(0) + logger.info("CTP | %s", msg) + + self._ee.register(EVENT_LOG, _on_log) + try: + ensure_process_locale() + logger.info( + "CTP 连接 [%s] user=%s td=%s env=%s", + mode, + setting.get("用户名"), + setting.get("交易服务器"), + setting.get("柜台环境", "实盘"), + ) + td_addr = setting.get("交易服务器", "") + ok, err = probe_tcp_address(td_addr, timeout=5.0) + if not ok: + raise RuntimeError( + f"SimNow 交易前置不可达:{td_addr}({err})。" + "请更新 .env 中 SIMNOW_TD_ADDRESS 为官网最新地址," + "并在服务器执行 nc -zv 验证出网。" + ) + self._engine.connect(setting, GATEWAY_NAME) + if self._wait_connected(mode, ctp_logs): + self._connected_mode = mode + self._last_connect_ok_ts = time.time() + self._last_error = "" + _persist_last_error("") + self._clear_login_cooldown() + logger.info("CTP 已连接 [%s] td_login=%s accounts=%s", + mode, self._td_logged_in(), + len(self._engine.get_all_accounts() or [])) + self._schedule_fee_sync(mode) + _fire_position_refresh_callback() + return + finally: + self._ee.unregister(EVENT_LOG, _on_log) + + self._close_gateway() + self._apply_login_failure_cooldown(ctp_logs) + hint = _format_ctp_failure(ctp_logs, td_address=setting.get("交易服务器", "")) + self._last_error = hint + _persist_last_error(hint) + logger.warning("CTP 连接失败 [%s]: %s | logs=%s", mode, hint, ctp_logs[-5:]) + raise RuntimeError(hint) + finally: + self._connect_in_progress = False + + def start_connect_async(self, mode: str, *, force: bool = False) -> dict[str, Any]: + """后台连接,不阻塞 HTTP 请求。""" + if self._connected_mode == mode and self.ping() and not force: + return {"started": False, "connecting": False, "connected": True} + if self._connect_in_progress: + return {"started": False, "connecting": True, "connected": False} + if self._is_login_cooldown_active() and not force: + self._last_error = self._login_cooldown_message() + return { + "started": False, + "connecting": False, + "connected": False, + "cooldown": True, + } + + def _run() -> None: + try: + self.connect(mode, force=force) + except Exception as exc: + logger.warning("CTP 后台连接失败: %s", exc) + + threading.Thread(target=_run, daemon=True, name="ctp-connect-async").start() + return {"started": True, "connecting": True, "connected": False} + + def ensure_connected(self, mode: str) -> None: + if self._connected_mode == mode and self.ping(): + return + self.connect(mode) + + def require_connected(self, mode: str) -> None: + """报单前检查:须已连接,不在此发起阻塞式 connect。""" + if self._connect_in_progress: + raise RuntimeError("CTP 连接中,请稍候再下单") + if self._connected_mode != mode or not self.ping(): + raise RuntimeError("请先连接 CTP(持仓监控页点击「连接 CTP」)") + if not self._td_logged_in(): + raise RuntimeError("CTP 交易通道未登录,请重连 CTP 后再下单") + + def _td_logged_in(self) -> bool: + try: + gw = self._engine.get_gateway(GATEWAY_NAME) + td = gw.td_api + return bool(getattr(td, "login_status", False)) + except Exception: + return False + + def _find_position(self, sym: str, ex_name: str, hold_direction: str) -> Any: + if not self._engine: + return None + sym_l = sym.lower() + ex_u = ex_name.upper() + want_long = hold_direction == "long" + try: + for pos in self._engine.get_all_positions(): + ps = (getattr(pos, "symbol", "") or "").lower() + pe = getattr(pos, "exchange", None) + pe_s = str(pe.value if hasattr(pe, "value") else pe or "").upper() + if ps != sym_l or pe_s != ex_u: + continue + vol = int(getattr(pos, "volume", 0) or 0) + if vol <= 0: + continue + is_long = _is_long_direction(getattr(pos, "direction", None)) + if is_long == want_long: + return pos + except Exception as exc: + logger.debug("find position: %s", exc) + return None + + def _resolve_close_offset(self, sym: str, ex_name: str, hold_direction: str, lots: int) -> Any: + from vnpy.trader.constant import Offset + + ex_u = (ex_name or "").upper() + # 上期所/能源中心/郑商所/中金所须区分平今/平昨;大商所等可用通用 CLOSE + if ex_u not in ("CZCE", "CFFEX", "SHFE", "INE"): + return Offset.CLOSE + pos = self._find_position(sym, ex_u, hold_direction) + if not pos: + # 找不到持仓明细时,日盘新开仓优先平今(避免 SHFE「平昨仓位不足」) + if ex_u in ("SHFE", "INE", "CZCE"): + return Offset.CLOSETODAY + return Offset.CLOSE + vol = int(getattr(pos, "volume", 0) or 0) + yd = int(getattr(pos, "yd_volume", 0) or 0) + today = max(0, vol - yd) + if today >= lots: + return Offset.CLOSETODAY + return Offset.CLOSEYESTERDAY + + def _aggressive_limit_price( + self, + ths_code: str, + sym: str, + ex_name: str, + direction: Any, + tick: float, + fallback: float, + ) -> float: + from vnpy.trader.constant import Direction + + self.subscribe_symbol(ths_code) + lp = fallback + detail = self.get_tick_detail(ths_code, mode=self._connected_mode or "") + if detail.get("price"): + lp = float(detail["price"]) + slip = max(tick, tick * 3) + if direction == Direction.LONG: + lp = lp + slip + else: + lp = max(tick, lp - slip) + return round_to_tick(lp, tick) + + def ping(self) -> bool: + """检测连接是否仍有效;无效则清除 connected 状态。""" + if not self._engine or not self._connected_mode: + return False + if self._td_logged_in(): + return True + try: + if self._engine.get_all_accounts(): + return True + except Exception as exc: + logger.debug("CTP ping failed: %s", exc) + self._connected_mode = None + return False + + def mark_disconnected(self) -> None: + self._connected_mode = None + + def reconnect_after_settings_saved(self, mode: str) -> dict[str, Any]: + """保存前置/账号后关闭旧连接,并用数据库中的新配置重连。""" + self._close_gateway() + self._last_error = "" + _persist_last_error("") + return self.start_connect_async(mode, force=True) + + def _schedule_fee_sync(self, mode: str) -> None: + """连接成功后触发每日同步检查(非每次全量)。""" + + def _run() -> None: + time.sleep(45) + try: + from ctp_fee_worker import try_daily_ctp_fee_sync + + def _gs(key: str, default: str = "") -> str: + from fee_specs import get_setting + return get_setting(key, default) + + def _ss(key: str, val: str) -> None: + from fee_specs import set_setting + set_setting(key, val) + + try_daily_ctp_fee_sync( + mode, + get_setting=_gs, + set_setting=_ss, + force=False, + ) + except Exception as exc: + logger.debug("CTP 手续费连接后检查: %s", exc) + + threading.Thread(target=_run, daemon=True, name="ctp-fee-sync-check").start() + + def _ensure_commission_callback(self) -> None: + if self._commission_hooked or not self._engine: + return + try: + gw = self._engine.get_gateway(GATEWAY_NAME) + td = gw.td_api + except Exception: + return + bridge = self + + def on_rsp(data: dict, error: dict, reqid: int, last: bool) -> None: + if error and int(error.get("ErrorID") or 0) != 0: + logger.debug( + "CTP commission error reqid=%s: %s", + reqid, + error.get("ErrorMsg") or error, + ) + if data and data.get("InstrumentID"): + bridge._commission_lists.setdefault(reqid, []).append(dict(data)) + ev = bridge._commission_waiters.get(reqid) + if last and ev: + ev.set() + + td.onRspQryInstrumentCommissionRate = on_rsp # type: ignore[method-assign] + self._commission_hooked = True + + def _query_commission( + self, + *, + mode: str, + instrument_id: str = "", + exchange_id: str = "", + timeout: float = 8, + ) -> list[dict]: + if self._connected_mode != mode or not self._engine: + return [] + try: + gw = self._engine.get_gateway(GATEWAY_NAME) + td = gw.td_api + except Exception as exc: + logger.debug("commission query init: %s", exc) + return [] + if not getattr(td, "login_status", False): + return [] + if not hasattr(td, "reqQryInstrumentCommissionRate"): + return [] + self._ensure_commission_callback() + reqid = int(getattr(td, "reqid", 0)) + 1 + td.reqid = reqid + ev = threading.Event() + self._commission_waiters[reqid] = ev + req = { + "BrokerID": td.brokerid, + "InvestorID": td.userid, + "InstrumentID": instrument_id or "", + "ExchangeID": exchange_id or "", + } + ret = td.reqQryInstrumentCommissionRate(req, reqid) + if ret != 0: + self._commission_waiters.pop(reqid, None) + return [] + ev.wait(timeout=timeout) + self._commission_waiters.pop(reqid, None) + return self._commission_lists.pop(reqid, []) + + def query_instrument_commission(self, ths_code: str, *, mode: str) -> dict: + """查询单合约 CTP 手续费率(需已连接)。""" + try: + sym, ex_name = ths_to_vnpy_symbol(ths_code) + except Exception: + return {} + rows = self._query_commission( + mode=mode, + instrument_id=sym, + exchange_id=ex_name, + ) + return rows[-1] if rows else {} + + def query_all_commissions(self, *, mode: str) -> list[dict]: + """批量查询全部合约手续费(InstrumentID 留空)。""" + return self._query_commission(mode=mode, timeout=45) + + def _tick_key(self, symbol: str, ex_name: str) -> str: + return f"{symbol.lower()}:{ex_name.upper()}" + + def _price_from_tick(self, tick: Any) -> Optional[float]: + for attr in ("last_price", "bid_price_1", "ask_price_1", "pre_close"): + try: + v = float(getattr(tick, attr, 0) or 0) + except (TypeError, ValueError): + v = 0.0 + if v > 0: + return v + return None + + def _lookup_tick(self, symbol: str, ex_name: str) -> Optional[float]: + if not self._engine: + return None + sym_l = symbol.lower() + ex_u = ex_name.upper() + try: + for tick in self._engine.get_all_ticks(): + ts = (getattr(tick, "symbol", "") or "").lower() + te = getattr(tick, "exchange", None) + te_s = str(te.value if hasattr(te, "value") else te or "").upper() + if ts == sym_l and te_s == ex_u: + p = self._price_from_tick(tick) + if p: + return p + except Exception as exc: + logger.debug("lookup tick: %s", exc) + return None + + def _bar_to_dict(self, bar: Any) -> dict: + dt = getattr(bar, "datetime", None) + d_str = dt.strftime("%Y-%m-%d %H:%M:%S") if dt else "" + return { + "d": d_str, + "o": float(getattr(bar, "open_price", 0) or 0), + "h": float(getattr(bar, "high_price", 0) or 0), + "l": float(getattr(bar, "low_price", 0) or 0), + "c": float(getattr(bar, "close_price", 0) or 0), + "v": float(getattr(bar, "volume", 0) or 0), + } + + def _ensure_bar_generator(self, sym: str, ex_name: str) -> None: + key = self._tick_key(sym, ex_name) + if key in self._bar_generators: + return + self._bars_1m[key] = deque(maxlen=4000) + + def on_bar(bar: Any) -> None: + row = self._bar_to_dict(bar) + if row.get("d"): + self._bars_1m[key].append(row) + + try: + from vnpy.trader.utility import BarGenerator + + self._bar_generators[key] = BarGenerator(on_bar=on_bar) + except ImportError: + logger.debug("BarGenerator unavailable") + + def _find_tick(self, symbol: str, ex_name: str) -> Any: + if not self._engine: + return None + sym_l = symbol.lower() + ex_u = ex_name.upper() + try: + for tick in self._engine.get_all_ticks(): + ts = (getattr(tick, "symbol", "") or "").lower() + te = getattr(tick, "exchange", None) + te_s = str(te.value if hasattr(te, "value") else te or "").upper() + if ts == sym_l and te_s == ex_u: + return tick + except Exception as exc: + logger.debug("find tick: %s", exc) + return None + + def _tick_to_bar(self, symbol: str, ex_name: str) -> Optional[dict]: + tick = self._find_tick(symbol, ex_name) + if not tick: + return None + lp = self._price_from_tick(tick) + if not lp or lp <= 0: + return None + dt = getattr(tick, "datetime", None) + d_str = dt.strftime("%Y-%m-%d %H:%M:%S") if dt else "" + if not d_str: + from datetime import datetime + from zoneinfo import ZoneInfo + + d_str = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") + o = float(getattr(tick, "open_price", 0) or lp) + h = float(getattr(tick, "high_price", 0) or lp) + lo = float(getattr(tick, "low_price", 0) or lp) + return { + "d": d_str, + "o": o, + "h": h, + "l": lo, + "c": lp, + "v": float(getattr(tick, "volume", 0) or 0), + } + + def _on_tick(self, tick: Any) -> None: + sym = (getattr(tick, "symbol", "") or "").lower() + te = getattr(tick, "exchange", None) + ex_s = str(te.value if hasattr(te, "value") else te or "").upper() + key = self._tick_key(sym, ex_s) + bg = self._bar_generators.get(key) + if not bg: + return + try: + bg.update_tick(tick) + except Exception as exc: + logger.debug("bar gen tick: %s", exc) + + def _ensure_tick_handler(self) -> None: + if self._tick_hooked or not self._ee: + return + try: + from vnpy.trader.event import EVENT_TICK + except ImportError: + return + + def process_tick(event: Any) -> None: + self._on_tick(event.data) + + self._ee.register(EVENT_TICK, process_tick) + self._tick_hooked = True + + def get_kline_bars_1m(self, ths_code: str, *, mode: str) -> list[dict]: + """订阅合约并返回 1 分钟 K 线(含正在形成的 bar)。""" + if self._connected_mode != mode or not self._engine: + return [] + try: + sym, ex_name = ths_to_vnpy_symbol(ths_code) + except Exception: + return [] + key = self._tick_key(sym, ex_name) + self._ensure_bar_generator(sym, ex_name) + self.subscribe_symbol(ths_code) + for _ in range(12): + if self._bars_1m.get(key) and len(self._bars_1m[key]) > 0: + break + if self._lookup_tick(sym, ex_name): + break + time.sleep(0.2) + bars_1m = list(self._bars_1m.get(key, [])) + bg = self._bar_generators.get(key) + if bg and getattr(bg, "bar", None): + forming = self._bar_to_dict(bg.bar) + if forming.get("d"): + if not bars_1m or bars_1m[-1]["d"] != forming["d"]: + bars_1m.append(forming) + else: + bars_1m[-1] = forming + if not bars_1m: + tick_bar = self._tick_to_bar(sym, ex_name) + if tick_bar: + bars_1m = [tick_bar] + return bars_1m + + def get_tick_detail(self, ths_code: str, *, mode: str) -> dict[str, Any]: + if self._connected_mode != mode or not self._engine: + return {} + try: + sym, ex_name = ths_to_vnpy_symbol(ths_code) + except Exception: + return {} + self.subscribe_symbol(ths_code) + for _ in range(8): + tick = self._find_tick(sym, ex_name) + if tick: + price = self._price_from_tick(tick) + try: + pre_close = float(getattr(tick, "pre_close", 0) or 0) + except (TypeError, ValueError): + pre_close = 0.0 + return { + "price": price, + "pre_close": pre_close if pre_close > 0 else None, + } + time.sleep(0.2) + return {} + + def subscribe_symbol(self, ths_code: str) -> None: + if not self._engine or not self._connected_mode: + return + try: + from vnpy.trader.object import SubscribeRequest + + sym, ex_name = ths_to_vnpy_symbol(ths_code) + key = self._tick_key(sym, ex_name) + self._ensure_bar_generator(sym, ex_name) + if key in self._subscribed: + return + exchange = to_vnpy_exchange(ex_name) + self._ensure_tick_handler() + req = SubscribeRequest(symbol=sym, exchange=exchange) + self._engine.subscribe(req, GATEWAY_NAME) + self._subscribed.add(key) + except Exception as exc: + logger.debug("CTP subscribe %s: %s", ths_code, exc) + + def get_tick_price(self, ths_code: str, *, mode: str) -> Optional[float]: + if self._connected_mode != mode or not self._engine: + return None + try: + sym, ex_name = ths_to_vnpy_symbol(ths_code) + except Exception: + return None + price = self._lookup_tick(sym, ex_name) + if price: + return price + self.subscribe_symbol(ths_code) + for _ in range(8): + time.sleep(0.2) + price = self._lookup_tick(sym, ex_name) + if price: + return price + return None + + def get_account(self) -> dict[str, Any]: + if not self._engine: + return {} + accounts = self._engine.get_all_accounts() + if not accounts: + return {} + acc = accounts[0] + return { + "balance": float(getattr(acc, "balance", 0) or 0), + "available": float(getattr(acc, "available", 0) or 0), + "frozen": float(getattr(acc, "frozen", 0) or 0), + "accountid": getattr(acc, "accountid", ""), + } + + def _position_margin_key(self, sym: str, direction: str) -> str: + return f"{(sym or '').lower()}:{(direction or 'long').strip().lower()}" + + def _lookup_position_open_time(self, sym: str, direction: str) -> str: + return (self._position_open_times.get(self._position_margin_key(sym, direction)) or "").strip() + + @staticmethod + def _parse_ctp_open_datetime(date_raw: str, time_raw: str = "") -> str: + """CTP OpenDate + OpenTime → YYYY-MM-DD HH:MM[:SS]。""" + d = (date_raw or "").strip() + if len(d) >= 8 and d[:8].isdigit(): + date_part = f"{d[:4]}-{d[4:6]}-{d[6:8]}" + else: + return "" + t = (time_raw or "").strip().replace(":", "") + if len(t) >= 6 and t[:6].isdigit(): + return f"{date_part} {t[0:2]}:{t[2:4]}:{t[4:6]}" + if len(t) >= 4 and t.isdigit(): + return f"{date_part} {t[0:2]}:{t[2:4]}" + return date_part + + def _parse_ctp_open_date(raw: str) -> str: + return CtpBridge._parse_ctp_open_datetime(raw, "") + + def _install_position_margin_hook(self) -> None: + """已禁用:monkey-patch CTP 持仓回调在并发下会触发 vnctptd 段错误。""" + return + + def _lookup_position_margin(self, sym: str, direction: str) -> float: + return float(self._position_margins.get(self._position_margin_key(sym, direction), 0) or 0) + + def estimate_margin_one_lot(self, ths_code: str, price: float) -> Optional[float]: + """用 CTP 合约信息估算 1 手保证金(需已连接并完成合约查询)。""" + if not self._engine or not price or price <= 0: + return None + try: + sym, ex_name = ths_to_vnpy_symbol(ths_code) + exchange = to_vnpy_exchange(ex_name) + vt_symbol = f"{sym}.{exchange.value}" + contract = self._engine.get_contract(vt_symbol) + if not contract: + return None + mult = float(getattr(contract, "size", 0) or 0) + long_r = float(getattr(contract, "long_margin_ratio", 0) or 0) + short_r = float(getattr(contract, "short_margin_ratio", 0) or 0) + ratio = max(long_r, short_r) + if mult <= 0 or ratio <= 0: + return None + return round(float(price) * mult * ratio, 2) + except Exception as exc: + logger.debug("estimate_margin_one_lot %s: %s", ths_code, exc) + return None + + def _collect_positions(self) -> list[dict[str, Any]]: + if not self._engine: + return [] + out: list[dict[str, Any]] = [] + for pos in self._engine.get_all_positions(): + vol = int(getattr(pos, "volume", 0) or 0) + if vol <= 0: + continue + d = "long" if _is_long_direction(getattr(pos, "direction", None)) else "short" + sym = getattr(pos, "symbol", "") or "" + exchange = getattr(pos, "exchange", None) + ex_name = str(exchange.value if hasattr(exchange, "value") else exchange or "") + margin = self._lookup_position_margin(sym, d) + open_time = self._lookup_position_open_time(sym, d) or None + out.append({ + "symbol": sym, + "exchange": ex_name, + "direction": d, + "lots": vol, + "avg_price": float(getattr(pos, "price", 0) or 0), + "pnl": float(getattr(pos, "pnl", 0) or 0), + "frozen": int(getattr(pos, "frozen", 0) or 0), + "margin": round(margin, 2) if margin > 0 else None, + "open_time": open_time, + }) + return out + + def refresh_positions(self) -> None: + """vnpy 内存缓存持仓;禁止 query_position(vnctptd 并发查询会段错误)。""" + return + + def list_positions(self, *, refresh_if_empty: bool = True, refresh_margin: bool = False) -> list[dict[str, Any]]: + del refresh_if_empty, refresh_margin + with _ctp_td_lock: + return self._collect_positions() + + @staticmethod + def _parse_trade_offset(offset_obj: Any) -> str: + s = str(offset_obj or "").upper() + if "OPEN" in s: + return "open" + return "close" + + @staticmethod + def _parse_trade_direction(direction_obj: Any) -> str: + return "long" if _is_long_direction(direction_obj) else "short" + + @staticmethod + def _position_direction_from_trade(trade_direction: str, offset: str) -> str: + td = (trade_direction or "long").strip().lower() + if (offset or "open").strip().lower() == "open": + return td + return "short" if td == "long" else "long" + + def _format_trade_datetime(self, dt_obj: Any, date_raw: str = "", time_raw: str = "") -> str: + if dt_obj is not None: + try: + if hasattr(dt_obj, "strftime"): + return dt_obj.strftime("%Y-%m-%d %H:%M:%S") + text = str(dt_obj).strip() + if text: + return text[:19].replace("T", " ") + except Exception: + pass + parsed = self._parse_ctp_open_datetime(date_raw, time_raw) + return parsed or "" + + def _trade_row_from_vnpy(self, trade: Any) -> Optional[dict[str, Any]]: + try: + sym = (getattr(trade, "symbol", "") or "").strip() + vol = int(getattr(trade, "volume", 0) or 0) + if not sym or vol <= 0: + return None + direction = self._parse_trade_direction(getattr(trade, "direction", None)) + offset = self._parse_trade_offset(getattr(trade, "offset", None)) + exchange = getattr(trade, "exchange", None) + ex_name = str(exchange.value if hasattr(exchange, "value") else exchange or "") + dt = self._format_trade_datetime(getattr(trade, "datetime", None)) + trade_id = str(getattr(trade, "tradeid", "") or getattr(trade, "vt_tradeid", "") or "") + order_id = str(getattr(trade, "orderid", "") or getattr(trade, "vt_orderid", "") or "") + if not trade_id: + trade_id = f"{order_id}:{sym}:{offset}:{direction}:{vol}:{getattr(trade, 'price', 0)}:{dt}" + return { + "trade_id": trade_id, + "order_id": order_id, + "symbol": sym, + "exchange": ex_name, + "direction": direction, + "offset": offset, + "position_direction": self._position_direction_from_trade(direction, offset), + "lots": vol, + "price": float(getattr(trade, "price", 0) or 0), + "datetime": dt, + } + except Exception as exc: + logger.debug("trade_row_from_vnpy: %s", exc) + return None + + def _trade_row_from_ctp_dict(self, data: dict) -> Optional[dict[str, Any]]: + try: + sym = (data.get("InstrumentID") or data.get("instrument_id") or "").strip() + vol = int(float(data.get("Volume") or data.get("volume") or 0)) + if not sym or vol <= 0: + return None + dir_raw = str(data.get("Direction") or data.get("direction") or "") + direction = "long" if dir_raw in ("0", "2") or "LONG" in dir_raw.upper() or dir_raw == "多" else "short" + off_raw = str(data.get("OffsetFlag") or data.get("offset") or "") + if off_raw in ("0",) or "OPEN" in off_raw.upper(): + offset = "open" + else: + offset = "close" + price = float(data.get("Price") or data.get("price") or 0) + trade_id = str(data.get("TradeID") or data.get("tradeid") or "").strip() + order_sys = str(data.get("OrderSysID") or data.get("orderid") or "").strip() + dt = self._format_trade_datetime( + None, + str(data.get("TradeDate") or data.get("trade_date") or ""), + str(data.get("TradeTime") or data.get("trade_time") or ""), + ) + if not trade_id: + trade_id = f"{order_sys}:{sym}:{offset}:{direction}:{vol}:{price}:{dt}" + return { + "trade_id": trade_id, + "order_id": order_sys, + "symbol": sym, + "exchange": str(data.get("ExchangeID") or data.get("exchange") or ""), + "direction": direction, + "offset": offset, + "position_direction": self._position_direction_from_trade(direction, offset), + "lots": vol, + "price": price, + "datetime": dt, + } + except Exception as exc: + logger.debug("trade_row_from_ctp_dict: %s", exc) + return None + + def _install_trade_query_hook(self) -> None: + """不再 monkey-patch CTP 成交回调(易与并发查询冲突导致 vnctptd 段错误)。""" + return + + @staticmethod + def _engine_collection_items(raw: Any) -> list[Any]: + """vnpy 不同版本可能返回 dict 或 list。""" + if raw is None: + return [] + if isinstance(raw, dict): + return list(raw.values()) + if isinstance(raw, (list, tuple)): + return list(raw) + return [raw] + + def _collect_engine_trades(self) -> list[dict[str, Any]]: + if not self._engine: + return [] + out: list[dict[str, Any]] = [] + seen: set[str] = set() + try: + trades = self._engine.get_all_trades() + except Exception: + trades = None + for trade in self._engine_collection_items(trades): + row = self._trade_row_from_vnpy(trade) + if not row: + continue + key = row["trade_id"] + if key in seen: + continue + seen.add(key) + out.append(row) + return out + + def refresh_trades(self) -> None: + """成交仅读 vnpy 内存回报;不调用 query_trade(避免 CTP 段错误)。""" + return + + def list_trades(self, *, refresh: bool = False) -> list[dict[str, Any]]: + with _ctp_td_lock: + out = self._collect_engine_trades() + out.sort(key=lambda r: (r.get("datetime") or "", r.get("trade_id") or "")) + return out + + def list_active_orders(self) -> list[dict[str, Any]]: + if not self._engine: + return [] + out: list[dict[str, Any]] = [] + try: + orders = self._engine.get_all_active_orders() + except Exception: + return [] + for order in orders or []: + status = getattr(order, "status", None) + status_s = str(status) + if status_s and not any(x in status_s for x in ("NOTTRADED", "PARTTRADED", "SUBMITTING")): + continue + vol = int(getattr(order, "volume", 0) or 0) + traded = int(getattr(order, "traded", 0) or 0) + remain = max(0, vol - traded) + if remain <= 0: + continue + direction = getattr(order, "direction", None) + d = "long" + if direction is not None and str(direction).endswith("SHORT"): + d = "short" + offset = getattr(order, "offset", None) + offset_s = str(offset or "") + sym = getattr(order, "symbol", "") or "" + exchange = getattr(order, "exchange", None) + ex_name = str(exchange.value if hasattr(exchange, "value") else exchange or "") + out.append({ + "symbol": sym, + "exchange": ex_name, + "direction": d, + "lots": remain, + "price": float(getattr(order, "price", 0) or 0), + "offset": offset_s, + "order_id": str(getattr(order, "orderid", "") or ""), + "status": status_s, + }) + return out + + def send_order( + self, + *, + ths_code: str, + offset: str, + direction: str, + lots: int, + price: float, + order_type: str = "limit", + ) -> str: + from vnpy.trader.constant import Direction, Offset, OrderType + from vnpy.trader.object import OrderRequest + + if not self._engine: + raise RuntimeError("CTP 未初始化") + if not self._td_logged_in(): + raise RuntimeError("CTP 交易通道未登录,请重连后再下单") + + sym, ex_name = ths_to_vnpy_symbol(ths_code) + exchange = to_vnpy_exchange(ex_name) + lots = max(1, int(lots)) + tick = float(get_contract_spec(ths_code).get("tick_size") or 1.0) + + offset = (offset or "open").lower() + direction = (direction or "long").lower() + + if offset in ("open", "open_long", "open_short"): + d = Direction.LONG if direction == "long" or offset == "open_long" else Direction.SHORT + off = Offset.OPEN + elif offset in ("close", "close_long", "close_short"): + hold = "long" if direction == "long" or offset == "close_long" else "short" + if hold == "long": + d = Direction.SHORT + else: + d = Direction.LONG + off = self._resolve_close_offset(sym, ex_name, hold, lots) + else: + raise ValueError(f"未知开平: {offset}") + + use_market = (order_type or "limit").lower() == "market" + if use_market: + ot = OrderType.FAK + price = self._aggressive_limit_price(ths_code, sym, ex_name, d, tick, price) + else: + ot = OrderType.LIMIT + price = round_to_tick(float(price), tick) + if price <= 0: + raise ValueError("委托价格无效,请检查行情或手动填写价格") + + req = OrderRequest( + symbol=sym, + exchange=exchange, + direction=d, + type=ot, + volume=lots, + price=price, + offset=off, + ) + logger.info( + "CTP 报单 %s %s %s %s手 @%s offset=%s type=%s", + sym, ex_name, d, lots, price, off, ot, + ) + with _ctp_td_lock: + vt_orderid = self._engine.send_order(req, GATEWAY_NAME) + if not vt_orderid: + raise RuntimeError("CTP 拒单或未返回委托号(请检查合约代码、价格是否为最小变动价位整数倍)") + return str(vt_orderid) + + def cancel_order(self, vt_orderid: str) -> bool: + if not self._engine or not vt_orderid: + return False + try: + with _ctp_td_lock: + order = self._engine.get_order(vt_orderid) + if order is None: + return False + req = order.create_cancel_request() + self._engine.cancel_order(req, GATEWAY_NAME) + logger.info("CTP 撤单 %s", vt_orderid) + return True + except Exception as exc: + logger.warning("CTP 撤单失败 %s: %s", vt_orderid, exc) + return False + + +def get_bridge() -> CtpBridge: + global _bridge + with _bridge_lock: + if _bridge is None: + _bridge = CtpBridge() + return _bridge + + +def try_init_vnpy(_settings: dict | None = None) -> bool: + return get_bridge().available() + + +def vnpy_available() -> bool: + return get_bridge().available() + + +def ctp_connect(mode: str, *, force: bool = False) -> dict[str, Any]: + b = get_bridge() + b.connect(mode, force=force) + return b.status(mode) + + +def ctp_start_connect(mode: str, *, force: bool = False) -> dict[str, Any]: + """非阻塞发起连接,供 Web API 使用。""" + b = get_bridge() + info = b.start_connect_async(mode, force=force) + st = b.status(mode) + return {**info, "status": st} + + +def ctp_try_auto_reconnect(mode: str) -> bool: + """断线时静默异步重连;已连接且交易通道正常则不再重复 connect。""" + b = get_bridge() + if not b.available(): + return False + if b.connect_in_progress(): + return False + if b.login_cooldown_remaining() > 0: + return False + st = _setting_for_mode(mode) + if not st.get("用户名") or not st.get("密码") or not st.get("交易服务器"): + return False + if b.connected_mode == mode: + if b._td_logged_in() or b.ping(): + return True + recent = time.time() - float(getattr(b, "_last_connect_ok_ts", 0) or 0) + if recent < 120: + logger.debug("CTP 跳过自动重连:刚连接 %.0fs", recent) + return True + td = st.get("交易服务器", "") + ok, err = probe_tcp_address(td, timeout=4.0) + if not ok: + b._last_error = ( + f"SimNow 交易前置不可达:{td}({err})。" + "请更新 SIMNOW_TD_ADDRESS 并确认服务器出网。" + ) + return False + info = b.start_connect_async(mode, force=False) + return bool( + info.get("connected") + or info.get("connecting") + or info.get("started") + ) + + +def ctp_status(mode: str) -> dict[str, Any]: + st = get_bridge().status(mode) + if not st.get("connected") and not st.get("connecting"): + setting = _setting_for_mode(mode) + td = setting.get("交易服务器", "") + if td: + ok, err = probe_tcp_address(td, timeout=3.0) + st["td_reachable"] = ok + if not ok and not st.get("last_error"): + st["last_error"] = ( + f"SimNow 交易前置不可达:{td}({err})" + ) + return st + + +def ctp_get_account(mode: str) -> dict[str, Any]: + b = get_bridge() + b.ensure_connected(mode) + return b.get_account() + + +def ctp_list_positions( + mode: str, + *, + refresh_if_empty: bool = True, + refresh_margin: bool = False, +) -> list[dict[str, Any]]: + b = get_bridge() + if b.connected_mode != mode or not b.ping(): + return [] + return b.list_positions(refresh_if_empty=refresh_if_empty, refresh_margin=refresh_margin) + + +def ctp_list_active_orders(mode: str) -> list[dict[str, Any]]: + b = get_bridge() + b.ensure_connected(mode) + return b.list_active_orders() + + +def ctp_cancel_order(mode: str, vt_orderid: str) -> bool: + b = get_bridge() + b.ensure_connected(mode) + return b.cancel_order(vt_orderid) + + +def ctp_list_trades(mode: str, *, refresh: bool = False) -> list[dict[str, Any]]: + b = get_bridge() + if b.connected_mode != mode or not b.ping(): + return [] + return b.list_trades(refresh=refresh) + + +def ctp_get_tick_price(mode: str, ths_code: str) -> Optional[float]: + """CTP 柜台最新价(需已连接并订阅)。""" + b = get_bridge() + if b.connected_mode != mode: + return None + try: + return b.get_tick_price(ths_code, mode=mode) + except Exception as exc: + logger.debug("ctp_get_tick_price: %s", exc) + return None + + +def ctp_get_tick_detail(mode: str, ths_code: str) -> dict[str, Any]: + b = get_bridge() + if b.connected_mode != mode: + return {} + try: + return b.get_tick_detail(ths_code, mode=mode) + except Exception as exc: + logger.debug("ctp_get_tick_detail: %s", exc) + return {} + + +def ctp_estimate_margin_one_lot(mode: str, ths_code: str, price: float) -> Optional[float]: + b = get_bridge() + if b.connected_mode != mode or not b.ping(): + return None + try: + return b.estimate_margin_one_lot(ths_code, price) + except Exception as exc: + logger.debug("ctp_estimate_margin_one_lot: %s", exc) + return None + + +def get_ctp_balance(mode: str) -> Optional[float]: + try: + acc = ctp_get_account(mode) + bal = acc.get("balance") + return float(bal) if bal else None + except Exception as exc: + logger.debug("get_ctp_balance: %s", exc) + return None + + +def execute_order( + conn, + *, + mode: str, + offset: str, + symbol: str, + direction: str, + lots: int, + price: float, + settings: dict | None = None, + order_type: str = "limit", +) -> dict[str, Any]: + """统一下单:simulation=SimNow,live=期货公司 CTP。""" + del conn, settings + if mode not in ("simulation", "live"): + raise ValueError("未知交易模式") + if not vnpy_available(): + raise ValueError( + "请先安装 vnpy 与 vnpy_ctp:pip install vnpy vnpy_ctp\n" + f"模拟盘需配置 .env 中 SIMNOW_USER / SIMNOW_PASSWORD 等" + ) + b = get_bridge() + b.require_connected(mode) + order_id = b.send_order( + ths_code=symbol, + offset=offset, + direction=direction, + lots=lots, + price=price, + order_type=order_type, + ) + return { + "order_id": order_id, + "mode": mode, + "mode_label": _mode_label(mode), + "symbol": symbol, + "lots": lots, + "price": price, + }