mirror of
https://github.com/OpenByteInc/QuantDinger.git
synced 2026-09-28 15:22:56 +08:00
+2
-2
@@ -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
@@ -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
@@ -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)`.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 不存在。
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user