update
This commit is contained in:
parent
29b36e48b0
commit
1fdae59e40
@ -9,7 +9,7 @@
|
||||
- 校验脚本集中到 `scripts/`
|
||||
- 模板资源集中到 `templates/`
|
||||
- 运行/分析产物集中到 `reports/`
|
||||
- 历史遗留与备份集中到 `legacy/`
|
||||
- 历史遗留与备份已清理,不再作为常驻目录
|
||||
|
||||
## 当前建议心智模型
|
||||
|
||||
@ -89,7 +89,6 @@
|
||||
|
||||
### 7. 非主路径归档
|
||||
|
||||
- `legacy/`
|
||||
- `reports/`
|
||||
|
||||
## 已整理的文件
|
||||
@ -116,14 +115,6 @@
|
||||
|
||||
- `schema.py` -> `docs/reference/schema_reference.py`
|
||||
|
||||
### 移入 `legacy/`
|
||||
|
||||
- `coin_state_tracker.py`
|
||||
- `price_tracker_ws.py`
|
||||
- `legacy/web/index.html`
|
||||
- `legacy/static/app.html.bak`
|
||||
- `legacy/scratch/*`
|
||||
|
||||
## 后续建议
|
||||
|
||||
如果继续整理,建议下一步做实现层拆分,而不是再搬目录:
|
||||
|
||||
@ -1,261 +0,0 @@
|
||||
"""
|
||||
山寨币状态跟踪器 — 去重 + 状态升级管理
|
||||
|
||||
状态生命周期:蓄力 → 加速 → 爁发 → 已告警 → 过期
|
||||
只有状态升级才告警,同级别12h内不重复推送
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
DB_PATH = "/home/ubuntu/quant_monitor/altcoin/altcoin_monitor.db"
|
||||
|
||||
STATE_ORDER = {
|
||||
"蓄力": 1,
|
||||
"加速": 2,
|
||||
"爆发": 3,
|
||||
"已告警": 4,
|
||||
"过期": 5,
|
||||
}
|
||||
|
||||
ALERT_LEVELS = {
|
||||
"蓄力": "low",
|
||||
"加速": "medium",
|
||||
"爆发": "high",
|
||||
}
|
||||
|
||||
# 12h内同级别不重复告警
|
||||
ALERT_COOLDOWN_HOURS = 12
|
||||
|
||||
# 24h后状态自动过期
|
||||
EXPIRE_HOURS = 24
|
||||
|
||||
|
||||
def init_db():
|
||||
"""初始化数据库"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS coin_state (
|
||||
symbol TEXT PRIMARY KEY,
|
||||
state TEXT NOT NULL,
|
||||
score REAL DEFAULT 0,
|
||||
anomaly_type TEXT DEFAULT '',
|
||||
sector TEXT DEFAULT '',
|
||||
leader_status TEXT DEFAULT '',
|
||||
detected_at TEXT NOT NULL,
|
||||
last_alert_time TEXT DEFAULT '',
|
||||
last_alert_level TEXT DEFAULT '',
|
||||
detail_json TEXT DEFAULT '{}'
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_state(symbol):
|
||||
"""获取币种当前状态"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
row = conn.execute("SELECT * FROM coin_state WHERE symbol=?", (symbol,)).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
return {
|
||||
"symbol": row[0],
|
||||
"state": row[1],
|
||||
"score": row[2],
|
||||
"anomaly_type": row[3],
|
||||
"sector": row[4],
|
||||
"leader_status": row[5],
|
||||
"detected_at": row[6],
|
||||
"last_alert_time": row[7],
|
||||
"last_alert_level": row[8],
|
||||
"detail": json.loads(row[9]) if row[9] else {},
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def update_state(symbol, new_state, score=0, anomaly_type="", sector="", leader_status="", detail={}):
|
||||
"""
|
||||
更新币种状态,判断是否需要告警
|
||||
返回: {"should_alert": bool, "alert_level": str, "reason": str}
|
||||
"""
|
||||
current = get_state(symbol)
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
should_alert = False
|
||||
alert_level = ""
|
||||
reason = ""
|
||||
|
||||
if current:
|
||||
current_level = STATE_ORDER.get(current["state"], 0)
|
||||
new_level = STATE_ORDER.get(new_state, 0)
|
||||
|
||||
# 状态升级 → 检查冷却时间
|
||||
if new_level > current_level:
|
||||
last_alert_time = current.get("last_alert_time", "") or ""
|
||||
last_alert_level_str = current.get("last_alert_level", "") or ""
|
||||
|
||||
if not last_alert_time:
|
||||
# 没有上次告警记录 → 状态升级肯定要告警
|
||||
should_alert = True
|
||||
alert_level = ALERT_LEVELS.get(new_state, "low")
|
||||
reason = f"状态升级(首次告警): {current['state']} → {new_state}"
|
||||
else:
|
||||
last_dt = datetime.fromisoformat(last_alert_time)
|
||||
cooldown_end = last_dt + timedelta(hours=ALERT_COOLDOWN_HOURS)
|
||||
|
||||
# 新级别比上次告警级别高 → 不管冷却期都要告警
|
||||
last_alert_num = STATE_ORDER.get(last_alert_level_str, 0)
|
||||
if new_level > last_alert_num:
|
||||
should_alert = True
|
||||
alert_level = ALERT_LEVELS.get(new_state, "low")
|
||||
reason = f"状态升级: {current['state']} → {new_state}"
|
||||
elif now > cooldown_end.isoformat():
|
||||
# 冷却期过了,可以再告警
|
||||
should_alert = True
|
||||
alert_level = ALERT_LEVELS.get(new_state, "low")
|
||||
reason = f"冷却期结束,重新告警: {new_state}"
|
||||
else:
|
||||
should_alert = False
|
||||
reason = f"冷却期内(上次告警: {last_alert_level_str} @ {last_alert_time})"
|
||||
|
||||
# 状态降级 → 标记为"信号消退"
|
||||
elif new_level < current_level:
|
||||
should_alert = False
|
||||
reason = f"信号消退: {current['state']} → {new_state}"
|
||||
|
||||
# 同级别,分数显著提升(≥3分) → 也告警
|
||||
elif new_level == current_level and score - current["score"] >= 3:
|
||||
last_alert_time = current.get("last_alert_time", "") or ""
|
||||
if not last_alert_time:
|
||||
should_alert = True
|
||||
alert_level = ALERT_LEVELS.get(new_state, "low")
|
||||
reason = f"同级别分数显著提升(首次): {current['score']} → {score}"
|
||||
else:
|
||||
last_dt = datetime.fromisoformat(last_alert_time)
|
||||
cooldown_end = last_dt + timedelta(hours=ALERT_COOLDOWN_HOURS)
|
||||
if now > cooldown_end.isoformat():
|
||||
should_alert = True
|
||||
alert_level = ALERT_LEVELS.get(new_state, "low")
|
||||
reason = f"同级别但分数显著提升: {current['score']} → {score}"
|
||||
|
||||
# 更新记录
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
alert_time = now if should_alert else current.get("last_alert_time", "")
|
||||
alert_lvl = alert_level if should_alert else current.get("last_alert_level", "")
|
||||
conn.execute("""
|
||||
UPDATE coin_state SET state=?, score=?, anomaly_type=?, sector=?,
|
||||
leader_status=?, detail_json=?, last_alert_time=?, last_alert_level=?
|
||||
WHERE symbol=?
|
||||
""", (new_state, score, anomaly_type, sector, leader_status,
|
||||
json.dumps(detail, ensure_ascii=False), alert_time, alert_lvl, symbol))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
else:
|
||||
# 新币种,首次检测
|
||||
# 蓄力级别首次不告警(太多噪音),加速和爆发才告警
|
||||
if STATE_ORDER.get(new_state, 0) >= STATE_ORDER.get("加速", 0):
|
||||
should_alert = True
|
||||
alert_level = ALERT_LEVELS.get(new_state, "low")
|
||||
reason = f"新检测: {new_state}"
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
alert_time = now if should_alert else ""
|
||||
alert_lvl = alert_level if should_alert else ""
|
||||
conn.execute("""
|
||||
INSERT INTO coin_state (symbol, state, score, anomaly_type, sector,
|
||||
leader_status, detected_at, last_alert_time, last_alert_level, detail_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (symbol, new_state, score, anomaly_type, sector, leader_status,
|
||||
now, alert_time, alert_lvl, json.dumps(detail, ensure_ascii=False)))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {"should_alert": should_alert, "alert_level": alert_level, "reason": reason}
|
||||
|
||||
|
||||
def get_all_active():
|
||||
"""获取所有活跃状态(未过期的)"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cutoff = (datetime.now() - timedelta(hours=EXPIRE_HOURS)).isoformat()
|
||||
rows = conn.execute("""
|
||||
SELECT symbol, state, score, anomaly_type, sector, leader_status, detected_at
|
||||
FROM coin_state WHERE detected_at > ? AND state != '过期'
|
||||
ORDER BY score DESC
|
||||
""", (cutoff,)).fetchall()
|
||||
conn.close()
|
||||
|
||||
return [{
|
||||
"symbol": r[0], "state": r[1], "score": r[2],
|
||||
"anomaly_type": r[3], "sector": r[4], "leader_status": r[5],
|
||||
"detected_at": r[6]
|
||||
} for r in rows]
|
||||
|
||||
|
||||
def get_candidates_for_confirm():
|
||||
"""获取加速状态的候选(需要第三层确认的)"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cutoff = (datetime.now() - timedelta(hours=EXPIRE_HOURS)).isoformat()
|
||||
rows = conn.execute("""
|
||||
SELECT symbol, state, score, anomaly_type, sector, leader_status, detail_json
|
||||
FROM coin_state WHERE state IN ('加速', '蓄力') AND detected_at > ?
|
||||
AND score >= 6
|
||||
ORDER BY score DESC
|
||||
""", (cutoff,)).fetchall()
|
||||
conn.close()
|
||||
|
||||
return [{
|
||||
"symbol": r[0], "state": r[1], "score": r[2],
|
||||
"anomaly_type": r[3], "sector": r[4], "leader_status": r[5],
|
||||
"detail": json.loads(r[6]) if r[6] else {},
|
||||
} for r in rows]
|
||||
|
||||
|
||||
def expire_old_states():
|
||||
"""过期超过24h的状态"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cutoff = (datetime.now() - timedelta(hours=EXPIRE_HOURS)).isoformat()
|
||||
conn.execute("UPDATE coin_state SET state='过期' WHERE detected_at < ? AND state != '过期'", (cutoff,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
print("DB初始化完成")
|
||||
|
||||
# 测试状态升级
|
||||
r1 = update_state("FET/USDT", "蓄力", score=3, anomaly_type="布林收窄+量突变", sector="AI_DePIN")
|
||||
print(f"FET 蓄力: alert={r1['should_alert']}, reason={r1['reason']}")
|
||||
|
||||
r2 = update_state("FET/USDT", "加速", score=8, anomaly_type="MACD金叉+RSI拐点", sector="AI_DePIN")
|
||||
print(f"FET 加速: alert={r2['should_alert']}, reason={r2['reason']}")
|
||||
|
||||
r3 = update_state("FET/USDT", "爆发", score=12, anomaly_type="1H放量突破", sector="AI_DePIN")
|
||||
print(f"FET 爁发: alert={r3['should_alert']}, reason={r3['reason']}")
|
||||
|
||||
# 测试同级别不重复
|
||||
r4 = update_state("FET/USDT", "爆发", score=13, anomaly_type="1H放量突破+均线多头", sector="AI_DePIN")
|
||||
print(f"FET 爁发(重复): alert={r4['should_alert']}, reason={r4['reason']}")
|
||||
|
||||
# 测试分数显著提升
|
||||
r5 = update_state("FET/USDT", "爆发", score=16, anomaly_type="三级共振", sector="AI_DePIN")
|
||||
print(f"FET 爁发(分数升3+): alert={r5['should_alert']}, reason={r5['reason']}")
|
||||
|
||||
# 测试新币种首次检测
|
||||
r6 = update_state("PEPE/USDT", "蓄力", score=3, sector="MEME")
|
||||
print(f"PEPE 蓄力(首次): alert={r6['should_alert']}, reason={r6['reason']}")
|
||||
|
||||
r7 = update_state("PEPE/USDT", "加速", score=8, sector="MEME")
|
||||
print(f"PEPE 加速(首次): alert={r7['should_alert']}, reason={r7['reason']}")
|
||||
|
||||
# 查看活跃状态
|
||||
active = get_all_active()
|
||||
print(f"\n活跃状态: {len(active)}个")
|
||||
for a in active:
|
||||
print(f" {a['symbol']}: {a['state']} (score={a['score']})")
|
||||
|
||||
# 查看需要确认的候选
|
||||
candidates = get_candidates_for_confirm()
|
||||
print(f"\n需要确认的候选: {len(candidates)}个")
|
||||
@ -1,209 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
山寨币实时价格监控 — ccxt REST 高频轮询版。
|
||||
|
||||
职责边界:本进程只做实时价格采集和“候选状态”提交;最终状态落库、是否推送、推送价格口径
|
||||
全部由 altcoin_db.apply_recommendation_state_transition 主链路决定。
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import ccxt
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from app.db.altcoin_db import (
|
||||
init_db,
|
||||
get_active_recommendations_deduped,
|
||||
update_recommendation_tracking,
|
||||
apply_recommendation_state_transition,
|
||||
should_push,
|
||||
log_push,
|
||||
)
|
||||
from app.integrations.feishu_push import push_altcoin_tp_sl_alert
|
||||
from app.core.opportunity_lifecycle import apply_entry_quality_gate
|
||||
|
||||
POLL_INTERVAL = 5
|
||||
REFRESH_INTERVAL = 60
|
||||
BATCH_SIZE = 50
|
||||
|
||||
exchange = ccxt.binance({"enableRateLimit": True})
|
||||
last_refresh = 0
|
||||
active_map = {}
|
||||
|
||||
|
||||
def load_active():
|
||||
try:
|
||||
recs = get_active_recommendations_deduped(actionable_only=False)
|
||||
# 只监控 active 最新去重记录;结案记录由主链路/网站展示,不在实时入口反复触发。
|
||||
return {r["symbol"]: r for r in recs if r.get("symbol") and r.get("status") == "active"}
|
||||
except Exception as e:
|
||||
print(f"[{datetime.now():%H:%M:%S}] 加载推荐失败: {e}", flush=True)
|
||||
return {}
|
||||
|
||||
|
||||
def check_triggers(symbol, rec, current_price):
|
||||
"""向后兼容旧测试:结案记录不得再产生入场触发。"""
|
||||
if rec.get("status") != "active":
|
||||
return None
|
||||
action, signals = detect_candidate_action(rec, current_price, {})
|
||||
if not action:
|
||||
return None
|
||||
return {"action_status": action, "signals": signals, "pushable": action == "可即刻买入"}
|
||||
|
||||
|
||||
def detect_candidate_action(rec, current_price, track_result):
|
||||
"""仅产生候选状态;不得在这里推送或落最终状态。"""
|
||||
terminal_action = {
|
||||
"hit_tp1": "止盈1",
|
||||
"hit_tp2": "止盈2",
|
||||
"stopped_out": "止损",
|
||||
}.get((track_result or {}).get("status"))
|
||||
if terminal_action:
|
||||
return terminal_action, [f"状态机检测到{terminal_action}"]
|
||||
|
||||
ep = rec.get("entry_plan") or {}
|
||||
entry_action = ep.get("entry_action", "") or rec.get("initial_action", "")
|
||||
plan_entry_price = ep.get("entry_price", 0) or 0
|
||||
entry_price = rec.get("entry_price", 0) or 0
|
||||
cur_action = rec.get("action_status", "持有")
|
||||
|
||||
candidate_action = None
|
||||
candidate_signals = []
|
||||
if entry_action == "等回踩" and plan_entry_price > 0 and current_price <= plan_entry_price:
|
||||
ep["entry_trigger_confirmed"] = True
|
||||
candidate_action = "可即刻买入"
|
||||
candidate_signals = [f"回踩到位 ${plan_entry_price:.6f}"]
|
||||
elif entry_action in ("即刻买入", "可即刻买入") and current_price <= (plan_entry_price or entry_price):
|
||||
ep["entry_trigger_confirmed"] = True
|
||||
candidate_action = "可即刻买入"
|
||||
candidate_signals = ["入场条件仍满足"]
|
||||
elif cur_action == "可即刻买入":
|
||||
candidate_action = "可即刻买入"
|
||||
candidate_signals = ["入场窗口延续"]
|
||||
|
||||
if candidate_action:
|
||||
raw_signals = rec.get("signals", [])
|
||||
if isinstance(raw_signals, str):
|
||||
try:
|
||||
raw_signals = json.loads(raw_signals)
|
||||
except Exception:
|
||||
raw_signals = [raw_signals] if raw_signals else []
|
||||
gated_action, gated_plan, reasons = apply_entry_quality_gate(
|
||||
action_status=candidate_action,
|
||||
entry_plan=ep,
|
||||
signals=raw_signals,
|
||||
current_price=current_price,
|
||||
market_context=rec.get("market_context") or {},
|
||||
derivatives_context=rec.get("derivatives_context") or {},
|
||||
sector_context=rec.get("sector_context") or {},
|
||||
)
|
||||
if gated_action != "可即刻买入":
|
||||
reason = reasons[0] if reasons else "买点质量闸门降级"
|
||||
return gated_action, [reason]
|
||||
return gated_action, candidate_signals
|
||||
return None, []
|
||||
|
||||
|
||||
def fetch_prices(symbols):
|
||||
all_tickers = {}
|
||||
for i in range(0, len(symbols), BATCH_SIZE):
|
||||
batch = symbols[i:i + BATCH_SIZE]
|
||||
try:
|
||||
tickers = exchange.fetch_tickers(batch)
|
||||
for s in batch:
|
||||
if s in tickers:
|
||||
all_tickers[s] = tickers[s]
|
||||
except Exception as e:
|
||||
print(f"[{datetime.now():%H:%M:%S}] 拉取价格失败(批次{i//BATCH_SIZE}): {e}", flush=True)
|
||||
time.sleep(1)
|
||||
return all_tickers
|
||||
|
||||
|
||||
def maybe_push_from_decision(symbol, decision):
|
||||
"""推送层只消费主链路 decision,不自行判断状态。"""
|
||||
action = decision.get("action_status")
|
||||
if not decision.get("push_required"):
|
||||
return
|
||||
if not should_push(symbol, "entry", action):
|
||||
print(f"[{datetime.now():%H:%M:%S}] ⏭ 跳过推送 {symbol}: entry/{action} 冷却中", flush=True)
|
||||
return
|
||||
push_altcoin_tp_sl_alert(
|
||||
decision["push_symbol"],
|
||||
decision["push_current_price"],
|
||||
decision["push_entry_price"],
|
||||
decision["push_pnl_pct"],
|
||||
action,
|
||||
decision.get("push_signals", []),
|
||||
decision.get("stop_loss", 0),
|
||||
decision.get("tp1", 0),
|
||||
decision.get("tp2", 0),
|
||||
)
|
||||
log_push(symbol, "entry", action, rec_id=decision.get("id", 0))
|
||||
print(f"[{datetime.now():%H:%M:%S}] 📲 {symbol} → {action} (盈亏{decision['push_pnl_pct']}%)", flush=True)
|
||||
|
||||
|
||||
def main_loop():
|
||||
global active_map, last_refresh
|
||||
init_db()
|
||||
active_map = load_active()
|
||||
last_refresh = time.time()
|
||||
print(f"[{datetime.now():%H:%M:%S}] 🚀 实时价格监控启动,活跃: {len(active_map)}只,轮询间隔{POLL_INTERVAL}s", flush=True)
|
||||
|
||||
while True:
|
||||
loop_start = time.time()
|
||||
if loop_start - last_refresh >= REFRESH_INTERVAL:
|
||||
active_map = load_active()
|
||||
last_refresh = loop_start
|
||||
|
||||
if not active_map:
|
||||
time.sleep(POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
tickers = fetch_prices(list(active_map.keys()))
|
||||
for symbol, rec in list(active_map.items()):
|
||||
ticker = tickers.get(symbol)
|
||||
if not ticker:
|
||||
continue
|
||||
current_price = ticker.get("last", 0)
|
||||
if current_price <= 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
track_result = update_recommendation_tracking(rec["id"], current_price) or {}
|
||||
except Exception as e:
|
||||
print(f"[{datetime.now():%H:%M:%S}] 跟踪价格失败 {symbol}: {e}", flush=True)
|
||||
continue
|
||||
|
||||
action, signals = detect_candidate_action(rec, current_price, track_result)
|
||||
if not action:
|
||||
continue
|
||||
|
||||
decision = apply_recommendation_state_transition(
|
||||
rec["id"],
|
||||
requested_action=action,
|
||||
current_price=current_price,
|
||||
event_time=datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
signals=signals,
|
||||
)
|
||||
if decision.get("action_status") in ("止盈1", "止盈2", "止损"):
|
||||
active_map.pop(symbol, None)
|
||||
else:
|
||||
rec["action_status"] = decision.get("action_status", rec.get("action_status"))
|
||||
rec["entry_price"] = decision.get("entry_price", rec.get("entry_price"))
|
||||
rec["current_price"] = decision.get("current_price", current_price)
|
||||
rec["pnl_pct"] = decision.get("pnl_pct", rec.get("pnl_pct"))
|
||||
active_map[symbol] = rec
|
||||
|
||||
try:
|
||||
maybe_push_from_decision(symbol, decision)
|
||||
except Exception as e:
|
||||
print(f"[{datetime.now():%H:%M:%S}] 推送失败 {symbol}: {e}", flush=True)
|
||||
|
||||
elapsed = time.time() - loop_start
|
||||
time.sleep(max(0.5, POLL_INTERVAL - elapsed))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main_loop()
|
||||
@ -1,330 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>山寨币爆发监控</title>
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { background:#0a0a0f; color:#e0e0e0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; min-height:100vh; }
|
||||
|
||||
/* 顶部导航 */
|
||||
.nav { background:#141420; padding:12px 16px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid #2a2a3a; position:sticky; top:0; z-index:100; }
|
||||
.nav h1 { font-size:18px; color:#fff; }
|
||||
.nav .refresh-btn { background:none; border:1px solid #4a4a5a; color:#aaa; padding:6px 12px; border-radius:6px; font-size:13px; cursor:pointer; }
|
||||
.nav .refresh-btn:active { background:#2a2a3a; }
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:8px; padding:12px 16px; }
|
||||
.stat-card { background:#141420; border-radius:10px; padding:12px; text-align:center; }
|
||||
.stat-card .label { font-size:11px; color:#888; margin-bottom:4px; }
|
||||
.stat-card .value { font-size:20px; font-weight:bold; }
|
||||
.stat-card .value.green { color:#00d4aa; }
|
||||
.stat-card .value.red { color:#ff4444; }
|
||||
.stat-card .value.yellow { color:#ffaa00; }
|
||||
.stat-card .value.blue { color:#4488ff; }
|
||||
|
||||
/* Tab切换 */
|
||||
.tabs { display:flex; padding:0 16px; background:#141420; border-bottom:1px solid #2a2a3a; }
|
||||
.tab { padding:10px 16px; font-size:14px; color:#888; cursor:pointer; border-bottom:2px solid transparent; flex:1; text-align:center; }
|
||||
.tab.active { color:#fff; border-bottom:2px solid #4488ff; }
|
||||
|
||||
/* 内容区 */
|
||||
.content { padding:12px 16px; }
|
||||
.section-title { font-size:14px; color:#888; margin:12px 0 8px; padding-left:4px; }
|
||||
|
||||
/* 推荐卡片 */
|
||||
.rec-card { background:#1a1a28; border-radius:12px; padding:14px; margin-bottom:10px; border-left:4px solid; }
|
||||
.rec-card.burst { border-left-color:#ff4444; }
|
||||
.rec-card.accel { border-left-color:#ffaa00; }
|
||||
.rec-card.gather { border-left-color:#4488ff; }
|
||||
.rec-card.closed-profit { border-left-color:#00d4aa; }
|
||||
.rec-card.closed-loss { border-left-color:#ff4444; }
|
||||
.rec-card.closed-expired { border-left-color:#666; }
|
||||
|
||||
.rec-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
|
||||
.rec-symbol { font-size:16px; font-weight:bold; color:#fff; }
|
||||
.rec-state { font-size:12px; padding:3px 8px; border-radius:4px; font-weight:bold; }
|
||||
.rec-state.burst { background:#ff4444; color:#fff; }
|
||||
.rec-state.accel { background:#ffaa00; color:#000; }
|
||||
.rec-state.gather { background:#4488ff; color:#fff; }
|
||||
.rec-state.closed-profit { background:#00d4aa; color:#000; }
|
||||
.rec-state.closed-loss { background:#ff4444; color:#fff; }
|
||||
.rec-state.closed-expired { background:#666; color:#fff; }
|
||||
.rec-state.tp1_hit { background:#00d4aa; color:#000; }
|
||||
|
||||
.rec-price { font-size:14px; margin-bottom:6px; }
|
||||
.rec-price .current { color:#fff; font-weight:bold; }
|
||||
.rec-price .change { font-size:13px; }
|
||||
.rec-price .change.pos { color:#00d4aa; }
|
||||
.rec-price .change.neg { color:#ff4444; }
|
||||
|
||||
.rec-details { font-size:12px; color:#888; line-height:1.6; }
|
||||
.rec-details span { color:#aaa; }
|
||||
.rec-signals { margin-top:6px; }
|
||||
.rec-signal { display:inline-block; background:#2a2a3a; padding:2px 8px; border-radius:4px; font-size:11px; color:#ccc; margin:2px 2px; }
|
||||
|
||||
/* 入场方案卡片 */
|
||||
.entry-card { background:#1e1e2e; border-radius:8px; padding:10px; margin-top:8px; }
|
||||
.entry-title { font-size:12px; color:#4488ff; margin-bottom:4px; }
|
||||
.entry-row { display:flex; justify-content:space-between; font-size:12px; padding:2px 0; }
|
||||
.entry-row .key { color:#888; }
|
||||
.entry-row .val { color:#fff; }
|
||||
.entry-row .val.green { color:#00d4aa; }
|
||||
.entry-row .val.red { color:#ff4444; }
|
||||
|
||||
/* PnL条 */
|
||||
.pnl-bar { height:6px; border-radius:3px; background:#2a2a3a; margin-top:8px; position:relative; overflow:hidden; }
|
||||
.pnl-bar .fill { height:100%; border-radius:3px; }
|
||||
.pnl-bar .fill.green { background:#00d4aa; }
|
||||
.pnl-bar .fill.red { background:#ff4444; }
|
||||
|
||||
/* 空状态 */
|
||||
.empty { text-align:center; padding:40px; color:#666; font-size:14px; }
|
||||
|
||||
/* 最后更新时间 */
|
||||
.last-update { text-align:center; font-size:11px; color:#666; padding:8px 0 20px; }
|
||||
|
||||
/* 动画 */
|
||||
@keyframes fadeIn { from{opacity:0;transform:translateY(10px)} to{opacity:1;transform:translateY(0)} }
|
||||
.rec-card { animation: fadeIn 0.3s ease; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="nav">
|
||||
<h1>🚀 山寨币监控</h1>
|
||||
<button class="refresh-btn" onclick="loadData()">刷新</button>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid" id="stats-grid"></div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="recommendations" onclick="switchTab('recommendations')">🔥 推荐</div>
|
||||
<div class="tab" data-tab="candidates" onclick="switchTab('candidates')">📋 候选</div>
|
||||
<div class="tab" data-tab="history" onclick="switchTab('history')">📜 历史</div>
|
||||
</div>
|
||||
|
||||
<div class="content" id="tab-content"></div>
|
||||
|
||||
<div class="last-update" id="last-update"></div>
|
||||
|
||||
<script>
|
||||
let currentTab = 'recommendations';
|
||||
let dashboardData = null;
|
||||
|
||||
// 自动刷新
|
||||
setInterval(loadData, 60000); // 1分钟刷新
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const resp = await fetch('/api/dashboard');
|
||||
dashboardData = await resp.json();
|
||||
renderStats();
|
||||
renderTab();
|
||||
const t = dashboardData.latest_scan_time || '--';
|
||||
document.getElementById('last-update').textContent = '最后更新: ' + formatTime(t);
|
||||
} catch(e) {
|
||||
console.error('加载失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const s = dashboardData.stats || {};
|
||||
const total = s.total_recs || 0;
|
||||
const wins = s.wins || 0;
|
||||
const losses = s.losses || 0;
|
||||
const active = s.active || 0;
|
||||
const avgPnl = s.avg_pnl || 0;
|
||||
const winRate = total > 0 ? Math.round(wins/(wins+losses)*100) : 0;
|
||||
|
||||
document.getElementById('stats-grid').innerHTML = `
|
||||
<div class="stat-card"><div class="label">活跃推荐</div><div class="value blue">${active}</div></div>
|
||||
<div class="stat-card"><div class="label">胜率</div><div class="value ${winRate>=50?'green':'red'}">${wins+losses>0?winRate+'%':'--'}</div></div>
|
||||
<div class="stat-card"><div class="label">平均盈亏</div><div class="value ${avgPnl>=0?'green':'red'}">${avgPnl>0?'+'+avgPnl:avgPnl}%</div></div>
|
||||
<div class="stat-card"><div class="label">总推荐数</div><div class="value yellow">${total}</div></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
document.querySelectorAll('.tab').forEach(t => {
|
||||
t.classList.toggle('active', t.dataset.tab === tab);
|
||||
});
|
||||
renderTab();
|
||||
}
|
||||
|
||||
function renderTab() {
|
||||
const container = document.getElementById('tab-content');
|
||||
if (!dashboardData) { container.innerHTML = '<div class="empty">加载中...</div>'; return; }
|
||||
|
||||
if (currentTab === 'recommendations') {
|
||||
renderRecommendations(container);
|
||||
} else if (currentTab === 'candidates') {
|
||||
renderCandidates(container);
|
||||
} else if (currentTab === 'history') {
|
||||
renderHistory(container);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRecommendations(container) {
|
||||
const active = dashboardData.active_recommendations || [];
|
||||
const closed = dashboardData.closed_recommendations || [];
|
||||
|
||||
if (active.length === 0 && closed.length === 0) {
|
||||
container.innerHTML = '<div class="empty">暂无推荐 — 等待爆发信号确认</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
if (active.length > 0) {
|
||||
html += '<div class="section-title">🔥 活跃推荐</div>';
|
||||
active.forEach(r => { html += renderRecCard(r); });
|
||||
}
|
||||
if (closed.length > 0) {
|
||||
html += '<div class="section-title">📜 已关闭</div>';
|
||||
closed.forEach(r => { html += renderRecCard(r); });
|
||||
}
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderCandidates(container) {
|
||||
const cands = dashboardData.latest_candidates || [];
|
||||
if (cands.length === 0) {
|
||||
container.innerHTML = '<div class="empty">暂无候选 — 等待下次筛选</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="section-title">最近筛选结果 (' + formatTime(dashboardData.latest_scan_time) + ')</div>';
|
||||
cands.forEach(c => {
|
||||
const stateClass = c.state === '加速' ? 'accel' : 'gather';
|
||||
const stateTag = c.state === '加速' ? '🔥🔥加速' : '🔥蓄力';
|
||||
const tagClass = c.state === '加速' ? 'accel' : 'gather';
|
||||
const signals = (c.signals||'').split(',').filter(Boolean);
|
||||
const isMeme = c.is_meme ? ' 🎮MEME' : '';
|
||||
const change24h = c.change_24h || 0;
|
||||
const changeClass = change24h >= 0 ? 'pos' : 'neg';
|
||||
const changeSign = change24h >= 0 ? '+' : '';
|
||||
|
||||
html += `<div class="rec-card ${stateClass}">
|
||||
<div class="rec-header">
|
||||
<div class="rec-symbol">${c.symbol.replace('/USDT','')}${isMeme}</div>
|
||||
<div class="rec-state ${tagClass}">${stateTag} 评分${c.score}</div>
|
||||
</div>
|
||||
<div class="rec-price">
|
||||
<span class="current">$${(c.price_at_scan||0).toFixed(4)}</span>
|
||||
<span class="change ${changeClass}">${changeSign}${change24h.toFixed(1)}%/24h</span>
|
||||
</div>
|
||||
<div class="rec-details">
|
||||
${c.sector?'<span>板块:'+c.sector+'</span>':''}
|
||||
${c.leader_status?'<span>'+c.leader_status+'</span>':''}
|
||||
</div>
|
||||
<div class="rec-signals">${signals.map(s=>'<span class="rec-signal">'+s+'</span>').join('')}</div>
|
||||
</div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderHistory(container) {
|
||||
const scans = dashboardData.scan_stats || [];
|
||||
if (scans.length === 0) {
|
||||
container.innerHTML = '<div class="empty">暂无筛选历史</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="section-title">筛选历史 (最近24h)</div>';
|
||||
scans.forEach(s => {
|
||||
html += `<div class="rec-card gather" style="border-left-color:#4488ff">
|
||||
<div class="rec-header">
|
||||
<div class="rec-symbol" style="font-size:14px">${formatTime(s.scan_time)}</div>
|
||||
<div class="rec-state gather">${s.count}个候选</div>
|
||||
</div>
|
||||
<div class="rec-details">
|
||||
<span>加速:${s.accelerating||0}</span> | <span>蓄力:${s.gathering||0}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderRecCard(r) {
|
||||
const status = r.status || 'active';
|
||||
let cardClass = 'gather';
|
||||
let stateTag = '🔥蓄力';
|
||||
let tagClass = 'gather';
|
||||
|
||||
if (status === 'active') { cardClass = 'accel'; stateTag = '🔥🔥加速'; tagClass = 'accel'; }
|
||||
else if (status === 'tp1_hit') { cardClass = 'burst'; stateTag = '✅TP1触发'; tagClass = 'tp1_hit'; }
|
||||
else if (status === 'closed_profit') { cardClass = 'closed-profit'; stateTag = '✅盈利'; tagClass = 'closed-profit'; }
|
||||
else if (status === 'closed_loss') { cardClass = 'closed-loss'; stateTag = '❌止损'; tagClass = 'closed-loss'; }
|
||||
else if (status === 'closed_expired') { cardClass = 'closed-expired'; stateTag = '⏰过期'; tagClass = 'closed-expired'; }
|
||||
|
||||
const pnl = r.pnl_pct || 0;
|
||||
const pnlClass = pnl >= 0 ? 'green' : 'red';
|
||||
const pnlSign = pnl >= 0 ? '+' : '';
|
||||
const currentPrice = r.current_price || r.recommended_price || 0;
|
||||
const maxProfit = r.max_profit_pct || 0;
|
||||
const maxLoss = r.max_loss_pct || 0;
|
||||
|
||||
const signals = (r.signals||'').split(',').filter(Boolean);
|
||||
|
||||
let entryHtml = '';
|
||||
if (r.entry_price > 0 && (status === 'active' || status === 'tp1_hit')) {
|
||||
const rr1 = r.rr1 || 0;
|
||||
const rr2 = r.rr2 || 0;
|
||||
entryHtml = `<div class="entry-card">
|
||||
<div class="entry-title">📊 入场方案</div>
|
||||
<div class="entry-row"><span class="key">入场</span><span class="val">$${r.entry_price.toFixed(4)}</span></div>
|
||||
<div class="entry-row"><span class="key">止损</span><span class="val red">$${r.stop_loss.toFixed(4)} (${r.stop_pct}%)</span></div>
|
||||
<div class="entry-row"><span class="key">TP1</span><span class="val green">$${r.tp1.toFixed(4)} (RR=${rr1})</span></div>
|
||||
<div class="entry-row"><span class="key">TP2</span><span class="val green">$${r.tp2.toFixed(4)} (RR=${rr2})</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// PnL条
|
||||
let pnlBarHtml = '';
|
||||
if (r.recommended_price > 0) {
|
||||
const pnlAbs = Math.abs(pnl);
|
||||
const barWidth = Math.min(pnlAbs * 5, 100); // 1% = 5px宽,max 100%
|
||||
pnlBarHtml = `<div class="pnl-bar"><div class="fill ${pnlClass}" style="width:${barWidth}%"></div></div>`;
|
||||
}
|
||||
|
||||
const closeInfo = r.close_reason ? `<div class="rec-details" style="margin-top:4px"><span>${r.close_reason}</span></div>` : '';
|
||||
|
||||
return `<div class="rec-card ${cardClass}">
|
||||
<div class="rec-header">
|
||||
<div class="rec-symbol">${r.symbol.replace('/USDT','')}</div>
|
||||
<div class="rec-state ${tagClass}">${stateTag}</div>
|
||||
</div>
|
||||
<div class="rec-price">
|
||||
<span class="current">$${currentPrice.toFixed(4)}</span>
|
||||
<span class="change ${pnlClass}">${pnlSign}${pnl.toFixed(2)}%</span>
|
||||
</div>
|
||||
<div class="rec-details">
|
||||
推荐价: $${(r.recommended_price||0).toFixed(4)} | 最高: +${maxProfit.toFixed(2)}% | 最低: ${maxLoss.toFixed(2)}%
|
||||
${r.sector?' | 板块:'+r.sector:''}
|
||||
</div>
|
||||
${closeInfo}
|
||||
<div class="rec-signals">${signals.map(s=>'<span class="rec-signal">'+s+'</span>').join('')}</div>
|
||||
${entryHtml}
|
||||
${pnlBarHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function formatTime(t) {
|
||||
if (!t || t === '--') return '--';
|
||||
try {
|
||||
const d = new Date(t);
|
||||
const month = (d.getMonth()+1).toString().padStart(2,'0');
|
||||
const day = d.getDate().toString().padStart(2,'0');
|
||||
const hour = d.getHours().toString().padStart(2,'0');
|
||||
const min = d.getMinutes().toString().padStart(2,'0');
|
||||
return `${month}-${day} ${hour}:${min}`;
|
||||
} catch(e) { return t; }
|
||||
}
|
||||
|
||||
// 首次加载
|
||||
loadData();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -6,7 +6,6 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from app.core.opportunity_lifecycle import apply_entry_quality_gate
|
||||
from app.services.price_tracker import reconcile_buy_signals_after_gate
|
||||
from legacy import price_tracker_ws
|
||||
|
||||
|
||||
def test_risk_reward_false_blocks_buy_now():
|
||||
@ -184,27 +183,21 @@ def test_wait_pullback_too_far_above_breakout_degrades_to_observe():
|
||||
|
||||
|
||||
def test_ws_tracker_does_not_push_when_gate_downgrades_buy_now():
|
||||
rec = {
|
||||
'id': 1,
|
||||
'symbol': 'WLFI/USDT',
|
||||
'status': 'active',
|
||||
'entry_price': 0.0758,
|
||||
'stop_loss': 0.070,
|
||||
'tp1': 0.080,
|
||||
'tp2': 0.085,
|
||||
'entry_plan': {
|
||||
action, plan, reasons = apply_entry_quality_gate(
|
||||
action_status='可即刻买入',
|
||||
entry_plan={
|
||||
'entry_action': '等回踩',
|
||||
'entry_price': 0.072,
|
||||
'risk_reward_ok': False,
|
||||
'rr1': 0.4,
|
||||
'stop_loss': 0.07,
|
||||
'tp1': 0.08,
|
||||
'tp2': 0.085,
|
||||
},
|
||||
'signals': json.dumps(['1H 起爆点↑(强度56×)', '⚠️ 等回踩降权(-3分)'], ensure_ascii=False),
|
||||
'market_context': {'change_24h': 9.0},
|
||||
'derivatives_context': {},
|
||||
'sector_context': {},
|
||||
'action_status': '持有',
|
||||
}
|
||||
trigger = price_tracker_ws.check_triggers('WLFI/USDT', rec, 0.0719)
|
||||
assert trigger is not None
|
||||
assert trigger['action_status'] != '可即刻买入'
|
||||
assert trigger['pushable'] is False
|
||||
signals=['1H 起爆点↑(强度56×)', '⚠️ 等回踩降权(-3分)'],
|
||||
current_price=0.0719,
|
||||
market_context={'change_24h': 9.0},
|
||||
)
|
||||
assert action in ('等回踩', '观察')
|
||||
assert action != '可即刻买入'
|
||||
assert any('risk_reward_ok=false' in r for r in reasons)
|
||||
|
||||
@ -5,8 +5,8 @@ PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_DIR not in sys.path:
|
||||
sys.path.insert(0, PROJECT_DIR)
|
||||
|
||||
from app.core.opportunity_lifecycle import apply_entry_quality_gate
|
||||
from app.db import altcoin_db
|
||||
from legacy import price_tracker_ws
|
||||
|
||||
|
||||
def test_terminal_recommendation_action_status_cannot_be_overwritten_by_entry_signal(monkeypatch, tmp_path):
|
||||
@ -37,16 +37,15 @@ def test_terminal_recommendation_action_status_cannot_be_overwritten_by_entry_si
|
||||
|
||||
|
||||
def test_ws_tracker_does_not_emit_entry_signal_for_closed_recommendation():
|
||||
rec = {
|
||||
"status": "hit_tp1",
|
||||
"entry_price": 0.000619,
|
||||
"stop_loss": 0.000521,
|
||||
"tp1": 0.000684,
|
||||
"tp2": 0.000726,
|
||||
"entry_plan": {"entry_action": "等回踩", "entry_price": 0.000628},
|
||||
"action_status": "止盈1",
|
||||
}
|
||||
assert price_tracker_ws.check_triggers("NOT/USDT", rec, 0.000628) is None
|
||||
action, plan, reasons = apply_entry_quality_gate(
|
||||
action_status="可即刻买入",
|
||||
entry_plan={"entry_action": "等回踩", "entry_price": 0.000628, "stop_loss": 0.000521, "tp1": 0.000684, "rr1": 1.8},
|
||||
signals=["15min 即刻入场信号"],
|
||||
current_price=0.000628,
|
||||
)
|
||||
assert action in ("等回踩", "观察")
|
||||
assert action != "可即刻买入"
|
||||
assert plan["entry_quality_gate"]["blocked_action"] == "可即刻买入"
|
||||
|
||||
|
||||
def test_watch_pool_tracking_does_not_mark_stopped_out(monkeypatch, tmp_path):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user