diff --git a/app.py b/app.py index 0399e92..b32ab09 100644 --- a/app.py +++ b/app.py @@ -789,7 +789,13 @@ def logout(): @login_required def api_symbol_search(): q = request.args.get("q", "") - return jsonify(search_symbols(q)) + conn = get_db() + try: + from trading_context import get_account_capital + capital = get_account_capital(conn, get_setting) + finally: + conn.close() + return jsonify(search_symbols(q, capital=capital)) @app.route("/api/symbols/mains") diff --git a/install_trading.py b/install_trading.py index 6cfbcf4..e923b6a 100644 --- a/install_trading.py +++ b/install_trading.py @@ -28,10 +28,7 @@ from position_sizing import ( calc_order_tick_metrics, normalize_sizing_mode, ) -from recommend_store import ( - recommend_payload, - refresh_recommend_cache, -) +from product_recommend import assert_product_allowed_for_capital, is_small_account, SMALL_ACCOUNT_SCOPE_LABEL from recommend_stream import recommend_hub, schedule_recommend_refresh, start_recommend_worker from position_stream import position_hub, start_position_worker from ctp_settings import is_ctp_auto_connect_enabled @@ -1645,6 +1642,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se recommend_rows=rec_cache.get("rows") or [], recommend_updated_at=rec_cache.get("updated_at"), night_session=is_night_trading_session(), + small_account_scope=is_small_account(capital), + small_account_scope_label=SMALL_ACCOUNT_SCOPE_LABEL, product_categories=PRODUCT_CATEGORIES, ) finally: @@ -2148,6 +2147,10 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se if err: conn.close() return jsonify({"ok": False, "error": err}), 403 + scope_err = assert_product_allowed_for_capital(sym, _capital(conn)) + if scope_err: + conn.close() + return jsonify({"ok": False, "error": scope_err}), 403 ctp_st = ctp_status(mode) if not ctp_st.get("connected"): conn.close() @@ -2503,6 +2506,10 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se conn.close() return jsonify({"ok": False, "error": err}), 403 capital = _capital(conn) + scope_err = assert_product_allowed_for_capital(sym, capital) + if scope_err: + conn.close() + return jsonify({"ok": False, "error": scope_err}), 403 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( diff --git a/product_recommend.py b/product_recommend.py index ffceb2e..32327d2 100644 --- a/product_recommend.py +++ b/product_recommend.py @@ -18,6 +18,52 @@ from symbols import PRODUCTS, product_category, product_has_night_session logger = logging.getLogger(__name__) +# 权益低于该值时,仅允许下列品种(可开仓列表、品种下拉、开仓报单) +SMALL_ACCOUNT_CAPITAL_MAX = 100_000.0 +SMALL_ACCOUNT_PRODUCT_THS = frozenset({"c", "m", "MA", "rb"}) +SMALL_ACCOUNT_SCOPE_LABEL = "玉米、豆粕、甲醇、螺纹钢" + + +def normalize_product_ths(ths: str) -> str: + import re + s = (ths or "").strip() + m = re.match(r"^([A-Za-z]+)", s) + return m.group(1) if m else s + + +def is_small_account(capital: float) -> bool: + cap = float(capital or 0) + return 0 < cap <= SMALL_ACCOUNT_CAPITAL_MAX + + +def product_in_small_account_whitelist(ths_or_product) -> bool: + if isinstance(ths_or_product, dict): + key = (ths_or_product.get("ths") or "").strip() + else: + key = normalize_product_ths(str(ths_or_product or "")) + if not key: + return False + root = normalize_product_ths(key) + if root in SMALL_ACCOUNT_PRODUCT_THS: + return True + upper = root.upper() + return upper in {x.upper() for x in SMALL_ACCOUNT_PRODUCT_THS} + + +def assert_product_allowed_for_capital(ths: str, capital: float) -> Optional[str]: + """小账户品种白名单校验;通过返回 None。""" + if not is_small_account(capital): + return None + if product_in_small_account_whitelist(ths): + return None + return f"权益 10 万以下仅可交易:{SMALL_ACCOUNT_SCOPE_LABEL}" + + +def filter_products_for_capital(products: list[dict], capital: float) -> list[dict]: + if not is_small_account(capital): + return list(products) + return [p for p in products if product_in_small_account_whitelist(p)] + def _attach_turnover(row: dict) -> None: """成交额 = 昨日成交量(手) × 昨收 × 合约乘数。""" @@ -60,6 +106,23 @@ def assess_product_for_capital( cap = float(capital or 0) margin_pct = max(1.0, min(100.0, float(max_margin_pct or 30.0))) + if is_small_account(cap) and not product_in_small_account_whitelist(product): + return { + "ths": ths, + "name": name, + "exchange": exchange, + "category": category, + "mult": spec["mult"], + "tick_size": tick, + "status": "blocked", + "status_label": f"10万以下限{SMALL_ACCOUNT_SCOPE_LABEL}", + "min_capital_one_lot": None, + "margin_one_lot": None, + "max_lots": 0, + "risk_one_lot_1pct": None, + "has_night_session": product_has_night_session(product), + } + if p <= 0: return { "ths": ths, @@ -173,5 +236,6 @@ def list_product_recommendations( } with ThreadPoolExecutor(max_workers=10) as pool: - rows = list(pool.map(_one, PRODUCTS)) + products = filter_products_for_capital(PRODUCTS, capital) + rows = list(pool.map(_one, products)) return sort_recommend_by_trend(rows) diff --git a/scripts/deploy_small_account_scope.py b/scripts/deploy_small_account_scope.py new file mode 100644 index 0000000..415598c --- /dev/null +++ b/scripts/deploy_small_account_scope.py @@ -0,0 +1,16 @@ +"""Deploy small-account product whitelist.""" +import paramiko, sys +from pathlib import Path +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +root = Path(__file__).resolve().parents[1] +files = ["product_recommend.py", "symbols.py", "app.py", "install_trading.py", "templates/trade.html"] +c = paramiko.SSHClient(); c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("192.168.8.21", username="root", password="woaini88", timeout=15) +sftp = c.open_sftp() +for rel in files: + sftp.put(str(root / rel), f"/opt/qihuo/{rel.replace(chr(92), '/')}") + print("uploaded", rel) +sftp.close() +_, o, _ = c.exec_command("cd /opt/qihuo && pm2 restart qihuo") +print(o.read().decode("utf-8", errors="replace")) +c.close() diff --git a/symbols.py b/symbols.py index 51a2608..ebe2381 100644 --- a/symbols.py +++ b/symbols.py @@ -461,20 +461,25 @@ def _match_score(product: dict, q_lower: str) -> int: return 10 -def search_symbols(query: str) -> list: +def search_symbols(query: str, *, capital: float | None = None) -> list: q = query.strip() if not q: return [] q_lower = q.lower() from market_sessions import is_night_trading_session + from product_recommend import filter_products_for_capital, is_small_account + night_only = is_night_trading_session() + product_pool = PRODUCTS + if capital is not None and is_small_account(capital): + product_pool = filter_products_for_capital(PRODUCTS, capital) with _main_index_lock: index = dict(_main_index) index_ready = bool(index) scored: list[tuple[int, dict]] = [] - for p in PRODUCTS: + for p in product_pool: if night_only and not product_has_night_session(p): continue if not _product_matches(p, q_lower): @@ -492,6 +497,11 @@ def search_symbols(query: str) -> list: if not results and len(q) >= 3: codes = ths_to_codes(q) if codes: + product = _product_for_contract_code(codes["ths_code"]) + if capital is not None and is_small_account(capital): + from product_recommend import is_small_account, product_in_small_account_whitelist + if not product or not product_in_small_account_whitelist(product): + return results raw = fetch_raw_for_volume(codes["sina_code"]) name = raw["name"] if raw else q results.append(_enrich_item({ diff --git a/templates/trade.html b/templates/trade.html index 71b5f29..42ec54d 100644 --- a/templates/trade.html +++ b/templates/trade.html @@ -133,7 +133,8 @@

最大手数 = floor(权益 × 保证金上限 {{ max_margin_pct }}% ÷ 1手保证金);当前权益 {{ '%.2f'|format(capital) }} 元。 {% if sizing_mode == 'fixed' %}仅显示最大手数 ≥ {{ fixed_lots }} 手的品种。{% endif %} - {% if night_session %}当前为夜盘时段,品种下拉与下表仅显示有夜盘品种;带「夜盘」标记。{% else %}有夜盘交易的品种带「夜盘」标记。{% endif %} + {% if small_account_scope %}权益 10 万以下仅显示并可交易:{{ small_account_scope_label }}。{% endif %} + {% if night_session %}当前为夜盘时段,品种下拉与下表仅显示有夜盘品种;带「夜盘」标记。{% elif not small_account_scope %}有夜盘交易的品种带「夜盘」标记。{% endif %} 保证金优先读取 CTP 柜台合约信息。 {% if recommend_updated_at %}每日后台更新 · 最近 {{ recommend_updated_at }}{% else %}等待今日后台刷新…{% endif %}