feat: bootstrap coinhunter cli package
This commit is contained in:
315
src/coinhunter/review_engine.py
Executable file
315
src/coinhunter/review_engine.py
Executable file
@@ -0,0 +1,315 @@
|
||||
#!/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
|
||||
|
||||
ENV_FILE = Path.home() / ".hermes" / ".env"
|
||||
REVIEW_DIR = Path.home() / ".coinhunter" / "reviews"
|
||||
|
||||
CST = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def load_env():
|
||||
if ENV_FILE.exists():
|
||||
for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, val = line.split("=", 1)
|
||||
os.environ.setdefault(key.strip(), val.strip())
|
||||
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user