2b5a32ca1e
新增模块: - fund_flow_analyzer.py: 主力资金流向分析(P0, ±20) - market_sentiment.py: 市场情绪指标(P1, ±10) - external_factors.py: 北向资金/美股/大宗商品/汇率(P2-P4,P7) - news_analyzer.py: 公告/并购/政策面LLM分析(P5-P6) - score_engine.py: 综合评分引擎,整合技术面+外部因素 路由更新: - analysis.py: deep_analyze接入综合评分,根据最终评级修正买卖建议 - market.py: 新增4个外部因素API端点 - trades.py: 交易路由更新 算法文档重构: - 章节重排: 技术面(二三)→外部因素(四)→买卖决策(五)→数据源(六)→性能(七) - 架构图更新为五层,标注章节对应 - 5.1/5.2标注纯技术面,5.3整合外部因素修正推荐
362 lines
15 KiB
Python
362 lines
15 KiB
Python
"""
|
||
交易记录 API 路由(纯数据库版,事务安全)
|
||
"""
|
||
from flask import Blueprint, request, jsonify, session
|
||
from psycopg2.extras import RealDictCursor
|
||
from db import (
|
||
login_required, get_current_user_id, get_db, put_db,
|
||
db_get_trades, db_get_available_cash, db_update_available_cash
|
||
)
|
||
|
||
bp = Blueprint('trades', __name__, url_prefix='/api')
|
||
|
||
|
||
def _parse_float(val):
|
||
if val is None or val == '':
|
||
return None
|
||
try:
|
||
return round(float(val), 4)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _parse_int(val):
|
||
if val is None or val == '':
|
||
return None
|
||
try:
|
||
return int(val)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _calc_cash_delta(trade_type, price, quantity):
|
||
"""计算交易对可用资金的影响"""
|
||
if not trade_type or price is None or quantity is None:
|
||
return 0
|
||
amount = round(float(price) * int(quantity), 2)
|
||
t = trade_type.lower()
|
||
if t == 'buy':
|
||
return -amount
|
||
elif t == 'sell':
|
||
return amount
|
||
return 0
|
||
|
||
|
||
@bp.route('/trades', methods=['GET'])
|
||
@login_required
|
||
def get_trades():
|
||
"""获取交易记录"""
|
||
user_id = get_current_user_id()
|
||
trades = db_get_trades(user_id)
|
||
return jsonify({'success': True, 'trades': trades})
|
||
|
||
|
||
@bp.route('/trades', methods=['POST'])
|
||
@login_required
|
||
def add_trade():
|
||
"""添加交易记录(trade 插入 + 可用资金更新在同一事务中)"""
|
||
conn = get_db()
|
||
if not conn:
|
||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||
try:
|
||
user_id = get_current_user_id()
|
||
data = request.get_json()
|
||
data['price'] = _parse_float(data.get('price'))
|
||
data['quantity'] = _parse_int(data.get('quantity'))
|
||
data['profit_amount'] = _parse_float(data.get('profit_amount'))
|
||
data['stop_loss_price'] = _parse_float(data.get('stop_loss_price'))
|
||
|
||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||
cur.execute("""
|
||
INSERT INTO trades (user_id, stock_code, stock_name, trade_type, price,
|
||
quantity, trade_date, reason, result, profit_amount,
|
||
stop_loss_price, notes)
|
||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||
RETURNING id, stock_code, stock_name, trade_type, price, quantity,
|
||
trade_date::text, reason, result, profit_amount, stop_loss_price,
|
||
notes, created_at::text
|
||
""", (user_id, data.get('stock_code'), data.get('stock_name'),
|
||
data.get('trade_type'), data.get('price'), data.get('quantity'),
|
||
data.get('trade_date'), data.get('reason'), data.get('result'),
|
||
data.get('profit_amount'), data.get('stop_loss_price'), data.get('notes')))
|
||
trade = cur.fetchone()
|
||
|
||
delta = _calc_cash_delta(data.get('trade_type'), data.get('price'), data.get('quantity'))
|
||
new_cash = None
|
||
if delta != 0:
|
||
cur.execute("SELECT available_cash FROM users WHERE id = %s FOR UPDATE", (user_id,))
|
||
row = cur.fetchone()
|
||
current = float(row['available_cash'] or 0) if row else 0
|
||
new_cash = max(0, round(current + delta, 2))
|
||
cur.execute("UPDATE users SET available_cash = %s WHERE id = %s", (new_cash, user_id))
|
||
|
||
conn.commit()
|
||
resp = {'success': True, 'trade': trade}
|
||
if new_cash is not None:
|
||
resp['available_cash'] = new_cash
|
||
return jsonify(resp)
|
||
except Exception as e:
|
||
conn.rollback()
|
||
return jsonify({'success': False, 'error': str(e)}), 400
|
||
finally:
|
||
put_db(conn)
|
||
|
||
|
||
@bp.route('/trades/<int:trade_id>', methods=['PUT'])
|
||
@login_required
|
||
def update_trade(trade_id):
|
||
"""更新交易记录(同一事务内回滚旧资金 + 应用新资金)"""
|
||
conn = get_db()
|
||
if not conn:
|
||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||
try:
|
||
user_id = get_current_user_id()
|
||
data = request.get_json()
|
||
if 'price' in data:
|
||
data['price'] = _parse_float(data.get('price'))
|
||
if 'quantity' in data:
|
||
data['quantity'] = _parse_int(data.get('quantity'))
|
||
if 'profit_amount' in data:
|
||
data['profit_amount'] = _parse_float(data.get('profit_amount'))
|
||
if 'stop_loss_price' in data:
|
||
data['stop_loss_price'] = _parse_float(data.get('stop_loss_price'))
|
||
|
||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||
|
||
cur.execute("""
|
||
SELECT id, stock_code, stock_name, trade_type, price, quantity,
|
||
trade_date::text, reason, result, profit_amount, stop_loss_price, notes
|
||
FROM trades WHERE id = %s AND user_id = %s
|
||
""", (trade_id, user_id))
|
||
old_trade = cur.fetchone()
|
||
if not old_trade:
|
||
put_db(conn)
|
||
return jsonify({'error': '交易记录不存在'}), 404
|
||
|
||
cur.execute("""
|
||
UPDATE trades SET
|
||
stock_code = COALESCE(%s, stock_code), stock_name = COALESCE(%s, stock_name),
|
||
trade_type = COALESCE(%s, trade_type), price = COALESCE(%s, price),
|
||
quantity = COALESCE(%s, quantity), trade_date = COALESCE(%s, trade_date),
|
||
reason = COALESCE(%s, reason), result = COALESCE(%s, result),
|
||
profit_amount = COALESCE(%s, profit_amount),
|
||
stop_loss_price = COALESCE(%s, stop_loss_price), notes = COALESCE(%s, notes)
|
||
WHERE id = %s AND user_id = %s
|
||
RETURNING id, stock_code, stock_name, trade_type, price, quantity,
|
||
trade_date::text, reason, result, profit_amount, stop_loss_price,
|
||
notes, created_at::text
|
||
""", (data.get('stock_code'), data.get('stock_name'), data.get('trade_type'),
|
||
data.get('price'), data.get('quantity'), data.get('trade_date'),
|
||
data.get('reason'), data.get('result'), data.get('profit_amount'),
|
||
data.get('stop_loss_price'), data.get('notes'), trade_id, user_id))
|
||
trade = cur.fetchone()
|
||
if not trade:
|
||
conn.rollback()
|
||
put_db(conn)
|
||
return jsonify({'error': '交易记录不存在'}), 404
|
||
|
||
old_delta = _calc_cash_delta(old_trade.get('trade_type'), old_trade.get('price'), old_trade.get('quantity'))
|
||
new_delta = _calc_cash_delta(trade.get('trade_type'), trade.get('price'), trade.get('quantity'))
|
||
delta = new_delta - old_delta
|
||
new_cash = None
|
||
if delta != 0:
|
||
cur.execute("SELECT available_cash FROM users WHERE id = %s FOR UPDATE", (user_id,))
|
||
row = cur.fetchone()
|
||
current = float(row['available_cash'] or 0) if row else 0
|
||
new_cash = max(0, round(current + delta, 2))
|
||
cur.execute("UPDATE users SET available_cash = %s WHERE id = %s", (new_cash, user_id))
|
||
|
||
conn.commit()
|
||
resp = {'success': True, 'trade': trade}
|
||
if new_cash is not None:
|
||
resp['available_cash'] = new_cash
|
||
return jsonify(resp)
|
||
except Exception as e:
|
||
conn.rollback()
|
||
return jsonify({'success': False, 'error': str(e)}), 400
|
||
finally:
|
||
put_db(conn)
|
||
|
||
|
||
@bp.route('/trades/<int:trade_id>', methods=['DELETE'])
|
||
@login_required
|
||
def delete_trade(trade_id):
|
||
"""删除交易记录(同一事务内删除 + 回滚资金)"""
|
||
conn = get_db()
|
||
if not conn:
|
||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||
try:
|
||
user_id = get_current_user_id()
|
||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||
|
||
cur.execute("""
|
||
SELECT trade_type, price, quantity FROM trades WHERE id = %s AND user_id = %s
|
||
""", (trade_id, user_id))
|
||
old_trade = cur.fetchone()
|
||
if not old_trade:
|
||
put_db(conn)
|
||
return jsonify({'success': False, 'error': '交易记录不存在'}), 404
|
||
|
||
cur.execute("DELETE FROM trades WHERE id = %s AND user_id = %s", (trade_id, user_id))
|
||
|
||
delta = -_calc_cash_delta(old_trade.get('trade_type'), old_trade.get('price'), old_trade.get('quantity'))
|
||
new_cash = None
|
||
if delta != 0:
|
||
cur.execute("SELECT available_cash FROM users WHERE id = %s FOR UPDATE", (user_id,))
|
||
row = cur.fetchone()
|
||
current = float(row['available_cash'] or 0) if row else 0
|
||
new_cash = max(0, round(current + delta, 2))
|
||
cur.execute("UPDATE users SET available_cash = %s WHERE id = %s", (new_cash, user_id))
|
||
|
||
conn.commit()
|
||
resp = {'success': True}
|
||
if new_cash is not None:
|
||
resp['available_cash'] = new_cash
|
||
return jsonify(resp)
|
||
except Exception as e:
|
||
conn.rollback()
|
||
return jsonify({'success': False, 'error': str(e)}), 400
|
||
finally:
|
||
put_db(conn)
|
||
|
||
|
||
@bp.route('/available_cash', methods=['GET'])
|
||
@login_required
|
||
def get_available_cash():
|
||
"""获取可用资金"""
|
||
user_id = get_current_user_id()
|
||
cash = db_get_available_cash(user_id)
|
||
return jsonify({'success': True, 'available_cash': cash})
|
||
|
||
|
||
@bp.route('/available_cash', methods=['PUT'])
|
||
@login_required
|
||
def update_available_cash():
|
||
"""更新可用资金"""
|
||
try:
|
||
user_id = get_current_user_id()
|
||
data = request.get_json()
|
||
amount = data.get('amount')
|
||
|
||
if amount is None:
|
||
return jsonify({'success': False, 'error': '金额不能为空'}), 400
|
||
|
||
try:
|
||
amount = round(float(amount), 2)
|
||
except:
|
||
return jsonify({'success': False, 'error': '金额格式错误'}), 400
|
||
|
||
success, error = db_update_available_cash(user_id, amount)
|
||
if error:
|
||
return jsonify({'success': False, 'error': error}), 400
|
||
|
||
return jsonify({'success': True, 'available_cash': amount})
|
||
except Exception as e:
|
||
return jsonify({'success': False, 'error': str(e)}), 400
|
||
|
||
|
||
@bp.route('/stoploss_check', methods=['GET'])
|
||
@login_required
|
||
def check_stoploss():
|
||
"""检查止损线触发情况"""
|
||
try:
|
||
user_id = get_current_user_id()
|
||
trades = db_get_trades(user_id)
|
||
|
||
# 计算每只股票的持仓情况
|
||
holdings = {}
|
||
for trade in trades:
|
||
code = trade.get('stock_code')
|
||
if not code:
|
||
continue
|
||
if code not in holdings:
|
||
holdings[code] = {
|
||
'code': code,
|
||
'name': trade.get('stock_name', code),
|
||
'total_cost': 0,
|
||
'total_quantity': 0,
|
||
'stop_loss_price': trade.get('stop_loss_price'),
|
||
}
|
||
|
||
trade_type = trade.get('trade_type')
|
||
quantity = int(trade.get('quantity', 0) or 0)
|
||
price = float(trade.get('price', 0) or 0)
|
||
|
||
if trade_type == 'buy':
|
||
holdings[code]['total_cost'] += price * quantity
|
||
holdings[code]['total_quantity'] += quantity
|
||
elif trade_type == 'sell':
|
||
holdings[code]['total_quantity'] -= quantity
|
||
if holdings[code]['total_quantity'] > 0:
|
||
cost_per_share = holdings[code]['total_cost'] / (holdings[code]['total_quantity'] + quantity)
|
||
holdings[code]['total_cost'] -= cost_per_share * quantity
|
||
|
||
if trade.get('stop_loss_price'):
|
||
holdings[code]['stop_loss_price'] = trade.get('stop_loss_price')
|
||
|
||
# 只保留有持仓的股票
|
||
active_holdings = {k: v for k, v in holdings.items() if v['total_quantity'] > 0}
|
||
|
||
# 获取实时价格并检查止损
|
||
alerts = []
|
||
try:
|
||
for code, holding in active_holdings.items():
|
||
try:
|
||
# 优先使用腾讯财经API(兼容腾讯云)
|
||
current_price = 0
|
||
try:
|
||
import requests as _rq
|
||
_tc = ('sh' if code.startswith('6') else 'sz') + code
|
||
_rr = _rq.get(f'http://qt.gtimg.cn/q={_tc}', timeout=5,
|
||
headers={'Referer': 'https://finance.qq.com'})
|
||
if _rr.status_code == 200 and '\"' in _rr.text:
|
||
_ff = _rr.text.split('\"')[1].split('~')
|
||
if len(_ff) > 3 and _ff[3]:
|
||
current_price = float(_ff[3])
|
||
except Exception:
|
||
pass
|
||
if current_price > 0:
|
||
stop_loss_price = holding.get('stop_loss_price')
|
||
avg_cost = float(holding['total_cost']) / holding['total_quantity'] if holding['total_quantity'] > 0 else 0.0
|
||
|
||
profit_loss = (current_price - avg_cost) * holding['total_quantity']
|
||
profit_percent = ((current_price - avg_cost) / avg_cost * 100) if avg_cost > 0 else 0
|
||
|
||
alert_data = {
|
||
'code': code,
|
||
'name': holding['name'],
|
||
'current_price': current_price,
|
||
'avg_cost': round(avg_cost, 4),
|
||
'quantity': holding['total_quantity'],
|
||
'profit_loss': round(profit_loss, 2),
|
||
'profit_percent': round(profit_percent, 2),
|
||
'stop_loss_price': stop_loss_price,
|
||
'triggered': False
|
||
}
|
||
|
||
if stop_loss_price and current_price <= stop_loss_price:
|
||
alert_data['triggered'] = True
|
||
alert_data['alert_type'] = 'stop_loss'
|
||
alert_data['message'] = f"⚠️ {holding['name']} 触发止损!"
|
||
elif profit_percent <= -5:
|
||
alert_data['triggered'] = True
|
||
alert_data['alert_type'] = 'default_stop'
|
||
alert_data['message'] = f"⚠️ {holding['name']} 跌破成本5%!"
|
||
|
||
alerts.append(alert_data)
|
||
except Exception as e:
|
||
print(f"获取 {code} 价格失败: {e}")
|
||
except Exception as e:
|
||
print(f"获取实时行情失败: {e}")
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'holdings': list(active_holdings.values()),
|
||
'alerts': [a for a in alerts if a.get('triggered')],
|
||
'all_positions': alerts
|
||
})
|
||
|
||
except Exception as e:
|
||
print(f"止损检查错误: {e}")
|
||
return jsonify({'error': str(e)}), 500
|