Skip to content

Commit b9ba063

Browse files
v3.0.14
Signed-off-by: Dinger <[email protected]>
1 parent 91dd4e2 commit b9ba063

37 files changed

Lines changed: 1195 additions & 102 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ backend_api_python/.env
2929
# ========================
3030
.idea/
3131
.vscode/
32+
agent-tools/
3233
*.swp
3334
*.swo
3435
*~

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -633,17 +633,27 @@ Yes—see **[QuantDinger-Mobile](https://github.com/brokermr810/QuantDinger-Mobi
633633

634634
## Exchange Partner Links
635635

636-
The following links are available in-app under **Profile -> Open account** and may qualify users for trading-fee rebates depending on venue policies.
636+
The following links are available in-app under **Profile → Open account** or **Broker Accounts → Open account**, and may qualify users for trading-fee rebates depending on venue policies.
637+
638+
### Crypto exchanges (API keys)
637639

638640
| Exchange | Signup Link |
639641
|----------|-------------|
640642
| Binance | [Register](https://www.bsmkweb.cc/register?ref=QUANTDINGER) |
641643
| Bitget | [Register](https://partner.hdmune.cn/bg/7r4xz8kd) |
642644
| Bybit | [Register](https://partner.bybit.com/b/DINGER) |
643645
| OKX | [Register](https://www.xqmnobxky.com/join/QUANTDINGER) |
644-
| Gate.io | [Register](https://www.gateport.company/share/DINGER) |
646+
| Gate.io | [Register](https://www.gateport.business/share/DINGER) |
645647
| HTX | [Register](https://www.htx.com/invite/zh-cn/1f?invite_code=dinger) |
646648

649+
### Forex / CFD — TMGM (MetaTrader 5)
650+
651+
| Broker | Signup Link |
652+
|--------|-------------|
653+
| TMGM (MT5) | [Register](https://portal.tmgm.com/register?node=MTM0Mzc5&language=en) |
654+
655+
After opening a TMGM account, install MetaTrader 5 and bind your server/login under **Profile → Exchange** or **Broker Accounts**.
656+
647657
## License and Commercial Terms
648658

649659
- Backend source code is licensed under **Apache License 2.0**. See `LICENSE`.

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.0.11
1+
3.0.14

backend_api_python/app/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@
55
CI). Do **not** edit by hand — run the bump script instead.
66
"""
77

8-
APP_VERSION = "3.0.11"
8+
APP_VERSION = "3.0.14"

backend_api_python/app/routes/credentials.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,53 @@ def create_credential():
252252
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
253253

254254

255+
@credentials_bp.route('/update-name', methods=['PUT', 'PATCH'])
256+
@login_required
257+
def update_credential_name():
258+
"""Update display name (alias) only — API keys in encrypted_config are untouched."""
259+
try:
260+
user_id = g.user_id
261+
data = request.get_json() or {}
262+
cred_id = data.get('id')
263+
if cred_id is None:
264+
cred_id = request.args.get('id', type=int)
265+
try:
266+
cred_id = int(cred_id)
267+
except (TypeError, ValueError):
268+
cred_id = None
269+
if not cred_id:
270+
return jsonify({'code': 0, 'msg': 'Missing id', 'data': None}), 400
271+
272+
name = (data.get('name') or '').strip()
273+
if len(name) > 128:
274+
return jsonify({'code': 0, 'msg': 'Name too long (max 128 characters)', 'data': None}), 400
275+
276+
with get_db_connection() as db:
277+
cur = db.cursor()
278+
cur.execute(
279+
"""
280+
UPDATE qd_exchange_credentials
281+
SET name = %s, updated_at = NOW()
282+
WHERE id = %s AND user_id = %s
283+
RETURNING id, name, exchange_id, api_key_hint, created_at, updated_at
284+
""",
285+
(name, cred_id, user_id),
286+
)
287+
row = cur.fetchone()
288+
if not row:
289+
cur.close()
290+
return jsonify({'code': 0, 'msg': 'Not found', 'data': None}), 404
291+
db.commit()
292+
cur.close()
293+
294+
item = dict(row or {})
295+
return jsonify({'code': 1, 'msg': 'success', 'data': item})
296+
except Exception as e:
297+
logger.error(f"update_credential_name failed: {str(e)}")
298+
logger.error(traceback.format_exc())
299+
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
300+
301+
255302
@credentials_bp.route('/delete', methods=['DELETE'])
256303
@login_required
257304
def delete_credential():

backend_api_python/app/routes/indicator.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,44 @@ def delete_indicator():
738738
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
739739

740740

741+
@indicator_bp.route("/applyParamDefaults", methods=["POST"])
742+
@login_required
743+
def apply_param_defaults():
744+
"""
745+
Apply tuned indicator parameter values into ``# @param`` lines in source code.
746+
747+
Body: { "code": "...", "indicatorParams": { "sma_short": 11, "sma_long": 35 } }
748+
or flat keys: { "indicator_params.sma_short": 11, ... }
749+
"""
750+
try:
751+
data = request.get_json() or {}
752+
code = str(data.get("code") or "")
753+
if not code.strip():
754+
return jsonify({"code": 0, "msg": "code is required", "data": None}), 400
755+
756+
params = data.get("indicatorParams") or data.get("indicator_params") or {}
757+
if not isinstance(params, dict):
758+
params = {}
759+
for key, value in list((data.get("overrides") or {}).items()):
760+
k = str(key or "")
761+
if k.startswith("indicator_params."):
762+
params[k.split(".", 1)[1]] = value
763+
764+
from app.services.experiment.overrides import enrich_experiment_overrides
765+
766+
nested = enrich_experiment_overrides({"indicatorParams": params}).get("indicatorParams") or params
767+
new_code = IndicatorParamsParser.apply_defaults_to_code(code, nested)
768+
changed = new_code != code
769+
return jsonify({
770+
"code": 1,
771+
"msg": "success",
772+
"data": {"code": new_code, "changed": changed, "indicatorParams": nested},
773+
})
774+
except Exception as e:
775+
logger.error("apply_param_defaults failed: %s", e, exc_info=True)
776+
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
777+
778+
741779
@indicator_bp.route("/getIndicatorParams", methods=["GET"])
742780
@login_required
743781
def get_indicator_params():

backend_api_python/app/routes/quick_trade.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1534,6 +1534,29 @@ def close_position():
15341534
actual_close_size = position_size
15351535
if actual_close_size <= 0:
15361536
return jsonify({"code": 0, "msg": "Close size is zero"}), 400
1537+
1538+
if market_type == "spot":
1539+
from app.services.live_trading.spot_sizing import clamp_spot_close_quantity
1540+
1541+
adjusted, spot_meta = clamp_spot_close_quantity(
1542+
client, symbol=symbol, requested_qty=actual_close_size
1543+
)
1544+
if adjusted <= 0:
1545+
return jsonify(
1546+
{
1547+
"code": 0,
1548+
"msg": "可卖余额不足,无法平仓(可能因买入手续费导致可用数量小于持仓记录)",
1549+
}
1550+
), 400
1551+
if spot_meta.get("adjusted"):
1552+
logger.info(
1553+
"quick_trade spot close adjusted: symbol=%s requested=%s final=%s meta=%s",
1554+
symbol,
1555+
actual_close_size,
1556+
adjusted,
1557+
spot_meta,
1558+
)
1559+
actual_close_size = adjusted
15371560

15381561
# ---- determine signal type based on position side ----
15391562
if market_type == "spot":

backend_api_python/app/routes/settings.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,20 @@ def _refresh_runtime_services() -> None:
648648
'default': '10',
649649
'description': 'Wait time for limit order fill before switching to market order'
650650
},
651+
{
652+
'key': 'SPOT_CLOSE_SAFETY_RATIO',
653+
'label': 'Spot Close Safety Ratio',
654+
'type': 'number',
655+
'default': '0.998',
656+
'description': 'When closing spot long, sell qty is capped to (exchange free base × this ratio), then floored to lot step. Lower if full close fails due to fees (valid range 0.9–1.0).'
657+
},
658+
{
659+
'key': 'SPOT_OPEN_QUOTE_BUFFER',
660+
'label': 'Spot Open Quote Buffer',
661+
'type': 'number',
662+
'default': '0.995',
663+
'description': 'Fraction of USDT/notional used on spot open (reserve headroom for buy fees). Example 0.995 uses 99.5% of allocated quote (valid range 0.9–1.0).'
664+
},
651665
{
652666
'key': 'ALLOW_LOCAL_DESKTOP_BROKERS',
653667
'label': 'Allow IBKR / MT5 (local desktop brokers)',

backend_api_python/app/routes/user.py

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1123,6 +1123,81 @@ def _safe_json_loads(s, default=None):
11231123
return default
11241124

11251125

1126+
def _strategy_exchange_display_name(
1127+
exchange_config: dict,
1128+
*,
1129+
credential_map: dict,
1130+
user_id: int = 0,
1131+
) -> str:
1132+
"""Resolve exchange label for admin strategy lists.
1133+
1134+
Strategies often persist ``exchange_config`` as ``{credential_id: N}`` only
1135+
(API secrets live in ``qd_exchange_credentials``). Read inline ``exchange_id``
1136+
first, then the credential row's ``exchange_id``, then ``resolve_exchange_config``
1137+
as a last resort.
1138+
"""
1139+
if not isinstance(exchange_config, dict):
1140+
return ''
1141+
1142+
direct = (
1143+
exchange_config.get('exchange_id')
1144+
or exchange_config.get('exchange')
1145+
or exchange_config.get('broker')
1146+
or ''
1147+
)
1148+
direct = str(direct or '').strip()
1149+
if direct:
1150+
return direct
1151+
1152+
cred_id = exchange_config.get('credential_id') or exchange_config.get('credentials_id')
1153+
if cred_id:
1154+
try:
1155+
row = credential_map.get(int(cred_id))
1156+
except (TypeError, ValueError):
1157+
row = None
1158+
if row:
1159+
ex = str(row.get('exchange_id') or '').strip()
1160+
if ex:
1161+
return ex
1162+
1163+
try:
1164+
from app.services.exchange_execution import resolve_exchange_config
1165+
1166+
resolved = resolve_exchange_config(exchange_config, user_id=int(user_id or 1))
1167+
ex = str(resolved.get('exchange_id') or resolved.get('exchange') or '').strip()
1168+
if ex:
1169+
return ex
1170+
except Exception:
1171+
pass
1172+
1173+
return ''
1174+
1175+
1176+
def _batch_load_credential_exchange_map(credential_ids: set) -> dict:
1177+
"""Map credential id -> {id, exchange_id, name} for display (no decrypt)."""
1178+
if not credential_ids:
1179+
return {}
1180+
ids = sorted({int(i) for i in credential_ids if i})
1181+
if not ids:
1182+
return {}
1183+
placeholders = ','.join(['?'] * len(ids))
1184+
credential_map = {}
1185+
with get_db_connection() as db:
1186+
cur = db.cursor()
1187+
cur.execute(
1188+
f"""
1189+
SELECT id, exchange_id, name
1190+
FROM qd_exchange_credentials
1191+
WHERE id IN ({placeholders})
1192+
""",
1193+
tuple(ids),
1194+
)
1195+
for row in (cur.fetchall() or []):
1196+
credential_map[int(row['id'])] = dict(row)
1197+
cur.close()
1198+
return credential_map
1199+
1200+
11261201
@user_bp.route('/system-strategies', methods=['GET'])
11271202
@login_required
11281203
@admin_required
@@ -1299,7 +1374,18 @@ def get_system_strategies():
12991374

13001375
cur.close()
13011376

1302-
# Build response
1377+
# Build response — batch-resolve exchange names for credential_id-only configs.
1378+
cred_ids = set()
1379+
for s in strategies:
1380+
ec = _safe_json_loads(s.get('exchange_config'), {})
1381+
cid = ec.get('credential_id') or ec.get('credentials_id')
1382+
if cid:
1383+
try:
1384+
cred_ids.add(int(cid))
1385+
except (TypeError, ValueError):
1386+
pass
1387+
credential_map = _batch_load_credential_exchange_map(cred_ids)
1388+
13031389
items = []
13041390
for s in strategies:
13051391
sid = s['id']
@@ -1322,10 +1408,12 @@ def get_system_strategies():
13221408
else:
13231409
indicator_name = s.get('strategy_name') or ''
13241410

1325-
# Extract exchange name
1326-
exchange_name = ''
1327-
if isinstance(exchange_config, dict):
1328-
exchange_name = exchange_config.get('exchange_id') or exchange_config.get('exchange') or ''
1411+
# Extract exchange name (inline config or saved credential reference).
1412+
exchange_name = _strategy_exchange_display_name(
1413+
exchange_config,
1414+
credential_map=credential_map,
1415+
user_id=int(s.get('user_id') or 0),
1416+
)
13291417

13301418
# Positions data
13311419
positions = positions_map.get(sid, [])

backend_api_python/app/services/alpaca_trading/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,23 @@ All endpoints require `@login_required`:
132132
| Extended hours | Configurable per-order | Configurable per-order (limit only) |
133133
| Crypto | No (separate IBKR Crypto product) | Yes, same client |
134134

135+
## Troubleshooting: WebSocket `400 invalid syntax`
136+
137+
QuantDinger **connection test** and live orders use the **REST Trading API** (`TradingClient.get_account()`), **not** the market-data WebSocket at `wss://stream.data.alpaca.markets/...`.
138+
139+
If you see Alpaca error **code 400 / invalid syntax** from the [streaming docs](https://docs.alpaca.markets/us/docs/streaming-market-data#authentication), that is almost always from a **WebSocket client** (custom script, another app, or a chart feed), not from `/api/alpaca/connect` or `/strategies/test-connection`.
140+
141+
Common causes:
142+
143+
| Issue | Fix |
144+
|-------|-----|
145+
| Subscribe before auth | After `connected`, send `{"action":"auth","key":"...","secret":"..."}` within 10s |
146+
| Wrong JSON shape | Use `action` + channel keys, e.g. `{"action":"subscribe","trades":["AAPL"]}` |
147+
| Invalid symbol | US stocks: `AAPL` (no `BTC/USDT`). Crypto: `BTC/USD` (Alpaca does **not** use `BTC/USDT`) |
148+
| Class shares | `BRK/B``BRK.B` for REST; do not treat as a crypto pair |
149+
150+
This module normalizes symbols (`symbols.py`): `BTC/USDT``BTC/USD` for crypto, `BRK/B``BRK.B` for equities.
151+
135152
## Limitations / TODO
136153

137154
- [ ] No bracket orders yet (Alpaca supports them; not yet wrapped)

0 commit comments

Comments
 (0)