Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
This commit is contained in:
TIANHE
2025-12-30 21:02:38 +08:00
parent 58a1133c83
commit 50939212be
23 changed files with 910 additions and 99 deletions
+14 -1
View File
@@ -80,10 +80,23 @@ Default address: `http://localhost:5000`
## Database (SQLite)
- Default file: `backend_api_python/quantdinger.db` (override via `SQLITE_DATABASE_FILE`)
- Default file: `backend_api_python/data/quantdinger.db` (override via `SQLITE_DATABASE_FILE`)
- Tables are created/updated automatically on startup (see `app/utils/db.py`)
- `qd_addon_config` exists for backward compatibility, but **this backend reads secrets from `.env` / OS env**, not from the database (see `app/utils/config_loader.py`)
## AI memory augmentation (local-only)
This backend includes a lightweight, privacy-first **memory-augmented multi-agent** system:
- Memory DBs (per role): `backend_api_python/data/memory/*_memory.db`
- Reflection DB (optional auto-verify loop): `backend_api_python/data/memory/reflection_records.db`
- API hooks:
- `POST /api/analysis/multi` (main entry)
- `POST /api/analysis/reflect` (manual learn from post-trade outcomes)
- Controls: see `.env` / `env.example`:
- `ENABLE_AGENT_MEMORY`, `AGENT_MEMORY_*`
- `ENABLE_REFLECTION_WORKER`, `REFLECTION_WORKER_INTERVAL_SEC`
## Frontend integration (Vue dev server)
The Vue dev server proxies `/api/*` to this backend by default:
+35
View File
@@ -13,6 +13,7 @@ logger = get_logger(__name__)
# Global singletons (avoid duplicate strategy threads).
_trading_executor = None
_pending_order_worker = None
_reflection_worker = None
def get_trading_executor():
@@ -33,6 +34,39 @@ def get_pending_order_worker():
return _pending_order_worker
def get_reflection_worker():
"""Get the reflection verification worker singleton."""
global _reflection_worker
if _reflection_worker is None:
from app.services.agents.reflection_worker import ReflectionWorker
_reflection_worker = ReflectionWorker()
return _reflection_worker
def start_reflection_worker():
"""
Start the reflection worker if enabled.
To enable it, set ENABLE_REFLECTION_WORKER=true.
"""
import os
enabled = os.getenv("ENABLE_REFLECTION_WORKER", "false").lower() == "true"
if not enabled:
logger.info("Reflection worker is disabled. Set ENABLE_REFLECTION_WORKER=true to enable.")
return
# Avoid running twice with Flask reloader
debug = os.getenv("PYTHON_API_DEBUG", "false").lower() == "true"
if debug:
if os.environ.get("WERKZEUG_RUN_MAIN") != "true":
return
try:
get_reflection_worker().start()
except Exception as e:
logger.error(f"Failed to start reflection worker: {e}")
def start_pending_order_worker():
"""Start the pending order worker (disabled by default in paper mode).
@@ -128,6 +162,7 @@ def create_app(config_name='default'):
# Startup hooks.
with app.app_context():
start_pending_order_worker()
start_reflection_worker()
restore_running_strategies()
return app
+2 -2
View File
@@ -51,9 +51,9 @@ class MetaSQLiteConfig(type):
@property
def DATABASE_FILE(cls):
# 默认放在 backend_api_python 根目录
# 默认放在 backend_api_python/data 目录(更干净,也与 docker-compose 挂载一致)
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
default_path = os.path.join(base_dir, 'quantdinger.db')
default_path = os.path.join(base_dir, 'data', 'quantdinger.db')
return os.getenv('SQLITE_DATABASE_FILE', default_path)
+4 -2
View File
@@ -83,6 +83,7 @@ def multi_analysis():
language = detect_request_language(request, body=data, default='en-US')
model = data.get('model', None)
use_multi_agent = data.get('use_multi_agent', None) # None -> use backend default
timeframe = data.get('timeframe', '1D')
if not symbol or not market:
return jsonify({
@@ -101,7 +102,7 @@ def multi_analysis():
# Create analysis service instance (local-only; no paid credits)
service = AnalysisService(use_multi_agent=use_multi_agent)
result = service.analyze(market, symbol, language, model=model)
result = service.analyze(market, symbol, language, model=model, timeframe=timeframe)
# Persist as "completed" history (no paid credits in local mode).
task_id = _store_task(market, symbol, model or '', language, 'completed', result=result, error_message='')
@@ -247,13 +248,14 @@ def stream_analysis():
symbol = data.get('symbol', '')
language = detect_request_language(request, body=data, default='en-US')
use_multi_agent = data.get('use_multi_agent', None)
timeframe = data.get('timeframe', '1D')
def generate():
try:
yield f"data: {json.dumps({'status': 'started', 'message': 'Analysis started'})}\n\n"
service = AnalysisService(use_multi_agent=use_multi_agent)
result = service.analyze(market, symbol, language)
result = service.analyze(market, symbol, language, timeframe=timeframe)
yield f"data: {json.dumps({'status': 'completed', 'data': result})}\n\n"
except Exception as e:
+21
View File
@@ -95,6 +95,27 @@ CONFIG_SCHEMA = {
{'key': 'ENABLE_AI_ANALYSIS', 'label': '启用AI分析', 'type': 'boolean', 'default': 'True'},
]
},
'agent_memory': {
'title': '记忆/反思配置',
'items': [
{'key': 'ENABLE_AGENT_MEMORY', 'label': '启用Agent记忆', 'type': 'boolean', 'default': 'True'},
{'key': 'AGENT_MEMORY_ENABLE_VECTOR', 'label': '启用向量检索(本地)', 'type': 'boolean', 'default': 'True'},
{'key': 'AGENT_MEMORY_EMBEDDING_DIM', 'label': 'Embedding维度', 'type': 'number', 'default': '256'},
{'key': 'AGENT_MEMORY_TOP_K', 'label': '召回数量TopK', 'type': 'number', 'default': '5'},
{'key': 'AGENT_MEMORY_CANDIDATE_LIMIT', 'label': '候选窗口大小', 'type': 'number', 'default': '500'},
{'key': 'AGENT_MEMORY_HALF_LIFE_DAYS', 'label': '时间衰减半衰期(天)', 'type': 'number', 'default': '30'},
{'key': 'AGENT_MEMORY_W_SIM', 'label': '相似度权重', 'type': 'number', 'default': '0.75'},
{'key': 'AGENT_MEMORY_W_RECENCY', 'label': '时间权重', 'type': 'number', 'default': '0.20'},
{'key': 'AGENT_MEMORY_W_RETURNS', 'label': '收益权重', 'type': 'number', 'default': '0.05'},
]
},
'reflection_worker': {
'title': '自动反思验证Worker',
'items': [
{'key': 'ENABLE_REFLECTION_WORKER', 'label': '启用自动验证', 'type': 'boolean', 'default': 'False'},
{'key': 'REFLECTION_WORKER_INTERVAL_SEC', 'label': '验证周期间隔(秒)', 'type': 'number', 'default': '86400'},
]
},
'ai': {
'title': 'AI/LLM配置',
'items': [
@@ -125,7 +125,13 @@ class FundamentalAnalyst(BaseAgent):
# Memory
situation = f"{market}:{symbol} fundamental analysis"
memories = self.get_memories(situation, n_matches=2)
memory_meta = {
"market": market,
"symbol": symbol,
"timeframe": context.get("timeframe"),
"features": context.get("memory_features") or {},
}
memories = self.get_memories(situation, n_matches=None, metadata=memory_meta)
memory_prompt = self.format_memories_for_prompt(memories)
lang_instruction = self._get_language_instruction(language)
@@ -3,6 +3,7 @@
"""
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional, List
import os
from app.utils.logger import get_logger
logger = get_logger(__name__)
@@ -36,7 +37,7 @@ class BaseAgent(ABC):
"""
pass
def get_memories(self, situation: str, n_matches: int = 2) -> List[Dict[str, Any]]:
def get_memories(self, situation: str, n_matches: Optional[int] = None, metadata: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
"""
从记忆中检索相似情况
@@ -47,8 +48,17 @@ class BaseAgent(ABC):
Returns:
匹配的历史记录列表
"""
if n_matches is None:
try:
n_matches = int(os.getenv("AGENT_MEMORY_TOP_K", "5") or 5)
except Exception:
n_matches = 5
if self.memory:
return self.memory.get_memories(situation, n_matches=n_matches)
# New memory API supports metadata; older implementations will ignore extra args.
try:
return self.memory.get_memories(situation, n_matches=n_matches, metadata=metadata)
except TypeError:
return self.memory.get_memories(situation, n_matches=n_matches)
return []
def format_memories_for_prompt(self, memories: List[Dict[str, Any]]) -> str:
@@ -62,10 +72,25 @@ class BaseAgent(ABC):
格式化的字符串
"""
if not memories:
return "无历史经验可参考。"
formatted = "历史经验参考:\n"
return "No prior experience available."
lines = ["Prior experience (most relevant first):"]
for i, mem in enumerate(memories, 1):
formatted += f"{i}. {mem.get('recommendation', 'N/A')}\n"
return formatted
rec = mem.get("recommendation") or "N/A"
res = mem.get("result") or ""
ret = mem.get("returns")
created_at = mem.get("created_at")
# Keep created_at as-is (SQLite string), but include it for traceability.
meta_bits = []
if created_at:
meta_bits.append(f"at {created_at}")
if ret is not None and ret != "":
meta_bits.append(f"returns={ret}%")
meta_s = f" ({', '.join(meta_bits)})" if meta_bits else ""
if res:
lines.append(f"{i}. {rec}{meta_s}\n outcome: {res}")
else:
lines.append(f"{i}. {rec}{meta_s}")
return "\n".join(lines)
@@ -97,7 +97,7 @@ class AgentCoordinator:
self.neutral_analyst = NeutralAnalyst()
self.safe_analyst = SafeAnalyst()
def run_analysis(self, market: str, symbol: str, language: str = 'zh-CN', model: str = None) -> Dict[str, Any]:
def run_analysis(self, market: str, symbol: str, language: str = 'zh-CN', model: str = None, timeframe: str = "1D") -> Dict[str, Any]:
"""
Run the full multi-agent analysis workflow.
"""
@@ -111,9 +111,13 @@ class AgentCoordinator:
current_price = tools.get_current_price(market, symbol)
company_data = tools.get_company_data(market, symbol, language=language)
# Normalize timeframe
tf = (timeframe or "1D").strip()
# 2) Kline + fundamentals
kline_data = tools.get_stock_data(market, symbol, days=30)
kline_data = tools.get_stock_data(market, symbol, days=30, timeframe=tf)
fundamental_data = tools.get_fundamental_data(market, symbol)
indicators = tools.calculate_technical_indicators(kline_data or [])
# 3) News (Finnhub + web search)
company_name = company_data.get('name', symbol) if company_data else symbol
@@ -127,6 +131,7 @@ class AgentCoordinator:
"fundamental_data": fundamental_data,
"company_data": company_data,
"news_data": news_data,
"indicators": indicators,
}
context = {
@@ -134,6 +139,14 @@ class AgentCoordinator:
"symbol": symbol,
"language": language,
"model": model,
"timeframe": tf,
# Compact structured features for memory retrieval.
"memory_features": {
"timeframe": tf,
"price": (current_price or {}).get("price"),
"changePercent": (current_price or {}).get("changePercent"),
"indicators": indicators,
},
"base_data": base_data
}
@@ -492,7 +505,18 @@ class AgentCoordinator:
# Update trader memory
if 'trader' in self.memories:
self.memories['trader'].add_memory(
situation, recommendation, result, returns
situation,
recommendation,
result,
returns,
metadata={
"market": market,
"symbol": symbol,
"timeframe": None,
"features": {
"source": "manual_reflect",
}
},
)
logger.info(f"Reflection completed: {market}:{symbol}, decision={decision}, returns={returns}")
@@ -0,0 +1,91 @@
"""
Lightweight embedding utilities for local-only deployments.
We intentionally avoid heavyweight ML deps (torch/sentence-transformers) and external services.
This module provides a deterministic "hashed embedding" (similar to feature hashing):
- Tokenize text
- Hash tokens into a fixed-size dense vector
- L2 normalize
It is not as semantically strong as modern transformer embeddings, but it enables:
- Vector storage in SQLite
- Cosine similarity retrieval
- Recency/return weighted ranking
"""
from __future__ import annotations
import math
import os
import re
import struct
import hashlib
from typing import List, Optional
_TOKEN_RE = re.compile(r"[A-Za-z0-9_]+", re.UNICODE)
def _tokenize(text: str) -> List[str]:
t = (text or "").lower()
return _TOKEN_RE.findall(t)
class EmbeddingService:
"""Deterministic local embedding service."""
def __init__(self, dim: Optional[int] = None):
self.dim = int(dim or os.getenv("AGENT_MEMORY_EMBEDDING_DIM", "256") or 256)
if self.dim <= 0:
self.dim = 256
def embed(self, text: str) -> List[float]:
"""
Return a dense, L2-normalized embedding vector.
"""
vec = [0.0] * self.dim
tokens = _tokenize(text)
if not tokens:
return vec
# Feature hashing with signed counts
for tok in tokens:
h = hashlib.blake2b(tok.encode("utf-8"), digest_size=8).digest()
# Use first 8 bytes as unsigned int
v = int.from_bytes(h, "little", signed=False)
idx = v % self.dim
sign = -1.0 if ((v >> 63) & 1) else 1.0
vec[idx] += sign
# L2 normalize
norm = math.sqrt(sum(x * x for x in vec)) or 1.0
return [x / norm for x in vec]
def to_bytes(self, vec: List[float]) -> bytes:
"""
Pack float vector into little-endian float32 bytes for SQLite BLOB storage.
"""
if not vec:
return b""
return struct.pack("<" + "f" * len(vec), *[float(x) for x in vec])
def from_bytes(self, blob: bytes) -> List[float]:
if not blob:
return []
n = len(blob) // 4
if n <= 0:
return []
return list(struct.unpack("<" + "f" * n, blob[: n * 4]))
def cosine_sim(a: List[float], b: List[float]) -> float:
"""
Cosine similarity for L2-normalized vectors.
If vectors are not normalized, this becomes a scaled dot product.
"""
if not a or not b:
return 0.0
n = min(len(a), len(b))
return float(sum(a[i] * b[i] for i in range(n)))
+180 -35
View File
@@ -1,15 +1,29 @@
"""
智能体记忆系统
使用 SQLite + 简单的文本相似度匹配
Agent memory system (local-only).
This module stores agent experiences in SQLite and retrieves relevant past cases
to inject into prompts (RAG-style). It does NOT finetune model weights.
Retrieval (configurable):
- Vector similarity via deterministic local embeddings (default)
- Fallback to difflib text similarity when embeddings are missing
Ranking combines:
- similarity
- recency decay (half-life)
- optional returns weight
"""
import sqlite3
import json
import os
import math
from typing import List, Dict, Any, Optional
from datetime import datetime
from datetime import datetime, timezone
import difflib
from app.utils.logger import get_logger
from .embedding import EmbeddingService, cosine_sim
logger = get_logger(__name__)
@@ -34,6 +48,13 @@ class AgentMemory:
db_path = os.path.join(db_dir, f'{agent_name}_memory.db')
self.db_path = db_path
self.embedder = EmbeddingService()
self.enable_vector = os.getenv("AGENT_MEMORY_ENABLE_VECTOR", "true").lower() == "true"
self.candidate_limit = int(os.getenv("AGENT_MEMORY_CANDIDATE_LIMIT", "500") or 500)
self.half_life_days = float(os.getenv("AGENT_MEMORY_HALF_LIFE_DAYS", "30") or 30)
self.w_sim = float(os.getenv("AGENT_MEMORY_W_SIM", "0.75") or 0.75)
self.w_recency = float(os.getenv("AGENT_MEMORY_W_RECENCY", "0.20") or 0.20)
self.w_returns = float(os.getenv("AGENT_MEMORY_W_RETURNS", "0.05") or 0.05)
self._init_database()
def _init_database(self):
@@ -49,22 +70,91 @@ class AgentMemory:
recommendation TEXT NOT NULL,
result TEXT,
returns REAL,
market TEXT,
symbol TEXT,
timeframe TEXT,
features_json TEXT,
embedding BLOB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# 创建索引
# Best-effort migration for older DBs
# NOTE: For existing tables, we must add missing columns BEFORE creating indexes
# that reference them (otherwise we'll hit: "no such column: market").
cursor.execute("PRAGMA table_info(memories)")
existing_cols = {row[1] for row in cursor.fetchall() or []}
for col, ddl in {
"market": "TEXT",
"symbol": "TEXT",
"timeframe": "TEXT",
"features_json": "TEXT",
"embedding": "BLOB",
}.items():
if col not in existing_cols:
cursor.execute(f"ALTER TABLE memories ADD COLUMN {col} {ddl}")
# 创建索引(放在迁移之后,兼容旧库)
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_created_at ON memories(created_at)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_market_symbol ON memories(market, symbol)
''')
conn.commit()
conn.close()
except Exception as e:
logger.error(f"初始化记忆数据库失败: {e}")
def add_memory(self, situation: str, recommendation: str, result: Optional[str] = None, returns: Optional[float] = None):
def _now_utc(self) -> datetime:
return datetime.now(timezone.utc)
def _parse_ts(self, ts_val: Any) -> Optional[datetime]:
if ts_val is None:
return None
if isinstance(ts_val, datetime):
return ts_val
s = str(ts_val)
try:
return datetime.fromisoformat(s.replace("Z", ""))
except Exception:
return None
def _recency_score(self, created_at: Any) -> float:
dt = self._parse_ts(created_at)
if not dt:
return 0.0
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
age_days = max(0.0, (self._now_utc() - dt).total_seconds() / 86400.0)
hl = max(0.1, float(self.half_life_days or 30.0))
return float(math.exp(-math.log(2.0) * (age_days / hl)))
def _returns_score(self, returns: Any) -> float:
try:
r = float(returns)
except Exception:
return 0.0
return float(math.tanh(r / 10.0))
def _build_embed_text(self, situation: str, recommendation: str, result: Optional[str], features_json: Optional[str]) -> str:
return "\n".join([
f"situation: {situation or ''}",
f"recommendation: {recommendation or ''}",
f"result: {result or ''}",
f"features: {features_json or ''}",
])
def add_memory(
self,
situation: str,
recommendation: str,
result: Optional[str] = None,
returns: Optional[float] = None,
metadata: Optional[Dict[str, Any]] = None,
):
"""
添加记忆
@@ -73,15 +163,32 @@ class AgentMemory:
recommendation: 建议/决策
result: 结果描述(可选)
returns: 收益(可选)
metadata: Optional structured metadata (market/symbol/timeframe/features...)
"""
try:
meta = metadata or {}
market = (meta.get("market") or "").strip() or None
symbol = (meta.get("symbol") or "").strip() or None
timeframe = (meta.get("timeframe") or "").strip() or None
features = meta.get("features") if isinstance(meta, dict) else None
try:
features_json = json.dumps(features, ensure_ascii=False) if features is not None else None
except Exception:
features_json = None
embedding_blob = None
if self.enable_vector:
text = self._build_embed_text(situation, recommendation, result, features_json)
vec = self.embedder.embed(text)
embedding_blob = self.embedder.to_bytes(vec)
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO memories (situation, recommendation, result, returns)
VALUES (?, ?, ?, ?)
''', (situation, recommendation, result, returns))
INSERT INTO memories (situation, recommendation, result, returns, market, symbol, timeframe, features_json, embedding)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (situation, recommendation, result, returns, market, symbol, timeframe, features_json, embedding_blob))
conn.commit()
conn.close()
@@ -89,7 +196,7 @@ class AgentMemory:
except Exception as e:
logger.error(f"添加记忆失败: {e}")
def get_memories(self, current_situation: str, n_matches: int = 2) -> List[Dict[str, Any]]:
def get_memories(self, current_situation: str, n_matches: int = 5, metadata: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
"""
检索相似记忆
@@ -106,11 +213,11 @@ class AgentMemory:
# 获取所有记忆
cursor.execute('''
SELECT id, situation, recommendation, result, returns, created_at
SELECT id, situation, recommendation, result, returns, created_at, market, symbol, timeframe, features_json, embedding
FROM memories
ORDER BY created_at DESC
LIMIT 100
''')
LIMIT ?
''', (int(self.candidate_limit),))
all_memories = cursor.fetchall()
conn.close()
@@ -118,33 +225,71 @@ class AgentMemory:
if not all_memories:
return []
# 计算相似度
scored_memories = []
for mem in all_memories:
mem_id, situation, recommendation, result, returns, created_at = mem
# 使用简单的文本相似度
similarity = difflib.SequenceMatcher(
None,
current_situation.lower(),
situation.lower()
).ratio()
scored_memories.append({
meta = metadata or {}
tf = (meta.get("timeframe") or "").strip()
features = meta.get("features") if isinstance(meta, dict) else None
try:
q_features_json = json.dumps(features, ensure_ascii=False) if features is not None else None
except Exception:
q_features_json = None
query_vec = []
if self.enable_vector:
query_text = self._build_embed_text(current_situation, "", "", q_features_json)
query_vec = self.embedder.embed(query_text)
ranked = []
for row in all_memories:
(
mem_id,
situation,
recommendation,
result,
returns,
created_at,
market,
symbol,
timeframe,
features_json,
embedding_blob,
) = row
sim = 0.0
if self.enable_vector and embedding_blob:
try:
mem_vec = self.embedder.from_bytes(embedding_blob)
sim = cosine_sim(query_vec, mem_vec)
except Exception:
sim = 0.0
else:
sim = difflib.SequenceMatcher(None, (current_situation or "").lower(), (situation or "").lower()).ratio()
rec = self._recency_score(created_at)
ret = self._returns_score(returns)
score = (self.w_sim * sim) + (self.w_recency * rec) + (self.w_returns * ret)
if tf and timeframe and str(timeframe).strip() != tf:
score -= 0.15
ranked.append({
'id': mem_id,
'matched_situation': situation,
'recommendation': recommendation,
'result': result,
'returns': returns,
'similarity_score': similarity,
'created_at': created_at
'created_at': created_at,
'market': market,
'symbol': symbol,
'timeframe': timeframe,
'features_json': features_json,
'score': float(score),
'sim': float(sim),
'recency': float(rec),
})
# 按相似度排序
scored_memories.sort(key=lambda x: x['similarity_score'], reverse=True)
# 返回前 n_matches 个
return scored_memories[:n_matches]
ranked.sort(key=lambda x: x.get('score', 0.0), reverse=True)
return ranked[: max(0, int(n_matches or 0))]
except Exception as e:
logger.error(f"检索记忆失败: {e}")
@@ -91,7 +91,7 @@ class ReflectionService:
conn.commit()
conn.close()
logger.info(f"已记录分析用于反思: {market}:{symbol}, 将在 {check_days} 天后验证")
logger.info(f"Recorded analysis for reflection: {market}:{symbol}, will verify after {check_days} day(s)")
except Exception as e:
logger.error(f"记录分析失败: {e}")
@@ -148,37 +148,52 @@ class ReflectionService:
if decision == "BUY":
if actual_return > 2.0:
result_desc = "准确:买入后价格上涨"
result_desc = "Correct: price rose after BUY"
is_good_prediction = True
elif actual_return < -2.0:
result_desc = "错误:买入后价格下跌"
result_desc = "Wrong: price fell after BUY"
else:
result_desc = "中性:价格波动不大"
result_desc = "Neutral: limited price movement"
elif decision == "SELL":
if actual_return < -2.0:
result_desc = "准确:卖出后价格下跌"
result_desc = "Correct: price fell after SELL"
is_good_prediction = True
elif actual_return > 2.0:
result_desc = "错误:卖出后价格上涨"
result_desc = "Wrong: price rose after SELL"
else:
result_desc = "中性:价格波动不大"
result_desc = "Neutral: limited price movement"
else: # HOLD
if -2.0 <= actual_return <= 2.0:
result_desc = "准确:持有期间波动不大"
result_desc = "Correct: limited movement during HOLD"
is_good_prediction = True
else:
result_desc = f"偏差:持有期间出现了较大波动 ({actual_return:.2f}%)"
result_desc = f"Deviated: large movement during HOLD ({actual_return:.2f}%)"
# 4. 写入记忆系统 (Let the agent learn)
memory_situation = f"{market}:{symbol} 自动验证 (预测日期: {analysis_date})"
memory_recommendation = f"当时决策: {decision} (置信度 {confidence}), 理由: {reasoning[:50]}..."
memory_result = f"验证结果: {result_desc}, 实际收益: {actual_return:.2f}% (初始 {initial_price} -> 最新 {current_price})"
memory_situation = f"{market}:{symbol} auto-verified (analysis_date: {analysis_date})"
memory_recommendation = f"Decision: {decision} (confidence {confidence}), reasoning: {(reasoning or '')[:120]}"
memory_result = f"Verification: {result_desc}; return={actual_return:.2f}% (initial {initial_price} -> final {current_price})"
trader_memory.add_memory(
memory_situation,
memory_recommendation,
memory_result,
actual_return
actual_return,
metadata={
"market": market,
"symbol": symbol,
"timeframe": "1D",
"features": {
"source": "auto_verify",
"decision": decision,
"confidence": confidence,
"initial_price": initial_price,
"final_price": current_price,
"analysis_date": str(analysis_date),
"result_desc": result_desc,
"is_good_prediction": bool(is_good_prediction),
},
}
)
# 5. 更新记录状态
@@ -0,0 +1,67 @@
"""
Background worker for automated reflection verification.
This replaces the need for an external cron job in local deployments.
It periodically runs ReflectionService.run_verification_cycle().
Controls (env):
- ENABLE_REFLECTION_WORKER=true/false (default: false)
- REFLECTION_WORKER_INTERVAL_SEC (default: 86400)
"""
from __future__ import annotations
import os
import time
import threading
from typing import Optional
from app.utils.logger import get_logger
from .reflection import ReflectionService
logger = get_logger(__name__)
class ReflectionWorker:
def __init__(self):
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
def start(self):
if self._thread and self._thread.is_alive():
return
interval = int(os.getenv("REFLECTION_WORKER_INTERVAL_SEC", "86400") or 86400)
interval = max(60, interval) # at least 1 minute
def _run():
logger.info(f"Reflection worker started (interval={interval}s)")
svc = ReflectionService()
# Initial small delay to avoid fighting startup spikes
time.sleep(3)
while not self._stop.is_set():
try:
svc.run_verification_cycle()
except Exception as e:
logger.error(f"Reflection worker cycle failed: {e}")
# Sleep in small steps to react to stop quickly
remaining = interval
while remaining > 0 and not self._stop.is_set():
step = min(5, remaining)
time.sleep(step)
remaining -= step
logger.info("Reflection worker stopped")
self._thread = threading.Thread(target=_run, name="ReflectionWorker", daemon=True)
self._thread.start()
def stop(self):
self._stop.set()
t = self._thread
if t and t.is_alive():
try:
t.join(timeout=5)
except Exception:
pass
@@ -32,7 +32,13 @@ class BullResearcher(BaseAgent):
# Memory
situation = f"{market}:{symbol} bull case"
memories = self.get_memories(situation, n_matches=2)
memory_meta = {
"market": market,
"symbol": symbol,
"timeframe": context.get("timeframe"),
"features": context.get("memory_features") or {},
}
memories = self.get_memories(situation, n_matches=None, metadata=memory_meta)
memory_prompt = self.format_memories_for_prompt(memories)
lang_instruction = self._get_language_instruction(language)
@@ -121,7 +127,13 @@ class BearResearcher(BaseAgent):
# Memory
situation = f"{market}:{symbol} bear case"
memories = self.get_memories(situation, n_matches=2)
memory_meta = {
"market": market,
"symbol": symbol,
"timeframe": context.get("timeframe"),
"features": context.get("memory_features") or {},
}
memories = self.get_memories(situation, n_matches=None, metadata=memory_meta)
memory_prompt = self.format_memories_for_prompt(memories)
lang_instruction = self._get_language_instruction(language)
@@ -82,27 +82,45 @@ class AgentTools:
"""Whether akshare is available at runtime."""
return bool(self._has_akshare and self._ak is not None)
def get_stock_data(self, market: str, symbol: str, days: int = 30) -> Optional[List[Dict[str, Any]]]:
def get_stock_data(self, market: str, symbol: str, days: int = 30, timeframe: str = "1d") -> Optional[List[Dict[str, Any]]]:
"""
Get daily Kline data for recent days (best-effort).
Args:
market: Market
symbol: Symbol
days: Days
days: Days (for daily) / candle count hint (best-effort for intraday)
timeframe: Kline timeframe (best-effort). Common values: 1d, 1h, 4h, 1w
Returns:
List of OHLCV dicts or None
"""
try:
klines = []
tf = (timeframe or "1d").strip().lower()
# Normalize common UI values
tf_map = {
"1d": "1d",
"1day": "1d",
"d": "1d",
"1h": "1h",
"60m": "1h",
"4h": "4h",
"240m": "4h",
"1w": "1wk",
"1wk": "1wk",
"w": "1wk",
}
tf_yf = tf_map.get(tf, "1d")
if market == 'USStock':
end_date = datetime.now().strftime('%Y-%m-%d')
start_date = (datetime.now() - timedelta(days=days + 5)).strftime('%Y-%m-%d')
ticker = yf.Ticker(symbol)
df = ticker.history(start=start_date, end=end_date, interval="1d")
# yfinance supports limited intervals. Fallback to 1d when unsupported.
interval = tf_yf if tf_yf in ["1d", "1h", "1wk"] else "1d"
df = ticker.history(start=start_date, end=end_date, interval=interval)
if not df.empty:
df = df.tail(days).reset_index()
@@ -121,7 +139,9 @@ class AgentTools:
exchange = self._ccxt_exchange()
symbol_pair = f'{symbol}/USDT'
start_time = int((datetime.now() - timedelta(days=days)).timestamp())
ohlcv = exchange.fetch_ohlcv(symbol_pair, '1d', since=start_time * 1000, limit=days)
# CCXT timeframes: 1d, 1h, 4h ...
ccxt_tf = tf if tf in ["1d", "1h", "4h"] else "1d"
ohlcv = exchange.fetch_ohlcv(symbol_pair, ccxt_tf, since=start_time * 1000, limit=days)
if ohlcv:
for candle in ohlcv:
klines.append({
@@ -175,7 +195,8 @@ class AgentTools:
start_date = (datetime.now() - timedelta(days=days + 5)).strftime('%Y-%m-%d')
ticker = yf.Ticker(yf_symbol)
df = ticker.history(start=start_date, end=end_date, interval="1d")
interval = tf_yf if tf_yf in ["1d", "1h", "1wk"] else "1d"
df = ticker.history(start=start_date, end=end_date, interval=interval)
if not df.empty:
df = df.tail(days).reset_index()
@@ -38,7 +38,13 @@ class TraderAgent(BaseAgent):
# Memory
situation = f"{market}:{symbol} trading decision"
memories = self.get_memories(situation, n_matches=2)
memory_meta = {
"market": market,
"symbol": symbol,
"timeframe": context.get("timeframe"),
"features": context.get("memory_features") or {},
}
memories = self.get_memories(situation, n_matches=None, metadata=memory_meta)
memory_prompt = self.format_memories_for_prompt(memories)
lang_instruction = self._get_language_instruction(language)
+5 -3
View File
@@ -37,8 +37,10 @@ class AnalysisService:
# Lazy import to avoid circular imports
from app.services.agents.coordinator import AgentCoordinator
import os
enable_memory = os.getenv("ENABLE_AGENT_MEMORY", "true").lower() == "true"
self.coordinator = AgentCoordinator(
enable_memory=True,
enable_memory=enable_memory,
max_debate_rounds=2
)
logger.info("Multi-agent coordinator initialized")
@@ -50,7 +52,7 @@ class AnalysisService:
finally:
AnalysisService._initializing = False
def analyze(self, market: str, symbol: str, language: str = 'en-US', model: str = None) -> Dict[str, Any]:
def analyze(self, market: str, symbol: str, language: str = 'en-US', model: str = None, timeframe: str = "1D") -> Dict[str, Any]:
"""
Args:
market: Market (AShare, USStock, HShare, Crypto, Forex, Futures)
@@ -79,7 +81,7 @@ class AnalysisService:
try:
logger.info(f"Run coordinator: {market}:{symbol}")
agent_result = self.coordinator.run_analysis(market, symbol, language, model=model)
agent_result = self.coordinator.run_analysis(market, symbol, language, model=model, timeframe=timeframe)
logger.info(f"Coordinator result keys: {list(agent_result.keys())}")
+19 -14
View File
@@ -12,8 +12,12 @@ from app.utils.logger import get_logger
logger = get_logger(__name__)
# 数据库文件默认路径(兼容旧行为:backend_api_python/quantdinger.db
# SQLite 主库路径解析
#
# 目标默认行为:把主库放到 `backend_api_python/data/quantdinger.db`
# 兼容旧行为:老版本会在 `backend_api_python/quantdinger.db` 建库
_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
_DEFAULT_DB_FILE = os.path.join(_BASE_DIR, 'data', 'quantdinger.db')
_LEGACY_DB_FILE = os.path.join(_BASE_DIR, 'quantdinger.db')
@@ -23,13 +27,13 @@ def _get_db_file() -> str:
Priority:
- SQLITE_DATABASE_FILE env (Docker 推荐/app/data/quantdinger.db)
- legacy default: backend_api_python/quantdinger.db
- default: backend_api_python/data/quantdinger.db (local recommended)
Also performs a best-effort one-time migration:
If the configured path doesn't exist but legacy db exists, copy legacy to configured path.
If the configured/default path doesn't exist but legacy db exists, copy legacy to configured/default path.
"""
env_path = os.getenv('SQLITE_DATABASE_FILE')
db_path = (env_path or '').strip() or _LEGACY_DB_FILE
db_path = (env_path or '').strip() or _DEFAULT_DB_FILE
# Ensure parent dir exists
parent = os.path.dirname(db_path)
@@ -421,8 +425,9 @@ def _init_db_schema(conn):
conn.commit()
logger.info("Database schema initialized (SQLite)")
# 初始化一次
# 初始化一次(按 db_file 维度)
_has_initialized = False
_initialized_db_file = None
class SQLiteCursor:
"""模拟 pymysql DictCursor"""
@@ -492,24 +497,24 @@ def get_db_connection():
"""
获取数据库连接 (Context Manager)
"""
global _has_initialized
global _has_initialized, _initialized_db_file
# 简单的连接创建,不使用连接池(SQLite 文件锁机制决定了连接池意义不大)
# 使用线程锁防止写冲突(虽然 SQLite 有 WAL 模式,但稳妥起见)
# 注意:这里加锁粒度较大,如果是高并发场景可能会慢,但对于个人量化系统足够。
# 初始化表结构
if not _has_initialized:
# 初始化表结构(确保每个 db_file 都被初始化过)
db_file = _get_db_file()
if (not _has_initialized) or (_initialized_db_file != db_file):
try:
db_file = _get_db_file()
conn_init = sqlite3.connect(db_file)
_init_db_schema(conn_init)
conn_init.close()
_has_initialized = True
_initialized_db_file = db_file
except Exception as e:
logger.error(f"Failed to initialize database: {e}")
db_file = _get_db_file()
conn = SQLiteConnection(db_file)
try:
# with _db_lock: # SQLite 内部有锁,这里如果不跨线程共享连接其实不用强加锁
@@ -523,18 +528,18 @@ def get_db_connection():
def get_db_connection_sync():
"""兼容旧接口"""
global _has_initialized
if not _has_initialized:
global _has_initialized, _initialized_db_file
db_file = _get_db_file()
if (not _has_initialized) or (_initialized_db_file != db_file):
try:
db_file = _get_db_file()
conn_init = sqlite3.connect(db_file)
_init_db_schema(conn_init)
conn_init.close()
_has_initialized = True
_initialized_db_file = db_file
except Exception as e:
logger.error(f"Failed to initialize database: {e}")
db_file = _get_db_file()
return SQLiteConnection(db_file)
def close_db_connection():
+28
View File
@@ -15,6 +15,14 @@ PYTHON_API_HOST=0.0.0.0
PYTHON_API_PORT=5000
PYTHON_API_DEBUG=False
# =========================
# Database (SQLite)
# =========================
# 主库文件路径(可选)
# - 不设置时,默认使用:backend_api_python/data/quantdinger.db
# - Docker 推荐:/app/data/quantdinger.db
SQLITE_DATABASE_FILE=
# =========================
# Pending orders worker (optional)
# =========================
@@ -95,6 +103,26 @@ ENABLE_CACHE=False
ENABLE_REQUEST_LOG=True
ENABLE_AI_ANALYSIS=True
# =========================
# Agent memory & reflection (optional)
# =========================
# Toggle agent memory usage in multi-agent analysis
ENABLE_AGENT_MEMORY=true
# Memory retrieval settings
AGENT_MEMORY_ENABLE_VECTOR=true
AGENT_MEMORY_EMBEDDING_DIM=256
AGENT_MEMORY_TOP_K=5
AGENT_MEMORY_CANDIDATE_LIMIT=500
AGENT_MEMORY_HALF_LIFE_DAYS=30
AGENT_MEMORY_W_SIM=0.75
AGENT_MEMORY_W_RECENCY=0.20
AGENT_MEMORY_W_RETURNS=0.05
# Automated verification loop (replaces cron)
ENABLE_REFLECTION_WORKER=false
REFLECTION_WORKER_INTERVAL_SEC=86400
# =========================
# OpenRouter / LLM
# =========================