cf1023754a
- :504 buy_price 可能为0导致除零,添加 if buy_price > 0 else 0 保护 - :1006 partial_sell 日志中 pos['avg_cost'] 除零,添加 if pos['avg_cost'] else 0 - :1055 sell 日志中 pos['avg_cost'] 除零,添加 if pos['avg_cost'] else 0
1305 lines
54 KiB
Python
1305 lines
54 KiB
Python
"""
|
|
智能交易引擎 v7.1 — 将回测验证的最优算法应用到实盘模拟交易
|
|
|
|
核心功能:
|
|
1. 读取用户的算法配置 (sim_algo_config)
|
|
2. 基于全景扫描信号生成买入决策
|
|
3. 基于持仓元数据 + 当前价格生成卖出/部分止盈决策
|
|
4. 动态仓位计算(信号加权)
|
|
5. 记录所有决策过程到 sim_trade_signals
|
|
6. 手续费模拟(佣金万2.5 + 印花税千1卖出)
|
|
|
|
算法来源: docs/algorithm_recommendation.md
|
|
回测验证: backtest_v6_analysis_report.md + backtest_v7_timing_comparison.md
|
|
"""
|
|
|
|
from datetime import date, datetime, time as dt_time
|
|
from decimal import Decimal
|
|
import traceback
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════
|
|
# 0. 手续费计算
|
|
# ═══════════════════════════════════════════════════════
|
|
|
|
# 佣金费率: 万分之2.5 (双向收取, 最低5元)
|
|
COMMISSION_RATE = 0.00025
|
|
COMMISSION_MIN = 5.0
|
|
|
|
# 印花税费率: 千分之1 (仅卖出收取)
|
|
STAMP_TAX_RATE = 0.001
|
|
|
|
# 滑点费率: 买入+0.3%, 卖出-0.3%
|
|
SLIPPAGE_RATE = 0.003
|
|
|
|
# 涨跌停阈值 (实际10%/20%, 留0.2%缓冲)
|
|
PRICE_LIMIT_NORMAL = 0.098 # 主板 10% (实际检测9.8%)
|
|
PRICE_LIMIT_STAR_GEM = 0.198 # 科创板/创业板 20% (实际检测19.8%)
|
|
|
|
|
|
def apply_slippage(price, trade_type):
|
|
"""
|
|
应用滑点: 买入时价格上浮, 卖出时价格下浮
|
|
|
|
参数:
|
|
price: 信号价格
|
|
trade_type: 'buy' 或 'sell'
|
|
|
|
返回:
|
|
float: 滑点调整后的实际成交价
|
|
"""
|
|
if trade_type == 'buy':
|
|
return round(price * (1 + SLIPPAGE_RATE), 4)
|
|
else: # sell
|
|
return round(price * (1 - SLIPPAGE_RATE), 4)
|
|
|
|
|
|
def get_price_limit(stock_code):
|
|
"""
|
|
获取股票的涨跌停幅度
|
|
|
|
参数:
|
|
stock_code: 股票代码
|
|
|
|
返回:
|
|
float: 涨跌停比例 (0.098 或 0.198)
|
|
"""
|
|
# 科创板 (688xxx) 和 创业板 (300xxx/301xxx) 涨跌停20%
|
|
if stock_code.startswith('688') or stock_code.startswith('300') or stock_code.startswith('301'):
|
|
return PRICE_LIMIT_STAR_GEM
|
|
# ST股涨跌停5% (简化: 不特别处理, 用主板标准)
|
|
return PRICE_LIMIT_NORMAL
|
|
|
|
|
|
def check_price_limit(stock_code, current_price, prev_close):
|
|
"""
|
|
检查股票是否涨跌停
|
|
|
|
参数:
|
|
stock_code: 股票代码
|
|
current_price: 当前价格
|
|
prev_close: 昨日收盘价
|
|
|
|
返回:
|
|
dict: {
|
|
'at_up_limit': bool, # 是否涨停
|
|
'at_down_limit': bool, # 是否跌停
|
|
'change_pct': float, # 涨跌幅%
|
|
}
|
|
"""
|
|
if not prev_close or prev_close <= 0:
|
|
return {'at_up_limit': False, 'at_down_limit': False, 'change_pct': 0}
|
|
|
|
limit = get_price_limit(stock_code)
|
|
change_pct = (current_price - prev_close) / prev_close
|
|
|
|
return {
|
|
'at_up_limit': change_pct >= limit,
|
|
'at_down_limit': change_pct <= -limit,
|
|
'change_pct': round(change_pct * 100, 2),
|
|
}
|
|
|
|
|
|
def calc_trade_fees(price, quantity, trade_type):
|
|
"""
|
|
计算交易手续费
|
|
|
|
参数:
|
|
price: 成交价格
|
|
quantity: 成交数量
|
|
trade_type: 'buy' 或 'sell'
|
|
|
|
返回:
|
|
dict: {
|
|
'commission': float, # 佣金
|
|
'stamp_tax': float, # 印花税
|
|
'total_fee': float, # 总手续费
|
|
}
|
|
"""
|
|
amount = price * quantity
|
|
|
|
# 佣金 (买卖双向, 最低5元)
|
|
commission = max(amount * COMMISSION_RATE, COMMISSION_MIN)
|
|
|
|
# 印花税 (仅卖出)
|
|
stamp_tax = amount * STAMP_TAX_RATE if trade_type == 'sell' else 0.0
|
|
|
|
return {
|
|
'commission': round(commission, 2),
|
|
'stamp_tax': round(stamp_tax, 2),
|
|
'total_fee': round(commission + stamp_tax, 2),
|
|
}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════
|
|
# 1. 算法配置管理
|
|
# ═══════════════════════════════════════════════════════
|
|
|
|
DEFAULT_CONFIG = {
|
|
'algo_name': 'PE50G3+BE8',
|
|
'take_profit_pct': 12.0,
|
|
'stop_loss_pct': 8.0,
|
|
'ignore_sell_signal': False,
|
|
'sell_confirm_days': 3,
|
|
'max_hold_days': 60,
|
|
'no_timeout_if_rising': True,
|
|
'total_capital': 200000.0,
|
|
'position_pct': 8.0,
|
|
'signal_weight': True,
|
|
'partial_exit_pct': 50,
|
|
'momentum_trail_gap': 3.0,
|
|
'breakeven_at': 8.0,
|
|
'momentum_tp': False,
|
|
'momentum_days': 3,
|
|
'buy_time': '09:35',
|
|
'sell_time': '13:40',
|
|
}
|
|
|
|
|
|
def get_user_algo_config(conn, user_id):
|
|
"""获取用户的算法配置,不存在则返回默认配置"""
|
|
from psycopg2.extras import RealDictCursor
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
cur.execute("""
|
|
SELECT * FROM sim_algo_config
|
|
WHERE user_id = %s AND is_active = TRUE
|
|
""", (user_id,))
|
|
row = cur.fetchone()
|
|
if row:
|
|
return dict(row)
|
|
return dict(DEFAULT_CONFIG)
|
|
|
|
|
|
def save_user_algo_config(conn, user_id, config):
|
|
"""保存/更新用户的算法配置"""
|
|
with conn.cursor() as cur:
|
|
cur.execute("""
|
|
INSERT INTO sim_algo_config (user_id, algo_name,
|
|
take_profit_pct, stop_loss_pct, ignore_sell_signal, sell_confirm_days,
|
|
max_hold_days, no_timeout_if_rising, total_capital, position_pct,
|
|
signal_weight, partial_exit_pct, momentum_trail_gap, breakeven_at,
|
|
momentum_tp, momentum_days, buy_time, sell_time, is_active)
|
|
VALUES (%(user_id)s, %(algo_name)s,
|
|
%(take_profit_pct)s, %(stop_loss_pct)s, %(ignore_sell_signal)s, %(sell_confirm_days)s,
|
|
%(max_hold_days)s, %(no_timeout_if_rising)s, %(total_capital)s, %(position_pct)s,
|
|
%(signal_weight)s, %(partial_exit_pct)s, %(momentum_trail_gap)s, %(breakeven_at)s,
|
|
%(momentum_tp)s, %(momentum_days)s, %(buy_time)s, %(sell_time)s, TRUE)
|
|
ON CONFLICT (user_id) DO UPDATE SET
|
|
algo_name = EXCLUDED.algo_name,
|
|
take_profit_pct = EXCLUDED.take_profit_pct,
|
|
stop_loss_pct = EXCLUDED.stop_loss_pct,
|
|
ignore_sell_signal = EXCLUDED.ignore_sell_signal,
|
|
sell_confirm_days = EXCLUDED.sell_confirm_days,
|
|
max_hold_days = EXCLUDED.max_hold_days,
|
|
no_timeout_if_rising = EXCLUDED.no_timeout_if_rising,
|
|
total_capital = EXCLUDED.total_capital,
|
|
position_pct = EXCLUDED.position_pct,
|
|
signal_weight = EXCLUDED.signal_weight,
|
|
partial_exit_pct = EXCLUDED.partial_exit_pct,
|
|
momentum_trail_gap = EXCLUDED.momentum_trail_gap,
|
|
breakeven_at = EXCLUDED.breakeven_at,
|
|
momentum_tp = EXCLUDED.momentum_tp,
|
|
momentum_days = EXCLUDED.momentum_days,
|
|
buy_time = EXCLUDED.buy_time,
|
|
sell_time = EXCLUDED.sell_time,
|
|
is_active = TRUE,
|
|
updated_at = NOW()
|
|
""", {**config, 'user_id': user_id})
|
|
conn.commit()
|
|
|
|
|
|
def apply_template(conn, user_id, template_name):
|
|
"""从算法模板创建用户配置"""
|
|
from psycopg2.extras import RealDictCursor
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
cur.execute("SELECT * FROM algo_templates WHERE name = %s", (template_name,))
|
|
tpl = cur.fetchone()
|
|
if not tpl:
|
|
return False
|
|
|
|
config = {
|
|
'algo_name': tpl['display_name'],
|
|
'take_profit_pct': float(tpl['take_profit_pct']),
|
|
'stop_loss_pct': float(tpl['stop_loss_pct']),
|
|
'ignore_sell_signal': tpl['ignore_sell_signal'],
|
|
'sell_confirm_days': tpl['sell_confirm_days'],
|
|
'max_hold_days': tpl['max_hold_days'],
|
|
'no_timeout_if_rising': tpl['no_timeout_if_rising'],
|
|
'total_capital': 200000.0, # 用户需自行设置
|
|
'position_pct': float(tpl['position_pct']),
|
|
'signal_weight': tpl['signal_weight'],
|
|
'partial_exit_pct': tpl['partial_exit_pct'],
|
|
'momentum_trail_gap': float(tpl['momentum_trail_gap']),
|
|
'breakeven_at': float(tpl['breakeven_at']),
|
|
'momentum_tp': tpl['momentum_tp'],
|
|
'momentum_days': tpl['momentum_days'],
|
|
'buy_time': tpl.get('buy_time', '09:35'),
|
|
'sell_time': tpl.get('sell_time', '13:40'),
|
|
}
|
|
save_user_algo_config(conn, user_id, config)
|
|
return True
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════
|
|
# 2. 仓位计算
|
|
# ═══════════════════════════════════════════════════════
|
|
|
|
def calc_dynamic_shares(available_cash, stock_price, config, recommend_rate=80, triggered_count=1):
|
|
"""
|
|
计算动态仓位股数(与回测引擎 backtest_recommend.py 一致的逻辑)
|
|
|
|
参数:
|
|
available_cash: 可用资金
|
|
stock_price: 当前股价
|
|
config: 算法配置
|
|
recommend_rate: 信号推荐率 (0-100)
|
|
triggered_count: 触发信号数
|
|
|
|
返回:
|
|
int: 建议买入股数(100的整数倍)
|
|
"""
|
|
total_capital = float(config.get('total_capital', 200000))
|
|
position_pct = float(config.get('position_pct', 8))
|
|
use_signal_weight = config.get('signal_weight', True)
|
|
|
|
if stock_price <= 0 or available_cash <= 0:
|
|
return 0
|
|
|
|
# 基础仓位金额 = 总资金 * 仓位百分比
|
|
base_amount = total_capital * position_pct / 100.0
|
|
|
|
# 信号加权: 强信号加大仓位
|
|
if use_signal_weight:
|
|
weight = 1.0
|
|
if recommend_rate >= 90:
|
|
weight = 1.5 # 强信号: 150%仓位
|
|
elif recommend_rate >= 80:
|
|
weight = 1.2 # 中强信号: 120%仓位
|
|
elif recommend_rate >= 70:
|
|
weight = 1.0 # 标准信号: 100%
|
|
else:
|
|
weight = 0.7 # 弱信号: 70%
|
|
|
|
# 多信号触发加成
|
|
if triggered_count >= 3:
|
|
weight *= 1.2
|
|
elif triggered_count >= 2:
|
|
weight *= 1.1
|
|
|
|
base_amount *= weight
|
|
|
|
# 不超过可用现金
|
|
base_amount = min(base_amount, available_cash * 0.95) # 留5%缓冲
|
|
|
|
# 计算股数 (100的整数倍)
|
|
shares = int(base_amount / stock_price / 100) * 100
|
|
return max(shares, 0)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════
|
|
# 3. 持仓元数据管理
|
|
# ═══════════════════════════════════════════════════════
|
|
|
|
def get_position_meta(conn, user_id, stock_code):
|
|
"""获取单只股票的持仓元数据"""
|
|
from psycopg2.extras import RealDictCursor
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
cur.execute("""
|
|
SELECT * FROM sim_position_meta
|
|
WHERE user_id = %s AND stock_code = %s
|
|
""", (user_id, stock_code))
|
|
return cur.fetchone()
|
|
|
|
|
|
def get_all_position_meta(conn, user_id):
|
|
"""获取用户所有持仓的元数据"""
|
|
from psycopg2.extras import RealDictCursor
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
cur.execute("""
|
|
SELECT m.*, p.quantity, p.avg_cost::float, p.current_price::float
|
|
FROM sim_position_meta m
|
|
JOIN sim_positions p ON m.user_id = p.user_id AND m.stock_code = p.stock_code
|
|
WHERE m.user_id = %s AND p.quantity > 0
|
|
""", (user_id,))
|
|
return cur.fetchall()
|
|
|
|
|
|
def create_position_meta(conn, user_id, stock_code, buy_price, buy_date,
|
|
shares, reason='', signal_rate=0, triggered_count=0):
|
|
"""创建新的持仓元数据"""
|
|
with conn.cursor() as cur:
|
|
cur.execute("""
|
|
INSERT INTO sim_position_meta
|
|
(user_id, stock_code, buy_date, buy_price, buy_reason,
|
|
buy_signal_rate, buy_triggered_count,
|
|
max_price_since_buy, current_shares, original_shares, last_update_date)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
ON CONFLICT (user_id, stock_code) DO UPDATE SET
|
|
buy_date = EXCLUDED.buy_date,
|
|
buy_price = EXCLUDED.buy_price,
|
|
buy_reason = EXCLUDED.buy_reason,
|
|
buy_signal_rate = EXCLUDED.buy_signal_rate,
|
|
buy_triggered_count = EXCLUDED.buy_triggered_count,
|
|
max_price_since_buy = EXCLUDED.max_price_since_buy,
|
|
days_held = 0,
|
|
consecutive_up_days = 0,
|
|
consecutive_sell_signals = 0,
|
|
partial_exit_done = FALSE,
|
|
breakeven_active = FALSE,
|
|
momentum_trailing_active = FALSE,
|
|
momentum_high_price = 0,
|
|
current_shares = EXCLUDED.current_shares,
|
|
original_shares = EXCLUDED.original_shares,
|
|
last_update_date = EXCLUDED.last_update_date,
|
|
updated_at = NOW()
|
|
""", (user_id, stock_code, buy_date, buy_price, reason,
|
|
signal_rate, triggered_count, buy_price, shares, shares, buy_date))
|
|
|
|
|
|
def update_position_meta(conn, user_id, stock_code, updates):
|
|
"""更新持仓元数据"""
|
|
set_clauses = []
|
|
values = []
|
|
for key, val in updates.items():
|
|
set_clauses.append(f"{key} = %s")
|
|
values.append(val)
|
|
set_clauses.append("updated_at = NOW()")
|
|
values.extend([user_id, stock_code])
|
|
|
|
with conn.cursor() as cur:
|
|
cur.execute(f"""
|
|
UPDATE sim_position_meta SET {', '.join(set_clauses)}
|
|
WHERE user_id = %s AND stock_code = %s
|
|
""", values)
|
|
|
|
|
|
def delete_position_meta(conn, user_id, stock_code):
|
|
"""删除持仓元数据(清仓时调用)"""
|
|
with conn.cursor() as cur:
|
|
cur.execute("""
|
|
DELETE FROM sim_position_meta
|
|
WHERE user_id = %s AND stock_code = %s
|
|
""", (user_id, stock_code))
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════
|
|
# 4. 信号日志
|
|
# ═══════════════════════════════════════════════════════
|
|
|
|
def _fix_all_sequences(conn):
|
|
"""修复所有模拟交易相关表的序列号,确保不会产生主键冲突"""
|
|
seq_table_map = [
|
|
('sim_trade_signals_id_seq', 'sim_trade_signals'),
|
|
('sim_positions_id_seq', 'sim_positions'),
|
|
('sim_trades_id_seq', 'sim_trades'),
|
|
('sim_position_meta_id_seq', 'sim_position_meta'),
|
|
('sim_algo_config_id_seq', 'sim_algo_config'),
|
|
('sim_daily_stats_id_seq', 'sim_daily_stats'),
|
|
]
|
|
try:
|
|
with conn.cursor() as cur:
|
|
for seq_name, table_name in seq_table_map:
|
|
try:
|
|
cur.execute(f"""
|
|
SELECT setval('{seq_name}',
|
|
COALESCE((SELECT MAX(id) FROM {table_name}), 0) + 1, false
|
|
)
|
|
""")
|
|
except Exception:
|
|
pass # 某些表可能不存在,跳过
|
|
except Exception as e:
|
|
print(f"⚠️ 修复序列失败: {e}", flush=True)
|
|
|
|
|
|
def _fix_trade_signals_sequence(conn):
|
|
"""修复 sim_trade_signals 序列号(向后兼容)"""
|
|
_fix_all_sequences(conn)
|
|
|
|
|
|
def log_signal(conn, user_id, signal_date, stock_code, stock_name,
|
|
action, reason, algo_rule, signal_price=None,
|
|
buy_price=None, profit_pct=None, executed=False,
|
|
execute_price=None, execute_shares=None):
|
|
"""记录交易信号到日志"""
|
|
params = (user_id, signal_date, datetime.now().time(), stock_code, stock_name,
|
|
action, reason, algo_rule, signal_price, buy_price, profit_pct,
|
|
executed, execute_price, execute_shares)
|
|
insert_sql = """
|
|
INSERT INTO sim_trade_signals
|
|
(user_id, signal_date, signal_time, stock_code, stock_name,
|
|
action, reason, algo_rule, signal_price, buy_price, profit_pct,
|
|
executed, execute_price, execute_shares)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
"""
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute("SAVEPOINT sp_log_signal")
|
|
cur.execute(insert_sql, params)
|
|
except Exception as e:
|
|
if 'duplicate key' in str(e):
|
|
with conn.cursor() as cur:
|
|
cur.execute("ROLLBACK TO SAVEPOINT sp_log_signal")
|
|
print(f"⚠️ 信号日志主键冲突,正在修复序列...", flush=True)
|
|
_fix_trade_signals_sequence(conn)
|
|
# 修复后重试一次
|
|
with conn.cursor() as cur:
|
|
cur.execute(insert_sql, params)
|
|
print(f"✅ 序列修复成功,信号已记录", flush=True)
|
|
else:
|
|
raise
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════
|
|
# 5. 核心交易决策引擎
|
|
# ═══════════════════════════════════════════════════════
|
|
|
|
def generate_sell_decisions(conn, user_id, config, current_prices, scan_map=None):
|
|
"""
|
|
生成卖出/部分止盈决策
|
|
|
|
参数:
|
|
conn: 数据库连接
|
|
user_id: 用户ID
|
|
config: 算法配置 (from get_user_algo_config)
|
|
current_prices: {stock_code: current_price} 当前价格
|
|
scan_map: {stock_code: scan_data} 今日扫描结果 (可选)
|
|
|
|
返回:
|
|
list[dict]: 卖出决策列表
|
|
[{'code': str, 'action': 'sell'|'partial_sell',
|
|
'shares': int, 'reason': str, 'rule': str, 'price': float}]
|
|
"""
|
|
tp_pct = float(config.get('take_profit_pct', 12))
|
|
sl_pct = float(config.get('stop_loss_pct', 8))
|
|
ign_sell = config.get('ignore_sell_signal', False)
|
|
confirm_days = int(config.get('sell_confirm_days', 3))
|
|
max_hold = int(config.get('max_hold_days', 60))
|
|
no_timeout_rising = config.get('no_timeout_if_rising', True)
|
|
pe_pct = int(config.get('partial_exit_pct', 0))
|
|
mt_gap = float(config.get('momentum_trail_gap', 3))
|
|
be_at = float(config.get('breakeven_at', 0))
|
|
mt_active = config.get('momentum_tp', False)
|
|
mt_days = int(config.get('momentum_days', 3))
|
|
|
|
today = date.today()
|
|
decisions = []
|
|
|
|
# 获取所有持仓及其元数据
|
|
positions = get_all_position_meta(conn, user_id)
|
|
|
|
print(f"[智能引擎] 卖出分析: {len(positions)}只持仓 (TP={tp_pct}%/SL={sl_pct}%/PE={pe_pct}%/BE@{be_at}%/MaxHold={max_hold}天)")
|
|
|
|
for pos in positions:
|
|
code = pos['stock_code']
|
|
price = current_prices.get(code)
|
|
if not price or price <= 0:
|
|
print(f" {code} 无价格,跳过")
|
|
continue
|
|
|
|
buy_price = float(pos['buy_price'])
|
|
current_shares = pos.get('current_shares', 0) or pos.get('quantity', 0)
|
|
if current_shares <= 0:
|
|
continue
|
|
|
|
profit_pct_now = (price - buy_price) / buy_price * 100 if buy_price > 0 else 0
|
|
days_held = pos.get('days_held', 0) or 0
|
|
print(f" {code} 成本{buy_price:.2f} 现价{price:.2f} 盈亏{profit_pct_now:+.1f}% 持仓{days_held}天")
|
|
consec_up = pos.get('consecutive_up_days', 0) or 0
|
|
pe_done = pos.get('partial_exit_done', False)
|
|
be_active_now = pos.get('breakeven_active', False)
|
|
mt_trailing = pos.get('momentum_trailing_active', False)
|
|
mt_high = float(pos.get('momentum_high_price', 0) or 0)
|
|
|
|
# ── 规则1: 止损 ──
|
|
effective_sl = -sl_pct
|
|
if be_active_now:
|
|
effective_sl = 0 # 保本止损: 止损线在成本价
|
|
if profit_pct_now <= effective_sl:
|
|
rule = 'BE_SL' if be_active_now else 'SL'
|
|
reason = f"{'保本止损' if be_active_now else '止损'}: 浮盈{profit_pct_now:.1f}% ≤ {effective_sl:.1f}%"
|
|
decisions.append({
|
|
'code': code, 'action': 'sell', 'shares': current_shares,
|
|
'reason': reason, 'rule': rule, 'price': price
|
|
})
|
|
continue
|
|
|
|
# ── 规则2: 动量跟踪止盈 ──
|
|
if mt_trailing and mt_high > 0:
|
|
drop_from_high = (mt_high - price) / mt_high * 100
|
|
if drop_from_high >= mt_gap:
|
|
reason = f"动量跟踪止盈: 从最高{mt_high:.2f}回落{drop_from_high:.1f}%≥{mt_gap}%"
|
|
decisions.append({
|
|
'code': code, 'action': 'sell', 'shares': current_shares,
|
|
'reason': reason, 'rule': 'MT_TP', 'price': price
|
|
})
|
|
continue
|
|
# 更新最高价
|
|
if price > mt_high:
|
|
update_position_meta(conn, user_id, code, {'momentum_high_price': price})
|
|
|
|
# ── 规则3: 止盈 / 部分止盈 ──
|
|
if profit_pct_now >= tp_pct:
|
|
# 检查是否应启动动量跟踪(连涨中不卖)
|
|
if mt_active and consec_up >= mt_days:
|
|
if not mt_trailing:
|
|
update_position_meta(conn, user_id, code, {
|
|
'momentum_trailing_active': True,
|
|
'momentum_high_price': price,
|
|
})
|
|
log_signal(conn, user_id, today, code, '',
|
|
'hold', f"连涨{consec_up}天+盈利{profit_pct_now:.1f}%≥TP,启动动量跟踪",
|
|
'MT_START', price, buy_price, profit_pct_now)
|
|
continue # 连涨中不触发止盈
|
|
|
|
if pe_pct > 0 and not pe_done:
|
|
# 部分止盈
|
|
sell_shares = int(current_shares * pe_pct / 100 / 100) * 100
|
|
sell_shares = max(sell_shares, 100) # 至少100股
|
|
sell_shares = min(sell_shares, current_shares)
|
|
reason = f"部分止盈{pe_pct}%: 盈利{profit_pct_now:.1f}%≥TP{tp_pct}%, 卖出{sell_shares}股"
|
|
decisions.append({
|
|
'code': code, 'action': 'partial_sell', 'shares': sell_shares,
|
|
'reason': reason, 'rule': 'PE', 'price': price
|
|
})
|
|
# 剩余部分启动跟踪止盈
|
|
update_position_meta(conn, user_id, code, {
|
|
'partial_exit_done': True,
|
|
'momentum_trailing_active': True,
|
|
'momentum_high_price': price,
|
|
})
|
|
else:
|
|
# 全部止盈
|
|
reason = f"止盈: 盈利{profit_pct_now:.1f}%≥TP{tp_pct}%"
|
|
decisions.append({
|
|
'code': code, 'action': 'sell', 'shares': current_shares,
|
|
'reason': reason, 'rule': 'TP', 'price': price
|
|
})
|
|
continue
|
|
|
|
# ── 规则4: 激活保本止损 ──
|
|
if be_at > 0 and not be_active_now and profit_pct_now >= be_at:
|
|
update_position_meta(conn, user_id, code, {'breakeven_active': True})
|
|
log_signal(conn, user_id, today, code, '',
|
|
'hold', f"盈利{profit_pct_now:.1f}%≥{be_at}%,保本止损已激活",
|
|
'BE_ACTIVATE', price, buy_price, profit_pct_now)
|
|
|
|
# ── 规则5: 超时平仓 ──
|
|
if max_hold > 0 and days_held >= max_hold:
|
|
# 连涨且盈利时不超时
|
|
if no_timeout_rising and consec_up >= 2 and profit_pct_now > 0:
|
|
log_signal(conn, user_id, today, code, '',
|
|
'hold', f"持仓{days_held}天≥{max_hold}天,但连涨{consec_up}天+盈利中,不平仓",
|
|
'NTO', price, buy_price, profit_pct_now)
|
|
else:
|
|
reason = f"超时平仓: 持仓{days_held}天≥{max_hold}天"
|
|
decisions.append({
|
|
'code': code, 'action': 'sell', 'shares': current_shares,
|
|
'reason': reason, 'rule': 'TIMEOUT', 'price': price
|
|
})
|
|
continue
|
|
|
|
# ── 规则6: 扫描卖出信号 ──
|
|
if not ign_sell and scan_map:
|
|
scan = scan_map.get(code)
|
|
if scan:
|
|
from services.stock_algorithms import compute_recommend
|
|
sig_type, display, reason_txt, rate = compute_recommend(
|
|
scan.get('signal_status'), scan.get('indicators'),
|
|
scan.get('triggered_count'), is_holding=True
|
|
)
|
|
if sig_type == 'sell':
|
|
consec_sell = pos.get('consecutive_sell_signals', 0) or 0
|
|
new_consec = consec_sell + 1
|
|
update_position_meta(conn, user_id, code, {
|
|
'consecutive_sell_signals': new_consec
|
|
})
|
|
if confirm_days <= 0 or new_consec >= confirm_days:
|
|
reason = f"卖出信号确认: {reason_txt} (连续{new_consec}天)"
|
|
decisions.append({
|
|
'code': code, 'action': 'sell', 'shares': current_shares,
|
|
'reason': reason, 'rule': 'SCAN_SELL', 'price': price
|
|
})
|
|
else:
|
|
log_signal(conn, user_id, today, code, '',
|
|
'hold', f"卖出信号{new_consec}/{confirm_days}天: {reason_txt}",
|
|
'SELL_WAIT', price, buy_price, profit_pct_now)
|
|
else:
|
|
# 非卖出信号,重置连续卖出计数
|
|
if pos.get('consecutive_sell_signals', 0):
|
|
update_position_meta(conn, user_id, code, {
|
|
'consecutive_sell_signals': 0
|
|
})
|
|
|
|
return decisions
|
|
|
|
|
|
def generate_buy_decisions(conn, user_id, config, scan_map, current_prices, holding_codes):
|
|
"""
|
|
生成买入决策
|
|
|
|
参数:
|
|
conn: 数据库连接
|
|
user_id: 用户ID
|
|
config: 算法配置
|
|
scan_map: {stock_code: scan_data} 今日扫描结果
|
|
current_prices: {stock_code: price} 当前价格
|
|
holding_codes: set 当前持仓股票代码
|
|
|
|
返回:
|
|
list[dict]: 买入决策列表
|
|
[{'code': str, 'name': str, 'shares': int, 'reason': str, 'price': float, 'rate': int}]
|
|
"""
|
|
from services.stock_algorithms import compute_recommend
|
|
|
|
total_capital = float(config.get('total_capital', 200000))
|
|
|
|
# 计算可用现金
|
|
from psycopg2.extras import RealDictCursor
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
cur.execute("""
|
|
SELECT COALESCE(SUM(quantity * avg_cost), 0)::float as total_invested
|
|
FROM sim_positions WHERE user_id = %s AND quantity > 0
|
|
""", (user_id,))
|
|
row = cur.fetchone()
|
|
total_invested = row['total_invested'] if row else 0.0
|
|
|
|
available_cash = total_capital - total_invested
|
|
|
|
if available_cash <= 0:
|
|
print(f"[智能引擎] 可用现金不足: ¥{available_cash:,.0f} (总本金¥{total_capital:,.0f} - 已投¥{total_invested:,.0f})")
|
|
return []
|
|
|
|
print(f"[智能引擎] 可用现金: ¥{available_cash:,.0f} (总本金¥{total_capital:,.0f} - 已投¥{total_invested:,.0f})")
|
|
|
|
# 候选买入列表
|
|
buy_candidates = []
|
|
for code, scan in scan_map.items():
|
|
if code in holding_codes:
|
|
continue
|
|
|
|
# 过滤退市、ST、*ST股票 — 不参与智能交易
|
|
stock_name = scan.get('name', '')
|
|
if any(tag in stock_name for tag in ('退', 'ST', '*ST', '退市')):
|
|
continue
|
|
|
|
sig_type, display, reason, rate = compute_recommend(
|
|
scan.get('signal_status'), scan.get('indicators'),
|
|
scan.get('triggered_count'), is_holding=False
|
|
)
|
|
|
|
if sig_type != 'buy':
|
|
continue
|
|
|
|
price = current_prices.get(code)
|
|
if not price or price <= 0:
|
|
continue
|
|
|
|
triggered = scan.get('triggered_count', 0) or 0
|
|
|
|
buy_candidates.append({
|
|
'code': code,
|
|
'name': scan.get('name', ''),
|
|
'rate': rate,
|
|
'triggered': triggered,
|
|
'reason': reason,
|
|
'price': price,
|
|
})
|
|
|
|
# 按推荐率 + 触发信号数排序
|
|
buy_candidates.sort(key=lambda x: (x['rate'], x['triggered']), reverse=True)
|
|
print(f"[智能引擎] 买入候选: {len(buy_candidates)}只 (从{len(scan_map)}只扫描结果中筛选)")
|
|
for c in buy_candidates[:5]:
|
|
print(f" 候选: {c['code']} {c['name']} rate={c['rate']} triggered={c['triggered']} price={c['price']:.2f}")
|
|
|
|
# 生成买入决策(按可用资金约束)
|
|
decisions = []
|
|
remaining_cash = available_cash
|
|
|
|
for cand in buy_candidates:
|
|
if remaining_cash <= 0:
|
|
break
|
|
|
|
shares = calc_dynamic_shares(
|
|
remaining_cash, cand['price'], config,
|
|
recommend_rate=cand['rate'],
|
|
triggered_count=cand['triggered']
|
|
)
|
|
|
|
if shares <= 0:
|
|
continue
|
|
|
|
cost = shares * cand['price']
|
|
if cost > remaining_cash:
|
|
shares = int(remaining_cash / cand['price'] / 100) * 100
|
|
if shares <= 0:
|
|
continue
|
|
cost = shares * cand['price']
|
|
|
|
decisions.append({
|
|
'code': cand['code'],
|
|
'name': cand['name'],
|
|
'shares': shares,
|
|
'reason': cand['reason'],
|
|
'price': cand['price'],
|
|
'rate': cand['rate'],
|
|
'triggered': cand['triggered'],
|
|
})
|
|
|
|
remaining_cash -= cost
|
|
|
|
return decisions
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════
|
|
# 6. 每日持仓状态更新
|
|
# ═══════════════════════════════════════════════════════
|
|
|
|
def update_daily_position_status(conn, user_id, current_prices):
|
|
"""
|
|
每日更新持仓元数据(在生成卖出决策前调用)
|
|
- 更新 days_held (持仓天数)
|
|
- 更新 max_price_since_buy (最高价)
|
|
- 更新 consecutive_up_days (连涨天数)
|
|
"""
|
|
today = date.today()
|
|
positions = get_all_position_meta(conn, user_id)
|
|
|
|
for pos in positions:
|
|
code = pos['stock_code']
|
|
price = current_prices.get(code)
|
|
if not price or price <= 0:
|
|
continue
|
|
|
|
buy_date = pos['buy_date']
|
|
if isinstance(buy_date, str):
|
|
buy_date = datetime.strptime(buy_date, '%Y-%m-%d').date()
|
|
|
|
days_held = (today - buy_date).days
|
|
max_price = max(float(pos.get('max_price_since_buy', 0) or 0), price)
|
|
|
|
# 判断是否连涨(当前价 > 昨天的最高价估计 - 简化处理)
|
|
prev_price = float(pos.get('current_price', 0) or pos.get('buy_price', 0))
|
|
consec_up = pos.get('consecutive_up_days', 0) or 0
|
|
if price > prev_price:
|
|
consec_up += 1
|
|
else:
|
|
consec_up = 0
|
|
|
|
# 更新部分止盈后的当前股数
|
|
current_shares = pos.get('quantity', 0) or pos.get('current_shares', 0)
|
|
|
|
update_position_meta(conn, user_id, code, {
|
|
'days_held': days_held,
|
|
'max_price_since_buy': max_price,
|
|
'consecutive_up_days': consec_up,
|
|
'current_shares': current_shares,
|
|
'last_update_date': today,
|
|
})
|
|
|
|
conn.commit()
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════
|
|
# 7. 主执行函数
|
|
# ═══════════════════════════════════════════════════════
|
|
|
|
def execute_smart_trade(conn, user_id, scan_date=None):
|
|
"""
|
|
智能交易主执行函数 — 替代旧的 execute_auto_trade_for_user
|
|
|
|
流程:
|
|
1. 读取算法配置
|
|
2. 获取当前持仓和价格
|
|
3. 更新持仓状态
|
|
4. 生成卖出决策并执行
|
|
5. 生成买入决策并执行
|
|
6. 记录所有信号
|
|
|
|
返回:
|
|
dict: {'success': bool, 'results': list, 'signals': int}
|
|
"""
|
|
from psycopg2.extras import RealDictCursor
|
|
|
|
today = date.today()
|
|
now = datetime.now().time()
|
|
|
|
print(f"[智能引擎] 开始为用户{user_id}执行智能交易...")
|
|
|
|
# 预防性修复序列号,避免主键冲突
|
|
_fix_trade_signals_sequence(conn)
|
|
|
|
try:
|
|
# 1. 读取算法配置
|
|
config = get_user_algo_config(conn, user_id)
|
|
algo_name = config.get('algo_name', 'unknown')
|
|
print(f"[智能引擎] 算法: {algo_name}")
|
|
|
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
|
|
|
# 2. 获取持仓
|
|
cur.execute("""
|
|
SELECT stock_code, stock_name, quantity, avg_cost::float, current_price::float
|
|
FROM sim_positions WHERE user_id = %s AND quantity > 0
|
|
""", (user_id,))
|
|
positions = cur.fetchall()
|
|
holding_codes = {p['stock_code'] for p in positions}
|
|
|
|
# 3. 读取扫描结果
|
|
if scan_date:
|
|
cur.execute("""
|
|
SELECT code, name, triggered_count, signal_status, indicators
|
|
FROM stock_signal_scan WHERE scan_date = %s
|
|
""", (scan_date,))
|
|
else:
|
|
cur.execute("""
|
|
SELECT code, name, triggered_count, signal_status, indicators
|
|
FROM stock_signal_scan
|
|
WHERE scan_date = (
|
|
SELECT MAX(scan_date) FROM stock_signal_scan
|
|
WHERE scan_date <= %s
|
|
)
|
|
""", (today,))
|
|
scan_rows = cur.fetchall()
|
|
scan_map = {r['code']: r for r in scan_rows}
|
|
|
|
if not scan_map:
|
|
print(f"[智能引擎] 无可用扫描数据,跳过")
|
|
return {'success': True, 'results': [], 'signals': 0}
|
|
|
|
print(f"[智能引擎] 扫描数据: {len(scan_map)}只, 持仓: {len(holding_codes)}只")
|
|
|
|
# 4. 批量获取所有相关股票的当前价格(单次查询,避免逐个连接)
|
|
all_codes = holding_codes | set(scan_map.keys())
|
|
current_prices = {}
|
|
if all_codes:
|
|
cur.execute("""
|
|
SELECT code, price::float FROM stock_realtime_price
|
|
WHERE code = ANY(%s) AND price > 0
|
|
""", (list(all_codes),))
|
|
for row in cur.fetchall():
|
|
current_prices[row['code']] = row['price']
|
|
|
|
# 对于持仓股票,优先使用 sim_positions.current_price(由持仓更新服务刷新,通常更新)
|
|
# stock_realtime_price 可能滞后(仅在全景扫描时更新)
|
|
for pos in positions:
|
|
code = pos['stock_code']
|
|
cp = float(pos.get('current_price', 0) or 0)
|
|
if cp > 0:
|
|
old_price = current_prices.get(code, 0)
|
|
current_prices[code] = cp
|
|
if old_price > 0 and abs(cp - old_price) / old_price > 0.001:
|
|
print(f" [价格修正] {code} realtime={old_price:.2f} → position={cp:.2f}")
|
|
|
|
price_hit = sum(1 for c in holding_codes if c in current_prices)
|
|
print(f"[智能引擎] 实时价格: {len(current_prices)}/{len(all_codes)}只, "
|
|
f"持仓覆盖: {price_hit}/{len(holding_codes)}只")
|
|
|
|
# 4b. 批量获取昨日收盘价 (用于涨跌停检测)
|
|
prev_close_prices = {}
|
|
if all_codes:
|
|
cur.execute("""
|
|
SELECT code, close::float as prev_close
|
|
FROM stock_kline_daily
|
|
WHERE code = ANY(%s) AND trade_date = (
|
|
SELECT MAX(trade_date) FROM stock_kline_daily
|
|
WHERE trade_date < %s
|
|
)
|
|
""", (list(all_codes), today))
|
|
for row in cur.fetchall():
|
|
prev_close_prices[row['code']] = row['prev_close']
|
|
print(f"[智能引擎] 昨收价: {len(prev_close_prices)}只 (涨跌停检测)")
|
|
|
|
# 4c. 获取今日买入的股票 (T+1规则: 当日买入不可当日卖出)
|
|
cur.execute("""
|
|
SELECT DISTINCT stock_code FROM sim_trades
|
|
WHERE user_id = %s AND trade_date = %s AND trade_type = 'buy'
|
|
""", (user_id, today))
|
|
today_bought_codes = {r['stock_code'] for r in cur.fetchall()}
|
|
if today_bought_codes:
|
|
print(f"[智能引擎] T+1限制: {len(today_bought_codes)}只今日已买入, 不可卖出")
|
|
|
|
# 5. 更新每日持仓状态
|
|
update_daily_position_status(conn, user_id, current_prices)
|
|
|
|
results = []
|
|
skipped_limit = [] # 因涨跌停跳过的交易
|
|
skipped_t1 = [] # 因T+1跳过的交易
|
|
|
|
# 6. 生成并执行卖出决策
|
|
sell_decisions = generate_sell_decisions(conn, user_id, config, current_prices, scan_map)
|
|
print(f"[智能引擎] 卖出决策: {len(sell_decisions)}笔")
|
|
|
|
for dec in sell_decisions:
|
|
code = dec['code']
|
|
price = dec['price']
|
|
shares = dec['shares']
|
|
action = dec['action']
|
|
|
|
pos = next((p for p in positions if p['stock_code'] == code), None)
|
|
if not pos:
|
|
continue
|
|
|
|
# T+1规则: 当日买入的股票不可当日卖出
|
|
if code in today_bought_codes:
|
|
skipped_t1.append(code)
|
|
print(f"[智能引擎] ⏳ T+1限制 {code} 今日买入,不可卖出")
|
|
log_signal(conn, user_id, today, code, pos.get('stock_name', ''),
|
|
'hold', f"T+1限制: 今日买入不可卖出 ({dec['reason']})", 'T+1',
|
|
price, pos['avg_cost'],
|
|
(price - pos['avg_cost']) / pos['avg_cost'] * 100 if pos['avg_cost'] else 0,
|
|
False, None, None)
|
|
continue
|
|
|
|
# 涨跌停检查: 跌停时无法卖出
|
|
prev_close = prev_close_prices.get(code)
|
|
if prev_close:
|
|
limit_info = check_price_limit(code, price, prev_close)
|
|
if limit_info['at_down_limit']:
|
|
skipped_limit.append(f"{code}(跌停{limit_info['change_pct']}%)")
|
|
print(f"[智能引擎] 🚫 跌停限制 {code} 涨跌幅{limit_info['change_pct']}%,无法卖出")
|
|
log_signal(conn, user_id, today, code, pos.get('stock_name', ''),
|
|
'hold', f"跌停无法卖出 ({dec['reason']})", 'LIMIT',
|
|
price, pos['avg_cost'],
|
|
(price - pos['avg_cost']) / pos['avg_cost'] * 100 if pos['avg_cost'] else 0,
|
|
False, None, None)
|
|
continue
|
|
|
|
# 应用滑点: 卖出价格下浮
|
|
price = apply_slippage(price, 'sell')
|
|
|
|
if action == 'partial_sell':
|
|
# 部分止盈
|
|
shares = min(shares, pos['quantity'])
|
|
if shares <= 0:
|
|
continue
|
|
remaining = pos['quantity'] - shares
|
|
sell_fees = calc_trade_fees(price, shares, 'sell')
|
|
realized_pnl = (price - pos['avg_cost']) * shares - sell_fees['total_fee']
|
|
|
|
cur.execute("""
|
|
INSERT INTO sim_trades
|
|
(user_id, stock_code, stock_name, trade_type, price, quantity,
|
|
trade_date, trade_time, recommend_rate, signal_reason,
|
|
commission, stamp_tax, total_fee)
|
|
VALUES (%s, %s, %s, 'sell', %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
""", (user_id, code, pos['stock_name'], price, shares,
|
|
today, now, 0, f"[PE] {dec['reason']}",
|
|
sell_fees['commission'], sell_fees['stamp_tax'], sell_fees['total_fee']))
|
|
|
|
cur.execute("""
|
|
UPDATE sim_positions SET
|
|
quantity = %s,
|
|
total_cost = avg_cost * %s,
|
|
current_price = %s,
|
|
updated_at = NOW()
|
|
WHERE user_id = %s AND stock_code = %s
|
|
""", (remaining, remaining, price, user_id, code))
|
|
|
|
update_position_meta(conn, user_id, code, {
|
|
'current_shares': remaining,
|
|
'partial_exit_done': True,
|
|
})
|
|
|
|
log_signal(conn, user_id, today, code, pos['stock_name'],
|
|
'partial_sell', dec['reason'], dec['rule'],
|
|
price, pos['avg_cost'],
|
|
(price - pos['avg_cost']) / pos['avg_cost'] * 100 if pos['avg_cost'] else 0,
|
|
True, price, shares)
|
|
|
|
results.append({
|
|
'type': 'partial_sell', 'code': code, 'name': pos['stock_name'],
|
|
'price': price, 'quantity': shares, 'pnl': realized_pnl,
|
|
'reason': dec['reason'], 'rule': dec['rule'],
|
|
'fee': sell_fees['total_fee']
|
|
})
|
|
print(f"[智能引擎] 部分止盈 {code} {pos['stock_name']} {shares}股@{price:.2f} "
|
|
f"手续费¥{sell_fees['total_fee']:.2f} | {dec['reason']}")
|
|
|
|
else:
|
|
# 全部卖出
|
|
qty = min(shares, pos['quantity'])
|
|
sell_fees = calc_trade_fees(price, qty, 'sell')
|
|
realized_pnl = (price - pos['avg_cost']) * qty - sell_fees['total_fee']
|
|
|
|
cur.execute("""
|
|
INSERT INTO sim_trades
|
|
(user_id, stock_code, stock_name, trade_type, price, quantity,
|
|
trade_date, trade_time, recommend_rate, signal_reason,
|
|
commission, stamp_tax, total_fee)
|
|
VALUES (%s, %s, %s, 'sell', %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
""", (user_id, code, pos['stock_name'], price, qty,
|
|
today, now, 0, f"[{dec['rule']}] {dec['reason']}",
|
|
sell_fees['commission'], sell_fees['stamp_tax'], sell_fees['total_fee']))
|
|
|
|
cur.execute("""
|
|
UPDATE sim_positions SET
|
|
quantity = 0, total_cost = 0, current_price = %s, updated_at = NOW()
|
|
WHERE user_id = %s AND stock_code = %s
|
|
""", (price, user_id, code))
|
|
|
|
# 记录已实现盈亏
|
|
cur.execute("""
|
|
INSERT INTO sim_daily_stats (user_id, stat_date, realized_profit, trade_count)
|
|
VALUES (%s, %s, %s, 1)
|
|
ON CONFLICT (user_id, stat_date) DO UPDATE SET
|
|
realized_profit = sim_daily_stats.realized_profit + %s,
|
|
trade_count = sim_daily_stats.trade_count + 1
|
|
""", (user_id, today, realized_pnl, realized_pnl))
|
|
|
|
# 清除持仓元数据
|
|
delete_position_meta(conn, user_id, code)
|
|
|
|
log_signal(conn, user_id, today, code, pos['stock_name'],
|
|
'sell', dec['reason'], dec['rule'],
|
|
price, pos['avg_cost'],
|
|
(price - pos['avg_cost']) / pos['avg_cost'] * 100 if pos['avg_cost'] else 0,
|
|
True, price, qty)
|
|
|
|
results.append({
|
|
'type': 'sell', 'code': code, 'name': pos['stock_name'],
|
|
'price': price, 'quantity': qty, 'pnl': realized_pnl,
|
|
'reason': dec['reason'], 'rule': dec['rule'],
|
|
'fee': sell_fees['total_fee']
|
|
})
|
|
print(f"[智能引擎] 卖出 {code} {pos['stock_name']} {qty}股@{price:.2f} "
|
|
f"盈亏¥{realized_pnl:+,.0f} 手续费¥{sell_fees['total_fee']:.2f} | [{dec['rule']}] {dec['reason']}")
|
|
|
|
# 更新卖出后的持仓列表
|
|
cur.execute("""
|
|
SELECT stock_code FROM sim_positions WHERE user_id = %s AND quantity > 0
|
|
""", (user_id,))
|
|
holding_codes = {r['stock_code'] for r in cur.fetchall()}
|
|
|
|
# 7. 生成并执行买入决策
|
|
buy_decisions = generate_buy_decisions(conn, user_id, config, scan_map,
|
|
current_prices, holding_codes)
|
|
print(f"[智能引擎] 买入决策: {len(buy_decisions)}笔 (持仓{len(holding_codes)}只)")
|
|
|
|
for dec in buy_decisions:
|
|
code = dec['code']
|
|
price = dec['price']
|
|
shares = dec['shares']
|
|
|
|
# 防重复买入
|
|
cur.execute("""
|
|
SELECT COUNT(*) as cnt FROM sim_trades
|
|
WHERE user_id=%s AND stock_code=%s AND trade_date=%s AND trade_type='buy'
|
|
""", (user_id, code, today))
|
|
if cur.fetchone()['cnt'] > 0:
|
|
continue
|
|
|
|
# 涨跌停检查: 涨停时无法买入
|
|
prev_close = prev_close_prices.get(code)
|
|
if prev_close:
|
|
limit_info = check_price_limit(code, price, prev_close)
|
|
if limit_info['at_up_limit']:
|
|
skipped_limit.append(f"{code}(涨停{limit_info['change_pct']}%)")
|
|
print(f"[智能引擎] 🚫 涨停限制 {code} 涨跌幅{limit_info['change_pct']}%,无法买入")
|
|
log_signal(conn, user_id, today, code, dec.get('name', ''),
|
|
'skip', f"涨停无法买入 ({dec['reason']})", 'LIMIT',
|
|
price, None, None, False, None, None)
|
|
continue
|
|
|
|
# 应用滑点: 买入价格上浮
|
|
price = apply_slippage(price, 'buy')
|
|
shares = dec['shares'] # 不改变shares
|
|
|
|
cost = price * shares
|
|
fees = calc_trade_fees(price, shares, 'buy')
|
|
|
|
cur.execute("""
|
|
INSERT INTO sim_trades
|
|
(user_id, stock_code, stock_name, trade_type, price, quantity,
|
|
trade_date, trade_time, recommend_rate, signal_reason,
|
|
commission, stamp_tax, total_fee)
|
|
VALUES (%s, %s, %s, 'buy', %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
""", (user_id, code, dec['name'], price, shares,
|
|
today, now, dec['rate'], dec['reason'],
|
|
fees['commission'], fees['stamp_tax'], fees['total_fee']))
|
|
|
|
# total_cost 包含手续费,更接近真实成本
|
|
actual_cost = cost + fees['total_fee']
|
|
# avg_cost = 含手续费的每股成本,确保 avg_cost * quantity == total_cost
|
|
avg_cost_per_share = actual_cost / shares if shares > 0 else price
|
|
cur.execute("""
|
|
INSERT INTO sim_positions
|
|
(user_id, stock_code, stock_name, quantity, avg_cost, total_cost, current_price)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
ON CONFLICT (user_id, stock_code) DO UPDATE SET
|
|
quantity = sim_positions.quantity + EXCLUDED.quantity,
|
|
total_cost = sim_positions.total_cost + EXCLUDED.total_cost,
|
|
avg_cost = (sim_positions.total_cost + EXCLUDED.total_cost) /
|
|
(sim_positions.quantity + EXCLUDED.quantity),
|
|
current_price = EXCLUDED.current_price,
|
|
stock_name = COALESCE(EXCLUDED.stock_name, sim_positions.stock_name),
|
|
updated_at = NOW()
|
|
""", (user_id, code, dec['name'], shares, avg_cost_per_share, actual_cost, price))
|
|
|
|
# 创建持仓元数据
|
|
create_position_meta(conn, user_id, code, price, today, shares,
|
|
reason=dec['reason'],
|
|
signal_rate=dec['rate'],
|
|
triggered_count=dec.get('triggered', 0))
|
|
|
|
log_signal(conn, user_id, today, code, dec['name'],
|
|
'buy', dec['reason'], 'BUY',
|
|
price, price, 0.0, True, price, shares)
|
|
|
|
results.append({
|
|
'type': 'buy', 'code': code, 'name': dec['name'],
|
|
'price': price, 'quantity': shares,
|
|
'reason': dec['reason'],
|
|
'fee': fees['total_fee']
|
|
})
|
|
print(f"[智能引擎] 买入 {code} {dec['name']} {shares}股@{price:.2f} "
|
|
f"金额¥{cost:,.0f} 手续费¥{fees['total_fee']:.2f} | {dec['reason']}")
|
|
|
|
conn.commit()
|
|
|
|
buy_count = len([r for r in results if r['type'] == 'buy'])
|
|
sell_count = len([r for r in results if r['type'] in ('sell', 'partial_sell')])
|
|
total_fees = sum(r.get('fee', 0) for r in results)
|
|
print(f"[智能引擎] 用户{user_id}完成: 买入{buy_count}笔, 卖出{sell_count}笔, "
|
|
f"总手续费¥{total_fees:.2f}, 算法: {algo_name}")
|
|
if skipped_limit:
|
|
print(f"[智能引擎] 涨跌停跳过: {', '.join(skipped_limit)}")
|
|
if skipped_t1:
|
|
print(f"[智能引擎] T+1跳过: {', '.join(skipped_t1)}")
|
|
|
|
# 构建详细原因摘要
|
|
reasons = []
|
|
if sell_count > 0:
|
|
reasons.append(f"卖出{sell_count}笔")
|
|
if buy_count > 0:
|
|
reasons.append(f"买入{buy_count}笔")
|
|
if skipped_t1:
|
|
t1_details = []
|
|
for code in skipped_t1:
|
|
pos = next((p for p in positions if p['stock_code'] == code), None)
|
|
if pos:
|
|
cp = current_prices.get(code, 0)
|
|
bp = float(pos.get('avg_cost', 0) or 0)
|
|
pct = ((cp - bp) / bp * 100) if bp > 0 else 0
|
|
t1_details.append(f"{code}({pct:+.1f}%)")
|
|
else:
|
|
t1_details.append(code)
|
|
reasons.append(f"T+1限制: {', '.join(t1_details)}")
|
|
if skipped_limit:
|
|
reasons.append(f"涨跌停: {', '.join(skipped_limit)}")
|
|
|
|
# 计算可用现金信息
|
|
total_capital = float(config.get('total_capital', 200000))
|
|
cur.execute("""
|
|
SELECT COALESCE(SUM(total_cost), 0)::float as total_invested
|
|
FROM sim_positions WHERE user_id = %s AND quantity > 0
|
|
""", (user_id,))
|
|
total_invested = cur.fetchone()['total_invested']
|
|
available_cash = total_capital - total_invested
|
|
if buy_count == 0 and available_cash < total_capital * 0.05:
|
|
cash_pct = total_invested / total_capital * 100
|
|
reasons.append(f"可用资金¥{available_cash:,.0f}({cash_pct:.0f}%已投)")
|
|
|
|
# 卖出决策但被跳过的情况 — 提供详细原因
|
|
if len(sell_decisions) > 0 and sell_count == 0:
|
|
for dec in sell_decisions:
|
|
code = dec['code']
|
|
if code in skipped_t1:
|
|
pass # 已记录
|
|
elif any(code in s for s in skipped_limit):
|
|
pass # 已记录
|
|
|
|
return {
|
|
'success': True,
|
|
'results': results,
|
|
'signals': len(results),
|
|
'algo': algo_name,
|
|
'total_fees': total_fees,
|
|
'skipped_limit': skipped_limit,
|
|
'skipped_t1': skipped_t1,
|
|
'detail_reasons': reasons,
|
|
'available_cash': available_cash,
|
|
'total_invested': total_invested,
|
|
}
|
|
|
|
except Exception as e:
|
|
conn.rollback()
|
|
traceback.print_exc()
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════
|
|
# 8. 获取交易引擎状态(供前端展示)
|
|
# ═══════════════════════════════════════════════════════
|
|
|
|
def get_engine_status(conn, user_id):
|
|
"""获取智能交易引擎的当前状态,供前端Dashboard展示"""
|
|
from psycopg2.extras import RealDictCursor
|
|
|
|
config = get_user_algo_config(conn, user_id)
|
|
positions = get_all_position_meta(conn, user_id)
|
|
|
|
# 最近信号
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
cur.execute("""
|
|
SELECT * FROM sim_trade_signals
|
|
WHERE user_id = %s
|
|
ORDER BY signal_date DESC, id DESC
|
|
LIMIT 20
|
|
""", (user_id,))
|
|
recent_signals = cur.fetchall()
|
|
|
|
# 统计
|
|
total_capital = float(config.get('total_capital', 200000))
|
|
total_invested = sum(
|
|
float(p.get('buy_price', 0)) * (p.get('current_shares', 0) or p.get('quantity', 0))
|
|
for p in positions
|
|
)
|
|
available_cash = total_capital - total_invested
|
|
|
|
# 持仓详情
|
|
position_details = []
|
|
for pos in positions:
|
|
buy_price = float(pos.get('buy_price', 0))
|
|
current_price = float(pos.get('current_price', 0) or buy_price)
|
|
shares = pos.get('current_shares', 0) or pos.get('quantity', 0)
|
|
pnl_pct = (current_price - buy_price) / buy_price * 100 if buy_price > 0 else 0
|
|
|
|
# 当前生效的规则
|
|
active_rules = []
|
|
if pos.get('breakeven_active'):
|
|
active_rules.append('🛡️ 保本止损')
|
|
if pos.get('momentum_trailing_active'):
|
|
active_rules.append('📈 动量跟踪')
|
|
if pos.get('partial_exit_done'):
|
|
active_rules.append('✂️ 已部分止盈')
|
|
|
|
tp = float(config.get('take_profit_pct', 12))
|
|
sl = float(config.get('stop_loss_pct', 8))
|
|
be_at_val = float(config.get('breakeven_at', 0))
|
|
|
|
position_details.append({
|
|
'code': pos['stock_code'],
|
|
'buy_price': buy_price,
|
|
'current_price': current_price,
|
|
'shares': shares,
|
|
'days_held': pos.get('days_held', 0),
|
|
'pnl_pct': round(pnl_pct, 2),
|
|
'pnl_amount': round((current_price - buy_price) * shares, 2),
|
|
'active_rules': active_rules,
|
|
'tp_target': round(buy_price * (1 + tp / 100), 2),
|
|
'sl_target': round(buy_price * (1 - (0 if pos.get('breakeven_active') else sl) / 100), 2),
|
|
'be_trigger': round(buy_price * (1 + be_at_val / 100), 2) if be_at_val > 0 else None,
|
|
'consec_up': pos.get('consecutive_up_days', 0),
|
|
'consec_sell': pos.get('consecutive_sell_signals', 0),
|
|
})
|
|
|
|
return {
|
|
'algo_config': config,
|
|
'positions': position_details,
|
|
'total_capital': total_capital,
|
|
'total_invested': round(total_invested, 2),
|
|
'available_cash': round(available_cash, 2),
|
|
'utilization_pct': round(total_invested / total_capital * 100, 1) if total_capital > 0 else 0,
|
|
'recent_signals': [dict(s) for s in recent_signals],
|
|
}
|