Files

1014 lines
41 KiB
Python
Raw Permalink Normal View History

2026-01-31 02:59:49 +08:00
"""
Analysis Memory System 2.0
Simplified memory for fast analysis service.
Features:
1. Store analysis decisions with market context
2. Retrieve similar historical patterns
3. Track decision outcomes for learning
"""
2026-01-31 02:59:49 +08:00
import json
from typing import Any, Dict, List, Optional
2026-01-31 02:59:49 +08:00
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
2026-01-31 02:59:49 +08:00
logger = get_logger(__name__)
def _safe_json_parse(val, default=None):
"""Safely parse JSON - handle cases where it's already a Python object or string"""
2026-01-31 02:59:49 +08:00
if val is None:
return default
if isinstance(val, (dict, list)):
return val # Already a Python object (PostgreSQL JSONB automatically converted)
2026-01-31 02:59:49 +08:00
if isinstance(val, str):
try:
return json.loads(val)
except (json.JSONDecodeError, TypeError):
return default
return default
class AnalysisMemory:
"""
Simple but effective memory system for AI analysis.
Uses PostgreSQL for persistence.
"""
2026-01-31 02:59:49 +08:00
def __init__(self):
self._ensure_table()
2026-01-31 02:59:49 +08:00
def _ensure_table(self):
2026-03-01 03:42:10 +08:00
"""Create memory table if not exists, and add missing columns if needed."""
2026-01-31 02:59:49 +08:00
try:
with get_db_connection() as db:
cur = db.cursor()
# Create table if it does not exist
2026-01-31 02:59:49 +08:00
cur.execute("""
CREATE TABLE IF NOT EXISTS qd_analysis_memory (
id SERIAL PRIMARY KEY,
user_id INT,
2026-01-31 02:59:49 +08:00
market VARCHAR(50) NOT NULL,
symbol VARCHAR(50) NOT NULL,
decision VARCHAR(10) NOT NULL,
confidence INT DEFAULT 50,
price_at_analysis DECIMAL(24, 8),
summary TEXT,
reasons JSONB,
scores JSONB,
indicators_snapshot JSONB,
2026-03-01 03:42:10 +08:00
raw_result JSONB,
consensus_score DECIMAL(24, 8),
consensus_abs DECIMAL(24, 8),
agreement_ratio DECIMAL(10, 6),
quality_multiplier DECIMAL(10, 6),
task_status VARCHAR(20) DEFAULT 'completed',
task_error TEXT,
updated_at TIMESTAMP DEFAULT NOW(),
2026-01-31 02:59:49 +08:00
created_at TIMESTAMP DEFAULT NOW(),
validated_at TIMESTAMP,
actual_outcome VARCHAR(20),
actual_return_pct DECIMAL(10, 4),
was_correct BOOLEAN,
user_feedback VARCHAR(20),
feedback_at TIMESTAMP
);
2026-03-01 03:42:10 +08:00
""")
# Check and add missing columns (for existing tables)
2026-03-01 03:42:10 +08:00
cur.execute("""
DO $$
BEGIN
-- Add the user_id column if it does not exist
2026-03-01 03:42:10 +08:00
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
2026-03-01 03:42:10 +08:00
WHERE table_name = 'qd_analysis_memory' AND column_name = 'user_id'
) THEN
ALTER TABLE qd_analysis_memory ADD COLUMN user_id INT;
END IF;
-- Add the raw_result column if it does not exist
2026-03-01 03:42:10 +08:00
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
2026-03-01 03:42:10 +08:00
WHERE table_name = 'qd_analysis_memory' AND column_name = 'raw_result'
) THEN
ALTER TABLE qd_analysis_memory ADD COLUMN raw_result JSONB;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_analysis_memory' AND column_name = 'consensus_score'
) THEN
ALTER TABLE qd_analysis_memory ADD COLUMN consensus_score DECIMAL(24, 8);
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_analysis_memory' AND column_name = 'consensus_abs'
) THEN
ALTER TABLE qd_analysis_memory ADD COLUMN consensus_abs DECIMAL(24, 8);
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_analysis_memory' AND column_name = 'agreement_ratio'
) THEN
ALTER TABLE qd_analysis_memory ADD COLUMN agreement_ratio DECIMAL(10, 6);
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_analysis_memory' AND column_name = 'quality_multiplier'
) THEN
ALTER TABLE qd_analysis_memory ADD COLUMN quality_multiplier DECIMAL(10, 6);
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_analysis_memory' AND column_name = 'task_status'
) THEN
ALTER TABLE qd_analysis_memory ADD COLUMN task_status VARCHAR(20) DEFAULT 'completed';
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_analysis_memory' AND column_name = 'task_error'
) THEN
ALTER TABLE qd_analysis_memory ADD COLUMN task_error TEXT;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_analysis_memory' AND column_name = 'updated_at'
) THEN
ALTER TABLE qd_analysis_memory ADD COLUMN updated_at TIMESTAMP DEFAULT NOW();
END IF;
2026-03-01 03:42:10 +08:00
END $$;
""")
# Create index
2026-03-01 03:42:10 +08:00
cur.execute("""
CREATE INDEX IF NOT EXISTS idx_analysis_memory_symbol
2026-01-31 02:59:49 +08:00
ON qd_analysis_memory(market, symbol);
CREATE INDEX IF NOT EXISTS idx_analysis_memory_created
2026-01-31 02:59:49 +08:00
ON qd_analysis_memory(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_analysis_memory_validated
2026-03-01 03:42:10 +08:00
ON qd_analysis_memory(validated_at) WHERE validated_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_analysis_memory_user
ON qd_analysis_memory(user_id);
2026-01-31 02:59:49 +08:00
""")
2026-01-31 02:59:49 +08:00
db.commit()
cur.close()
2026-03-01 03:42:10 +08:00
logger.debug("Analysis memory table ensured successfully")
2026-01-31 02:59:49 +08:00
except Exception as e:
2026-03-01 03:42:10 +08:00
logger.warning(f"Memory table creation/update skipped: {e}")
def store(self, analysis_result: Dict[str, Any], user_id: int = None) -> Optional[int]:
2026-01-31 02:59:49 +08:00
"""
Store an analysis result for future reference.
2026-01-31 02:59:49 +08:00
Args:
analysis_result: Result from FastAnalysisService.analyze()
user_id: User ID who created this analysis
2026-01-31 02:59:49 +08:00
Returns:
Memory ID or None if failed
"""
try:
with get_db_connection() as db:
cur = db.cursor()
# Prepare data
2026-01-31 02:59:49 +08:00
market = analysis_result.get("market")
symbol = analysis_result.get("symbol")
decision = analysis_result.get("decision")
confidence = analysis_result.get("confidence")
price = analysis_result.get("market_data", {}).get("current_price")
summary = analysis_result.get("summary")
reasons = json.dumps(analysis_result.get("reasons", []))
scores = json.dumps(analysis_result.get("scores", {}))
indicators = json.dumps(analysis_result.get("indicators", {}))
raw = json.dumps(analysis_result)
consensus = analysis_result.get("consensus") or {}
consensus_score = consensus.get("consensus_score")
consensus_abs = consensus.get("consensus_abs")
agreement_ratio = consensus.get("agreement_ratio")
quality_multiplier = consensus.get("quality_multiplier")
cur.execute(
"""
2026-01-31 02:59:49 +08:00
INSERT INTO qd_analysis_memory (
user_id, market, symbol, decision, confidence,
price_at_analysis, summary, reasons, scores, indicators_snapshot, raw_result,
consensus_score, consensus_abs, agreement_ratio, quality_multiplier,
task_status, task_error, updated_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, NOW())
2026-01-31 02:59:49 +08:00
RETURNING id
""",
(
user_id,
market,
symbol,
decision,
confidence,
price,
summary,
reasons,
scores,
indicators,
raw,
consensus_score,
consensus_abs,
agreement_ratio,
quality_multiplier,
"completed",
"",
),
)
# Use the lastrowid attribute to get the ID (execute has already processed RETURNING internally)
2026-01-31 02:59:49 +08:00
memory_id = cur.lastrowid
db.commit()
cur.close()
logger.info(f"Stored analysis memory #{memory_id} for {symbol} by user {user_id}")
2026-01-31 02:59:49 +08:00
return memory_id
2026-01-31 02:59:49 +08:00
except Exception as e:
logger.error(f"Failed to store analysis memory: {e}", exc_info=True)
return None
2026-01-31 02:59:49 +08:00
def get_recent(self, market: str, symbol: str, days: int = 7, limit: int = 5) -> List[Dict]:
"""
Get recent analysis history for a symbol.
2026-01-31 02:59:49 +08:00
Args:
market: Market type
symbol: Symbol
days: Look back period
limit: Max results
2026-01-31 02:59:49 +08:00
Returns:
List of historical analyses
"""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
f"""
SELECT
2026-01-31 02:59:49 +08:00
id, decision, confidence, price_at_analysis,
summary, reasons, scores,
created_at, validated_at, was_correct, actual_return_pct,
task_status, task_error, updated_at
2026-01-31 02:59:49 +08:00
FROM qd_analysis_memory
WHERE market = %s AND symbol = %s
AND created_at > NOW() - INTERVAL '{int(days)} days'
ORDER BY created_at DESC
LIMIT %s
""",
(market, symbol, limit),
)
2026-01-31 02:59:49 +08:00
rows = cur.fetchall() or []
cur.close()
2026-01-31 02:59:49 +08:00
results = []
for row in rows:
results.append(
{
"id": row["id"],
"decision": row["decision"],
"confidence": row["confidence"],
"price": float(row["price_at_analysis"]) if row["price_at_analysis"] else None,
"summary": row["summary"],
"reasons": _safe_json_parse(row["reasons"], []),
"scores": _safe_json_parse(row["scores"], {}),
"status": row.get("task_status") or "completed",
"error_message": row.get("task_error") or "",
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"updated_at": row["updated_at"].isoformat() if row.get("updated_at") else None,
"was_correct": row["was_correct"],
"actual_return_pct": float(row["actual_return_pct"]) if row["actual_return_pct"] else None,
}
)
2026-01-31 02:59:49 +08:00
return results
2026-01-31 02:59:49 +08:00
except Exception as e:
logger.error(f"Failed to get recent memories: {e}")
return []
def get_all_history(self, user_id: int = None, page: int = 1, page_size: int = 20) -> Dict:
"""
Get all analysis history with pagination.
2026-01-31 02:59:49 +08:00
Args:
user_id: User ID filter (required to show only user's own history)
2026-01-31 02:59:49 +08:00
page: Page number (1-indexed)
page_size: Items per page
2026-01-31 02:59:49 +08:00
Returns:
Dict with items list and total count
"""
try:
offset = (page - 1) * page_size
2026-01-31 02:59:49 +08:00
with get_db_connection() as db:
cur = db.cursor()
# Build WHERE clause based on user_id
where_clause = "WHERE user_id = %s" if user_id else ""
params_count = (user_id,) if user_id else ()
2026-01-31 02:59:49 +08:00
# Get total count
cur.execute(f"SELECT COUNT(*) as cnt FROM qd_analysis_memory {where_clause}", params_count)
2026-01-31 02:59:49 +08:00
total_row = cur.fetchone()
total = total_row["cnt"] if total_row else 0
2026-01-31 02:59:49 +08:00
# Get paginated results
params = (user_id, page_size, offset) if user_id else (page_size, offset)
cur.execute(
f"""
SELECT
2026-01-31 02:59:49 +08:00
id, market, symbol, decision, confidence, price_at_analysis,
summary, reasons, scores, indicators_snapshot, raw_result,
created_at, validated_at, was_correct, actual_return_pct,
task_status, task_error, updated_at
2026-01-31 02:59:49 +08:00
FROM qd_analysis_memory
{where_clause}
2026-01-31 02:59:49 +08:00
ORDER BY created_at DESC
LIMIT %s OFFSET %s
""",
params,
)
2026-01-31 02:59:49 +08:00
rows = cur.fetchall() or []
cur.close()
2026-01-31 02:59:49 +08:00
items = []
for row in rows:
items.append(
{
"id": row["id"],
"market": row["market"],
"symbol": row["symbol"],
"decision": row["decision"],
"confidence": row["confidence"],
"price": float(row["price_at_analysis"]) if row["price_at_analysis"] else None,
"summary": row["summary"],
"reasons": _safe_json_parse(row["reasons"], []),
"scores": _safe_json_parse(row["scores"], {}),
"indicators": _safe_json_parse(row["indicators_snapshot"], {}),
"full_result": _safe_json_parse(row["raw_result"], None),
"status": row.get("task_status") or "completed",
"error_message": row.get("task_error") or "",
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"updated_at": row["updated_at"].isoformat() if row.get("updated_at") else None,
"was_correct": row["was_correct"],
"actual_return_pct": float(row["actual_return_pct"]) if row["actual_return_pct"] else None,
}
)
return {"items": items, "total": total, "page": page, "page_size": page_size}
2026-01-31 02:59:49 +08:00
except Exception as e:
logger.error(f"Failed to get all history: {e}")
return {"items": [], "total": 0, "page": page, "page_size": page_size}
def delete_history(self, memory_id: int, user_id: int = None) -> bool:
2026-01-31 02:59:49 +08:00
"""
Delete a history record by ID.
2026-01-31 02:59:49 +08:00
Args:
memory_id: The ID of the analysis memory to delete
user_id: User ID to ensure user can only delete their own records
2026-01-31 02:59:49 +08:00
Returns:
True if deleted successfully, False otherwise
"""
try:
with get_db_connection() as db:
cur = db.cursor()
if user_id:
# Only delete if it belongs to the user
cur.execute("DELETE FROM qd_analysis_memory WHERE id = %s AND user_id = %s", (memory_id, user_id))
else:
cur.execute("DELETE FROM qd_analysis_memory WHERE id = %s", (memory_id,))
2026-01-31 02:59:49 +08:00
db.commit()
affected = cur.rowcount
cur.close()
return affected > 0
except Exception as e:
logger.error(f"Failed to delete memory {memory_id}: {e}")
return False
def create_pending_task(
self, market: str, symbol: str, language: str, model: str, timeframe: str, user_id: int = None
) -> Optional[int]:
"""Create a processing record in history before long-running analysis starts."""
try:
with get_db_connection() as db:
cur = db.cursor()
summary = f"Analysis submitted ({timeframe})..."
reasons = json.dumps([])
scores = json.dumps({})
indicators = json.dumps({})
raw = json.dumps(
{
"market": market,
"symbol": symbol,
"language": language,
"model": model,
"timeframe": timeframe,
"task_status": "processing",
}
)
cur.execute(
"""
INSERT INTO qd_analysis_memory (
user_id, market, symbol, decision, confidence,
summary, reasons, scores, indicators_snapshot, raw_result,
task_status, task_error, updated_at, created_at
) VALUES (%s, %s, %s, %s, %s,
%s, %s, %s, %s, %s,
%s, %s, NOW(), NOW())
RETURNING id
""",
(
user_id,
market,
symbol,
"HOLD",
0,
summary,
reasons,
scores,
indicators,
raw,
"processing",
"",
),
)
# PostgresCursor.execute() will consume the RETURNING result in advance from fetchone() during INSERT.
# So dont use cur.fetchone() here, just get lastrowid.
memory_id = cur.lastrowid
db.commit()
cur.close()
return memory_id
except Exception as e:
logger.error(f"Failed to create pending task: {e}")
return None
def finalize_pending_task(self, memory_id: int, result: Dict[str, Any]) -> bool:
"""Overwrite pending record with final analysis result."""
try:
consensus = result.get("consensus") or {}
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
UPDATE qd_analysis_memory
SET decision = %s,
confidence = %s,
price_at_analysis = %s,
summary = %s,
reasons = %s,
scores = %s,
indicators_snapshot = %s,
raw_result = %s,
consensus_score = %s,
consensus_abs = %s,
agreement_ratio = %s,
quality_multiplier = %s,
task_status = %s,
task_error = %s,
updated_at = NOW()
WHERE id = %s
""",
(
result.get("decision"),
result.get("confidence"),
result.get("market_data", {}).get("current_price"),
result.get("summary"),
json.dumps(result.get("reasons", [])),
json.dumps(result.get("scores", {})),
json.dumps(result.get("indicators", {})),
json.dumps(result),
consensus.get("consensus_score"),
consensus.get("consensus_abs"),
consensus.get("agreement_ratio"),
consensus.get("quality_multiplier"),
"completed" if not result.get("error") else "failed",
str(result.get("error") or ""),
int(memory_id),
),
)
ok = cur.rowcount > 0
db.commit()
cur.close()
return ok
except Exception as e:
logger.error(f"Failed to finalize pending task {memory_id}: {e}")
return False
def fail_pending_task(self, memory_id: int, error_message: str) -> bool:
"""Mark pending task as failed."""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
UPDATE qd_analysis_memory
SET task_status = 'failed',
task_error = %s,
summary = %s,
updated_at = NOW()
WHERE id = %s
""",
(
str(error_message or "analysis failed"),
f"Analysis failed: {str(error_message or '')}",
int(memory_id),
),
)
ok = cur.rowcount > 0
db.commit()
cur.close()
return ok
except Exception as e:
logger.error(f"Failed to mark task failed {memory_id}: {e}")
return False
def get_similar_patterns(self, market: str, symbol: str, current_indicators: Dict, limit: int = 3) -> List[Dict]:
2026-01-31 02:59:49 +08:00
"""
Find historical analyses with similar technical patterns.
Multi-indicator weighted similarity:
- RSI: ±15 range, weighted 0.3
- MACD signal: exact match, weighted 0.3
- MA trend: exact match, weighted 0.25
- Volatility level: similar band, weighted 0.15
- Time decay: prefer recent validated outcomes
2026-01-31 02:59:49 +08:00
"""
try:
rsi = float(current_indicators.get("rsi", {}).get("value") or 50)
macd_signal = str(current_indicators.get("macd", {}).get("signal") or "neutral").lower()
ma_trend = str(current_indicators.get("moving_averages", {}).get("trend") or "sideways").lower()
vol_level = str(current_indicators.get("volatility", {}).get("level") or "normal").lower()
2026-01-31 02:59:49 +08:00
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT
2026-01-31 02:59:49 +08:00
id, decision, confidence, price_at_analysis,
summary, reasons, indicators_snapshot,
created_at, was_correct, actual_return_pct
FROM qd_analysis_memory
WHERE market = %s AND symbol = %s
AND validated_at IS NOT NULL
AND was_correct IS NOT NULL
ORDER BY validated_at DESC NULLS LAST, created_at DESC
2026-01-31 02:59:49 +08:00
LIMIT %s
""",
(market, symbol, limit * 5),
)
2026-01-31 02:59:49 +08:00
rows = cur.fetchall() or []
cur.close()
scored = []
2026-01-31 02:59:49 +08:00
for row in rows:
ind = _safe_json_parse(row["indicators_snapshot"], {})
hist_rsi = float(ind.get("rsi", {}).get("value") or 50)
hist_macd = str(ind.get("macd", {}).get("signal") or "neutral").lower()
hist_ma = str(ind.get("moving_averages", {}).get("trend") or "sideways").lower()
hist_vol = str(ind.get("volatility", {}).get("level") or "normal").lower()
rsi_diff = abs(hist_rsi - rsi)
rsi_score = max(0, 1 - rsi_diff / 30) * 0.3
macd_score = 0.3 if hist_macd == macd_signal else 0
ma_score = 0.25 if hist_ma == ma_trend else 0
vol_score = (
0.15 if hist_vol == vol_level else (0.08 if _vol_bands_similar(vol_level, hist_vol) else 0)
)
sim = rsi_score + macd_score + ma_score + vol_score
if sim < 0.25:
continue
bonus = 0.1 if row["was_correct"] else 0
scored.append(
(
sim + bonus,
{
"id": row["id"],
"decision": row["decision"],
"confidence": row["confidence"],
"price": float(row["price_at_analysis"]) if row["price_at_analysis"] else None,
"summary": row["summary"],
"was_correct": row["was_correct"],
"actual_return_pct": float(row["actual_return_pct"])
if row["actual_return_pct"]
else None,
"similarity_score": round(sim + bonus, 3),
},
)
)
scored.sort(key=lambda x: -x[0])
return [p[1] for p in scored[:limit]]
2026-01-31 02:59:49 +08:00
except Exception as e:
logger.error(f"Failed to get similar patterns: {e}")
return []
2026-01-31 02:59:49 +08:00
def record_feedback(self, memory_id: int, feedback: str) -> bool:
"""
Record user feedback on an analysis.
2026-01-31 02:59:49 +08:00
Args:
memory_id: Analysis memory ID
feedback: 'helpful' | 'not_helpful' | 'accurate' | 'inaccurate'
"""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
2026-01-31 02:59:49 +08:00
UPDATE qd_analysis_memory
SET user_feedback = %s, feedback_at = NOW()
WHERE id = %s
""",
(feedback, memory_id),
)
2026-01-31 02:59:49 +08:00
db.commit()
cur.close()
return True
except Exception as e:
logger.error(f"Failed to record feedback: {e}")
return False
2026-01-31 02:59:49 +08:00
def validate_past_decisions(self, days_ago: int = 7) -> Dict[str, Any]:
"""
Validate historical decisions by comparing with actual price movements.
Run this periodically (e.g., daily) to build learning data.
2026-01-31 02:59:49 +08:00
Args:
days_ago: Validate decisions from N days ago
2026-01-31 02:59:49 +08:00
Returns:
Validation statistics
"""
from app.services.market_data_collector import MarketDataCollector
2026-01-31 02:59:49 +08:00
collector = MarketDataCollector()
2026-01-31 02:59:49 +08:00
stats = {
"validated": 0,
"correct": 0,
"incorrect": 0,
"errors": 0,
}
2026-01-31 02:59:49 +08:00
try:
with get_db_connection() as db:
cur = db.cursor()
2026-01-31 02:59:49 +08:00
# Get unvalidated decisions from N days ago
cur.execute(f"""
SELECT id, market, symbol, decision, price_at_analysis
FROM qd_analysis_memory
WHERE validated_at IS NULL
AND created_at < NOW() - INTERVAL '{int(days_ago)} days'
AND created_at > NOW() - INTERVAL '{int(days_ago + 1)} days'
LIMIT 50
""")
2026-01-31 02:59:49 +08:00
rows = cur.fetchall() or []
2026-01-31 02:59:49 +08:00
for row in rows:
try:
price_data = collector._get_price(row["market"], row["symbol"])
current_price = float(price_data.get("price", 0)) if price_data else None
2026-01-31 02:59:49 +08:00
if not current_price or current_price <= 0:
continue
analysis_price = float(row["price_at_analysis"])
2026-01-31 02:59:49 +08:00
if analysis_price <= 0:
continue
2026-01-31 02:59:49 +08:00
# Calculate return
return_pct = ((current_price - analysis_price) / analysis_price) * 100
2026-01-31 02:59:49 +08:00
# Determine if decision was correct
decision = row["decision"]
2026-01-31 02:59:49 +08:00
was_correct = False
if decision == "BUY" and return_pct > 2: # 2% threshold
2026-01-31 02:59:49 +08:00
was_correct = True
elif decision == "SELL" and return_pct < -2:
2026-01-31 02:59:49 +08:00
was_correct = True
elif decision == "HOLD" and abs(return_pct) <= 5:
2026-01-31 02:59:49 +08:00
was_correct = True
2026-01-31 02:59:49 +08:00
# Update record
cur.execute(
"""
2026-01-31 02:59:49 +08:00
UPDATE qd_analysis_memory
SET validated_at = NOW(),
actual_return_pct = %s,
was_correct = %s
WHERE id = %s
""",
(return_pct, was_correct, row["id"]),
)
2026-01-31 02:59:49 +08:00
stats["validated"] += 1
if was_correct:
stats["correct"] += 1
else:
stats["incorrect"] += 1
2026-01-31 02:59:49 +08:00
except Exception as e:
logger.warning(f"Failed to validate memory {row['id']}: {e}")
stats["errors"] += 1
2026-01-31 02:59:49 +08:00
db.commit()
cur.close()
2026-01-31 02:59:49 +08:00
except Exception as e:
logger.error(f"Validation batch failed: {e}")
2026-01-31 02:59:49 +08:00
accuracy = (stats["correct"] / stats["validated"] * 100) if stats["validated"] > 0 else 0
stats["accuracy_pct"] = round(accuracy, 2)
2026-01-31 02:59:49 +08:00
logger.info(f"Validation completed: {stats}")
return stats
def validate_unvalidated_older_than(self, min_age_days: int = 7, limit: int = 200) -> Dict[str, Any]:
"""
Best-effort backfill:
Validate unvalidated decisions older than `min_age_days`.
This is used by offline AI calibration so the system can tune itself automatically.
"""
from app.services.market_data_collector import MarketDataCollector
collector = MarketDataCollector()
stats = {
"validated": 0,
"correct": 0,
"incorrect": 0,
"errors": 0,
}
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
f"""
SELECT id, market, symbol, decision, price_at_analysis
FROM qd_analysis_memory
WHERE validated_at IS NULL
AND created_at < NOW() - INTERVAL '{int(min_age_days)} days'
LIMIT {int(limit)}
"""
)
rows = cur.fetchall() or []
for row in rows:
try:
price_data = collector._get_price(row["market"], row["symbol"])
current_price = float(price_data.get("price", 0)) if price_data else None
if not current_price or current_price <= 0:
continue
analysis_price = float(row.get("price_at_analysis") or 0.0)
if analysis_price <= 0:
continue
return_pct = ((float(current_price) - analysis_price) / analysis_price) * 100.0
decision = str(row.get("decision") or "HOLD")
was_correct = False
if decision == "BUY" and return_pct > 2:
was_correct = True
elif decision == "SELL" and return_pct < -2:
was_correct = True
elif decision == "HOLD" and abs(return_pct) <= 5:
was_correct = True
cur.execute(
"""
UPDATE qd_analysis_memory
SET validated_at = NOW(),
actual_return_pct = %s,
was_correct = %s
WHERE id = %s
""",
(return_pct, was_correct, int(row["id"])),
)
stats["validated"] += 1
if was_correct:
stats["correct"] += 1
else:
stats["incorrect"] += 1
except Exception as e:
logger.warning(f"Failed to validate memory {row.get('id')}: {e}", exc_info=True)
stats["errors"] += 1
db.commit()
cur.close()
except Exception as e:
logger.error(f"validate_unvalidated_older_than failed: {e}", exc_info=True)
return stats
def get_confidence_accuracy_by_bucket(
self, market: str = None, symbol: str = None, days: int = 90
) -> Dict[str, float]:
"""
Compute actual accuracy by confidence bucket for calibration.
Buckets: (50,60), (60,70), (70,80), (80,90), (90,100).
Returns e.g. {"60_70": 0.58, "70_80": 0.62} - bucket_key -> accuracy.
"""
try:
with get_db_connection() as db:
cur = db.cursor()
where = ["validated_at IS NOT NULL", "was_correct IS NOT NULL", "confidence IS NOT NULL"]
params = []
if market:
where.append("market = %s")
params.append(market)
if symbol:
where.append("symbol = %s")
params.append(symbol)
where.append(f"created_at > NOW() - INTERVAL '{int(days)} days'")
params = tuple(params) if params else ()
cur.execute(
f"""
SELECT confidence, was_correct
FROM qd_analysis_memory
WHERE {" AND ".join(where)}
""",
params,
)
rows = cur.fetchall() or []
cur.close()
buckets = [(50, 60), (60, 70), (70, 80), (80, 90), (90, 101)]
out = {}
for lo, hi in buckets:
subset = [r for r in rows if lo <= (r.get("confidence") or 0) < hi]
if len(subset) < 5:
continue
correct = sum(1 for r in subset if r.get("was_correct"))
out[f"{lo}_{hi}"] = correct / len(subset)
return out
except Exception as e:
logger.warning(f"get_confidence_accuracy_by_bucket failed: {e}")
return {}
def get_adjusted_confidence(self, raw_confidence: int, market: str = None, symbol: str = None) -> int:
"""
Adjust confidence based on historical accuracy in that bucket.
If model is overconfident (low actual accuracy), dampen. Underconfident -> boost slightly.
"""
buckets = [(50, 60, "50_60"), (60, 70, "60_70"), (70, 80, "70_80"), (80, 90, "80_90"), (90, 101, "90_100")]
bucket_key = None
for lo, hi, key in buckets:
if lo <= raw_confidence < hi:
bucket_key = key
break
if not bucket_key:
return max(1, min(99, int(raw_confidence)))
acc_map = self.get_confidence_accuracy_by_bucket(market=market, symbol=symbol)
acc = acc_map.get(bucket_key)
if acc is None or acc <= 0:
return max(1, min(99, int(raw_confidence)))
expected = 0.5 + (raw_confidence - 50) / 100
if expected <= 0:
return raw_confidence
factor = acc / expected
adjusted = int(raw_confidence * factor)
return max(1, min(99, adjusted))
def get_performance_stats(self, market: str = None, symbol: str = None, days: int = 30) -> Dict[str, Any]:
2026-01-31 02:59:49 +08:00
"""
Get AI performance statistics.
2026-01-31 02:59:49 +08:00
Returns:
Performance metrics for display
"""
try:
with get_db_connection() as db:
cur = db.cursor()
2026-01-31 02:59:49 +08:00
where_clauses = ["validated_at IS NOT NULL"]
params = []
2026-01-31 02:59:49 +08:00
if market:
where_clauses.append("market = %s")
params.append(market)
if symbol:
where_clauses.append("symbol = %s")
params.append(symbol)
2026-01-31 02:59:49 +08:00
# Use f-string for interval since psycopg2 doesn't support placeholder in INTERVAL
where_clauses.append(f"created_at > NOW() - INTERVAL '{int(days)} days'")
2026-01-31 02:59:49 +08:00
where_sql = " AND ".join(where_clauses)
cur.execute(
f"""
SELECT
2026-01-31 02:59:49 +08:00
COUNT(*) as total,
SUM(CASE WHEN was_correct = true THEN 1 ELSE 0 END) as correct,
AVG(actual_return_pct) as avg_return,
SUM(CASE WHEN decision = 'BUY' THEN 1 ELSE 0 END) as buy_count,
SUM(CASE WHEN decision = 'SELL' THEN 1 ELSE 0 END) as sell_count,
SUM(CASE WHEN decision = 'HOLD' THEN 1 ELSE 0 END) as hold_count,
SUM(CASE WHEN user_feedback = 'helpful' THEN 1 ELSE 0 END) as helpful_count,
SUM(CASE WHEN user_feedback IS NOT NULL THEN 1 ELSE 0 END) as feedback_count
FROM qd_analysis_memory
WHERE {where_sql}
""",
tuple(params) if params else None,
)
2026-01-31 02:59:49 +08:00
row = cur.fetchone()
cur.close()
if not row or not row["total"]:
2026-01-31 02:59:49 +08:00
return {
"total_analyses": 0,
"accuracy_pct": 0,
"avg_return_pct": 0,
"user_satisfaction_pct": 0,
}
total = row["total"]
correct = row["correct"] or 0
2026-01-31 02:59:49 +08:00
return {
"total_analyses": total,
"accuracy_pct": round((correct / total * 100) if total > 0 else 0, 2),
"avg_return_pct": round(float(row["avg_return"] or 0), 2),
2026-01-31 02:59:49 +08:00
"decision_distribution": {
"buy": row["buy_count"] or 0,
"sell": row["sell_count"] or 0,
"hold": row["hold_count"] or 0,
2026-01-31 02:59:49 +08:00
},
"user_satisfaction_pct": round(
(row["helpful_count"] / row["feedback_count"] * 100)
if row["feedback_count"] and row["feedback_count"] > 0
else 0,
2,
2026-01-31 02:59:49 +08:00
),
"period_days": days,
}
2026-01-31 02:59:49 +08:00
except Exception as e:
logger.error(f"Failed to get performance stats: {e}")
return {
"total_analyses": 0,
"accuracy_pct": 0,
"error": str(e),
}
def _vol_bands_similar(a: str, b: str) -> bool:
"""Check if two volatility levels are in similar band."""
low = {"low", "normal", "normal_low"}
high = {"high", "elevated", "volatile", "very_high"}
a, b = a.lower(), b.lower()
if a in low and b in low:
return True
if a in high and b in high:
return True
return False
2026-01-31 02:59:49 +08:00
# Singleton
_memory_instance = None
2026-01-31 02:59:49 +08:00
def get_analysis_memory() -> AnalysisMemory:
"""Get singleton AnalysisMemory instance."""
global _memory_instance
if _memory_instance is None:
_memory_instance = AnalysisMemory()
return _memory_instance