diff --git a/.env.example b/.env.example index 08cc69b..85ac736 100644 --- a/.env.example +++ b/.env.example @@ -3,19 +3,46 @@ HOST=0.0.0.0 PORT=6600 DEBUG=false -# Flask Session 密钥(部署时务必改为随机字符串,deploy.sh 首次会自动生成) SECRET_KEY=change-this-to-a-random-secret-key -# 初始管理员(首次建库自动写入;已建库后修改需设 ADMIN_SYNC_FROM_ENV=true 并重启) ADMIN_USERNAME=admin ADMIN_PASSWORD=change-me-on-first-login ADMIN_SYNC_FROM_ENV=false -# 企业微信 Webhook(也可在系统设置页面修改) WECHAT_WEBHOOK= -# 行情数据源: sina(默认,免费)| auto(有机构 token 时优先同花顺)| ths QUOTE_SOURCE=sina - -# 同花顺 iFinD refresh_token(仅机构用户,普通用户留空即可) THS_REFRESH_TOKEN= + +# 交易模式:simulation=SimNow,live=期货公司(系统设置页可改) +TRADING_MODE=simulation +POSITION_SIZING_MODE=risk +RISK_PERCENT=1 + +# —— SimNow 模拟盘(在 simnow.com 注册后填写)—— +SIMNOW_USER= +SIMNOW_PASSWORD= +SIMNOW_BROKER_ID=9999 +# 7×24 环境示例(以 SimNow 官网最新为准) +SIMNOW_TD_ADDRESS=tcp://180.168.146.187:10201 +SIMNOW_MD_ADDRESS=tcp://180.168.146.187:10211 +SIMNOW_APP_ID=simnow_client_test +SIMNOW_AUTH_CODE=0000000000000000 +SIMNOW_PRODUCT_INFO=simnow_client_test + +# —— 期货公司实盘(后期接入)—— +CTP_LIVE_USER= +CTP_LIVE_PASSWORD= +CTP_LIVE_BROKER_ID= +CTP_LIVE_TD_ADDRESS= +CTP_LIVE_MD_ADDRESS= +CTP_LIVE_APP_ID= +CTP_LIVE_AUTH_CODE= +CTP_LIVE_PRODUCT_INFO= + +# 账户冷静期 +RISK_CONTROL_ENABLED=true +RISK_COOLING_HOURS_MANUAL=4 +RISK_COOLING_HOURS_MANUAL_JOURNAL=1 +RISK_MANUAL_CLOSE_DAILY_LIMIT=2 +MAX_ACTIVE_POSITIONS=1 diff --git a/app.py b/app.py index 56f0979..9ad1403 100644 --- a/app.py +++ b/app.py @@ -34,6 +34,9 @@ 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 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")) @@ -306,6 +309,7 @@ def init_db(): data_json TEXT NOT NULL, updated_at TEXT NOT NULL)''') ensure_kline_tables(conn) + init_strategy_tables(conn) conn.commit() conn.close() @@ -322,6 +326,12 @@ def init_db(): 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", "risk") + if not get_setting("risk_percent"): + set_setting("risk_percent", "1") conn = get_db() fee_cnt = conn.execute("SELECT COUNT(*) FROM fee_rates").fetchone()[0] conn.close() @@ -569,6 +579,9 @@ def background_task(): 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) @@ -583,8 +596,6 @@ def start_background_threads(): threading.Thread(target=refresh_main_index, daemon=True).start() -start_background_threads() - # —————————————— 登录 —————————————— def login_required(f): @@ -1278,6 +1289,14 @@ def add_review(): 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() @@ -1535,9 +1554,25 @@ def settings(): flash("实盘资金不能为负数") else: set_setting("live_capital", str(val)) - flash("实盘资金已保存") + flash("参考资金已保存(CTP 已连接时以 SimNow/柜台权益为准)") except ValueError: 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", "risk").strip() + if sizing not in ("fixed", "risk"): + sizing = "risk" + set_setting("trading_mode", mode) + set_setting("position_sizing_mode", sizing) + 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: + flash("风险比例无效") + return redirect(url_for("settings")) + flash("交易模式已保存") elif action == "password": old_p = request.form.get("old_password", "") new_p = request.form.get("new_password", "") @@ -1563,8 +1598,26 @@ def settings(): username=username, live_capital=live_capital, quote_label=get_quote_source_label(), + trading_mode=get_setting("trading_mode", "simulation"), + position_sizing_mode=get_setting("position_sizing_mode", "risk"), + risk_percent=get_setting("risk_percent", "1"), ) + +install_trading( + app, + login_required=login_required, + 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__": diff --git a/contract_specs.py b/contract_specs.py index 85ead49..939192f 100644 --- a/contract_specs.py +++ b/contract_specs.py @@ -2,13 +2,13 @@ import re from typing import Optional -DEFAULT_SPEC = {"mult": 10, "margin_rate": 0.10} +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}, - "au": {"mult": 1000, "margin_rate": 0.10}, - "cu": {"mult": 5, "margin_rate": 0.10}, + "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}, @@ -52,10 +52,14 @@ _SPEC_BY_THS: dict[str, dict] = { "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}, - "IH": {"mult": 300, "margin_rate": 0.12}, - "IC": {"mult": 200, "margin_rate": 0.12}, - "IM": {"mult": 200, "margin_rate": 0.12}, + "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, } @@ -67,7 +71,10 @@ def get_contract_spec(ths_code: str) -> dict: 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: - return {"mult": spec["mult"], "margin_rate": spec["margin_rate"]} + 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) diff --git a/ctp_symbol.py b/ctp_symbol.py new file mode 100644 index 0000000..8be3df4 --- /dev/null +++ b/ctp_symbol.py @@ -0,0 +1,57 @@ +"""同花顺合约代码 → 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/docs/TRADING.md b/docs/TRADING.md new file mode 100644 index 0000000..580a6c4 --- /dev/null +++ b/docs/TRADING.md @@ -0,0 +1,40 @@ +# 期货下单与策略交易 + +## 两种交易通道 + +| 设置 | 实际连接 | 资金 | +|------|----------|------| +| **模拟盘** | **SimNow**(vnpy → CTP 仿真前置) | SimNow 账户权益 | +| **实盘** | **期货公司 CTP**(后期配置 `CTP_LIVE_*`) | 柜台权益 | + +已移除「本地 SQLite 假撮合」;模拟盘与实盘均走 **vnpy_ctp**,仅 `.env` 前置与账号不同。 + +## 首次使用 SimNow + +1. 在 [SimNow](https://www.simnow.com.cn/) 注册仿真账号 +2. 复制 `.env.example` → `.env`,填写 `SIMNOW_USER`、`SIMNOW_PASSWORD` +3. 核对 SimNow 官网最新的 **7×24 或交易时段** 前置地址 +4. `pip install vnpy vnpy_ctp` +5. 启动程序 → **期货下单** → 点击 **连接 CTP** +6. 连接成功后,权益、持仓、下单均来自 SimNow + +## 参考资金 + +系统设置中的「参考资金」仅在 **CTP 未连接** 时用于品种推荐与以损定仓估算;连接 SimNow 后自动改用柜台权益。 + +## 导航 + +| 页面 | 路径 | +|------|------| +| 品种推荐 | `/recommend` | +| 期货下单 | `/trade` | +| 策略交易 | `/strategy` | +| 策略记录 | `/strategy/records` | + +## API + +| 接口 | 说明 | +|------|------| +| `POST /api/ctp/connect` | 按当前模式连接 SimNow 或实盘 CTP | +| `GET /api/ctp/status` | 连接状态与缺失配置项 | +| `POST /api/trade/order` | 限价报单(需已连接 CTP) | diff --git a/install_trading.py b/install_trading.py new file mode 100644 index 0000000..371baaf --- /dev/null +++ b/install_trading.py @@ -0,0 +1,690 @@ +"""期货下单、品种推荐、策略交易路由注册。""" +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any, Callable + +from flask import flash, jsonify, redirect, render_template, request, url_for + +from contract_specs import calc_position_metrics, get_contract_spec +from position_sizing import ( + MODE_FIXED, + MODE_RISK, + calc_lots_by_risk, + calc_order_tick_metrics, + normalize_sizing_mode, +) +from product_recommend import list_product_recommendations +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 +from trading_context import ( + TRADING_MODE_LIVE, + TRADING_MODE_SIM, + get_account_capital, + get_risk_percent, + get_sizing_mode, + get_trading_mode, + trading_mode_label, +) +from ctp_symbol import ths_to_vnpy_symbol +from vnpy_bridge import ( + ctp_connect, + ctp_get_account, + ctp_list_positions, + ctp_status, + execute_order, +) + + +def install_trading(app, *, login_required, get_db, get_setting, set_setting, fetch_price, send_wechat_msg): + """注册交易相关路由。""" + + 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)), + } + + def _capital(conn) -> float: + return get_account_capital(conn, get_setting) + + def _main_price(product_ths: str): + 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) + if codes: + return fetch_price(sym, codes.get("market_code", ""), codes.get("sina_code", "")) + return None + + def _ctp_account(mode: str) -> dict: + try: + return ctp_get_account(mode) + except Exception: + return {} + + def _ctp_positions(mode: str) -> list: + try: + return ctp_list_positions(mode) + except Exception: + return [] + + def _match_ctp_symbol(ctp_sym: str, ths: str) -> bool: + a = (ctp_sym or "").lower() + b = (ths or "").lower() + if a == b: + return True + try: + vnpy_sym, _ = ths_to_vnpy_symbol(ths) + return a == vnpy_sym.lower() + except Exception: + return False + + @app.route("/trade") + @login_required + def trade_page(): + conn = get_db() + init_strategy_tables(conn) + mode = get_trading_mode(get_setting) + ctp_st = ctp_status(mode) + capital = _capital(conn) + sizing = get_sizing_mode(get_setting) + risk = get_risk_status(conn) + ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {} + positions = _ctp_positions(mode) if ctp_st.get("connected") else [] + conn.close() + return render_template( + "trade.html", + trading_mode=mode, + trading_mode_label=trading_mode_label(get_setting), + sizing_mode=sizing, + risk_percent=get_risk_percent(get_setting), + capital=capital, + risk_status=risk, + ctp_status=ctp_st, + ctp_account=ctp_acc, + ctp_positions=positions, + ) + + @app.route("/recommend") + @login_required + def recommend_page(): + conn = get_db() + capital = _capital(conn) + conn.close() + rows = list_product_recommendations(capital, _main_price) + return render_template("recommend.html", capital=capital, rows=rows, trading_mode_label=trading_mode_label(get_setting)) + + @app.route("/strategy") + @login_required + 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) + if sizing == MODE_RISK: + lots, err = calc_lots_by_risk(entry, sl, direction, capital, get_risk_percent(get_setting), sym) + if err: + return jsonify({"ok": False, "error": err}), 400 + 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 + if not sym or price <= 0: + return jsonify({"ok": False, "error": "品种或价格无效"}), 400 + conn = get_db() + init_strategy_tables(conn) + if offset.startswith("open"): + err = assert_can_open(conn) + if err: + conn.close() + return jsonify({"ok": False, "error": err}), 403 + mode = get_trading_mode(get_setting) + sizing = get_sizing_mode(get_setting) + if offset.startswith("open") and sizing == MODE_RISK: + 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_risk(price, sl, direction, _capital(conn), get_risk_percent(get_setting), sym) + if err: + conn.close() + return jsonify({"ok": False, "error": err}), 400 + lots = lots_calc or lots + try: + result = execute_order( + conn, + mode=mode, + offset=offset, + symbol=sym, + direction=direction, + lots=lots, + price=price, + settings=_settings_dict(), + ) + if offset.startswith("open"): + sl = d.get("stop_loss") + tp = d.get("take_profit") + codes = ths_to_codes(sym) + conn.execute( + """INSERT INTO trade_order_monitors ( + symbol, symbol_name, market_code, direction, lots, entry_price, + stop_loss, take_profit, open_time, monitor_type, status + ) VALUES (?,?,?,?,?,?,?,?,?,?, 'active')""", + ( + sym, + codes.get("name", sym) if codes else sym, + codes.get("market_code", "") if codes else "", + direction, + lots, + price, + float(sl) if sl else None, + float(tp) if tp else None, + datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "manual", + ), + ) + conn.commit() + send_wechat_msg(f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}") + conn.close() + return jsonify({"ok": True, "result": result, "lots": lots}) + except ValueError 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(): + mode = get_trading_mode(get_setting) + force = bool((request.get_json(silent=True) or {}).get("force")) + try: + st = ctp_connect(mode, force=force) + acc = _ctp_account(mode) + return jsonify({"ok": True, "status": st, "account": acc}) + except Exception as exc: + st = ctp_status(mode) + return jsonify({"ok": False, "error": str(exc), "status": st}), 400 + + @app.route("/api/ctp/status") + @login_required + def api_ctp_status(): + mode = get_trading_mode(get_setting) + st = ctp_status(mode) + acc = _ctp_account(mode) if st.get("connected") else {} + return jsonify({"ok": True, "status": st, "account": acc}) + + @app.route("/api/account_snapshot") + @login_required + def api_account_snapshot(): + conn = get_db() + init_strategy_tables(conn) + mode = get_trading_mode(get_setting) + ctp_st = ctp_status(mode) + capital = _capital(conn) + risk = get_risk_status(conn) + ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {} + positions = _ctp_positions(mode) if ctp_st.get("connected") else [] + conn.close() + return 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, + }) + + @app.route("/api/recommend/list") + @login_required + def api_recommend_list(): + conn = get_db() + capital = _capital(conn) + conn.close() + return jsonify({"ok": True, "capital": capital, "rows": list_product_recommendations(capital, _main_price)}) + + @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 diff --git a/position_sizing.py b/position_sizing.py new file mode 100644 index 0000000..42e9b0d --- /dev/null +++ b/position_sizing.py @@ -0,0 +1,94 @@ +"""期货计仓:固定张数 / 以损定仓(不含币圈全仓杠杆模式)。""" +from __future__ import annotations + +import math +from typing import Optional + +from contract_specs import get_contract_spec + +MODE_FIXED = "fixed" +MODE_RISK = "risk" + + +def normalize_sizing_mode(raw: str) -> str: + m = (raw or MODE_RISK).strip().lower() + return m if m in (MODE_FIXED, MODE_RISK) else MODE_RISK + + +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 calc_lots_by_risk( + entry: float, + stop_loss: float, + direction: str, + capital: float, + risk_percent: float, + ths_code: str, + *, + max_lots: Optional[int] = None, +) -> tuple[Optional[int], Optional[str]]: + """以损定仓:返回 (手数, 错误信息)。""" + try: + entry_f = float(entry) + sl_f = float(stop_loss) + cap = float(capital) + rp = float(risk_percent) + except (TypeError, ValueError): + return None, "参数格式错误" + if entry_f <= 0 or cap <= 0 or rp <= 0: + return None, "入场价、资金或风险比例无效" + spec = get_contract_spec(ths_code) + mult = spec["mult"] + d = (direction or "long").strip().lower() + if d == "short": + per_lot_risk = (sl_f - entry_f) * mult + else: + per_lot_risk = (entry_f - sl_f) * mult + if per_lot_risk <= 0: + return None, "止损方向与入场价不匹配" + budget = cap * rp / 100.0 + lots = int(math.floor(budget / per_lot_risk)) + if lots < 1: + return None, f"按 {rp}% 风险预算,当前止损距离下不足 1 手" + margin_rate = spec["margin_rate"] + margin_per_lot = entry_f * mult * margin_rate + max_by_margin = int(math.floor(cap * 0.85 / margin_per_lot)) if margin_per_lot > 0 else lots + if max_by_margin < 1: + return None, "可用资金不足以覆盖 1 手保证金" + lots = min(lots, max_by_margin) + if max_lots is not None: + lots = min(lots, int(max_lots)) + return lots, None + + +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, + } diff --git a/product_recommend.py b/product_recommend.py new file mode 100644 index 0000000..9a7a3bf --- /dev/null +++ b/product_recommend.py @@ -0,0 +1,96 @@ +"""按账户资金推荐可交易品种(期货核心筛选)。""" +from __future__ import annotations + +from typing import Callable, Optional + +from contract_specs import get_contract_spec +from symbols import PRODUCTS + + +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_position_pct: float = 50.0, + default_stop_ticks: int = 20, +) -> dict: + """评估单品种在当前资金下是否可交易。""" + ths = product.get("ths") or "" + name = product.get("name") or ths + exchange = product.get("exchange") or "" + 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) + + if p <= 0: + return { + "ths": ths, + "name": name, + "exchange": exchange, + "status": "no_price", + "status_label": "暂无行情", + "min_capital_one_lot": None, + "margin_one_lot": None, + "risk_one_lot_1pct": None, + } + + margin_one = p * mult * margin_rate + min_capital = margin_one / (max_position_pct / 100.0) if max_position_pct > 0 else margin_one + 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 + + can_margin = cap >= min_capital + can_risk = cap > 0 and risk_one_lot <= cap * 0.01 + + if can_margin and can_risk: + status, label = "ok", "推荐" + elif can_margin: + status, label = "margin_ok", "可开1手·止损偏宽" + else: + status, label = "blocked", "资金不足" + + return { + "ths": ths, + "name": name, + "exchange": exchange, + "price": round(p, 4), + "mult": mult, + "tick_size": tick, + "margin_one_lot": round(margin_one, 2), + "min_capital_one_lot": round(min_capital, 2), + "risk_one_lot_1pct": round(risk_one_lot, 2), + "risk_pct_1lot_at_1pct_rule": round(risk_pct_1lot, 2), + "status": status, + "status_label": label, + } + + +def list_product_recommendations( + capital: float, + price_fn: Callable[[str], Optional[float]], + *, + max_position_pct: float = 50.0, +) -> list[dict]: + """扫描全部品种并排序:推荐 > 可开 > 不足。""" + rows = [] + for p in PRODUCTS: + ths = p["ths"] + main_code = price_fn(ths) + row = assess_product_for_capital( + p, capital, main_code, max_position_pct=max_position_pct + ) + rows.append(row) + order = {"ok": 0, "margin_ok": 1, "blocked": 2, "no_price": 3} + rows.sort(key=lambda r: (order.get(r["status"], 9), r.get("min_capital_one_lot") or 1e18)) + return rows diff --git a/requirements.txt b/requirements.txt index 1b79433..52d1df5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,5 @@ python-dotenv==1.0.1 Werkzeug==3.0.3 matplotlib==3.9.2 akshare==1.18.64 +# 实盘 / 模拟 CTP(SimNow + 期货公司) +# pip install vnpy vnpy_ctp diff --git a/risk/__init__.py b/risk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/risk/account_risk_lib.py b/risk/account_risk_lib.py new file mode 100644 index 0000000..1c18639 --- /dev/null +++ b/risk/account_risk_lib.py @@ -0,0 +1,271 @@ +"""账户冷静期 / 日冻结(自 crypto_monitor 复制并简化为单账户期货版)。""" +from __future__ import annotations + +import os +from datetime import datetime +from typing import Any, Optional +from zoneinfo import ZoneInfo + +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 + + +def ensure_account_risk_schema(conn) -> None: + 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)" + ) + + +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) -> 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), + ) + 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) + 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, + } + + +def assert_can_open(conn) -> Optional[str]: + rs = get_risk_status(conn) + if not rs.get("can_trade"): + return rs.get("reason") or "当前不可开仓" + return None diff --git a/static/css/trade.css b/static/css/trade.css new file mode 100644 index 0000000..d715958 --- /dev/null +++ b/static/css/trade.css @@ -0,0 +1,20 @@ +.trade-page{max-width:720px;margin:0 auto} +.trade-top-bar{display:flex;flex-wrap:wrap;gap:.65rem;align-items:center;margin-bottom:1rem} +.trade-order-card{padding:1.25rem} +.trade-tabs{display:flex;gap:1rem;margin-bottom:1rem;font-size:.88rem} +.trade-tabs span.active{color:var(--accent);font-weight:600;border-bottom:2px solid var(--accent);padding-bottom:.25rem} +.trade-tabs a{color:var(--text-muted);text-decoration:none} +.trade-input-row,.trade-risk-row{display:grid;grid-template-columns:2fr 1fr 1fr;gap:.65rem;margin-bottom:.75rem} +.trade-field label{display:block;font-size:.72rem;margin-bottom:.25rem;color:var(--text-label)} +.trade-btn-row{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem;margin:1rem 0} +.trade-btn{border:none;border-radius:8px;padding:.75rem .35rem;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:.15rem;color:#fff;font-weight:600} +.trade-btn .btn-price{font-size:1.1rem} +.trade-btn .btn-label{font-size:.85rem} +.trade-btn .btn-sub{font-size:.68rem;opacity:.85;font-weight:400} +.trade-btn.long{background:linear-gradient(180deg,#e74c3c,#c0392b)} +.trade-btn.lock{background:linear-gradient(180deg,#27ae60,#1e8449)} +.trade-btn.close{background:linear-gradient(180deg,#3498db,#2980b9)} +.trade-footer{background:var(--card-inner);border-radius:8px;padding:.75rem 1rem;font-size:.82rem;line-height:1.55;border:1px solid var(--card-border)} +.trade-footer strong{color:var(--accent)} +.rec-blocked td{opacity:.55} +.rec-ok td:first-child{font-weight:600} diff --git a/static/js/market.js b/static/js/market.js index 4f383a7..d7b7ee2 100644 --- a/static/js/market.js +++ b/static/js/market.js @@ -242,41 +242,59 @@ function getDataZoom(c, preserve) { var defStart = getDefaultZoomStart(); - var zoom = [ - { - type: 'inside', - xAxisIndex: [0, 1], - start: defStart, - end: 100, - zoomOnMouseWheel: true, - moveOnMouseMove: true, - moveOnMouseWheel: false, + var xZoom = { + type: 'inside', + id: 'dzInsideX', + xAxisIndex: [0, 1], + start: defStart, + end: 100, + filterMode: 'none', + zoomOnMouseWheel: true, + moveOnMouseMove: true, + moveOnMouseWheel: false, + preventDefaultMouseMove: true, + minSpan: 2, + }; + var yZoom = { + type: 'inside', + id: 'dzInsideY', + yAxisIndex: [0], + orient: 'vertical', + filterMode: 'none', + zoomOnMouseWheel: true, + moveOnMouseMove: true, + preventDefaultMouseMove: true, + }; + var slider = { + type: 'slider', + id: 'dzSlider', + xAxisIndex: [0, 1], + start: defStart, + end: 100, + height: 22, + bottom: 4, + borderColor: c.grid, + backgroundColor: c.bg, + fillerColor: c.area, + handleStyle: { color: c.sliderFill }, + dataBackground: { + lineStyle: { color: c.grid, opacity: 0.35 }, + areaStyle: { color: c.area }, }, - { - type: 'slider', - xAxisIndex: [0, 1], - start: defStart, - end: 100, - height: 22, - bottom: 4, - borderColor: c.grid, - backgroundColor: c.bg, - fillerColor: c.area, - handleStyle: { color: c.sliderFill }, - dataBackground: { - lineStyle: { color: c.grid }, - areaStyle: { color: c.area }, - }, - textStyle: { color: c.text, fontSize: 10 }, - }, - ]; + textStyle: { color: c.text, fontSize: 10 }, + filterMode: 'none', + brushSelect: false, + }; + var zoom = [xZoom, yZoom, slider]; if (preserve && chart) { var opt = chart.getOption(); if (opt && opt.dataZoom) { - opt.dataZoom.forEach(function (z, i) { - if (zoom[i] && z.start != null && z.end != null) { - zoom[i].start = z.start; - zoom[i].end = z.end; + opt.dataZoom.forEach(function (z) { + if (!z.id) return; + var target = zoom.find(function (t) { return t.id === z.id; }); + if (target && z.start != null && z.end != null) { + target.start = z.start; + target.end = z.end; } }); } @@ -284,6 +302,11 @@ return zoom; } + function isFollowingLatest() { + var z = getZoomRange(); + return z.end >= 98; + } + function mapSeriesData(bars, values, gapDay) { if (!gapDay) return values; return bars.map(function (b, i) { @@ -303,6 +326,7 @@ var times = bars.map(function (b) { return b.time; }); var isLine = data.chart_type === 'line' || data.period === 'timeshare'; var gapDay = chartOpts.gapDay; + var followLatest = preserveZoom && isFollowingLatest(); var dataZoom = getDataZoom(c, preserveZoom); var zoom = preserveZoom ? getZoomRange() : { start: dataZoom[0].start, end: dataZoom[0].end }; var vIdx = visibleIndices(bars, zoom); @@ -326,6 +350,7 @@ boundaryGap: gapDay ? false : true, axisLabel: { color: c.text, fontSize: 10 }, axisLine: { lineStyle: { color: c.grid } }, + splitLine: { show: false }, }; var xAxis1 = { type: xAxisType, @@ -333,6 +358,7 @@ boundaryGap: gapDay ? false : true, axisLabel: { show: false }, axisLine: { lineStyle: { color: c.grid } }, + splitLine: { show: false }, }; if (!gapDay) { xAxis0.data = times; @@ -344,14 +370,27 @@ animation: false, tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, axisPointer: { link: [{ xAxisIndex: 'all' }] }, - dataZoom: dataZoom, grid: grids, xAxis: [xAxis0, xAxis1], yAxis: [ - { scale: true, gridIndex: 0, splitLine: { lineStyle: { color: c.grid } }, axisLabel: { color: c.text } }, - { scale: true, gridIndex: 1, splitLine: { show: false }, axisLabel: { color: c.text, fontSize: 10 }, splitNumber: 2 }, + { + scale: true, + gridIndex: 0, + splitLine: { show: false }, + axisLabel: { color: c.text }, + }, + { + scale: true, + gridIndex: 1, + splitLine: { show: false }, + axisLabel: { color: c.text, fontSize: 10 }, + splitNumber: 2, + }, ], }; + if (!preserveZoom) { + base.dataZoom = dataZoom; + } var series = []; var mainMark = { @@ -465,7 +504,12 @@ }; } - chart.setOption(Object.assign(base, { series: series }), true); + if (preserveZoom) { + chart.setOption(Object.assign(base, { series: series }), false); + } else { + chart.setOption(Object.assign(base, { series: series }), true); + dataZoomBound = false; + } var title = (data.chart_symbol || data.symbol || '') + ' · ' + periodLabel(data.period); chart.setOption({ @@ -478,6 +522,22 @@ } : { show: false }, }); + if (followLatest) { + var span = zoom.end - zoom.start; + chart.dispatchAction({ + type: 'dataZoom', + dataZoomIndex: 0, + start: Math.max(0, 100 - span), + end: 100, + }); + chart.dispatchAction({ + type: 'dataZoom', + dataZoomIndex: 2, + start: Math.max(0, 100 - span), + end: 100, + }); + } + bindDataZoomHL(); } @@ -656,12 +716,16 @@ if (start === 0) end = newSpan; else start = end - newSpan; } - chart.dispatchAction({ type: 'dataZoom', start: start, end: end }); + chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start: start, end: end }); + chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 2, start: start, end: end }); } function resetDataZoom() { if (!chart) return; - chart.dispatchAction({ type: 'dataZoom', start: getDefaultZoomStart(), end: 100 }); + var start = getDefaultZoomStart(); + chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start: start, end: 100 }); + chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 2, start: start, end: 100 }); + chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start: 0, end: 100 }); } function bindPeriodTabs() { diff --git a/static/js/strategy.js b/static/js/strategy.js new file mode 100644 index 0000000..fc9aeb4 --- /dev/null +++ b/static/js/strategy.js @@ -0,0 +1,76 @@ +(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; + } + + 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 () { + jsonPost('/api/strategy/trend/preview', formData(trendForm)).then(function (d) { + if (!d.ok) { previewEl.textContent = d.error || '预览失败'; btnExec.hidden = true; return; } + trendPayload = formData(trendForm); + previewEl.textContent = JSON.stringify(d.plan, null, 2); + btnExec.hidden = false; + }); + }); + } + if (btnExec) { + btnExec.addEventListener('click', function () { + if (!trendPayload) return; + jsonPost('/api/strategy/trend/execute', trendPayload).then(function (d) { + if (!d.ok) { alert(d.error); return; } + location.reload(); + }); + }); + } + + 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 () { + jsonPost('/api/strategy/roll/preview', formData(rollForm)).then(function (d) { + if (!d.ok) { rollPrev.textContent = d.error; btnRollE.hidden = true; return; } + rollPrev.textContent = JSON.stringify(d.preview, null, 2); + btnRollE.hidden = false; + }); + }); + } + if (btnRollE && rollForm) { + btnRollE.addEventListener('click', function () { + jsonPost('/api/strategy/roll/execute', formData(rollForm)).then(function (d) { + if (!d.ok) { alert(d.error); return; } + location.reload(); + }); + }); + } + + 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/trade.js b/static/js/trade.js new file mode 100644 index 0000000..33bed49 --- /dev/null +++ b/static/js/trade.js @@ -0,0 +1,127 @@ +(function () { + var symInput = document.getElementById('trade-symbol'); + var lotsInput = document.getElementById('trade-lots'); + var priceInput = document.getElementById('trade-price'); + var footer = document.getElementById('trade-footer'); + var slInput = document.getElementById('trade-sl'); + var tpInput = document.getElementById('trade-tp'); + var debounceTimer; + + function selectedSymbol() { + return (symInput && symInput.value || '').trim(); + } + + function refreshQuote() { + var sym = selectedSymbol(); + var lots = lotsInput ? lotsInput.value : '1'; + if (!sym) return; + fetch('/api/trade/quote?symbol=' + encodeURIComponent(sym) + '&lots=' + encodeURIComponent(lots)) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (!data.ok) return; + if (priceInput && !priceInput.dataset.manual && data.price) { + priceInput.value = data.price; + } + var px = data.price != null ? data.price : '—'; + ['px-long', 'px-short'].forEach(function (id) { + var el = document.getElementById(id); + if (el) el.textContent = px; + }); + var ml = document.getElementById('max-long'); + var ms = document.getElementById('max-short'); + if (ml) ml.textContent = '≤' + (data.max_open_long || '—'); + if (ms) ms.textContent = '≤' + (data.max_open_short || '—'); + document.getElementById('pos-long').textContent = '≤' + (data.pos_long || 0); + document.getElementById('pos-short').textContent = '≤' + (data.pos_short || 0); + if (footer && data.metrics) { + var m = data.metrics; + footer.innerHTML = + '
' + (data.name || sym) + ' ' + (data.footer_text || '') + '
' + + '价格精度 ' + m.price_precision + ' 位 · ' + + '最小变动 ' + m.tick_size + ' · ' + + '每跳 ' + m.tick_value_per_lot + ' 元/手 · ' + + '当前 ' + lots + ' 手每跳合计 ' + m.tick_value_total + ' 元
' + + (m.margin_total ? '预估保证金约 ' + m.margin_total + ' 元
' : ''); + } + }).catch(function () {}); + } + + function scheduleRefresh() { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(refreshQuote, 400); + } + + if (symInput) symInput.addEventListener('input', scheduleRefresh); + if (lotsInput) lotsInput.addEventListener('input', scheduleRefresh); + if (priceInput) { + priceInput.addEventListener('input', function () { + priceInput.dataset.manual = '1'; + }); + } + + function postOrder(offset, direction) { + var sym = selectedSymbol(); + if (!sym) { alert('请选择品种'); return; } + var body = { + symbol: sym, + offset: offset, + direction: direction, + lots: parseInt(lotsInput.value, 10) || 1, + price: parseFloat(priceInput.value) || 0, + stop_loss: slInput ? parseFloat(slInput.value) : null, + take_profit: tpInput ? parseFloat(tpInput.value) : null + }; + 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) { alert(data.error || '下单失败'); return; } + alert('已提交 ' + (data.lots || '') + ' 手'); + location.reload(); + }); + } + + var btnLong = document.getElementById('btn-open-long'); + var btnShort = document.getElementById('btn-open-short'); + var btnCloseL = document.getElementById('btn-close-long'); + var btnCloseS = document.getElementById('btn-close-short'); + if (btnLong) btnLong.addEventListener('click', function () { postOrder('open', 'long'); }); + if (btnShort) btnShort.addEventListener('click', function () { postOrder('open', 'short'); }); + if (btnCloseL) btnCloseL.addEventListener('click', function () { postOrder('close', 'long'); }); + if (btnCloseS) btnCloseS.addEventListener('click', function () { postOrder('close', 'short'); }); + + var btnConnect = document.getElementById('btn-ctp-connect'); + if (btnConnect) { + btnConnect.addEventListener('click', function () { + btnConnect.disabled = true; + btnConnect.textContent = '连接中…'; + fetch('/api/ctp/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (!d.ok) { alert(d.error || '连接失败'); return; } + location.reload(); + }) + .finally(function () { + btnConnect.disabled = false; + btnConnect.textContent = '连接 CTP'; + }); + }); + } + + setInterval(function () { + fetch('/api/account_snapshot').then(function (r) { return r.json(); }).then(function (d) { + var cap = document.getElementById('cap-display'); + if (cap && d.capital != null) cap.textContent = Number(d.capital).toFixed(2); + var badge = document.getElementById('risk-badge'); + if (badge && d.risk_status) badge.textContent = d.risk_status.status_label; + var ctpBadge = document.getElementById('ctp-badge'); + if (ctpBadge && d.ctp_status) { + ctpBadge.textContent = d.ctp_status.connected ? 'CTP 已连接' : 'CTP 未连接'; + ctpBadge.className = 'badge ' + (d.ctp_status.connected ? 'profit' : 'planned'); + } + }).catch(function () {}); + }, 5000); + + scheduleRefresh(); +})(); diff --git a/strategy/__init__.py b/strategy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/strategy/fib_lib.py b/strategy/fib_lib.py new file mode 100644 index 0000000..80f8085 --- /dev/null +++ b/strategy/fib_lib.py @@ -0,0 +1,18 @@ +"""斐波计算(自 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 new file mode 100644 index 0000000..451e378 --- /dev/null +++ b/strategy/strategy_db.py @@ -0,0 +1,131 @@ +"""策略相关表结构。""" +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) +) +""" + + +def init_strategy_tables(conn) -> None: + 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)") diff --git a/strategy/strategy_roll_lib.py b/strategy/strategy_roll_lib.py new file mode 100644 index 0000000..d207033 --- /dev/null +++ b/strategy/strategy_roll_lib.py @@ -0,0 +1,159 @@ +"""顺势加仓(滚仓):纯计算,期货版(手数整数、乘数计入盈亏)。""" +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 new file mode 100644 index 0000000..d8d3093 --- /dev/null +++ b/strategy/strategy_snapshot_lib.py @@ -0,0 +1,70 @@ +"""策略结束快照。""" +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 new file mode 100644 index 0000000..060774c --- /dev/null +++ b/strategy/strategy_trend_lib.py @@ -0,0 +1,108 @@ +"""趋势回调:纯计算(期货整数手)。""" +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/templates/base.html b/templates/base.html index a640df9..cb8a385 100644 --- a/templates/base.html +++ b/templates/base.html @@ -483,6 +483,9 @@