313 lines
12 KiB
Python
Executable File
313 lines
12 KiB
Python
Executable File
#!/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()
|