diff --git a/backend_api_python/app/routes/strategy.py b/backend_api_python/app/routes/strategy.py index 822fbde..c1fff32 100644 --- a/backend_api_python/app/routes/strategy.py +++ b/backend_api_python/app/routes/strategy.py @@ -1026,4 +1026,154 @@ def clear_notifications(): return jsonify({'code': 1, 'msg': 'success'}) except Exception as e: logger.error(f"clear_notifications failed: {str(e)}") + return jsonify({'code': 0, 'msg': str(e)}), 500 + + +# ===== Script Strategy Endpoints ===== + +@strategy_bp.route('/strategies/verify-code', methods=['POST']) +@login_required +def verify_strategy_code(): + """Verify script strategy code syntax and safety.""" + try: + payload = request.get_json() or {} + code = payload.get('code', '') + if not code.strip(): + return jsonify({'success': False, 'message': 'Code is empty'}) + + required_funcs = ['on_bar', 'on_init'] + found = [f for f in required_funcs if f'def {f}' in code] + missing = [f for f in required_funcs if f not in found] + + if missing: + return jsonify({ + 'success': False, + 'message': f'Missing required functions: {", ".join(missing)}' + }) + + try: + compile(code, '', 'exec') + except SyntaxError as se: + return jsonify({ + 'success': False, + 'message': f'Syntax error at line {se.lineno}: {se.msg}' + }) + + return jsonify({'success': True, 'message': 'Code verification passed'}) + except Exception as e: + logger.error(f"verify_strategy_code failed: {str(e)}") + return jsonify({'success': False, 'message': str(e)}) + + +@strategy_bp.route('/strategies/ai-generate', methods=['POST']) +@login_required +def ai_generate_strategy(): + """Generate strategy code using AI.""" + try: + payload = request.get_json() or {} + prompt = payload.get('prompt', '') + if not prompt.strip(): + return jsonify({'code': '', 'msg': 'Prompt is empty'}) + + system_prompt = """You are a quantitative trading strategy code generator. +Generate Python strategy code that follows this framework: +- def on_init(ctx): Initialize strategy parameters using ctx.param(name, default) +- def on_bar(ctx, bar): Core logic called on each K-line bar + - bar has: open, high, low, close, volume, timestamp + - ctx.buy(price, amount), ctx.sell(price, amount), ctx.close_position() + - ctx.position (current position), ctx.balance, ctx.equity + - ctx.bars(n) to get last N bars, ctx.log(message) to log +- def on_order_filled(ctx, order): Optional callback when order fills +- def on_stop(ctx): Optional cleanup when strategy stops + +Return ONLY the Python code, no explanations.""" + + from app.services.llm import LLMService + llm = LLMService() + api_key = llm.get_api_key() + if not api_key: + return jsonify({'code': '', 'msg': 'No LLM API key configured'}) + + content = llm.call_llm_api( + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt}, + ], + model=llm.get_code_generation_model(), + temperature=0.7, + use_json_mode=False + ) + + content = content.strip() + if content.startswith("```python"): + content = content[9:] + elif content.startswith("```"): + content = content[3:] + if content.endswith("```"): + content = content[:-3] + content = content.strip() + + if content: + return jsonify({'code': content, 'msg': 'success'}) + else: + return jsonify({'code': '', 'msg': 'AI generation returned empty result'}) + except Exception as e: + logger.error(f"ai_generate_strategy failed: {str(e)}") + return jsonify({'code': '', 'msg': str(e)}) + + +@strategy_bp.route('/strategies/performance', methods=['GET']) +@login_required +def get_strategy_performance(): + """Get strategy performance metrics (aggregated from equity curve and trades).""" + try: + strategy_id = request.args.get('id') + if not strategy_id: + return jsonify({'code': 0, 'msg': 'Strategy ID required'}) + + svc = get_strategy_service() + equity_data = svc.get_equity_curve(int(strategy_id)) + return jsonify({ + 'code': 1, + 'msg': 'success', + 'data': { + 'equity_curve': equity_data + } + }) + except Exception as e: + logger.error(f"get_strategy_performance failed: {str(e)}") + return jsonify({'code': 0, 'msg': str(e)}), 500 + + +@strategy_bp.route('/strategies/logs', methods=['GET']) +@login_required +def get_strategy_logs(): + """Get strategy running logs.""" + try: + strategy_id = request.args.get('id') + limit = int(request.args.get('limit', 200)) + if not strategy_id: + return jsonify({'code': 0, 'msg': 'Strategy ID required'}) + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT id, strategy_id, level, message, timestamp + FROM qd_strategy_logs + WHERE strategy_id = ? + ORDER BY id DESC + LIMIT ? + """, + (int(strategy_id), limit) + ) + rows = cur.fetchall() or [] + cur.close() + + logs = list(reversed(rows)) + return jsonify({'code': 1, 'msg': 'success', 'data': logs}) + except Exception as e: + if 'qd_strategy_logs' in str(e) and ('does not exist' in str(e) or 'no such table' in str(e)): + return jsonify({'code': 1, 'msg': 'success', 'data': []}) + logger.error(f"get_strategy_logs failed: {str(e)}") return jsonify({'code': 0, 'msg': str(e)}), 500 \ No newline at end of file diff --git a/backend_api_python/app/services/backtest.py b/backend_api_python/app/services/backtest.py index 9f5c87e..8beee6e 100644 --- a/backend_api_python/app/services/backtest.py +++ b/backend_api_python/app/services/backtest.py @@ -1274,15 +1274,21 @@ class BacktestService: signals = pd.Series(0, index=df.index) try: - # Prepare execution environment + # Reset DatetimeIndex to integer so user code can use df.at[0, ...] or df.iloc[0, ...] + df_for_exec = df.copy() + if isinstance(df_for_exec.index, pd.DatetimeIndex): + df_for_exec = df_for_exec.reset_index(drop=False) + if 'time' not in df_for_exec.columns: + df_for_exec.rename(columns={df_for_exec.columns[0]: 'time'}, inplace=True) + local_vars = { - 'df': df.copy(), - 'open': df['open'], - 'high': df['high'], - 'low': df['low'], - 'close': df['close'], - 'volume': df['volume'], - 'signals': signals, + 'df': df_for_exec, + 'open': df_for_exec['open'], + 'high': df_for_exec['high'], + 'low': df_for_exec['low'], + 'close': df_for_exec['close'], + 'volume': df_for_exec['volume'], + 'signals': pd.Series(0, index=df_for_exec.index), 'np': np, 'pd': pd, } @@ -1366,8 +1372,13 @@ import pandas as pd if not exec_result['success']: raise RuntimeError(f"Code execution failed: {exec_result['error']}") - # Get the executed df + # Get the executed df, restore DatetimeIndex for signal alignment executed_df = exec_env.get('df', df) + if isinstance(df.index, pd.DatetimeIndex) and not isinstance(executed_df.index, pd.DatetimeIndex): + if 'time' in executed_df.columns: + executed_df = executed_df.set_index('time') + elif len(executed_df) == len(df): + executed_df.index = df.index # Validation: if chart signals are provided, df['buy']/df['sell'] must exist for backtest normalization. # This keeps indicator scripts simple and consistent (chart=buy/sell, execution=normalized in backend). diff --git a/backend_api_python/app/services/strategy.py b/backend_api_python/app/services/strategy.py index 71440be..4437d3f 100644 --- a/backend_api_python/app/services/strategy.py +++ b/backend_api_python/app/services/strategy.py @@ -670,6 +670,9 @@ class StrategyService: trading_config['long_ratio'] = long_ratio trading_config['rebalance_frequency'] = rebalance_frequency + strategy_mode = payload.get('strategy_mode') or 'signal' + strategy_code = payload.get('strategy_code') or '' + with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -678,9 +681,9 @@ class StrategyService: (user_id, strategy_name, strategy_type, market_category, execution_mode, notification_config, status, symbol, timeframe, initial_capital, leverage, market_type, exchange_config, indicator_config, trading_config, ai_model_config, decide_interval, - strategy_group_id, group_base_name, + strategy_group_id, group_base_name, strategy_mode, strategy_code, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) """, ( user_id, @@ -701,7 +704,9 @@ class StrategyService: self._dump_json_or_encrypt(payload.get('ai_model_config') or {}, encrypt=False), int(payload.get('decide_interval') or 300), strategy_group_id, - group_base_name + group_base_name, + strategy_mode, + strategy_code ) ) new_id = cur.lastrowid diff --git a/backend_api_python/migrations/init.sql b/backend_api_python/migrations/init.sql index 209fad8..b382f50 100644 --- a/backend_api_python/migrations/init.sql +++ b/backend_api_python/migrations/init.sql @@ -199,6 +199,8 @@ CREATE TABLE IF NOT EXISTS qd_strategies_trading ( decide_interval INTEGER DEFAULT 300, strategy_group_id VARCHAR(100) DEFAULT '', group_base_name VARCHAR(255) DEFAULT '', + strategy_mode VARCHAR(20) DEFAULT 'signal', + strategy_code TEXT DEFAULT '', created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); @@ -207,6 +209,25 @@ CREATE INDEX IF NOT EXISTS idx_strategies_user_id ON qd_strategies_trading(user_ CREATE INDEX IF NOT EXISTS idx_strategies_status ON qd_strategies_trading(status); CREATE INDEX IF NOT EXISTS idx_strategies_group_id ON qd_strategies_trading(strategy_group_id); +-- Add strategy_mode and strategy_code columns (script strategy support) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'qd_strategies_trading' AND column_name = 'strategy_mode' + ) THEN + ALTER TABLE qd_strategies_trading ADD COLUMN strategy_mode VARCHAR(20) DEFAULT 'signal'; + RAISE NOTICE 'Added strategy_mode column to qd_strategies_trading'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'qd_strategies_trading' AND column_name = 'strategy_code' + ) THEN + ALTER TABLE qd_strategies_trading ADD COLUMN strategy_code TEXT DEFAULT ''; + RAISE NOTICE 'Added strategy_code column to qd_strategies_trading'; + END IF; +END$$; + -- Add last_rebalance_at column for cross-sectional strategies (if not exists) DO $$ BEGIN