Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
This commit is contained in:
TIANHE
2026-07-19 20:03:09 +08:00
parent cb2aa5c384
commit cbe8bbc591
13 changed files with 727 additions and 70 deletions
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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**.
+1 -1
View File
@@ -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:
@@ -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)`.
+128 -13
View File
@@ -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
@@ -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(
@@ -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():
@@ -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")
@@ -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"
+11 -11
View File
@@ -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
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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 不存在。
+3 -3
View File
@@ -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.