|
|
"""
|
|
|
交易反思系统单元测试与集成测试
|
|
|
"""
|
|
|
import json
|
|
|
import pytest
|
|
|
from sqlalchemy import create_engine
|
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
|
|
from app.analysis_db import AnalysisBase
|
|
|
from app.analysis_models import (
|
|
|
TradeRecord, TradePair, TradeReflection, DailyReflection,
|
|
|
TradeTag, TradeRecordTag, TradeExperience, TradeAIAnalysis,
|
|
|
)
|
|
|
from app.services.trade_pairing_engine import (
|
|
|
auto_pair_trades,
|
|
|
create_manual_pair,
|
|
|
delete_pair,
|
|
|
get_daily_trades_with_pairs,
|
|
|
)
|
|
|
from app.services.reflection_ai_analysis import (
|
|
|
build_reflection_prompt,
|
|
|
_parse_ai_response,
|
|
|
save_suggestion_as_experience,
|
|
|
)
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
def db():
|
|
|
"""提供已创建表结构的内存 SQLite 会话(analysis 库)。"""
|
|
|
engine = create_engine("sqlite:///:memory:")
|
|
|
AnalysisBase.metadata.create_all(bind=engine)
|
|
|
Session = sessionmaker(bind=engine)
|
|
|
session = Session()
|
|
|
yield session
|
|
|
session.close()
|
|
|
|
|
|
|
|
|
def _make_record(db, symbol="AG2608", variety="AG", direction="买", offset="开",
|
|
|
price=7850, volume=2, close_pnl=0, commission=10,
|
|
|
trade_date="2026-07-05", trade_time="09:30:00", import_batch="test-batch"):
|
|
|
"""辅助函数:创建交易记录"""
|
|
|
r = TradeRecord(
|
|
|
trade_type="期货",
|
|
|
symbol=symbol,
|
|
|
variety=variety,
|
|
|
symbol_name="沪银",
|
|
|
direction=direction,
|
|
|
offset=offset,
|
|
|
price=price,
|
|
|
volume=volume,
|
|
|
close_pnl=close_pnl,
|
|
|
commission=commission,
|
|
|
trade_date=trade_date,
|
|
|
trade_time=trade_time,
|
|
|
import_batch=import_batch,
|
|
|
)
|
|
|
db.add(r)
|
|
|
db.flush()
|
|
|
return r
|
|
|
|
|
|
|
|
|
class TestTradePairingEngine:
|
|
|
"""交易配对引擎测试"""
|
|
|
|
|
|
def test_fifo_pairing_single_day(self, db):
|
|
|
"""单日 FIFO 配对:先开先平"""
|
|
|
open1 = _make_record(db, direction="买", offset="开", price=7850, volume=2, trade_time="09:30:00")
|
|
|
close1 = _make_record(db, direction="卖", offset="平", price=7910, volume=2,
|
|
|
close_pnl=1200, commission=10, trade_time="14:20:00")
|
|
|
|
|
|
result = auto_pair_trades(db, "2026-07-05")
|
|
|
db.commit()
|
|
|
|
|
|
assert result["created"] == 1
|
|
|
pairs = db.query(TradePair).all()
|
|
|
assert len(pairs) == 1
|
|
|
assert pairs[0].direction == "long"
|
|
|
assert pairs[0].open_price == 7850
|
|
|
assert pairs[0].close_price == 7910
|
|
|
assert pairs[0].net_pnl == 1180 # 1200 - 10 - 10
|
|
|
|
|
|
def test_cross_day_pairing(self, db):
|
|
|
"""跨日配对:开仓和平仓在不同日期"""
|
|
|
open_rec = _make_record(db, direction="买", offset="开", price=4000, volume=1,
|
|
|
trade_date="2026-07-04", trade_time="09:30:00")
|
|
|
close_rec = _make_record(db, direction="卖", offset="平", price=4100, volume=1,
|
|
|
close_pnl=1000, commission=20,
|
|
|
trade_date="2026-07-05", trade_time="14:00:00")
|
|
|
|
|
|
# 先配对 7-04 的数据(只有开仓,不会配对)
|
|
|
auto_pair_trades(db, "2026-07-04")
|
|
|
# 再配对 7-05 的数据
|
|
|
result = auto_pair_trades(db, "2026-07-05")
|
|
|
db.commit()
|
|
|
|
|
|
pairs = db.query(TradePair).all()
|
|
|
assert len(pairs) == 1
|
|
|
assert pairs[0].open_date == "2026-07-04"
|
|
|
assert pairs[0].close_date == "2026-07-05"
|
|
|
assert pairs[0].net_pnl == 970 # 1000 - 10 - 20
|
|
|
|
|
|
def test_multiple_opens_merged_position(self, db):
|
|
|
"""多次开仓合并为一个头寸,FIFO 配对"""
|
|
|
open1 = _make_record(db, direction="买", offset="开", price=7800, volume=1, trade_time="09:30:00")
|
|
|
open2 = _make_record(db, direction="买", offset="开", price=7820, volume=1, trade_time="10:00:00")
|
|
|
close1 = _make_record(db, direction="卖", offset="平", price=7900, volume=2,
|
|
|
close_pnl=1800, commission=20, trade_time="14:00:00")
|
|
|
|
|
|
result = auto_pair_trades(db, "2026-07-05")
|
|
|
db.commit()
|
|
|
|
|
|
assert result["created"] == 1
|
|
|
pairs = db.query(TradePair).all()
|
|
|
assert len(pairs) == 1
|
|
|
# 由于 close_volume(2) == open1.volume(1) + open2.volume(1),会一起配对
|
|
|
assert pairs[0].total_volume == 2
|
|
|
|
|
|
def test_manual_pair(self, db):
|
|
|
"""手动创建配对"""
|
|
|
open_rec = _make_record(db, direction="买", offset="开", price=7800, volume=2)
|
|
|
close_rec = _make_record(db, direction="卖", offset="平", price=7900, volume=2,
|
|
|
close_pnl=2000, commission=20)
|
|
|
|
|
|
pair = create_manual_pair(db, [open_rec.id], [close_rec.id])
|
|
|
db.commit()
|
|
|
|
|
|
assert pair.direction == "long"
|
|
|
assert pair.open_price == 7800
|
|
|
assert pair.close_price == 7900
|
|
|
assert pair.net_pnl == 1970 # 2000 - 10 - 20
|
|
|
|
|
|
def test_delete_pair_keeps_records(self, db):
|
|
|
"""删除配对不删除原始交易记录"""
|
|
|
open_rec = _make_record(db, direction="买", offset="开", price=7800, volume=1)
|
|
|
close_rec = _make_record(db, direction="卖", offset="平", price=7900, volume=1,
|
|
|
close_pnl=1000, commission=10)
|
|
|
pair = create_manual_pair(db, [open_rec.id], [close_rec.id])
|
|
|
db.commit()
|
|
|
|
|
|
delete_pair(db, pair.id)
|
|
|
db.commit()
|
|
|
|
|
|
assert db.query(TradePair).count() == 0
|
|
|
assert db.query(TradeRecord).count() == 2
|
|
|
|
|
|
|
|
|
class TestDailyTradesView:
|
|
|
"""按天交易视图测试"""
|
|
|
|
|
|
def test_get_daily_trades_with_pairs(self, db):
|
|
|
"""获取某日交易+配对+反思状态"""
|
|
|
open_rec = _make_record(db, direction="买", offset="开", price=7800, volume=1)
|
|
|
close_rec = _make_record(db, direction="卖", offset="平", price=7900, volume=1,
|
|
|
close_pnl=1000, commission=10)
|
|
|
# 创建反思
|
|
|
pair = create_manual_pair(db, [open_rec.id], [close_rec.id])
|
|
|
reflection = TradeReflection(
|
|
|
trade_pair_id=pair.id,
|
|
|
trade_date="2026-07-05",
|
|
|
entry_reason="测试",
|
|
|
discipline_score=4,
|
|
|
)
|
|
|
db.add(reflection)
|
|
|
db.commit()
|
|
|
|
|
|
data = get_daily_trades_with_pairs(db, "2026-07-05")
|
|
|
|
|
|
assert data["stats"]["paired_count"] == 1
|
|
|
assert data["stats"]["unpaired_count"] == 0
|
|
|
assert len(data["pairs"]) == 1
|
|
|
assert data["pairs"][0]["reflection_status"] == "done"
|
|
|
|
|
|
|
|
|
class TestTagSystem:
|
|
|
"""标签系统测试"""
|
|
|
|
|
|
def test_tag_deduplication(self, db):
|
|
|
"""同一交易不能重复打相同标签"""
|
|
|
tag = TradeTag(name="追涨杀跌", category="operation", is_preset=True)
|
|
|
db.add(tag)
|
|
|
db.flush()
|
|
|
|
|
|
relation1 = TradeRecordTag(trade_pair_id=1, tag_id=tag.id)
|
|
|
db.add(relation1)
|
|
|
db.commit()
|
|
|
|
|
|
# 重复添加应该通过唯一约束失败
|
|
|
relation2 = TradeRecordTag(trade_pair_id=1, tag_id=tag.id)
|
|
|
db.add(relation2)
|
|
|
with pytest.raises(Exception):
|
|
|
db.commit()
|
|
|
db.rollback()
|
|
|
|
|
|
def test_preset_tags_initialization(self, db):
|
|
|
"""预设标签可以正常创建"""
|
|
|
tags = [
|
|
|
TradeTag(name="严格执行计划", category="discipline", is_preset=True),
|
|
|
TradeTag(name="突破交易", category="technical", is_preset=True),
|
|
|
]
|
|
|
db.add_all(tags)
|
|
|
db.commit()
|
|
|
|
|
|
assert db.query(TradeTag).filter_by(is_preset=True).count() == 2
|
|
|
|
|
|
|
|
|
class TestReflectionAIPrompt:
|
|
|
"""AI 反思分析提示词测试"""
|
|
|
|
|
|
def test_build_reflection_prompt_contains_key_info(self, db):
|
|
|
"""提示词包含交易关键信息和反思内容"""
|
|
|
open_rec = _make_record(db, direction="买", offset="开", price=7800, volume=1)
|
|
|
close_rec = _make_record(db, direction="卖", offset="平", price=7900, volume=1,
|
|
|
close_pnl=1000, commission=10)
|
|
|
pair = create_manual_pair(db, [open_rec.id], [close_rec.id])
|
|
|
reflection = TradeReflection(
|
|
|
trade_pair_id=pair.id,
|
|
|
trade_date="2026-07-05",
|
|
|
entry_reason="突破买入",
|
|
|
entry_timing="good",
|
|
|
discipline_score=4,
|
|
|
free_reflection="执行较好",
|
|
|
)
|
|
|
db.add(reflection)
|
|
|
db.commit()
|
|
|
|
|
|
prompt = build_reflection_prompt(db, pair, reflection)
|
|
|
|
|
|
assert "7800" in prompt
|
|
|
assert "7900" in prompt
|
|
|
assert "突破买入" in prompt
|
|
|
assert "执行较好" in prompt
|
|
|
assert "JSON" in prompt
|
|
|
|
|
|
def test_parse_ai_response_with_json(self):
|
|
|
"""正常 JSON 响应解析"""
|
|
|
response = json.dumps({
|
|
|
"overall_evaluation": "综合评价",
|
|
|
"strengths": ["优点1"],
|
|
|
"weaknesses": ["不足1"],
|
|
|
"experience_suggestion": "建议",
|
|
|
"suggestion_type": "lesson",
|
|
|
})
|
|
|
parsed = _parse_ai_response(response)
|
|
|
assert parsed["overall_evaluation"] == "综合评价"
|
|
|
assert parsed["strengths"] == ["优点1"]
|
|
|
|
|
|
def test_parse_ai_response_with_markdown(self):
|
|
|
"""Markdown 代码块中的 JSON 解析"""
|
|
|
response = '```json\n{"overall_evaluation": "评价", "strengths": [], "weaknesses": [], "experience_suggestion": "", "suggestion_type": "tip"}\n```'
|
|
|
parsed = _parse_ai_response(response)
|
|
|
assert parsed["suggestion_type"] == "tip"
|
|
|
|
|
|
def test_parse_ai_response_fallback(self):
|
|
|
"""无法解析时返回原始内容"""
|
|
|
response = "这是 AI 的纯文本回复"
|
|
|
parsed = _parse_ai_response(response)
|
|
|
assert parsed["overall_evaluation"] == response
|
|
|
|
|
|
|
|
|
class TestExperienceSave:
|
|
|
"""经验保存测试"""
|
|
|
|
|
|
def test_save_suggestion_as_experience(self, db):
|
|
|
"""将 AI 分析建议保存为经验"""
|
|
|
open_rec = _make_record(db, direction="买", offset="开", price=7800, volume=1)
|
|
|
close_rec = _make_record(db, direction="卖", offset="平", price=7900, volume=1,
|
|
|
close_pnl=1000, commission=10)
|
|
|
pair = create_manual_pair(db, [open_rec.id], [close_rec.id])
|
|
|
analysis = TradeAIAnalysis(
|
|
|
trade_pair_id=pair.id,
|
|
|
version=1,
|
|
|
overall_evaluation="评价",
|
|
|
experience_suggestion="重要经验",
|
|
|
suggestion_type="lesson",
|
|
|
)
|
|
|
db.add(analysis)
|
|
|
db.commit()
|
|
|
|
|
|
result = save_suggestion_as_experience(db, analysis.id, tags=["突破交易"])
|
|
|
|
|
|
assert result["success"] is True
|
|
|
exp = db.query(TradeExperience).first()
|
|
|
assert exp is not None
|
|
|
assert exp.exp_type == "lesson"
|
|
|
assert "重要经验" in exp.content
|
|
|
assert analysis.saved_to_experience is True
|
|
|
assert analysis.experience_id == exp.id
|