From c5d4b8805ec4de72aea5eabb78cc2c9c355420ba Mon Sep 17 00:00:00 2001 From: dl <1909703981@qq.com> Date: Thu, 13 Aug 2026 02:09:07 +0800 Subject: [PATCH 1/3] feat: add Futu OpenAPI integration for HK/US trading Integrate Futu market data and trading with secure OpenD configuration, resilient client lifecycles, and simulated/live execution support. --- .../app/data_sources/factory.py | 125 ++- backend_api_python/app/data_sources/futu.py | 226 +++++ backend_api_python/app/openapi/register.py | 3 + .../app/openapi/schemas/high_risk.py | 16 +- backend_api_python/app/openapi/tags.py | 2 + backend_api_python/app/routes/credentials.py | 75 +- backend_api_python/app/routes/futu.py | 234 +++++ .../app/services/broker_market_policy.py | 18 +- .../services/execution_streams/adapters.py | 88 ++ .../services/execution_streams/normalizers.py | 81 ++ .../app/services/futu_trading/README.md | 36 + .../app/services/futu_trading/__init__.py | 32 + .../app/services/futu_trading/client.py | 838 ++++++++++++++++++ .../app/services/futu_trading/config.py | 197 ++++ .../app/services/futu_trading/mappers.py | 239 +++++ .../app/services/futu_trading/quote_client.py | 177 ++++ .../app/services/futu_trading/quote_feed.py | 129 +++ .../app/services/futu_trading/symbols.py | 123 +++ .../app/services/futu_trading/timezones.py | 26 + .../app/services/live_trading/factory.py | 68 +- .../services/pending_order_position_sync.py | 53 ++ .../app/services/pending_order_worker.py | 584 +++++++++++- .../app/services/strategy_v2/deployment.py | 6 +- .../app/services/strategy_v2/market_data.py | 21 +- .../app/services/strategy_v2/service.py | 20 +- .../app/services/trading_executor.py | 128 ++- backend_api_python/app/utils/local_brokers.py | 21 +- backend_api_python/env.example | 12 +- backend_api_python/requirements.txt | 1 + .../tests/test_broker_market_policy.py | 46 +- .../tests/test_execution_stream_adapters.py | 3 +- .../tests/test_futu_client_contract.py | 220 +++++ backend_api_python/tests/test_futu_config.py | 88 ++ .../tests/test_futu_integration_opend.py | 41 + .../tests/test_futu_local_brokers.py | 17 + backend_api_python/tests/test_futu_mappers.py | 74 ++ .../tests/test_futu_pending_order_sync.py | 149 ++++ .../tests/test_futu_quote_client.py | 128 +++ backend_api_python/tests/test_futu_symbols.py | 40 + .../tests/test_strategy_v2_market_data.py | 61 ++ .../tests/test_strategy_v2_service.py | 59 ++ docs/architecture/EXTENSION_GUIDE.md | 5 +- docs/architecture/FUTU_OPEND.md | 78 ++ 43 files changed, 4506 insertions(+), 82 deletions(-) create mode 100644 backend_api_python/app/data_sources/futu.py create mode 100644 backend_api_python/app/routes/futu.py create mode 100644 backend_api_python/app/services/futu_trading/README.md create mode 100644 backend_api_python/app/services/futu_trading/__init__.py create mode 100644 backend_api_python/app/services/futu_trading/client.py create mode 100644 backend_api_python/app/services/futu_trading/config.py create mode 100644 backend_api_python/app/services/futu_trading/mappers.py create mode 100644 backend_api_python/app/services/futu_trading/quote_client.py create mode 100644 backend_api_python/app/services/futu_trading/quote_feed.py create mode 100644 backend_api_python/app/services/futu_trading/symbols.py create mode 100644 backend_api_python/app/services/futu_trading/timezones.py create mode 100644 backend_api_python/tests/test_futu_client_contract.py create mode 100644 backend_api_python/tests/test_futu_config.py create mode 100644 backend_api_python/tests/test_futu_integration_opend.py create mode 100644 backend_api_python/tests/test_futu_local_brokers.py create mode 100644 backend_api_python/tests/test_futu_mappers.py create mode 100644 backend_api_python/tests/test_futu_pending_order_sync.py create mode 100644 backend_api_python/tests/test_futu_quote_client.py create mode 100644 backend_api_python/tests/test_futu_symbols.py create mode 100644 docs/architecture/FUTU_OPEND.md diff --git a/backend_api_python/app/data_sources/factory.py b/backend_api_python/app/data_sources/factory.py index 38903b613..52dfdd5bb 100644 --- a/backend_api_python/app/data_sources/factory.py +++ b/backend_api_python/app/data_sources/factory.py @@ -42,6 +42,7 @@ def _env_positive_int(key: str, default: int) -> int: "equities": "USStock", "alpaca": "USStock", "ibkr": "USStock", + "futu": "HKStock", "cnstock": "CNStock", "cn_stock": "CNStock", "ashare": "CNStock", @@ -98,6 +99,15 @@ def _log_limited(cls, level: str, key: str, message: str, *args: Any) -> None: log_fn = getattr(logger, level, logger.warning) log_fn(message, *args) + @staticmethod + def _close_request_source(source: Optional[BaseDataSource]) -> None: + if source is None or not getattr(source, "close_after_request", False): + return + try: + source.close() + except Exception as exc: + logger.debug("Request-scoped data source close failed: %s", exc) + @classmethod def normalize_market(cls, market: str) -> str: """ @@ -172,6 +182,8 @@ def get_data_source(cls, name: str) -> BaseDataSource: return cls.get_source("Forex") if key in ("usstock", "us_stocks", "stock", "stocks", "ibkr", "alpaca"): return cls.get_source("USStock") + if key in ("futu", "hkstock", "hk_stock"): + return cls.get_source("HKStock") # Unknown alias — log and default to Crypto (legacy behavior). Callers # should migrate to the explicit `get_source(market)` API. logger.warning( @@ -220,6 +232,9 @@ def get_kline( after_time: Optional[int] = None, exchange_id: Optional[str] = None, market_type: Optional[str] = None, + exchange_config: Optional[Dict[str, Any]] = None, + allow_futu_fallback: bool = True, + strict_data_source: bool = False, ) -> List[Dict[str, Any]]: """ 获取K线数据的便捷方法 @@ -231,22 +246,31 @@ def get_kline( limit: 数据条数 before_time: 获取此时间之前的数据 after_time: 可选,Unix 秒,K 线 time 需 >= 此值(回测左边界) - exchange_id: 加密货币运行中策略 — 与策略绑定的交易所 (binance/okx/...) - market_type: 加密货币运行中策略 — spot 或 swap + exchange_id: 运行中策略绑定的交易所 (binance/okx/.../futu) + market_type: spot 或 swap Returns: K线数据列表 """ m = cls.normalize_market(market or "") + source = None try: assert_fd_available(f"market-data kline {m}:{symbol}") - source = cls._resolve_source(m, exchange_id=exchange_id, market_type=market_type) + source = cls._resolve_source( + m, + exchange_id=exchange_id, + market_type=market_type, + exchange_config=exchange_config, + allow_futu_fallback=allow_futu_fallback, + ) klines = source.get_kline(symbol, timeframe, limit, before_time, after_time) klines.sort(key=lambda x: x['time']) return klines except ResourceExhaustedError as e: + if strict_data_source: + raise cls._log_limited( "error", f"fd-cooldown:kline:{m}:{symbol}", @@ -259,6 +283,8 @@ def get_kline( except Exception as e: if is_fd_exhaustion(e): mark_fd_exhausted(e) + if strict_data_source: + raise cls._log_limited( "error", f"kline:{m}:{symbol}:{type(e).__name__}:{str(e)[:160]}", @@ -269,6 +295,8 @@ def get_kline( str(e), ) return [] + finally: + cls._close_request_source(source) @classmethod def _resolve_source( @@ -277,8 +305,10 @@ def _resolve_source( *, exchange_id: Optional[str] = None, market_type: Optional[str] = None, + exchange_config: Optional[Dict[str, Any]] = None, + allow_futu_fallback: bool = True, ) -> BaseDataSource: - """Pick data source; crypto live strategies may scope to execution exchange.""" + """Pick data source; crypto/Futu live strategies may scope to execution venue.""" ex = (exchange_id or "").strip().lower() mt = (market_type or "").strip().lower() if mt in ("futures", "future", "perp", "perpetual"): @@ -291,10 +321,78 @@ def _resolve_source( from app.data_sources.crypto import CryptoDataSource return CryptoDataSource.for_public_market("swap") + # Prefer Futu OpenD when the execution account is Futu (HK/US). + # On OpenD/permission failure, fall back to the public multi-source + # adapters unless callers disable fallback. + if ex == "futu" and market in ("HKStock", "USStock"): + from app.data_sources.futu import FutuDataSource, FutuDataSourceError + + futu_source = FutuDataSource.for_exchange_config(exchange_config or {}, market=market) + if not allow_futu_fallback: + return futu_source + + class _FutuWithFallback(BaseDataSource): + name = f"Futu+fallback/{market}" + close_after_request = True + + def close(self) -> None: + futu_source.close() + + def get_ticker(self, symbol: str) -> Dict[str, Any]: + try: + return futu_source.get_ticker(symbol) + except FutuDataSourceError as exc: + logger.warning( + "Futu ticker unavailable (%s); falling back to %s public source", + exc, + market, + ) + ticker = cls.get_source(market).get_ticker(symbol) + if isinstance(ticker, dict): + ticker = dict(ticker) + ticker["source"] = f"fallback:{market}" + ticker["futu_error"] = str(exc) + return ticker + + def get_kline( + self, + symbol: str, + timeframe: str, + limit: int, + before_time: Optional[int] = None, + after_time: Optional[int] = None, + ) -> List[Dict[str, Any]]: + try: + return futu_source.get_kline(symbol, timeframe, limit, before_time, after_time) + except FutuDataSourceError as exc: + logger.warning( + "Futu kline unavailable (%s); falling back to %s public source for %s", + exc, + market, + symbol, + ) + rows = cls.get_source(market).get_kline( + symbol, timeframe, limit, before_time, after_time + ) + for row in rows: + if isinstance(row, dict): + row.setdefault("source", f"fallback:{market}") + return rows + + return _FutuWithFallback() return cls.get_source(market) @classmethod - def get_ticker(cls, market: str, symbol: str, exchange_id: Optional[str] = None, market_type: Optional[str] = None) -> Dict[str, Any]: + def get_ticker( + cls, + market: str, + symbol: str, + exchange_id: Optional[str] = None, + market_type: Optional[str] = None, + exchange_config: Optional[Dict[str, Any]] = None, + allow_futu_fallback: bool = True, + strict_data_source: bool = False, + ) -> Dict[str, Any]: """ 获取实时报价的便捷方法 @@ -313,11 +411,20 @@ def get_ticker(cls, market: str, symbol: str, exchange_id: Optional[str] = None, } """ m = cls.normalize_market(market or "") + source = None try: assert_fd_available(f"market-data ticker {m}:{symbol}") - source = cls._resolve_source(m, exchange_id=exchange_id, market_type=market_type) + source = cls._resolve_source( + m, + exchange_id=exchange_id, + market_type=market_type, + exchange_config=exchange_config, + allow_futu_fallback=allow_futu_fallback, + ) return source.get_ticker(symbol) except ResourceExhaustedError as e: + if strict_data_source: + raise cls._log_limited( "error", f"fd-cooldown:ticker:{m}:{symbol}", @@ -328,6 +435,8 @@ def get_ticker(cls, market: str, symbol: str, exchange_id: Optional[str] = None, ) return {'last': 0, 'symbol': symbol} except NotImplementedError: + if strict_data_source: + raise cls._log_limited( "warning", f"ticker-not-implemented:{m}", @@ -338,6 +447,8 @@ def get_ticker(cls, market: str, symbol: str, exchange_id: Optional[str] = None, except Exception as e: if is_fd_exhaustion(e): mark_fd_exhausted(e) + if strict_data_source: + raise cls._log_limited( "error", f"ticker:{m}:{symbol}:{type(e).__name__}:{str(e)[:160]}", @@ -347,3 +458,5 @@ def get_ticker(cls, market: str, symbol: str, exchange_id: Optional[str] = None, str(e), ) return {'last': 0, 'symbol': symbol} + finally: + cls._close_request_source(source) diff --git a/backend_api_python/app/data_sources/futu.py b/backend_api_python/app/data_sources/futu.py new file mode 100644 index 000000000..daea59c74 --- /dev/null +++ b/backend_api_python/app/data_sources/futu.py @@ -0,0 +1,226 @@ +""" +Futu OpenD market-data adapter for HKStock / USStock. + +Used when a strategy's execution account is Futu so signal bars match the +broker. Falls back is handled by DataSourceFactory / callers — this module +raises identifiable errors instead of silently returning empty lists when +permissions or OpenD connectivity fail. +""" + +from __future__ import annotations + +import os +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from app.data_sources.base import BaseDataSource +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +_TF_TO_KLTYPE = { + "1m": "K_1M", + "3m": "K_3M", + "5m": "K_5M", + "15m": "K_15M", + "30m": "K_30M", + "1H": "K_60M", + "60m": "K_60M", + "4H": "K_60M", # OpenD has no native 4H; caller may resample + "1D": "K_DAY", + "1W": "K_WEEK", +} + + +class FutuDataSourceError(RuntimeError): + """Raised for permission / quota / OpenD failures (not empty markets).""" + + def __init__(self, code: str, message: str = ""): + self.code = code + super().__init__(f"{code}:{message}" if message else code) + + +class FutuDataSource(BaseDataSource): + """Historical K-line + ticker via FutuOpenD.""" + + name = "Futu/OpenD" + close_after_request = True + + def __init__( + self, + *, + market: str = "HKStock", + host: Optional[str] = None, + port: Optional[int] = None, + exchange_config: Optional[Dict[str, Any]] = None, + ): + self.market = "USStock" if str(market or "").strip() == "USStock" else "HKStock" + self._exchange_config = dict(exchange_config or {}) + self._host = str( + host + or self._exchange_config.get("futu_host") + or self._exchange_config.get("host") + or os.getenv("FUTU_OPEND_HOST") + or "127.0.0.1" + ).strip() + self._port = int( + port + or self._exchange_config.get("futu_port") + or self._exchange_config.get("port") + or os.getenv("FUTU_OPEND_PORT") + or 11111 + ) + self._client = None + + @classmethod + def for_exchange_config(cls, exchange_config: Dict[str, Any], market: str = "HKStock") -> "FutuDataSource": + return cls(market=market, exchange_config=exchange_config or {}) + + def _get_client(self): + if self._client is not None and getattr(self._client, "connected", False): + return self._client + from app.services.futu_trading.config import config_from_exchange_config + from app.services.futu_trading.quote_client import FutuQuoteClient + + config_data = dict(self._exchange_config) + config_data["futu_host"] = self._host + config_data["futu_port"] = self._port + config_data.setdefault("market_category", self.market) + cfg = config_from_exchange_config(config_data) + # Quote-only consumers never need to retain a trading password. + cfg.unlock_password = "" + client = FutuQuoteClient(cfg) + if not client.connect(): + raise FutuDataSourceError("FUTU_OPEND_UNREACHABLE", f"{self._host}:{self._port}") + self._client = client + return client + + def close(self) -> None: + if self._client is not None: + try: + self._client.disconnect() + except Exception: + pass + self._client = None + + def get_ticker(self, symbol: str) -> Dict[str, Any]: + try: + client = self._get_client() + quote = client.get_quote(symbol, self.market) + return { + "last": float(quote.get("last") or 0), + "bid": float(quote.get("bid") or 0), + "ask": float(quote.get("ask") or 0), + "high": float(quote.get("high") or 0), + "low": float(quote.get("low") or 0), + "volume": float(quote.get("volume") or 0), + "previousClose": float(quote.get("close") or 0), + "symbol": quote.get("symbol") or symbol, + "source": "futu", + } + except FutuDataSourceError: + raise + except Exception as exc: + msg = str(exc) + if "PERMISSION" in msg.upper() or "权限" in msg or "FUTU_QUOTE" in msg: + raise FutuDataSourceError("FUTU_QUOTE_PERMISSION_DENIED", msg) from exc + if "UNREACHABLE" in msg.upper() or "connect" in msg.lower(): + raise FutuDataSourceError("FUTU_OPEND_UNREACHABLE", msg) from exc + raise FutuDataSourceError("FUTU_API_ERROR", msg) from exc + + def get_kline( + self, + symbol: str, + timeframe: str, + limit: int, + before_time: Optional[int] = None, + after_time: Optional[int] = None, + ) -> List[Dict[str, Any]]: + tf = str(timeframe or "1D").strip() + ktype = _TF_TO_KLTYPE.get(tf) or _TF_TO_KLTYPE.get(tf.upper()) or "K_DAY" + lim = max(int(limit or 300), 1) + + end_dt = None + start_dt = None + if before_time: + end_dt = datetime.fromtimestamp(int(before_time), tz=timezone.utc) + if after_time: + start_dt = datetime.fromtimestamp(int(after_time), tz=timezone.utc) + if end_dt is None: + end_dt = datetime.now(timezone.utc) + if start_dt is None: + # Rough lookback; OpenD pages results. + seconds = { + "1m": 60, "3m": 180, "5m": 300, "15m": 900, "30m": 1800, + "1H": 3600, "60m": 3600, "4H": 14400, "1D": 86400, "1W": 604800, + }.get(tf, 86400) + start_dt = end_dt - timedelta(seconds=seconds * lim * 1.5) + + try: + from app.services.futu_trading.timezones import market_timezone + + exchange_tz = market_timezone(self.market) + client = self._get_client() + rows = client.get_history_kline( + symbol, + market_type=self.market, + ktype=ktype, + start=start_dt.astimezone(exchange_tz).strftime("%Y-%m-%d"), + end=end_dt.astimezone(exchange_tz).strftime("%Y-%m-%d"), + max_count=lim + 5, + autype="QFQ", + ) + except FutuDataSourceError: + raise + except Exception as exc: + msg = str(exc) + if "QUOTA" in msg.upper() or "额度" in msg: + raise FutuDataSourceError("FUTU_QUOTE_QUOTA_EXCEEDED", msg) from exc + if "PERMISSION" in msg.upper() or "权限" in msg or "FUTU_QUOTE" in msg: + raise FutuDataSourceError("FUTU_QUOTE_PERMISSION_DENIED", msg) from exc + if "UNREACHABLE" in msg.upper() or "connect" in msg.lower(): + raise FutuDataSourceError("FUTU_OPEND_UNREACHABLE", msg) from exc + raise FutuDataSourceError("FUTU_API_ERROR", msg) from exc + + # Tag source for observability (non-breaking extra field ignored by most consumers) + for row in rows: + row["source"] = "futu" + + # 4H resampling from 60m when requested + if tf == "4H" and rows: + rows = self._resample_hours(rows, hours=4) + + return self.filter_and_limit( + rows, + limit=lim, + before_time=before_time, + after_time=after_time, + truncate=(after_time is None), + ) + + @staticmethod + def _resample_hours(rows: List[Dict[str, Any]], hours: int = 4) -> List[Dict[str, Any]]: + if not rows: + return rows + bucket_sec = int(hours) * 3600 + buckets: Dict[int, Dict[str, Any]] = {} + for row in rows: + t = int(row.get("time") or 0) + key = t - (t % bucket_sec) + cur = buckets.get(key) + if cur is None: + buckets[key] = { + "time": key, + "open": float(row["open"]), + "high": float(row["high"]), + "low": float(row["low"]), + "close": float(row["close"]), + "volume": float(row.get("volume") or 0), + "source": "futu", + } + else: + cur["high"] = max(cur["high"], float(row["high"])) + cur["low"] = min(cur["low"], float(row["low"])) + cur["close"] = float(row["close"]) + cur["volume"] = float(cur.get("volume") or 0) + float(row.get("volume") or 0) + return [buckets[k] for k in sorted(buckets.keys())] diff --git a/backend_api_python/app/openapi/register.py b/backend_api_python/app/openapi/register.py index 50c99f094..824d06a00 100644 --- a/backend_api_python/app/openapi/register.py +++ b/backend_api_python/app/openapi/register.py @@ -34,6 +34,7 @@ ("/api/portfolio", "Portfolio"), ("/api/ibkr", "IBKR"), ("/api/alpaca", "Alpaca"), + ("/api/futu", "Futu"), ("/api/global-market", "GlobalMarket"), ("/api/community", "Community"), ("/api/fast-analysis", "FastAnalysis"), @@ -75,6 +76,7 @@ def register_human_blueprints(api: Api) -> None: from app.routes.portfolio import portfolio_blp from app.routes.ibkr import ibkr_blp from app.routes.alpaca import alpaca_blp + from app.routes.futu import futu_blp from app.routes.global_market import global_market_blp from app.routes.community import community_blp from app.routes.fast_analysis import fast_analysis_blp @@ -102,6 +104,7 @@ def register_human_blueprints(api: Api) -> None: (portfolio_blp, "/api/portfolio"), (ibkr_blp, "/api/ibkr"), (alpaca_blp, "/api/alpaca"), + (futu_blp, "/api/futu"), (global_market_blp, "/api/global-market"), (community_blp, "/api/community"), (fast_analysis_blp, "/api/fast-analysis"), diff --git a/backend_api_python/app/openapi/schemas/high_risk.py b/backend_api_python/app/openapi/schemas/high_risk.py index 2f3ec1232..c70b715d4 100644 --- a/backend_api_python/app/openapi/schemas/high_risk.py +++ b/backend_api_python/app/openapi/schemas/high_risk.py @@ -63,6 +63,20 @@ class CredentialCreateRequestSchema(Schema): ibkr_port = fields.Integer(load_default=7497, validate=validate.Range(min=1, max=65535)) ibkr_client_id = fields.Integer(load_default=7, validate=validate.Range(min=0, max=2147483647)) ibkr_account = fields.String(load_default="", validate=validate.Length(max=128)) + futu_host = fields.String(load_default="127.0.0.1", validate=validate.Length(max=255)) + futu_port = fields.Integer(load_default=11111, validate=validate.Range(min=1, max=65535)) + host = fields.String(load_default="", validate=validate.Length(max=255)) + port = fields.Integer(load_default=0, validate=validate.Range(min=0, max=65535)) + trade_env = fields.String(load_default="", validate=validate.Length(max=32)) + trade_market = fields.String(load_default="", validate=validate.Length(max=32)) + tradeMarket = fields.String(load_default="", validate=validate.Length(max=32)) + security_firm = fields.String(load_default="", validate=validate.Length(max=64)) + securityFirm = fields.String(load_default="", validate=validate.Length(max=64)) + acc_id = fields.Integer(load_default=0) + accId = fields.Integer(load_default=0) + unlock_password = fields.String(load_default="", validate=validate.Length(max=128)) + unlockPassword = fields.String(load_default="", validate=validate.Length(max=128)) + market_category = fields.String(load_default="", validate=validate.Length(max=32)) @pre_load def normalize_exchange(self, data, **kwargs): @@ -72,7 +86,7 @@ def normalize_exchange(self, data, **kwargs): @validates_schema def validate_exchange_secret(self, data, **kwargs): - if str(data.get("exchange_id") or "").lower() == "ibkr": + if str(data.get("exchange_id") or "").lower() in ("ibkr", "futu"): return if not (data.get("api_key") or data.get("apiKey")): raise ValidationError("api_key is required", field_name="api_key") diff --git a/backend_api_python/app/openapi/tags.py b/backend_api_python/app/openapi/tags.py index 85d05f154..ad3cd39ab 100644 --- a/backend_api_python/app/openapi/tags.py +++ b/backend_api_python/app/openapi/tags.py @@ -23,6 +23,7 @@ QUICK_TRADE = "QuickTrade" IBKR = "IBKR" ALPACA = "Alpaca" +FUTU = "Futu" ALL_TAGS = [ {"name": HEALTH, "description": "Liveness and API metadata (Public)"}, @@ -48,4 +49,5 @@ {"name": QUICK_TRADE, "description": "Manual quick trade (Internal)"}, {"name": IBKR, "description": "Interactive Brokers adapter (Internal)"}, {"name": ALPACA, "description": "Alpaca adapter (Internal)"}, + {"name": FUTU, "description": "Futu OpenAPI / OpenD adapter (Internal)"}, ] diff --git a/backend_api_python/app/routes/credentials.py b/backend_api_python/app/routes/credentials.py index afcc592d6..71bc5fade 100644 --- a/backend_api_python/app/routes/credentials.py +++ b/backend_api_python/app/routes/credentials.py @@ -38,7 +38,7 @@ @login_required def desktop_brokers_policy(): """ - Whether IBKR (local TWS or IB Gateway) may be configured on this deployment. + Whether IBKR / Futu (local TWS, IB Gateway, or FutuOpenD) may be configured. Frontend uses this to disable options and show guidance before save/test. """ from app.utils.local_brokers import desktop_broker_cloud_reject_message, local_desktop_brokers_allowed @@ -50,6 +50,7 @@ def desktop_brokers_policy(): 'msg': 'success', 'data': { 'allow_local_desktop_brokers': allowed, + 'local_desktop_brokers': ['ibkr', 'futu'], 'disabled_message': None if allowed else desktop_broker_cloud_reject_message(), }, } @@ -89,7 +90,7 @@ def list_credentials(): items = [] for row in rows: item = dict(row or {}) - if str(item.get('exchange_id') or '').strip().lower() not in {*CRYPTO_EXCHANGES, 'ibkr', 'alpaca'}: + if str(item.get('exchange_id') or '').strip().lower() not in {*CRYPTO_EXCHANGES, 'ibkr', 'alpaca', 'futu'}: continue item['enable_demo_trading'] = False item['environment'] = 'live' @@ -233,6 +234,42 @@ def test_credential(data): if hasattr(client, 'connect') and not client.connect(): raise ValueError('CREDENTIAL_CONNECTION_FAILED') return jsonify({'code': 1, 'msg': 'CREDENTIAL_CONNECTION_OK', 'data': None}) + if exchange_id == 'futu': + from app.utils.local_brokers import desktop_broker_cloud_reject_message, local_desktop_brokers_allowed + from app.services.futu_trading.config import normalize_trade_env, normalize_trade_market + + if not local_desktop_brokers_allowed(): + raise ValueError(desktop_broker_cloud_reject_message('futu')) + trade_env = normalize_trade_env( + data.get('trade_env') or data.get('environment') or 'demo', + default='demo', + ) + config = { + 'exchange_id': exchange_id, + 'futu_host': str(data.get('futu_host') or data.get('host') or '127.0.0.1').strip(), + 'futu_port': int(data.get('futu_port') or data.get('port') or 11111), + 'trade_env': trade_env, + 'environment': trade_env, + 'trade_market': normalize_trade_market( + data.get('trade_market') or data.get('tradeMarket'), + market_category=str(data.get('market_category') or ''), + ), + 'security_firm': str(data.get('security_firm') or data.get('securityFirm') or 'FUTUSECURITIES').strip(), + 'acc_id': int(data.get('acc_id') or data.get('accId') or 0), + 'unlock_password': str(data.get('unlock_password') or data.get('unlockPassword') or ''), + } + client = create_client(config, market_type='spot') + probe = client.probe_permissions() if hasattr(client, 'probe_permissions') else {} + return jsonify({ + 'code': 1, + 'msg': 'CREDENTIAL_CONNECTION_OK', + 'data': { + 'environment': trade_env, + 'market_scope': 'spot', + 'probe': probe, + 'status': client.get_connection_status() if hasattr(client, 'get_connection_status') else {}, + }, + }) return jsonify({'code': 0, 'msg': 'UNSUPPORTED_EXCHANGE', 'data': None}), 400 except Exception as exc: return jsonify({'code': 0, 'msg': str(exc) or 'CREDENTIAL_CONNECTION_FAILED', 'data': None}), 400 @@ -245,7 +282,7 @@ def test_credential(data): def create_credential(data): """Create a new credential for the current user. - Supports crypto exchanges, IBKR (US stocks), and Alpaca. + Supports crypto exchanges, IBKR (US stocks), Alpaca, and Futu (HK/US). """ try: user_id = g.user_id @@ -255,11 +292,15 @@ def create_credential(data): if not exchange_id: return jsonify({'code': 0, 'msg': 'Missing exchange_id', 'data': None}), 400 - if exchange_id == 'ibkr': + if exchange_id in ('ibkr', 'futu'): from app.utils.local_brokers import desktop_broker_cloud_reject_message, local_desktop_brokers_allowed if not local_desktop_brokers_allowed(): - return jsonify({'code': 0, 'msg': desktop_broker_cloud_reject_message(), 'data': None}), 403 + return jsonify({ + 'code': 0, + 'msg': desktop_broker_cloud_reject_message(exchange_id), + 'data': None, + }), 403 config = {'exchange_id': exchange_id} hint = '' @@ -301,6 +342,30 @@ def create_credential(data): 'ibkr_account': (data.get('ibkr_account') or '').strip() }) hint = f"{config['ibkr_host']}:{config['ibkr_port']}" + elif exchange_id == 'futu': + from app.services.futu_trading.config import normalize_trade_env, normalize_trade_market + + trade_env = normalize_trade_env( + data.get('trade_env') or data.get('environment') or 'demo', + default='demo', + ) + trade_market = normalize_trade_market( + data.get('trade_market') or data.get('tradeMarket'), + market_category=str(data.get('market_category') or data.get('marketCategory') or ''), + ) + config.update({ + 'futu_host': (data.get('futu_host') or data.get('host') or '127.0.0.1').strip(), + 'futu_port': int(data.get('futu_port') or data.get('port') or 11111), + 'trade_env': trade_env, + 'environment': trade_env, + 'trade_market': trade_market, + 'security_firm': (data.get('security_firm') or data.get('securityFirm') or 'FUTUSECURITIES').strip(), + 'acc_id': int(data.get('acc_id') or data.get('accId') or 0), + # Prefer GUI unlock; store only when explicitly provided for headless OpenD. + 'unlock_password': str(data.get('unlock_password') or data.get('unlockPassword') or ''), + 'market_category': 'USStock' if trade_market == 'US' else 'HKStock', + }) + hint = f"{config['futu_host']}:{config['futu_port']} ({trade_env}/{trade_market})" elif exchange_id in CRYPTO_EXCHANGES: # Crypto exchanges try: diff --git a/backend_api_python/app/routes/futu.py b/backend_api_python/app/routes/futu.py new file mode 100644 index 000000000..45a7e8ac4 --- /dev/null +++ b/backend_api_python/app/routes/futu.py @@ -0,0 +1,234 @@ +""" +Futu OpenAPI routes — connection diagnostics, account/positions, quote probe. + +Strategy live orders still go through pending_orders; these endpoints are for +credential setup and operator health checks only. +""" + +from flask import jsonify, request +from app.openapi.blueprint import HumanBlueprint as Blueprint +from app.utils.auth import login_required +from app.utils.broker_session import BrokerSessionRegistry +from app.utils.logger import get_logger +from app.utils.local_brokers import desktop_broker_cloud_reject_message, local_desktop_brokers_allowed +from app.services.futu_trading import FutuClient, FutuConfig +from app.services.futu_trading.config import normalize_trade_env, normalize_trade_market + +logger = get_logger(__name__) + +futu_blp = Blueprint("futu", __name__) +_sessions = BrokerSessionRegistry("futu") + + +def _placeholder_status(): + return { + "connected": False, + "host": "", + "port": 0, + "trade_env": "demo", + "trade_market": "HK", + "acc_id": None, + } + + +def _require_connected_client(): + client = _sessions.get() + if client is None or not client.connected: + return None, (jsonify({"success": False, "error": "Not connected to FutuOpenD"}), 400) + return client, None + + +def _config_from_request(data: dict) -> FutuConfig: + env = normalize_trade_env( + data.get("trade_env") or data.get("environment") or data.get("tradeEnv") or "demo", + default="demo", + ) + market = normalize_trade_market( + data.get("trade_market") or data.get("tradeMarket") or data.get("market"), + market_category=str(data.get("market_category") or data.get("marketCategory") or ""), + ) + encrypt_raw = data.get("is_encrypt") + if encrypt_raw is None: + encrypt_raw = data.get("isEncrypt") + is_encrypt = None if encrypt_raw in (None, "") else bool(encrypt_raw) + return FutuConfig( + host=str(data.get("host") or data.get("futu_host") or "127.0.0.1").strip(), + port=int(data.get("port") or data.get("futu_port") or 11111), + trade_env=env, + trade_market=market, + security_firm=str(data.get("security_firm") or data.get("securityFirm") or "FUTUSECURITIES"), + acc_id=int(data.get("acc_id") or data.get("accId") or 0), + unlock_password=str( + data.get("unlock_password") or data.get("unlockPassword") or "" + ), + is_encrypt=is_encrypt, + market_category="USStock" if market == "US" else "HKStock", + ) + + +@futu_blp.route("/status", methods=["GET"]) +@login_required +def get_status(): + """Get FutuOpenD connection status for the current user session.""" + try: + client = _sessions.get() + if client is None: + return jsonify({"success": True, "data": _placeholder_status()}) + return jsonify({"success": True, "data": client.get_connection_status()}) + except Exception as e: + logger.error("Futu get status failed: %s", e) + return jsonify({"success": False, "error": str(e)}), 500 + + +@futu_blp.route("/connect", methods=["POST"]) +@login_required +def connect(): + """ + Connect to FutuOpenD (diagnostics only — no orders placed). + + Body: host, port, trade_env (demo|live), trade_market (HK|US), + security_firm, acc_id, unlock_password (optional). + """ + try: + if not local_desktop_brokers_allowed(): + return jsonify({ + "success": False, + "error": desktop_broker_cloud_reject_message("futu"), + }), 403 + + data = request.get_json() or {} + config = _config_from_request(data) + client = FutuClient(config) + if not client.connect(): + return jsonify({ + "success": False, + "error": "Connection failed. Ensure FutuOpenD is running and reachable.", + }), 400 + + _sessions.set(client) + return jsonify({ + "success": True, + "message": "Connected successfully", + "data": client.get_connection_status(), + }) + except ImportError: + return jsonify({ + "success": False, + "error": "futu-api not installed. Run: pip install futu-api", + }), 500 + except Exception as e: + logger.error("Futu connection failed: %s", e) + return jsonify({"success": False, "error": str(e)}), 500 + + +@futu_blp.route("/disconnect", methods=["POST"]) +@login_required +def disconnect(): + try: + _sessions.disconnect_current() + return jsonify({"success": True, "message": "Disconnected"}) + except Exception as e: + logger.error("Futu disconnect failed: %s", e) + return jsonify({"success": False, "error": str(e)}), 500 + + +@futu_blp.route("/probe", methods=["POST"]) +@login_required +def probe(): + """Connect (or reuse session) and return permissions / account probe (no orders).""" + try: + if not local_desktop_brokers_allowed(): + return jsonify({ + "success": False, + "error": desktop_broker_cloud_reject_message("futu"), + }), 403 + + data = request.get_json() or {} + client = _sessions.get() + if client is None or not client.connected: + config = _config_from_request(data) + client = FutuClient(config) + if not client.connect(): + return jsonify({ + "success": False, + "error": "Connection failed. Ensure FutuOpenD is running.", + }), 400 + _sessions.set(client) + + probe_data = client.probe_permissions() + return jsonify({ + "success": True, + "data": { + "status": client.get_connection_status(), + "probe": probe_data, + }, + }) + except ImportError: + return jsonify({ + "success": False, + "error": "futu-api not installed. Run: pip install futu-api", + }), 500 + except Exception as e: + logger.error("Futu probe failed: %s", e) + return jsonify({"success": False, "error": str(e)}), 500 + + +@futu_blp.route("/account", methods=["GET"]) +@login_required +def get_account(): + try: + client, err = _require_connected_client() + if err is not None: + return err + return jsonify({"success": True, "data": client.get_account_summary()}) + except Exception as e: + logger.error("Futu get account failed: %s", e) + return jsonify({"success": False, "error": str(e)}), 500 + + +@futu_blp.route("/positions", methods=["GET"]) +@login_required +def get_positions(): + try: + client, err = _require_connected_client() + if err is not None: + return err + return jsonify({"success": True, "data": client.get_positions()}) + except Exception as e: + logger.error("Futu get positions failed: %s", e) + return jsonify({"success": False, "error": str(e)}), 500 + + +@futu_blp.route("/orders", methods=["GET"]) +@login_required +def get_orders(): + try: + client, err = _require_connected_client() + if err is not None: + return err + return jsonify({"success": True, "data": client.get_open_orders()}) + except Exception as e: + logger.error("Futu get orders failed: %s", e) + return jsonify({"success": False, "error": str(e)}), 500 + + +@futu_blp.route("/quote", methods=["GET"]) +@login_required +def get_quote(): + """Get a snapshot quote (query: symbol, marketType=HKStock|USStock).""" + try: + client, err = _require_connected_client() + if err is not None: + return err + symbol = request.args.get("symbol") + market_type = request.args.get("marketType") or request.args.get("market_type") or "HKStock" + if not symbol: + return jsonify({"success": False, "error": "Missing symbol"}), 400 + return jsonify(client.get_quote(symbol, market_type)) + except Exception as e: + logger.error("Futu get quote failed: %s", e) + return jsonify({"success": False, "error": str(e)}), 500 + + +# openapi-compat: legacy import name +futu_bp = futu_blp diff --git a/backend_api_python/app/services/broker_market_policy.py b/backend_api_python/app/services/broker_market_policy.py index 32464fb5f..8e462ba6f 100644 --- a/backend_api_python/app/services/broker_market_policy.py +++ b/backend_api_python/app/services/broker_market_policy.py @@ -36,6 +36,8 @@ def _build_broker_markets() -> Dict[str, Dict[str, Set[str]]]: # US stocks via Interactive Brokers (TWS/Gateway, local desktop only) "ibkr": {"USStock": {"spot"}}, "alpaca": {"USStock": {"spot"}}, + # Futu OpenD: HK + US equities (spot, long-only) + "futu": {"HKStock": {"spot"}, "USStock": {"spot"}}, } for ex, capability in CRYPTO_VENUE_CAPABILITIES.items(): matrix[ex] = {"Crypto": set(capability.market_types)} @@ -52,7 +54,7 @@ def _build_broker_markets() -> Dict[str, Dict[str, Set[str]]]: # margin accounts, but neither _execute_ibkr_order nor _execute_alpaca_order # in pending_order_worker.py implement the short path (they reject any # signal containing 'short'). -LONG_ONLY_BROKERS: Set[str] = {"ibkr", "alpaca"} +LONG_ONLY_BROKERS: Set[str] = {"ibkr", "alpaca", "futu"} # Map bot strategy type -> markets where that bot makes sense and can @@ -65,15 +67,15 @@ def _build_broker_markets() -> Dict[str, Dict[str, Set[str]]]: BOT_TYPE_MARKETS: Dict[str, Set[str]] = { "grid": {"Crypto"}, "martingale": {"Crypto"}, - "dca": {"Crypto", "USStock"}, - "trend": {"Crypto", "USStock"}, + "dca": {"Crypto", "USStock", "HKStock"}, + "trend": {"Crypto", "USStock", "HKStock"}, } # Markets we recognize as legal canonical values. Anything outside this set -# is considered analysis/backtest-only (e.g. CNStock, HKStock, MOEX, Futures +# is considered analysis/backtest-only (e.g. CNStock, MOEX, Futures # generic) and may not be used for live strategies. -LIVE_MARKET_CATEGORIES: Set[str] = {"Crypto", "USStock"} +LIVE_MARKET_CATEGORIES: Set[str] = {"Crypto", "USStock", "HKStock"} # --------------------------------------------------------------------------- @@ -174,7 +176,7 @@ def validate_strategy_config( raise ValueError( f"market_category='{mc}' is not supported for live trading. " f"Supported: {sorted(LIVE_MARKET_CATEGORIES)}. " - "(CNStock / HKStock / MOEX / Futures are analysis-only.)" + "(CNStock / MOEX / Futures are analysis-only.)" ) if require_exchange: raise ValueError( @@ -199,7 +201,7 @@ def validate_strategy_config( raise ValueError( f"market_category='{mc}' is not supported for live trading. " f"Supported: {sorted(LIVE_MARKET_CATEGORIES)}. " - "(CNStock / HKStock / MOEX / Futures are analysis-only.)" + "(CNStock / MOEX / Futures are analysis-only.)" ) if mc and mc not in BROKER_MARKETS[ex]: @@ -227,7 +229,7 @@ def validate_strategy_config( f"long-only (got trade_direction='{td}'). For short selling " f"please use a perpetual-swap crypto exchange " f"(Binance/OKX/Bybit/Bitget) for crypto. " - f"Stock short selling on IBKR/Alpaca is not yet implemented." + f"Stock short selling on IBKR/Alpaca/Futu is not yet implemented." ) # Rule 6: crypto short requires swap. diff --git a/backend_api_python/app/services/execution_streams/adapters.py b/backend_api_python/app/services/execution_streams/adapters.py index c4ea20586..2b0d27c0a 100644 --- a/backend_api_python/app/services/execution_streams/adapters.py +++ b/backend_api_python/app/services/execution_streams/adapters.py @@ -22,6 +22,7 @@ parse_binance, parse_bitget, parse_bybit, + parse_futu_deal, parse_gate, parse_htx, parse_ibkr_execution, @@ -729,6 +730,92 @@ def _on_commission(self, trade: Any, fill: Any, report: Any) -> None: self.on_event(event) +class FutuExecutionAdapter: + """Poll / push FutuOpenD deal updates through TradeDealHandler callbacks.""" + + def __init__( + self, + *, + credential_id: int, + user_id: int, + config: Dict[str, Any], + on_event: EventCallback, + on_state: StateCallback, + **_: Any, + ) -> None: + self.credential_id = int(credential_id or 0) + self.user_id = int(user_id or 1) + self.config = dict(config or {}) + self.on_event = on_event + self.on_state = on_state + self._client: Any = None + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + + @property + def stream_key(self) -> str: + return f"futu:{self.credential_id}:stock" + + @property + def connected(self) -> bool: + return bool(self._client and self._client.connected) + + @property + def is_alive(self) -> bool: + return bool(self._thread and self._thread.is_alive()) + + def start(self) -> None: + if self.is_alive: + return + self._stop.clear() + self._thread = threading.Thread(target=self._run, name=f"ExecStream-{self.stream_key}", daemon=True) + self._thread.start() + + def stop(self, timeout: float = 5.0) -> bool: + self._stop.set() + if self._client: + try: + self._client.disconnect() + except Exception: + pass + if self.is_alive: + self._thread.join(timeout=timeout) + return not self.is_alive + + def _emit_deal(self, payload: Dict[str, Any]) -> None: + for event in parse_futu_deal(payload if isinstance(payload, dict) else {}): + event.credential_id = self.credential_id + event.user_id = self.user_id + self.on_event(event) + + def _emit_order(self, payload: Dict[str, Any]) -> None: + # Treat order push with dealt_qty as a deal-like update for REST audit gaps. + if not isinstance(payload, dict): + return + dealt = float(payload.get("dealt_qty") or payload.get("filled") or 0.0) + if dealt <= 0: + return + self._emit_deal(payload) + + def _run(self) -> None: + try: + from app.services.live_trading.factory import create_futu_client + + self._client = create_futu_client(self.config) + if not self._client.connected and not self._client.connect(): + raise RuntimeError("FutuOpenD connection failed") + self._client.add_deal_handler(self._emit_deal) + self._client.add_order_handler(self._emit_order) + self._client.start_push() + self.on_state("connected", "", False) + while not self._stop.is_set() and self._client.connected: + time.sleep(0.5) + except Exception as exc: + self.on_state("error", str(exc), False) + finally: + self.on_state("disconnected", "", False) + + ADAPTERS = { "binance": BinanceExecutionAdapter, "okx": OkxExecutionAdapter, @@ -738,4 +825,5 @@ def _on_commission(self, trade: Any, fill: Any, report: Any) -> None: "htx": HtxExecutionAdapter, "alpaca": AlpacaExecutionAdapter, "ibkr": IBKRExecutionAdapter, + "futu": FutuExecutionAdapter, } diff --git a/backend_api_python/app/services/execution_streams/normalizers.py b/backend_api_python/app/services/execution_streams/normalizers.py index d43927ea1..835130857 100644 --- a/backend_api_python/app/services/execution_streams/normalizers.py +++ b/backend_api_python/app/services/execution_streams/normalizers.py @@ -357,6 +357,87 @@ def parse_alpaca(payload: Dict[str, Any]) -> List[ExecutionEvent]: ] +def parse_futu_deal(payload: Dict[str, Any]) -> List[ExecutionEvent]: + """Normalize a Futu trade-deal push / order row into ExecutionEvent list.""" + if not isinstance(payload, dict): + return [] + code = str(payload.get("code") or payload.get("symbol") or "") + try: + from app.services.futu_trading.symbols import from_futu_code + + display, market = from_futu_code(code) + except Exception: + display, market = code, "HKStock" + order_id = str(payload.get("order_id") or payload.get("orderId") or "") + deal_id = str(payload.get("deal_id") or payload.get("exchange_fill_id") or payload.get("exec_id") or "") + is_order_snapshot = not bool(deal_id) + if is_order_snapshot: + # Order pushes expose cumulative dealt_qty and an aggregate fill price. + # Their ``price`` field is the order's limit price, not execution price. + qty = 0.0 + cumulative_qty = as_float(payload.get("dealt_qty") or payload.get("filled")) + price = as_float( + payload.get("dealt_avg_price") + or payload.get("avg_price") + or payload.get("price") + ) + else: + # TradeDealHandler rows carry a stable deal_id and per-deal qty/price. + qty = as_float(payload.get("qty") or payload.get("quantity")) + cumulative_qty = as_float(payload.get("dealt_qty")) + price = as_float( + payload.get("price") + or payload.get("dealt_avg_price") + or payload.get("avg_price") + ) + remark = str(payload.get("remark") or payload.get("client_order_id") or "") + side = str(payload.get("trd_side") or payload.get("side") or "").lower() + if "." in side: + side = side.split(".")[-1] + if side in ("buy", "buy_back"): + side = "buy" + elif side in ("sell", "sell_short"): + side = "sell" + status = normalize_status(payload.get("order_status") or payload.get("status") or "partial") + if status == "partial" and as_float(payload.get("dealt_qty")) > 0: + # keep partial; filled_all maps via normalize_status + pass + market_type = "hkstock" if market == "HKStock" else "usstock" + occurred = payload.get("create_time") or payload.get("updated_time") or payload.get("time") + try: + if isinstance(occurred, str) and occurred: + from app.services.futu_trading.timezones import market_timezone + + occurred_at = ( + datetime.strptime(occurred[:19], "%Y-%m-%d %H:%M:%S") + .replace(tzinfo=market_timezone(market)) + .astimezone(timezone.utc) + ) + else: + occurred_at = datetime.now(timezone.utc) + except Exception: + occurred_at = datetime.now(timezone.utc) + return [ + ExecutionEvent( + exchange_id="futu", + market_type=market_type, + symbol=display or code, + exchange_order_id=order_id, + client_order_id=remark, + exchange_fill_id=deal_id, + side=side, + order_status=status if status in ("filled", "partial", "cancelled", "open") else "partial", + price=price, + quantity=abs(qty), + cumulative_quantity=abs(cumulative_qty), + is_cumulative=is_order_snapshot, + fee_status="pending", + occurred_at=occurred_at, + raw=payload, + ) + ] + + def parse_ibkr_execution(execution: Any, contract: Any = None) -> ExecutionEvent: symbol = str(getattr(contract, "symbol", "") or getattr(execution, "symbol", "")) occurred = getattr(execution, "time", None) diff --git a/backend_api_python/app/services/futu_trading/README.md b/backend_api_python/app/services/futu_trading/README.md new file mode 100644 index 000000000..4c8ab9fdb --- /dev/null +++ b/backend_api_python/app/services/futu_trading/README.md @@ -0,0 +1,36 @@ +# Futu OpenAPI Adapter + +QuantDinger broker adapter for [Futu OpenAPI](https://openapi.futunn.com/futu-api-doc/) via **FutuOpenD**. + +## Prerequisites + +1. Install and login to **FutuOpenD** (GUI or console). Default listen address: `127.0.0.1:11111`. +2. Install Python SDK: `pip install futu-api` +3. Ensure `ALLOW_LOCAL_DESKTOP_BROKERS=true` when the API runs in Docker/SaaS that should talk to a desktop OpenD. + +## Credential fields + +| Field | Description | +|-------|-------------| +| `futu_host` | OpenD host (default `127.0.0.1`) | +| `futu_port` | OpenD port (default `11111`) | +| `trade_env` / `environment` | `demo` → `TrdEnv.SIMULATE`, `live` → `TrdEnv.REAL` | +| `trade_market` | `HK` or `US` (filters accounts) | +| `security_firm` | e.g. `FUTUSECURITIES`, `FUTUINC`, `FUTUSG` | +| `acc_id` | Optional; auto-select first matching account when empty | +| `unlock_password` | Optional; prefer GUI unlock for live trading | + +## Symbol format + +| QuantDinger | Futu | +|-------------|------| +| `00700.HK` | `HK.00700` | +| `AAPL` | `US.AAPL` | + +## Supported markets (MVP) + +- `HKStock` spot, long-only +- `USStock` spot, long-only +- Paper (`demo`) and live (`live`) + +Not supported in this adapter: short selling, options, futures, margin financing, grid/martingale bots. diff --git a/backend_api_python/app/services/futu_trading/__init__.py b/backend_api_python/app/services/futu_trading/__init__.py new file mode 100644 index 000000000..31dbac210 --- /dev/null +++ b/backend_api_python/app/services/futu_trading/__init__.py @@ -0,0 +1,32 @@ +""" +Futu (富途) OpenAPI trading module. + +Requires a running FutuOpenD gateway (default 127.0.0.1:11111) and the +official ``futu-api`` Python package. + +Supports HKStock and USStock spot (long-only) on simulate / real trade envs. +""" + +from app.services.futu_trading.client import FutuClient, OrderResult +from app.services.futu_trading.config import FutuConfig, config_from_exchange_config +from app.services.futu_trading.quote_client import FutuQuoteClient +from app.services.futu_trading.symbols import ( + format_display_symbol, + from_futu_code, + normalize_symbol, + parse_symbol, + to_futu_code, +) + +__all__ = [ + "FutuClient", + "FutuConfig", + "FutuQuoteClient", + "OrderResult", + "config_from_exchange_config", + "format_display_symbol", + "from_futu_code", + "normalize_symbol", + "parse_symbol", + "to_futu_code", +] diff --git a/backend_api_python/app/services/futu_trading/client.py b/backend_api_python/app/services/futu_trading/client.py new file mode 100644 index 000000000..2a490968c --- /dev/null +++ b/backend_api_python/app/services/futu_trading/client.py @@ -0,0 +1,838 @@ +""" +Futu OpenAPI trading client. + +Wraps futu-api OpenQuoteContext / OpenSecTradeContext behind a surface +compatible with IBKRClient / AlpacaClient used by PendingOrderWorker. +""" + +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional + +from app.services.futu_trading.config import FutuConfig, validate_opend_host +from app.services.futu_trading.mappers import ( + account_row_to_dict, + classify_futu_error, + is_final_fill_status, + normalize_order_status, + order_row_to_raw, + order_type_to_futu, + position_row_to_dict, + safe_float, + side_from_futu, + side_to_futu, +) +from app.services.futu_trading.symbols import ( + format_display_symbol, + from_futu_code, + infer_market_category, + to_futu_code, +) +from app.services.futu_trading.timezones import futu_time_key_to_timestamp +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +_futu_modules = None + + +def _ensure_futu(): + """Lazy-import futu-api so other brokers still work without it installed.""" + global _futu_modules + if _futu_modules is None: + try: + import futu as ft + except ImportError as exc: + raise ImportError( + "futu-api is not installed. Run: pip install futu-api" + ) from exc + _futu_modules = ft + return _futu_modules + + +@dataclass +class OrderResult: + """Order execution result (mirrors ibkr_trading.OrderResult).""" + + success: bool + order_id: str = "" + filled: float = 0.0 + avg_price: float = 0.0 + status: str = "" + message: str = "" + raw: Dict[str, Any] = field(default_factory=dict) + + +class FutuClient: + """ + Futu securities trading client via OpenD. + + Usage: + config = FutuConfig(host="127.0.0.1", port=11111, trade_env="demo") + client = FutuClient(config) + if client.connect(): + client.place_market_order("00700.HK", "buy", 100, "HKStock") + client.disconnect() + """ + + def __init__(self, config: Optional[FutuConfig] = None): + self.config = config or FutuConfig() + self._quote_ctx = None + self._trade_ctx = None + self._connected = False + self._lock = threading.RLock() + self._acc_id = int(self.config.acc_id or 0) + self._accounts: List[Dict[str, Any]] = [] + self._order_handlers: List[Callable[[Dict[str, Any]], None]] = [] + self._deal_handlers: List[Callable[[Dict[str, Any]], None]] = [] + self._push_handler = None + + # ------------------------------------------------------------------ + # Connection + # ------------------------------------------------------------------ + + @property + def connected(self) -> bool: + return bool(self._connected and self._quote_ctx is not None and self._trade_ctx is not None) + + def connect(self) -> bool: + with self._lock: + if self.connected: + return True + try: + validate_opend_host(self.config.host) + ft = _ensure_futu() + encrypt = self.config.is_encrypt + quote_kwargs: Dict[str, Any] = { + "host": self.config.host, + "port": int(self.config.port), + } + trade_kwargs: Dict[str, Any] = { + "host": self.config.host, + "port": int(self.config.port), + "filter_trdmarket": self._trd_market_enum(ft), + "security_firm": self._security_firm_enum(ft), + } + if encrypt is not None: + quote_kwargs["is_encrypt"] = encrypt + trade_kwargs["is_encrypt"] = encrypt + + logger.info( + "Connecting to FutuOpenD %s:%s env=%s market=%s firm=%s", + self.config.host, + self.config.port, + self.config.trade_env, + self.config.trade_market, + self.config.security_firm, + ) + self._quote_ctx = ft.OpenQuoteContext(**quote_kwargs) + self._trade_ctx = ft.OpenSecTradeContext(**trade_kwargs) + + # Probe quote connection + ret, data = self._quote_ctx.get_global_state() + if ret != ft.RET_OK: + raise RuntimeError(f"OpenD quote probe failed: {data}") + + self._refresh_accounts_unlocked() + if self.config.trade_env == "live" and self.config.unlock_password: + self._unlock_trade_unlocked() + + self._connected = True + logger.info( + "Futu connected host=%s:%s acc_id=%s accounts=%s", + self.config.host, + self.config.port, + self._acc_id or "auto", + len(self._accounts), + ) + return True + except Exception as exc: + logger.error("Futu connection failed: %s", exc) + self._cleanup_contexts() + self._connected = False + return False + + def disconnect(self) -> None: + with self._lock: + self._cleanup_contexts() + self._connected = False + logger.info("Futu disconnected") + + def _cleanup_contexts(self) -> None: + for attr in ("_trade_ctx", "_quote_ctx"): + ctx = getattr(self, attr, None) + if ctx is None: + continue + try: + ctx.close() + except Exception as exc: + logger.debug("Futu context close error: %s", exc) + setattr(self, attr, None) + self._push_handler = None + + def _ensure_connected(self) -> None: + if not self.connected: + if not self.connect(): + raise ConnectionError("Cannot connect to FutuOpenD") + + # ------------------------------------------------------------------ + # Enum helpers + # ------------------------------------------------------------------ + + def _trd_env(self, ft) -> Any: + return ft.TrdEnv.SIMULATE if self.config.is_simulate else ft.TrdEnv.REAL + + def _trd_market_enum(self, ft) -> Any: + market = (self.config.trade_market or "NONE").upper() + mapping = { + "HK": getattr(ft.TrdMarket, "HK", None), + "US": getattr(ft.TrdMarket, "US", None), + "NONE": getattr(ft.TrdMarket, "NONE", None), + "CN": getattr(ft.TrdMarket, "CN", None), + } + return mapping.get(market) or ft.TrdMarket.NONE + + def _security_firm_enum(self, ft) -> Any: + firm = (self.config.security_firm or "FUTUSECURITIES").upper() + return getattr(ft.SecurityFirm, firm, ft.SecurityFirm.FUTUSECURITIES) + + def _side_enum(self, ft, side: str) -> Any: + name = side_to_futu(side) + return getattr(ft.TrdSide, name, ft.TrdSide.BUY) + + def _order_type_enum(self, ft, order_type: str) -> Any: + name = order_type_to_futu(order_type) + return getattr(ft.OrderType, name, ft.OrderType.MARKET) + + # ------------------------------------------------------------------ + # Accounts / unlock + # ------------------------------------------------------------------ + + def _refresh_accounts_unlocked(self) -> None: + ft = _ensure_futu() + ret, data = self._trade_ctx.get_acc_list() + accounts: List[Dict[str, Any]] = [] + if ret == ft.RET_OK and data is not None: + try: + records = data.to_dict("records") if hasattr(data, "to_dict") else list(data) + except Exception: + records = [] + for row in records: + try: + acc_id = int(row.get("acc_id") or row.get("accid") or 0) + except Exception: + acc_id = 0 + accounts.append({ + "acc_id": acc_id, + "trd_env": str(row.get("trd_env") or ""), + "acc_type": str(row.get("acc_type") or ""), + "uni_card_num": str(row.get("uni_card_num") or ""), + "card_num": str(row.get("card_num") or ""), + "security_firm": str(row.get("security_firm") or ""), + "sim_acc_type": str(row.get("sim_acc_type") or ""), + "trdmarket_auth": row.get("trdmarket_auth"), + }) + self._accounts = accounts + if self._acc_id <= 0 and accounts: + # Prefer matching env when possible + env_name = "SIMULATE" if self.config.is_simulate else "REAL" + preferred = [ + a for a in accounts + if env_name in str(a.get("trd_env") or "").upper() + ] + chosen = preferred[0] if preferred else accounts[0] + self._acc_id = int(chosen.get("acc_id") or 0) + + def _unlock_trade_unlocked(self) -> None: + ft = _ensure_futu() + password = self.config.unlock_password + if not password: + return + ret, data = self._trade_ctx.unlock_trade(password) + if ret != ft.RET_OK: + code, msg = classify_futu_error(data) + raise RuntimeError(f"{code}:{msg}") + + def unlock_trade(self, password: Optional[str] = None) -> bool: + with self._lock: + self._ensure_connected() + ft = _ensure_futu() + pwd = password if password is not None else self.config.unlock_password + if not pwd: + # GUI OpenD may already be unlocked + return True + ret, data = self._trade_ctx.unlock_trade(pwd) + if ret != ft.RET_OK: + logger.error("Futu unlock_trade failed: %s", data) + return False + return True + + # ------------------------------------------------------------------ + # Diagnostics + # ------------------------------------------------------------------ + + def get_connection_status(self) -> Dict[str, Any]: + status = { + "connected": self.connected, + "host": self.config.host, + "port": self.config.port, + "trade_env": self.config.trade_env, + "trade_market": self.config.trade_market, + "security_firm": self.config.security_firm, + "acc_id": self._acc_id or None, + "accounts": [ + {k: v for k, v in acc.items() if k != "uni_card_num"} + for acc in self._accounts + ], + } + if not self.connected: + return status + try: + ft = _ensure_futu() + ret, data = self._quote_ctx.get_global_state() + if ret == ft.RET_OK and isinstance(data, dict): + status["opend"] = { + "quote_login": data.get("qot_logined") or data.get("market_sz"), + "trade_login": data.get("trd_logined"), + "server_ver": data.get("server_ver"), + "login_user_id": data.get("login_user_id"), + } + except Exception as exc: + status["opend_error"] = str(exc) + return status + + def probe_permissions(self) -> Dict[str, Any]: + """Best-effort market / quote permission snapshot (no orders).""" + self._ensure_connected() + result: Dict[str, Any] = { + "trade_env": self.config.trade_env, + "accounts": self._accounts, + "quote_ok": False, + "trade_ok": False, + "sample_quote": None, + "errors": [], + } + sample_code = "HK.00700" if self.config.trade_market != "US" else "US.AAPL" + try: + quote = self.get_quote(format_display_symbol(sample_code), infer_market_category(sample_code)) + result["quote_ok"] = bool(quote.get("success")) + result["sample_quote"] = quote + if not quote.get("success"): + result["errors"].append(quote.get("error") or "quote_failed") + except Exception as exc: + result["errors"].append(str(exc)) + try: + acc = self.get_account_summary() + result["trade_ok"] = bool(acc.get("success")) + result["account"] = acc + if not acc.get("success"): + result["errors"].append(acc.get("error") or "account_failed") + except Exception as exc: + result["errors"].append(str(exc)) + return result + + # ------------------------------------------------------------------ + # Orders + # ------------------------------------------------------------------ + + def _acc_id_arg(self) -> int: + return int(self._acc_id or 0) + + def _place_order( + self, + *, + symbol: str, + side: str, + quantity: float, + price: float, + order_type: str, + market_type: str = "", + remark: str = "", + ) -> OrderResult: + try: + with self._lock: + self._ensure_connected() + ft = _ensure_futu() + market = market_type or infer_market_category(symbol) + if market not in ("HKStock", "USStock"): + return OrderResult(success=False, message=f"Unsupported market_type: {market}") + code = to_futu_code(symbol, market) + qty = float(quantity or 0.0) + if qty <= 0: + return OrderResult(success=False, message="quantity must be > 0") + + # Lot-size validation (best effort) + lot = self._query_lot_size_unlocked(code) + if lot and lot > 1: + # Reject non-multiples instead of silently rounding up + if abs(qty % lot) > 1e-8: + return OrderResult( + success=False, + message=f"FUTU_INVALID_LOT_SIZE: qty={qty} lot_size={lot}", + ) + + if self.config.trade_env == "live" and self.config.unlock_password: + self._unlock_trade_unlocked() + + ot = order_type_to_futu(order_type) + px = float(price or 0.0) + if ot == "MARKET" and px <= 0: + # Some markets still require a reference price for market orders. + snap = self._snapshot_unlocked(code) + px = safe_float(snap.get("last_price") or snap.get("price") or snap.get("last")) + if px <= 0: + px = 0.01 + + kwargs = { + "price": px, + "qty": qty, + "code": code, + "trd_side": self._side_enum(ft, side), + "order_type": self._order_type_enum(ft, order_type), + "trd_env": self._trd_env(ft), + "acc_id": self._acc_id_arg(), + } + if remark: + kwargs["remark"] = str(remark)[:64] + + ret, data = self._trade_ctx.place_order(**kwargs) + if ret != ft.RET_OK: + code_err, msg = classify_futu_error(data) + return OrderResult(success=False, message=f"{code_err}:{msg}", raw={"error": str(data)}) + + row = None + if hasattr(data, "iloc") and len(data) > 0: + row = data.iloc[0] + elif isinstance(data, list) and data: + row = data[0] + elif isinstance(data, dict): + row = data + raw = order_row_to_raw(row) + status = normalize_order_status(raw.get("status")) + return OrderResult( + success=True, + order_id=str(raw.get("order_id") or ""), + filled=safe_float(raw.get("filled")), + avg_price=safe_float(raw.get("avg_price")), + status=status, + message="Order submitted", + raw=raw, + ) + except Exception as exc: + logger.error("Futu place_order failed: %s", exc) + code_err, msg = classify_futu_error(exc) + return OrderResult(success=False, message=f"{code_err}:{msg}") + + def place_market_order( + self, + symbol: str, + side: str, + quantity: float, + market_type: str = "HKStock", + remark: str = "", + **_: Any, + ) -> OrderResult: + return self._place_order( + symbol=symbol, + side=side, + quantity=quantity, + price=0.0, + order_type="market", + market_type=market_type, + remark=remark, + ) + + def place_limit_order( + self, + symbol: str, + side: str, + quantity: float, + price: float, + market_type: str = "HKStock", + remark: str = "", + **_: Any, + ) -> OrderResult: + if float(price or 0.0) <= 0: + return OrderResult(success=False, message="limit price must be > 0") + return self._place_order( + symbol=symbol, + side=side, + quantity=quantity, + price=float(price), + order_type="limit", + market_type=market_type, + remark=remark, + ) + + def cancel_order(self, order_id: str) -> bool: + try: + with self._lock: + self._ensure_connected() + ft = _ensure_futu() + oid = str(order_id or "").strip() + if not oid: + return False + ret, data = self._trade_ctx.modify_order( + modify_order_op=ft.ModifyOrderOp.CANCEL, + order_id=oid, + qty=0, + price=0, + trd_env=self._trd_env(ft), + acc_id=self._acc_id_arg(), + ) + if ret != ft.RET_OK: + logger.warning("Futu cancel_order failed: %s", data) + return False + return True + except Exception as exc: + logger.error("Futu cancel_order exception: %s", exc) + return False + + def get_order_status(self, order_id: str) -> OrderResult: + try: + with self._lock: + self._ensure_connected() + ft = _ensure_futu() + oid = str(order_id or "").strip() + if not oid: + return OrderResult(success=False, message="Missing order_id") + ret, data = self._trade_ctx.order_list_query( + order_id=oid, + trd_env=self._trd_env(ft), + acc_id=self._acc_id_arg(), + ) + if ret != ft.RET_OK: + code_err, msg = classify_futu_error(data) + return OrderResult(success=False, order_id=oid, message=f"{code_err}:{msg}") + if data is None or (hasattr(data, "__len__") and len(data) == 0): + return OrderResult( + success=False, + order_id=oid, + message="Order not found in current query window", + ) + row = data.iloc[0] if hasattr(data, "iloc") else (data[0] if isinstance(data, list) else data) + raw = order_row_to_raw(row) + return OrderResult( + success=True, + order_id=str(raw.get("order_id") or oid), + filled=safe_float(raw.get("filled")), + avg_price=safe_float(raw.get("avg_price")), + status=normalize_order_status(raw.get("status")), + message=str(raw.get("message") or "OK"), + raw=raw, + ) + except Exception as exc: + logger.error("Futu get_order_status failed: %s", exc) + return OrderResult(success=False, order_id=str(order_id or ""), message=str(exc)) + + def find_order_by_remark(self, remark: str) -> Optional[OrderResult]: + """Idempotency helper: locate an order by client remark after a timeout.""" + tag = str(remark or "").strip() + if not tag: + return None + try: + with self._lock: + self._ensure_connected() + ft = _ensure_futu() + ret, data = self._trade_ctx.order_list_query( + trd_env=self._trd_env(ft), + acc_id=self._acc_id_arg(), + ) + if ret != ft.RET_OK or data is None: + return None + records = data.to_dict("records") if hasattr(data, "to_dict") else list(data) + for row in records: + raw = order_row_to_raw(row) + if str(raw.get("remark") or "") == tag: + return OrderResult( + success=True, + order_id=str(raw.get("order_id") or ""), + filled=safe_float(raw.get("filled")), + avg_price=safe_float(raw.get("avg_price")), + status=normalize_order_status(raw.get("status")), + message="matched_by_remark", + raw=raw, + ) + except Exception as exc: + logger.debug("find_order_by_remark failed: %s", exc) + return None + + # ------------------------------------------------------------------ + # Account / positions / quote + # ------------------------------------------------------------------ + + def get_account_summary(self) -> Dict[str, Any]: + try: + with self._lock: + self._ensure_connected() + ft = _ensure_futu() + ret, data = self._trade_ctx.accinfo_query( + trd_env=self._trd_env(ft), + acc_id=self._acc_id_arg(), + ) + if ret != ft.RET_OK: + return {"success": False, "error": str(data)} + row = data.iloc[0] if hasattr(data, "iloc") and len(data) else data + summary = account_row_to_dict(row) + return { + "success": True, + "account": self._acc_id, + "trade_env": self.config.trade_env, + "summary": summary, + } + except Exception as exc: + logger.error("Futu get_account_summary failed: %s", exc) + return {"success": False, "error": str(exc)} + + def get_positions(self) -> List[Dict[str, Any]]: + try: + with self._lock: + self._ensure_connected() + ft = _ensure_futu() + ret, data = self._trade_ctx.position_list_query( + trd_env=self._trd_env(ft), + acc_id=self._acc_id_arg(), + ) + if ret != ft.RET_OK or data is None: + logger.warning("Futu position_list_query failed: %s", data) + return [] + records = data.to_dict("records") if hasattr(data, "to_dict") else list(data) + return [position_row_to_dict(row) for row in records] + except Exception as exc: + logger.error("Futu get_positions failed: %s", exc) + return [] + + def get_open_orders(self) -> List[Dict[str, Any]]: + try: + with self._lock: + self._ensure_connected() + ft = _ensure_futu() + ret, data = self._trade_ctx.order_list_query( + trd_env=self._trd_env(ft), + acc_id=self._acc_id_arg(), + status_filter_list=[ + ft.OrderStatus.SUBMITTED, + ft.OrderStatus.FILLED_PART, + ft.OrderStatus.WAITING_SUBMIT, + ft.OrderStatus.SUBMITTING, + ], + ) + if ret != ft.RET_OK or data is None: + return [] + records = data.to_dict("records") if hasattr(data, "to_dict") else list(data) + out = [] + for row in records: + raw = order_row_to_raw(row) + display, _ = from_futu_code(str(raw.get("code") or "")) + out.append({ + "orderId": raw.get("order_id"), + "symbol": display, + "futu_code": raw.get("code"), + "action": raw.get("side"), + "quantity": raw.get("qty"), + "orderType": "limit" if safe_float(raw.get("price")) > 0 else "market", + "limitPrice": raw.get("price"), + "status": raw.get("status"), + "filled": raw.get("filled"), + "avgFillPrice": raw.get("avg_price"), + "remark": raw.get("remark"), + }) + return out + except Exception as exc: + logger.error("Futu get_open_orders failed: %s", exc) + return [] + + def get_quote(self, symbol: str, market_type: str = "HKStock") -> Dict[str, Any]: + try: + with self._lock: + self._ensure_connected() + code = to_futu_code(symbol, market_type or infer_market_category(symbol)) + snap = self._snapshot_unlocked(code) + if not snap: + return {"success": False, "error": f"No quote for {code}"} + last = safe_float(snap.get("last_price") or snap.get("price") or snap.get("last")) + return { + "success": True, + "symbol": format_display_symbol(code), + "futu_code": code, + "bid": safe_float(snap.get("bid_price") or snap.get("bid")), + "ask": safe_float(snap.get("ask_price") or snap.get("ask")), + "last": last, + "high": safe_float(snap.get("high_price") or snap.get("high")), + "low": safe_float(snap.get("low_price") or snap.get("low")), + "volume": safe_float(snap.get("volume")), + "close": safe_float(snap.get("prev_close_price") or snap.get("close")), + "raw": snap, + } + except Exception as exc: + code_err, msg = classify_futu_error(exc) + logger.error("Futu get_quote failed: %s", msg) + return {"success": False, "error": f"{code_err}:{msg}"} + + def _snapshot_unlocked(self, code: str) -> Dict[str, Any]: + ft = _ensure_futu() + ret, data = self._quote_ctx.get_market_snapshot([code]) + if ret != ft.RET_OK or data is None or len(data) == 0: + raise RuntimeError(data if ret != ft.RET_OK else f"empty snapshot for {code}") + row = data.iloc[0] if hasattr(data, "iloc") else data[0] + try: + return dict(row) + except Exception: + return {"last_price": safe_float(getattr(row, "last_price", 0)), "code": code} + + def _query_lot_size_unlocked(self, code: str) -> int: + try: + ft = _ensure_futu() + ret, data = self._quote_ctx.get_market_snapshot([code]) + if ret != ft.RET_OK or data is None or len(data) == 0: + return 0 + row = data.iloc[0] + lot = int(safe_float(row.get("lot_size") if hasattr(row, "get") else getattr(row, "lot_size", 0))) + return max(0, lot) + except Exception: + return 0 + + # ------------------------------------------------------------------ + # History K-line (used by data_sources.futu) + # ------------------------------------------------------------------ + + def get_history_kline( + self, + symbol: str, + *, + market_type: str = "", + ktype: str = "K_DAY", + start: Optional[str] = None, + end: Optional[str] = None, + max_count: int = 500, + autype: str = "QFQ", + ) -> List[Dict[str, Any]]: + with self._lock: + self._ensure_connected() + ft = _ensure_futu() + market = market_type or infer_market_category(symbol) + code = to_futu_code(symbol, market) + ktype_enum = getattr(ft.KLType, ktype, ft.KLType.K_DAY) + autype_enum = getattr(ft.AuType, autype, ft.AuType.QFQ) + page_req_key = None + rows: List[Dict[str, Any]] = [] + remaining = max(1, int(max_count or 500)) + while remaining > 0: + batch = min(1000, remaining) + ret, data, page_req_key = self._quote_ctx.request_history_kline( + code=code, + start=start, + end=end, + ktype=ktype_enum, + autype=autype_enum, + max_count=batch, + page_req_key=page_req_key, + ) + if ret != ft.RET_OK: + code_err, msg = classify_futu_error(data) + raise RuntimeError(f"{code_err}:{msg}") + if data is None or len(data) == 0: + break + records = data.to_dict("records") if hasattr(data, "to_dict") else list(data) + for rec in records: + ts = rec.get("time_key") or rec.get("time") + try: + unix_ts = futu_time_key_to_timestamp(ts, market) + except (TypeError, ValueError): + continue + rows.append({ + "time": unix_ts, + "open": safe_float(rec.get("open")), + "high": safe_float(rec.get("high")), + "low": safe_float(rec.get("low")), + "close": safe_float(rec.get("close")), + "volume": safe_float(rec.get("volume")), + }) + remaining -= len(records) + if not page_req_key: + break + rows.sort(key=lambda x: x["time"]) + return rows + + # ------------------------------------------------------------------ + # Push handlers (execution stream) + # ------------------------------------------------------------------ + + def add_order_handler(self, handler: Callable[[Dict[str, Any]], None]) -> None: + self._order_handlers.append(handler) + + def add_deal_handler(self, handler: Callable[[Dict[str, Any]], None]) -> None: + self._deal_handlers.append(handler) + + def start_push(self) -> bool: + """Subscribe trade order / deal push on the trade context.""" + try: + with self._lock: + self._ensure_connected() + ft = _ensure_futu() + + client = self + + class _Handler(ft.TradeOrderHandlerBase if hasattr(ft, "TradeOrderHandlerBase") else object): + def on_recv_rsp(self, rsp_pb): # type: ignore[no-untyped-def] + try: + ret, data = super().on_recv_rsp(rsp_pb) # type: ignore[misc] + except Exception: + return + if ret != ft.RET_OK or data is None: + return + records = data.to_dict("records") if hasattr(data, "to_dict") else [data] + for row in records: + raw = order_row_to_raw(row) + for cb in list(client._order_handlers): + try: + cb(raw) + except Exception as exc: + logger.debug("Futu order handler error: %s", exc) + + class _DealHandler(ft.TradeDealHandlerBase if hasattr(ft, "TradeDealHandlerBase") else object): + def on_recv_rsp(self, rsp_pb): # type: ignore[no-untyped-def] + try: + ret, data = super().on_recv_rsp(rsp_pb) # type: ignore[misc] + except Exception: + return + if ret != ft.RET_OK or data is None: + return + records = data.to_dict("records") if hasattr(data, "to_dict") else [data] + for row in records: + payload = dict(row) if not isinstance(row, dict) else row + for cb in list(client._deal_handlers): + try: + cb(payload) + except Exception as exc: + logger.debug("Futu deal handler error: %s", exc) + + if hasattr(ft, "TradeOrderHandlerBase"): + self._trade_ctx.set_handler(_Handler()) + if hasattr(ft, "TradeDealHandlerBase"): + self._trade_ctx.set_handler(_DealHandler()) + return True + except Exception as exc: + logger.warning("Futu start_push failed: %s", exc) + return False + + def subscribe_quote(self, symbols: List[str], market_type: str = "") -> bool: + try: + with self._lock: + self._ensure_connected() + ft = _ensure_futu() + codes = [to_futu_code(s, market_type or infer_market_category(s)) for s in symbols if s] + if not codes: + return True + ret, err = self._quote_ctx.subscribe(codes, [ft.SubType.QUOTE]) + if ret != ft.RET_OK: + code_err, msg = classify_futu_error(err) + logger.warning("Futu subscribe failed: %s:%s", code_err, msg) + return False + return True + except Exception as exc: + logger.warning("Futu subscribe_quote exception: %s", exc) + return False diff --git a/backend_api_python/app/services/futu_trading/config.py b/backend_api_python/app/services/futu_trading/config.py new file mode 100644 index 000000000..123f5ac28 --- /dev/null +++ b/backend_api_python/app/services/futu_trading/config.py @@ -0,0 +1,197 @@ +"""Futu OpenD connection configuration.""" + +from __future__ import annotations + +import ipaddress +import os +from dataclasses import dataclass +from typing import Any, Dict, Optional + +_LOCAL_OPEND_HOSTNAMES = {"localhost", "host.docker.internal"} +_PRIVATE_OPEND_NETWORKS = tuple( + ipaddress.ip_network(cidr) + for cidr in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fd00::/8") +) + + +def is_local_or_private_opend_host(host: Any) -> bool: + """Allow loopback, approved local names, RFC1918 IPv4, and IPv6 ULA only.""" + value = str(host or "").strip().lower() + if value in _LOCAL_OPEND_HOSTNAMES: + return True + try: + address = ipaddress.ip_address(value.strip("[]")) + except ValueError: + return False + return address.is_loopback or any(address in network for network in _PRIVATE_OPEND_NETWORKS) + + +def remote_opend_allowed() -> bool: + return _truthy(os.getenv("FUTU_ALLOW_REMOTE_OPEND")) + + +def validate_opend_host(host: Any) -> str: + """Validate every OpenD connection target before the SDK opens a socket.""" + value = str(host or "").strip() + if remote_opend_allowed() or is_local_or_private_opend_host(value): + return value + raise ValueError( + "FutuOpenD host must be localhost / private LAN unless " + "FUTU_ALLOW_REMOTE_OPEND=true" + ) + + +def _truthy(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return int(value) == 1 + return str(value or "").strip().lower() in ("true", "1", "yes", "on") + + +def normalize_trade_env(value: Any, *, default: str = "demo") -> str: + """Map credential / request values to QuantDinger env: demo | live.""" + raw = str(value or "").strip().lower() + if raw in ("live", "real", "prod", "production", "mainnet"): + return "live" + if raw in ("demo", "paper", "simulate", "simulation", "simulated", "sim", "testnet", "sandbox"): + return "demo" + if _truthy(value) and raw in ("true", "1", "yes", "on"): + # Bare true flags are treated as demo for safety. + return "demo" + return default if not raw else default + + +def normalize_security_firm(value: Any) -> str: + raw = str(value or "").strip().upper() + aliases = { + "": "FUTUSECURITIES", + "NONE": "NONE", + "FUTU": "FUTUSECURITIES", + "FUTUSECURITIES": "FUTUSECURITIES", + "FUTUINC": "FUTUINC", + "FUTUSG": "FUTUSG", + "FUTUAU": "FUTUAU", + "FUTUMY": "FUTUMY", + "FUTUJP": "FUTUJP", + "FUTUCA": "FUTUCA", + } + return aliases.get(raw, raw or "FUTUSECURITIES") + + +def normalize_trade_market(value: Any, *, market_category: str = "") -> str: + """Return Futu TrdMarket name: HK | US | NONE.""" + raw = str(value or "").strip().upper() + mc = str(market_category or "").strip() + if raw in ("HK", "HKSTOCK", "HONGKONG", "HK_STOCK"): + return "HK" + if raw in ("US", "USSTOCK", "US_STOCK", "NYSE", "NASDAQ"): + return "US" + if raw in ("NONE", "ALL", ""): + if mc == "HKStock": + return "HK" + if mc == "USStock": + return "US" + return "NONE" + if mc == "HKStock": + return "HK" + if mc == "USStock": + return "US" + return raw or "NONE" + + +@dataclass +class FutuConfig: + """Connection settings for FutuOpenD.""" + + host: str = "127.0.0.1" + port: int = 11111 + trade_env: str = "demo" # demo -> SIMULATE, live -> REAL + trade_market: str = "HK" # HK | US | NONE + security_firm: str = "FUTUSECURITIES" + acc_id: int = 0 + unlock_password: str = "" + is_encrypt: Optional[bool] = None + timeout: float = 20.0 + market_category: str = "" + + def __post_init__(self) -> None: + self.host = str(self.host or "127.0.0.1").strip() or "127.0.0.1" + self.port = int(self.port or 11111) + self.trade_env = normalize_trade_env(self.trade_env, default="demo") + self.trade_market = normalize_trade_market(self.trade_market, market_category=self.market_category) + self.security_firm = normalize_security_firm(self.security_firm) + self.unlock_password = str(self.unlock_password or "") + try: + self.acc_id = int(self.acc_id or 0) + except (TypeError, ValueError): + self.acc_id = 0 + + @property + def is_simulate(self) -> bool: + return self.trade_env != "live" + + def redacted_dict(self) -> Dict[str, Any]: + return { + "host": self.host, + "port": self.port, + "trade_env": self.trade_env, + "trade_market": self.trade_market, + "security_firm": self.security_firm, + "acc_id": self.acc_id or None, + "is_encrypt": self.is_encrypt, + "has_unlock_password": bool(self.unlock_password), + "market_category": self.market_category or None, + } + + +def config_from_exchange_config(exchange_config: Dict[str, Any]) -> FutuConfig: + """Build FutuConfig from qd_exchange_credentials / exchange_config blob.""" + cfg = exchange_config if isinstance(exchange_config, dict) else {} + env = normalize_trade_env( + cfg.get("trade_env") + or cfg.get("environment") + or cfg.get("env") + or cfg.get("network") + or ("demo" if ( + _truthy(cfg.get("paper")) + or _truthy(cfg.get("paper_trading")) + or _truthy(cfg.get("enable_demo_trading")) + or _truthy(cfg.get("simulated_trading")) + ) else ""), + default="demo", + ) + market_category = str( + cfg.get("market_category") or cfg.get("marketCategory") or "" + ).strip() + encrypt_raw = cfg.get("is_encrypt") + if encrypt_raw is None: + encrypt_raw = cfg.get("isEncrypt") + is_encrypt: Optional[bool] + if encrypt_raw is None or encrypt_raw == "": + is_encrypt = None + else: + is_encrypt = _truthy(encrypt_raw) + + return FutuConfig( + host=str(cfg.get("futu_host") or cfg.get("host") or "127.0.0.1").strip(), + port=int(cfg.get("futu_port") or cfg.get("port") or 11111), + trade_env=env, + trade_market=normalize_trade_market( + cfg.get("trade_market") or cfg.get("tradeMarket") or cfg.get("filter_trdmarket"), + market_category=market_category, + ), + security_firm=normalize_security_firm( + cfg.get("security_firm") or cfg.get("securityFirm") + ), + acc_id=int(cfg.get("acc_id") or cfg.get("accId") or 0), + unlock_password=str( + cfg.get("unlock_password") + or cfg.get("unlockPassword") + or cfg.get("trade_password") + or cfg.get("password") + or "" + ), + is_encrypt=is_encrypt, + market_category=market_category, + ) diff --git a/backend_api_python/app/services/futu_trading/mappers.py b/backend_api_python/app/services/futu_trading/mappers.py new file mode 100644 index 000000000..5510562cb --- /dev/null +++ b/backend_api_python/app/services/futu_trading/mappers.py @@ -0,0 +1,239 @@ +"""Map Futu enums / status strings onto QuantDinger canonical values.""" + +from __future__ import annotations + +from typing import Any, Dict, Optional, Tuple + + +# Futu OrderStatus → QuantDinger pending_orders / worker status +_ORDER_STATUS_MAP = { + "NONE": "submitted", + "UNSUBMITTED": "submitted", + "WAITING_SUBMIT": "submitted", + "SUBMITTING": "submitted", + "SUBMITTED": "submitted", + "FILLED_PART": "partially_filled", + "FILLED_ALL": "filled", + "CANCELLED_PART": "cancelled", + "CANCELLED_ALL": "cancelled", + "FAILED": "rejected", + "DISABLED": "rejected", + "DELETED": "cancelled", + # Lowercase / alternate spellings seen in DataFrames / str(enum) + "filled_part": "partially_filled", + "filled_all": "filled", + "cancelled_part": "cancelled", + "cancelled_all": "cancelled", + "canceled_part": "cancelled", + "canceled_all": "cancelled", + "partially_filled": "partially_filled", + "filled": "filled", + "cancelled": "cancelled", + "canceled": "cancelled", + "rejected": "rejected", + "failed": "rejected", + "submitted": "submitted", + "open": "submitted", + "new": "submitted", +} + + +def normalize_order_status(value: Any) -> str: + if value is None: + return "submitted" + if hasattr(value, "name"): + key = str(value.name) + else: + key = str(value).strip() + # "OrderStatus.SUBMITTED" → SUBMITTED + if "." in key: + key = key.split(".")[-1] + mapped = _ORDER_STATUS_MAP.get(key) or _ORDER_STATUS_MAP.get(key.upper()) or _ORDER_STATUS_MAP.get(key.lower()) + return mapped or "submitted" + + +def is_terminal_status(status: str) -> bool: + return normalize_order_status(status) in ("filled", "cancelled", "rejected") + + +def is_final_fill_status(status: str) -> bool: + return normalize_order_status(status) == "filled" + + +def side_to_futu(side: str) -> str: + s = str(side or "").strip().lower() + if s in ("buy", "long", "open_long", "add_long"): + return "BUY" + if s in ("sell", "short", "close_long", "reduce_long"): + return "SELL" + return s.upper() or "BUY" + + +def side_from_futu(side: Any) -> str: + if hasattr(side, "name"): + raw = str(side.name).upper() + else: + raw = str(side or "").strip().upper() + if "." in raw: + raw = raw.split(".")[-1] + if raw in ("BUY", "BUY_BACK"): + return "buy" + if raw in ("SELL", "SELL_SHORT"): + return "sell" + return raw.lower() + + +def order_type_to_futu(order_type: str) -> str: + ot = str(order_type or "market").strip().lower() + if ot in ("limit", "normal", "lmt"): + return "NORMAL" + if ot in ("market", "mkt"): + return "MARKET" + return "MARKET" + + +def safe_float(value: Any, default: float = 0.0) -> float: + try: + if value is None or value == "": + return default + return float(value) + except (TypeError, ValueError): + return default + + +def row_get(row: Any, *keys: str, default: Any = None) -> Any: + """Read a value from a pandas Series, dict, or object.""" + if row is None: + return default + for key in keys: + if isinstance(row, dict) and key in row: + return row.get(key) + try: + # pandas Series + if hasattr(row, "get"): + val = row.get(key) + if val is not None: + return val + except Exception: + pass + try: + if hasattr(row, "__getitem__"): + val = row[key] + if val is not None: + return val + except Exception: + pass + if hasattr(row, key): + return getattr(row, key) + return default + + +def order_row_to_raw(row: Any) -> Dict[str, Any]: + """Normalize one Futu order_list_query / place_order row into a dict.""" + if row is None: + return {} + if isinstance(row, dict): + data = dict(row) + else: + try: + data = dict(row) + except Exception: + data = {} + for key in ( + "order_id", "orderid", "code", "stock_name", "trd_side", "order_type", + "order_status", "qty", "price", "dealt_qty", "dealt_avg_price", + "currency", "remark", "create_time", "updated_time", "aux_price", + "dealt_avg_price", "last_err_msg", "acc_id", + ): + val = row_get(row, key) + if val is not None: + data[key] = val + order_id = str(row_get(data, "order_id", "orderid", default="") or "") + status = normalize_order_status(row_get(data, "order_status", "status")) + filled = safe_float(row_get(data, "dealt_qty", "filled", "filled_qty")) + avg_price = safe_float(row_get(data, "dealt_avg_price", "avg_price", "avgFillPrice")) + qty = safe_float(row_get(data, "qty", "quantity", "total_qty")) + price = safe_float(row_get(data, "price", "limit_price")) + code = str(row_get(data, "code", "symbol", default="") or "") + remark = str(row_get(data, "remark", "client_order_id", default="") or "") + commission = safe_float(row_get(data, "commission", "fee", "charge")) + commission_ccy = str(row_get(data, "currency", "commission_ccy", default="") or "") + return { + "order_id": order_id, + "orderId": order_id, + "code": code, + "symbol": code, + "status": status, + "order_status": status, + "filled": filled, + "dealt_qty": filled, + "avg_price": avg_price, + "dealt_avg_price": avg_price, + "qty": qty, + "price": price, + "side": side_from_futu(row_get(data, "trd_side", "side")), + "remark": remark, + "client_order_id": remark, + "commission": commission, + "commission_ccy": commission_ccy, + "message": str(row_get(data, "last_err_msg", "message", default="") or ""), + "raw": data, + } + + +def position_row_to_dict(row: Any) -> Dict[str, Any]: + from app.services.futu_trading.symbols import from_futu_code + + code = str(row_get(row, "code", "symbol", default="") or "") + display, market = from_futu_code(code) + qty = safe_float(row_get(row, "qty", "quantity", "can_sell_qty")) + avg = safe_float(row_get(row, "cost_price", "average_cost", "avgCost", "avg_cost")) + market_val = safe_float(row_get(row, "market_val", "market_value", "marketValue")) + pl = safe_float(row_get(row, "pl_val", "unrealized_pl", "pl")) + currency = str(row_get(row, "currency", default="") or "") + side = "long" if qty >= 0 else "short" + return { + "symbol": display or code, + "futu_code": code, + "market_category": market, + "quantity": abs(qty), + "qty": abs(qty), + "avgCost": avg, + "avg_cost": avg, + "marketValue": market_val, + "unrealized_pl": pl, + "currency": currency, + "side": side, + } + + +def account_row_to_dict(row: Any) -> Dict[str, Any]: + return { + "power": safe_float(row_get(row, "power", "buying_power")), + "total_assets": safe_float(row_get(row, "total_assets", "totalAssets")), + "cash": safe_float(row_get(row, "cash", "avl_withdrawal_cash")), + "market_val": safe_float(row_get(row, "market_val", "marketValue")), + "currency": str(row_get(row, "currency", default="") or ""), + "max_power_short": safe_float(row_get(row, "max_power_short")), + "net_cash_power": safe_float(row_get(row, "net_cash_power")), + "avl_withdrawal_cash": safe_float(row_get(row, "avl_withdrawal_cash")), + } + + +def classify_futu_error(message: Any) -> Tuple[str, str]: + """Return (error_code, human_message).""" + msg = str(message or "").strip() + low = msg.lower() + if "not connected" in low or "connect" in low and "fail" in low: + return "FUTU_OPEND_UNREACHABLE", msg or "Cannot reach FutuOpenD" + if "no right" in low or "no authority" in low or "permission" in low or "行情权限" in msg: + return "FUTU_QUOTE_PERMISSION_DENIED", msg + if "unlock" in low or "交易密码" in msg or "password" in low: + return "FUTU_TRADE_LOCKED", msg + if "lot" in low or "手数" in msg or "qty" in low and "invalid" in low: + return "FUTU_INVALID_LOT_SIZE", msg + if "quota" in low or "额度" in msg or "limit" in low and "kline" in low: + return "FUTU_QUOTE_QUOTA_EXCEEDED", msg + if "subscribe" in low and ("max" in low or "limit" in low or "超额" in msg): + return "FUTU_SUBSCRIBE_LIMIT", msg + return "FUTU_API_ERROR", msg diff --git a/backend_api_python/app/services/futu_trading/quote_client.py b/backend_api_python/app/services/futu_trading/quote_client.py new file mode 100644 index 000000000..702a4b5d5 --- /dev/null +++ b/backend_api_python/app/services/futu_trading/quote_client.py @@ -0,0 +1,177 @@ +"""Quote-only FutuOpenD client used by market-data code paths.""" + +from __future__ import annotations + +import threading +from typing import Any, Dict, List, Optional + +from app.services.futu_trading.client import _ensure_futu +from app.services.futu_trading.config import FutuConfig, validate_opend_host +from app.services.futu_trading.mappers import classify_futu_error, safe_float +from app.services.futu_trading.symbols import ( + format_display_symbol, + infer_market_category, + to_futu_code, +) +from app.services.futu_trading.timezones import futu_time_key_to_timestamp +from app.utils.logger import get_logger + +logger = get_logger(__name__) + + +class FutuQuoteClient: + """Small wrapper that opens only ``OpenQuoteContext`` (never a trade context).""" + + def __init__(self, config: Optional[FutuConfig] = None): + self.config = config or FutuConfig() + self._quote_ctx = None + self._connected = False + self._lock = threading.RLock() + + @property + def connected(self) -> bool: + return bool(self._connected and self._quote_ctx is not None) + + def connect(self) -> bool: + with self._lock: + if self.connected: + return True + try: + validate_opend_host(self.config.host) + ft = _ensure_futu() + kwargs: Dict[str, Any] = { + "host": self.config.host, + "port": int(self.config.port), + } + if self.config.is_encrypt is not None: + kwargs["is_encrypt"] = self.config.is_encrypt + self._quote_ctx = ft.OpenQuoteContext(**kwargs) + ret, data = self._quote_ctx.get_global_state() + if ret != ft.RET_OK: + raise RuntimeError(f"OpenD quote probe failed: {data}") + self._connected = True + return True + except Exception as exc: + logger.error("Futu quote connection failed: %s", exc) + self.close() + return False + + def close(self) -> None: + with self._lock: + if self._quote_ctx is not None: + try: + self._quote_ctx.close() + except Exception as exc: + logger.debug("Futu quote context close error: %s", exc) + self._quote_ctx = None + self._connected = False + + disconnect = close + + def _ensure_connected(self) -> None: + if not self.connected and not self.connect(): + raise ConnectionError("Cannot connect to FutuOpenD quote service") + + def get_quote(self, symbol: str, market_type: str = "HKStock") -> Dict[str, Any]: + with self._lock: + self._ensure_connected() + ft = _ensure_futu() + market = market_type or infer_market_category(symbol) + code = to_futu_code(symbol, market) + ret, data = self._quote_ctx.get_market_snapshot([code]) + if ret != ft.RET_OK or data is None or len(data) == 0: + error_code, message = classify_futu_error( + data if ret != ft.RET_OK else f"empty snapshot for {code}" + ) + raise RuntimeError(f"{error_code}:{message}") + row = data.iloc[0] if hasattr(data, "iloc") else data[0] + snap = dict(row) + return { + "success": True, + "symbol": format_display_symbol(code), + "futu_code": code, + "bid": safe_float(snap.get("bid_price") or snap.get("bid")), + "ask": safe_float(snap.get("ask_price") or snap.get("ask")), + "last": safe_float(snap.get("last_price") or snap.get("price") or snap.get("last")), + "high": safe_float(snap.get("high_price") or snap.get("high")), + "low": safe_float(snap.get("low_price") or snap.get("low")), + "volume": safe_float(snap.get("volume")), + "close": safe_float(snap.get("prev_close_price") or snap.get("close")), + "raw": snap, + } + + def get_history_kline( + self, + symbol: str, + *, + market_type: str = "", + ktype: str = "K_DAY", + start: Optional[str] = None, + end: Optional[str] = None, + max_count: int = 500, + autype: str = "QFQ", + ) -> List[Dict[str, Any]]: + with self._lock: + self._ensure_connected() + ft = _ensure_futu() + market = market_type or infer_market_category(symbol) + code = to_futu_code(symbol, market) + ktype_enum = getattr(ft.KLType, ktype, ft.KLType.K_DAY) + autype_enum = getattr(ft.AuType, autype, ft.AuType.QFQ) + page_req_key = None + rows: List[Dict[str, Any]] = [] + remaining = max(1, int(max_count or 500)) + while remaining > 0: + ret, data, page_req_key = self._quote_ctx.request_history_kline( + code=code, + start=start, + end=end, + ktype=ktype_enum, + autype=autype_enum, + max_count=min(1000, remaining), + page_req_key=page_req_key, + ) + if ret != ft.RET_OK: + error_code, message = classify_futu_error(data) + raise RuntimeError(f"{error_code}:{message}") + if data is None or len(data) == 0: + break + records = data.to_dict("records") if hasattr(data, "to_dict") else list(data) + for record in records: + try: + timestamp = futu_time_key_to_timestamp( + record.get("time_key") or record.get("time"), + market, + ) + except (TypeError, ValueError): + continue + rows.append({ + "time": timestamp, + "open": safe_float(record.get("open")), + "high": safe_float(record.get("high")), + "low": safe_float(record.get("low")), + "close": safe_float(record.get("close")), + "volume": safe_float(record.get("volume")), + }) + remaining -= len(records) + if not page_req_key: + break + rows.sort(key=lambda item: item["time"]) + return rows + + def subscribe_quote(self, symbols: List[str], market_type: str = "") -> bool: + with self._lock: + self._ensure_connected() + ft = _ensure_futu() + codes = [ + to_futu_code(symbol, market_type or infer_market_category(symbol)) + for symbol in symbols + if symbol + ] + if not codes: + return True + ret, error = self._quote_ctx.subscribe(codes, [ft.SubType.QUOTE]) + if ret != ft.RET_OK: + error_code, message = classify_futu_error(error) + raise RuntimeError(f"{error_code}:{message}") + return True diff --git a/backend_api_python/app/services/futu_trading/quote_feed.py b/backend_api_python/app/services/futu_trading/quote_feed.py new file mode 100644 index 000000000..41eddde82 --- /dev/null +++ b/backend_api_python/app/services/futu_trading/quote_feed.py @@ -0,0 +1,129 @@ +"""Bounded Futu quote cache for strategy risk ticks. + +Subscribes (best-effort) to OpenD QUOTE for the strategy's symbols and +refreshes a local last-price cache. Falls back to snapshot polling when +push is unavailable. +""" + +from __future__ import annotations + +import threading +import time +from typing import Any, Dict, Iterable, List, Mapping, Optional + +from app.utils.logger import get_logger + +logger = get_logger(__name__) + + +class FutuQuoteFeed: + """Per-runtime quote cache backed by FutuClient.get_quote / subscribe.""" + + def __init__( + self, + *, + exchange_config: Dict[str, Any], + instruments: Iterable[Mapping[str, Any]], + poll_interval_sec: float = 2.0, + max_symbols: int = 50, + ) -> None: + self.exchange_config = dict(exchange_config or {}) + self.instruments = [dict(item) for item in instruments][: max(1, int(max_symbols))] + self.poll_interval_sec = max(0.5, float(poll_interval_sec)) + self._prices: Dict[str, float] = {} + self._updated_at = 0.0 + self._connected = False + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + self._client = None + self._last_error = "" + + @property + def connected(self) -> bool: + return self._connected + + @property + def last_error(self) -> str: + return self._last_error + + def start(self) -> None: + if self._thread and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread(target=self._run, name="FutuQuoteFeed", daemon=True) + self._thread.start() + + def stop(self, timeout: float = 5.0) -> None: + self._stop.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=timeout) + if self._client is not None: + try: + self._client.disconnect() + except Exception: + pass + self._client = None + self._connected = False + + def snapshot(self, max_age_seconds: float = 10.0) -> Dict[str, Any]: + age_ms = int(max(0.0, (time.time() - self._updated_at) * 1000)) + stale = (time.time() - self._updated_at) > float(max_age_seconds) + return { + "prices": dict(self._prices), + "source": "futu_quote" if self._prices and not stale else "futu_stale", + "age_ms": age_ms, + "connected": self._connected, + "error": self._last_error, + } + + def _run(self) -> None: + try: + from app.services.futu_trading.config import config_from_exchange_config + from app.services.futu_trading.quote_client import FutuQuoteClient + + config = config_from_exchange_config(self.exchange_config) + config.unlock_password = "" + self._client = FutuQuoteClient(config) + if not self._client.connect(): + raise RuntimeError("FutuOpenD quote connection failed") + codes: List[str] = [] + for item in self.instruments: + symbol = str(item.get("symbol") or "") + market = str(item.get("market") or "HKStock") + if symbol: + codes.append(symbol) + try: + self._client.subscribe_quote([symbol], market) + except Exception: + pass + self._connected = True + while not self._stop.is_set(): + self._poll_once() + self._stop.wait(self.poll_interval_sec) + except Exception as exc: + self._last_error = str(exc) + self._connected = False + logger.warning("FutuQuoteFeed stopped: %s", exc) + + def _poll_once(self) -> None: + if self._client is None: + return + updated = False + for item in self.instruments: + key = str(item.get("key") or "") + symbol = str(item.get("symbol") or "") + market = str(item.get("market") or "HKStock") + if not key or not symbol: + continue + try: + quote = self._client.get_quote(symbol, market) + if isinstance(quote, dict) and quote.get("success"): + price = float(quote.get("last") or quote.get("close") or 0.0) + if price > 0: + self._prices[key] = price + updated = True + except Exception as exc: + self._last_error = str(exc) + if updated: + self._updated_at = time.time() + self._connected = True diff --git a/backend_api_python/app/services/futu_trading/symbols.py b/backend_api_python/app/services/futu_trading/symbols.py new file mode 100644 index 000000000..a8ef5c3c8 --- /dev/null +++ b/backend_api_python/app/services/futu_trading/symbols.py @@ -0,0 +1,123 @@ +"""Symbol mapping between QuantDinger and Futu OpenAPI codes.""" + +from __future__ import annotations + +from typing import Optional, Tuple + + +def _clean(symbol: str) -> str: + return str(symbol or "").strip().upper().replace(" ", "") + + +def infer_market_category(symbol: str, market_hint: str = "") -> str: + hint = str(market_hint or "").strip() + if hint in ("HKStock", "USStock"): + return hint + s = _clean(symbol) + if s.startswith("HK.") or s.endswith(".HK") or s.startswith("HK:"): + return "HKStock" + if s.startswith("US.") or s.endswith(".US") or s.startswith("US:"): + return "USStock" + # Pure digits (with optional leading zeros) → HK + code = s.split(".")[-1].split(":")[-1] + if code.isdigit(): + return "HKStock" + return "USStock" + + +def to_futu_code(symbol: str, market_category: str = "") -> str: + """ + Convert QuantDinger symbol to Futu code. + + Examples: + 00700.HK / 700.HK / 00700 -> HK.00700 + AAPL / US.AAPL / AAPL.US -> US.AAPL + """ + s = _clean(symbol) + if not s: + return "" + market = infer_market_category(s, market_category) + + if s.startswith("HK.") or s.startswith("US."): + prefix, code = s.split(".", 1) + if prefix == "HK": + return f"HK.{code.zfill(5) if code.isdigit() else code}" + return f"US.{code}" + + if ":" in s: + # NASDAQ:AAPL / HKEX:00700 + _, code = s.split(":", 1) + s = code + + if s.endswith(".HK"): + code = s[:-3] + return f"HK.{code.zfill(5) if code.isdigit() else code}" + if s.endswith(".US"): + return f"US.{s[:-3]}" + + if market == "HKStock": + code = s + if code.isdigit(): + code = code.zfill(5) + return f"HK.{code}" + return f"US.{s}" + + +def from_futu_code(code: str) -> Tuple[str, str]: + """ + Convert Futu code to QuantDinger display symbol + market category. + + Returns: + (display_symbol, market_category) + e.g. ("00700.HK", "HKStock"), ("AAPL", "USStock") + """ + s = _clean(code) + if not s: + return "", "" + if s.startswith("HK."): + raw = s[3:] + if raw.isdigit(): + raw = raw.zfill(5) + return f"{raw}.HK", "HKStock" + if s.startswith("US."): + return s[3:], "USStock" + if s.endswith(".HK"): + return from_futu_code(to_futu_code(s, "HKStock")) + return s, infer_market_category(s) + + +def parse_symbol(symbol: str, market_hint: str = "") -> Tuple[str, str]: + """Return (futu_code, market_category).""" + market = infer_market_category(symbol, market_hint) + return to_futu_code(symbol, market), market + + +def format_display_symbol(futu_code: str) -> str: + display, _ = from_futu_code(futu_code) + return display + + +def normalize_symbol(symbol: str, market_type: str = "") -> Tuple[str, str]: + """ + IBKR/Alpaca-compatible helper. + + Returns: + (futu_code, market_category) + """ + hint = "HKStock" if str(market_type or "").strip() in ("HKStock", "hk", "HK") else market_type + if str(market_type or "").strip() in ("USStock", "us", "US", "spot"): + # spot alone is ambiguous; prefer inference from symbol + if str(market_type or "").strip() == "spot": + hint = "" + else: + hint = "USStock" + return parse_symbol(symbol, hint) + + +def lot_size_hint(market_category: str) -> Optional[int]: + """Conservative default lot size when static info is unavailable.""" + if market_category == "HKStock": + return 100 + if market_category == "USStock": + return 1 + return None diff --git a/backend_api_python/app/services/futu_trading/timezones.py b/backend_api_python/app/services/futu_trading/timezones.py new file mode 100644 index 000000000..bbdffdd99 --- /dev/null +++ b/backend_api_python/app/services/futu_trading/timezones.py @@ -0,0 +1,26 @@ +"""Exchange-time conversion helpers for Futu market data.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from zoneinfo import ZoneInfo + +from app.services.futu_trading.mappers import safe_float + +_MARKET_TIMEZONES = { + "HKStock": ZoneInfo("Asia/Hong_Kong"), + "USStock": ZoneInfo("America/New_York"), +} + + +def market_timezone(market: str) -> ZoneInfo: + return _MARKET_TIMEZONES.get(str(market or ""), ZoneInfo("UTC")) + + +def futu_time_key_to_timestamp(value: Any, market: str) -> int: + """Interpret Futu's naive ``time_key`` in the exchange's local timezone.""" + if not isinstance(value, str): + return int(safe_float(value)) + local_dt = datetime.strptime(value[:19], "%Y-%m-%d %H:%M:%S") + return int(local_dt.replace(tzinfo=market_timezone(market)).timestamp()) diff --git a/backend_api_python/app/services/live_trading/factory.py b/backend_api_python/app/services/live_trading/factory.py index 969a7c1aa..b35dfff0c 100644 --- a/backend_api_python/app/services/live_trading/factory.py +++ b/backend_api_python/app/services/live_trading/factory.py @@ -3,7 +3,7 @@ Supports: - Crypto exchanges: Binance, OKX, Bitget, Bybit, Gate, HTX -- Traditional brokers: Interactive Brokers (IBKR) and Alpaca +- Traditional brokers: Interactive Brokers (IBKR), Alpaca, and Futu """ from __future__ import annotations @@ -32,6 +32,10 @@ AlpacaClient = None AlpacaConfig = None +# Lazy import Futu to avoid ImportError if futu-api not installed +FutuClient = None +FutuConfig = None + def _get(cfg: Dict[str, Any], *keys: str) -> str: for k in keys: @@ -142,6 +146,7 @@ def validate_exchange_environment(exchange_id: str, environment: str, market_sco "bybit": {"live", "demo"}, "gate": {"live", "testnet"}, "htx": {"live"}, + "futu": {"live", "demo"}, } if env not in allowed.get(ex, {"live"}): if ex == "htx" and env != "live": @@ -220,6 +225,9 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap") mt = (market_type or exchange_config.get("market_type") or exchange_config.get("defaultType") or "swap").strip().lower() if mt in ("futures", "future", "perp", "perpetual"): mt = "swap" + # Futu is equities spot-only; callers often omit market_type (defaults to swap). + if exchange_id == "futu": + mt = "spot" environment = exchange_trading_environment(exchange_config, exchange_id) if environment not in ("live", "demo", "testnet"): @@ -327,6 +335,10 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap") if exchange_id == "alpaca": return create_alpaca_client(exchange_config) + # Futu: local OpenD gateway for HKStock / USStock spot. + if exchange_id == "futu": + return create_futu_client(exchange_config) + raise LiveTradingError(f"Unsupported exchange_id: {exchange_id}") @@ -452,6 +464,60 @@ def create_alpaca_client(exchange_config: Dict[str, Any]): return client +def create_futu_client(exchange_config: Dict[str, Any]): + """ + Create Futu client for HK / US stock trading via FutuOpenD. + + exchange_config should contain: + - futu_host / host: OpenD host (default 127.0.0.1) + - futu_port / port: OpenD port (default 11111) + - trade_env / environment: demo | live + - trade_market: HK | US + - security_firm: FUTUSECURITIES | FUTUINC | FUTUSG | ... + - acc_id: optional account id + - unlock_password: optional (prefer GUI unlock for live) + """ + global FutuClient, FutuConfig + + if FutuClient is None or FutuConfig is None: + try: + from app.services.futu_trading import FutuClient as _FutuClient + from app.services.futu_trading.config import ( + FutuConfig as _FutuConfig, + config_from_exchange_config, + ) + FutuClient = _FutuClient + FutuConfig = _FutuConfig + except ImportError: + raise LiveTradingError("Futu trading requires futu-api. Run: pip install futu-api") + + from app.services.futu_trading.config import ( + config_from_exchange_config, + validate_opend_host, + ) + from app.utils.local_brokers import desktop_broker_cloud_reject_message, local_desktop_brokers_allowed + + if not local_desktop_brokers_allowed(): + raise LiveTradingError(desktop_broker_cloud_reject_message("futu")) + + host = str(exchange_config.get("futu_host") or exchange_config.get("host") or "127.0.0.1").strip() + # Basic SSRF guard: reject obviously non-local targets in production-ish setups + # unless explicitly allow-listed via env. + try: + validate_opend_host(host) + except ValueError as exc: + raise LiveTradingError(str(exc)) from exc + + config = config_from_exchange_config(exchange_config) + client = FutuClient(config) + if not client.connect(): + raise LiveTradingError( + "Failed to connect to FutuOpenD. Ensure OpenD is running and reachable " + f"at {config.host}:{config.port}." + ) + return client + + def query_fee_rate( exchange_config: Dict[str, Any], symbol: str, diff --git a/backend_api_python/app/services/pending_order_position_sync.py b/backend_api_python/app/services/pending_order_position_sync.py index 881df8fa7..1fa5f68cb 100644 --- a/backend_api_python/app/services/pending_order_position_sync.py +++ b/backend_api_python/app/services/pending_order_position_sync.py @@ -46,6 +46,7 @@ IBKRClient = None AlpacaClient = None +FutuClient = None _POSITION_SYNC_FD_BACKOFF_UNTIL = 0.0 @@ -202,6 +203,14 @@ def _sync_positions_best_effort(self, target_strategy_id: Optional[int] = None) except ImportError: pass + global FutuClient + if FutuClient is None: + try: + from app.services.futu_trading import FutuClient as _FutuClient + FutuClient = _FutuClient + except ImportError: + pass + cache_key = position_sync_cache_key(sync_user_id, exchange_id, market_type, exchange_config) cached_snap = get_position_sync_snapshot(cache_key) exch_size: Dict[str, Dict[str, float]] = {} @@ -533,6 +542,50 @@ def _sync_positions_best_effort(self, target_strategy_id: Optional[int] = None) exch_entry_price.setdefault(sym, {"long": 0.0, "short": 0.0})[side_str] = avg # Continue to reconciliation logic below + elif FutuClient is not None and isinstance(client, FutuClient): + try: + positions = client.get_positions() or [] + except Exception as e: + msg = str(e) + if is_file_descriptor_exhausted(e): + set_exchange_sync_backoff(cache_key, seconds=_position_sync_fd_backoff_sec()) + _activate_position_sync_fd_backoff(msg) + return + if is_fatal_exchange_error(msg): + logger.error( + "[PositionSync] Strategy %s Futu fatal error; auto-stopping. error=%s", + sid, + msg, + ) + auto_stop_live_strategy(int(sid), msg, source="position_sync_futu") + else: + logger.error( + f"[PositionSync] Strategy {sid} Futu get_positions failed: {e}", + exc_info=True, + ) + continue + if isinstance(positions, list): + for p in positions: + if not isinstance(p, dict): + continue + sym = str(p.get("symbol") or p.get("futu_code") or "").strip() + try: + qty = float(p.get("quantity") or p.get("qty") or 0.0) + except Exception: + qty = 0.0 + try: + avg = float(p.get("avgCost") or p.get("avg_cost") or 0.0) + except Exception: + avg = 0.0 + if not sym or abs(qty) <= 0: + continue + side_str = str(p.get("side") or "").strip().lower() + if side_str not in ("long", "short"): + side_str = "long" if qty > 0 else "short" + exch_size.setdefault(sym, {"long": 0.0, "short": 0.0})[side_str] = abs(qty) + if avg > 0: + exch_entry_price.setdefault(sym, {"long": 0.0, "short": 0.0})[side_str] = avg + elif market_type == "spot": from app.services.live_trading.spot_wallet_snapshot import list_spot_wallet_positions diff --git a/backend_api_python/app/services/pending_order_worker.py b/backend_api_python/app/services/pending_order_worker.py index e5587a20c..8a5972fd1 100644 --- a/backend_api_python/app/services/pending_order_worker.py +++ b/backend_api_python/app/services/pending_order_worker.py @@ -129,9 +129,13 @@ # Lazy import Alpaca to avoid ImportError if alpaca-py not installed AlpacaClient = None +# Lazy import Futu to avoid ImportError if futu-api not installed +FutuClient = None + logger = get_logger(__name__) ALPACA_FILL_DELTA_EPSILON = 1e-8 +FUTU_FILL_DELTA_EPSILON = 1e-8 class PendingOrderWorker(PendingOrderPositionSyncMixin): @@ -207,6 +211,7 @@ def _tick(self) -> None: # logger.info(f"[PendingOrderWorker] _tick start. last_sync={self._last_position_sync_ts}") self._sync_quick_trade_orders() self._sync_alpaca_sent_orders() + self._sync_futu_sent_orders() self._sync_live_sent_orders() orders = self._fetch_pending_orders(limit=self.batch_size) # logger.info(f"[PendingOrderWorker] orders fetched: {len(orders)}") @@ -697,6 +702,359 @@ def _update_alpaca_sent_order_snapshot( db.commit() cur.close() + def _sync_futu_sent_orders(self, limit: int = 50) -> None: + rows = self._fetch_futu_sent_orders(limit=limit) + for row in rows: + try: + self._sync_one_futu_sent_order(row) + except Exception as e: + self._release_futu_sync_claim( + int(row.get("id") or 0), + f"unexpected_error:{type(e).__name__}", + ) + logger.warning( + "Futu fill sync failed: pending_id=%s err=%s", + row.get("id"), + e, + ) + + def _fetch_futu_sent_orders(self, limit: int = 50) -> List[Dict[str, Any]]: + try: + try: + stale_sec = int(self._stale_processing_sec or 0) + except Exception: + stale_sec = 0 + if stale_sec > 0: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + UPDATE pending_orders + SET status = 'sent', + dispatch_note = 'futu_fill_sync:requeued_stale_sync', + updated_at = NOW() + WHERE status = 'syncing' + AND LOWER(COALESCE(exchange_id, '')) = 'futu' + AND updated_at < NOW() - (%s * INTERVAL '1 second') + """, + (stale_sec,), + ) + db.commit() + cur.close() + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT * + FROM pending_orders + WHERE status = 'sent' + AND LOWER(COALESCE(exchange_id, '')) = 'futu' + AND COALESCE(exchange_order_id, '') <> '' + ORDER BY sent_at ASC NULLS FIRST, id ASC + LIMIT %s + """, + (int(limit),), + ) + rows = cur.fetchall() or [] + cur.close() + return rows + except Exception as e: + logger.warning("fetch_futu_sent_orders failed: %s", e) + return [] + + def _claim_futu_sent_order(self, order_id: int) -> Optional[Dict[str, Any]]: + if int(order_id or 0) <= 0: + return None + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + UPDATE pending_orders + SET status = 'syncing', + dispatch_note = 'futu_fill_sync:syncing', + updated_at = NOW() + WHERE id = %s + AND status = 'sent' + AND LOWER(COALESCE(exchange_id, '')) = 'futu' + AND COALESCE(exchange_order_id, '') <> '' + RETURNING * + """, + (int(order_id),), + ) + row = cur.fetchone() + db.commit() + cur.close() + return row if isinstance(row, dict) else None + except Exception as e: + logger.warning("claim_futu_sent_order failed: pending_id=%s err=%s", order_id, e) + return None + + def _release_futu_sync_claim(self, order_id: int, reason: str) -> None: + """Return a non-finalized Futu sync claim to the retryable sent state.""" + if int(order_id or 0) <= 0: + return + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + UPDATE pending_orders + SET status = 'sent', + dispatch_note = %s, + updated_at = NOW() + WHERE id = %s + AND status = 'syncing' + AND LOWER(COALESCE(exchange_id, '')) = 'futu' + """, + ( + f"futu_fill_sync:retry:{str(reason or 'unknown')[:160]}", + int(order_id), + ), + ) + db.commit() + cur.close() + except Exception as e: + logger.warning( + "release_futu_sync_claim failed: pending_id=%s err=%s", + order_id, + e, + ) + + def _sync_one_futu_sent_order(self, row: Dict[str, Any]) -> None: + order_id = int(row.get("id") or 0) + if order_id <= 0: + return + claimed = self._claim_futu_sent_order(order_id) + if not claimed: + return + row = claimed + finalized = False + try: + finalized = self._sync_claimed_futu_order(row) + finally: + if not finalized: + self._release_futu_sync_claim(order_id, "not_finalized") + + def _sync_claimed_futu_order(self, row: Dict[str, Any]) -> bool: + """Sync one already-claimed row; return True once its DB state is finalized.""" + order_id = int(row.get("id") or 0) + exchange_order_id = str(row.get("exchange_order_id") or "").strip() + if not exchange_order_id: + return False + + payload = {} + payload_json = row.get("payload_json") or "" + if isinstance(payload_json, str) and payload_json.strip(): + try: + payload = json.loads(payload_json) or {} + except Exception: + payload = {} + + strategy_id = int(payload.get("strategy_id") or row.get("strategy_id") or 0) + if strategy_id <= 0: + return False + + sc = load_strategy_configs(strategy_id) + exchange_config = resolve_exchange_config(sc.get("exchange_config") or {}, user_id=int(sc.get("user_id") or 1)) + if str(exchange_config.get("exchange_id") or "").strip().lower() != "futu": + return False + + client = None + try: + client = create_client(exchange_config, market_type="spot") + except Exception as e: + logger.warning("Futu fill sync create_client failed: pending_id=%s err=%s", order_id, e) + return False + + try: + global FutuClient + if FutuClient is None: + try: + from app.services.futu_trading import FutuClient as _FutuClient + FutuClient = _FutuClient + except Exception: + FutuClient = None + if FutuClient is None or not isinstance(client, FutuClient): + return False + + result = client.get_order_status(exchange_order_id) + if not result.success: + logger.warning( + "Futu order status unavailable: pending_id=%s order_id=%s err=%s", + order_id, + exchange_order_id, + result.message, + ) + return False + + status = str(result.status or "").strip().lower() + cumulative_filled = float(result.filled or 0.0) + cumulative_avg = float(result.avg_price or 0.0) + previous_filled = float(row.get("filled") or 0.0) + previous_avg = float(row.get("avg_price") or 0.0) + if cumulative_filled + FUTU_FILL_DELTA_EPSILON < previous_filled: + logger.warning( + "Ignoring regressive Futu fill snapshot: pending_id=%s previous=%s current=%s", + order_id, + previous_filled, + cumulative_filled, + ) + cumulative_filled = previous_filled + cumulative_avg = previous_avg + + raw_json = json.dumps(result.raw or {}, ensure_ascii=False) + cumulative_commission, commission_ccy = _commission_snapshot(result.raw) + commission_delta = max(0.0, cumulative_commission - _previous_commission(row)) + + # Derive the recordable delta from the durable trade ledger, not + # only pending_orders.filled. If trade persistence succeeded but + # the pending-order snapshot failed, a retry must not book it twice. + delta = self._unrecorded_pending_fill( + order_id, + cumulative_filled, + fail_closed=True, + ) + if delta > FUTU_FILL_DELTA_EPSILON and cumulative_avg > 0: + delta_avg = cumulative_avg + if previous_filled > 0 and previous_avg > 0: + delta_notional = cumulative_filled * cumulative_avg - previous_filled * previous_avg + if delta_notional > 0: + delta_avg = delta_notional / delta + + signal_type = payload.get("signal_type") or row.get("signal_type") + symbol = payload.get("symbol") or row.get("symbol") + market_category = str( + sc.get("market_category") + or (sc.get("trading_config") or {}).get("market_category") + or "HKStock" + ) + market_type_for_client = "HKStock" if market_category == "HKStock" else "USStock" + from app.services.live_trading.fee_quote import fee_to_quote + commission_quote = fee_to_quote( + client, + symbol=str(symbol or ""), + fee=commission_delta, + fee_ccy=commission_ccy, + fill_price=delta_avg, + ) + profit, _matched_entry = persist_strategy_fill( + strategy_id=strategy_id, + symbol=str(symbol or ""), + signal_type=str(signal_type or ""), + filled=float(delta), + avg_price=float(delta_avg), + exchange_config=exchange_config, + market_type=market_type_for_client, + order_id=order_id, + fill_source="worker_futu_fill_sync", + commission=commission_delta, + commission_ccy=commission_ccy, + commission_quote=commission_quote, + close_reason=trade_close_reason_from_payload(payload, str(signal_type or "")), + strategy_run_id=int(payload.get("strategy_run_id") or row.get("strategy_run_id") or 0), + order_intent_id=int(payload.get("order_intent_id") or row.get("order_intent_id") or 0), + exchange_id="futu", + exchange_order_id=str(exchange_order_id or ""), + raw_fill=result.raw or {}, + ) + _pstr = f", profit={profit:.4f}" if profit is not None else "" + append_strategy_log( + strategy_id, + "trade", + f"Futu fill synced: {signal_type} {symbol} filled={delta:.6f} @ {delta_avg:.6f}{_pstr}", + ) + + final_statuses = {"filled", "canceled", "cancelled", "rejected", "expired"} + new_status = "sent" + if status == "filled": + new_status = "filled" + elif status in ("canceled", "cancelled"): + new_status = "cancelled" + elif status in ("rejected", "expired"): + new_status = "failed" + + self._update_futu_sent_order_snapshot( + order_id=order_id, + status=new_status, + exchange_status=status, + filled=cumulative_filled, + avg_price=cumulative_avg, + exchange_response_json=raw_json, + final=status in final_statuses, + ) + return True + finally: + try: + client.disconnect() + except Exception: + pass + + def _update_futu_sent_order_snapshot( + self, + *, + order_id: int, + status: str, + exchange_status: str, + filled: float, + avg_price: float, + exchange_response_json: str, + final: bool, + ) -> None: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + UPDATE pending_orders + SET status = %s, + last_error = CASE WHEN %s = 'failed' THEN %s ELSE '' END, + dispatch_note = %s, + filled = %s, + avg_price = %s, + exchange_response_json = %s, + executed_at = CASE WHEN %s THEN NOW() ELSE executed_at END, + updated_at = NOW() + WHERE id = %s + """, + ( + str(status or "sent"), + str(status or "sent"), + str(exchange_status or ""), + f"futu_fill_sync:{exchange_status or 'unknown'}", + float(filled or 0.0), + float(avg_price or 0.0), + str(exchange_response_json or ""), + bool(final and float(filled or 0.0) > 0), + int(order_id), + ), + ) + cur.execute( + """ + UPDATE strategy_order_intents soi + SET status = CASE + WHEN %s = 'filled' THEN 'filled' + WHEN %s = 'failed' THEN 'rejected' + WHEN %s = 'cancelled' THEN 'cancelled' + WHEN %s > 0 THEN 'partially_filled' + ELSE 'submitted' + END, + exchange_order_id = COALESCE(NULLIF(po.exchange_order_id, ''), soi.exchange_order_id), + updated_at = NOW() + FROM pending_orders po + WHERE po.id = %s + AND po.order_intent_id = soi.id + """, + ( + str(status or "sent"), + str(status or "sent"), + str(status or "sent"), + float(filled or 0.0), + int(order_id), + ), + ) + db.commit() + cur.close() + def _sync_live_sent_orders(self, limit: int = 50) -> None: """Reconcile submitted crypto orders, including durable resting limits.""" rows = self._fetch_live_sent_orders(limit=limit) @@ -1691,6 +2049,28 @@ def _execute_live_order(self, *, order_id: int, order_row: Dict[str, Any], paylo ) return + global FutuClient + if FutuClient is None: + try: + from app.services.futu_trading import FutuClient as _FutuClient + FutuClient = _FutuClient + except ImportError: + pass + + if FutuClient is not None and isinstance(client, FutuClient): + self._execute_futu_order( + order_id=order_id, + order_row=order_row, + payload=payload, + client=client, + strategy_id=strategy_id, + exchange_config=exchange_config, + market_category=market_category, + _notify_live_best_effort=_notify_live_best_effort, + _console_print=_console_print, + ) + return + client_oid = make_client_order_id(exchange_id=exchange_id, strategy_id=strategy_id, order_id=order_id) self._register_pending_order_binding( order_id=order_id, @@ -2737,6 +3117,201 @@ def _execute_alpaca_order( _notify_live_best_effort(status="failed", error=str(e)) append_strategy_log(strategy_id, "error", f"Alpaca order exception ({symbol} {signal_type}): {e}") + def _execute_futu_order( + self, + *, + order_id: int, + order_row: Dict[str, Any], + payload: Dict[str, Any], + client, # FutuClient instance + strategy_id: int, + exchange_config: Dict[str, Any], + market_category: str, + _notify_live_best_effort, + _console_print, + ) -> None: + """Execute order via FutuOpenD for HKStock / USStock (long-only).""" + signal_type = payload.get("signal_type") or order_row.get("signal_type") + symbol = payload.get("symbol") or order_row.get("symbol") + amount = float(payload.get("amount") or order_row.get("amount") or 0.0) + ref_price = float(payload.get("ref_price") or payload.get("price") or order_row.get("price") or 0.0) + + sig = str(signal_type or "").strip().lower() + if "short" in sig: + self._mark_failed(order_id=order_id, error="futu_short_not_supported") + _console_print( + f"[worker] Futu order rejected: strategy_id={strategy_id} pending_id={order_id} short not supported" + ) + _notify_live_best_effort(status="failed", error="futu_short_not_supported") + return + + if sig in ("open_long", "add_long"): + action = "buy" + elif sig in ("close_long", "reduce_long", "close_long_stop", "close_long_profit", "close_long_trailing"): + action = "sell" + else: + self._mark_failed(order_id=order_id, error=f"futu_unsupported_signal:{signal_type}") + _console_print( + f"[worker] Futu order rejected: strategy_id={strategy_id} pending_id={order_id} unsupported signal {signal_type}" + ) + _notify_live_best_effort(status="failed", error=f"futu_unsupported_signal:{signal_type}") + return + + mc = (market_category or "").strip() + if not mc: + mc = str( + payload.get("market_category") + or exchange_config.get("market_category") + or "HKStock" + ).strip() + market_type_for_client = "HKStock" if mc == "HKStock" else "USStock" + client_remark = make_client_order_id(exchange_id="futu", strategy_id=strategy_id, order_id=order_id) + + try: + order_type, limit_price = _broker_order_type(payload, ref_price) + # Idempotency: if a prior attempt already placed this remark, reuse it. + existing = None + find_fn = getattr(client, "find_order_by_remark", None) + if callable(find_fn): + existing = find_fn(client_remark) + if existing and existing.success and existing.order_id: + result = existing + elif order_type == "limit": + result = client.place_limit_order( + symbol=symbol, + side=action, + quantity=amount, + price=limit_price, + market_type=market_type_for_client, + remark=client_remark, + ) + else: + result = client.place_market_order( + symbol=symbol, + side=action, + quantity=amount, + market_type=market_type_for_client, + remark=client_remark, + ) + + if not result.success: + # Timeout / ambiguous failure: query by remark before failing hard. + if callable(find_fn) and ("timeout" in str(result.message or "").lower() or "connect" in str(result.message or "").lower()): + recovered = find_fn(client_remark) + if recovered and recovered.success and recovered.order_id: + result = recovered + else: + self._mark_failed(order_id=order_id, error=f"futu_order_failed:{result.message}") + _console_print( + f"[worker] Futu order failed: strategy_id={strategy_id} pending_id={order_id} err={result.message}" + ) + _notify_live_best_effort(status="failed", error=f"futu_order_failed:{result.message}") + append_strategy_log( + strategy_id, "error", + f"Futu order failed ({symbol} {signal_type}): {result.message}", + ) + return + else: + self._mark_failed(order_id=order_id, error=f"futu_order_failed:{result.message}") + _console_print( + f"[worker] Futu order failed: strategy_id={strategy_id} pending_id={order_id} err={result.message}" + ) + _notify_live_best_effort(status="failed", error=f"futu_order_failed:{result.message}") + append_strategy_log( + strategy_id, "error", + f"Futu order failed ({symbol} {signal_type}): {result.message}", + ) + return + + filled = float(result.filled or 0.0) + avg_price = float(result.avg_price or 0.0) + exchange_order_id = str(result.order_id or "") + commission, commission_ccy = _commission_snapshot(result.raw) + from app.services.live_trading.fee_quote import fee_to_quote + commission_quote = fee_to_quote( + client, + symbol=str(symbol), + fee=commission, + fee_ccy=commission_ccy, + fill_price=avg_price, + ) + + if avg_price <= 0 and ref_price > 0 and filled > 0: + avg_price = ref_price + + executed_at = int(time.time()) + self._mark_sent( + order_id=order_id, + note="futu_order_sent", + exchange_id="futu", + exchange_order_id=exchange_order_id, + exchange_response_json=json.dumps(result.raw or {}, ensure_ascii=False), + filled=filled, + avg_price=avg_price, + executed_at=executed_at if filled > 0 else None, + final_filled=is_final_fill(amount, filled, avg_price, result.status), + client_order_id=client_remark, + ) + _console_print( + f"[worker] Futu order sent: strategy_id={strategy_id} pending_id={order_id} " + f"order_id={exchange_order_id} filled={filled} avg={avg_price}" + ) + + try: + recordable_filled = self._unrecorded_pending_fill(order_id, filled) + if recordable_filled > 0 and avg_price > 0: + profit, matched_entry = persist_strategy_fill( + strategy_id=int(strategy_id), + symbol=str(symbol), + signal_type=str(signal_type), + filled=float(recordable_filled), + avg_price=float(avg_price), + exchange_config=exchange_config, + market_type=str(market_type_for_client or "HKStock"), + order_id=int(order_id), + fill_source="worker_futu", + commission=commission, + commission_ccy=commission_ccy, + commission_quote=commission_quote, + close_reason=trade_close_reason_from_payload(payload, str(signal_type)), + strategy_run_id=int(payload.get("strategy_run_id") or order_row.get("strategy_run_id") or 0), + order_intent_id=int(payload.get("order_intent_id") or order_row.get("order_intent_id") or 0), + exchange_id="futu", + exchange_order_id=str(exchange_order_id or ""), + fee_status="actual" if abs(float(commission or 0.0)) > 1e-18 else "pending", + fee_source="rest" if abs(float(commission or 0.0)) > 1e-18 else "", + raw_fill=result.raw or {}, + ) + _pstr = f", profit={profit:.4f}" if profit is not None else "" + append_strategy_log( + strategy_id, "trade", + f"Trade executed: {signal_type} {symbol} filled={filled:.6f} @ {avg_price:.6f}{_pstr} (exchange=futu)", + ) + else: + append_strategy_log( + strategy_id, "info", + f"Futu order submitted: {signal_type} {symbol} status={result.status or 'submitted'}, awaiting fill", + ) + except Exception as e: + logger.warning(f"Futu record_trade/update_position failed: pending_id={order_id}, err={e}") + + _notify_live_best_effort( + status="sent", + exchange_id="futu", + exchange_order_id=exchange_order_id, + price_hint=avg_price, + amount_hint=filled, + ) + + except Exception as e: + logger.error(f"Futu order execution failed: pending_id={order_id}, strategy_id={strategy_id}, err={e}") + self._mark_failed(order_id=order_id, error=f"futu_exception:{e}") + _console_print(f"[worker] Futu order exception: strategy_id={strategy_id} pending_id={order_id} err={e}") + _notify_live_best_effort(status="failed", error=str(e)) + append_strategy_log(strategy_id, "error", f"Futu order exception ({symbol} {signal_type}): {e}") + if is_fatal_exchange_error(str(e)): + auto_stop_live_strategy(int(strategy_id), str(e), source="futu_order") + def _mark_sent( self, order_id: int, @@ -2816,7 +3391,12 @@ def _mark_sent( ) @staticmethod - def _unrecorded_pending_fill(order_id: int, cumulative_filled: float) -> float: + def _unrecorded_pending_fill( + order_id: int, + cumulative_filled: float, + *, + fail_closed: bool = False, + ) -> float: """Prevent the immediate REST result racing the private stream event.""" if int(order_id or 0) <= 0: return max(0.0, float(cumulative_filled or 0.0)) @@ -2835,6 +3415,8 @@ def _unrecorded_pending_fill(order_id: int, cumulative_filled: float) -> float: cur.close() return max(0.0, float(cumulative_filled or 0.0) - float(row.get("recorded") or 0.0)) except Exception: + if fail_closed: + raise return max(0.0, float(cumulative_filled or 0.0)) def _register_pending_order_binding( diff --git a/backend_api_python/app/services/strategy_v2/deployment.py b/backend_api_python/app/services/strategy_v2/deployment.py index 8ae620986..491ff1c76 100644 --- a/backend_api_python/app/services/strategy_v2/deployment.py +++ b/backend_api_python/app/services/strategy_v2/deployment.py @@ -255,9 +255,11 @@ def _validate_execution_account(markets: tuple[str, ...], exchange_id: str, exec market = next(iter(market_set), "") if market == "Crypto" and exchange_id not in {"binance", "bitget", "bybit", "okx", "gate", "htx"}: raise StrategyV2ContractError("strategyV2.cryptoCredentialRequired") - if market == "USStock" and exchange_id not in {"alpaca", "ibkr"}: + if market == "USStock" and exchange_id not in {"alpaca", "ibkr", "futu"}: raise StrategyV2ContractError("strategyV2.stockCredentialRequired") - if market not in {"Crypto", "USStock"}: + if market == "HKStock" and exchange_id not in {"futu"}: + raise StrategyV2ContractError("strategyV2.hkStockCredentialRequired") + if market not in {"Crypto", "USStock", "HKStock"}: raise StrategyV2ContractError("strategyV2.liveMarketUnsupported") @staticmethod diff --git a/backend_api_python/app/services/strategy_v2/market_data.py b/backend_api_python/app/services/strategy_v2/market_data.py index 171afc8da..54a01eefd 100644 --- a/backend_api_python/app/services/strategy_v2/market_data.py +++ b/backend_api_python/app/services/strategy_v2/market_data.py @@ -4,7 +4,7 @@ import math from datetime import datetime, timedelta, timezone -from typing import Optional +from typing import Any, Dict, Optional import pandas as pd @@ -51,6 +51,8 @@ def load_strategy_frame( *, market_type: Optional[str] = None, exchange_id: Optional[str] = None, + exchange_config: Optional[Dict[str, Any]] = None, + strict_data_source: bool = False, ) -> pd.DataFrame: start_utc = _normalize_utc_datetime(start_date) end_utc = _normalize_utc_datetime(end_date) @@ -61,12 +63,22 @@ def load_strategy_frame( limit = int(math.ceil(total_seconds / timeframe_seconds * 1.15) + 200) after_time = int((start_utc - timedelta(seconds=timeframe_seconds)).timestamp()) before_time = int((end_utc + timedelta(seconds=timeframe_seconds)).timestamp()) + safe_config_scope = "" + if isinstance(exchange_config, dict) and str(exchange_id or "").lower() == "futu": + safe_config_scope = ":".join(( + str(exchange_config.get("futu_host") or exchange_config.get("host") or ""), + str(exchange_config.get("futu_port") or exchange_config.get("port") or ""), + str(exchange_config.get("trade_market") or ""), + str(exchange_config.get("security_firm") or ""), + )) cache_key = ":".join(( str(market), str(symbol), str(timeframe), str(market_type or ""), str(exchange_id or ""), + safe_config_scope, + "strict" if strict_data_source else "fallback", start_utc.isoformat(), end_utc.isoformat(), )) @@ -83,6 +95,9 @@ def load_strategy_frame( after_time=after_time, exchange_id=exchange_id, market_type=market_type, + exchange_config=exchange_config, + allow_futu_fallback=not strict_data_source, + strict_data_source=strict_data_source, ) except Exception as exc: logger.warning( @@ -94,6 +109,10 @@ def load_strategy_frame( market_type or "default", exc, ) + if strict_data_source: + raise RuntimeError( + f"strategyV2.executionMarketDataUnavailable:{market}:{symbol}:{exc}" + ) from exc return pd.DataFrame() if not rows: return pd.DataFrame() diff --git a/backend_api_python/app/services/strategy_v2/service.py b/backend_api_python/app/services/strategy_v2/service.py index a3431955d..683b16007 100644 --- a/backend_api_python/app/services/strategy_v2/service.py +++ b/backend_api_python/app/services/strategy_v2/service.py @@ -6,7 +6,7 @@ import os from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timedelta -from typing import Any, Callable +from typing import Any, Callable, Optional import pandas as pd @@ -333,19 +333,29 @@ def fetch_frames( frequency: str, start_date: datetime, end_date: datetime, + *, + exchange_config: Optional[dict[str, Any]] = None, + strict_data_source: bool = False, ) -> tuple[dict[str, pd.DataFrame], list[dict[str, str]]]: frames: dict[str, pd.DataFrame] = {} skipped: list[dict[str, str]] = [] def fetch(member: dict[str, Any]): + kwargs: dict[str, Any] = { + "market_type": member.get("market_type") or "", + "exchange_id": member.get("exchange_id") or "", + } + if exchange_config is not None: + kwargs["exchange_config"] = exchange_config + if strict_data_source: + kwargs["strict_data_source"] = True frame = self.frame_fetcher( member["market"], member["symbol"], frequency, start_date, end_date, - market_type=member.get("market_type") or "", - exchange_id=member.get("exchange_id") or "", + **kwargs, ) return member, frame @@ -360,6 +370,10 @@ def fetch(member: dict[str, Any]): continue frames[member["key"]] = frame except Exception as exc: + if strict_data_source: + raise RuntimeError( + f"strategyV2.executionMarketDataUnavailable:{exc}" + ) from exc skipped.append({"symbol": "", "reason": str(exc)[:240]}) return dict(sorted(frames.items())), skipped diff --git a/backend_api_python/app/services/trading_executor.py b/backend_api_python/app/services/trading_executor.py index daee3876e..8af061402 100644 --- a/backend_api_python/app/services/trading_executor.py +++ b/backend_api_python/app/services/trading_executor.py @@ -359,9 +359,16 @@ def _run_strategy_loop(self, strategy_id: int) -> None: ).strip().lower() if execution_mode == "live" and account_exchange: for member in candidates: - if member.get("market") == "Crypto": + member_market = str(member.get("market") or "") + if member_market == "Crypto" or ( + account_exchange == "futu" + and member_market in {"HKStock", "USStock"} + ): member["exchange_id"] = account_exchange member["key"] = _member_key(member) + strict_futu_market_data = ( + execution_mode == "live" and account_exchange == "futu" + ) frequency = program.manifest.primary_frequency history_days = live_history_days( @@ -376,6 +383,8 @@ def fetch_frames() -> dict[str, pd.DataFrame]: frequency, end - timedelta(days=history_days), end, + exchange_config=exchange_config if strict_futu_market_data else None, + strict_data_source=strict_futu_market_data, ) if skipped: details = ", ".join( @@ -484,29 +493,46 @@ def runtime_prices() -> dict[str, float]: "connected": False, } if execution_mode == "live" and account_exchange: - from app.services.market_price_stream import PublicMarketPriceFeed - - market_price_feed = PublicMarketPriceFeed( - exchange_id=account_exchange, - market_type=str(primary.get("market_type") or "spot"), - instruments=candidates, - rest_fallback=runtime_prices, - ) - market_price_feed.start() rest_runtime_prices = runtime_prices + stale_after = float(trading_config.get("price_stale_after_seconds") or 10.0) + if str(account_exchange or "").strip().lower() == "futu": + from app.services.futu_trading.quote_feed import FutuQuoteFeed + + market_price_feed = FutuQuoteFeed( + exchange_config=exchange_config if isinstance(exchange_config, dict) else {}, + instruments=candidates, + poll_interval_sec=float(trading_config.get("futu_quote_poll_sec") or 2.0), + ) + market_price_feed.start() + + def runtime_prices() -> dict[str, float]: + snapshot = market_price_feed.snapshot(max_age_seconds=stale_after) + price_feed_meta.update({ + "source": snapshot.get("source") or "futu_quote", + "age_ms": int(snapshot.get("age_ms") or 0), + "connected": bool(snapshot.get("connected")), + }) + prices = dict(snapshot.get("prices") or {}) + return prices or rest_runtime_prices() + else: + from app.services.market_price_stream import PublicMarketPriceFeed - def runtime_prices() -> dict[str, float]: - snapshot = market_price_feed.snapshot( - max_age_seconds=float( - trading_config.get("price_stale_after_seconds") or 10.0 - ) + market_price_feed = PublicMarketPriceFeed( + exchange_id=account_exchange, + market_type=str(primary.get("market_type") or "spot"), + instruments=candidates, + rest_fallback=runtime_prices, ) - price_feed_meta.update({ - "source": snapshot.source, - "age_ms": snapshot.age_ms, - "connected": snapshot.connected, - }) - return snapshot.prices or rest_runtime_prices() + market_price_feed.start() + + def runtime_prices() -> dict[str, float]: + snapshot = market_price_feed.snapshot(max_age_seconds=stale_after) + price_feed_meta.update({ + "source": snapshot.source, + "age_ms": snapshot.age_ms, + "connected": snapshot.connected, + }) + return snapshot.prices or rest_runtime_prices() state_store = RuntimeStateStore( strategy_id=strategy_id, strategy_run_id=run_id, @@ -1843,7 +1869,11 @@ def _execution_account_prices( exchange_config: dict[str, Any], client_holder: dict[str, Any], ) -> dict[str, float]: - prices = cls._live_prices(candidates) + exchange_id = str(exchange_config.get("exchange_id") or "").strip().lower() + strict_futu = exchange_id == "futu" + # A Futu live strategy must never use a public-provider price for risk + # decisions or entry signals when its execution feed is unavailable. + prices = {} if strict_futu else cls._live_prices(candidates) try: from app.services.live_trading.factory import create_client from app.services.live_trading.symbols import to_okx_spot_inst_id, to_okx_swap_inst_id @@ -1853,36 +1883,46 @@ def _execution_account_prices( if client is None: client = create_client(exchange_config, market_type=market_type) client_holder["client"] = client - exchange_id = str(exchange_config.get("exchange_id") or "").strip().lower() for member in candidates: - if str(member.get("market") or "") != "Crypto": - continue + market = str(member.get("market") or "") symbol = str(member.get("symbol") or "") price = 0.0 - if hasattr(client, "get_mark_price"): - price = float(client.get_mark_price(symbol=symbol) or 0.0) - elif hasattr(client, "get_ticker"): - if exchange_id == "okx": - is_spot = str(member.get("market_type") or "").lower() == "spot" - inst_id = to_okx_spot_inst_id(symbol) if is_spot else to_okx_swap_inst_id(symbol) - ticker = client.get_ticker(inst_id=inst_id) - else: - ticker = client.get_ticker(symbol=symbol) - if isinstance(ticker, dict): - price = float( - ticker.get("last") - or ticker.get("lastPrice") - or ticker.get("lastPr") - or ticker.get("lastPx") - or ticker.get("markPrice") - or ticker.get("price") - or ticker.get("close") - or 0.0 + if market == "Crypto": + if hasattr(client, "get_mark_price"): + price = float(client.get_mark_price(symbol=symbol) or 0.0) + elif hasattr(client, "get_ticker"): + if exchange_id == "okx": + is_spot = str(member.get("market_type") or "").lower() == "spot" + inst_id = to_okx_spot_inst_id(symbol) if is_spot else to_okx_swap_inst_id(symbol) + ticker = client.get_ticker(inst_id=inst_id) + else: + ticker = client.get_ticker(symbol=symbol) + if isinstance(ticker, dict): + price = float( + ticker.get("last") + or ticker.get("lastPrice") + or ticker.get("lastPr") + or ticker.get("lastPx") + or ticker.get("markPrice") + or ticker.get("price") + or ticker.get("close") + or 0.0 + ) + elif exchange_id == "futu" and market in ("HKStock", "USStock") and hasattr(client, "get_quote"): + quote = client.get_quote(symbol, market) + if isinstance(quote, dict) and quote.get("success"): + price = float(quote.get("last") or quote.get("close") or 0.0) + if price <= 0: + raise RuntimeError( + f"FUTU_EXECUTION_QUOTE_UNAVAILABLE:{market}:{symbol}:" + f"{(quote or {}).get('error') or 'empty price'}" ) if price > 0: prices[str(member.get("key") or "")] = price except Exception as exc: logger.warning("Execution-account price fetch failed: %s", exc) + if strict_futu: + raise RuntimeError(f"strategyV2.executionMarketDataUnavailable:{exc}") from exc return prices @staticmethod diff --git a/backend_api_python/app/utils/local_brokers.py b/backend_api_python/app/utils/local_brokers.py index bffe6a379..54fc76e44 100644 --- a/backend_api_python/app/utils/local_brokers.py +++ b/backend_api_python/app/utils/local_brokers.py @@ -1,17 +1,32 @@ -"""Local desktop broker policy for IBKR.""" +"""Local desktop broker policy for IBKR / Futu OpenD.""" from __future__ import annotations import os +from typing import Set + + +LOCAL_DESKTOP_BROKERS: Set[str] = {"ibkr", "futu"} def local_desktop_brokers_allowed() -> bool: - """When False, IBKR credential creation and related flows are rejected.""" + """When False, IBKR/Futu credential creation and related flows are rejected.""" v = os.getenv("ALLOW_LOCAL_DESKTOP_BROKERS", "true").strip().lower() return v in ("1", "true", "yes", "on") -def desktop_broker_cloud_reject_message() -> str: +def is_local_desktop_broker(exchange_id: str) -> bool: + return str(exchange_id or "").strip().lower() in LOCAL_DESKTOP_BROKERS + + +def desktop_broker_cloud_reject_message(exchange_id: str = "ibkr") -> str: + ex = str(exchange_id or "ibkr").strip().lower() + if ex == "futu": + return ( + "This server has disabled Futu local desktop broker access " + "(requires FutuOpenD). Deploy QuantDinger on your own machine " + "or private server and run FutuOpenD." + ) return ( "This server has disabled IBKR local desktop broker access " "(requires local TWS or IB Gateway). Deploy QuantDinger on your own " diff --git a/backend_api_python/env.example b/backend_api_python/env.example index 18ea79594..4960b88e9 100644 --- a/backend_api_python/env.example +++ b/backend_api_python/env.example @@ -297,9 +297,9 @@ PROXY_URL= #LIVE_TRADING_SSL_VERIFY= # ========================= -# Local desktop brokers (IBKR) +# Local desktop brokers (IBKR / Futu OpenD) # ========================= -# Interactive Brokers needs TWS/IB Gateway on a +# Interactive Brokers needs TWS/IB Gateway, and Futu needs FutuOpenD, on a # machine reachable from this API (typically your own PC or a VPS with those apps). # On a public multi-tenant cloud deployment, set to false so users see a clear message # instead of broken flows. Crypto exchange API keys are unaffected. @@ -311,6 +311,14 @@ ALLOW_LOCAL_DESKTOP_BROKERS=true # Dedicated IBKR execution/commission stream session; must differ from order/UI sessions. #IBKR_STREAM_CLIENT_ID=8 +# Futu OpenD (HKStock / USStock via futu-api) +# Default OpenD listen address. Docker containers often need host.docker.internal +# or the LAN IP of the machine running OpenD. +FUTU_OPEND_HOST=127.0.0.1 +FUTU_OPEND_PORT=11111 +# Allow non-localhost / non-RFC1918 OpenD hosts (SSRF guard). Keep false in SaaS. +FUTU_ALLOW_REMOTE_OPEND=false + # ========================= # Captcha / OAuth (optional) # ========================= diff --git a/backend_api_python/requirements.txt b/backend_api_python/requirements.txt index c1d0bb130..d9402c1f3 100644 --- a/backend_api_python/requirements.txt +++ b/backend_api_python/requirements.txt @@ -47,6 +47,7 @@ reportlab>=5.0.0 bcrypt>=5.0.0 # Interactive Brokers trading (optional, for US stock trading via TWS/IB Gateway) ib_insync>=0.9.86 +futu-api>=6.0.0 # Enhanced search services (optional, for better news search) # Tavily - AI-optimized search API (free 1000 requests/month) # tavily-python>=0.3.0 diff --git a/backend_api_python/tests/test_broker_market_policy.py b/backend_api_python/tests/test_broker_market_policy.py index 2dd73d3ce..944cd3d96 100644 --- a/backend_api_python/tests/test_broker_market_policy.py +++ b/backend_api_python/tests/test_broker_market_policy.py @@ -36,6 +36,7 @@ class TestHelpers: def test_is_long_only_broker_truthy(self): assert is_long_only_broker("ibkr") is True assert is_long_only_broker("alpaca") is True + assert is_long_only_broker("futu") is True assert is_long_only_broker("ALPACA") is True # case-insensitive assert is_long_only_broker(" ibkr ") is True # whitespace tolerant @@ -49,6 +50,9 @@ def test_is_compatible_credential_known(self): assert is_compatible_credential("alpaca", "Crypto") is False assert is_compatible_credential("alpaca", "USStock") is True assert is_compatible_credential("binance", "Crypto") is True + assert is_compatible_credential("futu", "HKStock") is True + assert is_compatible_credential("futu", "USStock") is True + assert is_compatible_credential("futu", "Crypto") is False def test_is_compatible_credential_mismatch(self): assert is_compatible_credential("ibkr", "Crypto") is False @@ -69,6 +73,8 @@ def test_allowed_market_types_spot_only(self): # Traditional stock brokers remain spot-only for US equities. assert allowed_market_types("alpaca", "USStock") == {"spot"} assert allowed_market_types("ibkr", "USStock") == {"spot"} + assert allowed_market_types("futu", "HKStock") == {"spot"} + assert allowed_market_types("futu", "USStock") == {"spot"} def test_allowed_market_types_invalid_combo(self): # Returns empty set rather than raising — caller decides how to react. @@ -78,14 +84,17 @@ def test_allowed_bot_types_per_market(self): # Crypto: every bot type is supported. assert allowed_bot_types("Crypto") == {"grid", "martingale", "dca", "trend"} assert allowed_bot_types("Forex") == set() - # USStock: no grid (overnight gaps), no martingale. + # USStock / HKStock: no grid (overnight gaps), no martingale. assert allowed_bot_types("USStock") == {"dca", "trend"} + assert allowed_bot_types("HKStock") == {"dca", "trend"} def test_list_supported_brokers_for_market(self): usstock_brokers = list_supported_brokers_for_market("USStock") assert "ibkr" in usstock_brokers assert "alpaca" in usstock_brokers + assert "futu" in usstock_brokers assert "binance" not in usstock_brokers + assert list_supported_brokers_for_market("HKStock") == {"futu"} assert list_supported_brokers_for_market("Forex") == set() @@ -111,6 +120,9 @@ class TestValidateLegalCombos: ("htx", "Crypto", "spot", "long"), # IBKR US stocks ("ibkr", "USStock", "spot", "long"), + # Futu HK / US + ("futu", "HKStock", "spot", "long"), + ("futu", "USStock", "spot", "long"), ]) def test_valid_strategy_combo_raises_nothing( self, exchange_id, market_category, market_type, trade_direction @@ -241,6 +253,24 @@ def test_alpaca_short_rejected(self): trade_direction="both", ) + def test_futu_short_rejected(self): + with pytest.raises(ValueError, match="long-only"): + validate_strategy_config( + exchange_id="futu", + market_category="HKStock", + market_type="spot", + trade_direction="short", + ) + + def test_futu_cannot_trade_crypto(self): + with pytest.raises(ValueError, match="FUTU cannot trade"): + validate_strategy_config( + exchange_id="futu", + market_category="Crypto", + market_type="spot", + trade_direction="long", + ) + def test_crypto_short_on_spot_rejected(self): # Even on a perp exchange, asking for short while staying on crypto # spot must be rejected (no spot shorts in crypto). @@ -265,6 +295,8 @@ class TestBotTypeRules: ("dca", "Crypto"), ("dca", "USStock"), ("trend", "USStock"), + ("dca", "HKStock"), + ("trend", "HKStock"), ]) def test_valid_bot_market_pair(self, bot_type, market_category): # Combine with a broker that supports the market. @@ -344,15 +376,21 @@ def test_market_types_serialize_as_sorted_lists(self): assert bm["alpaca"] == {"USStock": ["spot"]} def test_long_only_brokers_serialized(self): - assert sorted(to_dict()["long_only_brokers"]) == ["alpaca", "ibkr"] + assert sorted(to_dict()["long_only_brokers"]) == ["alpaca", "futu", "ibkr"] def test_bot_type_markets_serialized(self): bot_markets = to_dict()["bot_type_markets"] assert sorted(bot_markets["grid"]) == ["Crypto"] assert sorted(bot_markets["martingale"]) == ["Crypto"] + assert "HKStock" in bot_markets["dca"] + assert "HKStock" in bot_markets["trend"] def test_live_market_categories_serialized(self): - assert sorted(to_dict()["live_market_categories"]) == ["Crypto", "USStock"] + assert sorted(to_dict()["live_market_categories"]) == ["Crypto", "HKStock", "USStock"] + + def test_futu_matrix_serialized(self): + bm = to_dict()["broker_markets"] + assert bm["futu"] == {"HKStock": ["spot"], "USStock": ["spot"]} def test_matrix_internal_consistency(self): # Every long-only broker must be present in BROKER_MARKETS. @@ -383,4 +421,6 @@ def test_get_broker_market_returns_full_snapshot(self, client): assert "bot_type_markets" in data assert data["broker_markets"]["binance"]["Crypto"] == ["spot", "swap"] assert data["broker_markets"]["alpaca"] == {"USStock": ["spot"]} + assert data["broker_markets"]["futu"] == {"HKStock": ["spot"], "USStock": ["spot"]} assert "alpaca" in data["long_only_brokers"] + assert "futu" in data["long_only_brokers"] diff --git a/backend_api_python/tests/test_execution_stream_adapters.py b/backend_api_python/tests/test_execution_stream_adapters.py index 2fc0bf575..8648b7994 100644 --- a/backend_api_python/tests/test_execution_stream_adapters.py +++ b/backend_api_python/tests/test_execution_stream_adapters.py @@ -43,7 +43,7 @@ def _adapter(adapter_cls, *, market_type="swap", symbols=()): return adapter, states -def test_adapter_registry_covers_six_exchanges_and_two_brokers(): +def test_adapter_registry_covers_six_exchanges_and_three_brokers(): assert set(ADAPTERS) == { "binance", "okx", @@ -53,6 +53,7 @@ def test_adapter_registry_covers_six_exchanges_and_two_brokers(): "htx", "alpaca", "ibkr", + "futu", } diff --git a/backend_api_python/tests/test_futu_client_contract.py b/backend_api_python/tests/test_futu_client_contract.py new file mode 100644 index 000000000..79125c058 --- /dev/null +++ b/backend_api_python/tests/test_futu_client_contract.py @@ -0,0 +1,220 @@ +"""Contract tests for FutuClient with a mocked futu-api SDK.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pandas as pd +import pytest + +from app.services.futu_trading.client import FutuClient +from app.services.futu_trading.config import FutuConfig + + +class _FakeFT: + RET_OK = 0 + RET_ERROR = -1 + + class TrdEnv: + SIMULATE = "SIMULATE" + REAL = "REAL" + + class TrdMarket: + HK = "HK" + US = "US" + NONE = "NONE" + + class SecurityFirm: + FUTUSECURITIES = "FUTUSECURITIES" + + class TrdSide: + BUY = "BUY" + SELL = "SELL" + + class OrderType: + MARKET = "MARKET" + NORMAL = "NORMAL" + + class OrderStatus: + SUBMITTED = "SUBMITTED" + FILLED_PART = "FILLED_PART" + FILLED_ALL = "FILLED_ALL" + WAITING_SUBMIT = "WAITING_SUBMIT" + SUBMITTING = "SUBMITTING" + + class ModifyOrderOp: + CANCEL = "CANCEL" + + class KLType: + K_DAY = "K_DAY" + + class AuType: + QFQ = "QFQ" + + class SubType: + QUOTE = "QUOTE" + + +def _client_with_mocks(): + cfg = FutuConfig(host="127.0.0.1", port=11111, trade_env="demo", trade_market="HK") + client = FutuClient(cfg) + quote = MagicMock() + trade = MagicMock() + quote.get_global_state.return_value = (_FakeFT.RET_OK, {"trd_logined": True, "server_ver": 1}) + trade.get_acc_list.return_value = ( + _FakeFT.RET_OK, + pd.DataFrame([{"acc_id": 99, "trd_env": "SIMULATE", "acc_type": "STOCK"}]), + ) + client._quote_ctx = quote + client._trade_ctx = trade + client._connected = True + client._acc_id = 99 + return client, quote, trade + + +@patch("app.services.futu_trading.client._ensure_futu", return_value=_FakeFT) +def test_place_market_order_success(_ensure): + client, quote, trade = _client_with_mocks() + quote.get_market_snapshot.return_value = ( + _FakeFT.RET_OK, + pd.DataFrame([{"lot_size": 100, "last_price": 350.0}]), + ) + trade.place_order.return_value = ( + _FakeFT.RET_OK, + pd.DataFrame([{ + "order_id": "OID-1", + "order_status": "SUBMITTED", + "dealt_qty": 0, + "dealt_avg_price": 0, + "qty": 100, + "price": 350.0, + "code": "HK.00700", + "trd_side": "BUY", + "remark": "r1", + }]), + ) + result = client.place_market_order("00700.HK", "buy", 100, "HKStock", remark="r1") + assert result.success + assert result.order_id == "OID-1" + assert result.status == "submitted" + kwargs = trade.place_order.call_args.kwargs + assert kwargs["code"] == "HK.00700" + assert kwargs["qty"] == 100 + assert kwargs["remark"] == "r1" + + +@patch("app.services.futu_trading.client._ensure_futu", return_value=_FakeFT) +def test_place_order_rejects_bad_lot(_ensure): + client, quote, trade = _client_with_mocks() + quote.get_market_snapshot.return_value = ( + _FakeFT.RET_OK, + pd.DataFrame([{"lot_size": 100, "last_price": 350.0}]), + ) + result = client.place_market_order("00700.HK", "buy", 50, "HKStock") + assert not result.success + assert "FUTU_INVALID_LOT_SIZE" in result.message + trade.place_order.assert_not_called() + + +@patch("app.services.futu_trading.client._ensure_futu", return_value=_FakeFT) +def test_get_order_status_and_find_by_remark(_ensure): + client, _quote, trade = _client_with_mocks() + trade.order_list_query.return_value = ( + _FakeFT.RET_OK, + pd.DataFrame([{ + "order_id": "OID-9", + "order_status": "FILLED_ALL", + "dealt_qty": 100, + "dealt_avg_price": 351.2, + "qty": 100, + "code": "HK.00700", + "trd_side": "BUY", + "remark": "futu-remark", + }]), + ) + status = client.get_order_status("OID-9") + assert status.success + assert status.status == "filled" + assert status.filled == 100 + found = client.find_order_by_remark("futu-remark") + assert found is not None + assert found.order_id == "OID-9" + + +@patch("app.services.futu_trading.client._ensure_futu", return_value=_FakeFT) +def test_get_order_status_treats_empty_query_as_failure(_ensure): + client, _quote, trade = _client_with_mocks() + trade.order_list_query.return_value = (_FakeFT.RET_OK, pd.DataFrame()) + + status = client.get_order_status("OID-MISSING") + + assert not status.success + assert status.filled == 0 + assert "not found" in status.message.lower() + + +@patch("app.services.futu_trading.client._ensure_futu") +def test_trading_client_rejects_remote_host_before_loading_sdk(ensure_futu, monkeypatch): + monkeypatch.delenv("FUTU_ALLOW_REMOTE_OPEND", raising=False) + client = FutuClient(FutuConfig(host="169.254.169.254")) + + assert client.connect() is False + ensure_futu.assert_not_called() + + +@patch("app.services.futu_trading.client._ensure_futu", return_value=_FakeFT) +def test_cancel_order(_ensure): + client, _quote, trade = _client_with_mocks() + trade.modify_order.return_value = (_FakeFT.RET_OK, pd.DataFrame([{"order_id": "OID-1"}])) + assert client.cancel_order("OID-1") is True + + +def test_parse_futu_deal_normalizer(): + try: + from app.services.execution_streams.normalizers import parse_futu_deal + except ModuleNotFoundError: + pytest.skip("optional deps missing for execution_streams import") + + events = parse_futu_deal({ + "code": "HK.00700", + "order_id": "OID-1", + "deal_id": "D-1", + "qty": 100, + "price": 350.0, + "trd_side": "BUY", + "remark": "r1", + "dealt_qty": 100, + "order_status": "FILLED_ALL", + "create_time": "2026-08-10 10:00:00", + }) + assert len(events) == 1 + assert events[0].exchange_id == "futu" + assert events[0].symbol == "00700.HK" + assert events[0].quantity == 100 + assert events[0].client_order_id == "r1" + assert events[0].occurred_at == datetime(2026, 8, 10, 2, 0, tzinfo=timezone.utc) + + +def test_parse_futu_order_snapshot_uses_cumulative_fill_and_average_price(): + from app.services.execution_streams.normalizers import parse_futu_deal + + base = { + "code": "HK.00700", + "order_id": "OID-2", + "qty": 100, + "price": 350.0, + "dealt_avg_price": 349.2, + "trd_side": "BUY", + "order_status": "FILLED_PART", + "updated_time": "2026-08-10 10:01:00", + } + first = parse_futu_deal({**base, "dealt_qty": 20})[0] + second = parse_futu_deal({**base, "dealt_qty": 40})[0] + + assert first.exchange_fill_id == "" + assert first.price == 349.2 + assert first.quantity == 0 + assert first.cumulative_quantity == 20 + assert first.is_cumulative + assert first.event_key() != second.event_key() diff --git a/backend_api_python/tests/test_futu_config.py b/backend_api_python/tests/test_futu_config.py new file mode 100644 index 000000000..4fa4ba5cd --- /dev/null +++ b/backend_api_python/tests/test_futu_config.py @@ -0,0 +1,88 @@ +import pytest + +from app.services.futu_trading.config import ( + FutuConfig, + config_from_exchange_config, + is_local_or_private_opend_host, + normalize_security_firm, + normalize_trade_env, + normalize_trade_market, + validate_opend_host, +) + + +def test_normalize_trade_env(): + assert normalize_trade_env("demo") == "demo" + assert normalize_trade_env("paper") == "demo" + assert normalize_trade_env("simulate") == "demo" + assert normalize_trade_env("live") == "live" + assert normalize_trade_env("REAL") == "live" + assert normalize_trade_env("") == "demo" + + +def test_normalize_trade_market(): + assert normalize_trade_market("HK") == "HK" + assert normalize_trade_market("", market_category="HKStock") == "HK" + assert normalize_trade_market("", market_category="USStock") == "US" + assert normalize_trade_market("USStock") == "US" + + +def test_normalize_security_firm(): + assert normalize_security_firm("") == "FUTUSECURITIES" + assert normalize_security_firm("futu") == "FUTUSECURITIES" + assert normalize_security_firm("FUTUINC") == "FUTUINC" + + +def test_config_from_exchange_config_demo_default(): + cfg = config_from_exchange_config({ + "futu_host": "host.docker.internal", + "futu_port": 11111, + "environment": "demo", + "trade_market": "HK", + }) + assert isinstance(cfg, FutuConfig) + assert cfg.host == "host.docker.internal" + assert cfg.is_simulate is True + assert cfg.trade_market == "HK" + redacted = cfg.redacted_dict() + assert "unlock_password" not in redacted + assert redacted["has_unlock_password"] is False + + +def test_config_live_with_password_flag(): + cfg = config_from_exchange_config({ + "host": "127.0.0.1", + "port": 11111, + "trade_env": "live", + "unlock_password": "secret", + "market_category": "USStock", + }) + assert cfg.trade_env == "live" + assert cfg.trade_market == "US" + assert cfg.redacted_dict()["has_unlock_password"] is True + + +def test_opend_host_validation_accepts_only_local_and_private_ranges(): + assert is_local_or_private_opend_host("127.0.0.1") + assert is_local_or_private_opend_host("localhost") + assert is_local_or_private_opend_host("host.docker.internal") + assert is_local_or_private_opend_host("10.1.2.3") + assert is_local_or_private_opend_host("172.16.0.1") + assert is_local_or_private_opend_host("172.31.255.254") + assert is_local_or_private_opend_host("192.168.1.2") + assert is_local_or_private_opend_host("fd12::1") + assert not is_local_or_private_opend_host("172.15.255.255") + assert not is_local_or_private_opend_host("172.32.0.1") + assert not is_local_or_private_opend_host("8.8.8.8") + assert not is_local_or_private_opend_host("example.com") + + +def test_validate_opend_host_rejects_remote_by_default(monkeypatch): + monkeypatch.delenv("FUTU_ALLOW_REMOTE_OPEND", raising=False) + with pytest.raises(ValueError, match="private LAN"): + validate_opend_host("169.254.169.254") + + +def test_validate_opend_host_allows_remote_only_with_explicit_opt_in(monkeypatch): + monkeypatch.setenv("FUTU_ALLOW_REMOTE_OPEND", "true") + assert validate_opend_host("203.0.113.8") == "203.0.113.8" diff --git a/backend_api_python/tests/test_futu_integration_opend.py b/backend_api_python/tests/test_futu_integration_opend.py new file mode 100644 index 000000000..96b5ee2b2 --- /dev/null +++ b/backend_api_python/tests/test_futu_integration_opend.py @@ -0,0 +1,41 @@ +"""Optional integration tests against a local FutuOpenD simulate account. + +Skipped unless FUTU_INTEGRATION=1 and OpenD is reachable. +""" + +from __future__ import annotations + +import os + +import pytest + +pytestmark = pytest.mark.integration + + +def _enabled() -> bool: + return str(os.getenv("FUTU_INTEGRATION") or "").strip().lower() in ("1", "true", "yes", "on") + + +@pytest.mark.skipif(not _enabled(), reason="Set FUTU_INTEGRATION=1 with a running FutuOpenD") +def test_futu_opend_probe_and_quote(): + from app.services.futu_trading import FutuClient, FutuConfig + + client = FutuClient( + FutuConfig( + host=os.getenv("FUTU_OPEND_HOST", "127.0.0.1"), + port=int(os.getenv("FUTU_OPEND_PORT", "11111")), + trade_env="demo", + trade_market="HK", + ) + ) + assert client.connect(), "FutuOpenD connect failed" + try: + status = client.get_connection_status() + assert status.get("connected") is True + probe = client.probe_permissions() + assert probe.get("quote_ok") or probe.get("trade_ok") + quote = client.get_quote("00700.HK", "HKStock") + assert quote.get("success") is True + assert float(quote.get("last") or 0) > 0 + finally: + client.disconnect() diff --git a/backend_api_python/tests/test_futu_local_brokers.py b/backend_api_python/tests/test_futu_local_brokers.py new file mode 100644 index 000000000..b8812e180 --- /dev/null +++ b/backend_api_python/tests/test_futu_local_brokers.py @@ -0,0 +1,17 @@ +from app.utils.local_brokers import ( + LOCAL_DESKTOP_BROKERS, + desktop_broker_cloud_reject_message, + is_local_desktop_broker, +) + + +def test_futu_is_local_desktop_broker(): + assert "futu" in LOCAL_DESKTOP_BROKERS + assert is_local_desktop_broker("futu") + assert is_local_desktop_broker("FUTU") + assert not is_local_desktop_broker("alpaca") + + +def test_futu_reject_message(): + msg = desktop_broker_cloud_reject_message("futu") + assert "FutuOpenD" in msg diff --git a/backend_api_python/tests/test_futu_mappers.py b/backend_api_python/tests/test_futu_mappers.py new file mode 100644 index 000000000..434a78037 --- /dev/null +++ b/backend_api_python/tests/test_futu_mappers.py @@ -0,0 +1,74 @@ +from app.services.futu_trading.mappers import ( + classify_futu_error, + is_terminal_status, + normalize_order_status, + order_row_to_raw, + position_row_to_dict, + side_from_futu, + side_to_futu, +) + + +def test_normalize_order_status(): + assert normalize_order_status("SUBMITTED") == "submitted" + assert normalize_order_status("FILLED_PART") == "partially_filled" + assert normalize_order_status("FILLED_ALL") == "filled" + assert normalize_order_status("CANCELLED_ALL") == "cancelled" + assert normalize_order_status("FAILED") == "rejected" + assert is_terminal_status("filled") + assert is_terminal_status("cancelled") + assert not is_terminal_status("submitted") + + +def test_side_mapping(): + assert side_to_futu("buy") == "BUY" + assert side_to_futu("open_long") == "BUY" + assert side_to_futu("sell") == "SELL" + assert side_from_futu("BUY") == "buy" + assert side_from_futu("TrdSide.SELL") == "sell" + + +def test_order_row_to_raw(): + raw = order_row_to_raw({ + "order_id": "12345", + "code": "HK.00700", + "order_status": "FILLED_PART", + "dealt_qty": 100, + "dealt_avg_price": 350.5, + "qty": 200, + "price": 351.0, + "trd_side": "BUY", + "remark": "futu-1-2", + "currency": "HKD", + "commission": 1.2, + }) + assert raw["order_id"] == "12345" + assert raw["status"] == "partially_filled" + assert raw["filled"] == 100 + assert raw["avg_price"] == 350.5 + assert raw["side"] == "buy" + assert raw["client_order_id"] == "futu-1-2" + assert raw["commission_ccy"] == "HKD" + + +def test_position_row_to_dict(): + pos = position_row_to_dict({ + "code": "HK.00700", + "qty": 500, + "cost_price": 320.0, + "market_val": 180000, + "currency": "HKD", + }) + assert pos["symbol"] == "00700.HK" + assert pos["quantity"] == 500 + assert pos["side"] == "long" + assert pos["avgCost"] == 320.0 + + +def test_classify_futu_error(): + code, _ = classify_futu_error("no right to get the quote") + assert code == "FUTU_QUOTE_PERMISSION_DENIED" + code, _ = classify_futu_error("unlock trade first") + assert code == "FUTU_TRADE_LOCKED" + code, _ = classify_futu_error("invalid lot size qty") + assert code == "FUTU_INVALID_LOT_SIZE" diff --git a/backend_api_python/tests/test_futu_pending_order_sync.py b/backend_api_python/tests/test_futu_pending_order_sync.py new file mode 100644 index 000000000..e65eec0d4 --- /dev/null +++ b/backend_api_python/tests/test_futu_pending_order_sync.py @@ -0,0 +1,149 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import app.services.pending_order_worker as worker_module +from app.services.pending_order_worker import PendingOrderWorker + + +class _FakeFutuClient: + def __init__(self, result): + self.result = result + self.disconnected = False + + def get_order_status(self, _order_id): + return self.result + + def disconnect(self): + self.disconnected = True + + +def _worker_with_claim(row): + worker = PendingOrderWorker.__new__(PendingOrderWorker) + worker._claim_futu_sent_order = MagicMock(return_value=dict(row)) + worker._release_futu_sync_claim = MagicMock() + worker._update_futu_sent_order_snapshot = MagicMock() + worker._unrecorded_pending_fill = MagicMock(return_value=0.0) + return worker + + +def _configure_futu_strategy(monkeypatch, client): + monkeypatch.setattr( + worker_module, + "load_strategy_configs", + lambda _strategy_id: { + "user_id": 1, + "exchange_config": {"exchange_id": "futu"}, + }, + ) + monkeypatch.setattr( + worker_module, + "resolve_exchange_config", + lambda config, user_id: config, + ) + monkeypatch.setattr(worker_module, "create_client", lambda *_args, **_kwargs: client) + monkeypatch.setattr(worker_module, "FutuClient", _FakeFutuClient) + + +def test_failed_status_query_requeues_without_overwriting_fill(monkeypatch): + row = { + "id": 17, + "exchange_order_id": "OID-17", + "strategy_id": 9, + "filled": 5, + "avg_price": 100, + } + result = SimpleNamespace( + success=False, + status="", + filled=0, + avg_price=0, + raw={}, + message="OpenD unavailable", + ) + client = _FakeFutuClient(result) + worker = _worker_with_claim(row) + _configure_futu_strategy(monkeypatch, client) + + worker._sync_one_futu_sent_order(row) + + worker._release_futu_sync_claim.assert_called_once_with(17, "not_finalized") + worker._update_futu_sent_order_snapshot.assert_not_called() + assert client.disconnected + + +def test_invalid_claimed_order_is_requeued_immediately(): + row = { + "id": 18, + "exchange_order_id": "OID-18", + "strategy_id": 0, + } + worker = _worker_with_claim(row) + + worker._sync_one_futu_sent_order(row) + + worker._release_futu_sync_claim.assert_called_once_with(18, "not_finalized") + worker._update_futu_sent_order_snapshot.assert_not_called() + + +def test_successful_regressive_snapshot_preserves_recorded_fill(monkeypatch): + row = { + "id": 19, + "exchange_order_id": "OID-19", + "strategy_id": 9, + "filled": 5, + "avg_price": 100, + } + result = SimpleNamespace( + success=True, + status="submitted", + filled=0, + avg_price=0, + raw={}, + message="OK", + ) + client = _FakeFutuClient(result) + worker = _worker_with_claim(row) + _configure_futu_strategy(monkeypatch, client) + + worker._sync_one_futu_sent_order(row) + + worker._release_futu_sync_claim.assert_not_called() + update = worker._update_futu_sent_order_snapshot.call_args.kwargs + assert update["filled"] == 5 + assert update["avg_price"] == 100 + assert update["status"] == "sent" + assert client.disconnected + + +def test_retry_uses_durable_trade_ledger_to_avoid_duplicate_fill(monkeypatch): + row = { + "id": 20, + "exchange_order_id": "OID-20", + "strategy_id": 9, + "filled": 0, + "avg_price": 0, + } + result = SimpleNamespace( + success=True, + status="partially_filled", + filled=5, + avg_price=101, + raw={}, + message="OK", + ) + client = _FakeFutuClient(result) + worker = _worker_with_claim(row) + _configure_futu_strategy(monkeypatch, client) + persist = MagicMock() + monkeypatch.setattr(worker_module, "persist_strategy_fill", persist) + + worker._sync_one_futu_sent_order(row) + + worker._unrecorded_pending_fill.assert_called_once_with( + 20, + 5.0, + fail_closed=True, + ) + persist.assert_not_called() + update = worker._update_futu_sent_order_snapshot.call_args.kwargs + assert update["filled"] == 5 diff --git a/backend_api_python/tests/test_futu_quote_client.py b/backend_api_python/tests/test_futu_quote_client.py new file mode 100644 index 000000000..083052f23 --- /dev/null +++ b/backend_api_python/tests/test_futu_quote_client.py @@ -0,0 +1,128 @@ +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest + +from app.data_sources.factory import DataSourceFactory +from app.data_sources.futu import FutuDataSource +from app.services.futu_trading.config import FutuConfig +from app.services.futu_trading.quote_client import FutuQuoteClient +from app.services.futu_trading.timezones import futu_time_key_to_timestamp + + +def test_futu_time_key_uses_hk_exchange_timezone(): + timestamp = futu_time_key_to_timestamp("2026-08-10 09:30:00", "HKStock") + assert datetime.fromtimestamp(timestamp, tz=timezone.utc) == datetime( + 2026, 8, 10, 1, 30, tzinfo=timezone.utc + ) + + +def test_futu_time_key_uses_us_dst_exchange_timezone(): + timestamp = futu_time_key_to_timestamp("2026-08-10 09:30:00", "USStock") + assert datetime.fromtimestamp(timestamp, tz=timezone.utc) == datetime( + 2026, 8, 10, 13, 30, tzinfo=timezone.utc + ) + + +def test_market_data_source_opens_quote_only_client(monkeypatch): + created = [] + + class FakeQuoteClient: + def __init__(self, config): + created.append(config) + self.connected = False + + def connect(self): + self.connected = True + return True + + def close(self): + self.connected = False + + monkeypatch.setattr( + "app.services.futu_trading.quote_client.FutuQuoteClient", + FakeQuoteClient, + ) + source = FutuDataSource( + market="HKStock", + exchange_config={"futu_host": "10.0.0.8", "futu_port": 11112}, + ) + + client = source._get_client() + + assert isinstance(client, FakeQuoteClient) + assert created[0].host == "10.0.0.8" + assert created[0].port == 11112 + assert not hasattr(client, "_trade_ctx") + source.close() + + +def test_futu_boundaries_are_converted_to_exchange_dates(monkeypatch): + client = MagicMock() + client.connected = True + client.get_history_kline.return_value = [] + source = FutuDataSource(market="USStock") + source._client = client + + source.get_kline( + "AAPL", + "1D", + 5, + before_time=int(datetime(2026, 8, 10, 2, 0, tzinfo=timezone.utc).timestamp()), + after_time=int(datetime(2026, 8, 9, 22, 0, tzinfo=timezone.utc).timestamp()), + ) + + kwargs = client.get_history_kline.call_args.kwargs + # UTC Aug 9 22:00 / Aug 10 02:00 are both Aug 9 in New York (EDT). + assert kwargs["start"] == "2026-08-09" + assert kwargs["end"] == "2026-08-09" + + +@pytest.mark.parametrize("raises", [False, True]) +def test_factory_closes_request_scoped_futu_source(monkeypatch, raises): + class FakeSource: + close_after_request = True + + def __init__(self): + self.closed = False + + def get_kline(self, *_args): + if raises: + raise RuntimeError("quote failed") + return [{"time": 1}] + + def close(self): + self.closed = True + + source = FakeSource() + monkeypatch.setattr( + DataSourceFactory, + "_resolve_source", + lambda *_args, **_kwargs: source, + ) + + if raises: + with pytest.raises(RuntimeError, match="quote failed"): + DataSourceFactory.get_kline( + "HKStock", + "00700.HK", + "1D", + 1, + strict_data_source=True, + ) + else: + assert DataSourceFactory.get_kline("HKStock", "00700.HK", "1D", 1) + assert source.closed + + +def test_quote_client_rejects_remote_host_before_loading_sdk(monkeypatch): + monkeypatch.delenv("FUTU_ALLOW_REMOTE_OPEND", raising=False) + ensure_futu = MagicMock() + monkeypatch.setattr( + "app.services.futu_trading.quote_client._ensure_futu", + ensure_futu, + ) + client = FutuQuoteClient(FutuConfig(host="169.254.169.254")) + + assert client.connect() is False + ensure_futu.assert_not_called() diff --git a/backend_api_python/tests/test_futu_symbols.py b/backend_api_python/tests/test_futu_symbols.py new file mode 100644 index 000000000..65b56db42 --- /dev/null +++ b/backend_api_python/tests/test_futu_symbols.py @@ -0,0 +1,40 @@ +from app.services.futu_trading.symbols import ( + format_display_symbol, + from_futu_code, + infer_market_category, + parse_symbol, + to_futu_code, +) + + +def test_hk_symbol_to_futu(): + assert to_futu_code("00700.HK") == "HK.00700" + assert to_futu_code("700.HK") == "HK.00700" + assert to_futu_code("00700", "HKStock") == "HK.00700" + assert to_futu_code("HK.00700") == "HK.00700" + + +def test_us_symbol_to_futu(): + assert to_futu_code("AAPL", "USStock") == "US.AAPL" + assert to_futu_code("US.AAPL") == "US.AAPL" + assert to_futu_code("AAPL.US") == "US.AAPL" + + +def test_from_futu_code_roundtrip(): + display, market = from_futu_code("HK.00700") + assert display == "00700.HK" + assert market == "HKStock" + assert to_futu_code(display, market) == "HK.00700" + + display, market = from_futu_code("US.AAPL") + assert display == "AAPL" + assert market == "USStock" + + +def test_infer_market_and_parse(): + assert infer_market_category("00700.HK") == "HKStock" + assert infer_market_category("AAPL") == "USStock" + code, market = parse_symbol("0700.HK") + assert code == "HK.00700" + assert market == "HKStock" + assert format_display_symbol("HK.00700") == "00700.HK" diff --git a/backend_api_python/tests/test_strategy_v2_market_data.py b/backend_api_python/tests/test_strategy_v2_market_data.py index 07ca2738a..abb739ed9 100644 --- a/backend_api_python/tests/test_strategy_v2_market_data.py +++ b/backend_api_python/tests/test_strategy_v2_market_data.py @@ -1,5 +1,7 @@ from datetime import datetime, timedelta, timezone +import pytest + from app.services.strategy_v2 import market_data @@ -81,3 +83,62 @@ def test_market_data_normalizes_naive_and_aware_datetimes_to_utc(): assert normalized_naive == datetime(2026, 7, 19, 4, 14, 13, tzinfo=timezone.utc) assert normalized_aware == normalized_naive assert normalized_naive.timestamp() == normalized_aware.timestamp() + + +def test_futu_config_and_strict_mode_are_forwarded_without_secrets_in_cache(monkeypatch): + captured = {} + cache_keys = [] + config = { + "exchange_id": "futu", + "futu_host": "host.docker.internal", + "futu_port": 11111, + "trade_market": "HK", + "unlock_password": "must-not-enter-cache-key", + } + + def get_kline(**kwargs): + captured.update(kwargs) + return [] + + monkeypatch.setattr(market_data.DataSourceFactory, "get_kline", get_kline) + monkeypatch.setattr(market_data._cache, "get", lambda key: cache_keys.append(key)) + + market_data.load_strategy_frame( + "HKStock", + "00700.HK", + "1d", + datetime(2026, 8, 1), + datetime(2026, 8, 2), + market_type="spot", + exchange_id="futu", + exchange_config=config, + strict_data_source=True, + ) + + assert captured["exchange_config"] is config + assert captured["allow_futu_fallback"] is False + assert captured["strict_data_source"] is True + assert "host.docker.internal" in cache_keys[0] + assert "must-not-enter-cache-key" not in cache_keys[0] + + +def test_strict_market_data_failure_is_not_silenced(monkeypatch): + monkeypatch.setattr(market_data._cache, "get", lambda _key: None) + + def get_kline(**_kwargs): + raise RuntimeError("FUTU_OPEND_UNREACHABLE") + + monkeypatch.setattr(market_data.DataSourceFactory, "get_kline", get_kline) + + with pytest.raises(RuntimeError, match="executionMarketDataUnavailable"): + market_data.load_strategy_frame( + "HKStock", + "00700.HK", + "1d", + datetime(2026, 8, 1), + datetime(2026, 8, 2), + market_type="spot", + exchange_id="futu", + exchange_config={"futu_host": "127.0.0.1"}, + strict_data_source=True, + ) diff --git a/backend_api_python/tests/test_strategy_v2_service.py b/backend_api_python/tests/test_strategy_v2_service.py index 8fac5b958..702dc09f1 100644 --- a/backend_api_python/tests/test_strategy_v2_service.py +++ b/backend_api_python/tests/test_strategy_v2_service.py @@ -84,6 +84,65 @@ def test_dynamic_universe_reference_matches_canonical_universe_code(): ) +def test_fetch_frames_forwards_futu_config_in_strict_mode(): + captured = {} + + def frame_fetcher(*_args, **kwargs): + captured.update(kwargs) + return _frame() + + config = {"exchange_id": "futu", "futu_host": "10.0.0.8"} + service = StrategyV2BacktestService( + repository=_Repository(), + frame_fetcher=frame_fetcher, + ) + frames, skipped = service.fetch_frames( + [{ + "key": "HKStock:00700.HK", + "market": "HKStock", + "symbol": "00700.HK", + "market_type": "spot", + "exchange_id": "futu", + }], + "1d", + datetime(2026, 1, 1), + datetime(2026, 1, 5), + exchange_config=config, + strict_data_source=True, + ) + + assert frames + assert not skipped + assert captured["exchange_config"] is config + assert captured["strict_data_source"] is True + + +def test_fetch_frames_fails_whole_futu_batch_in_strict_mode(): + def failing_fetcher(*_args, **_kwargs): + raise RuntimeError("FUTU_OPEND_UNREACHABLE") + + service = StrategyV2BacktestService( + repository=_Repository(), + frame_fetcher=failing_fetcher, + ) + + with pytest.raises(RuntimeError, match="executionMarketDataUnavailable"): + service.fetch_frames( + [{ + "key": "HKStock:00700.HK", + "market": "HKStock", + "symbol": "00700.HK", + "market_type": "spot", + "exchange_id": "futu", + }], + "1d", + datetime(2026, 1, 1), + datetime(2026, 1, 5), + exchange_config={"exchange_id": "futu"}, + strict_data_source=True, + ) + + def test_v2_service_accepts_a_controlled_fundamental_enricher(): code = """ def initialize(context): diff --git a/docs/architecture/EXTENSION_GUIDE.md b/docs/architecture/EXTENSION_GUIDE.md index 3c53d18e0..662f7324e 100644 --- a/docs/architecture/EXTENSION_GUIDE.md +++ b/docs/architecture/EXTENSION_GUIDE.md @@ -85,9 +85,12 @@ Use this flow for external AI agents, MCP clients, and automation: 5. Add explicit notes for market type support: - spot - swap/perpetual - - US stock + - US stock / HK stock - paper/live 6. If an adapter supports live orders, document idempotency and retry behavior. +7. For local-gateway brokers (IBKR TWS, FutuOpenD), also update + `app/utils/local_brokers.py`, `broker_market_policy.py`, and the + deployment notes under `docs/architecture/FUTU_OPEND.md` (or IBKR README). ## Add a Strategy Runtime Feature diff --git a/docs/architecture/FUTU_OPEND.md b/docs/architecture/FUTU_OPEND.md new file mode 100644 index 000000000..dec61a011 --- /dev/null +++ b/docs/architecture/FUTU_OPEND.md @@ -0,0 +1,78 @@ +# Futu OpenD Integration + +QuantDinger connects to [Futu OpenAPI](https://openapi.futunn.com/futu-api-doc/) through a local **FutuOpenD** gateway (same operational model as IBKR TWS). + +## What is supported (MVP) + +| Area | Support | +|------|---------| +| Markets | `HKStock`, `USStock` spot | +| Direction | Long-only | +| Environments | `demo` → `TrdEnv.SIMULATE`, `live` → `TrdEnv.REAL` | +| Orders | Market / limit / cancel | +| Data | History K-line, snapshot quote, quote feed for risk ticks | +| Bots | DCA / Trend only (no Grid / Martingale) | + +## Prerequisites + +1. Install and log into **FutuOpenD** (GUI recommended). +2. Default listen: `127.0.0.1:11111`. +3. Backend dependency: `pip install futu-api`. +4. Set `ALLOW_LOCAL_DESKTOP_BROKERS=true` on self-hosted deployments. + +## Docker / network topology + +| Runtime | OpenD host tip | +|---------|----------------| +| Same host (native Python) | `127.0.0.1` | +| Docker Compose on Docker Desktop / WSL2 | `host.docker.internal` | +| LAN server | Private IP of the OpenD machine (`192.168.x.x` / `10.x`) | + +Open the OpenD API port in the host firewall. Keep `FUTU_ALLOW_REMOTE_OPEND=false` unless you intentionally point at a non-LAN OpenD. + +## Credential fields + +Stored encrypted in `qd_exchange_credentials`: + +- `futu_host`, `futu_port` +- `trade_env` / `environment`: `demo` \| `live` +- `trade_market`: `HK` \| `US` +- `security_firm`: e.g. `FUTUSECURITIES`, `FUTUINC`, `FUTUSG` +- `acc_id` (optional) +- `unlock_password` (optional — prefer GUI unlock for live) + +## Operator APIs + +- `POST /api/futu/connect` — session connect +- `POST /api/futu/probe` — account + quote permission probe (**no orders**) +- `GET /api/futu/account|positions|orders|quote` +- `POST /api/credentials/test` with `exchange_id=futu` +- `GET /api/policy/broker-market` — includes `futu` matrix + +Live strategy orders still go through `pending_orders` → `trading-worker` → `FutuClient`. + +## Unlock / live trading + +1. Prefer unlocking in the OpenD GUI. +2. Headless: store `unlock_password` in the credential vault (encrypted). +3. Never log unlock passwords or full credential blobs. + +## Failure modes + +| Symptom | Likely cause | +|---------|----------------| +| `FUTU_OPEND_UNREACHABLE` | OpenD not running / wrong host:port / Docker networking | +| `FUTU_QUOTE_PERMISSION_DENIED` | Account lacks quote rights for that market | +| `FUTU_QUOTE_QUOTA_EXCEEDED` | History K-line quota exhausted | +| `FUTU_TRADE_LOCKED` | Live trade not unlocked | +| `FUTU_INVALID_LOT_SIZE` | HK qty not a multiple of lot size | + +When OpenD quote fails for a Futu execution account, K-line/ticker falls back to the public HK/US sources and tags `source=fallback:...` — never silently pretend Futu succeeded. + +## Acceptance checklist + +1. `POST /api/futu/probe` succeeds on simulate env. +2. Demo market/limit order fills and appears in strategy ledger. +3. Restart `trading-worker` — open `sent` orders reconcile without duplicate fills. +4. Position sync matches OpenD positions for the credential account. +5. Only then enable `trade_env=live` with whitelist symbols and small size limits. From 68a62296fd0b4aa97d0075e36544e0789944902c Mon Sep 17 00:00:00 2001 From: dl <1909703981@qq.com> Date: Thu, 13 Aug 2026 02:09:07 +0800 Subject: [PATCH 2/3] fix: harden Futu execution streams and reconciliation Prevent duplicate fills, preserve session-bar routing, and make push/REST reconciliation fail closed across reconnects. --- .../app/data_sources/factory.py | 11 +- backend_api_python/app/data_sources/futu.py | 26 +- .../app/openapi/schemas/high_risk.py | 29 +- backend_api_python/app/routes/credentials.py | 30 +- .../app/services/exchange_execution.py | 11 +- .../services/execution_streams/adapters.py | 75 ++++- .../services/execution_streams/processor.py | 48 ++- .../services/execution_streams/supervisor.py | 15 +- .../app/services/pending_order_worker.py | 78 ++++- .../app/services/trading_executor.py | 20 +- backend_api_python/requirements.lock | 1 + .../scripts/backend_quality_baseline.json | 4 +- .../tests/test_execution_stream_adapters.py | 81 +++++ .../tests/test_execution_stream_processor.py | 27 ++ .../tests/test_execution_stream_supervisor.py | 76 ++++- .../tests/test_futu_client_contract.py | 20 ++ backend_api_python/tests/test_futu_config.py | 33 ++ .../tests/test_futu_pending_order_sync.py | 25 ++ .../tests/test_futu_quote_client.py | 36 +- .../test_pending_order_worker_live_sync.py | 37 ++ docs/api/openapi.yaml | 315 +++++++++++++++++- 21 files changed, 919 insertions(+), 79 deletions(-) create mode 100644 backend_api_python/tests/test_execution_stream_processor.py diff --git a/backend_api_python/app/data_sources/factory.py b/backend_api_python/app/data_sources/factory.py index 52dfdd5bb..32e9dac27 100644 --- a/backend_api_python/app/data_sources/factory.py +++ b/backend_api_python/app/data_sources/factory.py @@ -42,7 +42,6 @@ def _env_positive_int(key: str, default: int) -> int: "equities": "USStock", "alpaca": "USStock", "ibkr": "USStock", - "futu": "HKStock", "cnstock": "CNStock", "cn_stock": "CNStock", "ashare": "CNStock", @@ -132,6 +131,10 @@ def normalize_market(cls, market: str) -> str: if raw in cls._CANONICAL_MARKETS: return raw key = raw.lower().replace(" ", "").replace("-", "_") + if key == "futu": + raise UnsupportedMarketError( + "futu is a broker, not a market; specify HKStock or USStock" + ) if key in _MARKET_ALIASES: return _MARKET_ALIASES[key] cls._log_limited( @@ -182,7 +185,11 @@ def get_data_source(cls, name: str) -> BaseDataSource: return cls.get_source("Forex") if key in ("usstock", "us_stocks", "stock", "stocks", "ibkr", "alpaca"): return cls.get_source("USStock") - if key in ("futu", "hkstock", "hk_stock"): + if key == "futu": + raise UnsupportedMarketError( + "futu is a broker, not a market; specify HKStock or USStock" + ) + if key in ("hkstock", "hk_stock"): return cls.get_source("HKStock") # Unknown alias — log and default to Crypto (legacy behavior). Callers # should migrate to the explicit `get_source(market)` API. diff --git a/backend_api_python/app/data_sources/futu.py b/backend_api_python/app/data_sources/futu.py index daea59c74..1b76c9671 100644 --- a/backend_api_python/app/data_sources/futu.py +++ b/backend_api_python/app/data_sources/futu.py @@ -198,19 +198,29 @@ def get_kline( truncate=(after_time is None), ) - @staticmethod - def _resample_hours(rows: List[Dict[str, Any]], hours: int = 4) -> List[Dict[str, Any]]: + def _resample_hours( + self, + rows: List[Dict[str, Any]], + hours: int = 4, + ) -> List[Dict[str, Any]]: if not rows: return rows - bucket_sec = int(hours) * 3600 - buckets: Dict[int, Dict[str, Any]] = {} - for row in rows: + from app.services.futu_trading.timezones import market_timezone + + bucket_seconds = max(1, int(hours)) * 3600 + exchange_tz = market_timezone(self.market) + buckets: Dict[tuple[Any, int], Dict[str, Any]] = {} + for row in sorted(rows, key=lambda item: int(item.get("time") or 0)): t = int(row.get("time") or 0) - key = t - (t % bucket_sec) + local_time = datetime.fromtimestamp(t, tz=timezone.utc).astimezone(exchange_tz) + trading_date = local_time.date() + session_open = local_time.replace(hour=9, minute=30, second=0, microsecond=0) + bucket_index = int((local_time - session_open).total_seconds() // bucket_seconds) + key = (trading_date, bucket_index) cur = buckets.get(key) if cur is None: buckets[key] = { - "time": key, + "time": t, "open": float(row["open"]), "high": float(row["high"]), "low": float(row["low"]), @@ -223,4 +233,4 @@ def _resample_hours(rows: List[Dict[str, Any]], hours: int = 4) -> List[Dict[str cur["low"] = min(cur["low"], float(row["low"])) cur["close"] = float(row["close"]) cur["volume"] = float(cur.get("volume") or 0) + float(row.get("volume") or 0) - return [buckets[k] for k in sorted(buckets.keys())] + return sorted(buckets.values(), key=lambda item: item["time"]) diff --git a/backend_api_python/app/openapi/schemas/high_risk.py b/backend_api_python/app/openapi/schemas/high_risk.py index c70b715d4..a0ba091fc 100644 --- a/backend_api_python/app/openapi/schemas/high_risk.py +++ b/backend_api_python/app/openapi/schemas/high_risk.py @@ -86,7 +86,34 @@ def normalize_exchange(self, data, **kwargs): @validates_schema def validate_exchange_secret(self, data, **kwargs): - if str(data.get("exchange_id") or "").lower() in ("ibkr", "futu"): + exchange_id = str(data.get("exchange_id") or "").lower() + if exchange_id == "futu": + trade_market = str(data.get("trade_market") or data.get("tradeMarket") or "").strip() + market_category = str(data.get("market_category") or "").strip() + if not trade_market and not market_category: + raise ValidationError( + "trade_market or market_category is required for Futu", + field_name="trade_market", + ) + from app.services.futu_trading.config import normalize_trade_market + + normalized_trade_market = normalize_trade_market( + trade_market, + market_category=market_category, + ) + expected = {"HKStock": "HK", "USStock": "US"}.get(market_category) + if normalized_trade_market not in {"HK", "US"} or (market_category and not expected): + raise ValidationError( + "Futu market must be HK/HKStock or US/USStock", + field_name="trade_market", + ) + if expected and normalized_trade_market != expected: + raise ValidationError( + "trade_market does not match market_category", + field_name="trade_market", + ) + return + if exchange_id == "ibkr": return if not (data.get("api_key") or data.get("apiKey")): raise ValidationError("api_key is required", field_name="api_key") diff --git a/backend_api_python/app/routes/credentials.py b/backend_api_python/app/routes/credentials.py index 71bc5fade..924f83891 100644 --- a/backend_api_python/app/routes/credentials.py +++ b/backend_api_python/app/routes/credentials.py @@ -259,17 +259,25 @@ def test_credential(data): 'unlock_password': str(data.get('unlock_password') or data.get('unlockPassword') or ''), } client = create_client(config, market_type='spot') - probe = client.probe_permissions() if hasattr(client, 'probe_permissions') else {} - return jsonify({ - 'code': 1, - 'msg': 'CREDENTIAL_CONNECTION_OK', - 'data': { - 'environment': trade_env, - 'market_scope': 'spot', - 'probe': probe, - 'status': client.get_connection_status() if hasattr(client, 'get_connection_status') else {}, - }, - }) + try: + probe = client.probe_permissions() if hasattr(client, 'probe_permissions') else {} + return jsonify({ + 'code': 1, + 'msg': 'CREDENTIAL_CONNECTION_OK', + 'data': { + 'environment': trade_env, + 'market_scope': 'spot', + 'probe': probe, + 'status': client.get_connection_status() if hasattr(client, 'get_connection_status') else {}, + }, + }) + finally: + disconnect = getattr(client, 'disconnect', None) + if callable(disconnect): + try: + disconnect() + except Exception: + pass return jsonify({'code': 0, 'msg': 'UNSUPPORTED_EXCHANGE', 'data': None}), 400 except Exception as exc: return jsonify({'code': 0, 'msg': str(exc) or 'CREDENTIAL_CONNECTION_FAILED', 'data': None}), 400 diff --git a/backend_api_python/app/services/exchange_execution.py b/backend_api_python/app/services/exchange_execution.py index b844ebfc6..d81ce28cf 100644 --- a/backend_api_python/app/services/exchange_execution.py +++ b/backend_api_python/app/services/exchange_execution.py @@ -86,7 +86,16 @@ def safe_exchange_config_for_log(cfg: Dict[str, Any]) -> Dict[str, Any]: ) out = redact_partner_attribution(strip_partner_config(cfg)) - for k in ["api_key", "secret_key", "passphrase", "apiKey", "secret", "password"]: + for k in [ + "api_key", + "secret_key", + "passphrase", + "apiKey", + "secret", + "password", + "unlock_password", + "unlockPassword", + ]: if k in out and out.get(k): out[k] = mask_secret(str(out.get(k))) return out diff --git a/backend_api_python/app/services/execution_streams/adapters.py b/backend_api_python/app/services/execution_streams/adapters.py index 2b0d27c0a..f49948238 100644 --- a/backend_api_python/app/services/execution_streams/adapters.py +++ b/backend_api_python/app/services/execution_streams/adapters.py @@ -10,6 +10,7 @@ import ssl import threading import time +from collections import OrderedDict from datetime import datetime, timezone from typing import Any, Callable, Dict, Iterable, List, Optional from urllib.parse import urlencode, urlparse @@ -751,6 +752,11 @@ def __init__( self._client: Any = None self._thread: Optional[threading.Thread] = None self._stop = threading.Event() + self._fill_lock = threading.Lock() + self._deal_ids_by_order: Dict[str, set[str]] = {} + self._deal_quantity_by_order: Dict[str, float] = {} + self._order_cumulative_by_order: Dict[str, float] = {} + self._fill_order_lru: OrderedDict[str, None] = OrderedDict() @property def stream_key(self) -> str: @@ -783,10 +789,40 @@ def stop(self, timeout: float = 5.0) -> bool: return not self.is_alive def _emit_deal(self, payload: Dict[str, Any]) -> None: - for event in parse_futu_deal(payload if isinstance(payload, dict) else {}): - event.credential_id = self.credential_id - event.user_id = self.user_id - self.on_event(event) + if not isinstance(payload, dict): + return + order_id = str(payload.get("order_id") or payload.get("orderId") or "") + deal_id = str( + payload.get("deal_id") + or payload.get("exchange_fill_id") + or payload.get("exec_id") + or "" + ) + order_key = order_id or f"deal:{deal_id}" + try: + quantity = abs(float(payload.get("qty") or payload.get("quantity") or 0.0)) + except (TypeError, ValueError): + quantity = 0.0 + with self._fill_lock: + self._touch_fill_order_unlocked(order_key) + deal_ids = self._deal_ids_by_order.setdefault(order_key, set()) + if deal_id and deal_id in deal_ids: + return + previous_deals = float(self._deal_quantity_by_order.get(order_key) or 0.0) + deal_total = previous_deals + quantity + covered_by_order = float(self._order_cumulative_by_order.get(order_key) or 0.0) + if deal_total <= covered_by_order + 1e-12: + if deal_id: + deal_ids.add(deal_id) + self._deal_quantity_by_order[order_key] = deal_total + return + for event in parse_futu_deal(payload): + event.credential_id = self.credential_id + event.user_id = self.user_id + self.on_event(event) + if deal_id: + deal_ids.add(deal_id) + self._deal_quantity_by_order[order_key] = deal_total def _emit_order(self, payload: Dict[str, Any]) -> None: # Treat order push with dealt_qty as a deal-like update for REST audit gaps. @@ -795,7 +831,31 @@ def _emit_order(self, payload: Dict[str, Any]) -> None: dealt = float(payload.get("dealt_qty") or payload.get("filled") or 0.0) if dealt <= 0: return - self._emit_deal(payload) + order_id = str(payload.get("order_id") or payload.get("orderId") or "") + if not order_id: + return + with self._fill_lock: + self._touch_fill_order_unlocked(order_id) + previous_order = float(self._order_cumulative_by_order.get(order_id) or 0.0) + observed_deals = float(self._deal_quantity_by_order.get(order_id) or 0.0) + if dealt <= max(previous_order, observed_deals) + 1e-12: + self._order_cumulative_by_order[order_id] = max(previous_order, dealt) + return + for event in parse_futu_deal(payload): + event.credential_id = self.credential_id + event.user_id = self.user_id + self.on_event(event) + self._order_cumulative_by_order[order_id] = max(previous_order, dealt) + + def _touch_fill_order_unlocked(self, order_id: str) -> None: + """Bound cross-channel dedupe state for long-running adapters.""" + self._fill_order_lru.pop(order_id, None) + self._fill_order_lru[order_id] = None + while len(self._fill_order_lru) > 4096: + stale_order_id, _ = self._fill_order_lru.popitem(last=False) + self._deal_ids_by_order.pop(stale_order_id, None) + self._deal_quantity_by_order.pop(stale_order_id, None) + self._order_cumulative_by_order.pop(stale_order_id, None) def _run(self) -> None: try: @@ -813,6 +873,11 @@ def _run(self) -> None: except Exception as exc: self.on_state("error", str(exc), False) finally: + if self._client: + try: + self._client.disconnect() + except Exception: + pass self.on_state("disconnected", "", False) diff --git a/backend_api_python/app/services/execution_streams/processor.py b/backend_api_python/app/services/execution_streams/processor.py index a11b5fc72..9ff785750 100644 --- a/backend_api_python/app/services/execution_streams/processor.py +++ b/backend_api_python/app/services/execution_streams/processor.py @@ -121,6 +121,24 @@ def _fee_storage(fees: Dict[str, float]) -> Tuple[float, str]: return 0.0, "MIXED" return 0.0, "" + @staticmethod + def _fill_progress( + *, + previous: float, + durable_recorded: float, + event_qty: float, + cumulative: float, + is_cumulative: bool, + ) -> Tuple[float, float]: + """Return the unrecorded delta and monotonic cumulative target.""" + durable = max(0.0, float(previous), float(durable_recorded)) + if is_cumulative or cumulative > 0: + target = max(durable, max(0.0, float(cumulative))) + return max(0.0, target - durable), target + ledger_ahead = max(0.0, durable - float(previous)) + delta = max(0.0, max(0.0, float(event_qty)) - ledger_ahead) + return delta, durable + delta + def _process_pending_order(self, event: Dict[str, Any], binding: Dict[str, Any]) -> None: pending_id = int(binding.get("pending_order_id") or binding.get("owner_id") or 0) with get_db_connection() as db: @@ -133,20 +151,34 @@ def _process_pending_order(self, event: Dict[str, Any], binding: Dict[str, Any]) pending = dict(pending) payload = self._json(pending.get("payload_json")) previous = float(pending.get("filled") or 0.0) + durable_recorded = previous + if str(event.get("exchange_id") or "").lower() == "futu": + cur.execute( + """ + SELECT COALESCE(SUM(amount), 0) AS recorded + FROM qd_strategy_trades + WHERE pending_order_id = %s + """, + (pending_id,), + ) + trade_row = cur.fetchone() or {} + durable_recorded = max(previous, float(trade_row.get("recorded") or 0.0)) event_qty = max(0.0, float(event.get("quantity") or 0.0)) cumulative = max(0.0, float(event.get("cumulative_quantity") or 0.0)) - if bool(event.get("is_cumulative")) or cumulative > 0: - target = max(previous, cumulative) - delta = max(0.0, target - previous) - else: - delta = event_qty - target = previous + delta + delta, target = self._fill_progress( + previous=previous, + durable_recorded=durable_recorded, + event_qty=event_qty, + cumulative=cumulative, + is_cumulative=bool(event.get("is_cumulative")), + ) price = float(event.get("price") or pending.get("avg_price") or 0.0) previous_avg = float(pending.get("avg_price") or 0.0) + durable_avg = previous_avg or price aggregate_avg = ( - ((previous * previous_avg) + (delta * price)) / target + ((durable_recorded * durable_avg) + (delta * price)) / target if target > 0 and delta > 0 and price > 0 - else previous_avg or price + else durable_avg ) status = str(event.get("order_status") or "") queue_status = "filled" if status == "filled" else "sent" diff --git a/backend_api_python/app/services/execution_streams/supervisor.py b/backend_api_python/app/services/execution_streams/supervisor.py index 7df066c4f..dcc2c5884 100644 --- a/backend_api_python/app/services/execution_streams/supervisor.py +++ b/backend_api_python/app/services/execution_streams/supervisor.py @@ -120,6 +120,7 @@ def is_healthy(self, *, exchange_id: str, credential_id: int, market_type: str) f"{exchange}:{int(credential_id or 0)}:{market}", f"{exchange}:{int(credential_id or 0)}:all", f"{exchange}:{int(credential_id or 0)}:usstock", + f"{exchange}:{int(credential_id or 0)}:stock", ] with self._lock: return any(bool(self._adapters.get(key) and self._adapters[key].connected) for key in keys) @@ -170,9 +171,9 @@ def _reconcile(self) -> None: existing_spec = self._specs.get(key) existing = self._adapters.get(key) if existing and existing_spec == spec: - if not existing.connected: - self._run_rest_catchup_limited(spec) - continue + if existing.connected: + continue + self._run_rest_catchup_limited(spec) if existing: if not existing.stop(): logger.warning( @@ -243,6 +244,8 @@ def _stream_key_for_event(event: ExecutionEvent) -> str: market = "all" elif event.exchange_id in {"alpaca", "ibkr"}: market = "usstock" + elif event.exchange_id == "futu": + market = "stock" else: market = event.market_type return f"{event.exchange_id}:{event.credential_id}:{market}" @@ -293,7 +296,7 @@ def _discover_specs(self) -> List[StreamSpec]: crypto = supported_crypto_exchange_ids() for row in credentials: exchange = str(row.get("exchange_id") or "").strip().lower() - if exchange not in crypto | {"alpaca", "ibkr"}: + if exchange not in crypto | {"alpaca", "ibkr", "futu"}: continue try: plain = decrypt_credential_blob(row.get("encrypted_config")) @@ -314,6 +317,8 @@ def _discover_specs(self) -> List[StreamSpec]: scope = str(config.get("market_scope") or config.get("marketScope") or "both").lower() if exchange in {"alpaca", "ibkr"}: markets = ["usstock"] + elif exchange == "futu": + markets = ["stock"] elif exchange in {"okx", "bybit"}: markets = ["all"] elif scope == "spot": @@ -364,7 +369,7 @@ def _credential_rows(credential_ids: Iterable[int]) -> List[Dict[str, Any]]: """, ( active_ids, - sorted(supported_crypto_exchange_ids() | {"alpaca", "ibkr"}), + sorted(supported_crypto_exchange_ids() | {"alpaca", "ibkr", "futu"}), ), ) rows = [dict(row) for row in (cur.fetchall() or [])] diff --git a/backend_api_python/app/services/pending_order_worker.py b/backend_api_python/app/services/pending_order_worker.py index 8a5972fd1..7a076dcdf 100644 --- a/backend_api_python/app/services/pending_order_worker.py +++ b/backend_api_python/app/services/pending_order_worker.py @@ -914,6 +914,7 @@ def _sync_claimed_futu_order(self, row: Dict[str, Any]) -> bool: order_id, cumulative_filled, fail_closed=True, + include_stream_events=True, ) if delta > FUTU_FILL_DELTA_EPSILON and cumulative_avg > 0: delta_avg = cumulative_avg @@ -1113,7 +1114,7 @@ def _fetch_live_sent_orders(self, limit: int = 50) -> List[Dict[str, Any]]: dispatch_note = 'live_fill_sync:requeued_stale_sync', updated_at = NOW() WHERE status = 'syncing' - AND LOWER(COALESCE(exchange_id, '')) <> 'alpaca' + AND LOWER(COALESCE(exchange_id, '')) NOT IN ('alpaca', 'futu') AND updated_at < NOW() - (%s * INTERVAL '1 second') """, (stale_sec,), @@ -1142,7 +1143,7 @@ def _fetch_live_sent_orders(self, limit: int = 50) -> List[Dict[str, Any]]: ) ) ) - AND LOWER(COALESCE(exchange_id, '')) <> 'alpaca' + AND LOWER(COALESCE(exchange_id, '')) NOT IN ('alpaca', 'futu') AND COALESCE(exchange_id, '') <> '' AND COALESCE(exchange_order_id, '') <> '' ORDER BY sent_at ASC NULLS FIRST, id ASC @@ -1188,7 +1189,7 @@ def _claim_live_sent_order(self, order_id: int) -> Optional[Dict[str, Any]]: ) ) ) - AND LOWER(COALESCE(exchange_id, '')) <> 'alpaca' + AND LOWER(COALESCE(exchange_id, '')) NOT IN ('alpaca', 'futu') AND COALESCE(exchange_order_id, '') <> '' RETURNING * """, @@ -2058,17 +2059,23 @@ def _execute_live_order(self, *, order_id: int, order_row: Dict[str, Any], paylo pass if FutuClient is not None and isinstance(client, FutuClient): - self._execute_futu_order( - order_id=order_id, - order_row=order_row, - payload=payload, - client=client, - strategy_id=strategy_id, - exchange_config=exchange_config, - market_category=market_category, - _notify_live_best_effort=_notify_live_best_effort, - _console_print=_console_print, - ) + try: + self._execute_futu_order( + order_id=order_id, + order_row=order_row, + payload=payload, + client=client, + strategy_id=strategy_id, + exchange_config=exchange_config, + market_category=market_category, + _notify_live_best_effort=_notify_live_best_effort, + _console_print=_console_print, + ) + finally: + try: + client.disconnect() + except Exception: + pass return client_oid = make_client_order_id(exchange_id=exchange_id, strategy_id=strategy_id, order_id=order_id) @@ -3258,7 +3265,11 @@ def _execute_futu_order( ) try: - recordable_filled = self._unrecorded_pending_fill(order_id, filled) + recordable_filled = self._unrecorded_pending_fill( + order_id, + filled, + include_stream_events=True, + ) if recordable_filled > 0 and avg_price > 0: profit, matched_entry = persist_strategy_fill( strategy_id=int(strategy_id), @@ -3396,6 +3407,7 @@ def _unrecorded_pending_fill( cumulative_filled: float, *, fail_closed: bool = False, + include_stream_events: bool = False, ) -> float: """Prevent the immediate REST result racing the private stream event.""" if int(order_id or 0) <= 0: @@ -3412,8 +3424,42 @@ def _unrecorded_pending_fill( (int(order_id),), ) row = cur.fetchone() or {} + stream_observed = 0.0 + if include_stream_events: + cur.execute( + """ + SELECT + COALESCE(MAX( + CASE WHEN event.is_cumulative + THEN event.cumulative_quantity ELSE 0 END + ), 0) AS cumulative, + COALESCE(SUM( + CASE WHEN event.is_cumulative + THEN 0 ELSE event.quantity END + ), 0) AS incremental + FROM qd_live_order_bindings binding + JOIN qd_execution_events event + ON event.credential_id = binding.credential_id + AND event.exchange_id = binding.exchange_id + AND ( + (binding.exchange_order_id <> '' + AND event.exchange_order_id = binding.exchange_order_id) + OR (binding.client_order_id <> '' + AND event.client_order_id = binding.client_order_id) + ) + WHERE binding.pending_order_id = %s + AND binding.exchange_id = 'futu' + """, + (int(order_id),), + ) + event_row = cur.fetchone() or {} + stream_observed = max( + float(event_row.get("cumulative") or 0.0), + float(event_row.get("incremental") or 0.0), + ) cur.close() - return max(0.0, float(cumulative_filled or 0.0) - float(row.get("recorded") or 0.0)) + already_recorded = max(float(row.get("recorded") or 0.0), stream_observed) + return max(0.0, float(cumulative_filled or 0.0) - already_recorded) except Exception: if fail_closed: raise diff --git a/backend_api_python/app/services/trading_executor.py b/backend_api_python/app/services/trading_executor.py index 8af061402..838f6a0bc 100644 --- a/backend_api_python/app/services/trading_executor.py +++ b/backend_api_python/app/services/trading_executor.py @@ -357,6 +357,21 @@ def _run_strategy_loop(self, strategy_id: int) -> None: account_exchange = str( exchange_config.get("exchange_id") or exchange_config.get("exchangeId") or "" ).strip().lower() + if execution_mode == "live" and account_exchange == "futu": + from app.services.futu_trading.config import normalize_trade_market + + credential_market = normalize_trade_market( + exchange_config.get("trade_market") or exchange_config.get("tradeMarket"), + market_category=str(exchange_config.get("market_category") or ""), + ) + strategy_markets = { + str(member.get("market") or "") + for member in candidates + if str(member.get("market") or "") in {"HKStock", "USStock"} + } + expected_markets = {"HKStock": "HK", "USStock": "US"} + if any(expected_markets[market] != credential_market for market in strategy_markets): + raise RuntimeError("strategyV2.futuCredentialMarketMismatch") if execution_mode == "live" and account_exchange: for member in candidates: member_market = str(member.get("market") or "") @@ -512,8 +527,9 @@ def runtime_prices() -> dict[str, float]: "age_ms": int(snapshot.get("age_ms") or 0), "connected": bool(snapshot.get("connected")), }) - prices = dict(snapshot.get("prices") or {}) - return prices or rest_runtime_prices() + # A Futu live account must fail closed when OpenD quotes + # are stale; public REST prices are not executable prices. + return dict(snapshot.get("prices") or {}) else: from app.services.market_price_stream import PublicMarketPriceFeed diff --git a/backend_api_python/requirements.lock b/backend_api_python/requirements.lock index 361ec3bac..ef4734616 100644 --- a/backend_api_python/requirements.lock +++ b/backend_api_python/requirements.lock @@ -42,6 +42,7 @@ flask-cors==6.0.5 flask-smorest==0.47.0 frozenlist==1.8.0 fsspec==2026.6.0 +futu-api==10.09.6908 gunicorn==26.0.0 h11==0.16.0 hf-xet==1.5.1 diff --git a/backend_api_python/scripts/backend_quality_baseline.json b/backend_api_python/scripts/backend_quality_baseline.json index c45d4246a..1eaed5d2f 100644 --- a/backend_api_python/scripts/backend_quality_baseline.json +++ b/backend_api_python/scripts/backend_quality_baseline.json @@ -1,14 +1,14 @@ { "max_file_lines": { "app/services/trading_executor.py": 4455, - "app/services/pending_order_worker.py": 2938, + "app/services/pending_order_worker.py": 3554, "app/services/fast_analysis.py": 1848, "app/routes/strategy.py": 1247, "app/routes/quick_trade.py": 1795 }, "max_function_lines": { "app/services/trading_executor.py::_run_strategy_loop": 1176, - "app/services/pending_order_worker.py::_execute_live_order": 806, + "app/services/pending_order_worker.py::_execute_live_order": 834, "app/services/fast_analysis.py::analyze": 585, "app/routes/strategy.py::ai_generate_strategy": 307, "app/routes/indicator.py::ai_generate": 499 diff --git a/backend_api_python/tests/test_execution_stream_adapters.py b/backend_api_python/tests/test_execution_stream_adapters.py index 8648b7994..d81803c36 100644 --- a/backend_api_python/tests/test_execution_stream_adapters.py +++ b/backend_api_python/tests/test_execution_stream_adapters.py @@ -9,6 +9,7 @@ AlpacaExecutionAdapter, BitgetExecutionAdapter, BybitExecutionAdapter, + FutuExecutionAdapter, GateExecutionAdapter, HtxExecutionAdapter, OkxExecutionAdapter, @@ -57,6 +58,86 @@ def test_adapter_registry_covers_six_exchanges_and_three_brokers(): } +def _futu_adapter(events): + return FutuExecutionAdapter( + credential_id=9, + user_id=3, + config={}, + on_event=events.append, + on_state=lambda *_args: None, + ) + + +def test_futu_order_then_deal_push_is_recorded_once(): + events = [] + adapter = _futu_adapter(events) + order = { + "code": "HK.00700", + "order_id": "OID-1", + "dealt_avg_price": 350, + "trd_side": "BUY", + "order_status": "FILLED_PART", + "updated_time": "2026-08-10 10:00:00", + } + + adapter._emit_order({**order, "dealt_qty": 10}) + adapter._emit_deal({**order, "deal_id": "D-1", "qty": 10, "price": 350}) + adapter._emit_order({**order, "dealt_qty": 20}) + adapter._emit_deal({**order, "deal_id": "D-2", "qty": 10, "price": 351}) + adapter._emit_deal({**order, "deal_id": "D-2", "qty": 10, "price": 351}) + + assert [event.cumulative_quantity for event in events] == [10, 20] + + +def test_futu_deal_then_order_push_is_recorded_once(): + events = [] + adapter = _futu_adapter(events) + base = { + "code": "US.AAPL", + "order_id": "OID-2", + "trd_side": "BUY", + "order_status": "FILLED_PART", + "updated_time": "2026-08-10 10:00:00", + } + + adapter._emit_deal({**base, "deal_id": "D-1", "qty": 10, "price": 220}) + adapter._emit_order({**base, "dealt_qty": 10, "dealt_avg_price": 220}) + adapter._emit_deal({**base, "deal_id": "D-2", "qty": 10, "price": 221}) + adapter._emit_order({**base, "dealt_qty": 20, "dealt_avg_price": 220.5}) + + assert [event.exchange_fill_id for event in events] == ["D-1", "D-2"] + + +def test_futu_failed_order_ingest_does_not_suppress_authoritative_deal(): + def fail_ingest(_event): + raise RuntimeError("database unavailable") + + adapter = FutuExecutionAdapter( + credential_id=9, + user_id=3, + config={}, + on_event=fail_ingest, + on_state=lambda *_args: None, + ) + order = { + "code": "HK.00700", + "order_id": "OID-3", + "dealt_qty": 10, + "dealt_avg_price": 350, + "trd_side": "BUY", + "order_status": "FILLED_PART", + "updated_time": "2026-08-10 10:00:00", + } + with pytest.raises(RuntimeError, match="database unavailable"): + adapter._emit_order(order) + + events = [] + adapter.on_event = events.append + adapter._emit_deal({**order, "deal_id": "D-3", "qty": 10, "price": 350}) + + assert [event.exchange_fill_id for event in events] == ["D-3"] + + @pytest.mark.parametrize( "adapter_cls", (OkxExecutionAdapter, BybitExecutionAdapter, BitgetExecutionAdapter, HtxExecutionAdapter, AlpacaExecutionAdapter), diff --git a/backend_api_python/tests/test_execution_stream_processor.py b/backend_api_python/tests/test_execution_stream_processor.py new file mode 100644 index 000000000..d3996fbe8 --- /dev/null +++ b/backend_api_python/tests/test_execution_stream_processor.py @@ -0,0 +1,27 @@ +from app.services.execution_streams.processor import ExecutionEventProcessor + + +def test_futu_cumulative_event_excludes_fill_already_in_trade_ledger(): + delta, target = ExecutionEventProcessor._fill_progress( + previous=0, + durable_recorded=10, + event_qty=0, + cumulative=10, + is_cumulative=True, + ) + + assert delta == 0 + assert target == 10 + + +def test_futu_incremental_event_excludes_ledger_ahead_quantity(): + delta, target = ExecutionEventProcessor._fill_progress( + previous=2, + durable_recorded=5, + event_qty=4, + cumulative=0, + is_cumulative=False, + ) + + assert delta == 1 + assert target == 6 diff --git a/backend_api_python/tests/test_execution_stream_supervisor.py b/backend_api_python/tests/test_execution_stream_supervisor.py index 40daf22c0..2fc92cb83 100644 --- a/backend_api_python/tests/test_execution_stream_supervisor.py +++ b/backend_api_python/tests/test_execution_stream_supervisor.py @@ -3,7 +3,7 @@ import json from app.services.execution_streams import supervisor as supervisor_module -from app.services.execution_streams.supervisor import ExecutionStreamSupervisor +from app.services.execution_streams.supervisor import ExecutionStreamSupervisor, StreamSpec def test_stream_discovery_only_loads_credentials_used_by_active_work(monkeypatch): @@ -74,6 +74,80 @@ def test_stream_discovery_applies_adapter_cap_and_uses_rest_for_overflow(monkeyp assert specs[0].key == "binance:7:spot" +def test_stream_discovery_registers_futu_with_canonical_stock_key(monkeypatch): + service = ExecutionStreamSupervisor() + monkeypatch.setattr(service, "_symbols_by_credential", lambda: {9: {"AAPL"}}) + monkeypatch.setattr( + service, + "_credential_rows", + lambda _ids: [{ + "id": 9, + "user_id": 3, + "exchange_id": "futu", + "encrypted_config": "encrypted", + }], + ) + monkeypatch.setattr( + supervisor_module, + "decrypt_credential_blob", + lambda _value: json.dumps({"trade_market": "US"}), + ) + + specs = service._discover_specs() + + assert [spec.key for spec in specs] == ["futu:9:stock"] + assert specs[0].market_type == "stock" + assert service._stream_key_for_event( + type("Event", (), { + "exchange_id": "futu", + "credential_id": 9, + "market_type": "USStock", + })() + ) == "futu:9:stock" + + +def test_reconcile_replaces_disconnected_adapter_with_unchanged_spec(monkeypatch): + service = ExecutionStreamSupervisor() + spec = StreamSpec( + key="futu:9:stock", + credential_id=9, + user_id=3, + exchange_id="futu", + market_type="stock", + config_json="{}", + symbols=("AAPL",), + ) + + class OldAdapter: + connected = False + + def stop(self): + return True + + created = [] + + class NewAdapter: + connected = False + + def __init__(self, **_kwargs): + created.append(self) + + def start(self): + self.connected = True + + service._adapters[spec.key] = OldAdapter() + service._specs[spec.key] = spec + monkeypatch.setattr(service, "_discover_specs", lambda: [spec]) + monkeypatch.setattr(service, "_run_rest_catchup_limited", lambda *_args, **_kwargs: None) + monkeypatch.setitem(supervisor_module.ADAPTERS, "futu", NewAdapter) + + service._reconcile() + + assert created + assert service._adapters[spec.key] is created[0] + assert created[0].connected + + def test_active_stream_query_excludes_stopped_and_signal_strategies(monkeypatch): executed: list[str] = [] diff --git a/backend_api_python/tests/test_futu_client_contract.py b/backend_api_python/tests/test_futu_client_contract.py index 79125c058..d975a685d 100644 --- a/backend_api_python/tests/test_futu_client_contract.py +++ b/backend_api_python/tests/test_futu_client_contract.py @@ -55,6 +55,14 @@ class AuType: class SubType: QUOTE = "QUOTE" + class TradeOrderHandlerBase: + def on_recv_rsp(self, rsp_pb): + return rsp_pb + + class TradeDealHandlerBase: + def on_recv_rsp(self, rsp_pb): + return rsp_pb + def _client_with_mocks(): cfg = FutuConfig(host="127.0.0.1", port=11111, trade_env="demo", trade_market="HK") @@ -73,6 +81,18 @@ def _client_with_mocks(): return client, quote, trade +@patch("app.services.futu_trading.client._ensure_futu", return_value=_FakeFT) +def test_start_push_registers_order_and_deal_handlers(_ensure): + client, _quote, trade = _client_with_mocks() + + assert client.start_push() + + handlers = [call.args[0] for call in trade.set_handler.call_args_list] + assert len(handlers) == 2 + assert any(isinstance(handler, _FakeFT.TradeOrderHandlerBase) for handler in handlers) + assert any(isinstance(handler, _FakeFT.TradeDealHandlerBase) for handler in handlers) + + @patch("app.services.futu_trading.client._ensure_futu", return_value=_FakeFT) def test_place_market_order_success(_ensure): client, quote, trade = _client_with_mocks() diff --git a/backend_api_python/tests/test_futu_config.py b/backend_api_python/tests/test_futu_config.py index 4fa4ba5cd..6b541c812 100644 --- a/backend_api_python/tests/test_futu_config.py +++ b/backend_api_python/tests/test_futu_config.py @@ -1,5 +1,8 @@ import pytest +from marshmallow import ValidationError +from app.openapi.schemas.high_risk import CredentialCreateRequestSchema +from app.services.exchange_execution import safe_exchange_config_for_log from app.services.futu_trading.config import ( FutuConfig, config_from_exchange_config, @@ -86,3 +89,33 @@ def test_validate_opend_host_rejects_remote_by_default(monkeypatch): def test_validate_opend_host_allows_remote_only_with_explicit_opt_in(monkeypatch): monkeypatch.setenv("FUTU_ALLOW_REMOTE_OPEND", "true") assert validate_opend_host("203.0.113.8") == "203.0.113.8" + + +def test_futu_credential_requires_and_cross_validates_market(): + schema = CredentialCreateRequestSchema() + with pytest.raises(ValidationError, match="trade_market"): + schema.load({"exchange_id": "futu"}) + with pytest.raises(ValidationError, match="does not match"): + schema.load({ + "exchange_id": "futu", + "trade_market": "US", + "market_category": "HKStock", + }) + with pytest.raises(ValidationError, match="HK/HKStock"): + schema.load({"exchange_id": "futu", "trade_market": "unsupported"}) + loaded = schema.load({ + "exchange_id": "futu", + "trade_market": "US", + "market_category": "USStock", + }) + assert loaded["trade_market"] == "US" + + +def test_safe_exchange_config_masks_futu_unlock_password(): + safe = safe_exchange_config_for_log({ + "exchange_id": "futu", + "unlock_password": "super-secret-password", + "unlockPassword": "second-secret-password", + }) + assert "super-secret-password" not in str(safe) + assert "second-secret-password" not in str(safe) diff --git a/backend_api_python/tests/test_futu_pending_order_sync.py b/backend_api_python/tests/test_futu_pending_order_sync.py index e65eec0d4..8194d283f 100644 --- a/backend_api_python/tests/test_futu_pending_order_sync.py +++ b/backend_api_python/tests/test_futu_pending_order_sync.py @@ -143,7 +143,32 @@ def test_retry_uses_durable_trade_ledger_to_avoid_duplicate_fill(monkeypatch): 20, 5.0, fail_closed=True, + include_stream_events=True, ) persist.assert_not_called() update = worker._update_futu_sent_order_snapshot.call_args.kwargs assert update["filled"] == 5 + + +def test_futu_rest_sync_accounts_for_ingested_stream_events(monkeypatch): + cursor = MagicMock() + cursor.fetchone.side_effect = [ + {"recorded": 2}, + {"cumulative": 5, "incremental": 3}, + ] + connection = MagicMock() + connection.cursor.return_value = cursor + context = MagicMock() + context.__enter__.return_value = connection + context.__exit__.return_value = False + monkeypatch.setattr(worker_module, "get_db_connection", lambda: context) + + delta = PendingOrderWorker._unrecorded_pending_fill( + 20, + 6, + fail_closed=True, + include_stream_events=True, + ) + + assert delta == 1 + assert cursor.execute.call_count == 2 diff --git a/backend_api_python/tests/test_futu_quote_client.py b/backend_api_python/tests/test_futu_quote_client.py index 083052f23..1609effa6 100644 --- a/backend_api_python/tests/test_futu_quote_client.py +++ b/backend_api_python/tests/test_futu_quote_client.py @@ -1,9 +1,10 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock import pytest from app.data_sources.factory import DataSourceFactory +from app.data_sources.errors import UnsupportedMarketError from app.data_sources.futu import FutuDataSource from app.services.futu_trading.config import FutuConfig from app.services.futu_trading.quote_client import FutuQuoteClient @@ -126,3 +127,36 @@ def test_quote_client_rejects_remote_host_before_loading_sdk(monkeypatch): assert client.connect() is False ensure_futu.assert_not_called() + + +def test_four_hour_resampling_aligns_to_first_exchange_session_bar(): + source = FutuDataSource(market="USStock") + start = datetime(2026, 1, 5, 14, 30, tzinfo=timezone.utc) + offsets = [0, 1, 4] # Missing source bars must not shift the 13:30 bucket. + rows = [ + { + "time": int((start + timedelta(hours=hour_offset)).timestamp()), + "open": 100 + index, + "high": 101 + index, + "low": 99 + index, + "close": 100.5 + index, + "volume": 10, + } + for index, hour_offset in enumerate(offsets) + ] + + resampled = source._resample_hours(rows, hours=4) + + assert len(resampled) == 2 + assert resampled[0]["time"] == rows[0]["time"] + assert resampled[0]["open"] == 100 + assert resampled[0]["close"] == 101.5 + assert resampled[0]["volume"] == 20 + assert resampled[1]["time"] == rows[2]["time"] + + +def test_futu_broker_alias_requires_explicit_market(): + with pytest.raises(UnsupportedMarketError, match="HKStock or USStock"): + DataSourceFactory.normalize_market("futu") + with pytest.raises(UnsupportedMarketError, match="HKStock or USStock"): + DataSourceFactory.get_data_source("futu") diff --git a/backend_api_python/tests/test_pending_order_worker_live_sync.py b/backend_api_python/tests/test_pending_order_worker_live_sync.py index 3c41cfd89..2501cead4 100644 --- a/backend_api_python/tests/test_pending_order_worker_live_sync.py +++ b/backend_api_python/tests/test_pending_order_worker_live_sync.py @@ -65,6 +65,7 @@ def execute(self, sql, params): assert "status = 'syncing'" in sql assert "COALESCE(filled, 0) <= 0" in sql assert "live_fee_sync:retry" in sql + assert "NOT IN ('alpaca', 'futu')" in sql assert params == (41, 300) def fetchone(self): @@ -93,6 +94,42 @@ def commit(self): assert worker._claim_live_sent_order(41) == {"id": 41, "status": "syncing"} +def test_live_sent_fetch_excludes_dedicated_broker_sync_queues(monkeypatch): + statements = [] + + class Cursor: + def execute(self, sql, params): + statements.append((sql, params)) + + def fetchall(self): + return [] + + def close(self): + return None + + class Database: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def cursor(self): + return Cursor() + + def commit(self): + return None + + monkeypatch.setattr(worker_module, "get_db_connection", lambda: Database()) + worker = object.__new__(worker_module.PendingOrderWorker) + worker._stale_processing_sec = 90 + worker._fee_sync_retry_sec = 300 + + assert worker._fetch_live_sent_orders() == [] + assert len(statements) == 2 + assert all("NOT IN ('alpaca', 'futu')" in sql for sql, _params in statements) + + def test_live_sent_sync_finalizes_after_restart_without_duplicate_fill(monkeypatch): row = _row(filled=1.0, avg_price=101.0) worker, snapshots, persisted = _worker( diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 551825c90..2de1d8d8f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -418,6 +418,62 @@ components: type: string default: '' maxLength: 128 + futu_host: + type: string + default: 127.0.0.1 + maxLength: 255 + futu_port: + type: integer + default: 11111 + minimum: 1 + maximum: 65535 + host: + type: string + default: '' + maxLength: 255 + port: + type: integer + default: 0 + minimum: 0 + maximum: 65535 + trade_env: + type: string + default: '' + maxLength: 32 + trade_market: + type: string + default: '' + maxLength: 32 + tradeMarket: + type: string + default: '' + maxLength: 32 + security_firm: + type: string + default: '' + maxLength: 64 + securityFirm: + type: string + default: '' + maxLength: 64 + acc_id: + type: integer + default: 0 + accId: + type: integer + default: 0 + unlock_password: + type: string + default: '' + maxLength: 128 + unlockPassword: + type: string + default: '' + maxLength: 128 + market_category: + type: string + default: '' + maxLength: 32 required: - exchange_id additionalProperties: false @@ -598,8 +654,8 @@ components: application/json: schema: $ref: '#/components/schemas/Error' - UNPROCESSABLE_ENTITY: - description: Unprocessable Entity + UNPROCESSABLE_CONTENT: + description: Unprocessable Content content: application/json: schema: @@ -651,6 +707,8 @@ tags: description: Interactive Brokers adapter (Internal) - name: Alpaca description: Alpaca adapter (Internal) +- name: Futu + description: Futu OpenAPI / OpenD adapter (Internal) servers: - url: http://localhost:5000 description: Backend direct (python run.py) @@ -826,7 +884,7 @@ paths: post: responses: '422': - $ref: '#/components/responses/UNPROCESSABLE_ENTITY' + $ref: '#/components/responses/UNPROCESSABLE_CONTENT' '200': description: OK content: @@ -936,7 +994,7 @@ paths: post: responses: '422': - $ref: '#/components/responses/UNPROCESSABLE_ENTITY' + $ref: '#/components/responses/UNPROCESSABLE_CONTENT' '200': description: OK content: @@ -975,7 +1033,7 @@ paths: post: responses: '422': - $ref: '#/components/responses/UNPROCESSABLE_ENTITY' + $ref: '#/components/responses/UNPROCESSABLE_CONTENT' '400': description: Bad Request content: @@ -1010,7 +1068,7 @@ paths: post: responses: '422': - $ref: '#/components/responses/UNPROCESSABLE_ENTITY' + $ref: '#/components/responses/UNPROCESSABLE_CONTENT' '400': description: Bad Request content: @@ -3368,6 +3426,27 @@ paths: - Indicator operationId: getApiIndicatorGetIndicatorParams x-visibility: public + /api/indicator/chart-preview: + post: + responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HumanErrorEnvelope' + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HumanSuccessEnvelope' + summary: Return candles, indicator plots, and visual signals without creating + a task. + tags: + - Indicator + operationId: postApiIndicatorChartPreview + x-visibility: public /api/indicator/aiGenerate: post: responses: @@ -4255,6 +4334,46 @@ paths: - Strategy operationId: getApiStrategiesPositions x-visibility: internal + /api/strategies/position-ownership: + get: + responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HumanErrorEnvelope' + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HumanSuccessEnvelope' + tags: + - Strategy + operationId: getApiStrategiesPositionOwnership + summary: Get Api Strategies Position Ownership + x-visibility: internal + /api/strategies/position-ownership/repair: + post: + responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HumanErrorEnvelope' + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HumanSuccessEnvelope' + tags: + - Strategy + operationId: postApiStrategiesPositionOwnershipRepair + summary: Post Api Strategies Position Ownership Repair + x-visibility: internal /api/strategies/review-report: post: responses: @@ -4558,12 +4677,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HumanSuccessEnvelope' - summary: Whether IBKR (local TWS or IB Gateway) may be configured on this deployment. + summary: Whether IBKR / Futu (local TWS, IB Gateway, or FutuOpenD) may be configured. tags: - Credentials operationId: getApiCredentialsDesktopBrokersPolicy - description: 'Whether IBKR (local TWS or IB Gateway) may be configured on this - deployment. + description: 'Whether IBKR / Futu (local TWS, IB Gateway, or FutuOpenD) may + be configured. Frontend uses this to disable options and show guidance before save/test.' x-visibility: private @@ -4616,7 +4735,7 @@ paths: post: responses: '422': - $ref: '#/components/responses/UNPROCESSABLE_ENTITY' + $ref: '#/components/responses/UNPROCESSABLE_CONTENT' '400': description: Bad Request content: @@ -4644,7 +4763,7 @@ paths: post: responses: '422': - $ref: '#/components/responses/UNPROCESSABLE_ENTITY' + $ref: '#/components/responses/UNPROCESSABLE_CONTENT' '200': description: OK content: @@ -4664,7 +4783,7 @@ paths: schema: $ref: '#/components/schemas/CredentialCreateRequest' summary: Create a new credential for the current user. - description: Supports crypto exchanges, IBKR (US stocks), and Alpaca. + description: Supports crypto exchanges, IBKR (US stocks), Alpaca, and Futu (HK/US). tags: - Credentials operationId: postApiCredentialsCreate @@ -4721,7 +4840,7 @@ paths: required: true responses: '422': - $ref: '#/components/responses/UNPROCESSABLE_ENTITY' + $ref: '#/components/responses/UNPROCESSABLE_CONTENT' '400': description: Bad Request content: @@ -5848,6 +5967,170 @@ paths: schema: type: string minLength: 1 + /api/futu/status: + get: + responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HumanErrorEnvelope' + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HumanSuccessEnvelope' + summary: Get FutuOpenD connection status for the current user session. + tags: + - Futu + operationId: getApiFutuStatus + x-visibility: internal + /api/futu/connect: + post: + responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HumanErrorEnvelope' + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HumanSuccessEnvelope' + summary: Connect to FutuOpenD (diagnostics only — no orders placed). + description: 'Body: host, port, trade_env (demo|live), trade_market (HK|US), + + security_firm, acc_id, unlock_password (optional).' + tags: + - Futu + operationId: postApiFutuConnect + x-visibility: internal + /api/futu/disconnect: + post: + responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HumanErrorEnvelope' + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HumanSuccessEnvelope' + tags: + - Futu + operationId: postApiFutuDisconnect + summary: Post Api Futu Disconnect + x-visibility: internal + /api/futu/probe: + post: + responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HumanErrorEnvelope' + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HumanSuccessEnvelope' + summary: Connect (or reuse session) and return permissions / account probe (no + orders). + tags: + - Futu + operationId: postApiFutuProbe + x-visibility: internal + /api/futu/account: + get: + responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HumanErrorEnvelope' + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HumanSuccessEnvelope' + tags: + - Futu + operationId: getApiFutuAccount + summary: Get Api Futu Account + x-visibility: internal + /api/futu/positions: + get: + responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HumanErrorEnvelope' + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HumanSuccessEnvelope' + tags: + - Futu + operationId: getApiFutuPositions + summary: Get Api Futu Positions + x-visibility: internal + /api/futu/orders: + get: + responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HumanErrorEnvelope' + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HumanSuccessEnvelope' + tags: + - Futu + operationId: getApiFutuOrders + summary: Get Api Futu Orders + x-visibility: internal + /api/futu/quote: + get: + responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HumanErrorEnvelope' + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HumanSuccessEnvelope' + summary: 'Get a snapshot quote (query: symbol, marketType=HKStock|USStock).' + tags: + - Futu + operationId: getApiFutuQuote + x-visibility: internal /api/global-market/overview: get: responses: @@ -6830,7 +7113,7 @@ paths: post: responses: '422': - $ref: '#/components/responses/UNPROCESSABLE_ENTITY' + $ref: '#/components/responses/UNPROCESSABLE_CONTENT' '400': description: Bad Request content: @@ -6895,7 +7178,7 @@ paths: post: responses: '422': - $ref: '#/components/responses/UNPROCESSABLE_ENTITY' + $ref: '#/components/responses/UNPROCESSABLE_CONTENT' '400': description: Bad Request content: @@ -6983,7 +7266,7 @@ paths: post: responses: '422': - $ref: '#/components/responses/UNPROCESSABLE_ENTITY' + $ref: '#/components/responses/UNPROCESSABLE_CONTENT' '400': description: Bad Request content: From 8e2dbaba2cd3eceeaf34a80ad67b7cee85ea00f1 Mon Sep 17 00:00:00 2001 From: dl <1909703981@qq.com> Date: Thu, 13 Aug 2026 02:09:07 +0800 Subject: [PATCH 3/3] feat: add secure FutuOpenD Docker bridge support Keep OpenD loopback-only for WSL deployments while preventing relay, SDK log, and reconciliation connection leaks. --- .../services/execution_streams/repository.py | 6 +- .../app/services/pending_order_worker.py | 60 +++++++++++++++++-- backend_api_python/docker-entrypoint.sh | 6 ++ backend_api_python/scripts/opend_relay.py | 55 +++++++++++++++++ .../tests/test_execution_stream_repository.py | 45 ++++++++++++++ .../tests/test_futu_pending_order_sync.py | 28 ++++++++- docker-compose.yml | 30 ++++++++++ 7 files changed, 223 insertions(+), 7 deletions(-) create mode 100644 backend_api_python/scripts/opend_relay.py create mode 100644 backend_api_python/tests/test_execution_stream_repository.py diff --git a/backend_api_python/app/services/execution_streams/repository.py b/backend_api_python/app/services/execution_streams/repository.py index c2f11c8f3..e696246cb 100644 --- a/backend_api_python/app/services/execution_streams/repository.py +++ b/backend_api_python/app/services/execution_streams/repository.py @@ -294,7 +294,11 @@ def _discover_legacy_binding(self, event: Dict[str, Any]) -> Optional[Dict[str, self.register_binding( credential_id=int(data.get("credential_id") or credential_id), exchange_id=exchange_id, - market_type=str(data.get("market_type") or market_type), + # A broker push uses its canonical stock market (for example + # ``hkstock``), while legacy pending rows may store execution + # product type ``spot``. Register with the event market first so + # the recursive lookup below can actually match the new binding. + market_type=str(market_type or data.get("market_type")), owner_type=str(data.get("owner_type")), owner_id=int(data.get("owner_id") or 0), user_id=int(data.get("user_id") or 1), diff --git a/backend_api_python/app/services/pending_order_worker.py b/backend_api_python/app/services/pending_order_worker.py index 7a076dcdf..da6ccf5f5 100644 --- a/backend_api_python/app/services/pending_order_worker.py +++ b/backend_api_python/app/services/pending_order_worker.py @@ -160,6 +160,11 @@ def __init__(self, poll_interval_sec: float = 1.0, batch_size: int = 50): self._exchange_catchups: set[tuple[str, int, str]] = set() self._last_stream_audit: Dict[tuple[str, int, str], float] = {} self._stream_audit_sec = max(10.0, float(os.getenv("EXECUTION_STREAM_REST_AUDIT_SEC", "30"))) + # futu-api keeps networking resources alive longer than an individual + # context.close(). Recreating contexts every one-second reconciliation + # tick eventually exhausts OpenD's 128-connection limit. + self._futu_sync_clients: Dict[str, Any] = {} + self._futu_sync_clients_lock = threading.Lock() logger.info(f"PendingOrderWorker: sync_enabled={self._position_sync_enabled}, interval={self._position_sync_interval_sec}s") def request_exchange_catchup( @@ -197,6 +202,20 @@ def stop(self, timeout_sec: float = 5.0) -> None: th = self._thread if th and th.is_alive(): th.join(timeout=timeout_sec) + clients = getattr(self, "_futu_sync_clients", {}) + lock = getattr(self, "_futu_sync_clients_lock", None) + if lock is not None: + with lock: + stale_clients = list(clients.values()) + clients.clear() + else: + stale_clients = list(clients.values()) + clients.clear() + for client in stale_clients: + try: + client.disconnect() + except Exception: + pass logger.info("PendingOrderWorker stopped") def _run_loop(self) -> None: @@ -836,6 +855,36 @@ def _sync_one_futu_sent_order(self, row: Dict[str, Any]) -> None: if not finalized: self._release_futu_sync_claim(order_id, "not_finalized") + def _futu_sync_client(self, exchange_config: Dict[str, Any]) -> Any: + """Reuse OpenD contexts while a submitted order is being reconciled.""" + key = json.dumps(exchange_config or {}, sort_keys=True, default=str) + if not hasattr(self, "_futu_sync_clients"): + self._futu_sync_clients = {} + if not hasattr(self, "_futu_sync_clients_lock"): + self._futu_sync_clients_lock = threading.Lock() + with self._futu_sync_clients_lock: + client = self._futu_sync_clients.get(key) + if client is not None and bool(getattr(client, "connected", True)): + return client + if client is not None: + try: + client.disconnect() + except Exception: + pass + client = create_client(exchange_config, market_type="spot") + self._futu_sync_clients[key] = client + return client + + def _discard_futu_sync_client(self, exchange_config: Dict[str, Any], client: Any) -> None: + key = json.dumps(exchange_config or {}, sort_keys=True, default=str) + with self._futu_sync_clients_lock: + if self._futu_sync_clients.get(key) is client: + self._futu_sync_clients.pop(key, None) + try: + client.disconnect() + except Exception: + pass + def _sync_claimed_futu_order(self, row: Dict[str, Any]) -> bool: """Sync one already-claimed row; return True once its DB state is finalized.""" order_id = int(row.get("id") or 0) @@ -862,7 +911,7 @@ def _sync_claimed_futu_order(self, row: Dict[str, Any]) -> bool: client = None try: - client = create_client(exchange_config, market_type="spot") + client = self._futu_sync_client(exchange_config) except Exception as e: logger.warning("Futu fill sync create_client failed: pending_id=%s err=%s", order_id, e) return False @@ -984,12 +1033,13 @@ def _sync_claimed_futu_order(self, row: Dict[str, Any]) -> bool: exchange_response_json=raw_json, final=status in final_statuses, ) + if status in final_statuses: + self._discard_futu_sync_client(exchange_config, client) return True finally: - try: - client.disconnect() - except Exception: - pass + # Non-final orders intentionally retain their OpenD contexts in + # the worker cache; terminal orders discard them above. + pass def _update_futu_sent_order_snapshot( self, diff --git a/backend_api_python/docker-entrypoint.sh b/backend_api_python/docker-entrypoint.sh index fd3523b4f..dfe96ecbb 100644 --- a/backend_api_python/docker-entrypoint.sh +++ b/backend_api_python/docker-entrypoint.sh @@ -112,6 +112,12 @@ fi # long enough to initialize bind-mounted secrets and volume ownership. if [ "$(id -u)" = "0" ] && id quantdinger >/dev/null 2>&1; then chown -R quantdinger:quantdinger /app/logs /app/data 2>/dev/null || true + # futu-api writes SDK logs below HOME before opening any quote/trade + # context. Root-run diagnostics may have created this tree first, so + # always restore ownership before workers drop privileges. + FUTU_LOG_HOME="/home/quantdinger/.com.futunn.FutuOpenD" + mkdir -p "$FUTU_LOG_HOME/Log" + chown -R quantdinger:quantdinger "$FUTU_LOG_HOME" 2>/dev/null || true if [ -f /app/.env ]; then if chown quantdinger:quantdinger /app/.env 2>/dev/null; then chmod 600 /app/.env 2>/dev/null || \ diff --git a/backend_api_python/scripts/opend_relay.py b/backend_api_python/scripts/opend_relay.py new file mode 100644 index 000000000..2e2af118d --- /dev/null +++ b/backend_api_python/scripts/opend_relay.py @@ -0,0 +1,55 @@ +"""Restrict FutuOpenD to loopback while exposing it to local Docker bridges.""" + +from __future__ import annotations + +import os +import select +import socket +import socketserver + + +LISTEN = ( + os.getenv("OPEND_RELAY_BIND_HOST", "172.17.0.1"), + int(os.getenv("OPEND_RELAY_PORT", "11112")), +) +UPSTREAM = ( + os.getenv("OPEND_HOST", "127.0.0.1"), + int(os.getenv("OPEND_PORT", "11111")), +) + + +class RelayHandler(socketserver.BaseRequestHandler): + def handle(self) -> None: + try: + upstream = socket.create_connection(UPSTREAM, timeout=5) + except OSError: + return + with upstream: + upstream.settimeout(None) + sockets = (self.request, upstream) + try: + while True: + readable, _, _ = select.select(sockets, [], []) + for source in readable: + target = upstream if source is self.request else self.request + data = source.recv(65536) + if not data: + return + target.sendall(data) + except (ConnectionError, OSError): + return + + +class RelayServer(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + +if __name__ == "__main__": + print( + f"OpenD relay listening on {LISTEN[0]}:{LISTEN[1]} " + f"and forwarding to {UPSTREAM[0]}:{UPSTREAM[1]}", + flush=True, + ) + with RelayServer(LISTEN, RelayHandler) as server: + server.serve_forever() diff --git a/backend_api_python/tests/test_execution_stream_repository.py b/backend_api_python/tests/test_execution_stream_repository.py new file mode 100644 index 000000000..5f3e3d442 --- /dev/null +++ b/backend_api_python/tests/test_execution_stream_repository.py @@ -0,0 +1,45 @@ +from unittest.mock import MagicMock + +import app.services.execution_streams.repository as repository_module +from app.services.execution_streams.repository import ExecutionEventRepository + + +def test_legacy_binding_uses_event_market_for_recursive_lookup(monkeypatch): + cursor = MagicMock() + cursor.fetchone.return_value = { + "id": 22, + "credential_id": 7, + "exchange_id": "futu", + "market_type": "spot", + "owner_type": "pending_order", + "owner_id": 22, + "user_id": 1, + "strategy_id": 9, + "symbol": "00700.HK", + "signal_type": "close_long", + "client_order_id": "qd_9_22", + "exchange_order_id": "OID-22", + } + connection = MagicMock() + connection.cursor.return_value = cursor + context = MagicMock() + context.__enter__.return_value = connection + context.__exit__.return_value = False + monkeypatch.setattr(repository_module, "get_db_connection", lambda: context) + + repository = ExecutionEventRepository() + repository.register_binding = MagicMock() + repository.resolve_binding = MagicMock(return_value={"id": 91}) + + result = repository._discover_legacy_binding( + { + "credential_id": 7, + "exchange_id": "futu", + "market_type": "hkstock", + "exchange_order_id": "OID-22", + "client_order_id": "qd_9_22", + } + ) + + assert result == {"id": 91} + assert repository.register_binding.call_args.kwargs["market_type"] == "hkstock" diff --git a/backend_api_python/tests/test_futu_pending_order_sync.py b/backend_api_python/tests/test_futu_pending_order_sync.py index 8194d283f..540a329df 100644 --- a/backend_api_python/tests/test_futu_pending_order_sync.py +++ b/backend_api_python/tests/test_futu_pending_order_sync.py @@ -68,7 +68,7 @@ def test_failed_status_query_requeues_without_overwriting_fill(monkeypatch): worker._release_futu_sync_claim.assert_called_once_with(17, "not_finalized") worker._update_futu_sent_order_snapshot.assert_not_called() - assert client.disconnected + assert not client.disconnected def test_invalid_claimed_order_is_requeued_immediately(): @@ -112,7 +112,33 @@ def test_successful_regressive_snapshot_preserves_recorded_fill(monkeypatch): assert update["filled"] == 5 assert update["avg_price"] == 100 assert update["status"] == "sent" + assert not client.disconnected + + +def test_terminal_snapshot_discards_cached_client(monkeypatch): + row = { + "id": 21, + "exchange_order_id": "OID-21", + "strategy_id": 9, + "filled": 0, + "avg_price": 0, + } + result = SimpleNamespace( + success=True, + status="filled", + filled=0, + avg_price=0, + raw={}, + message="OK", + ) + client = _FakeFutuClient(result) + worker = _worker_with_claim(row) + _configure_futu_strategy(monkeypatch, client) + + worker._sync_one_futu_sent_order(row) + assert client.disconnected + assert worker._futu_sync_clients == {} def test_retry_uses_durable_trade_ledger_to_avoid_duplicate_fill(monkeypatch): diff --git a/docker-compose.yml b/docker-compose.yml index d512327b6..af799cae9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -256,6 +256,8 @@ services: # Takes precedence over mounted .env (run.py load_dotenv override=False). # Include :8000 when using Vite dev server alongside this stack. - FRONTEND_URL=${FRONTEND_URL:-http://localhost:8888,http://localhost:8889,http://localhost:8000} + extra_hosts: + - "host.docker.internal:host-gateway" networks: - quantdinger-network healthcheck: @@ -318,6 +320,8 @@ services: <<: *worker-environment QD_PROCESS_ROLE: trading STRATEGY_COMMANDS_ENABLED: "false" + extra_hosts: + - "host.docker.internal:host-gateway" networks: - quantdinger-network healthcheck: @@ -327,6 +331,32 @@ services: retries: 3 start_period: 30s + # Linux/WSL-only loopback relay for FutuOpenD. OpenD stays bound to + # 127.0.0.1, while Docker clients use host.docker.internal:11112. + # Start with: docker compose --profile local-brokers up -d opend-relay + opend-relay: + <<: *backend-runtime + profiles: ["local-brokers"] + build: *backend-build + image: ${BACKEND_LOCAL_IMAGE:-quantdinger-backend:local} + container_name: quantdinger-opend-relay + restart: unless-stopped + network_mode: host + user: "10001:10001" + entrypoint: ["python"] + command: ["/app/scripts/opend_relay.py"] + environment: + OPEND_RELAY_BIND_HOST: ${OPEND_RELAY_BIND_HOST:-172.17.0.1} + OPEND_RELAY_PORT: ${OPEND_RELAY_PORT:-11112} + OPEND_HOST: ${OPEND_HOST:-127.0.0.1} + OPEND_PORT: ${OPEND_PORT:-11111} + healthcheck: + test: ["CMD", "python", "-c", "import os,socket; socket.create_connection((os.getenv('OPEND_RELAY_BIND_HOST','172.17.0.1'),int(os.getenv('OPEND_RELAY_PORT','11112'))),2).close()"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 5s + scheduler-worker: <<: *backend-runtime build: *backend-build