""" 交易配对引擎 - 将开仓和平仓配对为完整交易,持久化到 TradePair 表 """ import logging from datetime import datetime from typing import Optional from sqlalchemy.orm import Session from collections import defaultdict from app.analysis_models import TradeRecord, TradePair, TradeReflection logger = logging.getLogger(__name__) def auto_pair_trades(db: Session, trade_date: str) -> dict: """ 自动配对指定日期的交易记录 按品种+方向分组,FIFO 规则配对 返回:{created: 新建配对数, updated: 更新配对数, unpaired: 未配对记录数} """ records = db.query(TradeRecord).filter( TradeRecord.trade_date == trade_date ).order_by( TradeRecord.variety, TradeRecord.symbol, TradeRecord.trade_date, TradeRecord.trade_time ).all() if not records: return {"created": 0, "updated": 0, "unpaired": 0} # 按品种分组 by_variety = defaultdict(list) for r in records: by_variety[r.variety].append(r) result = {"created": 0, "updated": 0, "unpaired": 0} for variety, recs in by_variety.items(): pair_result = _pair_single_variety(db, variety, recs) result["created"] += pair_result["created"] result["updated"] += pair_result["updated"] result["unpaired"] += pair_result["unpaired"] return result def _pair_single_variety(db: Session, variety: str, records: list) -> dict: """单个品种的配对逻辑""" # 分离开仓和平仓 opens = [r for r in records if r.offset == '开'] closes = [r for r in records if r.offset == '平'] result = {"created": 0, "updated": 0, "unpaired": 0} # 按方向分组 long_opens = [r for r in opens if r.direction == '买'] short_opens = [r for r in opens if r.direction == '卖'] long_closes = [r for r in closes if r.direction == '卖'] # 卖平 = 多头平仓 short_closes = [r for r in closes if r.direction == '买'] # 买平 = 空头平仓 # 多头配对 result["created"] += _pair_direction(db, variety, long_opens, long_closes, "long") # 空头配对 result["created"] += _pair_direction(db, variety, short_opens, short_closes, "short") # 统计未配对 all_paired_open_ids = set() all_paired_close_ids = set() existing_pairs = db.query(TradePair).filter( TradePair.variety == variety, TradePair.open_date >= records[0].trade_date if records else None ).all() for p in existing_pairs: all_paired_open_ids.update(p.open_record_ids) all_paired_close_ids.update(p.close_record_ids) unpaired_count = 0 for r in records: if r.id not in all_paired_open_ids and r.id not in all_paired_close_ids: unpaired_count += 1 result["unpaired"] = unpaired_count return result def _pair_direction(db: Session, variety: str, opens: list, closes: list, direction: str) -> int: """单方向配对(FIFO),返回新建配对数""" created = 0 open_queue = list(opens) # FIFO 队列 close_queue = list(closes) while open_queue and close_queue: open_rec = open_queue[0] close_rec = close_queue[0] open_volume = open_rec.volume or 0 close_volume = close_rec.volume or 0 # 计算配对手数 pair_volume = min(open_volume, close_volume) # 计算平均价格 open_price = open_rec.price close_price = close_rec.price # 计算盈亏 close_pnl = close_rec.close_pnl or 0 total_commission = (open_rec.commission or 0) + (close_rec.commission or 0) net_pnl = close_pnl - total_commission # 创建配对记录 pair = TradePair( variety=variety, direction=direction, open_record_ids=[open_rec.id], close_record_ids=[close_rec.id], open_price=open_price, close_price=close_price, total_volume=pair_volume, close_pnl=close_pnl, total_commission=total_commission, net_pnl=net_pnl, open_date=open_rec.trade_date, close_date=close_rec.trade_date, ) db.add(pair) created += 1 # 从队列中移除或更新剩余手数 if open_volume == pair_volume: open_queue.pop(0) else: open_rec.volume = open_volume - pair_volume if close_volume == pair_volume: close_queue.pop(0) else: close_rec.volume = close_volume - pair_volume db.flush() return created def get_daily_trades_with_pairs(db: Session, trade_date: str) -> dict: """ 获取指定日期的交易数据,包含配对信息和反思状态 返回:{date, stats, pairs, unpaired_records, daily_reflection} """ # 获取所有配对 pairs = db.query(TradePair).filter( (TradePair.open_date == trade_date) | (TradePair.close_date == trade_date) ).order_by(TradePair.open_date, TradePair.close_date).all() # 获取所有交易记录 records = db.query(TradeRecord).filter( TradeRecord.trade_date == trade_date ).order_by(TradeRecord.trade_date, TradeRecord.trade_time).all() # 计算统计 total_pnl = sum(p.net_pnl or 0 for p in pairs) total_trades = len(pairs) + len(records) # 配对 + 未配对 winning_trades = sum(1 for p in pairs if (p.net_pnl or 0) > 0) win_rate = round(winning_trades / total_trades * 100, 1) if total_trades > 0 else 0 # 获取反思状态 pair_ids = [p.id for p in pairs] reflections = db.query(TradeReflection).filter( TradeReflection.trade_pair_id.in_(pair_ids) ).all() reflection_map = {r.trade_pair_id: r for r in reflections} # 获取当日反思 daily_reflection = db.query(DailyReflection).filter( DailyReflection.reflection_date == trade_date ).first() # 构建返回数据 pairs_data = [] for p in pairs: reflection = reflection_map.get(p.id) pairs_data.append({ "id": p.id, "variety": p.variety, "direction": p.direction, "open_price": p.open_price, "close_price": p.close_price, "total_volume": p.total_volume, "close_pnl": p.close_pnl, "total_commission": p.total_commission, "net_pnl": p.net_pnl, "open_date": p.open_date, "close_date": p.close_date, "open_record_ids": p.open_record_ids, "close_record_ids": p.close_record_ids, "reflection_status": "done" if reflection else ("pending" if _has_unpaired_records(db, p) else "none"), "ai_analyzed": reflection.ai_analyzed if reflection else False, "ai_version": reflection.ai_version if reflection else None, }) # 未配对记录 paired_record_ids = set() for p in pairs: paired_record_ids.update(p.open_record_ids) paired_record_ids.update(p.close_record_ids) unpaired = [r for r in records if r.id not in paired_record_ids] unpaired_data = [{ "id": r.id, "symbol": r.symbol, "variety": r.variety, "direction": r.direction, "offset": r.offset, "price": r.price, "volume": r.volume, "trade_date": r.trade_date, "trade_time": r.trade_time, } for r in unpaired] return { "date": trade_date, "stats": { "total_pnl": round(total_pnl, 2), "total_trades": total_trades, "winning_trades": winning_trades, "win_rate": win_rate, "paired_count": len(pairs), "unpaired_count": len(unpaired), "reflected_count": sum(1 for r in reflection_map.values()), }, "pairs": pairs_data, "unpaired_records": unpaired_data, "daily_reflection": { "id": daily_reflection.id if daily_reflection else None, "emotion_state": daily_reflection.emotion_state if daily_reflection else None, "market_judgment": daily_reflection.market_judgment if daily_reflection else None, "discipline_score": daily_reflection.discipline_score if daily_reflection else None, "overall_rating": daily_reflection.overall_rating if daily_reflection else None, "summary": daily_reflection.summary if daily_reflection else None, "improvements": daily_reflection.improvements if daily_reflection else None, } if daily_reflection else None, } def _has_unpaired_records(db: Session, pair: TradePair) -> bool: """检查配对是否有未配对的开仓或平仓记录""" # 简化逻辑:如果开仓日期和平仓日期不同,可能存在跨日未配对 return pair.open_date != pair.close_date def create_manual_pair(db: Session, open_record_ids: list[int], close_record_ids: list[int]) -> TradePair: """手动创建配对""" open_records = db.query(TradeRecord).filter(TradeRecord.id.in_(open_record_ids)).all() close_records = db.query(TradeRecord).filter(TradeRecord.id.in_(close_record_ids)).all() if not open_records or not close_records: raise ValueError("开仓或平仓记录不存在") variety = open_records[0].variety direction = "long" if open_records[0].direction == '买' else "short" # 计算统计 total_volume = sum(r.volume or 0 for r in open_records) total_close_pnl = sum(r.close_pnl or 0 for r in close_records) total_commission = sum((r.commission or 0) for r in open_records) + sum((r.commission or 0) for r in close_records) net_pnl = total_close_pnl - total_commission avg_open_price = sum(r.price or 0 for r in open_records) / len(open_records) if open_records else 0 avg_close_price = sum(r.price or 0 for r in close_records) / len(close_records) if close_records else 0 pair = TradePair( variety=variety, direction=direction, open_record_ids=open_record_ids, close_record_ids=close_record_ids, open_price=round(avg_open_price, 2), close_price=round(avg_close_price, 2), total_volume=total_volume, close_pnl=total_close_pnl, total_commission=total_commission, net_pnl=round(net_pnl, 2), open_date=open_records[0].trade_date, close_date=close_records[-1].trade_date, ) db.add(pair) db.flush() return pair def delete_pair(db: Session, pair_id: int) -> bool: """删除配对(不删除原始交易记录)""" pair = db.query(TradePair).filter(TradePair.id == pair_id).first() if not pair: return False # 删除关联的反思 db.query(TradeReflection).filter(TradeReflection.trade_pair_id == pair_id).delete() # 删除配对 db.delete(pair) db.flush() return True