feat: add anomaly signal detection and backtesting system

- Add anomaly signal model and history tracking
- Implement backtest framework with detailed reports
- Update whale analyzer prompts for better analysis
- Improve LLM analyzer with enhanced features
- Remove deprecated report history service
- Add numerous whale activity reports

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
SII-leiyu
2026-01-10 12:52:26 +08:00
co-authored by Claude
parent ed625564de
commit cc5c0c3578
205 changed files with 24119 additions and 304 deletions
+1 -1
View File
@@ -108,7 +108,7 @@ class WhaleWatcher:
full_report = self.llm_analyzer.format_full_report(
whale_trade,
decision,
historical_report_count=self.llm_analyzer.last_historical_report_count,
historical_signal_count=self.llm_analyzer.last_historical_signal_count,
)
print(full_report)
+82
View File
@@ -0,0 +1,82 @@
"""Anomaly signal models for storing historical anomalous trades."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
from src.models.trade import TraderRanking, TraderHistory
class AnomalySignal(BaseModel):
"""
Represents a stored anomaly signal for a market.
This captures the raw trade and trader information for trades with medium
or higher insider trading likelihood. The insider_trading_likelihood is stored
for sorting/filtering purposes, but NOT shown to LLM - the model will
re-analyze all signals (historical + current) together without bias.
"""
# Unique identifier
id: str = Field(default_factory=lambda: "")
# Market identification
market_id: str
market_question: str
market_slug: Optional[str] = None
# Trade information
transaction_hash: str
trade_timestamp: int # Unix timestamp of the trade
trade_side: str # BUY or SELL
trade_price: float
trade_size_usd: float
trade_outcome: str
# Trader information
trader_wallet: Optional[str] = None
trader_ranking: Optional[TraderRanking] = None
trader_history: Optional[TraderHistory] = None
# Insider trading likelihood (for sorting/filtering only, NOT shown to LLM)
insider_trading_likelihood: float = Field(default=0.0, ge=0.0, le=1.0)
# Metadata
detected_at: datetime = Field(default_factory=datetime.utcnow)
def to_context_string(self) -> str:
"""
Format this anomaly signal as a context string for LLM.
Returns:
Formatted string describing this historical anomaly signal.
"""
trade_time = datetime.fromtimestamp(self.trade_timestamp).strftime('%Y-%m-%d %H:%M:%S')
# Trader ranking info
trader_rank_str = "未上榜"
trader_pnl_str = "N/A"
trader_vol_str = "N/A"
if self.trader_ranking:
if self.trader_ranking.rank:
trader_rank_str = f"#{self.trader_ranking.rank}"
if self.trader_ranking.pnl is not None:
trader_pnl_str = f"${self.trader_ranking.pnl:,.2f}"
if self.trader_ranking.volume is not None:
trader_vol_str = f"${self.trader_ranking.volume:,.2f}"
# Trader history info
trader_history_str = ""
if self.trader_history:
trader_history_str = f"""
- 近期交易数: {self.trader_history.total_trades}
- 交易总额: ${self.trader_history.total_volume:,.2f}
- 大额交易数: {self.trader_history.large_trades_count}"""
return f"""**交易时间**: {trade_time}
**交易方向**: {self.trade_side}
**交易金额**: ${self.trade_size_usd:,.2f} USDC
**交易价格**: {self.trade_price:.4f}
**交易结果**: {self.trade_outcome}
**交易者钱包**: {self.trader_wallet or 'Unknown'}
**交易者排名**: {trader_rank_str} (PnL: {trader_pnl_str}, 交易量: {trader_vol_str}){trader_history_str}"""
+31 -24
View File
@@ -21,11 +21,12 @@ class WhaleAnalyzerPrompts:
- 交易者的排行榜排名和历史盈亏
- **交易者历史交易记录**(近期交易总数、交易总额、大额交易次数、活跃市场等)
### 第二步:获取市场信息
### 第二步:获取市场信息和历史异常信号
你会同时收到该交易对应的市场信息:
- 市场问题(预测的事件)
- 市场描述
- 当前各结果的价格/概率
- **历史异常交易信号**(如有):该市场之前检测到的其他异常交易记录
### 第三步:使用 Google Search 验证(关键步骤!)
**你必须使用 Google 搜索来验证这笔交易是否基于真实信息:**
@@ -35,7 +36,7 @@ class WhaleAnalyzerPrompts:
- 寻找任何可能触发这笔交易的事件
### 第四步:综合判断并生成报告
结合所有信息,判断:
结合所有信息(当前交易 + 历史信号 + 搜索结果),判断:
- 这笔交易是"真正的内幕交易"还是"普通大额交易"
- 给出内幕交易可能性评分(0-100%
- 提供跟单建议(BUY/SELL/HOLD
@@ -61,15 +62,15 @@ class WhaleAnalyzerPrompts:
- 评估信息的时效性和可靠性
4. **综合判断标准**
- 高排名 + 频繁大额交易 + 有最新未反映信息 = 高度可疑内幕交易 (0.8+)
- 高排名 + 频繁大额交易 + 有最新未反映信息 + 历史信号方向一致 = 高度可疑内幕交易 (0.8+)
- 高排名 + 有历史记录 + 无明显信息 = 可能基于深度分析 (0.5-0.7)
- 低排名/未上榜 + 首次大额交易 + 无信息 = 普通投机交易 (<0.4)
- 低排名/未上榜 + 首次大额交易 + 无信息 + 无历史信号 = 普通投机交易 (<0.4)
- 未上榜但有大量历史交易记录 = 可能是隐藏的专业玩家,需要重点关注
**重要原则**
- **务必使用 Google Search!** 不要仅依赖你的历史知识
- **重视交易者历史记录!** 这是判断交易者专业性的关键依据
- **如果有历史报告,务必结合历史报告进行综合分析!** 这能帮助你了解该市场的交易模式
- **如果有历史异常交易信号,务必结合这些信号进行对比分析!** 这能帮助你了解该市场的交易模式和趋势
- 关注过去24-72小时的最新动态
- 如果搜索不到支持信息,内幕交易可能性应该降低
- 信心不足时建议观望(HOLD"""
@@ -152,41 +153,47 @@ class WhaleAnalyzerPrompts:
---
## 第三点五步:历史报告综合分析(如有历史报告
## 第步:历史异常信号分析(如有历史信号
如果上文提供了历史报告,请进行以下分析:
如果上文提供了历史异常交易信号,请将历史信号与当前信号一起进行分析:
### 3.5.1 交易方向对比
- 历史报告中的交易方向(BUY/SELL与当前交易是否一致?
- 如果方向一致,这可能表明多个交易者对同一结果有信心
- 如果方向相反,需要分析原因(时间变化、新信息、不同交易者的判断)
### 4.1 交易方向对比
- 历史信号与当前信号的交易方向(BUY/SELL)是否一致?
- 如果方向一致,说明该市场持续有资金流入同一方向,内幕交易可能性提高
- 如果方向相反,需要分析原因(时间变化、新信息出现、不同交易者的判断)
### 3.5.2 历史内幕交易评估
- 历史报告对内幕交易的判断如何?
- 如果历史报告也认为是内幕交易,这增强了当前交易的可信度
- 结合历史报告的证据和当前搜索结果进行综合判断
### 4.2 交易者对比分析
- 对比各信号的交易者排名和历史记录
- 是否有高排名交易者(前100名)参与?
- 是否有"聪明钱"流入某一方向?
- 同一个钱包是否多次出现?
### 3.5.3 趋势演变分析
- 该市场的交易模式是否有变化?
- 价格从历史报告到现在有何变动
- 鲸鱼交易的频率和规模是否在增加
### 4.3 趋势演变分析
- 交易金额是否在增加?(信心增强的信号)
- 交易价格的变化趋势如何
- 两笔或多笔交易之间的时间间隔有多长
### 4.4 综合评估
- 结合所有信号的交易者特征和交易模式
- 结合 Google 搜索结果验证
- 给出对该市场异常交易活动的统一判断
---
## 第步:内幕交易判定
## 第步:内幕交易判定
### 4.1 内幕交易可能性评估
### 5.1 内幕交易可能性评估
综合以上分析,判断这笔交易是:
- **真正的内幕交易**:交易者确实掌握了市场未反映的信息
- **深度分析交易**:交易者基于公开信息的深度分析
- **普通投机交易**:没有明显信息优势
### 4.2 关键证据
### 5.2 关键证据
列出支持你判断的关键证据(来自搜索结果)
---
## 第步:跟单风险提示
## 第步:跟单风险提示
- 鲸鱼也可能犯错或有其他动机(对冲、试探等)
- 市场可能已经部分反映了该信息
@@ -194,7 +201,7 @@ class WhaleAnalyzerPrompts:
---
## 第步:最终决策
## 第步:最终决策
基于以上分析,给出你的交易建议,并用以下JSON格式输出决策:
+315
View File
@@ -0,0 +1,315 @@
"""Anomaly history service - stores and retrieves historical anomaly signals by market."""
import json
import logging
import os
import re
from pathlib import Path
from typing import List, Optional
from src.models.anomaly_signal import AnomalySignal
logger = logging.getLogger(__name__)
class AnomalyHistoryService:
"""
Service for storing and retrieving historical anomaly signals.
Anomaly signals are stored in JSON files, organized by market.
Only trades with medium or higher insider trading likelihood (>= 0.4) are stored.
"""
# Minimum insider trading likelihood to store a signal
MIN_INSIDER_LIKELIHOOD = 0.4
def __init__(self, storage_dir: Optional[Path] = None):
"""
Initialize the anomaly history service.
Args:
storage_dir: Path to the storage directory. Defaults to project's anomaly_signals dir.
"""
if storage_dir is None:
self.storage_dir = Path(__file__).parent.parent.parent / "anomaly_signals"
else:
self.storage_dir = storage_dir
# Ensure storage directory exists
self.storage_dir.mkdir(parents=True, exist_ok=True)
def _sanitize_market_id(self, market_id: str) -> str:
"""
Sanitize market ID for use in filename.
Args:
market_id: The market ID
Returns:
Sanitized market ID safe for filenames
"""
# Keep only alphanumeric characters and hyphens
return re.sub(r'[^\w\-]', '_', market_id)
def _get_market_filepath(self, market_id: str) -> Path:
"""
Get the filepath for a market's anomaly signals.
Args:
market_id: The market ID
Returns:
Path to the market's anomaly signals file
"""
sanitized_id = self._sanitize_market_id(market_id)
return self.storage_dir / f"{sanitized_id}.json"
def should_store_signal(self, insider_likelihood: float) -> bool:
"""
Check if a signal should be stored based on insider trading likelihood.
Args:
insider_likelihood: The insider trading likelihood score (0-1)
Returns:
True if the signal should be stored, False otherwise
"""
return insider_likelihood >= self.MIN_INSIDER_LIKELIHOOD
def store_signal(self, signal: AnomalySignal) -> bool:
"""
Store an anomaly signal for a market.
Args:
signal: The anomaly signal to store
Returns:
True if stored successfully, False otherwise
"""
if not self.should_store_signal(signal.insider_trading_likelihood):
logger.debug(
f"Signal not stored: insider likelihood {signal.insider_trading_likelihood:.2f} "
f"below threshold {self.MIN_INSIDER_LIKELIHOOD}"
)
return False
filepath = self._get_market_filepath(signal.market_id)
# Load existing signals
existing_signals = self._load_signals_from_file(filepath)
# Check for duplicate (same transaction hash)
for existing in existing_signals:
if existing.transaction_hash == signal.transaction_hash:
logger.debug(f"Signal already exists for transaction: {signal.transaction_hash}")
return False
# Add new signal
existing_signals.append(signal)
# Save back to file
try:
self._save_signals_to_file(filepath, existing_signals)
logger.info(
f"Stored anomaly signal for market {signal.market_id}: "
f"${signal.trade_size_usd:,.2f} {signal.trade_side} "
f"(insider likelihood: {signal.insider_trading_likelihood:.0%})"
)
return True
except Exception as e:
logger.error(f"Failed to store anomaly signal: {e}")
return False
def get_signals_for_market(
self,
market_id: str,
top_recent: int = 5,
top_likelihood: int = 5,
) -> List[AnomalySignal]:
"""
Get historical anomaly signals for a market.
Selects the most recent signals and highest insider likelihood signals,
then deduplicates and returns the combined list.
Args:
market_id: The market ID
top_recent: Number of most recent signals to include
top_likelihood: Number of highest insider likelihood signals to include
Returns:
List of AnomalySignal objects (deduplicated, sorted by trade timestamp newest first)
"""
filepath = self._get_market_filepath(market_id)
signals = self._load_signals_from_file(filepath)
if not signals:
return []
# Get top N most recent signals (by trade timestamp)
signals_by_time = sorted(signals, key=lambda s: s.trade_timestamp, reverse=True)
recent_signals = signals_by_time[:top_recent]
# Get top N highest insider trading likelihood signals
signals_by_likelihood = sorted(signals, key=lambda s: s.insider_trading_likelihood, reverse=True)
high_likelihood_signals = signals_by_likelihood[:top_likelihood]
# Deduplicate by transaction_hash
seen_hashes = set()
combined_signals = []
for signal in recent_signals + high_likelihood_signals:
if signal.transaction_hash not in seen_hashes:
seen_hashes.add(signal.transaction_hash)
combined_signals.append(signal)
# Sort final result by trade timestamp (newest first)
combined_signals.sort(key=lambda s: s.trade_timestamp, reverse=True)
return combined_signals
def format_historical_signals_context(
self,
signals: List[AnomalySignal],
) -> str:
"""
Format historical anomaly signals into a context string for LLM.
Args:
signals: List of historical anomaly signals
Returns:
Formatted string for LLM context
"""
if not signals:
return ""
signal_count = len(signals)
context = f"""
### 历史异常交易信号 (共 {signal_count} 笔)
**重要**: 该市场之前已经检测到 {signal_count} 笔异常交易。请将这些历史信号与当前最新信号一起进行综合分析,统一评估内幕交易可能性。
"""
for i, signal in enumerate(signals, 1):
context += f"""
---
#### 历史信号 {i}
{signal.to_context_string()}
---
"""
context += """
**综合分析要点**:
1. 对比所有信号(历史+当前)的交易方向,分析是否有一致趋势
2. 对比不同交易者的排名和历史记录,判断"聪明钱"的流向
3. 如果多个高排名交易者都指向同一方向,内幕交易可能性显著提高
4. 如果信号方向相反,需要分析原因(时间变化、新信息、不同判断)
5. 考虑时间因素:越近期的信号越有参考价值
6. 观察交易金额的变化趋势:金额是否在增加?
"""
return context
def _load_signals_from_file(self, filepath: Path) -> List[AnomalySignal]:
"""
Load anomaly signals from a JSON file.
Args:
filepath: Path to the JSON file
Returns:
List of AnomalySignal objects
"""
if not filepath.exists():
return []
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
signals = []
for item in data:
try:
signal = AnomalySignal.model_validate(item)
signals.append(signal)
except Exception as e:
logger.warning(f"Failed to parse anomaly signal: {e}")
continue
return signals
except json.JSONDecodeError as e:
logger.error(f"Failed to parse JSON file {filepath}: {e}")
return []
except Exception as e:
logger.error(f"Failed to load signals from {filepath}: {e}")
return []
def _save_signals_to_file(self, filepath: Path, signals: List[AnomalySignal]) -> None:
"""
Save anomaly signals to a JSON file.
Args:
filepath: Path to the JSON file
signals: List of AnomalySignal objects to save
"""
data = [signal.model_dump(mode='json') for signal in signals]
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2, default=str)
def get_all_market_ids(self) -> List[str]:
"""
Get all market IDs that have stored anomaly signals.
Returns:
List of market IDs
"""
market_ids = []
for filepath in self.storage_dir.glob("*.json"):
market_id = filepath.stem
market_ids.append(market_id)
return market_ids
def get_signal_count(self, market_id: str) -> int:
"""
Get the number of stored signals for a market.
Args:
market_id: The market ID
Returns:
Number of stored signals
"""
filepath = self._get_market_filepath(market_id)
signals = self._load_signals_from_file(filepath)
return len(signals)
def cleanup_old_signals(self, max_age_days: int = 30) -> int:
"""
Remove signals older than the specified number of days.
Args:
max_age_days: Maximum age of signals to keep
Returns:
Number of signals removed
"""
from datetime import datetime, timedelta, timezone
cutoff_time = datetime.now(timezone.utc) - timedelta(days=max_age_days)
total_removed = 0
for filepath in self.storage_dir.glob("*.json"):
signals = self._load_signals_from_file(filepath)
original_count = len(signals)
# Filter out old signals
signals = [s for s in signals if s.detected_at >= cutoff_time]
removed_count = original_count - len(signals)
if removed_count > 0:
self._save_signals_to_file(filepath, signals)
total_removed += removed_count
logger.info(f"Removed {removed_count} old signals from {filepath.stem}")
return total_removed
+80 -22
View File
@@ -12,8 +12,9 @@ from google.genai import types
from src.config import get_settings
from src.models.trade import WhaleTrade
from src.models.decision import LLMDecision, TradeRecommendation, TradeAction, TraderCredibility
from src.models.anomaly_signal import AnomalySignal
from src.services.anomaly_detector import AnomalyDetector
from src.services.report_history import ReportHistoryService
from src.services.anomaly_history import AnomalyHistoryService
from src.prompts.whale_analyzer import WhaleAnalyzerPrompts
logger = logging.getLogger(__name__)
@@ -36,15 +37,15 @@ class LLMAnalyzer:
self.anomaly_detector = AnomalyDetector()
self.prompts = WhaleAnalyzerPrompts()
self.report_history = ReportHistoryService()
self.anomaly_history = AnomalyHistoryService()
# Track the number of historical reports used in the last analysis
self._last_historical_report_count = 0
# Track the number of historical signals used in the last analysis
self._last_historical_signal_count = 0
@property
def last_historical_report_count(self) -> int:
"""Get the number of historical reports used in the last analysis."""
return self._last_historical_report_count
def last_historical_signal_count(self) -> int:
"""Get the number of historical anomaly signals used in the last analysis."""
return self._last_historical_signal_count
def _extract_json_from_response(self, response: str) -> Optional[dict]:
"""
@@ -129,6 +130,57 @@ class LLMAnalyzer:
insider_evidence=insider_evidence,
)
def _store_anomaly_signal_if_qualified(
self,
whale_trade: WhaleTrade,
decision: LLMDecision,
) -> None:
"""
Store an anomaly signal if the insider trading likelihood meets threshold.
Only signals with insider_trading_likelihood >= 0.4 are stored.
Args:
whale_trade: The whale trade
decision: The LLM decision
"""
rec = decision.recommendation
if not self.anomaly_history.should_store_signal(rec.insider_trading_likelihood):
logger.debug(
f"Signal not stored: insider likelihood {rec.insider_trading_likelihood:.2f} "
f"below threshold"
)
return
# Create anomaly signal from whale trade
# Store insider_trading_likelihood for sorting, but it won't be shown to LLM
signal = AnomalySignal(
id=whale_trade.id,
market_id=whale_trade.market_id,
market_question=whale_trade.market_question,
market_slug=whale_trade.trade.slug,
transaction_hash=whale_trade.trade.transaction_hash,
trade_timestamp=whale_trade.trade.timestamp,
trade_side=whale_trade.trade.side,
trade_price=whale_trade.trade.price,
trade_size_usd=whale_trade.trade.usdc_size,
trade_outcome=whale_trade.trade.outcome,
trader_wallet=whale_trade.trade.proxy_wallet,
trader_ranking=whale_trade.trader_ranking,
trader_history=whale_trade.trader_history,
insider_trading_likelihood=rec.insider_trading_likelihood,
detected_at=whale_trade.detected_at,
)
# Store the signal
stored = self.anomaly_history.store_signal(signal)
if stored:
logger.info(
f"Stored anomaly signal: {whale_trade.market_question[:50]}... "
f"insider_likelihood={rec.insider_trading_likelihood:.0%}"
)
async def analyze_whale_trade(self, whale_trade: WhaleTrade) -> LLMDecision:
"""
Analyze a whale trade using LLM.
@@ -142,17 +194,18 @@ class LLMAnalyzer:
# Format trade context for LLM
trade_context = self.anomaly_detector.format_for_llm(whale_trade)
# Find and format historical reports for the same market
# Find and format historical anomaly signals for the same market
# Get top 5 most recent + top 5 highest insider likelihood, deduplicated
historical_context = ""
historical_reports = self.report_history.find_historical_reports(
whale_trade.market_question,
similarity_threshold=0.5,
max_reports=5,
historical_signals = self.anomaly_history.get_signals_for_market(
whale_trade.market_id,
top_recent=5,
top_likelihood=5,
)
self._last_historical_report_count = len(historical_reports)
if historical_reports:
historical_context = self.report_history.format_historical_context(historical_reports)
logger.info(f"Found {len(historical_reports)} historical reports for market: {whale_trade.market_question}")
self._last_historical_signal_count = len(historical_signals)
if historical_signals:
historical_context = self.anomaly_history.format_historical_signals_context(historical_signals)
logger.info(f"Found {len(historical_signals)} historical anomaly signals for market: {whale_trade.market_question}")
# Build prompt (Gemini uses single prompt with system instruction)
system_prompt = self.prompts.system_prompt()
@@ -187,13 +240,18 @@ class LLMAnalyzer:
reasoning="Failed to parse LLM response",
)
return LLMDecision(
decision = LLMDecision(
whale_trade_id=whale_trade.id,
market_id=whale_trade.market_id,
analysis=analysis_text,
recommendation=recommendation,
)
# Store anomaly signal if insider trading likelihood >= 0.4
self._store_anomaly_signal_if_qualified(whale_trade, decision)
return decision
except Exception as e:
logger.error(f"Error calling LLM: {e}")
# Return a safe default decision
@@ -213,7 +271,7 @@ class LLMAnalyzer:
self,
whale_trade: WhaleTrade,
decision: LLMDecision,
historical_report_count: int = 0,
historical_signal_count: int = 0,
) -> str:
"""
Format a complete analysis report with trade info, analysis, and decision.
@@ -221,7 +279,7 @@ class LLMAnalyzer:
Args:
whale_trade: The whale trade
decision: The LLM decision
historical_report_count: Number of historical reports used in analysis
historical_signal_count: Number of historical anomaly signals used in analysis
Returns:
Formatted report string
@@ -270,10 +328,10 @@ class LLMAnalyzer:
pnl_str = f"${tr.pnl:,.2f}" if tr.pnl else "N/A"
trader_ranking_str = f"| **交易者排名** | {rank_str} (PnL: {pnl_str}) |"
# Historical reports info
# Historical signals info
historical_info = ""
if historical_report_count > 0:
historical_info = f"\n**参考历史报告**: {historical_report_count} (已综合分析)"
if historical_signal_count > 0:
historical_info = f"\n**参考历史异常信号**: {historical_signal_count} (已综合分析)"
report = f"""
{'='*70}
-257
View File
@@ -1,257 +0,0 @@
"""Report history service - finds and summarizes historical reports for the same market."""
import os
import re
from pathlib import Path
from typing import List, Optional
from dataclasses import dataclass
from datetime import datetime
@dataclass
class HistoricalReport:
"""Represents a historical report for the same market."""
filepath: str
filename: str
timestamp: datetime
side: str
amount_usd: float
market_name: str
content: str
@property
def summary(self) -> str:
"""Extract key decision info from the report."""
# Try to extract the JSON decision section
json_pattern = r'```json\s*([\s\S]*?)```'
matches = re.findall(json_pattern, self.content)
decision_info = ""
for match in matches:
try:
import json
data = json.loads(match.strip())
action = data.get('action', 'N/A')
confidence = data.get('confidence', 'N/A')
insider_likelihood = data.get('insider_trading_likelihood', 'N/A')
reasoning = data.get('reasoning', 'N/A')
if isinstance(confidence, (int, float)):
confidence = f"{confidence:.0%}"
if isinstance(insider_likelihood, (int, float)):
insider_likelihood = f"{insider_likelihood:.0%}"
decision_info = f"""
- **操作建议**: {action}
- **信心程度**: {confidence}
- **内幕交易可能性**: {insider_likelihood}
- **决策理由**: {reasoning}"""
break
except (json.JSONDecodeError, KeyError):
continue
return f"""**报告时间**: {self.timestamp.strftime('%Y-%m-%d %H:%M:%S')}
**交易方向**: {self.side}
**交易金额**: ${self.amount_usd:,.2f} USDC
{decision_info}"""
class ReportHistoryService:
"""Service for finding and managing historical reports."""
def __init__(self, reports_dir: Optional[Path] = None):
"""
Initialize the report history service.
Args:
reports_dir: Path to the reports directory. Defaults to project's reports dir.
"""
if reports_dir is None:
self.reports_dir = Path(__file__).parent.parent.parent / "reports"
else:
self.reports_dir = reports_dir
def _parse_filename(self, filename: str) -> Optional[dict]:
"""
Parse a report filename to extract metadata.
Filename format: {timestamp}_{side}_{amount}USD_{market_name}.md
Example: 20260105_150505_BUY_7520USD_Trump_out_as_President_before_2027.md
Args:
filename: The filename to parse
Returns:
Dictionary with parsed metadata or None if parsing fails
"""
if not filename.endswith('.md'):
return None
# Pattern: timestamp_side_amountUSD_market_name.md
pattern = r'^(\d{8}_\d{6})_(BUY|SELL)_(\d+)USD_(.+)\.md$'
match = re.match(pattern, filename)
if not match:
return None
timestamp_str, side, amount_str, market_name = match.groups()
try:
timestamp = datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S')
amount = float(amount_str)
except ValueError:
return None
return {
'timestamp': timestamp,
'side': side,
'amount_usd': amount,
'market_name': market_name,
}
def _sanitize_market_name(self, market_question: str, max_length: int = 50) -> str:
"""
Sanitize market question for matching with filenames.
Args:
market_question: The market question to sanitize
max_length: Maximum length of the sanitized name
Returns:
Sanitized market name
"""
# Remove special characters, keep alphanumeric and spaces
sanitized = re.sub(r'[^\w\s-]', '', market_question)
# Replace spaces with underscores
sanitized = re.sub(r'\s+', '_', sanitized)
# Truncate if too long
return sanitized[:max_length]
def _calculate_similarity(self, name1: str, name2: str) -> float:
"""
Calculate similarity between two market names.
Uses a simple word overlap method for fuzzy matching.
Args:
name1: First market name (sanitized)
name2: Second market name (from filename)
Returns:
Similarity score between 0 and 1
"""
# Convert to lowercase and split into words
words1 = set(name1.lower().replace('_', ' ').split())
words2 = set(name2.lower().replace('_', ' ').split())
# Remove common stop words
stop_words = {'the', 'a', 'an', 'is', 'are', 'will', 'by', 'to', 'of', 'in', 'on', 'for'}
words1 = words1 - stop_words
words2 = words2 - stop_words
if not words1 or not words2:
return 0.0
# Calculate Jaccard similarity
intersection = len(words1 & words2)
union = len(words1 | words2)
return intersection / union if union > 0 else 0.0
def find_historical_reports(
self,
market_question: str,
similarity_threshold: float = 0.5,
max_reports: int = 5,
) -> List[HistoricalReport]:
"""
Find historical reports for the same or similar market.
Args:
market_question: The market question to search for
similarity_threshold: Minimum similarity score to include a report
max_reports: Maximum number of reports to return
Returns:
List of HistoricalReport objects, sorted by timestamp (newest first)
"""
if not self.reports_dir.exists():
return []
sanitized_question = self._sanitize_market_name(market_question)
matching_reports = []
for filename in os.listdir(self.reports_dir):
metadata = self._parse_filename(filename)
if metadata is None:
continue
# Calculate similarity between market names
similarity = self._calculate_similarity(
sanitized_question,
metadata['market_name']
)
if similarity >= similarity_threshold:
filepath = self.reports_dir / filename
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
except Exception:
continue
report = HistoricalReport(
filepath=str(filepath),
filename=filename,
timestamp=metadata['timestamp'],
side=metadata['side'],
amount_usd=metadata['amount_usd'],
market_name=metadata['market_name'],
content=content,
)
matching_reports.append((similarity, report))
# Sort by similarity (descending) then by timestamp (descending)
matching_reports.sort(key=lambda x: (x[0], x[1].timestamp), reverse=True)
# Return only the reports (without similarity scores)
return [report for _, report in matching_reports[:max_reports]]
def format_historical_context(
self,
reports: List[HistoricalReport],
) -> str:
"""
Format historical reports into a context string for LLM.
Args:
reports: List of historical reports
Returns:
Formatted string for LLM context
"""
if not reports:
return ""
context = f"""
### 历史报告分析 (共 {len(reports)} 份历史报告)
**重要**: 该市场之前已经生成过分析报告,请结合历史报告进行综合分析。
"""
for i, report in enumerate(reports, 1):
context += f"""
---
#### 历史报告 {i}
{report.summary}
---
"""
context += """
**综合分析要点**:
1. 对比历史报告中的交易方向和当前交易方向,分析是否有趋势变化
2. 对比历史的内幕交易可能性评估,判断该市场是否持续有异常交易
3. 如果多份报告都指向同一方向,这可能加强信号的可信度
4. 如果报告方向相反,需要分析原因并给出更审慎的判断
5. 考虑时间因素:越近期的报告越有参考价值
"""
return context