#!/usr/bin/env python3 """Coin Hunter hourly review engine.""" import json import os import sys from datetime import datetime, timezone, timedelta from pathlib import Path import ccxt from .logger import get_logs_last_n_hours, log_error from .runtime import get_runtime_paths, load_env_file PATHS = get_runtime_paths() ENV_FILE = PATHS.env_file REVIEW_DIR = PATHS.reviews_dir CST = timezone(timedelta(hours=8)) def load_env(): load_env_file(PATHS) def get_exchange(): load_env() ex = ccxt.binance({ "apiKey": os.getenv("BINANCE_API_KEY"), "secret": os.getenv("BINANCE_API_SECRET"), "options": {"defaultType": "spot"}, "enableRateLimit": True, }) ex.load_markets() return ex def ensure_review_dir(): REVIEW_DIR.mkdir(parents=True, exist_ok=True) def norm_symbol(symbol: str) -> str: s = symbol.upper().replace("-", "").replace("_", "") if "/" in s: return s if s.endswith("USDT"): return s[:-4] + "/USDT" return s def fetch_current_price(ex, symbol: str): try: return float(ex.fetch_ticker(norm_symbol(symbol))["last"]) except Exception: return None def analyze_trade(trade: dict, ex) -> dict: symbol = trade.get("symbol") price = trade.get("price") action = trade.get("action", "") current_price = fetch_current_price(ex, symbol) if symbol else None pnl_estimate = None outcome = "neutral" if price and current_price and symbol: change_pct = (current_price - float(price)) / float(price) * 100 if action == "BUY": pnl_estimate = round(change_pct, 2) outcome = "good" if change_pct > 2 else "bad" if change_pct < -2 else "neutral" elif action == "SELL_ALL": pnl_estimate = round(-change_pct, 2) # Lowered missed threshold: >2% is a missed opportunity in short-term trading outcome = "good" if change_pct < -2 else "missed" if change_pct > 2 else "neutral" return { "timestamp": trade.get("timestamp"), "symbol": symbol, "action": action, "decision_id": trade.get("decision_id"), "execution_price": price, "current_price": current_price, "pnl_estimate_pct": pnl_estimate, "outcome_assessment": outcome, } def analyze_hold_passes(decisions: list, ex) -> list: """Check HOLD decisions where an opportunity was explicitly PASSed but later rallied.""" misses = [] for d in decisions: if d.get("decision") != "HOLD": continue analysis = d.get("analysis") if not isinstance(analysis, dict): continue opportunities = analysis.get("opportunities_evaluated", []) market_snapshot = d.get("market_snapshot", {}) if not opportunities or not market_snapshot: continue for op in opportunities: verdict = op.get("verdict", "") if "PASS" not in verdict and "pass" not in verdict: continue symbol = op.get("symbol", "") # Try to extract decision-time price from market_snapshot snap = market_snapshot.get(symbol) or market_snapshot.get(symbol.replace("/", "")) if not snap: continue decision_price = None if isinstance(snap, dict): decision_price = float(snap.get("lastPrice", 0)) or float(snap.get("last", 0)) elif isinstance(snap, (int, float, str)): decision_price = float(snap) if not decision_price: continue current_price = fetch_current_price(ex, symbol) if not current_price: continue change_pct = (current_price - decision_price) / decision_price * 100 if change_pct > 3: # >3% rally after being passed = missed watch misses.append({ "timestamp": d.get("timestamp"), "symbol": symbol, "decision_price": round(decision_price, 8), "current_price": round(current_price, 8), "change_pct": round(change_pct, 2), "verdict_snippet": verdict[:80], }) return misses def analyze_cash_misses(decisions: list, ex) -> list: """If portfolio was mostly USDT but a watchlist coin rallied >5%, flag it.""" misses = [] watchlist = set() for d in decisions: snap = d.get("market_snapshot", {}) if isinstance(snap, dict): for k in snap.keys(): if k.endswith("USDT"): watchlist.add(k) for d in decisions: ts = d.get("timestamp") balances = d.get("balances") or d.get("balances_before", {}) if not balances: continue total = sum(float(v) if isinstance(v, (int, float, str)) else 0 for v in balances.values()) usdt = float(balances.get("USDT", 0)) if total == 0 or (usdt / total) < 0.9: continue # Portfolio mostly cash — check watchlist performance snap = d.get("market_snapshot", {}) if not isinstance(snap, dict): continue for symbol, data in snap.items(): if not symbol.endswith("USDT"): continue decision_price = None if isinstance(data, dict): decision_price = float(data.get("lastPrice", 0)) or float(data.get("last", 0)) elif isinstance(data, (int, float, str)): decision_price = float(data) if not decision_price: continue current_price = fetch_current_price(ex, symbol) if not current_price: continue change_pct = (current_price - decision_price) / decision_price * 100 if change_pct > 5: misses.append({ "timestamp": ts, "symbol": symbol, "decision_price": round(decision_price, 8), "current_price": round(current_price, 8), "change_pct": round(change_pct, 2), }) # Deduplicate by symbol keeping the worst miss seen = {} for m in misses: sym = m["symbol"] if sym not in seen or m["change_pct"] > seen[sym]["change_pct"]: seen[sym] = m return list(seen.values()) def generate_review(hours: int = 1) -> dict: decisions = get_logs_last_n_hours("decisions", hours) trades = get_logs_last_n_hours("trades", hours) errors = get_logs_last_n_hours("errors", hours) review = { "review_period_hours": hours, "review_timestamp": datetime.now(CST).isoformat(), "total_decisions": len(decisions), "total_trades": len(trades), "total_errors": len(errors), "decision_quality": [], "stats": {}, "insights": [], "recommendations": [], } if not decisions and not trades: review["insights"].append("本周期无决策/交易记录") return review ex = get_exchange() outcomes = {"good": 0, "neutral": 0, "bad": 0, "missed": 0} pnl_samples = [] for trade in trades: analysis = analyze_trade(trade, ex) review["decision_quality"].append(analysis) outcomes[analysis["outcome_assessment"]] += 1 if analysis["pnl_estimate_pct"] is not None: pnl_samples.append(analysis["pnl_estimate_pct"]) # New: analyze missed opportunities from HOLD / cash decisions hold_pass_misses = analyze_hold_passes(decisions, ex) cash_misses = analyze_cash_misses(decisions, ex) total_missed = outcomes["missed"] + len(hold_pass_misses) + len(cash_misses) review["stats"] = { "good_decisions": outcomes["good"], "neutral_decisions": outcomes["neutral"], "bad_decisions": outcomes["bad"], "missed_opportunities": total_missed, "missed_sell_all": outcomes["missed"], "missed_hold_passes": len(hold_pass_misses), "missed_cash_sits": len(cash_misses), "avg_estimated_edge_pct": round(sum(pnl_samples) / len(pnl_samples), 2) if pnl_samples else None, } if errors: review["insights"].append(f"本周期出现 {len(errors)} 次执行/系统错误,健壮性需优先关注") if outcomes["bad"] > outcomes["good"]: review["insights"].append("最近交易质量偏弱,建议降低交易频率或提高入场门槛") if total_missed > 0: parts = [] if outcomes["missed"]: parts.append(f"卖出后继续上涨 {outcomes['missed']} 次") if hold_pass_misses: parts.append(f"PASS 后错失 {len(hold_pass_misses)} 次") if cash_misses: parts.append(f"空仓观望错失 {len(cash_misses)} 次") review["insights"].append("存在错失机会: " + ",".join(parts) + ",建议放宽趋势跟随或入场条件") if outcomes["good"] >= max(1, outcomes["bad"] + total_missed): review["insights"].append("近期决策总体可接受") if not trades and decisions: review["insights"].append("有决策无成交,可能是观望、最小成交额限制或执行被拦截") if len(trades) < len(decisions) * 0.1 and decisions: review["insights"].append("大量决策未转化为交易,需检查执行门槛(最小成交额/精度/手续费缓冲)是否过高") if hold_pass_misses: for m in hold_pass_misses[:3]: review["insights"].append(f"HOLD 时 PASS 了 {m['symbol']},之后上涨 {m['change_pct']}%") if cash_misses: for m in cash_misses[:3]: review["insights"].append(f"持仓以 USDT 为主时 {m['symbol']} 上涨 {m['change_pct']}%") review["recommendations"] = [ "优先检查最小成交额/精度拒单是否影响小资金执行", "若连续两个复盘周期 edge 为负,下一小时减少换仓频率", "若错误日志增加,优先进入防守模式(多持 USDT)", ] return review def save_review(review: dict): ensure_review_dir() ts = datetime.now(CST).strftime("%Y%m%d_%H%M%S") path = REVIEW_DIR / f"review_{ts}.json" path.write_text(json.dumps(review, indent=2, ensure_ascii=False), encoding="utf-8") return str(path) def print_review(review: dict): print("=" * 50) print("📊 Coin Hunter 小时复盘报告") print(f"复盘时间: {review['review_timestamp']}") print(f"统计周期: 过去 {review['review_period_hours']} 小时") print(f"总决策数: {review['total_decisions']} | 总交易数: {review['total_trades']} | 总错误数: {review['total_errors']}") stats = review.get("stats", {}) print("\n决策质量统计:") print(f" ✓ 优秀: {stats.get('good_decisions', 0)}") print(f" ○ 中性: {stats.get('neutral_decisions', 0)}") print(f" ✗ 失误: {stats.get('bad_decisions', 0)}") print(f" ↗ 错过机会: {stats.get('missed_opportunities', 0)}") if stats.get("avg_estimated_edge_pct") is not None: print(f" 平均估计 edge: {stats['avg_estimated_edge_pct']}%") if review.get("insights"): print("\n💡 见解:") for item in review["insights"]: print(f" • {item}") if review.get("recommendations"): print("\n🔧 优化建议:") for item in review["recommendations"]: print(f" • {item}") print("=" * 50) def main(): try: hours = int(sys.argv[1]) if len(sys.argv) > 1 else 1 review = generate_review(hours) path = save_review(review) print_review(review) print(f"复盘已保存至: {path}") except Exception as e: log_error("review_engine", e) raise if __name__ == "__main__": main()