Skip to content

Commit 57b54c5

Browse files
v3.0.17
Signed-off-by: Dinger <[email protected]>
1 parent c613586 commit 57b54c5

70 files changed

Lines changed: 8488 additions & 1482 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.0.15
1+
3.0.17

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.15"
8+
APP_VERSION = "3.0.17"

backend_api_python/app/routes/auth.py

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,15 @@ def _get_user_agent() -> str:
108108
return request.headers.get('User-Agent', '')[:500]
109109

110110

111+
def _userinfo_must_change_initial_password(user_id: int) -> bool:
112+
"""Whether the UI should prompt the user to change their bootstrap password."""
113+
try:
114+
from app.services.user_service import get_user_service
115+
return get_user_service().must_change_initial_password(int(user_id))
116+
except Exception:
117+
return False
118+
119+
111120
# =============================================================================
112121
# Security Config Endpoint
113122
# =============================================================================
@@ -249,8 +258,15 @@ def login():
249258
security.record_login_attempt(username, 'account', True, ip_address, user_agent)
250259
security.clear_login_attempts(ip_address, 'ip')
251260
security.clear_login_attempts(username, 'account')
252-
security.log_security_event('login_success', user.get('id'), ip_address, user_agent)
253-
261+
from app.services.login_notify import notify_successful_login
262+
notify_successful_login(
263+
user_id=int(user.get('id') or user_id),
264+
action='login_success',
265+
ip_address=ip_address,
266+
user_agent=user_agent,
267+
extra_details={'method': 'password'},
268+
)
269+
254270
# Build user info for frontend
255271
userinfo = {
256272
'id': user.get('id') or user.get('user_id', 1),
@@ -261,7 +277,8 @@ def login():
261277
'role': {
262278
'id': user.get('role', 'admin'),
263279
'permissions': _get_permissions(user.get('role', 'admin'))
264-
}
280+
},
281+
'must_change_initial_password': _userinfo_must_change_initial_password(user_id),
265282
}
266283

267284
return jsonify({
@@ -455,9 +472,15 @@ def login_with_code():
455472
except Exception as e:
456473
logger.error(f"Failed to update last_login_at for user_id={user.get('id')}: {e}")
457474

458-
# Log login
459-
security.log_security_event('login_via_code', user['id'], ip_address, user_agent)
460-
475+
from app.services.login_notify import notify_successful_login
476+
notify_successful_login(
477+
user_id=int(user['id']),
478+
action='login_via_code',
479+
ip_address=ip_address,
480+
user_agent=user_agent,
481+
extra_details={'method': 'email_code', 'is_new_user': bool(is_new_user)},
482+
)
483+
461484
return jsonify({
462485
'code': 1,
463486
'msg': 'Login successful' + (' (new account created)' if is_new_user else ''),
@@ -993,10 +1016,15 @@ def oauth_google_callback():
9931016
token_version=new_token_version
9941017
)
9951018

996-
# Log OAuth login
997-
security.log_security_event('oauth_login', user_result['id'], ip_address, user_agent,
998-
{'provider': 'google'})
999-
1019+
from app.services.login_notify import notify_successful_login
1020+
notify_successful_login(
1021+
user_id=int(user_result['id']),
1022+
action='oauth_login',
1023+
ip_address=ip_address,
1024+
user_agent=user_agent,
1025+
extra_details={'provider': 'google', 'method': 'oauth'},
1026+
)
1027+
10001028
# Redirect to frontend with token
10011029
return redirect(_build_frontend_login_redirect(frontend_url, oauth_token=token))
10021030

@@ -1085,10 +1113,15 @@ def oauth_github_callback():
10851113
token_version=new_token_version
10861114
)
10871115

1088-
# Log OAuth login
1089-
security.log_security_event('oauth_login', user_result['id'], ip_address, user_agent,
1090-
{'provider': 'github'})
1091-
1116+
from app.services.login_notify import notify_successful_login
1117+
notify_successful_login(
1118+
user_id=int(user_result['id']),
1119+
action='oauth_login',
1120+
ip_address=ip_address,
1121+
user_agent=user_agent,
1122+
extra_details={'provider': 'github', 'method': 'oauth'},
1123+
)
1124+
10921125
# Redirect to frontend with token
10931126
return redirect(_build_frontend_login_redirect(frontend_url, oauth_token=token))
10941127

@@ -1128,11 +1161,12 @@ def get_user_info():
11281161
logger.warning(f"Failed to get user from database: {e}")
11291162

11301163
if user_data:
1164+
uid = user_data.get('id')
11311165
return jsonify({
11321166
'code': 1,
11331167
'msg': 'Success',
11341168
'data': {
1135-
'id': user_data.get('id'),
1169+
'id': uid,
11361170
'username': user_data.get('username'),
11371171
'nickname': user_data.get('nickname', 'User'),
11381172
'email': user_data.get('email'),
@@ -1141,7 +1175,8 @@ def get_user_info():
11411175
'role': {
11421176
'id': user_data.get('role', 'user'),
11431177
'permissions': _get_permissions(user_data.get('role', 'user'))
1144-
}
1178+
},
1179+
'must_change_initial_password': _userinfo_must_change_initial_password(uid),
11451180
}
11461181
})
11471182

backend_api_python/app/routes/backtest.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -176,13 +176,6 @@ def run_backtest():
176176
# Extract params - use current user's ID
177177
user_id = g.user_id
178178
indicator_code = data.get('indicatorCode', '')
179-
is_safe_code, unsafe_reason = validate_code_safety(indicator_code or '')
180-
if not is_safe_code:
181-
return jsonify({
182-
'code': 0,
183-
'msg': f'Unsafe indicator code: {unsafe_reason}',
184-
'data': None
185-
}), 400
186179
indicator_id = data.get('indicatorId')
187180
symbol = (data.get('symbol') or '').strip()
188181
market = (data.get('market') or '').strip()
@@ -229,6 +222,14 @@ def run_backtest():
229222
'msg': 'Missing required parameters',
230223
'data': None
231224
}), 400
225+
226+
is_safe_code, unsafe_reason = validate_code_safety(indicator_code or '')
227+
if not is_safe_code:
228+
return jsonify({
229+
'code': 0,
230+
'msg': f'Unsafe indicator code: {unsafe_reason}',
231+
'data': None
232+
}), 400
232233

233234
# 转换日期
234235
# 开始日期:当天的 00:00:00

backend_api_python/app/routes/indicator.py

Lines changed: 75 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,14 @@ def _indicator_hint_to_text(hint_code: str, params: Dict[str, Any] | None = None
340340
if hint_code == "MISSING_OUTPUT":
341341
return "缺少 output 字典。" if is_zh else "Missing output dictionary."
342342
if hint_code == "MISSING_BUY_SELL_COLUMNS":
343-
return "缺少 df['buy'] 或 df['sell'] 信号列。" if is_zh else "Missing df['buy'] or df['sell'] signal columns."
343+
return (
344+
"缺少执行信号列:请提供四路 open_long/close_long/open_short/close_short,"
345+
"或两路 buy/sell。"
346+
if is_zh
347+
else
348+
"Missing execution columns: provide four-way open_long/close_long/open_short/close_short "
349+
"or two-way buy/sell."
350+
)
344351
if hint_code == "MISSING_DF_COPY":
345352
return "缺少 df = df.copy()。" if is_zh else "Missing df = df.copy()."
346353
if hint_code == "MISSING_INDICATOR_NAME":
@@ -539,6 +546,16 @@ def save_indicator():
539546
if not code or not str(code).strip():
540547
return jsonify({"code": 0, "msg": "code is required", "data": None}), 400
541548

549+
from app.utils.safe_exec import validate_code_safety
550+
551+
is_safe_code, unsafe_reason = validate_code_safety(code)
552+
if not is_safe_code:
553+
return jsonify({
554+
"code": 0,
555+
"msg": f"Unsafe indicator code: {unsafe_reason}",
556+
"data": None,
557+
}), 400
558+
542559
# Local dev UX: if name/description not provided, derive from code variables.
543560
if not name or not description:
544561
meta = _extract_indicator_meta_from_code(code)
@@ -902,6 +919,7 @@ def _err_stream():
902919
- Environment: browser-side Pyodide–style sandbox **or** API verify sandbox: **no network**, no file I/O, no subprocess.
903920
- **`pd` and `np` are already available.** Do **not** write `import pandas` / `import numpy`. Avoid any `import` unless unavoidable; never import `os`, `sys`, `requests`, `socket`, `subprocess`, `threading`, `sqlite3`, `multiprocessing`, or other I/O/network modules.
904921
- Do **not** use: `eval`, `exec`, `compile`, `open`, `__import__`, `getattr`/`setattr`/`delattr` on untrusted names, `globals`, `vars`, `dir`, or meta-programming to escape the sandbox. `locals()` is allowed if needed to assemble `output` (backtest/verify allow it); avoid `globals()`.
922+
- Allowed imports only: `numpy`, `pandas`, `math`, `json`, `datetime`, `time`, `collections`, `functools`, `itertools`, `statistics`, `decimal`, `fractions`, `copy`. **Never** `import operator`.
905923
- Work **vectorized** with pandas on `df` where possible; avoid O(n) Python loops over every row for core series (rolling/ewm/shift are preferred).
906924
907925
# Series vs ndarray contract (critical — common AI bug source)
@@ -940,20 +958,25 @@ def _err_stream():
940958
941959
# Backtest contract (strict)
942960
943-
The backtest engine reads **boolean** columns on `df`:
961+
**Preferred (platform default): four-way execution columns**
944962
945-
- `df['buy']` — True on bars where a **new** long entry signal is allowed (edge-triggered).
946-
- `df['sell']` — True on bars where a **new** exit / short entry signal is allowed (per product semantics).
963+
- `df['open_long']`, `df['close_long']`, `df['open_short']`, `df['close_short']` — all bool, length `len(df)`.
964+
- Use an `edge(s)` helper: `s & ~s.shift(1).fillna(False)` on each raw condition.
965+
- On trend flip bars you MAY set both `close_*` and opposing `open_*` true (flip_mode R2); for tp/sl-only exits use `close_*` alone without mixing tp/sl into `buy`/`sell`.
966+
- Declare contract header comments: `# signal_form: four_way`, `# exit_owner: engine|indicator`, `# flip_mode: R1|R2`.
967+
- See `docs/SIGNAL_EXECUTION_STANDARD_CN.md`.
947968
948-
Rules:
969+
**Legacy two-way** (simple crossover only):
970+
971+
- `df['buy']` / `df['sell']` — edge-triggered; `tradeDirection both` maps buy→open long (flip short first), sell→open short (flip long first). Do **not** use buy/sell for “close only” exits when `both` — use four-way `close_*`.
972+
973+
Rules (both forms):
949974
950975
- Same **index and length** as `df`; dtype boolean (use `.astype(bool)` after fillna).
951-
- **Edge-trigger (mandatory)** unless the user explicitly asks for repeated signals on consecutive bars:
952-
- `raw_buy = (...condition...)`
953-
- `buy = raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))`
954-
- Same pattern for `raw_sell` / `sell`.
955-
- Signals represent **confirmation on bar close**; the engine fills on the **next bar open** (live-like). Do not implement intrabar lookahead (e.g. do not use the same bar’s `high` to validate a signal that assumes you bought at that bar’s `open` unless the user clearly wants that research mode).
976+
- **Edge-trigger (mandatory)** unless the user explicitly asks for repeated signals on consecutive bars.
977+
- Signals represent **confirmation on bar close**; the engine fills on the **next bar open** (live-like). Do not implement intrabar lookahead unless the user clearly wants research mode.
956978
- Fill NaN from indicators before comparisons; replace division-by-zero (`replace(0, np.nan)` then fill).
979+
- If you use four-way columns, you do **not** need `df['buy']`/`df['sell']` unless `output['signals']` chart markers require them (markers can use open_long/open_short only).
957980
958981
# Chart output: `output` dict (strict)
959982
@@ -1007,6 +1030,8 @@ def _err_stream():
10071030
- `trailingStopPct`, `trailingActivationPct`: float **0–1**.
10081031
- `tradeDirection`: exactly `long`, `short`, or `both`.
10091032
1033+
**`tradeDirection both` execution semantics:** `df['buy']` → open long (close short first if short); `df['sell']` → open short (close long first if long). Do not document `buy` as a separate close-short column. If the strategy uses in-code tp/sl on `high`/`low` touches, prefer **not** also setting `trailingEnabled true` unless the user explicitly wants engine trailing — see `docs/STRATEGY_DEV_GUIDE.md`.
1034+
10101035
**Do not** put `leverage` in `@strategy`; users set leverage in the IDE backtest panel.
10111036
10121037
**Do not** emit `signalTiming`; the product fixes fills to next bar open.
@@ -1035,58 +1060,21 @@ def _err_stream():
10351060
"""
10361061

10371062
def _template_code() -> str:
1038-
# Fallback template that follows the project expectations.
1039-
header = (
1040-
f"my_indicator_name = \"Custom Indicator\"\n"
1041-
f"my_indicator_description = \"{(prompt or '').replace('\n', ' ')[:200]}\"\n\n"
1042-
)
1043-
body = (
1044-
"# ===== Strategy defaults (single source of truth) =====\n"
1045-
"# @strategy stopLossPct 0.03 # Hard stop-loss (3%)\n"
1046-
"# @strategy takeProfitPct 0.06 # Take-profit (6%)\n"
1047-
"# @strategy entryPct 1.0 # Use 100% of available capital per entry\n"
1048-
"# @strategy trailingEnabled false # Set true to enable trailing stop\n"
1049-
"# @strategy trailingStopPct 0.02 # Trailing distance (2%)\n"
1050-
"# @strategy trailingActivationPct 0.03 # Activate trailing after +3% in profit\n"
1051-
"# @strategy tradeDirection long # long | short | both\n\n"
1052-
"# ===== Indicator parameters =====\n"
1053-
"# @param rsi_len int 14 RSI period\n\n"
1054-
"rsi_len = params.get('rsi_len', 14)\n"
1055-
"df = df.copy()\n\n"
1056-
"# Example: robust RSI with edge-triggered buy/sell (no position management, no TP/SL on chart)\n"
1057-
"delta = df['close'].diff()\n"
1058-
"gain = delta.clip(lower=0)\n"
1059-
"loss = (-delta).clip(lower=0)\n"
1060-
"# Wilder-style smoothing (stable and avoids early NaN explosion)\n"
1061-
"avg_gain = gain.ewm(alpha=1/rsi_len, adjust=False).mean()\n"
1062-
"avg_loss = loss.ewm(alpha=1/rsi_len, adjust=False).mean()\n"
1063-
"rs = avg_gain / avg_loss.replace(0, np.nan)\n"
1064-
"rsi = 100 - (100 / (1 + rs))\n"
1065-
"rsi = rsi.fillna(50)\n\n"
1066-
"# Raw conditions (avoid overly strict filters)\n"
1067-
"raw_buy = (rsi < 30)\n"
1068-
"raw_sell = (rsi > 70)\n"
1069-
"# One-shot signals\n"
1070-
"buy = (raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))).astype(bool)\n"
1071-
"sell = (raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False))).astype(bool)\n"
1072-
"df['buy'] = buy\n"
1073-
"df['sell'] = sell\n\n"
1074-
"buy_marks = [df['low'].iloc[i] * 0.995 if bool(df['buy'].iloc[i]) else None for i in range(len(df))]\n"
1075-
"sell_marks = [df['high'].iloc[i] * 1.005 if bool(df['sell'].iloc[i]) else None for i in range(len(df))]\n\n"
1076-
"output = {\n"
1077-
" 'name': my_indicator_name,\n"
1078-
" 'plots': [\n"
1079-
" {'name': 'RSI(14)', 'data': rsi.tolist(), 'color': '#faad14', 'overlay': False}\n"
1080-
" ],\n"
1081-
" 'signals': [\n"
1082-
" {'type': 'buy', 'text': 'B', 'data': buy_marks, 'color': '#00E676'},\n"
1083-
" {'type': 'sell', 'text': 'S', 'data': sell_marks, 'color': '#FF5252'}\n"
1084-
" ]\n"
1085-
"}\n"
1063+
from app.services.indicator_default_template import build_default_indicator_template
1064+
1065+
desc = (prompt or "").replace("\n", " ")[:200]
1066+
if not desc:
1067+
desc = (
1068+
"双均线四路信号模板:边缘触发 + 引擎风控。"
1069+
"详见 SIGNAL_EXECUTION_STANDARD_CN.md"
1070+
)
1071+
code = build_default_indicator_template(
1072+
name="Custom Indicator",
1073+
description=desc,
10861074
)
10871075
if existing:
1088-
header = "# Existing code was provided as context.\n" + header
1089-
return header + body
1076+
code = "# Existing code was provided as context.\n" + code
1077+
return code
10901078

10911079
def _generate_code_via_llm() -> str:
10921080
"""Use unified LLMService to support all configured providers (OpenRouter, OpenAI, Grok, etc.)."""
@@ -1115,7 +1103,7 @@ def _generate_code_via_llm() -> str:
11151103
+ existing.strip()
11161104
+ "\n```\n\n# Change request:\n\n"
11171105
+ prompt
1118-
+ "\n\nReturn one full replacement script: same QuantDinger rules (my_indicator_name/description, df = df.copy(), declared @param values must be read via params.get(...), df['buy']/df['sell'], output dict, list lengths == len(df)). "
1106+
+ "\n\nReturn one full replacement script: same QuantDinger rules (my_indicator_name/description, df = df.copy(), declared @param values must be read via params.get(...), four-way OR buy/sell execution columns, output dict, list lengths == len(df)). "
11191107
"Python only — no markdown, no prose outside the code."
11201108
)
11211109

@@ -1368,6 +1356,32 @@ def stream():
13681356
)
13691357

13701358

1359+
@indicator_bp.route("/defaultTemplate", methods=["GET"])
1360+
@login_required
1361+
def get_default_indicator_template():
1362+
"""
1363+
Return the platform default indicator starter (four-way, contract v1).
1364+
1365+
GET /api/indicator/defaultTemplate
1366+
Optional query: name=...&description=...
1367+
"""
1368+
from app.services.indicator_default_template import build_default_indicator_template
1369+
1370+
args = request.args or {}
1371+
name = (args.get("name") or "").strip() or "策略模板(四路信号)"
1372+
description = (args.get("description") or "").strip() or (
1373+
"双均线金叉/死叉:四路显式信号 + 边缘触发 + 引擎风控。"
1374+
)
1375+
code = build_default_indicator_template(name=name, description=description)
1376+
return jsonify(
1377+
{
1378+
"code": 1,
1379+
"msg": "success",
1380+
"data": {"code": code, "name": name, "description": description},
1381+
}
1382+
)
1383+
1384+
13711385
@indicator_bp.route("/codeQualityHints", methods=["POST"])
13721386
@login_required
13731387
def code_quality_hints():

0 commit comments

Comments
 (0)