Files
freedakgmail cf1023754a fix: 修复 smart_trade_engine.py 3处除零风险
- :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
2026-07-22 07:42:17 +08:00

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],
}