transaction.py 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048
  1. """
  2. 交易记录相关路由
  3. """
  4. from flask import Blueprint, jsonify, request, render_template, send_file, make_response
  5. from app import db
  6. from app.models.transaction import TransactionRecord
  7. from app.models.future_info import FutureInfo
  8. from app.models.dimension import StrategyInfo, CandleInfo, TrendInfo
  9. from datetime import datetime
  10. import pandas as pd
  11. import io
  12. import os
  13. from werkzeug.utils import secure_filename
  14. from sqlalchemy import text
  15. import uuid
  16. import tempfile
  17. import logging
  18. logger = logging.getLogger(__name__)
  19. bp = Blueprint('transaction', __name__, url_prefix='/transaction')
  20. @bp.route('/', methods=['GET'])
  21. def index():
  22. """交易记录列表页面"""
  23. return render_template('transaction/index.html')
  24. @bp.route('/add', methods=['GET'])
  25. def add():
  26. """添加交易记录页面"""
  27. from app.models.trade import TradeRecord
  28. # 检查是否是平仓操作
  29. close_for_trade_id = request.args.get('close_for')
  30. close_for_transaction_id = request.args.get('close_for_transaction')
  31. close_trade_data = None
  32. if close_for_trade_id:
  33. try:
  34. # 查询要平仓的交易记录
  35. trade_to_close = TradeRecord.query.get(int(close_for_trade_id))
  36. if trade_to_close:
  37. print(f"准备为交易ID {close_for_trade_id} 创建平仓记录")
  38. # 根据原始持仓类型确定平仓类型
  39. close_position_type = None
  40. if trade_to_close.position_type == 0: # 原来是多头
  41. close_position_type = 1 # 平多
  42. elif trade_to_close.position_type == 1: # 原来是空头
  43. close_position_type = 3 # 平空
  44. close_trade_data = {
  45. 'original_trade_id': trade_to_close.id,
  46. 'contract_code': trade_to_close.contract_code,
  47. 'name': trade_to_close.name,
  48. 'account': trade_to_close.account,
  49. 'position_type': close_position_type,
  50. 'position_volume': trade_to_close.position_volume,
  51. 'contract_multiplier': trade_to_close.contract_multiplier,
  52. 'strategy_name': '', # 平仓时清空操作策略
  53. 'candle_pattern': '', # 平仓时清空K线形态
  54. 'long_trend_name': trade_to_close.long_trend_name,
  55. 'mid_trend_name': trade_to_close.mid_trend_name,
  56. 'trade_type': trade_to_close.trade_type,
  57. 'trade_status': 3 # 设置平仓记录状态为"已结束"
  58. }
  59. print(f"平仓数据准备完成: {close_trade_data}")
  60. else:
  61. print(f"未找到交易ID {close_for_trade_id} 的记录")
  62. except (ValueError, TypeError) as e:
  63. print(f"解析平仓交易ID失败: {e}")
  64. elif close_for_transaction_id:
  65. try:
  66. # 查询要平仓的交易记录
  67. transaction_to_close = TransactionRecord.query.get(int(close_for_transaction_id))
  68. if transaction_to_close:
  69. print(f"准备为交易记录ID {close_for_transaction_id} 创建平仓记录")
  70. # 根据原始持仓类型确定平仓类型
  71. close_position_type = None
  72. if transaction_to_close.position_type == 0: # 开多 -> 平多
  73. close_position_type = 1
  74. elif transaction_to_close.position_type == 2: # 开空 -> 平空
  75. close_position_type = 3
  76. if close_position_type is not None:
  77. close_trade_data = {
  78. 'original_transaction_id': transaction_to_close.id,
  79. 'contract_code': transaction_to_close.contract_code,
  80. 'name': transaction_to_close.name,
  81. 'account': transaction_to_close.account,
  82. 'position_type': close_position_type,
  83. 'position_volume': transaction_to_close.volume,
  84. 'contract_multiplier': transaction_to_close.contract_multiplier,
  85. 'strategy_name': '', # 平仓时清空操作策略
  86. 'candle_pattern': '', # 平仓时清空K线形态
  87. 'long_trend_name': transaction_to_close.long_trend_name,
  88. 'mid_trend_name': transaction_to_close.mid_trend_name,
  89. 'trade_type': transaction_to_close.trade_type,
  90. 'trade_status': 3 # 设置平仓记录状态为"已结束"
  91. }
  92. print(f"基于交易记录的平仓数据准备完成: {close_trade_data}")
  93. else:
  94. print(f"交易记录ID {close_for_transaction_id} 的仓位类型不支持平仓操作")
  95. else:
  96. print(f"未找到交易记录ID {close_for_transaction_id} 的记录")
  97. except (ValueError, TypeError) as e:
  98. print(f"解析平仓交易记录ID失败: {e}")
  99. return render_template('transaction/add.html', close_trade_data=close_trade_data)
  100. @bp.route('/edit/<int:id>', methods=['GET'])
  101. def edit(id):
  102. """编辑交易记录页面"""
  103. return render_template('transaction/edit.html', transaction_id=id)
  104. @bp.route('/detail/view/<int:id>', methods=['GET'])
  105. def detail(id):
  106. """查看交易记录详情页面"""
  107. transaction_obj = TransactionRecord.query.get_or_404(id)
  108. transaction_dict = transaction_obj.to_dict() # Convert to dictionary first
  109. print(f"transaction_dict: {transaction_dict}")
  110. return render_template('transaction/detail.html', transaction=transaction_dict)
  111. @bp.route('/api/list', methods=['GET'])
  112. def get_list():
  113. """获取交易记录列表"""
  114. try:
  115. print("\n--- [DEBUG] ---")
  116. print(f"Request Args: {request.args}")
  117. # 获取分页参数
  118. page = request.args.get('page', 1, type=int)
  119. # 从配置服务获取默认分页大小
  120. try:
  121. from app.services.config_service import get_int_config
  122. default_page_size = get_int_config('pagination_default_size', 10)
  123. except Exception:
  124. default_page_size = 10
  125. limit = request.args.get('limit', default_page_size, type=int)
  126. # 获取筛选参数
  127. start_time = request.args.get('start_time')
  128. end_time = request.args.get('end_time')
  129. names = request.args.getlist('name')
  130. contract_letters = request.args.getlist('contract_letter')
  131. contract_code = request.args.get('contract_code')
  132. strategy_ids = request.args.getlist('strategy_id')
  133. trade_type = request.args.get('trade_type')
  134. trade_statuses = request.args.getlist('trade_status[]')
  135. # 构建查询
  136. query = TransactionRecord.query
  137. if start_time:
  138. query = query.filter(TransactionRecord.transaction_time >= datetime.strptime(start_time, '%Y-%m-%d'))
  139. if end_time:
  140. query = query.filter(TransactionRecord.transaction_time <= datetime.strptime(end_time, '%Y-%m-%d'))
  141. if names:
  142. query = query.filter(TransactionRecord.name.in_(names))
  143. if contract_letters:
  144. # 假设合约代码的前1-2位是合约字母
  145. query = query.filter(db.or_(*[TransactionRecord.contract_code.startswith(letter) for letter in contract_letters]))
  146. if contract_code:
  147. query = query.filter(TransactionRecord.contract_code.like(f'%{contract_code}%'))
  148. if strategy_ids:
  149. try:
  150. # strategy_ids 是字符串字段,需要按字符串匹配
  151. strategy_ids = [i.strip() for i in strategy_ids if i.strip()]
  152. if strategy_ids:
  153. # 使用 OR 条件匹配包含任一策略ID的记录
  154. conditions = []
  155. for strategy_id in strategy_ids:
  156. conditions.append(TransactionRecord.strategy_ids.contains(strategy_id))
  157. if conditions:
  158. query = query.filter(db.or_(*conditions))
  159. except ValueError:
  160. pass
  161. if trade_type is not None and trade_type.strip():
  162. try:
  163. query = query.filter(TransactionRecord.trade_type == int(trade_type))
  164. except ValueError:
  165. pass
  166. if trade_statuses:
  167. try:
  168. trade_statuses_int = [int(i) for i in trade_statuses if i.strip()]
  169. if trade_statuses_int:
  170. query = query.filter(TransactionRecord.trade_status.in_(trade_statuses_int))
  171. except ValueError:
  172. pass
  173. # 执行分页查询
  174. pagination = query.order_by(TransactionRecord.transaction_time.desc()).paginate(page=page, per_page=limit, error_out=False)
  175. transactions = pagination.items
  176. total = pagination.total
  177. print(f"Query returned {total} total records.")
  178. print("--- [END DEBUG] ---\n")
  179. # 返回结果
  180. return jsonify({
  181. 'code': 0,
  182. 'msg': '成功',
  183. 'count': total, # 返回总记录数用于分页
  184. 'data': [transaction.to_dict() for transaction in transactions]
  185. })
  186. except Exception as e:
  187. return jsonify({
  188. 'code': 1,
  189. 'msg': f'获取列表失败: {str(e)}',
  190. 'count': 0,
  191. 'data': []
  192. })
  193. @bp.route('/api/future_info/by_letter/<string:letter>', methods=['GET'])
  194. def get_future_info_by_letter(letter):
  195. """根据合约字母获取期货信息"""
  196. if not letter:
  197. return jsonify({'code': 1, 'msg': '缺少合约字母参数'})
  198. # 统一转为大写进行查询
  199. letter_upper = letter.upper()
  200. # 查找匹配的 FutureInfo 记录
  201. # 假设 future_info 表中有 contract_letter 字段存储纯字母(如 CU, ZC)
  202. # 使用 ilike 可能更健壮,如果 contract_letter 存储的是完整代码的前缀
  203. # future_info = FutureInfo.query.filter(FutureInfo.contract_letter.ilike(f'{letter_upper}%')).first()
  204. future_info = FutureInfo.query.filter_by(contract_letter=letter_upper).first()
  205. if future_info:
  206. return jsonify({
  207. 'code': 0,
  208. 'msg': '成功',
  209. 'data': {
  210. 'name': future_info.name,
  211. 'open_fee': future_info.open_fee,
  212. 'close_fee': future_info.close_fee,
  213. 'contract_multiplier': future_info.contract_multiplier,
  214. 'long_margin_rate': future_info.long_margin_rate,
  215. 'short_margin_rate': future_info.short_margin_rate
  216. }
  217. })
  218. else:
  219. return jsonify({
  220. 'code': 1,
  221. 'msg': f'未找到合约字母为 {letter_upper} 的期货信息'
  222. })
  223. @bp.route('/api/strategy_info/list', methods=['GET'])
  224. def get_strategy_info_list():
  225. """获取所有策略信息列表"""
  226. try:
  227. strategies = StrategyInfo.query.order_by(StrategyInfo.id).all()
  228. strategy_list = [{'id': s.id, 'name': s.name} for s in strategies]
  229. return jsonify({
  230. 'code': 0,
  231. 'msg': '成功',
  232. 'data': strategy_list
  233. })
  234. except Exception as e:
  235. return jsonify({
  236. 'code': 1,
  237. 'msg': f'获取策略列表失败: {str(e)}'
  238. })
  239. @bp.route('/api/detail/<int:id>', methods=['GET'])
  240. def get_detail(id):
  241. """获取交易记录详情"""
  242. transaction = TransactionRecord.query.get_or_404(id)
  243. # transaction_dict = transaction.to_dict()
  244. # # 尝试根据名称查找关联的 ID
  245. # account_id = None
  246. # future_info_id = None
  247. # if transaction.account:
  248. # account_obj = Account.query.filter_by(account_name=transaction.account).first()
  249. # if account_obj:
  250. # account_id = account_obj.id
  251. # if transaction.name:
  252. # # 优先按名称精确匹配
  253. # future_obj = FutureInfo.query.filter_by(name=transaction.name).first()
  254. # if not future_obj and transaction.contract_code:
  255. # # 如果按名称找不到,尝试按合约字母匹配
  256. # letter = ''.join(filter(str.isalpha, transaction.contract_code))[:2]
  257. # if letter:
  258. # future_obj = FutureInfo.query.filter(FutureInfo.contract_letter.ilike(f'{letter}%')).first()
  259. # if future_obj:
  260. # future_info_id = future_obj.id
  261. # # 将 ID 添加到返回的字典中
  262. # transaction_dict['account_id'] = account_id
  263. # transaction_dict['future_info_id'] = future_info_id
  264. return jsonify({
  265. 'code': 0,
  266. 'msg': '成功',
  267. 'data': transaction.to_dict() # 直接返回 to_dict() 结果
  268. })
  269. @bp.route('/api/create', methods=['POST'])
  270. def create():
  271. """创建交易记录 (手动添加)"""
  272. # 在函数内部导入,避免循环依赖
  273. from app.models.trade import TradeRecord
  274. from app.services.trade_logic import update_trade_record, generate_trade_from_transactions
  275. data = request.json
  276. try:
  277. # --- 1. Process Input Data ---
  278. transaction_time = datetime.strptime(data.get('transaction_time', ''), '%Y-%m-%d %H:%M') if data.get('transaction_time') else datetime.now()
  279. operation_time = transaction_time
  280. if 'operation_time' in data and data['operation_time']:
  281. try:
  282. operation_time = datetime.strptime(data['operation_time'], '%Y-%m-%d %H:%M')
  283. except ValueError:
  284. pass
  285. # Basic data
  286. name = data.get('name')
  287. price = data.get('price')
  288. volume = data.get('volume')
  289. contract_multiplier = data.get('contract_multiplier')
  290. position_type = data.get('position_type')
  291. # 从配置服务获取默认值
  292. try:
  293. from app.services.config_service import get_str_config, get_int_config
  294. default_account = get_str_config('default_account_name', '华安期货')
  295. default_trade_type = get_int_config('default_trade_type', 0)
  296. default_trade_status = get_int_config('default_trade_status', 0)
  297. except Exception as e:
  298. logger.warning(f"获取业务配置失败,使用硬编码默认值: {e}")
  299. default_account = '华安期货'
  300. default_trade_type = 0
  301. default_trade_status = 0
  302. account = data.get('account', default_account)
  303. trade_type = data.get('trade_type', default_trade_type)
  304. trade_status = data.get('trade_status', default_trade_status)
  305. stop_loss_price = data.get('stop_loss_price')
  306. confidence_index = data.get('confidence_index')
  307. similarity_evaluation = data.get('similarity_evaluation')
  308. notes = data.get('notes')
  309. contract_code=data.get('contract_code')
  310. # Calculated financial data (from frontend)
  311. amount = data.get('amount')
  312. fee = data.get('fee')
  313. volume_change = data.get('volume_change')
  314. margin = data.get('margin')
  315. # Process names to IDs (Strategies, Candles, Trends)
  316. strategy_name = (data.get('strategy_name') or '').strip()
  317. strategy_ids, corrected_strategy_name = _get_ids_from_names(strategy_name, StrategyInfo)
  318. candle_pattern_name = (data.get('candle_pattern_name') or '').strip()
  319. candle_pattern_ids, corrected_candle_pattern_name = _get_ids_from_names(candle_pattern_name, CandleInfo)
  320. long_trend_name = (data.get('long_trend_name') or '').strip()
  321. long_trend_ids, corrected_long_trend_name = _get_ids_from_names(long_trend_name, TrendInfo)
  322. mid_trend_name = (data.get('mid_trend_name') or '').strip()
  323. mid_trend_ids, corrected_mid_trend_name = _get_ids_from_names(mid_trend_name, TrendInfo)
  324. # --- 2. Create TransactionRecord (trade_id is initially None) ---
  325. new_transaction = TransactionRecord(
  326. transaction_time=transaction_time,
  327. operation_time=operation_time,
  328. contract_code=contract_code,
  329. name=name,
  330. account=account,
  331. strategy_ids=strategy_ids,
  332. strategy_name=corrected_strategy_name,
  333. position_type=position_type,
  334. candle_pattern_ids=candle_pattern_ids,
  335. candle_pattern=corrected_candle_pattern_name,
  336. price=price,
  337. volume=volume,
  338. contract_multiplier=contract_multiplier,
  339. amount=amount,
  340. fee=fee,
  341. volume_change=volume_change,
  342. margin=margin,
  343. trade_type=trade_type,
  344. trade_status=trade_status,
  345. stop_loss_price=stop_loss_price,
  346. confidence_index=confidence_index,
  347. similarity_evaluation=similarity_evaluation,
  348. long_trend_ids=long_trend_ids,
  349. long_trend_name=corrected_long_trend_name,
  350. mid_trend_ids=mid_trend_ids,
  351. mid_trend_name=corrected_mid_trend_name,
  352. # notes=notes, # Add if model has notes field
  353. trade_id = None # Initial state
  354. )
  355. # --- 3. Handle Trade Logic (Find Match or Create New) ---
  356. target_trade_id = None
  357. final_trade_msg = ""
  358. # Only try to find a match if it's a closing transaction
  359. if position_type in [1, 3]: # 平多 or 平空
  360. print("处理平仓,尝试查找匹配的未平仓 Trade...")
  361. target_open_pos_type = 0 if position_type == 1 else 2
  362. # Find the latest open transaction of the opposite type for the same contract/account/strategy
  363. # that is linked to a TradeRecord which is currently open (close_time is null)
  364. matching_open_trans = db.session.query(TransactionRecord)\
  365. .join(TradeRecord, TransactionRecord.trade_id == TradeRecord.id)\
  366. .filter(
  367. TradeRecord.close_time.is_(None), # Must be an open trade
  368. TransactionRecord.contract_code == new_transaction.contract_code,
  369. TransactionRecord.account == new_transaction.account,
  370. TransactionRecord.strategy_ids == new_transaction.strategy_ids, # Strategy must match
  371. TransactionRecord.position_type == target_open_pos_type
  372. )\
  373. .order_by(TransactionRecord.transaction_time.desc())\
  374. .first()
  375. if matching_open_trans:
  376. target_trade_id = matching_open_trans.trade_id
  377. new_transaction.trade_id = target_trade_id # Associate with the found trade
  378. print(f"找到匹配的未平仓 Trade ID: {target_trade_id},关联此平仓记录。")
  379. else:
  380. # 尝试更宽松的查找:不要求策略匹配
  381. print("未找到完全匹配的开仓记录,尝试更宽松的查找...")
  382. loose_matching_trans = db.session.query(TransactionRecord)\
  383. .join(TradeRecord, TransactionRecord.trade_id == TradeRecord.id)\
  384. .filter(
  385. TradeRecord.close_time.is_(None), # Must be an open trade
  386. TransactionRecord.contract_code == new_transaction.contract_code,
  387. TransactionRecord.account == new_transaction.account,
  388. TransactionRecord.position_type == target_open_pos_type
  389. )\
  390. .order_by(TransactionRecord.transaction_time.desc())\
  391. .first()
  392. if loose_matching_trans:
  393. target_trade_id = loose_matching_trans.trade_id
  394. new_transaction.trade_id = target_trade_id
  395. print(f"找到宽松匹配的未平仓 Trade ID: {target_trade_id},关联此平仓记录。")
  396. else:
  397. print("ERROR: 平仓操作找不到任何匹配的开仓记录,这是无效的平仓操作。")
  398. return jsonify({
  399. 'code': 1,
  400. 'msg': f'平仓操作失败:找不到对应的开仓记录。请确认有相同合约和账户的开仓交易。'
  401. })
  402. # --- 4. Add Transaction to Session ---
  403. db.session.add(new_transaction)
  404. db.session.flush() # Get the ID for new_transaction
  405. # --- 5. Create or Update Trade Record ---
  406. if target_trade_id:
  407. # Update existing TradeRecord
  408. print(f"触发 TradeRecord 更新 ID: {target_trade_id}")
  409. update_result = update_trade_record(target_trade_id) # This function handles fetching all related trans and recalculating
  410. final_trade_msg = update_result.get('msg', f"尝试更新 TradeRecord ID: {target_trade_id}")
  411. else:
  412. # Create new TradeRecord (only for opening transactions)
  413. if position_type in [0, 2]: # 开多 or 开空
  414. print("创建新的 TradeRecord...")
  415. # Use the helper that returns a TradeRecord object
  416. new_trade = generate_trade_from_transactions([new_transaction])
  417. if new_trade:
  418. try:
  419. db.session.add(new_trade)
  420. db.session.flush() # Get the ID for the new trade
  421. new_transaction.trade_id = new_trade.id # Backfill the trade_id
  422. final_trade_msg = f"成功创建新的 TradeRecord ID: {new_trade.id}"
  423. print(final_trade_msg)
  424. except Exception as trade_create_e:
  425. final_trade_msg = f"创建 TradeRecord 实例时出错: {trade_create_e}"
  426. print(final_trade_msg)
  427. # Consider what to do if trade creation fails - maybe rollback transaction?
  428. else:
  429. final_trade_msg = "创建新的 TradeRecord 失败(无法计算数据)。"
  430. print(final_trade_msg)
  431. else:
  432. # 平仓操作但没有找到对应的开仓记录,这个情况已经在前面处理了
  433. final_trade_msg = "平仓操作已处理。"
  434. # --- 6. 平仓后更新相关交易记录状态 ---
  435. update_msg = ""
  436. if (position_type in [1, 3]) and (trade_status == 3) and new_transaction.trade_id:
  437. # 如果是平仓操作且状态为"已结束",更新所有相同交易ID的相关交易记录状态
  438. try:
  439. related_transactions = TransactionRecord.query.filter_by(trade_id=new_transaction.trade_id).all()
  440. updated_count = 0
  441. for trans in related_transactions:
  442. if trans.trade_status != 3: # 只更新未结束的记录
  443. trans.trade_status = 3
  444. updated_count += 1
  445. if updated_count > 0:
  446. update_msg = f"已更新 {updated_count} 条相关交易记录状态为'已结束'。"
  447. print(f"平仓完成:{update_msg}")
  448. except Exception as update_e:
  449. print(f"更新相关交易记录状态时出错: {update_e}")
  450. update_msg = "更新相关交易记录状态时出现问题。"
  451. # --- 7. Commit and Respond ---
  452. db.session.commit()
  453. return jsonify({
  454. 'code': 0,
  455. 'msg': f'操作成功。{final_trade_msg} {update_msg}'.strip(),
  456. 'data': new_transaction.to_dict() # Return the transaction, possibly with updated trade_id
  457. })
  458. except Exception as e:
  459. db.session.rollback()
  460. import traceback
  461. print(traceback.format_exc())
  462. return jsonify({
  463. 'code': 1,
  464. 'msg': f'创建交易记录时出错: {str(e)}'
  465. })
  466. # Helper to convert names to IDs
  467. def _get_ids_from_names(names_string, model):
  468. ids = None
  469. corrected_names = None
  470. if names_string:
  471. name_list = [name.strip() for name in names_string.split('+') if name.strip()]
  472. if name_list:
  473. records = model.query.filter(model.name.in_(name_list)).all()
  474. id_map = {r.name: r.id for r in records}
  475. id_list = [str(id_map[name]) for name in name_list if name in id_map]
  476. matched_names = [name for name in name_list if name in id_map]
  477. if id_list:
  478. ids = ','.join(id_list)
  479. if matched_names:
  480. corrected_names = '+'.join(matched_names)
  481. return ids, corrected_names
  482. @bp.route('/api/update/<int:id>', methods=['PUT'])
  483. def update(id):
  484. """更新交易记录"""
  485. # 在函数内部导入
  486. from app.services.trade_logic import update_trade_record
  487. transaction = TransactionRecord.query.get_or_404(id)
  488. original_trade_id = transaction.trade_id # 记录原始 trade_id
  489. data = request.json
  490. recalculate_financials = False
  491. trigger_trade_update = False # Flag to trigger trade update
  492. # 更新字段
  493. if 'transaction_time' in data:
  494. try:
  495. transaction.transaction_time = datetime.fromisoformat(data['transaction_time'])
  496. except ValueError:
  497. transaction.transaction_time = datetime.strptime(data['transaction_time'], '%Y-%m-%d %H:%M')
  498. recalculate_financials = True # 时间变化影响汇总
  499. if 'contract_code' in data:
  500. transaction.contract_code = data['contract_code']
  501. recalculate_financials = True
  502. if 'name' in data:
  503. transaction.name = data['name']
  504. recalculate_financials = True # name 变化影响 margin 计算和汇总
  505. if 'account' in data:
  506. transaction.account = data['account']
  507. recalculate_financials = True
  508. if 'strategy_ids' in data:
  509. transaction.strategy_ids = data['strategy_ids']
  510. recalculate_financials = True
  511. if 'strategy_name' in data:
  512. transaction.strategy_name = data['strategy_name']
  513. if 'position_type' in data:
  514. transaction.position_type = data['position_type']
  515. recalculate_financials = True # position_type 变化影响 volume_change 和 margin
  516. if 'candle_pattern_ids' in data:
  517. transaction.candle_pattern_ids = data['candle_pattern_ids']
  518. if 'candle_pattern' in data:
  519. transaction.candle_pattern = data['candle_pattern']
  520. if 'price' in data:
  521. transaction.price = data['price']
  522. recalculate_financials = True # price 变化影响 amount, margin
  523. if 'volume' in data:
  524. transaction.volume = data['volume']
  525. recalculate_financials = True # volume 变化影响 amount, volume_change, margin
  526. if 'contract_multiplier' in data and data['contract_multiplier'] is not None:
  527. transaction.contract_multiplier = data['contract_multiplier']
  528. recalculate_financials = True # multiplier 变化影响 amount, margin
  529. if 'fee' in data:
  530. transaction.fee = data['fee']
  531. # fee 变化本身不直接触发重算 amount/margin/volume_change, 但会影响最终利润计算
  532. if 'trade_type' in data:
  533. transaction.trade_type = data['trade_type']
  534. if 'trade_status' in data and data['trade_status'] is not None:
  535. try:
  536. transaction.trade_status = int(data['trade_status'])
  537. except (ValueError, TypeError):
  538. # 如果转换失败,可以记录日志或返回错误,这里暂时忽略
  539. pass
  540. if 'latest_price' in data:
  541. transaction.latest_price = data['latest_price']
  542. # latest_price 变化影响 to_dict 中的计算,不需要在此重算存储字段
  543. if 'stop_loss_price' in data:
  544. transaction.stop_loss_price = data['stop_loss_price']
  545. # stop_loss_price 变化影响 to_dict 中的计算
  546. # 移除 is_close_today, related_open_id, notes 的更新 (根据 BRD 要求)
  547. # if 'is_close_today' in data:
  548. # transaction.is_close_today = data['is_close_today']
  549. # if 'related_open_id' in data:
  550. # transaction.related_open_id = data['related_open_id']
  551. # if 'notes' in data:
  552. # transaction.notes = data['notes']
  553. if 'operation_time' in data:
  554. try:
  555. transaction.operation_time = datetime.fromisoformat(data['operation_time'])
  556. except ValueError:
  557. transaction.operation_time = datetime.strptime(data['operation_time'], '%Y-%m-%d %H:%M')
  558. if 'confidence_index' in data:
  559. transaction.confidence_index = data['confidence_index']
  560. if 'similarity_evaluation' in data:
  561. transaction.similarity_evaluation = data['similarity_evaluation']
  562. if 'long_trend_ids' in data:
  563. transaction.long_trend_ids = data['long_trend_ids']
  564. if 'long_trend_name' in data:
  565. transaction.long_trend_name = data['long_trend_name']
  566. if 'mid_trend_ids' in data:
  567. transaction.mid_trend_ids = data['mid_trend_ids']
  568. if 'mid_trend_name' in data:
  569. transaction.mid_trend_name = data['mid_trend_name']
  570. # 重新计算相关字段
  571. if recalculate_financials:
  572. # 确保必要字段存在
  573. price = transaction.price
  574. volume = transaction.volume
  575. contract_multiplier = transaction.contract_multiplier
  576. position_type = transaction.position_type
  577. name = transaction.name
  578. if price is not None and volume is not None and contract_multiplier is not None:
  579. # 重新计算成交金额
  580. transaction.amount = price * volume * contract_multiplier
  581. # 重新计算手数变化
  582. if position_type in [0, 3]:
  583. transaction.volume_change = volume
  584. elif position_type in [1, 2]:
  585. transaction.volume_change = -volume
  586. else:
  587. transaction.volume_change = 0
  588. # 重新计算保证金
  589. margin = None
  590. future_info = None
  591. if name:
  592. future_info = FutureInfo.query.filter_by(name=name).first()
  593. if future_info and transaction.amount is not None:
  594. margin_rate = None
  595. if position_type in [0, 1]: # 多头
  596. margin_rate = future_info.long_margin_rate
  597. elif position_type in [2, 3]: # 空头
  598. margin_rate = future_info.short_margin_rate
  599. if margin_rate is not None:
  600. # 假设 margin_rate 是百分比形式存储
  601. margin = transaction.amount * (margin_rate / 100.0)
  602. transaction.margin = margin
  603. else:
  604. # 如果计算所需字段不全,将计算结果设为 None
  605. transaction.amount = None
  606. transaction.volume_change = None
  607. transaction.margin = None
  608. # 保存到数据库
  609. db.session.commit()
  610. # --- Update Trade Record(s) if needed ---
  611. trade_update_msg = ""
  612. if trigger_trade_update:
  613. ids_to_update = set()
  614. if original_trade_id:
  615. ids_to_update.add(original_trade_id)
  616. if transaction.trade_id and transaction.trade_id != original_trade_id:
  617. ids_to_update.add(transaction.trade_id)
  618. print(f"交易记录更新触发 Trade Record 更新 IDs: {ids_to_update}")
  619. for t_id in ids_to_update:
  620. if t_id: # Ensure not None
  621. try:
  622. update_result = update_trade_record(t_id)
  623. trade_update_msg += f" Trade ID {t_id}: {update_result.get('msg', '尝试更新')}. "
  624. except Exception as e:
  625. trade_update_msg += f" Trade ID {t_id} 更新失败: {e}. "
  626. print(f"更新 Trade ID {t_id} 失败: {e}")
  627. return jsonify({
  628. 'code': 0,
  629. 'msg': f'更新成功。{trade_update_msg}',
  630. 'data': transaction.to_dict()
  631. })
  632. @bp.route('/api/delete/<int:id>', methods=['DELETE'])
  633. def delete(id):
  634. """删除交易记录"""
  635. # 在函数内部导入
  636. from app.services.trade_logic import update_trade_record
  637. transaction = TransactionRecord.query.get_or_404(id)
  638. associated_trade_id = transaction.trade_id
  639. db.session.delete(transaction)
  640. db.session.commit() # Commit deletion first
  641. # Trigger update for the associated trade record
  642. trade_update_msg = ""
  643. if associated_trade_id:
  644. print(f"删除交易记录 ID {id} 触发 Trade Record 更新 ID: {associated_trade_id}")
  645. try:
  646. update_result = update_trade_record(associated_trade_id)
  647. trade_update_msg = f"关联 Trade ID {associated_trade_id}: {update_result.get('msg', '尝试更新')}"
  648. except Exception as e:
  649. trade_update_msg = f"关联 Trade ID {associated_trade_id} 更新失败: {e}"
  650. print(f"更新 Trade ID {associated_trade_id} (因删除) 失败: {e}")
  651. # Consider if the trade should be deleted if it has no transactions left
  652. return jsonify({
  653. 'code': 0,
  654. 'msg': f'删除成功。{trade_update_msg}'
  655. })
  656. @bp.route('/template', methods=['GET'])
  657. def get_template():
  658. """获取交易记录的Excel导入模板"""
  659. # 创建DataFrame
  660. columns = [
  661. '交易ID', '换月ID', '成交时间', '合约代码', '合约名称', '账户',
  662. '操作策略', '多空仓位', 'K线形态', '成交价格', '成交手数', '单位',
  663. '成交金额', '手续费', '手数变化', '现金流', '保证金', '资金阈值判定',
  664. '交易类别', '交易状态', '止损点', '操作日期',
  665. '长期趋势名称', '中期趋势名称'
  666. ]
  667. # 创建示例数据
  668. data = [
  669. [1, 0, '2023-03-29 14:30', 'CU2305', '沪铜', '华安期货',
  670. '趋势突破+均线突破', 0, '突破回踩+双底', 68000, 1, 5,
  671. 340000, 15, 1, -340015, 34000, 0,
  672. 0, 0, 67500, '2023-03-29 14:30',
  673. '长期上涨+短期震荡', '中期下跌+短期震荡']
  674. ]
  675. df = pd.DataFrame(data, columns=columns)
  676. # 创建Excel文件
  677. output = io.BytesIO()
  678. with pd.ExcelWriter(output, engine='xlsxwriter') as writer:
  679. df.to_excel(writer, sheet_name='交易记录导入模板', index=False)
  680. # 自动调整列宽
  681. worksheet = writer.sheets['交易记录导入模板']
  682. for i, col in enumerate(df.columns):
  683. column_width = max(df[col].astype(str).map(len).max(), len(col) + 2)
  684. worksheet.set_column(i, i, column_width)
  685. output.seek(0)
  686. # 设置下载文件名
  687. filename = f'交易记录导入模板_{datetime.now().strftime("%Y%m%d%H%M%S")}.xlsx'
  688. return send_file(
  689. output,
  690. mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  691. as_attachment=True,
  692. download_name=filename
  693. )
  694. @bp.route('/api/import', methods=['POST'])
  695. def import_excel():
  696. """从Excel导入交易记录 (修改后)"""
  697. # 在函数内部导入
  698. from app.services.trade_logic import sync_trades_after_import
  699. if 'file' not in request.files:
  700. return jsonify({'code': 1,'msg': '没有上传文件'})
  701. file = request.files['file']
  702. if file.filename == '':
  703. return jsonify({'code': 1, 'msg': '没有选择文件'})
  704. if not file.filename.endswith('.xlsx'):
  705. return jsonify({'code': 1, 'msg': '请上传Excel文件(.xlsx)'})
  706. try:
  707. temp_dir = tempfile.gettempdir()
  708. cache_buster = str(uuid.uuid4())
  709. temp_path = os.path.join(temp_dir, f"transaction_import_{cache_buster}.xlsx")
  710. file.save(temp_path)
  711. df = pd.read_excel(temp_path)
  712. try:
  713. os.remove(temp_path)
  714. except Exception: pass
  715. print(f"Excel 列名: {df.columns.tolist()}")
  716. required_columns = ['交易ID', '合约代码', '合约名称', '多空仓位', '成交价格', '成交手数']
  717. missing_columns = [col for col in required_columns if col not in df.columns]
  718. if missing_columns:
  719. return jsonify({'code': 1, 'msg': f'Excel文件缺少必填列: {", ".join(missing_columns)}'})
  720. # --- 验证 Trade ID 配对 ---
  721. trade_id_map = {}
  722. row_errors = {} # Store errors by row index
  723. for i, row in df.iterrows():
  724. row_num = i + 2 # Excel row number
  725. excel_trade_id = None
  726. pos_type = None
  727. try:
  728. if '交易ID' in row and not pd.isna(row['交易ID']):
  729. excel_trade_id = int(row['交易ID'])
  730. else:
  731. raise ValueError("缺少必需的 '交易ID'")
  732. if '多空仓位' in row and not pd.isna(row['多空仓位']):
  733. pos_type = int(row['多空仓位'])
  734. if pos_type not in [0, 1, 2, 3]:
  735. raise ValueError("无效的 '多空仓位' 值")
  736. else:
  737. raise ValueError("缺少必需的 '多空仓位'")
  738. if excel_trade_id not in trade_id_map:
  739. trade_id_map[excel_trade_id] = []
  740. trade_id_map[excel_trade_id].append({'pos_type': pos_type, 'row_num': row_num})
  741. except Exception as e:
  742. row_errors[row_num] = f"行预检错误: {str(e)}"
  743. # Check pairs
  744. for trade_id, items in trade_id_map.items():
  745. if len(items) > 2:
  746. involved_rows = ", ".join([str(item['row_num']) for item in items])
  747. error_msg = f"交易ID {trade_id} 在行 {involved_rows} 出现超过2次。"
  748. for item in items: row_errors[item['row_num']] = error_msg # Mark all related rows
  749. elif len(items) == 2:
  750. pos_types = {item['pos_type'] for item in items}
  751. if not ((0 in pos_types and 1 in pos_types) or (2 in pos_types and 3 in pos_types)):
  752. involved_rows = ", ".join([str(item['row_num']) for item in items])
  753. error_msg = f"交易ID {trade_id} 在行 {involved_rows} 的仓位类型不是有效的开平仓对。"
  754. for item in items: row_errors[item['row_num']] = error_msg
  755. # Single entry is allowed, will create/update trade based on that single entry
  756. # --- Process Rows ---
  757. transactions_to_add = []
  758. imported_trade_ids = set()
  759. error_count = len(row_errors)
  760. error_messages = list(row_errors.values()) # Collect pre-check errors
  761. # Load dimension maps once
  762. strategy_id_map, candle_pattern_id_map, trend_id_map = _load_dimension_maps()
  763. for i, row in df.iterrows():
  764. row_num = i + 2
  765. if row_num in row_errors: # Skip rows with pre-check errors
  766. continue
  767. try:
  768. excel_trade_id = int(row['交易ID']) # Already validated
  769. position_type = int(row['多空仓位']) # Already validated
  770. transaction_time, operation_time = _parse_excel_dates(row.get('成交时间'), row.get('操作日期'))
  771. price = float(row['成交价格'])
  772. volume = float(row['成交手数'])
  773. contract_multiplier = float(row.get('单位', 1)) if not pd.isna(row.get('单位')) else 1
  774. amount = float(row.get('成交金额', price * volume * contract_multiplier)) if not pd.isna(row.get('成交金额')) else price * volume * contract_multiplier
  775. fee = float(row.get('手续费', 0)) if not pd.isna(row.get('手续费')) else 0
  776. # Calculate volume_change based on position type
  777. volume_change = volume if position_type in [0, 3] else -volume
  778. # Margin needs calculation based on FutureInfo (similar to create logic)
  779. # margin = _calculate_margin(...) # Need a helper or repeat logic
  780. margin = float(row.get('保证金', 0)) if not pd.isna(row.get('保证金')) else None # Simplified: Take from Excel or None
  781. # Get IDs from names using preloaded maps
  782. strategy_ids, strategy_name = _resolve_names(row.get('操作策略', ''), strategy_id_map)
  783. candle_pattern_ids, candle_pattern = _resolve_names(row.get('K线形态', ''), candle_pattern_id_map)
  784. long_trend_ids, long_trend_name = _resolve_names(row.get('长期趋势名称', ''), trend_id_map)
  785. mid_trend_ids, mid_trend_name = _resolve_names(row.get('中期趋势名称', ''), trend_id_map)
  786. transaction = TransactionRecord(
  787. trade_id=excel_trade_id,
  788. roll_id=int(row.get('换月ID', 0)) if not pd.isna(row.get('换月ID')) else None,
  789. transaction_time=transaction_time,
  790. operation_time=operation_time,
  791. contract_code=row['合约代码'],
  792. name=row['合约名称'],
  793. account=row.get('账户', '华安期货'),
  794. strategy_ids=strategy_ids,
  795. strategy_name=strategy_name,
  796. position_type=position_type,
  797. candle_pattern_ids=candle_pattern_ids,
  798. candle_pattern=candle_pattern,
  799. price=price,
  800. volume=volume,
  801. contract_multiplier=contract_multiplier,
  802. amount=amount,
  803. fee=fee,
  804. volume_change=volume_change, # Use calculated value
  805. # cash_flow=... # Not directly in simpler template?
  806. margin=margin, # Use calculated or Excel value
  807. # fund_threshold=...
  808. trade_type=int(row.get('交易类别', 0)) if not pd.isna(row.get('交易类别')) else 0,
  809. trade_status=int(row.get('交易状态', 0)) if not pd.isna(row.get('交易状态')) else 0,
  810. stop_loss_price=float(row.get('止损点', 0)) if not pd.isna(row.get('止损点')) else None,
  811. confidence_index=int(row.get('信心指数', 0)) if not pd.isna(row.get('信心指数')) else None,
  812. similarity_evaluation=row.get('相似度评估'),
  813. long_trend_ids=long_trend_ids,
  814. long_trend_name=long_trend_name,
  815. mid_trend_ids=mid_trend_ids,
  816. mid_trend_name=mid_trend_name
  817. # notes=...
  818. )
  819. transactions_to_add.append(transaction)
  820. imported_trade_ids.add(excel_trade_id)
  821. except Exception as e:
  822. error_count += 1
  823. row_data_str = ", ".join([f"{k}={v}" for k, v in row.items()])
  824. # 从配置服务获取错误消息最大长度
  825. try:
  826. from app.services.config_service import get_int_config
  827. max_length = get_int_config('error_message_max_length', 200)
  828. except Exception:
  829. max_length = 200
  830. error_msg = f'第{row_num}行处理错误: {str(e)}\n行数据: {row_data_str[:max_length]}...' # Limit row data length
  831. print(error_msg)
  832. error_messages.append(error_msg)
  833. # No rollback needed here as we haven't added to session yet
  834. # --- Add valid transactions and sync trades ---
  835. sync_result = None # 初始化为 None
  836. if transactions_to_add:
  837. try:
  838. # Check for duplicates before adding (e.g., unique constraint on id?)
  839. # Add all valid Transaction Records
  840. db.session.add_all(transactions_to_add)
  841. db.session.flush() # Assign transaction IDs
  842. print(f"已添加 {len(transactions_to_add)} 条交易记录到 session。")
  843. final_success_count = len(transactions_to_add) # Update success count
  844. # Sync Trade Records
  845. print(f"开始同步 {len(imported_trade_ids)} 个关联的 Trade Records...")
  846. sync_result = sync_trades_after_import(list(imported_trade_ids))
  847. sync_msg = sync_result.get('msg', '交易汇总记录同步完成。')
  848. print(sync_msg) # 打印同步结果
  849. db.session.commit() # Commit transaction additions/updates and trade creations/updates
  850. except Exception as commit_sync_e:
  851. db.session.rollback() # Rollback if commit or sync fails
  852. import traceback
  853. print("Commit/Sync 阶段出错:")
  854. print(traceback.format_exc())
  855. final_success_count = 0 # Reset success count on final error
  856. error_count = len(df) # Mark all as failed if commit fails
  857. sync_msg = "数据库提交或同步失败,所有更改已回滚。"
  858. error_messages.append(f"数据库错误: {str(commit_sync_e)}")
  859. return jsonify({
  860. 'code': 0 if error_count == 0 else 1, # Adjust code based on if errors occurred
  861. 'msg': f'处理完成: {final_success_count} 条记录成功导入/更新, {error_count} 行存在错误。{sync_msg}',
  862. 'data': {
  863. 'success_count': final_success_count,
  864. 'error_count': error_count,
  865. 'error_messages': error_messages,
  866. 'sync_details': sync_result # Optional: include sync details
  867. }
  868. })
  869. except Exception as e:
  870. # Catch errors during file reading or initial setup
  871. db.session.rollback()
  872. import traceback
  873. print(traceback.format_exc())
  874. return jsonify({
  875. 'code': 1,
  876. 'msg': f'导入过程中发生意外错误: {str(e)}'
  877. })
  878. # --- Helper functions for import ---
  879. def _load_dimension_maps():
  880. strategy_map = {s.name: s.id for s in StrategyInfo.query.all()}
  881. candle_map = {c.name: c.id for c in CandleInfo.query.all()}
  882. trend_map = {t.name: t.id for t in TrendInfo.query.all()}
  883. return strategy_map, candle_map, trend_map
  884. def _parse_excel_dates(time_val, op_time_val):
  885. transaction_time = datetime.now() # Default
  886. if not pd.isna(time_val):
  887. try:
  888. # Handle various possible Excel date formats
  889. if isinstance(time_val, datetime): transaction_time = time_val
  890. else: transaction_time = pd.to_datetime(time_val).to_pydatetime()
  891. except Exception as e:
  892. print(f"无法解析成交时间 '{time_val}', 使用当前时间. 错误: {e}")
  893. operation_time = transaction_time # Default to transaction_time
  894. if not pd.isna(op_time_val):
  895. try:
  896. if isinstance(op_time_val, datetime): operation_time = op_time_val
  897. else: operation_time = pd.to_datetime(op_time_val).to_pydatetime()
  898. except Exception as e:
  899. print(f"无法解析操作时间 '{op_time_val}', 使用成交时间. 错误: {e}")
  900. return transaction_time, operation_time
  901. def _resolve_names(names_string, id_map):
  902. ids = None
  903. corrected_names = None
  904. if isinstance(names_string, str) and names_string.strip():
  905. names_string = names_string.strip()
  906. name_list = [name.strip() for name in names_string.split('+') if name.strip()]
  907. if name_list:
  908. id_list = [str(id_map[name]) for name in name_list if name in id_map]
  909. matched_names = [name for name in name_list if name in id_map]
  910. if id_list:
  911. ids = ','.join(id_list)
  912. if matched_names:
  913. corrected_names = '+'.join(matched_names)
  914. return ids, corrected_names
  915. @bp.route('/import', methods=['GET'])
  916. def import_view():
  917. """导入交易记录页面"""
  918. return render_template('transaction/import.html')
  919. # Remove the /generate_trades endpoint as it's replaced by logic within create/import/update
  920. # @bp.route('/api/generate_trades', methods=['POST'])
  921. # def generate_all_trades():
  922. # pass
  923. ""