From cbe8bbc5914dd538affc60b7f9b57504dca49e2f Mon Sep 17 00:00:00 2001 From: TIANHE Date: Sun, 19 Jul 2026 20:03:09 +0800 Subject: [PATCH] v5.0.4 Signed-off-by: TIANHE --- .env.example | 4 +- CONTRIBUTING.md | 4 +- DEVELOPMENT.md | 2 +- .../app/services/ai_generation_contracts.py | 41 +-- backend_api_python/app/services/llm.py | 141 +++++++- .../app/services/strategy_v2/contract.py | 317 ++++++++++++++++++ .../test_ai_generation_prompt_contracts.py | 23 +- .../tests/test_llm_litellm_provider.py | 75 ++++- .../tests/test_strategy_v2_contract.py | 152 +++++++++ docker-compose.ghcr.yml | 22 +- docker-compose.yml | 4 +- docs/deployment/CLOUD_DEPLOYMENT_CN.md | 6 +- docs/deployment/CLOUD_DEPLOYMENT_EN.md | 6 +- 13 files changed, 727 insertions(+), 70 deletions(-) diff --git a/.env.example b/.env.example index 1dfe04b..813cffe 100644 --- a/.env.example +++ b/.env.example @@ -92,5 +92,5 @@ # BACKEND_TAG=5.0.1 # # Override the GHCR image paths (e.g. for forks): -# BACKEND_IMAGE=ghcr.io/brokermr810/quantdinger-backend -# FRONTEND_IMAGE=ghcr.io/brokermr810/quantdinger-frontend +# BACKEND_IMAGE=ghcr.io/openbyteinc/quantdinger-backend +# FRONTEND_IMAGE=ghcr.io/openbyteinc/quantdinger-frontend diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d70af2d..89aa726 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,7 +102,7 @@ This repository contains: - `backend_api_python/`: Flask backend + strategy runtime - `docker-compose.yml` / `docker-compose.ghcr.yml`: deployment stacks -The web UI source lives in the separate private **QuantDinger-Vue** repo, which publishes `ghcr.io/brokermr810/quantdinger-frontend` to GHCR on every `v*` tag — both Compose files pull that image directly. +The web UI source lives in the separate private **QuantDinger-Vue** repo, which publishes `ghcr.io/openbyteinc/quantdinger-frontend` to GHCR on every `v*` tag — both Compose files pull that image directly. ### Backend (Python) @@ -115,7 +115,7 @@ python run.py ### Frontend -The SPA lives in the private **QuantDinger-Vue** repo. Tagging a release there (`git tag vX.Y.Z && git push --tags`) triggers `.github/workflows/release-frontend.yml`, which builds a multi-arch image and pushes it to `ghcr.io/brokermr810/quantdinger-frontend`. No frontend artefacts are committed here — pin the consumed tag via `IMAGE_TAG` (or `FRONTEND_TAG` for a per-side override) in a root-level `.env`. +The SPA lives in the private **QuantDinger-Vue** repo. Tagging a release there (`git tag vX.Y.Z && git push --tags`) triggers `.github/workflows/release-frontend.yml`, which builds a multi-arch image and pushes it to `ghcr.io/openbyteinc/quantdinger-frontend`. No frontend artefacts are committed here — pin the consumed tag via `IMAGE_TAG` (or `FRONTEND_TAG` for a per-side override) in a root-level `.env`. For local iteration without publishing, clone the Vue repo into `./QuantDinger-Vue/` (gitignored) and run `docker compose -f docker-compose.yml -f docker-compose.build.yml up --build` — see **DEVELOPMENT.md → Building frontend from local source**. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 42b9e8e..2ae2a9f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -81,7 +81,7 @@ git tag v5.0.1 git push origin v5.0.1 ``` -The `release-frontend.yml` workflow there builds `linux/amd64 + linux/arm64` images via buildx and pushes them to `ghcr.io/brokermr810/quantdinger-frontend`, tagged with the semver value, `{major}.{minor}`, and `latest`. +The `release-frontend.yml` workflow there builds `linux/amd64 + linux/arm64` images via buildx and pushes them to `ghcr.io/openbyteinc/quantdinger-frontend`, tagged with the semver value, `{major}.{minor}`, and `latest`. This repo's `docker-compose.yml` (and `docker-compose.ghcr.yml`) references that image by default. To pin a version while testing: diff --git a/backend_api_python/app/services/ai_generation_contracts.py b/backend_api_python/app/services/ai_generation_contracts.py index 972ab28..f78c7b6 100644 --- a/backend_api_python/app/services/ai_generation_contracts.py +++ b/backend_api_python/app/services/ai_generation_contracts.py @@ -21,13 +21,16 @@ Return Python source only. Do not use markdown fences or explanatory prose. ## Event model - CTA strategies implement `handle_data(context, data)`. -- Portfolio strategies normally register `run_daily`, `run_weekly`, or `run_monthly` callbacks in `initialize` and rebalance inside the callback. +- Single-symbol signal strategies normally implement `handle_data(context, data)`. Do not add a schedule unless the user requests one or the strategy is explicitly a periodic portfolio rebalance. +- Portfolio strategies may register the global helpers `run_daily(callback, time="HH:MM")`, `run_weekly(callback, weekday=1, time="HH:MM")`, or `run_monthly(callback, monthday=1, time="HH:MM")` in `initialize` and rebalance inside the callback. These are runtime-bound global helpers; call them directly and never as `context.run_daily`, `context.run_weekly`, or `context.run_monthly`. - Optional lifecycle handlers are `before_trading_start(context, data)` and `after_trading_end(context, data)`. - Store per-run state on the global `g` namespace. - Confirm decisions from visible completed data only. Never read future rows, use negative shifts, or otherwise introduce look-ahead bias. ## Data and factors -- Use `get_history(count, frequency, field, security_list)` or `history(...)` for historical bars. +- Historical-bar signatures are exact: `get_history(count, frequency=None, field=None, security_list=None)` and `data.history(symbols, count, fields=None)`. +- In `get_history(...)`, `count` is always the first argument and must be an integer. In `data.history(...)`, symbols are first and the integer count is second. Prefer explicit keywords when using `data.history`, for example `data.history(symbol, count=60, fields=["close"])`. +- A history request for one symbol returns a pandas `DataFrame` directly. Use `bars["close"]`; never index the result again with `bars[symbol]`. Multiple-symbol requests return a dictionary keyed by canonical symbol. - Use `indicator(name, symbol, **params)`, `factor(name, symbol, **params)`, or `get_factors(symbols, names, **params)` for technical factors. - TA-Lib indicators and factors are available through the registered 129-function adapter; use canonical TA-Lib names and valid parameters. - Use `get_fundamentals(fields, symbols)` only for real point-in-time fundamental fields supported by the platform. Do not invent fields or use future reports. @@ -35,8 +38,10 @@ Return Python source only. Do not use markdown fences or explanatory prose. - Use `get_universe_stocks()` for the currently selected platform universe pool. Do not copy pool constituents into source code. ## Orders and positions -- Use `order`, `order_value`, `order_target`, `order_target_value`, or `order_target_percent`. -- Use `get_position(symbol)` or `get_positions(...)` to inspect holdings. +- Order-helper signatures are exact: `order(symbol, amount)`, `order_value(symbol, value)`, `order_target(symbol, amount)`, `order_target_value(symbol, value)`, and `order_target_percent(symbol, percent)`. +- These are runtime-bound global helpers. Never pass `context` as their first argument. Optional execution and protection values must be keyword arguments after the two required arguments. +- `get_position(symbol)` returns a `Position` object. Read `position.amount`, `position.avg_cost`, and `position.last_price` directly; never use dictionary membership, subscripting, `.get(...)`, or `getattr(...)` on it. +- Use `get_positions(...)` when a dictionary of multiple positions is required. - Values passed to value-based order APIs are quote-currency exposure targets. Keep sizing bounded by available capital and explicit allocation rules. - Keep long entry, long exit, short entry, and short exit conditions independent. A bearish long exit is not automatically a short entry. - Spot and all non-crypto markets are long-only for now. @@ -54,17 +59,6 @@ Return Python source only. Do not use markdown fences or explanatory prose. - Do not use `eval`, `exec`, `compile`, `open`, `getattr`, `setattr`, dunder access, or unsafe imports. """ -INDICATOR_TO_STRATEGY_CONTRACT = """# Indicator-to-Strategy API V2 conversion - -- Convert the indicator's signal meaning into Strategy API V2 source with `initialize(context)` and executable handlers. -- Remove chart-only `output`, plot, layer, and marker structures from the result. -- Preserve event algebra and recursive indicator semantics without look-ahead. -- Preserve the source timeframe in `context.subscribe(...)` when it is declared by the source; otherwise choose a conservative strategy-owned default. -- Map an explicit bullish entry to a long entry and an explicit bearish exit to a long exit. Do not invent short, leverage, reversal, grid, DCA, or martingale behavior. -- Add short logic only when the user explicitly requests it and supplies a distinct bearish entry rule. -- Keep visual-only colors, label offsets, and layout parameters out of executable code. -""" - SCRIPT_STRATEGY_QUICK_TOOL_SYSTEM_PROMPT = SCRIPT_STRATEGY_SYSTEM_PROMPT + """ # Homepage quick-tool entry @@ -73,24 +67,17 @@ SCRIPT_STRATEGY_QUICK_TOOL_SYSTEM_PROMPT = SCRIPT_STRATEGY_SYSTEM_PROMPT + """ - Do not return a research memo, checklist, or pseudo-code. """ -INDICATOR_TO_STRATEGY_SYSTEM_PROMPT = ( - SCRIPT_STRATEGY_SYSTEM_PROMPT - + "\n\n" - + INDICATOR_TO_STRATEGY_CONTRACT - + """ - -# Indicator conversion entry -- The generated source may be saved directly and must compile as Strategy API V2. -- Preserve the source indicator's visible signal meaning before adding execution behavior. -""" -) - SCRIPT_STRATEGY_REPAIR_REQUIREMENTS = """# Strategy API V2 repair requirements - Return Python source only. - Require a metadata docstring and `initialize(context)`. - Require a source-owned universe and subscription. - Require at least one executable handler or registered schedule callback. - Use only Strategy API V2 data, factor, fundamental, position, and order APIs. +- Prefer `handle_data(context, data)` for single-symbol signal strategies. Use schedules only for an explicitly requested schedule or periodic portfolio rebalance. +- Schedule helpers are global calls: `run_daily(callback, time="HH:MM")`, `run_weekly(callback, weekday=1, time="HH:MM")`, and `run_monthly(callback, monthday=1, time="HH:MM")`. Never call them through `context`. +- Enforce exact history signatures: `get_history(count, frequency, field, security_list)` and `data.history(symbols, count, fields)`. A single-symbol result is already a DataFrame. +- Enforce exact order signatures such as `order_target_percent(symbol, percent)` and never pass `context` to a global order helper. +- Treat `get_position(symbol)` as a `Position` object with direct `.amount`, `.avg_cost`, and `.last_price` attributes. Never treat it as a dictionary or use `getattr`. - Preserve completed-data-only execution and remove look-ahead. - Keep symbol, market, frequency, schedule, and universe in source code. - Permit user-adjustable leverage only for Crypto `@swap` instruments and only after `context.allow_leverage(max_leverage=N)`. diff --git a/backend_api_python/app/services/llm.py b/backend_api_python/app/services/llm.py index d86cbd0..7b77800 100644 --- a/backend_api_python/app/services/llm.py +++ b/backend_api_python/app/services/llm.py @@ -17,6 +17,15 @@ from app.utils.config_loader import load_addon_config logger = get_logger(__name__) +class LLMAPIError(ValueError): + """Provider HTTP error with status and request metadata preserved.""" + + def __init__(self, message: str, *, status_code: int, request_id: str = ""): + super().__init__(message) + self.status_code = status_code + self.request_id = request_id + + class LLMProvider(Enum): """Supported LLM providers""" OPENROUTER = "openrouter" @@ -305,18 +314,22 @@ class LLMService: # Handle non-2xx with provider/model-aware details if response.status_code >= 400: - provider_name = "OpenRouter" if "openrouter" in (base_url or "").lower() else "LLM" - error_msg = f"{provider_name} API {response.status_code}" - err_text = "" - try: - error_data = response.json() or {} - error_detail = error_data.get("error") - if isinstance(error_detail, dict): - err_text = str(error_detail.get("message") or "").strip() - elif isinstance(error_detail, str): - err_text = error_detail.strip() - except Exception: - err_text = (response.text or "").strip()[:300] + normalized_base_url = (base_url or "").lower() + if "atlascloud" in normalized_base_url: + provider_name = "AtlasCloud" + elif "openrouter" in normalized_base_url: + provider_name = "OpenRouter" + else: + provider_name = "LLM" + err_text = self._extract_provider_error(response) + request_id = self._provider_request_id(response) + metadata = [f"model={model}"] + if request_id: + metadata.append(f"request_id={request_id}") + error_msg = ( + f"{provider_name} API {response.status_code} " + f"({', '.join(metadata)})" + ) if err_text: error_msg = f"{error_msg}: {err_text}" @@ -331,7 +344,11 @@ class LLMService: elif response.status_code == 404: error_msg += ". 可能原因:模型不可用或账户隐私/数据策略限制。请检查 https://openrouter.ai/settings/privacy" - raise ValueError(error_msg) + raise LLMAPIError( + error_msg, + status_code=response.status_code, + request_id=request_id, + ) result = response.json() if "choices" in result and len(result["choices"]) > 0: @@ -342,6 +359,64 @@ class LLMService: else: raise ValueError("API response is missing 'choices'") + @staticmethod + def _provider_request_id(response) -> str: + headers = getattr(response, "headers", None) or {} + for name in ( + "x-request-id", + "request-id", + "x-correlation-id", + "cf-ray", + ): + value = headers.get(name) or headers.get(name.title()) + if value: + return str(value).strip()[:200] + return "" + + @classmethod + def _extract_provider_error(cls, response) -> str: + payload = None + try: + payload = response.json() + except Exception: + pass + + detail = cls._format_provider_error_value(payload) + if not detail: + detail = str(getattr(response, "text", "") or "").strip() + return " ".join(detail.split())[:1000] + + @classmethod + def _format_provider_error_value(cls, value) -> str: + if isinstance(value, str): + return value.strip() + if isinstance(value, list): + parts = [cls._format_provider_error_value(item) for item in value] + return "; ".join(part for part in parts if part) + if not isinstance(value, dict): + return "" + + parts = [] + location = value.get("loc") or value.get("location") + if isinstance(location, (list, tuple)): + location = ".".join(str(item) for item in location) + if location: + parts.append(str(location).strip()) + + for key in ("error", "message", "msg", "detail", "reason"): + if key not in value: + continue + text = cls._format_provider_error_value(value.get(key)) + if text and text not in parts: + parts.append(text) + + if not parts: + for key in ("code", "type", "status"): + item = value.get(key) + if isinstance(item, (str, int, float)) and str(item).strip(): + parts.append(f"{key}={item}") + return ": ".join(parts) + def _call_google_gemini(self, messages: list, model: str, temperature: float, api_key: str, base_url: str, timeout: int) -> str: """Call Google Gemini API.""" @@ -707,6 +782,46 @@ class LLMService: use_json_mode=use_json_mode ) + except LLMAPIError as e: + status_code = e.status_code + last_status_code = status_code + last_error = str(e) + logger.warning( + "%s API HTTP error (%s): %s", + p.value, + current_model, + e, + ) + + if ( + status_code in (402, 403) + and try_alternative_providers + and current_model == models_to_try[-1] + ): + logger.warning( + "%s returned %s. Trying alternative providers...", + p.value, + status_code, + ) + return self._try_alternative_providers( + messages, + original_model, + temperature, + use_json_mode, + excluded_provider=p, + ) + + if not use_fallback or current_model == models_to_try[-1]: + raise + + logger.warning( + "%s returned %s for model %s; trying fallback model...", + p.value, + status_code, + current_model, + ) + continue + except requests.exceptions.HTTPError as e: error_detail = e.response.text if e.response else str(e) status_code = e.response.status_code if e.response else None diff --git a/backend_api_python/app/services/strategy_v2/contract.py b/backend_api_python/app/services/strategy_v2/contract.py index 13a09bd..5ff5e10 100644 --- a/backend_api_python/app/services/strategy_v2/contract.py +++ b/backend_api_python/app/services/strategy_v2/contract.py @@ -178,6 +178,7 @@ def compile_strategy_v2(code: str) -> CompiledStrategyV2: if not raw: raise StrategyV2ContractError("strategyV2.codeRequired") _validate_dataframe_truthiness(raw) + _validate_strategy_api_calls(raw) context = DiscoveryContext() state = StateNamespace() @@ -298,6 +299,23 @@ _DATAFRAME_API_NAMES = { "factor", } +_ORDER_API_ARGUMENTS = { + "order": ("symbol", "amount"), + "order_value": ("symbol", "value"), + "order_target": ("symbol", "amount"), + "order_target_value": ("symbol", "value"), + "order_target_percent": ("symbol", "percent"), +} + +_CANONICAL_SYMBOL_PREFIXES = ( + "CNStock:", + "Crypto:", + "Forex:", + "Future:", + "Futures:", + "USStock:", +) + def _validate_dataframe_truthiness(code: str) -> None: try: @@ -340,6 +358,305 @@ def _uses_direct_boolean_name(node: ast.AST, names: set[str]) -> bool: return False +def _validate_strategy_api_calls(code: str) -> None: + try: + tree = ast.parse(code) + except SyntaxError: + return + static_values = _collect_static_api_values(tree) + single_frame_names: set[str] = set() + position_names: set[str] = set() + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + call_name, call_owner = _api_call_identity(node) + if call_name in _ORDER_API_ARGUMENTS and call_owner in {"", "context"}: + _validate_order_call(node, call_name, static_values) + elif call_name in {"get_history", "history"} and call_owner == "": + _validate_get_history_call(node, call_name, static_values) + elif call_name == "history" and call_owner == "data": + _validate_data_history_call(node, static_values) + if _history_call_returns_single_frame(node, call_name, call_owner, static_values): + parent_target = _assigned_name_for_call(tree, node) + if parent_target: + single_frame_names.add(parent_target) + if call_name == "get_position" and call_owner in {"", "context"}: + parent_target = _assigned_name_for_call(tree, node) + if parent_target: + position_names.add(parent_target) + for node in ast.walk(tree): + if isinstance(node, ast.Subscript): + if isinstance(node.value, ast.Call): + call_name, call_owner = _api_call_identity(node.value) + if _history_call_returns_single_frame( + node.value, + call_name, + call_owner, + static_values, + ): + kind, _ = _static_api_value(node.slice, static_values) + if kind == "symbol": + raise StrategyV2ContractError( + "strategyV2.apiCallInvalid:history:singleSymbolResultIsDataFrame" + ) + if not isinstance(node.value, ast.Name): + continue + if node.value.id in position_names: + raise StrategyV2ContractError( + "strategyV2.apiCallInvalid:get_position:returnsPositionObject" + ) + if node.value.id not in single_frame_names: + continue + kind, _ = _static_api_value(node.slice, static_values) + if kind != "symbol": + continue + raise StrategyV2ContractError( + "strategyV2.apiCallInvalid:history:singleSymbolResultIsDataFrame" + ) + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id in position_names + and node.func.attr in {"get", "items", "keys", "values"} + ): + raise StrategyV2ContractError( + "strategyV2.apiCallInvalid:get_position:returnsPositionObject" + ) + if isinstance(node, ast.Compare): + operands = [node.left, *node.comparators] + for index, operator in enumerate(node.ops): + if not isinstance(operator, (ast.In, ast.NotIn)): + continue + container = operands[index + 1] + if isinstance(container, ast.Name) and container.id in position_names: + raise StrategyV2ContractError( + "strategyV2.apiCallInvalid:get_position:returnsPositionObject" + ) + + +def _collect_static_api_values(tree: ast.AST) -> dict[str, tuple[str, int | None]]: + assignments: list[tuple[str, ast.AST]] = [] + for node in ast.walk(tree): + if not isinstance(node, (ast.Assign, ast.AnnAssign)): + continue + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + for target in targets: + key = _static_target_key(target) + if key: + assignments.append((key, node.value)) + values: dict[str, tuple[str, int | None]] = {} + for _ in range(len(assignments) + 1): + changed = False + for key, value in assignments: + resolved = _static_api_value(value, values) + if resolved[0] != "unknown" and values.get(key) != resolved: + values[key] = resolved + changed = True + if not changed: + break + return values + + +def _static_target_key(node: ast.AST) -> str: + if isinstance(node, ast.Name): + return node.id + if ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id == "g" + ): + return f"g.{node.attr}" + return "" + + +def _static_api_value( + node: ast.AST | None, + values: dict[str, tuple[str, int | None]], +) -> tuple[str, int | None]: + if node is None: + return "unknown", None + if isinstance(node, ast.Constant): + if isinstance(node.value, bool): + return "boolean", None + if isinstance(node.value, (int, float)): + return "number", None + if isinstance(node.value, str): + raw = node.value.strip() + if raw.startswith(_CANONICAL_SYMBOL_PREFIXES): + return "symbol", None + return "string", None + if isinstance(node, ast.Name): + if node.id == "context": + return "context", None + return values.get(node.id, ("unknown", None)) + if ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id == "g" + ): + return values.get(f"g.{node.attr}", ("unknown", None)) + if isinstance(node, (ast.List, ast.Tuple, ast.Set)): + items = [_static_api_value(item, values) for item in node.elts] + if items and all(kind == "symbol" for kind, _ in items): + return "symbols", len(items) + return "collection", len(items) + return "unknown", None + + +def _api_call_identity(node: ast.Call) -> tuple[str, str]: + if isinstance(node.func, ast.Name): + return node.func.id, "" + if isinstance(node.func, ast.Attribute): + owner = node.func.value.id if isinstance(node.func.value, ast.Name) else "" + return node.func.attr, owner + return "", "" + + +def _call_keywords(node: ast.Call) -> dict[str, ast.AST]: + return {item.arg: item.value for item in node.keywords if item.arg} + + +def _validate_order_call( + node: ast.Call, + call_name: str, + static_values: dict[str, tuple[str, int | None]], +) -> None: + required = _ORDER_API_ARGUMENTS[call_name] + keywords = _call_keywords(node) + if len(node.args) > 2: + raise StrategyV2ContractError( + f"strategyV2.apiCallInvalid:{call_name}:expectedSymbolAndValue" + ) + for index, argument_name in enumerate(required): + if index < len(node.args) and argument_name in keywords: + raise StrategyV2ContractError( + f"strategyV2.apiCallInvalid:{call_name}:duplicateArgument:{argument_name}" + ) + if index >= len(node.args) and argument_name not in keywords: + raise StrategyV2ContractError( + f"strategyV2.apiCallInvalid:{call_name}:missingArgument:{argument_name}" + ) + symbol_node = node.args[0] if node.args else keywords.get("symbol") + value_node = node.args[1] if len(node.args) > 1 else keywords.get(required[1]) + symbol_kind, _ = _static_api_value(symbol_node, static_values) + value_kind, _ = _static_api_value(value_node, static_values) + if symbol_kind in {"context", "number", "boolean"} or value_kind in { + "context", + "symbol", + "symbols", + }: + raise StrategyV2ContractError( + f"strategyV2.apiCallInvalid:{call_name}:expectedSymbolAndValue" + ) + + +def _validate_get_history_call( + node: ast.Call, + call_name: str, + static_values: dict[str, tuple[str, int | None]], +) -> None: + keywords = _call_keywords(node) + if "fields" in keywords: + raise StrategyV2ContractError( + f"strategyV2.apiCallInvalid:{call_name}:unsupportedArgument:fields" + ) + if len(node.args) > 4: + raise StrategyV2ContractError( + f"strategyV2.apiCallInvalid:{call_name}:expectedCountFirst" + ) + positional_names = ("count", "frequency", "field", "security_list") + for index, name in enumerate(positional_names): + if len(node.args) > index and name in keywords: + raise StrategyV2ContractError( + f"strategyV2.apiCallInvalid:{call_name}:duplicateArgument:{name}" + ) + count_node = node.args[0] if node.args else keywords.get("count") + if count_node is None: + raise StrategyV2ContractError( + f"strategyV2.apiCallInvalid:{call_name}:missingArgument:count" + ) + count_kind, _ = _static_api_value(count_node, static_values) + if count_kind in {"context", "string", "symbol", "symbols", "collection", "boolean"}: + raise StrategyV2ContractError( + f"strategyV2.apiCallInvalid:{call_name}:expectedCountFirst" + ) + security_node = node.args[3] if len(node.args) > 3 else keywords.get("security_list") + security_kind, _ = _static_api_value(security_node, static_values) + if security_kind in {"context", "number", "boolean"}: + raise StrategyV2ContractError( + f"strategyV2.apiCallInvalid:{call_name}:invalidSecurityList" + ) + + +def _validate_data_history_call( + node: ast.Call, + static_values: dict[str, tuple[str, int | None]], +) -> None: + keywords = _call_keywords(node) + unsupported = set(keywords) - {"symbols", "count", "fields"} + if unsupported: + name = sorted(unsupported)[0] + raise StrategyV2ContractError( + f"strategyV2.apiCallInvalid:data.history:unsupportedArgument:{name}" + ) + if len(node.args) > 3: + raise StrategyV2ContractError( + "strategyV2.apiCallInvalid:data.history:expectedSymbolsThenCount" + ) + positional_names = ("symbols", "count", "fields") + for index, name in enumerate(positional_names): + if len(node.args) > index and name in keywords: + raise StrategyV2ContractError( + f"strategyV2.apiCallInvalid:data.history:duplicateArgument:{name}" + ) + symbols_node = node.args[0] if node.args else keywords.get("symbols") + count_node = node.args[1] if len(node.args) > 1 else keywords.get("count") + if symbols_node is None or count_node is None: + raise StrategyV2ContractError( + "strategyV2.apiCallInvalid:data.history:expectedSymbolsThenCount" + ) + symbols_kind, _ = _static_api_value(symbols_node, static_values) + count_kind, _ = _static_api_value(count_node, static_values) + if symbols_kind in {"context", "number", "boolean"} or count_kind in { + "context", + "string", + "symbol", + "symbols", + "collection", + "boolean", + }: + raise StrategyV2ContractError( + "strategyV2.apiCallInvalid:data.history:expectedSymbolsThenCount" + ) + + +def _history_call_returns_single_frame( + node: ast.Call, + call_name: str, + call_owner: str, + static_values: dict[str, tuple[str, int | None]], +) -> bool: + keywords = _call_keywords(node) + target_node: ast.AST | None = None + if call_name in {"get_history", "history"} and call_owner == "": + target_node = node.args[3] if len(node.args) > 3 else keywords.get("security_list") + elif call_name == "history" and call_owner == "data": + target_node = node.args[0] if node.args else keywords.get("symbols") + kind, count = _static_api_value(target_node, static_values) + return kind == "symbol" or (kind == "symbols" and count == 1) + + +def _assigned_name_for_call(tree: ast.AST, target_call: ast.Call) -> str: + for node in ast.walk(tree): + if not isinstance(node, (ast.Assign, ast.AnnAssign)) or node.value is not target_call: + continue + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + if len(targets) == 1 and isinstance(targets[0], ast.Name): + return targets[0].id + return "" + + def is_strategy_v2_code(code: str) -> bool: raw = str(code or "") return "def initialize(" in raw and "context.set_universe(" in raw and any( diff --git a/backend_api_python/tests/test_ai_generation_prompt_contracts.py b/backend_api_python/tests/test_ai_generation_prompt_contracts.py index b95e301..be80153 100644 --- a/backend_api_python/tests/test_ai_generation_prompt_contracts.py +++ b/backend_api_python/tests/test_ai_generation_prompt_contracts.py @@ -5,7 +5,6 @@ from app.services.ai_generation_contracts import ( INDICATOR_GENERATION_CONTRACT, INDICATOR_REPAIR_REQUIREMENTS, INDICATOR_SYSTEM_CONTRACT, - INDICATOR_TO_STRATEGY_CONTRACT, SCRIPT_STRATEGY_REPAIR_REQUIREMENTS, SCRIPT_STRATEGY_SYSTEM_PROMPT, ) @@ -36,10 +35,24 @@ def test_strategy_generation_prompt_exposes_v2_factor_and_fundamental_contract() assert "context.set_universe(pool=" in SCRIPT_STRATEGY_SYSTEM_PROMPT -def test_indicator_conversion_targets_v2_without_inventing_execution_behavior(): - assert "Strategy API V2" in INDICATOR_TO_STRATEGY_CONTRACT - assert "Remove chart-only" in INDICATOR_TO_STRATEGY_CONTRACT - assert "Do not invent short, leverage" in INDICATOR_TO_STRATEGY_CONTRACT +def test_strategy_generation_prompt_documents_exact_history_and_order_signatures(): + assert "get_history(count, frequency=None, field=None, security_list=None)" in SCRIPT_STRATEGY_SYSTEM_PROMPT + assert "data.history(symbols, count, fields=None)" in SCRIPT_STRATEGY_SYSTEM_PROMPT + assert "one symbol returns a pandas `DataFrame` directly" in SCRIPT_STRATEGY_SYSTEM_PROMPT + assert "order_target_percent(symbol, percent)" in SCRIPT_STRATEGY_SYSTEM_PROMPT + assert "Never pass `context` as their first argument" in SCRIPT_STRATEGY_SYSTEM_PROMPT + assert "`get_position(symbol)` returns a `Position` object" in SCRIPT_STRATEGY_SYSTEM_PROMPT + assert "`position.amount`" in SCRIPT_STRATEGY_SYSTEM_PROMPT + assert "single-symbol result is already a DataFrame" in SCRIPT_STRATEGY_REPAIR_REQUIREMENTS + assert "Treat `get_position(symbol)` as a `Position` object" in SCRIPT_STRATEGY_REPAIR_REQUIREMENTS + + +def test_strategy_generation_prompt_documents_global_schedule_helpers(): + assert "Single-symbol signal strategies normally implement `handle_data(context, data)`" in SCRIPT_STRATEGY_SYSTEM_PROMPT + assert "global helpers `run_daily(callback, time=\"HH:MM\")`" in SCRIPT_STRATEGY_SYSTEM_PROMPT + assert "never as `context.run_daily`" in SCRIPT_STRATEGY_SYSTEM_PROMPT + assert "Schedule helpers are global calls" in SCRIPT_STRATEGY_REPAIR_REQUIREMENTS + assert "Never call them through `context`" in SCRIPT_STRATEGY_REPAIR_REQUIREMENTS def test_indicator_prompt_remains_chart_only(): diff --git a/backend_api_python/tests/test_llm_litellm_provider.py b/backend_api_python/tests/test_llm_litellm_provider.py index 6d8efa2..a748e1f 100644 --- a/backend_api_python/tests/test_llm_litellm_provider.py +++ b/backend_api_python/tests/test_llm_litellm_provider.py @@ -3,7 +3,7 @@ from types import SimpleNamespace import pytest -from app.services.llm import LLMProvider, LLMService +from app.services.llm import LLMAPIError, LLMProvider, LLMService import app.utils.config_loader as config_loader from app.utils.config_loader import clear_config_cache, load_addon_config @@ -93,6 +93,79 @@ def test_atlascloud_openai_compatible_call_skips_response_format(monkeypatch): assert "response_format" not in captured["json"] +@pytest.mark.parametrize( + ("payload", "expected"), + [ + ({"message": "Unsupported parameter: temperature"}, "Unsupported parameter: temperature"), + ({"error": {"message": "Model access denied"}}, "Model access denied"), + ( + { + "detail": [ + { + "loc": ["body", "messages", 0, "content"], + "msg": "Value is not valid", + } + ] + }, + "body.messages.0.content: Value is not valid", + ), + ], +) +def test_atlascloud_http_error_preserves_provider_detail(monkeypatch, payload, expected): + class FakeResponse: + status_code = 400 + headers = {"x-request-id": "atlas-request-42"} + text = "" + + def json(self): + return payload + + service = LLMService(provider="atlascloud") + monkeypatch.setattr(service, "_llm_post", lambda *args, **kwargs: FakeResponse()) + + with pytest.raises(LLMAPIError) as exc_info: + service._call_openai_compatible( + [{"role": "user", "content": "hello"}], + "openai/gpt-5.4", + 0.4, + "atlas-key", + "https://api.atlascloud.ai/v1", + 30, + use_json_mode=False, + ) + + message = str(exc_info.value) + assert "AtlasCloud API 400" in message + assert "model=openai/gpt-5.4" in message + assert "request_id=atlas-request-42" in message + assert expected in message + assert exc_info.value.status_code == 400 + + +def test_atlascloud_http_error_falls_back_to_plain_response_text(monkeypatch): + class FakeResponse: + status_code = 400 + headers = {} + text = "invalid request body" + + def json(self): + raise ValueError("not json") + + service = LLMService(provider="atlascloud") + monkeypatch.setattr(service, "_llm_post", lambda *args, **kwargs: FakeResponse()) + + with pytest.raises(LLMAPIError, match="invalid request body"): + service._call_openai_compatible( + [{"role": "user", "content": "hello"}], + "deepseek-v3", + 0.4, + "atlas-key", + "https://api.atlascloud.ai/v1", + 30, + use_json_mode=False, + ) + + def test_litellm_keeps_provider_prefixed_model(monkeypatch): monkeypatch.setenv("LLM_PROVIDER", "litellm") monkeypatch.setenv("LITELLM_MODEL", "anthropic/claude-sonnet-4-20250514") diff --git a/backend_api_python/tests/test_strategy_v2_contract.py b/backend_api_python/tests/test_strategy_v2_contract.py index 59426d5..cbd759d 100644 --- a/backend_api_python/tests/test_strategy_v2_contract.py +++ b/backend_api_python/tests/test_strategy_v2_contract.py @@ -32,6 +32,158 @@ def handle_data(context, data): assert compile_strategy_v2(code).manifest.primary_frequency == "1m" +def test_contract_rejects_symbol_in_get_history_count_position(): + code = ''' +def initialize(context): + context.set_universe(["Crypto:ZEC/USDT@swap"]) + context.subscribe(frequency="30m") + +def handle_data(context, data): + symbol = "Crypto:ZEC/USDT@swap" + get_history(symbol, "30m", ["close"], [symbol]) +''' + with pytest.raises(StrategyV2ContractError, match="strategyV2.apiCallInvalid:get_history:expectedCountFirst"): + compile_strategy_v2(code) + + +def test_contract_rejects_reversed_data_history_arguments(): + code = ''' +def initialize(context): + context.set_universe(["Crypto:ZEC/USDT@spot"]) + context.subscribe(frequency="30m") + +def handle_data(context, data): + symbol = "Crypto:ZEC/USDT@spot" + data.history(200, symbol, ["close", "high", "low"]) +''' + with pytest.raises(StrategyV2ContractError, match="strategyV2.apiCallInvalid:data.history:expectedSymbolsThenCount"): + compile_strategy_v2(code) + + +def test_contract_rejects_context_passed_to_global_order_helper(): + code = ''' +def initialize(context): + context.set_universe(["Crypto:ZEC/USDT@spot"]) + context.subscribe(frequency="30m") + +def handle_data(context, data): + symbol = "Crypto:ZEC/USDT@spot" + order_target_percent(context, 1.0, symbol) +''' + with pytest.raises(StrategyV2ContractError, match="strategyV2.apiCallInvalid:order_target_percent:expectedSymbolAndValue"): + compile_strategy_v2(code) + + +def test_contract_rejects_symbol_index_on_single_history_dataframe(): + code = ''' +def initialize(context): + context.set_universe(["Crypto:ZEC/USDT@spot"]) + context.subscribe(frequency="30m") + +def handle_data(context, data): + symbol = "Crypto:ZEC/USDT@spot" + history_data = get_history( + count=200, + frequency="30m", + field=["close", "high", "low"], + security_list=[symbol], + ) + close = history_data[symbol]["close"] +''' + with pytest.raises(StrategyV2ContractError, match="strategyV2.apiCallInvalid:history:singleSymbolResultIsDataFrame"): + compile_strategy_v2(code) + + +def test_contract_rejects_plural_fields_keyword_for_get_history(): + code = ''' +def initialize(context): + context.set_universe(["Crypto:ZEC/USDT@spot"]) + context.subscribe(frequency="30m") + +def handle_data(context, data): + get_history( + count=200, + frequency="30m", + fields=["close", "high", "low"], + security_list=["Crypto:ZEC/USDT@spot"], + ) +''' + with pytest.raises(StrategyV2ContractError, match="strategyV2.apiCallInvalid:get_history:unsupportedArgument:fields"): + compile_strategy_v2(code) + + +def test_contract_rejects_chained_symbol_index_on_single_history_dataframe(): + code = ''' +def initialize(context): + g.symbol = "Crypto:ZEC/USDT@spot" + context.set_universe([g.symbol]) + context.subscribe(frequency="30m") + +def handle_data(context, data): + bars = get_history( + 80, + frequency="30m", + field=None, + security_list=[g.symbol], + )[g.symbol] +''' + with pytest.raises(StrategyV2ContractError, match="strategyV2.apiCallInvalid:history:singleSymbolResultIsDataFrame"): + compile_strategy_v2(code) + + +def test_contract_accepts_canonical_history_and_order_calls(): + code = ''' +def initialize(context): + g.symbol = "Crypto:ZEC/USDT@spot" + context.set_universe([g.symbol]) + context.subscribe(frequency="30m") + +def handle_data(context, data): + bars = data.history(g.symbol, count=200, fields=["close", "high", "low"]) + if len(bars) < 20: + return + order_target_percent(g.symbol, 1.0, reason="entry") +''' + assert compile_strategy_v2(code).manifest.primary_frequency == "30m" + + +@pytest.mark.parametrize( + "invalid_access", + [ + '"amount" in position', + 'position["amount"] > 0', + 'position.get("amount", 0) > 0', + ], +) +def test_contract_rejects_dictionary_access_on_position_object(invalid_access): + code = f''' +def initialize(context): + context.set_universe(["Crypto:ZEC/USDT@spot"]) + context.subscribe(frequency="30m") + +def handle_data(context, data): + position = get_position("Crypto:ZEC/USDT@spot") + if {invalid_access}: + return +''' + with pytest.raises(StrategyV2ContractError, match="strategyV2.apiCallInvalid:get_position:returnsPositionObject"): + compile_strategy_v2(code) + + +def test_contract_accepts_position_object_attributes(): + code = ''' +def initialize(context): + context.set_universe(["Crypto:ZEC/USDT@spot"]) + context.subscribe(frequency="30m") + +def handle_data(context, data): + position = get_position("Crypto:ZEC/USDT@spot") + if float(position.amount or 0.0) > 0: + order_target_percent("Crypto:ZEC/USDT@spot", 0.0) +''' + assert compile_strategy_v2(code).manifest.primary_frequency == "30m" + + def test_instrument_parser_normalizes_ptrade_and_crypto_symbols(): assert parse_instrument("600519.XSHG").key == "CNStock:600519.SH" assert parse_instrument("USStock:MSFT").key == "USStock:MSFT" diff --git a/docker-compose.ghcr.yml b/docker-compose.ghcr.yml index fea1229..af0fe8c 100644 --- a/docker-compose.ghcr.yml +++ b/docker-compose.ghcr.yml @@ -26,9 +26,9 @@ # IMAGE_TAG - unified tag for backend AND frontend (optional; see per-side defaults below) # BACKEND_TAG - per-side override: pins backend only (default: $IMAGE_TAG) # FRONTEND_TAG - per-side override: pins frontend only (default: $IMAGE_TAG) -# BACKEND_IMAGE - backend image path (default: ghcr.io/brokermr810/quantdinger-backend) -# FRONTEND_IMAGE - frontend image path (default: ghcr.io/brokermr810/quantdinger-frontend) -# MOBILE_IMAGE - mobile H5 image path (default: ghcr.io/brokermr810/quantdinger-mobile) +# BACKEND_IMAGE - backend image path (default: ghcr.io/openbyteinc/quantdinger-backend) +# FRONTEND_IMAGE - frontend image path (default: ghcr.io/openbyteinc/quantdinger-frontend) +# MOBILE_IMAGE - mobile H5 image path (default: ghcr.io/openbyteinc/quantdinger-mobile) # IMAGE_PREFIX - postgres/redis registry prefix (e.g. docker.m.daocloud.io/library/) # # Normal use: set IMAGE_TAG once and both services move in lockstep. @@ -123,7 +123,7 @@ services: migration: <<: *backend-runtime - image: ${BACKEND_IMAGE:-ghcr.io/brokermr810/quantdinger-backend}:${BACKEND_TAG:-${IMAGE_TAG:-latest}} + image: ${BACKEND_IMAGE:-ghcr.io/openbyteinc/quantdinger-backend}:${BACKEND_TAG:-${IMAGE_TAG:-latest}} pull_policy: always container_name: quantdinger-migration restart: "no" @@ -146,7 +146,7 @@ services: backend: <<: *backend-runtime # Tag resolution: BACKEND_TAG > IMAGE_TAG > latest. See header comment. - image: ${BACKEND_IMAGE:-ghcr.io/brokermr810/quantdinger-backend}:${BACKEND_TAG:-${IMAGE_TAG:-latest}} + image: ${BACKEND_IMAGE:-ghcr.io/openbyteinc/quantdinger-backend}:${BACKEND_TAG:-${IMAGE_TAG:-latest}} pull_policy: always container_name: quantdinger-backend restart: unless-stopped @@ -221,7 +221,7 @@ services: trading-worker: <<: *backend-runtime - image: ${BACKEND_IMAGE:-ghcr.io/brokermr810/quantdinger-backend}:${BACKEND_TAG:-${IMAGE_TAG:-latest}} + image: ${BACKEND_IMAGE:-ghcr.io/openbyteinc/quantdinger-backend}:${BACKEND_TAG:-${IMAGE_TAG:-latest}} pull_policy: always container_name: quantdinger-trading-worker restart: unless-stopped @@ -262,7 +262,7 @@ services: scheduler-worker: <<: *backend-runtime - image: ${BACKEND_IMAGE:-ghcr.io/brokermr810/quantdinger-backend}:${BACKEND_TAG:-${IMAGE_TAG:-latest}} + image: ${BACKEND_IMAGE:-ghcr.io/openbyteinc/quantdinger-backend}:${BACKEND_TAG:-${IMAGE_TAG:-latest}} pull_policy: always container_name: quantdinger-scheduler-worker restart: unless-stopped @@ -301,7 +301,7 @@ services: celery-worker: <<: *backend-runtime - image: ${BACKEND_IMAGE:-ghcr.io/brokermr810/quantdinger-backend}:${BACKEND_TAG:-${IMAGE_TAG:-latest}} + image: ${BACKEND_IMAGE:-ghcr.io/openbyteinc/quantdinger-backend}:${BACKEND_TAG:-${IMAGE_TAG:-latest}} pull_policy: always container_name: quantdinger-celery-worker restart: unless-stopped @@ -345,7 +345,7 @@ services: celery-beat: <<: *backend-runtime - image: ${BACKEND_IMAGE:-ghcr.io/brokermr810/quantdinger-backend}:${BACKEND_TAG:-${IMAGE_TAG:-latest}} + image: ${BACKEND_IMAGE:-ghcr.io/openbyteinc/quantdinger-backend}:${BACKEND_TAG:-${IMAGE_TAG:-latest}} pull_policy: always container_name: quantdinger-celery-beat restart: unless-stopped @@ -387,7 +387,7 @@ services: frontend: # Tag resolution: FRONTEND_TAG > IMAGE_TAG > latest. See header comment. - image: ${FRONTEND_IMAGE:-ghcr.io/brokermr810/quantdinger-frontend}:${FRONTEND_TAG:-${IMAGE_TAG:-latest}} + image: ${FRONTEND_IMAGE:-ghcr.io/openbyteinc/quantdinger-frontend}:${FRONTEND_TAG:-${IMAGE_TAG:-latest}} pull_policy: always container_name: quantdinger-frontend restart: unless-stopped @@ -407,7 +407,7 @@ services: mobile: # Tag resolution: MOBILE_TAG > IMAGE_TAG > latest. See header comment. - image: ${MOBILE_IMAGE:-ghcr.io/brokermr810/quantdinger-mobile}:${MOBILE_TAG:-${IMAGE_TAG:-latest}} + image: ${MOBILE_IMAGE:-ghcr.io/openbyteinc/quantdinger-mobile}:${MOBILE_TAG:-${IMAGE_TAG:-latest}} pull_policy: always container_name: quantdinger-mobile restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index d66879c..e719b17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -401,7 +401,7 @@ services: # FRONTEND_TAG — per-side override, decouples FE from BE # IMAGE_TAG — unified knob, sets both FE and BE (BE only relevant in ghcr.yml) # latest — fallback; pin semver in project-root .env (e.g. IMAGE_TAG=5.0.1) - image: ${FRONTEND_IMAGE:-ghcr.io/brokermr810/quantdinger-frontend}:${FRONTEND_TAG:-${IMAGE_TAG:-latest}} + image: ${FRONTEND_IMAGE:-ghcr.io/openbyteinc/quantdinger-frontend}:${FRONTEND_TAG:-${IMAGE_TAG:-latest}} pull_policy: always container_name: quantdinger-frontend restart: unless-stopped @@ -429,7 +429,7 @@ services: # It proxies /api to the same backend service, so users do not need to # manually configure a server URL for the one-click Docker stack. mobile: - image: ${MOBILE_IMAGE:-ghcr.io/brokermr810/quantdinger-mobile}:${MOBILE_TAG:-${IMAGE_TAG:-latest}} + image: ${MOBILE_IMAGE:-ghcr.io/openbyteinc/quantdinger-mobile}:${MOBILE_TAG:-${IMAGE_TAG:-latest}} pull_policy: always container_name: quantdinger-mobile restart: unless-stopped diff --git a/docs/deployment/CLOUD_DEPLOYMENT_CN.md b/docs/deployment/CLOUD_DEPLOYMENT_CN.md index 7c46f43..59e3195 100644 --- a/docs/deployment/CLOUD_DEPLOYMENT_CN.md +++ b/docs/deployment/CLOUD_DEPLOYMENT_CN.md @@ -332,9 +332,9 @@ docker compose -f docker-compose.ghcr.yml pull 如果 GHCR 镜像拉取失败: ```bash -docker pull ghcr.io/brokermr810/quantdinger-backend:latest -docker pull ghcr.io/brokermr810/quantdinger-frontend:latest -docker pull ghcr.io/brokermr810/quantdinger-mobile:latest +docker pull ghcr.io/openbyteinc/quantdinger-backend:latest +docker pull ghcr.io/openbyteinc/quantdinger-frontend:latest +docker pull ghcr.io/openbyteinc/quantdinger-mobile:latest ``` 常见原因包括网络阻断、包可见性不是 public、或者固定的 tag 不存在。 diff --git a/docs/deployment/CLOUD_DEPLOYMENT_EN.md b/docs/deployment/CLOUD_DEPLOYMENT_EN.md index 57587a3..f1e707b 100644 --- a/docs/deployment/CLOUD_DEPLOYMENT_EN.md +++ b/docs/deployment/CLOUD_DEPLOYMENT_EN.md @@ -332,9 +332,9 @@ docker compose -f docker-compose.ghcr.yml pull If GHCR images fail: ```bash -docker pull ghcr.io/brokermr810/quantdinger-backend:latest -docker pull ghcr.io/brokermr810/quantdinger-frontend:latest -docker pull ghcr.io/brokermr810/quantdinger-mobile:latest +docker pull ghcr.io/openbyteinc/quantdinger-backend:latest +docker pull ghcr.io/openbyteinc/quantdinger-frontend:latest +docker pull ghcr.io/openbyteinc/quantdinger-mobile:latest ``` Common causes include network blocks, private package visibility, or a pinned tag that does not exist.