Skip to content

Commit e63b215

Browse files
committed
fix: complete stock trading and backtest contracts
1 parent 925c8cb commit e63b215

23 files changed

Lines changed: 704 additions & 56 deletions

‎backend_api_python/app/services/ai_generation_contracts.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
## Exchange-routed equity product contract
55
- Preserve the complete canonical instrument returned by product discovery, including leading zeros, quote or settlement currency, exchange, and market type. Examples are `Crypto:00700/HKD@gate:spot`, `Crypto:AAPL/USD@gate:spot`, and a catalog-confirmed Binance Hong Kong equity perpetual such as `Crypto:HK0700/USDT@binance:swap`.
66
- The `Crypto` prefix is the exchange-routing namespace; it does not assert that the underlying asset is cryptocurrency. The catalog distinguishes `direct_equity`, `tokenized_equity`, `stock_perpetual`, and ordinary `crypto` products and records an API family for execution.
7-
- Current venue families are exact: Binance supports `stock_perpetual/swap/swap`; OKX and Bybit support `tokenized_equity/spot/spot` plus `stock_perpetual/swap/swap`; Bitget supports `tokenized_equity/spot/reality` plus `stock_perpetual/swap/swap`; Gate supports `direct_equity/spot/stock` plus `stock_perpetual/swap/swap`; HTX equity products remain disabled until authoritative product metadata and an execution contract are verified.
7+
- Current venue families are exact: Binance, OKX, and Bybit support `tokenized_equity/spot/spot` plus `stock_perpetual/swap/swap`; Bitget supports `tokenized_equity/spot/reality` plus `stock_perpetual/swap/swap`; Gate supports `direct_equity/spot/stock` plus `stock_perpetual/swap/swap`; HTX equity products remain disabled until authoritative product metadata and an execution contract are verified.
88
- Product identity and execution eligibility come from the active exchange product catalog. Do not infer a direct equity, tokenized equity, stock perpetual, venue, region, currency, or native instrument ID from ticker spelling alone, and do not manufacture catalog metadata in strategy source. When a venue does not expose authoritative equity metadata, keep the product unsupported rather than guessing.
99
- Gate Tencent uses `Crypto:00700/HKD@gate:spot` with `direct_equity/spot/stock` and maps to `HKStock:00700`; Gate Apple uses `Crypto:AAPL/USD@gate:spot` and maps to `USStock:AAPL`. Find them with exchange Gate, market type Spot, and product type Direct Equity, then select the exact returned product.
1010
- A catalog-confirmed Binance Hong Kong equity perpetual such as `Crypto:HK0700/USDT@binance:swap` uses `stock_perpetual/swap/swap` and may map to `HKStock:00700`. It is a derivative settled under the venue contract, not direct ownership of the Hong Kong share. Do not rewrite it as Gate spot, `HKStock:00700.HK`, or a differently named Binance contract.

‎backend_api_python/app/services/live_trading/capabilities.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@ def supports_swap(self) -> bool:
3030
CRYPTO_VENUE_CAPABILITIES: Dict[str, VenueCapability] = {
3131
"binance": VenueCapability(
3232
"binance", frozenset({"spot", "swap"}),
33-
equity_product_types=frozenset({"stock_perpetual"}),
34-
equity_api_families=frozenset({("stock_perpetual", "swap", "swap")}),
33+
equity_product_types=frozenset({"tokenized_equity", "stock_perpetual"}),
34+
equity_api_families=frozenset({
35+
("tokenized_equity", "spot", "spot"),
36+
("stock_perpetual", "swap", "swap"),
37+
}),
3538
),
3639
"okx": VenueCapability(
3740
"okx", frozenset({"spot", "swap"}),

‎backend_api_python/app/services/market/instrument_products.py‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import re
66
from dataclasses import asdict, dataclass
7-
from typing import Any, Mapping
7+
from typing import Any, Collection, Mapping
88

99

1010
PRODUCT_CRYPTO = "crypto"
@@ -35,15 +35,15 @@ def classify_instrument_product(
3535
market_type: str = "spot",
3636
symbol: str = "",
3737
instrument_id: str = "",
38+
known_equity_symbols: Collection[str] | None = None,
3839
) -> InstrumentProductProfile:
3940
"""Build a conservative product profile from exchange instrument metadata.
4041
4142
The classifier only promotes a symbol to an equity product when the venue
42-
returns an explicit product flag. Bitget Reality is the sole fallback: its
43-
public catalog currently omits ``isReality`` in some CCXT versions, while
44-
the venue-defined ``R<TICKER>`` convention and ``areaSymbol=yes`` remain
45-
available. Unknown instruments stay ordinary crypto and therefore remain
46-
outside equity-product live feature gates.
43+
returns an explicit product flag. Bitget Reality also accepts its venue
44+
marker, while Binance bStocks require both the venue suffix and a matching
45+
reference equity symbol. Unknown instruments stay ordinary crypto and
46+
therefore remain outside equity-product live feature gates.
4747
"""
4848

4949
unified = dict(info or {})
@@ -63,7 +63,18 @@ def classify_instrument_product(
6363
and _looks_like_bitget_reality_base(base)
6464
)
6565
)
66-
is_equity_product = explicit_equity or bitget_reality
66+
known_equities = {
67+
str(value or "").strip().upper()
68+
for value in (known_equity_symbols or ())
69+
if str(value or "").strip()
70+
}
71+
binance_bstock = (
72+
exchange == "binance"
73+
and mt == "spot"
74+
and base.endswith("B")
75+
and base[:-1] in known_equities
76+
)
77+
is_equity_product = explicit_equity or bitget_reality or binance_bstock
6778

6879
if is_equity_product and mt == "swap":
6980
product_type = PRODUCT_STOCK_PERPETUAL

‎backend_api_python/app/services/market/product_catalog.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def get_catalog_product(
2727
clauses.append("market_type = ?")
2828
params.append(str(market_type).strip().lower())
2929
if instrument_id:
30-
clauses.append("instrument_id = ?")
30+
clauses.append("UPPER(instrument_id) = UPPER(?)")
3131
params.append(str(instrument_id).strip())
3232

3333
with get_db_connection() as db:

‎backend_api_python/app/services/market_catalog_sync.py‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from app.services.symbol_master_sync import (
1010
fetch_crypto_symbols_with_diagnostics,
11+
reclassify_stored_equity_products,
1112
upsert_symbol_master,
1213
)
1314
from app.utils.db import get_db_connection
@@ -17,7 +18,7 @@
1718
logger = get_logger(__name__)
1819
_thread_lock = threading.Lock()
1920
_worker = None
20-
MARKET_CATALOG_SCHEMA_VERSION = 2
21+
MARKET_CATALOG_SCHEMA_VERSION = 3
2122

2223

2324
def _json_value(value):
@@ -81,13 +82,15 @@ def _run_sync(run_id: int) -> None:
8182
try:
8283
rows, contexts = fetch_crypto_symbols_with_diagnostics()
8384
written = upsert_symbol_master(rows) if rows else 0
85+
reclassified = reclassify_stored_equity_products()
8486
succeeded = sum(1 for item in contexts if item.get("ok"))
8587
failed = len(contexts) - succeeded
8688
status = "success" if failed == 0 else ("partial" if succeeded else "failed")
8789
_finish_run(run_id, status, {
8890
"catalog_schema_version": MARKET_CATALOG_SCHEMA_VERSION,
8991
"rows": len(rows),
9092
"upserted": written,
93+
"reclassified": reclassified,
9194
"contexts_total": len(contexts),
9295
"contexts_succeeded": succeeded,
9396
"contexts_failed": failed,
@@ -198,7 +201,20 @@ def get_market_catalog_overview() -> dict:
198201
COUNT(*) FILTER (WHERE is_active = 1) AS active,
199202
COUNT(DISTINCT symbol) FILTER (WHERE is_active = 1) AS symbols,
200203
COUNT(*) FILTER (WHERE is_active = 1 AND asset_class = 'equity') AS equities,
201-
COUNT(*) FILTER (WHERE is_active = 1 AND asset_class = 'rwa') AS rwa
204+
COUNT(*) FILTER (WHERE is_active = 1 AND asset_class = 'rwa') AS rwa,
205+
COUNT(*) FILTER (
206+
WHERE is_active = 1 AND market_type = 'spot'
207+
AND COALESCE(product_type, 'crypto') = 'crypto'
208+
) AS ordinary_spot,
209+
COUNT(*) FILTER (
210+
WHERE is_active = 1 AND product_type = 'tokenized_equity'
211+
) AS tokenized_equity,
212+
COUNT(*) FILTER (
213+
WHERE is_active = 1 AND product_type = 'direct_equity'
214+
) AS direct_equity,
215+
COUNT(*) FILTER (
216+
WHERE is_active = 1 AND product_type = 'stock_perpetual'
217+
) AS stock_perpetual
202218
FROM qd_market_symbols
203219
WHERE market = 'Crypto' AND exchange <> ''
204220
GROUP BY LOWER(exchange), market_type
@@ -211,7 +227,20 @@ def get_market_catalog_overview() -> dict:
211227
SELECT COUNT(*) FILTER (WHERE is_active = 1) AS active,
212228
COUNT(DISTINCT symbol) FILTER (WHERE is_active = 1) AS symbols,
213229
COUNT(*) FILTER (WHERE is_active = 1 AND asset_class = 'equity') AS equities,
214-
COUNT(*) FILTER (WHERE is_active = 1 AND asset_class = 'rwa') AS rwa
230+
COUNT(*) FILTER (WHERE is_active = 1 AND asset_class = 'rwa') AS rwa,
231+
COUNT(*) FILTER (
232+
WHERE is_active = 1 AND market_type = 'spot'
233+
AND COALESCE(product_type, 'crypto') = 'crypto'
234+
) AS ordinary_spot,
235+
COUNT(*) FILTER (
236+
WHERE is_active = 1 AND product_type = 'tokenized_equity'
237+
) AS tokenized_equity,
238+
COUNT(*) FILTER (
239+
WHERE is_active = 1 AND product_type = 'direct_equity'
240+
) AS direct_equity,
241+
COUNT(*) FILTER (
242+
WHERE is_active = 1 AND product_type = 'stock_perpetual'
243+
) AS stock_perpetual
215244
FROM qd_market_symbols
216245
WHERE market = 'Crypto'
217246
"""

‎backend_api_python/app/services/script_source.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import time
1313
from typing import Any, Dict, List, Optional
1414

15+
from app.services.strategy_params import canonical_strategy_param_schema
1516
from app.utils.db import get_db_connection
1617
from app.utils.logger import get_logger
1718

@@ -249,7 +250,10 @@ def create_source(self, payload: Dict[str, Any]) -> int:
249250
code = str(payload.get("code") or "")
250251
description = str(payload.get("description") or "")
251252
template_key = str(payload.get("template_key") or payload.get("templateKey") or "")
252-
param_schema = payload.get("param_schema") or payload.get("paramSchema") or {}
253+
param_schema = canonical_strategy_param_schema(
254+
code,
255+
payload.get("param_schema") or payload.get("paramSchema") or {},
256+
)
253257
metadata = payload.get("metadata") or {}
254258
asset_type = _source_asset_type(payload.get("asset_type") or payload.get("assetType"))
255259
source_marketplace_indicator_id = payload.get("source_marketplace_indicator_id") or payload.get("sourceMarketplaceIndicatorId")
@@ -304,7 +308,12 @@ def update_source(self, source_id: int, user_id: int, payload: Dict[str, Any]) -
304308
code = str(payload.get("code") if payload.get("code") is not None else existing.get("code") or "")
305309
description = str(payload.get("description") if payload.get("description") is not None else existing.get("description") or "")
306310
template_key = str(payload.get("template_key") or payload.get("templateKey") or existing.get("template_key") or "")
307-
param_schema = payload.get("param_schema") if "param_schema" in payload else payload.get("paramSchema", existing.get("param_schema") or {})
311+
param_schema = canonical_strategy_param_schema(
312+
code,
313+
payload.get("param_schema")
314+
if "param_schema" in payload
315+
else payload.get("paramSchema", existing.get("param_schema") or {}),
316+
)
308317
metadata = payload.get("metadata") if "metadata" in payload else existing.get("metadata") or {}
309318
asset_type = _source_asset_type(
310319
payload.get("asset_type") if "asset_type" in payload

‎backend_api_python/app/services/strategy_authoring.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,19 @@ def get_strategy_authoring_contract() -> dict[str, Any]:
138138
"underlying_symbol": "00700",
139139
"catalog_validation_required": True,
140140
"direct_share_ownership": False,
141+
}, {
142+
"instrument": "Crypto:NVDAB/USDT@binance:spot",
143+
"market": "Crypto",
144+
"symbol": "NVDAB/USDT",
145+
"exchange_id": "binance",
146+
"market_type": "spot",
147+
"asset_class": "equity",
148+
"product_type": "tokenized_equity",
149+
"api_family": "spot",
150+
"underlying_market": "USStock",
151+
"underlying_symbol": "NVDA",
152+
"catalog_validation_required": True,
153+
"direct_share_ownership": False,
141154
}],
142155
"discovery_filters": {
143156
"required": ["exchange_id", "market_type", "product_type"],
@@ -153,6 +166,12 @@ def get_strategy_authoring_contract() -> dict[str, Any]:
153166
"product_type": "stock_perpetual",
154167
"search_examples": ["HK0700", "TENCENT"],
155168
},
169+
"binance_bstock": {
170+
"exchange_id": "binance",
171+
"market_type": "spot",
172+
"product_type": "tokenized_equity",
173+
"search_examples": ["NVDAB", "AAPLB"],
174+
},
156175
},
157176
"venue_capabilities": {
158177
exchange_id: [
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Canonical parameter schema for Strategy API V2 source code."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from app.services.indicator_params import IndicatorParamsParser
8+
9+
10+
_TYPE_MAP = {
11+
"int": "integer",
12+
"float": "number",
13+
"bool": "boolean",
14+
"str": "text",
15+
"string": "text",
16+
}
17+
18+
19+
def canonical_strategy_param_schema(
20+
code: str,
21+
schema: dict[str, Any] | None = None,
22+
) -> dict[str, Any]:
23+
"""Make declared ``# @param`` defaults authoritative for saved sources."""
24+
existing = dict(schema or {})
25+
declared = IndicatorParamsParser.parse_params(code or "")
26+
if not declared:
27+
return existing
28+
29+
existing_rows = existing.get("params")
30+
previous_by_name = {
31+
str(item.get("name") or ""): dict(item)
32+
for item in (existing_rows if isinstance(existing_rows, list) else [])
33+
if isinstance(item, dict) and str(item.get("name") or "")
34+
}
35+
params: list[dict[str, Any]] = []
36+
for declaration in declared:
37+
name = str(declaration.get("name") or "")
38+
row = previous_by_name.get(name, {})
39+
declared_type = str(declaration.get("type") or "").lower()
40+
previous_type = str(row.get("type") or "").lower()
41+
compatible_types = {
42+
"int": {"int", "integer"},
43+
"float": {"float", "number", "percent"},
44+
"bool": {"bool", "boolean"},
45+
"str": {"str", "string", "text"},
46+
}
47+
row.update({
48+
"name": name,
49+
"type": (
50+
previous_type
51+
if previous_type in compatible_types.get(declared_type, set())
52+
else _TYPE_MAP.get(declared_type, declared_type or "text")
53+
),
54+
"default": declaration.get("default"),
55+
"source": "code_param",
56+
})
57+
description = str(declaration.get("description") or "").strip()
58+
if description:
59+
row["description"] = description
60+
if row["type"] == "integer":
61+
row.setdefault("step", 1)
62+
elif row["type"] == "number":
63+
row.setdefault("step", 0.1)
64+
values = declaration.get("values")
65+
if isinstance(values, list) and values:
66+
row["values"] = list(values)
67+
params.append(row)
68+
69+
return {**existing, "params": params}

‎backend_api_python/app/services/strategy_runtime/timeframes.py‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,27 @@
1919
from app.services.strategy_v2.service import StrategyV2BacktestService
2020

2121

22-
def live_history_days(frequency: str, warmup_bars: int) -> int:
22+
def live_history_days(
23+
frequency: str,
24+
warmup_bars: int,
25+
candidates: list[dict[str, object]] | tuple = (),
26+
) -> int:
2327
"""Return a frequency-aware live lookback with a startup buffer."""
2428
bars = max(10, max(1, int(warmup_bars or 0)) * 3)
2529
seconds = frequency_seconds(frequency) * bars
26-
return max(1, int(math.ceil(seconds / 86_400)))
30+
days = max(1, int(math.ceil(seconds / 86_400)))
31+
normalized = str(frequency or "").strip().lower()
32+
stock_session = any(
33+
str(item.get("market") or "") in {"USStock", "HKStock", "CNStock", "AStock"}
34+
or str(item.get("underlying_market") or "") in {"USStock", "HKStock", "CNStock", "AStock"}
35+
or str(item.get("api_family") or "").strip().lower() == "stock"
36+
for item in candidates
37+
)
38+
if stock_session and normalized.endswith(("m", "h")):
39+
hours = float(normalized[:-1]) / (60 if normalized.endswith("m") else 1)
40+
session_days = math.ceil(max(1, warmup_bars) * 3 * hours / 4 * 7 / 5 * 1.5)
41+
days = max(days, 7, session_days)
42+
return days
2743

2844

2945
def completed_bar_token(frequency: str, now: datetime | None = None) -> int:
@@ -46,7 +62,7 @@ def load_live_frequency_frames(
4662
"""Load a complete live frame bundle for all declared strategy timeframes."""
4763
start_dates = {
4864
frequency: end_date
49-
- timedelta(days=live_history_days(frequency, manifest.warmup_bars))
65+
- timedelta(days=live_history_days(frequency, manifest.warmup_bars, candidates))
5066
for frequency in manifest.frequencies
5167
}
5268
bundles, skipped = service.fetch_frequency_frames(

0 commit comments

Comments
 (0)