1
This commit is contained in:
parent
1390ae1e9f
commit
5986c239eb
@ -131,6 +131,16 @@ def to_float(value: Any, default: float = 0.0) -> float:
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def calc_rr_target_entry(stop_loss: float, tp1: float, min_rr: float) -> float:
|
||||||
|
"""最高允许入场价:在该价格或更低买入,RR1 才能达到 min_rr。"""
|
||||||
|
stop_loss = to_float(stop_loss)
|
||||||
|
tp1 = to_float(tp1)
|
||||||
|
min_rr = to_float(min_rr)
|
||||||
|
if stop_loss <= 0 or tp1 <= stop_loss or min_rr <= 0:
|
||||||
|
return 0.0
|
||||||
|
return round((tp1 + min_rr * stop_loss) / (1 + min_rr), 8)
|
||||||
|
|
||||||
|
|
||||||
def detect_breakout_distance_pct(signals: Iterable[Any]) -> float:
|
def detect_breakout_distance_pct(signals: Iterable[Any]) -> float:
|
||||||
"""从“站稳突破位 +66.7%”等信号中提取最大追高距离。"""
|
"""从“站稳突破位 +66.7%”等信号中提取最大追高距离。"""
|
||||||
max_pct = 0.0
|
max_pct = 0.0
|
||||||
@ -320,6 +330,19 @@ def apply_entry_quality_gate(
|
|||||||
if action_status == "等回踩" and current_price > 0 and to_float(entry_plan.get("entry_price")) > 0 and current_price <= to_float(entry_plan.get("entry_price")) * 1.003 and (risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now")):
|
if action_status == "等回踩" and current_price > 0 and to_float(entry_plan.get("entry_price")) > 0 and current_price <= to_float(entry_plan.get("entry_price")) * 1.003 and (risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now")):
|
||||||
target_action = "观察"
|
target_action = "观察"
|
||||||
reasons.append("回踩参考已到,但实时盈亏比不达标,转为观察")
|
reasons.append("回踩参考已到,但实时盈亏比不达标,转为观察")
|
||||||
|
elif action_status == "可即刻买入" and current_price > 0 and stop_loss > 0 and tp1 > stop_loss and (risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now")):
|
||||||
|
rr_target_entry = calc_rr_target_entry(stop_loss, tp1, _cfg_value(cfg, "min_rr_buy_now"))
|
||||||
|
if rr_target_entry > stop_loss and rr_target_entry < current_price * 0.997:
|
||||||
|
target_action = "等回踩"
|
||||||
|
entry_plan["entry_price"] = rr_target_entry
|
||||||
|
entry_plan["entry_method"] = f"等回踩至可买RR价 {rr_target_entry:.8g}"
|
||||||
|
entry_plan["entry_action"] = "等回踩"
|
||||||
|
entry_plan["rr_target_entry"] = rr_target_entry
|
||||||
|
entry_plan["rr_target_reason"] = f"现价RR不足,需回落到该价或更低,RR1才≥{_cfg_value(cfg, 'min_rr_buy_now')}"
|
||||||
|
reasons.append(f"现价不买,等回落到{rr_target_entry:.8g}附近再评估")
|
||||||
|
else:
|
||||||
|
target_action = "观察"
|
||||||
|
reasons.append("无法给出有效回踩买点,转为观察")
|
||||||
else:
|
else:
|
||||||
# risk_reward_ok=false / rr1不足 / 追高距离过远 都代表“现价买入被禁止”;
|
# risk_reward_ok=false / rr1不足 / 追高距离过远 都代表“现价买入被禁止”;
|
||||||
# 展示层必须降级为“等回踩/观察”,否则会出现“闸门禁止买入但仍显示入场窗口”的矛盾。
|
# 展示层必须降级为“等回踩/观察”,否则会出现“闸门禁止买入但仍显示入场窗口”的矛盾。
|
||||||
|
|||||||
@ -1612,11 +1612,30 @@ def update_state(symbol, new_state, score=0, anomaly_type="", sector="",
|
|||||||
|
|
||||||
|
|
||||||
def get_candidates_for_confirm():
|
def get_candidates_for_confirm():
|
||||||
"""获取需要确认层检查的候选(加速状态+score≥5)"""
|
"""获取需要确认层检查的候选。
|
||||||
|
|
||||||
|
优先处理最近一轮粗筛/细筛刚更新的候选,避免旧 coin_state 中的高分候选
|
||||||
|
抢占确认层,导致链路日志里“细筛通过”和“确认处理”对不上。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_, _, accumulate_threshold = state_score_thresholds()
|
||||||
|
except Exception:
|
||||||
|
accumulate_threshold = 3
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
rows = conn.execute("""
|
rows = conn.execute("""
|
||||||
SELECT * FROM coin_state WHERE state IN ('加速', '蓄力') AND score >= 5
|
SELECT * FROM coin_state
|
||||||
""").fetchall()
|
WHERE state IN ('加速', '蓄力')
|
||||||
|
AND score >= ?
|
||||||
|
AND julianday(?) - julianday(detected_at) <= ?
|
||||||
|
ORDER BY detected_at DESC, score DESC
|
||||||
|
""", (accumulate_threshold, datetime.now().isoformat(), 45 / 1440.0)).fetchall()
|
||||||
|
if not rows:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT * FROM coin_state
|
||||||
|
WHERE state IN ('加速', '蓄力')
|
||||||
|
AND score >= 5
|
||||||
|
ORDER BY detected_at DESC, score DESC
|
||||||
|
""").fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"""Analytics-facing DB API grouped by read concerns."""
|
"""Analytics-facing DB API grouped by read concerns."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from app.db.altcoin_db import (
|
from app.db.altcoin_db import (
|
||||||
_classify_recommendation_result,
|
_classify_recommendation_result,
|
||||||
@ -38,6 +38,37 @@ def _loads_json(value, fallback):
|
|||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(value, default=0):
|
||||||
|
try:
|
||||||
|
return int(value or 0)
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(value, default=0.0):
|
||||||
|
try:
|
||||||
|
return float(value or 0)
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_dt(value):
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(str(value).replace("Z", "+00:00")).replace(tzinfo=None)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value):
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.isoformat(timespec="seconds")
|
||||||
|
return str(value or "")
|
||||||
|
|
||||||
|
|
||||||
def get_observation_candidates(limit=50):
|
def get_observation_candidates(limit=50):
|
||||||
"""Return current coarse-screen observation candidates for the watch pool."""
|
"""Return current coarse-screen observation candidates for the watch pool."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
@ -750,11 +781,369 @@ def get_cron_run_summary(hours=24):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pipeline_window(run, next_run_start=None):
|
||||||
|
started = _parse_dt(run.get("started_at")) or datetime.now()
|
||||||
|
finished = _parse_dt(run.get("finished_at")) or started
|
||||||
|
if finished < started:
|
||||||
|
finished = started
|
||||||
|
window_start = started - timedelta(minutes=10)
|
||||||
|
window_end = finished + timedelta(minutes=30)
|
||||||
|
next_started = _parse_dt(next_run_start)
|
||||||
|
if next_started and next_started > finished:
|
||||||
|
window_end = min(window_end, next_started - timedelta(seconds=1))
|
||||||
|
return window_start, window_end
|
||||||
|
|
||||||
|
|
||||||
|
def _cron_item(row):
|
||||||
|
item = dict(row)
|
||||||
|
item["summary_json"] = _loads_json(item.get("summary_json"), {})
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _screening_item(row):
|
||||||
|
item = dict(row)
|
||||||
|
item["signals"] = _loads_json(item.get("signals"), [])
|
||||||
|
item["detail_json"] = _loads_json(item.get("detail_json"), {})
|
||||||
|
if item.get("layer") == "细筛":
|
||||||
|
item["stage_bucket"] = "fine"
|
||||||
|
item["stage_label"] = "细筛通过"
|
||||||
|
elif item.get("layer") == "确认":
|
||||||
|
item["stage_bucket"] = "confirm"
|
||||||
|
item["stage_label"] = "确认记录"
|
||||||
|
else:
|
||||||
|
item["stage_bucket"] = "coarse"
|
||||||
|
item["stage_label"] = "观察候选"
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _recommendation_item(row):
|
||||||
|
item = dict(row)
|
||||||
|
item["signals"] = _loads_json(item.get("signals"), [])
|
||||||
|
item["signal_codes"] = _loads_json(item.get("signal_codes_json"), [])
|
||||||
|
item["signal_labels"] = _loads_json(item.get("signal_labels_json"), [])
|
||||||
|
item["entry_plan"] = _loads_json(item.get("entry_plan_json"), {})
|
||||||
|
rec_result, rec_result_label = _classify_recommendation_result(item)
|
||||||
|
item["recommendation_result"] = rec_result
|
||||||
|
item["recommendation_result_label"] = rec_result_label
|
||||||
|
_derive_execution_fields(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _review_item(row):
|
||||||
|
item = dict(row)
|
||||||
|
item["triggered_signals"] = _loads_json(item.get("triggered_signals"), [])
|
||||||
|
item["hit_signals"] = _loads_json(item.get("hit_signals"), [])
|
||||||
|
item["miss_signals"] = _loads_json(item.get("miss_signals"), [])
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _missed_item(row):
|
||||||
|
item = dict(row)
|
||||||
|
item["features_detected"] = _loads_json(item.get("features_detected"), {})
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _performance_status(rec, reviews_by_rec):
|
||||||
|
status = (rec.get("status") or "").strip()
|
||||||
|
review_outcomes = [(r.get("outcome") or "").strip() for r in reviews_by_rec.get(rec.get("id"), [])]
|
||||||
|
if status in ("hit_tp1", "hit_tp2") or "爆发" in review_outcomes:
|
||||||
|
return "success"
|
||||||
|
if status == "stopped_out" or "失败" in review_outcomes:
|
||||||
|
return "failed"
|
||||||
|
return "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def _select_pipeline_rows(conn, run):
|
||||||
|
next_row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT started_at FROM cron_run_log
|
||||||
|
WHERE job_name='粗筛' AND started_at > ?
|
||||||
|
ORDER BY started_at ASC, id ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(run.get("started_at"),),
|
||||||
|
).fetchone()
|
||||||
|
window_start, window_end = _pipeline_window(run, next_row["started_at"] if next_row else None)
|
||||||
|
run_started = _iso(_parse_dt(run.get("started_at")) or window_start)
|
||||||
|
run_finished = _iso(_parse_dt(run.get("finished_at")) or _parse_dt(run.get("started_at")) or window_start)
|
||||||
|
start_text = _iso(window_start)
|
||||||
|
end_text = _iso(window_end)
|
||||||
|
cron_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM cron_run_log
|
||||||
|
WHERE started_at >= ? AND started_at <= ?
|
||||||
|
AND (
|
||||||
|
job_name IN ('事件舆情', '跟踪', '复盘')
|
||||||
|
OR (job_name='粗筛' AND id=?)
|
||||||
|
OR (job_name='确认' AND started_at >= ?)
|
||||||
|
)
|
||||||
|
ORDER BY started_at ASC, id ASC
|
||||||
|
""",
|
||||||
|
(start_text, end_text, run.get("id"), run_finished),
|
||||||
|
).fetchall()
|
||||||
|
screening_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM screening_log
|
||||||
|
WHERE (
|
||||||
|
layer IN ('粗筛', '细筛') AND scan_time >= ? AND scan_time <= ?
|
||||||
|
) OR (
|
||||||
|
layer='确认' AND scan_time >= ? AND scan_time <= ?
|
||||||
|
) OR (
|
||||||
|
layer='舆情触发' AND scan_time >= ? AND scan_time <= ?
|
||||||
|
)
|
||||||
|
ORDER BY scan_time ASC, score DESC, id ASC
|
||||||
|
""",
|
||||||
|
(run_started, run_finished, run_finished, end_text, start_text, end_text),
|
||||||
|
).fetchall()
|
||||||
|
rec_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM recommendation
|
||||||
|
WHERE rec_time >= ? AND rec_time <= ?
|
||||||
|
ORDER BY rec_time ASC, id ASC
|
||||||
|
""",
|
||||||
|
(run_finished, end_text),
|
||||||
|
).fetchall()
|
||||||
|
rec_ids = [row["id"] for row in rec_rows]
|
||||||
|
reviews = []
|
||||||
|
if rec_ids:
|
||||||
|
placeholders = ",".join(["?"] * len(rec_ids))
|
||||||
|
reviews = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT * FROM review_log
|
||||||
|
WHERE rec_id IN ({placeholders})
|
||||||
|
ORDER BY review_time ASC, id ASC
|
||||||
|
""",
|
||||||
|
tuple(rec_ids),
|
||||||
|
).fetchall()
|
||||||
|
review_window_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM review_log
|
||||||
|
WHERE review_time >= ? AND review_time <= ?
|
||||||
|
ORDER BY review_time ASC, id ASC
|
||||||
|
""",
|
||||||
|
(run_finished, end_text),
|
||||||
|
).fetchall()
|
||||||
|
known_review_ids = {row["id"] for row in reviews}
|
||||||
|
for row in review_window_rows:
|
||||||
|
if row["id"] not in known_review_ids:
|
||||||
|
reviews.append(row)
|
||||||
|
known_review_ids.add(row["id"])
|
||||||
|
missed_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM missed_explosions
|
||||||
|
WHERE detect_time >= ? AND detect_time <= ?
|
||||||
|
ORDER BY detect_time ASC, id ASC
|
||||||
|
""",
|
||||||
|
(run_finished, end_text),
|
||||||
|
).fetchall()
|
||||||
|
return {
|
||||||
|
"window_start": start_text,
|
||||||
|
"window_end": end_text,
|
||||||
|
"cron_rows": [_cron_item(row) for row in cron_rows],
|
||||||
|
"screening_rows": [_screening_item(row) for row in screening_rows],
|
||||||
|
"recommendation_rows": [_recommendation_item(row) for row in rec_rows],
|
||||||
|
"review_rows": [_review_item(row) for row in reviews],
|
||||||
|
"missed_rows": [_missed_item(row) for row in missed_rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pipeline_summary_for_run(run, related):
|
||||||
|
summary = _loads_json(run.get("summary_json"), {})
|
||||||
|
confirm_rows = [r for r in related["cron_rows"] if r.get("job_name") == "确认"]
|
||||||
|
event_rows = [r for r in related["cron_rows"] if r.get("job_name") == "事件舆情"]
|
||||||
|
track_rows = [r for r in related["cron_rows"] if r.get("job_name") == "跟踪"]
|
||||||
|
review_cron_rows = [r for r in related["cron_rows"] if r.get("job_name") == "复盘"]
|
||||||
|
|
||||||
|
confirm_processed = 0
|
||||||
|
confirm_hits = 0
|
||||||
|
for row in confirm_rows:
|
||||||
|
s = row.get("summary_json") or {}
|
||||||
|
confirm_processed += _safe_int(s.get("processed_count"))
|
||||||
|
confirm_hits += _safe_int(s.get("confirmed_count"))
|
||||||
|
|
||||||
|
reviews_by_rec = {}
|
||||||
|
for review in related["review_rows"]:
|
||||||
|
reviews_by_rec.setdefault(review.get("rec_id"), []).append(review)
|
||||||
|
|
||||||
|
perf_counts = {"success": 0, "failed": 0, "pending": 0}
|
||||||
|
for rec in related["recommendation_rows"]:
|
||||||
|
perf_counts[_performance_status(rec, reviews_by_rec)] += 1
|
||||||
|
|
||||||
|
status = run.get("run_status") or "unknown"
|
||||||
|
rough_candidates = _safe_int(summary.get("total_candidates"))
|
||||||
|
fine_qualified = _safe_int(summary.get("total_qualified"))
|
||||||
|
if not rough_candidates:
|
||||||
|
rough_candidates = sum(1 for item in related["screening_rows"] if item.get("layer") == "粗筛")
|
||||||
|
if not fine_qualified:
|
||||||
|
fine_qualified = sum(1 for item in related["screening_rows"] if item.get("layer") == "细筛")
|
||||||
|
|
||||||
|
recommendations = len(related["recommendation_rows"])
|
||||||
|
hit_rate = round(recommendations / fine_qualified * 100, 1) if fine_qualified else 0
|
||||||
|
issue_notes = []
|
||||||
|
if status != "success":
|
||||||
|
issue_notes.append(run.get("error_message") or "任务异常")
|
||||||
|
if rough_candidates and not fine_qualified:
|
||||||
|
issue_notes.append("粗筛后细筛为空")
|
||||||
|
if fine_qualified and not confirm_hits:
|
||||||
|
issue_notes.append("确认未命中")
|
||||||
|
if confirm_hits and not recommendations:
|
||||||
|
issue_notes.append("确认命中但未生成推荐")
|
||||||
|
if perf_counts["failed"]:
|
||||||
|
issue_notes.append(f"失败 {perf_counts['failed']}")
|
||||||
|
if related["missed_rows"]:
|
||||||
|
issue_notes.append(f"漏选 {len(related['missed_rows'])}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": run.get("id"),
|
||||||
|
"run_id": run.get("id"),
|
||||||
|
"job_name": run.get("job_name"),
|
||||||
|
"script_name": run.get("script_name"),
|
||||||
|
"started_at": run.get("started_at"),
|
||||||
|
"finished_at": run.get("finished_at"),
|
||||||
|
"duration_ms": _safe_int(run.get("duration_ms")),
|
||||||
|
"run_status": status,
|
||||||
|
"result_status": run.get("result_status") or "",
|
||||||
|
"error_message": run.get("error_message") or "",
|
||||||
|
"window_start": related["window_start"],
|
||||||
|
"window_end": related["window_end"],
|
||||||
|
"rough_candidates": rough_candidates,
|
||||||
|
"fine_qualified": fine_qualified,
|
||||||
|
"confirm_processed": confirm_processed,
|
||||||
|
"confirm_hits": confirm_hits,
|
||||||
|
"recommendations": recommendations,
|
||||||
|
"perf_success": perf_counts["success"],
|
||||||
|
"perf_failed": perf_counts["failed"],
|
||||||
|
"perf_pending": perf_counts["pending"],
|
||||||
|
"missed_count": len(related["missed_rows"]),
|
||||||
|
"event_count": sum(_safe_int((row.get("summary_json") or {}).get("processed_count")) for row in event_rows),
|
||||||
|
"tracked_count": sum(_safe_int((row.get("summary_json") or {}).get("tracked_count")) for row in track_rows),
|
||||||
|
"review_count": len(related["review_rows"]),
|
||||||
|
"review_run_count": len(review_cron_rows),
|
||||||
|
"hit_rate": hit_rate,
|
||||||
|
"issue_notes": issue_notes[:3],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_pipeline_runs(limit=30, hours=24):
|
||||||
|
"""按粗筛任务批次聚合推荐链路日志。"""
|
||||||
|
try:
|
||||||
|
limit = max(1, min(int(limit or 30), 100))
|
||||||
|
except Exception:
|
||||||
|
limit = 30
|
||||||
|
try:
|
||||||
|
hours = max(1, min(int(hours or 24), 24 * 30))
|
||||||
|
except Exception:
|
||||||
|
hours = 24
|
||||||
|
|
||||||
|
conn = get_conn()
|
||||||
|
run_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM cron_run_log
|
||||||
|
WHERE job_name = '粗筛'
|
||||||
|
AND julianday(?) - julianday(started_at) <= ?
|
||||||
|
ORDER BY started_at DESC, id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(datetime.now().isoformat(), hours / 24.0, limit),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
runs = []
|
||||||
|
for row in run_rows:
|
||||||
|
run = _cron_item(row)
|
||||||
|
related = _select_pipeline_rows(conn, run)
|
||||||
|
runs.append(_pipeline_summary_for_run(run, related))
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
kpi = {
|
||||||
|
"hours": hours,
|
||||||
|
"run_count": len(runs),
|
||||||
|
"rough_candidates": sum(item["rough_candidates"] for item in runs),
|
||||||
|
"fine_qualified": sum(item["fine_qualified"] for item in runs),
|
||||||
|
"confirm_processed": sum(item["confirm_processed"] for item in runs),
|
||||||
|
"confirm_hits": sum(item["confirm_hits"] for item in runs),
|
||||||
|
"recommendations": sum(item["recommendations"] for item in runs),
|
||||||
|
"perf_success": sum(item["perf_success"] for item in runs),
|
||||||
|
"perf_failed": sum(item["perf_failed"] for item in runs),
|
||||||
|
"perf_pending": sum(item["perf_pending"] for item in runs),
|
||||||
|
"missed_count": sum(item["missed_count"] for item in runs),
|
||||||
|
}
|
||||||
|
kpi["recommendation_rate"] = round(kpi["recommendations"] / kpi["fine_qualified"] * 100, 1) if kpi["fine_qualified"] else 0
|
||||||
|
kpi["performance_hit_rate"] = round(kpi["perf_success"] / (kpi["perf_success"] + kpi["perf_failed"]) * 100, 1) if (kpi["perf_success"] + kpi["perf_failed"]) else 0
|
||||||
|
return {"kpi": kpi, "runs": runs}
|
||||||
|
|
||||||
|
|
||||||
|
def get_pipeline_run_detail(run_id):
|
||||||
|
"""返回某次粗筛批次的链路明细。"""
|
||||||
|
conn = get_conn()
|
||||||
|
row = conn.execute("SELECT * FROM cron_run_log WHERE id=? AND job_name='粗筛'", (run_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
return None
|
||||||
|
run = _cron_item(row)
|
||||||
|
related = _select_pipeline_rows(conn, run)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
summary = _pipeline_summary_for_run(run, related)
|
||||||
|
reviews_by_rec = {}
|
||||||
|
for review in related["review_rows"]:
|
||||||
|
reviews_by_rec.setdefault(review.get("rec_id"), []).append(review)
|
||||||
|
|
||||||
|
recommendations = []
|
||||||
|
for rec in related["recommendation_rows"]:
|
||||||
|
rec_reviews = reviews_by_rec.get(rec.get("id"), [])
|
||||||
|
rec["performance_status"] = _performance_status(rec, reviews_by_rec)
|
||||||
|
rec["reviews"] = rec_reviews
|
||||||
|
if rec["performance_status"] == "success":
|
||||||
|
rec["stage_label"] = "复盘命中"
|
||||||
|
elif rec["performance_status"] == "failed":
|
||||||
|
rec["stage_label"] = "复盘失败"
|
||||||
|
else:
|
||||||
|
rec["stage_label"] = "交易推荐"
|
||||||
|
recommendations.append(rec)
|
||||||
|
|
||||||
|
timeline = []
|
||||||
|
for cron in related["cron_rows"]:
|
||||||
|
s = cron.get("summary_json") or {}
|
||||||
|
timeline.append({
|
||||||
|
"stage": cron.get("job_name") or "任务",
|
||||||
|
"started_at": cron.get("started_at"),
|
||||||
|
"finished_at": cron.get("finished_at"),
|
||||||
|
"duration_ms": _safe_int(cron.get("duration_ms")),
|
||||||
|
"run_status": cron.get("run_status") or "",
|
||||||
|
"result_status": cron.get("result_status") or "",
|
||||||
|
"summary": s,
|
||||||
|
"error_message": cron.get("error_message") or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
screening_items = related["screening_rows"]
|
||||||
|
stage_counts = {
|
||||||
|
"observation": sum(1 for item in screening_items if item.get("layer") == "粗筛"),
|
||||||
|
"fine": sum(1 for item in screening_items if item.get("layer") == "细筛"),
|
||||||
|
"confirm_rejected": max(0, summary["confirm_processed"] - summary["confirm_hits"]),
|
||||||
|
"recommendation": len(recommendations),
|
||||||
|
"review_success": summary["perf_success"],
|
||||||
|
"review_failed": summary["perf_failed"],
|
||||||
|
"missed": summary["missed_count"],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": summary,
|
||||||
|
"timeline": timeline,
|
||||||
|
"stage_counts": stage_counts,
|
||||||
|
"screening_items": screening_items,
|
||||||
|
"recommendations": recommendations,
|
||||||
|
"reviews": related["review_rows"],
|
||||||
|
"missed_explosions": related["missed_rows"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"get_all_recommendations",
|
"get_all_recommendations",
|
||||||
"get_observation_candidates",
|
"get_observation_candidates",
|
||||||
"get_cron_run_logs",
|
"get_cron_run_logs",
|
||||||
"get_cron_run_summary",
|
"get_cron_run_summary",
|
||||||
|
"get_pipeline_run_detail",
|
||||||
|
"get_pipeline_runs",
|
||||||
"get_review_stats",
|
"get_review_stats",
|
||||||
"get_screening_history",
|
"get_screening_history",
|
||||||
"get_stats",
|
"get_stats",
|
||||||
|
|||||||
@ -1292,6 +1292,21 @@ def main(compact: bool = False):
|
|||||||
mainline_item = get_recommendation_for_push(rec_id)
|
mainline_item = get_recommendation_for_push(rec_id)
|
||||||
push_mainline_state_update(symbol, rec_id, mainline_item)
|
push_mainline_state_update(symbol, rec_id, mainline_item)
|
||||||
else:
|
else:
|
||||||
|
cand_detail = json.loads(cand.get("detail_json", "{}"))
|
||||||
|
log_screening(
|
||||||
|
layer="确认", symbol=symbol, state=cand.get("state", "蓄力"), score=result.get("score", 0),
|
||||||
|
price=result.get("price", 0), signals=result.get("signals", []),
|
||||||
|
sector=cand_detail.get("sector", cand.get("sector", "")),
|
||||||
|
leader_status=cand_detail.get("leader_status", cand.get("leader_status", "")),
|
||||||
|
is_meme=int(is_meme_coin(symbol)),
|
||||||
|
detail={
|
||||||
|
"confirmed": False,
|
||||||
|
"reason": "确认未通过",
|
||||||
|
"entry_plan": result.get("entry_plan") or {},
|
||||||
|
"fresh_reason": result.get("fresh_reason", ""),
|
||||||
|
"trigger_context": result.get("trigger_context") or {},
|
||||||
|
},
|
||||||
|
)
|
||||||
result["state_update"] = {"should_alert": False, "reason": "未确认爆发"}
|
result["state_update"] = {"should_alert": False, "reason": "未确认爆发"}
|
||||||
|
|
||||||
results.append({"symbol": symbol, **result})
|
results.append({"symbol": symbol, **result})
|
||||||
|
|||||||
@ -882,6 +882,26 @@ def layer1_coarse_filter():
|
|||||||
|
|
||||||
total_bypass = bypass_count + hl_count_total + cs_count_total
|
total_bypass = bypass_count + hl_count_total + cs_count_total
|
||||||
print(f"粗筛结果: {len(candidates)}个候选(含{total_bypass}个旁路: 静K{bypass_count}+底抬{hl_count_total}+压放{cs_count_total})")
|
print(f"粗筛结果: {len(candidates)}个候选(含{total_bypass}个旁路: 静K{bypass_count}+底抬{hl_count_total}+压放{cs_count_total})")
|
||||||
|
for symbol, cand in candidates.items():
|
||||||
|
log_screening(
|
||||||
|
layer="粗筛",
|
||||||
|
symbol=symbol,
|
||||||
|
state="候选",
|
||||||
|
score=cand.get("anomaly_score", 0),
|
||||||
|
price=cand.get("price", 0),
|
||||||
|
signals=cand.get("anomalies", []),
|
||||||
|
is_meme=int(cand.get("is_meme") or 0),
|
||||||
|
change_24h=cand.get("change_24h", 0),
|
||||||
|
funding_rate=cand.get("funding_rate", 0),
|
||||||
|
detail={
|
||||||
|
"candidate_stage": "coarse_candidate",
|
||||||
|
"volume_24h": cand.get("volume_24h", 0),
|
||||||
|
"turnover_acceleration_1h": cand.get("turnover_acceleration_1h", 0),
|
||||||
|
"turnover_acceleration_4h": cand.get("turnover_acceleration_4h", 0),
|
||||||
|
"signal_recency": _build_signal_recency(cand),
|
||||||
|
"bypass_origin": cand.get("bypass_origin", ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,12 @@ from app.core.opportunity_lifecycle import apply_entry_quality_gate
|
|||||||
exchange = ccxt.binance({"enableRateLimit": True})
|
exchange = ccxt.binance({"enableRateLimit": True})
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
PROVISIONAL_BUY_SIGNAL_MARKERS = (
|
||||||
|
"可即刻入场",
|
||||||
|
"当前价接近回踩目标",
|
||||||
|
"回踩确认完毕",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def fetch_klines(symbol, timeframe, limit=200):
|
def fetch_klines(symbol, timeframe, limit=200):
|
||||||
try:
|
try:
|
||||||
@ -52,6 +58,49 @@ def fetch_klines(symbol, timeframe, limit=200):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_tracking_price(price):
|
||||||
|
try:
|
||||||
|
price = float(price)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
if price <= 0:
|
||||||
|
return ""
|
||||||
|
if price >= 1:
|
||||||
|
return f"${price:.3f}"
|
||||||
|
if price >= 0.01:
|
||||||
|
return f"${price:.4f}"
|
||||||
|
if price >= 0.0001:
|
||||||
|
return f"${price:.6f}"
|
||||||
|
return f"${price:.8f}"
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_buy_signals_after_gate(buy_signals, final_action, gated_plan, gate_reasons):
|
||||||
|
"""买点质量闸门降级后,移除临时买入文案,保留最终终端指引。"""
|
||||||
|
if final_action == "可即刻买入" or not gate_reasons:
|
||||||
|
return list(buy_signals or [])
|
||||||
|
|
||||||
|
filtered = [
|
||||||
|
str(signal)
|
||||||
|
for signal in (buy_signals or [])
|
||||||
|
if not any(marker in str(signal) for marker in PROVISIONAL_BUY_SIGNAL_MARKERS)
|
||||||
|
]
|
||||||
|
first_reason = str(gate_reasons[0])
|
||||||
|
if final_action == "等回踩":
|
||||||
|
target_price = (
|
||||||
|
(gated_plan or {}).get("rr_target_entry")
|
||||||
|
or (gated_plan or {}).get("entry_price")
|
||||||
|
or (gated_plan or {}).get("wait_price")
|
||||||
|
)
|
||||||
|
price_text = _format_tracking_price(target_price)
|
||||||
|
if price_text:
|
||||||
|
filtered.append(f"🟡 现价不买,等待回踩至{price_text}附近;{first_reason}")
|
||||||
|
else:
|
||||||
|
filtered.append(f"🟡 现价不买,继续等待回踩;{first_reason}")
|
||||||
|
elif final_action == "观察":
|
||||||
|
filtered.append(f"🟡 买点未达标,保持观察;{first_reason}")
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
def analyze_tracking_signals(symbol, rec, current_price):
|
def analyze_tracking_signals(symbol, rec, current_price):
|
||||||
"""
|
"""
|
||||||
对active推荐做动态跟踪分析
|
对active推荐做动态跟踪分析
|
||||||
@ -264,6 +313,12 @@ def analyze_tracking_signals(symbol, rec, current_price):
|
|||||||
sector_context=rec.get("sector_context") or {},
|
sector_context=rec.get("sector_context") or {},
|
||||||
)
|
)
|
||||||
if gate_reasons:
|
if gate_reasons:
|
||||||
|
buy_signals = reconcile_buy_signals_after_gate(
|
||||||
|
buy_signals,
|
||||||
|
action_status,
|
||||||
|
gated_plan,
|
||||||
|
gate_reasons,
|
||||||
|
)
|
||||||
buy_signals.append("⚠️ 买点质量闸门: " + ";".join(gate_reasons[:3]))
|
buy_signals.append("⚠️ 买点质量闸门: " + ";".join(gate_reasons[:3]))
|
||||||
entry_plan.update(gated_plan)
|
entry_plan.update(gated_plan)
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,13 @@ def build_router(templates, repo_root: Path, stock_report_template: str):
|
|||||||
return redirect
|
return redirect
|
||||||
return render_page("watchlist.html", request)
|
return render_page("watchlist.html", request)
|
||||||
|
|
||||||
|
@router.get("/pipeline", response_class=HTMLResponse)
|
||||||
|
async def pipeline_page(request: Request):
|
||||||
|
user, redirect = require_page_user(request)
|
||||||
|
if redirect:
|
||||||
|
return redirect
|
||||||
|
return render_page("pipeline.html", request)
|
||||||
|
|
||||||
@router.get("/strategy", response_class=HTMLResponse)
|
@router.get("/strategy", response_class=HTMLResponse)
|
||||||
async def strategy_page(request: Request):
|
async def strategy_page(request: Request):
|
||||||
user, redirect = require_page_user(request)
|
user, redirect = require_page_user(request)
|
||||||
|
|||||||
@ -6,6 +6,8 @@ from app.db.analytics import (
|
|||||||
get_cron_run_logs,
|
get_cron_run_logs,
|
||||||
get_cron_run_summary,
|
get_cron_run_summary,
|
||||||
get_observation_candidates,
|
get_observation_candidates,
|
||||||
|
get_pipeline_run_detail,
|
||||||
|
get_pipeline_runs,
|
||||||
get_review_stats,
|
get_review_stats,
|
||||||
get_screening_history,
|
get_screening_history,
|
||||||
get_stats,
|
get_stats,
|
||||||
@ -149,3 +151,18 @@ async def api_cron(limit: int = 50, job_name: str = "", altcoin_session: str = C
|
|||||||
async def api_cron_summary(hours: int = 24, altcoin_session: str = Cookie(default="")):
|
async def api_cron_summary(hours: int = 24, altcoin_session: str = Cookie(default="")):
|
||||||
require_api_user_with_subscription(altcoin_session)
|
require_api_user_with_subscription(altcoin_session)
|
||||||
return get_cron_run_summary(hours=hours)
|
return get_cron_run_summary(hours=hours)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/pipeline/runs")
|
||||||
|
async def api_pipeline_runs(limit: int = 30, hours: int = 24, altcoin_session: str = Cookie(default="")):
|
||||||
|
require_api_user_with_subscription(altcoin_session)
|
||||||
|
return get_pipeline_runs(limit=limit, hours=hours)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/pipeline/runs/{run_id}")
|
||||||
|
async def api_pipeline_run_detail(run_id: int, altcoin_session: str = Cookie(default="")):
|
||||||
|
require_api_user_with_subscription(altcoin_session)
|
||||||
|
detail = get_pipeline_run_detail(run_id)
|
||||||
|
if not detail:
|
||||||
|
return {"error": "pipeline run not found", "run_id": run_id}
|
||||||
|
return detail
|
||||||
|
|||||||
@ -35,9 +35,9 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
# 默认 dry-run,确保第一次 docker compose up 不会直接写库/推送。
|
# 本地 Docker 副本需要真实跑链路,方便验证筛选/确认/跟踪/复盘结果。
|
||||||
# 验证无误后改成 0。
|
# 调度器仍然单进程串行执行,避免 SQLite 写锁。
|
||||||
ALPHAX_SCHEDULER_DRY_RUN: "1"
|
ALPHAX_SCHEDULER_DRY_RUN: "0"
|
||||||
ALPHAX_DB_PATH: "/app/data/altcoin_monitor.db"
|
ALPHAX_DB_PATH: "/app/data/altcoin_monitor.db"
|
||||||
command: ["scheduler"]
|
command: ["scheduler"]
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@ -405,11 +405,11 @@ event_driven:
|
|||||||
note: Solana meme主题扩散
|
note: Solana meme主题扩散
|
||||||
meta:
|
meta:
|
||||||
version: 1
|
version: 1
|
||||||
last_review: '2026-05-14T01:10:42.599449'
|
last_review: '2026-05-14T09:19:05.923167'
|
||||||
last_reverse_analysis: '2026-05-14T01:11:19.360232'
|
last_reverse_analysis: '2026-05-14T09:19:39.019005'
|
||||||
total_reviews: 20
|
total_reviews: 26
|
||||||
total_rules_learned: 37
|
total_rules_learned: 37
|
||||||
iteration_count: 25
|
iteration_count: 31
|
||||||
strategy_version: v1.7.11
|
strategy_version: v1.7.11
|
||||||
strategy_revision_started_at: '2026-05-09T01:20:00'
|
strategy_revision_started_at: '2026-05-09T01:20:00'
|
||||||
strategy_revision_note: 'v1.7.11: 触发时效治理,旧形态只作背景,消息触发显式标记'
|
strategy_revision_note: 'v1.7.11: 触发时效治理,旧形态只作背景,消息触发显式标记'
|
||||||
|
|||||||
@ -3,12 +3,13 @@
|
|||||||
|
|
||||||
{% block nav_links %}
|
{% block nav_links %}
|
||||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||||
<a class="sidebar-link" href="/watchlist"><svg class="link-icon"><use href="#svg-star"/></svg>关注</a>
|
|
||||||
<a class="sidebar-link" href="/strategy"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
|
||||||
<a class="sidebar-link" href="/iteration"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
|
||||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||||
|
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||||
|
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||||
<a class="sidebar-link active admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
<a class="sidebar-link active admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
102
static/app.html
102
static/app.html
@ -139,6 +139,21 @@
|
|||||||
.h-pnl-row { display: flex; align-items: center; gap: 8px; padding: 4px 18px 8px; }
|
.h-pnl-row { display: flex; align-items: center; gap: 8px; padding: 4px 18px 8px; }
|
||||||
.h-arrow { color: var(--stone); font-size: 12px; }
|
.h-arrow { color: var(--stone); font-size: 12px; }
|
||||||
.h-duration { font-size: 11px; margin-left: auto; }
|
.h-duration { font-size: 11px; margin-left: auto; }
|
||||||
|
.decision-strip { display: grid; grid-template-columns: minmax(92px, auto) minmax(0, 1fr); align-items: center; gap: 10px; margin: 0 18px 10px; padding: 9px 10px; min-height: 48px; border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); min-width: 0; }
|
||||||
|
.decision-head { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||||
|
.decision-label { color: var(--stone); font-size: 10px; font-weight: 900; line-height: 1.1; white-space: nowrap; }
|
||||||
|
.decision-title { font-size: 13px; font-weight: 900; line-height: 1.2; white-space: nowrap; }
|
||||||
|
.decision-body { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.decision-focus { color: var(--ink); font-size: 13px; font-weight: 900; line-height: 1.2; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.decision-reason { color: var(--stone); font-size: 11px; font-weight: 700; line-height: 1.25; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.decision-strip.buy { background: var(--green-light); border-color: rgba(0,180,115,.18); }
|
||||||
|
.decision-strip.buy .decision-title { color: var(--green); }
|
||||||
|
.decision-strip.wait { background: var(--yellow-light); border-color: rgba(252,185,0,.24); }
|
||||||
|
.decision-strip.wait .decision-title { color: var(--yellow-dark); }
|
||||||
|
.decision-strip.observe,
|
||||||
|
.decision-strip.weak { background: rgba(66,98,255,.04); border-color: rgba(66,98,255,.12); }
|
||||||
|
.decision-strip.observe .decision-title { color: var(--blue); }
|
||||||
|
.decision-strip.weak .decision-title { color: var(--muted); }
|
||||||
|
|
||||||
/* ===== K-LINE ===== */
|
/* ===== K-LINE ===== */
|
||||||
.kline-wrap { padding: 0 8px 4px; }
|
.kline-wrap { padding: 0 8px 4px; }
|
||||||
@ -156,38 +171,9 @@
|
|||||||
.ep-val { font-weight: 900; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.ep-val { font-weight: 900; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.ep-val.entry-ref { color: var(--yellow-dark); } .ep-val.risk-line { color: var(--red); } .ep-val.space-ref { color: var(--blue); } .ep-val.phase-ref { color: var(--green); }
|
.ep-val.entry-ref { color: var(--yellow-dark); } .ep-val.risk-line { color: var(--red); } .ep-val.space-ref { color: var(--blue); } .ep-val.phase-ref { color: var(--green); }
|
||||||
.ep-sub { color: var(--muted); font-size: 10px; font-weight: 600; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.ep-sub { color: var(--muted); font-size: 10px; font-weight: 600; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.trigger-cause { margin: 0 18px 8px; padding: 8px 10px; border: 1px solid rgba(66,98,255,.12); border-radius: var(--radius-lg); background: rgba(66,98,255,.04); display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
||||||
.trigger-cause .tc-label { flex-shrink: 0; color: var(--blue); font-size: 10px; font-weight: 900; line-height: 1.2; }
|
|
||||||
.trigger-cause .tc-value { color: var(--slate); font-size: 12px; font-weight: 700; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.signal-context { display: flex; flex-direction: column; gap: 6px; padding: 0 18px 8px; }
|
|
||||||
.signal-context .trigger-cause,
|
|
||||||
.signal-context .trigger-meta { margin: 0; }
|
|
||||||
.trigger-meta { padding: 8px 10px; border-radius: var(--radius-lg); border: 1px solid var(--hairline-soft); background: var(--surface); font-size: 12px; color: var(--stone); display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
|
||||||
.trigger-meta span { font-size: 10px; font-weight: 900; color: var(--stone); line-height: 1.2; }
|
|
||||||
.trigger-meta small { font-size: 11px; color: var(--stone); line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.trigger-meta.current { border-color: rgba(0,180,115,.18); background: rgba(0,180,115,.045); }
|
|
||||||
.trigger-meta.current span { color: var(--green); }
|
|
||||||
.trigger-meta.event { border-color: rgba(66,98,255,.16); background: rgba(66,98,255,.04); }
|
|
||||||
.trigger-meta.event span { color: var(--blue); }
|
|
||||||
.trigger-meta.stale { border-color: var(--hairline-soft); background: var(--surface); }
|
|
||||||
.trigger-meta.stale span { color: var(--muted); }
|
|
||||||
.trust-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 0 18px 10px; }
|
|
||||||
.trust-pill { border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); padding: 8px 10px; min-width: 0; }
|
|
||||||
.trust-pill .trust-label { display: block; font-size: 10px; color: var(--stone); font-weight: 700; text-transform: uppercase; margin-bottom: 3px; }
|
|
||||||
.trust-pill .trust-value { display: block; font-size: 13px; color: var(--ink); font-weight: 800; line-height: 1.25; }
|
|
||||||
.trust-pill .trust-sub { display: block; font-size: 10px; color: var(--stone); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.trust-pill.window-active { background: var(--green-light); border-color: rgba(0,180,115,.18); }
|
|
||||||
.trust-pill.window-active .trust-value { color: var(--green); }
|
|
||||||
.trust-pill.window-warn { background: var(--yellow-light); border-color: rgba(252,185,0,.24); }
|
|
||||||
.trust-pill.window-warn .trust-value { color: var(--yellow-dark); }
|
|
||||||
.trust-pill.window-danger { background: var(--red-light); border-color: rgba(229,62,62,.20); }
|
|
||||||
.trust-pill.window-danger .trust-value { color: var(--red); }
|
|
||||||
.trust-pill.risk { background: rgba(66,98,255,.06); border-color: rgba(66,98,255,.12); }
|
|
||||||
.trust-pill.risk .trust-value { color: var(--blue); }
|
|
||||||
|
|
||||||
/* ===== SIGNALS ===== */
|
/* ===== SIGNALS ===== */
|
||||||
.signals-row { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 18px 8px; }
|
.signals-row { display: flex; flex-wrap: nowrap; gap: 4px; padding: 0 18px 8px; min-height: 25px; overflow: hidden; }
|
||||||
.sig { font-size: 11px; padding: 3px 8px; border-radius: var(--radius-full); font-weight: 700; white-space: nowrap; line-height: 1.3; }
|
.sig { font-size: 11px; padding: 3px 8px; border-radius: var(--radius-full); font-weight: 700; white-space: nowrap; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; max-width: 50%; flex: 0 1 auto; }
|
||||||
.sig.strong { color: #600000; background: #ffc6c6; }
|
.sig.strong { color: #600000; background: #ffc6c6; }
|
||||||
.sig.forward { color: var(--green); background: var(--green-light); }
|
.sig.forward { color: var(--green); background: var(--green-light); }
|
||||||
.sig.pa { color: var(--blue); background: rgba(66,98,255,.06); }
|
.sig.pa { color: var(--blue); background: rgba(66,98,255,.06); }
|
||||||
@ -241,12 +227,7 @@
|
|||||||
.stats-strip { align-items: stretch; }
|
.stats-strip { align-items: stretch; }
|
||||||
.stats-main { width: 100%; }
|
.stats-main { width: 100%; }
|
||||||
.entry-plan { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 8px 14px; }
|
.entry-plan { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 8px 14px; }
|
||||||
.trigger-cause { margin: 0 14px 8px; align-items: flex-start; }
|
.decision-strip { margin: 0 14px 8px; grid-template-columns: 86px minmax(0,1fr); }
|
||||||
.trigger-cause .tc-value { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
|
||||||
.signal-context { padding: 0 14px 8px; }
|
|
||||||
.signal-context .trigger-cause { margin: 0; }
|
|
||||||
.trigger-meta small { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
|
||||||
.trust-row { grid-template-columns: 1fr; padding: 0 14px 8px; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(max-width:360px) {
|
@media(max-width:360px) {
|
||||||
@ -648,7 +629,8 @@ function renderRecCard(r) {
|
|||||||
function opportunityPhase(r, triggerText, sigText) {
|
function opportunityPhase(r, triggerText, sigText) {
|
||||||
var text = cleanDisplayText([r.execution_label, r.execution_reason, triggerText, sigText].join(' '));
|
var text = cleanDisplayText([r.execution_label, r.execution_reason, triggerText, sigText].join(' '));
|
||||||
if (r.execution_status === 'buy_now') return {label:'入场窗口', cls:'buy', short:'窗口'};
|
if (r.execution_status === 'buy_now') return {label:'入场窗口', cls:'buy', short:'窗口'};
|
||||||
if (/回踩|pullback/i.test(text)) return {label:'等回踩', cls:'wait', short:'回踩'};
|
if (r.execution_status === 'wait_pullback' || r.lifecycle_state === 'waiting_entry') return {label:'等回踩', cls:'wait', short:'回踩'};
|
||||||
|
if (r.execution_status === 'observe' || r.display_bucket === 'watch_pool') return (r.observe_tier === 'weak') ? {label:'弱观察', cls:'weak', short:'弱观察'} : {label:'观察中', cls:'obs', short:'观察'};
|
||||||
if (/突破|breakout|上破|放量突破|突破确认/i.test(text)) return {label:'等突破', cls:'wait', short:'突破'};
|
if (/突破|breakout|上破|放量突破|突破确认/i.test(text)) return {label:'等突破', cls:'wait', short:'突破'};
|
||||||
if (/确认|静K|收线|站稳|量能|放量|confirm/i.test(text)) return {label:'等确认', cls:'obs', short:'确认'};
|
if (/确认|静K|收线|站稳|量能|放量|confirm/i.test(text)) return {label:'等确认', cls:'obs', short:'确认'};
|
||||||
return (r.observe_tier === 'weak') ? {label:'弱观察', cls:'weak', short:'弱观察'} : {label:'观察中', cls:'obs', short:'观察'};
|
return (r.observe_tier === 'weak') ? {label:'弱观察', cls:'weak', short:'弱观察'} : {label:'观察中', cls:'obs', short:'观察'};
|
||||||
@ -700,14 +682,22 @@ function renderRecCard(r) {
|
|||||||
function fmtP(p) { return fmtPrice(p, priceDecimals(price || p)); }
|
function fmtP(p) { return fmtPrice(p, priceDecimals(price || p)); }
|
||||||
var pnl = r.pnl_pct||0, pnlCls = pnl>0?'pos':pnl<0?'neg':'zero', pnlSign = pnl>0?'+':'';
|
var pnl = r.pnl_pct||0, pnlCls = pnl>0?'pos':pnl<0?'neg':'zero', pnlSign = pnl>0?'+':'';
|
||||||
var priceFmt = fmtPrice(price);
|
var priceFmt = fmtPrice(price);
|
||||||
var sigHtml = sigs.slice(0,3).map(function(s){
|
function displaySignalText(s) {
|
||||||
|
var text = cleanDisplayText(s);
|
||||||
|
if (!isBuy) {
|
||||||
|
text = text
|
||||||
|
.replace(/15min\s*入场窗口信号/g, '15min触发信号')
|
||||||
|
.replace(/入场窗口信号/g, '触发信号')
|
||||||
|
.replace(/入场窗口确认/g, '触发确认');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
var sigHtml = sigs.slice(0,2).map(function(s){
|
||||||
var cls = 'info'; if(/量价齐飞|起爆点|放量/.test(s)) cls='strong';
|
var cls = 'info'; if(/量价齐飞|起爆点|放量/.test(s)) cls='strong';
|
||||||
else if(/静K|筑底|回踩|突破|蓄力|底部抬高|压缩/.test(s)) cls='forward';
|
else if(/静K|筑底|回踩|突破|蓄力|底部抬高|压缩/.test(s)) cls='forward';
|
||||||
else if(/动K|PA|转折/.test(s)) cls='pa'; else if(/衰减|空头|风险|背离|闸门/.test(s)) cls='warn';
|
else if(/动K|PA|转折/.test(s)) cls='pa'; else if(/衰减|空头|风险|背离|闸门/.test(s)) cls='warn';
|
||||||
return '<span class="sig '+cls+'">'+cleanDisplayText(s)+'</span>';
|
return '<span class="sig '+cls+'">'+displaySignalText(s)+'</span>';
|
||||||
}).join('');
|
}).join('');
|
||||||
var entryMethod = ep.entry_method || '';
|
|
||||||
var triggerCause = normalizeTriggerCause(entryMethod || (isBuy?'15min 触发 · 窗口有效':phase.label+' · 等待条件满足'));
|
|
||||||
var score = r.rec_score||0, st = scoreTier(score), ver = r.strategy_version||'';
|
var score = r.rec_score||0, st = scoreTier(score), ver = r.strategy_version||'';
|
||||||
var hasQualityGate = ep.entry_quality_gate && Array.isArray(ep.entry_quality_gate.reasons) && ep.entry_quality_gate.reasons.length;
|
var hasQualityGate = ep.entry_quality_gate && Array.isArray(ep.entry_quality_gate.reasons) && ep.entry_quality_gate.reasons.length;
|
||||||
var entryLabel = isWait ? '回踩参考' : (hasQualityGate ? '失效参考' : '参考价位');
|
var entryLabel = isWait ? '回踩参考' : (hasQualityGate ? '失效参考' : '参考价位');
|
||||||
@ -723,18 +713,21 @@ function renderRecCard(r) {
|
|||||||
var riskLine = ep.stop_loss || r.stop_loss || 0;
|
var riskLine = ep.stop_loss || r.stop_loss || 0;
|
||||||
var spaceRef = ep.tp1 || r.tp1 || 0;
|
var spaceRef = ep.tp1 || r.tp1 || 0;
|
||||||
var upsidePct = entryRef && spaceRef ? ((spaceRef / entryRef - 1) * 100) : 0;
|
var upsidePct = entryRef && spaceRef ? ((spaceRef / entryRef - 1) * 100) : 0;
|
||||||
function trustWindowHtml() {
|
function entryWindowSummary() {
|
||||||
var w = r.entry_window || {};
|
var w = r.entry_window || {};
|
||||||
if (!isBuy || !w.status) return '';
|
if (!isBuy || !w.status) return '';
|
||||||
var cls = w.status === 'active' ? 'window-active' : (w.status === 'price_left_up' ? 'window-warn' : 'window-danger');
|
|
||||||
var mins = Number(w.remaining_minutes || 0);
|
var mins = Number(w.remaining_minutes || 0);
|
||||||
var remain = mins >= 60 ? (Math.floor(mins/60)+'h'+Math.round(mins%60)+'m') : (Math.max(0, Math.round(mins))+'m');
|
var remain = mins >= 60 ? (Math.floor(mins/60)+'h'+Math.round(mins%60)+'m') : (Math.max(0, Math.round(mins))+'m');
|
||||||
var dev = Number(w.deviation_pct || 0);
|
var dev = Number(w.deviation_pct || 0);
|
||||||
var devText = (dev>0?'+':'') + dev.toFixed(2) + '%';
|
var devText = (dev>0?'+':'') + dev.toFixed(2) + '%';
|
||||||
return '<div class="trust-pill '+cls+'"><span class="trust-label">窗口有效期</span><span class="trust-value">'+cleanDisplayText(w.label||'入场窗口')+'</span><span class="trust-sub">剩余 '+remain+' · 偏离 '+devText+'</span></div>';
|
return '剩余 '+remain+' · 偏离 '+devText;
|
||||||
}
|
}
|
||||||
var trustHtml = trustWindowHtml();
|
|
||||||
var weakNoteHtml = isWeakObserve ? '<div class="weak-note">'+cleanDisplayText(r.observe_reason || '信号强度不足,仅保留为低优先级观察,不构成实时机会。')+'</div>' : '';
|
var weakNoteHtml = isWeakObserve ? '<div class="weak-note">'+cleanDisplayText(r.observe_reason || '信号强度不足,仅保留为低优先级观察,不构成实时机会。')+'</div>' : '';
|
||||||
|
var decisionCls = isBuy ? 'buy' : (isWait ? 'wait' : (isWeakObserve ? 'weak' : 'observe'));
|
||||||
|
var decisionTitle = cleanDisplayText(r.execution_label || phase.label);
|
||||||
|
var decisionFocus = isBuy ? ('现价 '+fmtP(price)) : (isWait ? ('等 '+fmtP(entryRef)) : (isWeakObserve ? '低优先级观察' : '等待确认'));
|
||||||
|
var decisionReason = cleanDisplayText(isBuy ? (entryWindowSummary() || '入场窗口有效') : (isWait ? '现价不追,等回踩价附近再评估' : (r.observe_reason || r.state_reason || '未形成入场窗口')));
|
||||||
|
var decisionHtml = '<div class="decision-strip '+decisionCls+'"><div class="decision-head"><span class="decision-label">最终建议</span><span class="decision-title">'+decisionTitle+'</span></div><div class="decision-body"><span class="decision-focus">'+decisionFocus+'</span><span class="decision-reason">'+decisionReason+'</span></div></div>';
|
||||||
var entryPlanHtml = '';
|
var entryPlanHtml = '';
|
||||||
if (isTradePlan) {
|
if (isTradePlan) {
|
||||||
entryPlanHtml = '<div class="entry-plan">' +
|
entryPlanHtml = '<div class="entry-plan">' +
|
||||||
@ -751,26 +744,11 @@ function renderRecCard(r) {
|
|||||||
'<div class="ep-item"><span class="ep-label">绩效口径</span><span class="ep-val risk-line">不计入</span><span class="ep-sub">未成交易推荐</span></div>'+
|
'<div class="ep-item"><span class="ep-label">绩效口径</span><span class="ep-val risk-line">不计入</span><span class="ep-sub">未成交易推荐</span></div>'+
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
var triggerCauseHtml = triggerCause ? '<div class="trigger-cause"><span class="tc-label">'+(hasQualityGate?'观察原因':'触发依据')+'</span><span class="tc-value">'+(hasQualityGate ? cleanDisplayText(r.observe_reason || triggerCause).slice(0,96) : triggerCause.slice(0,80))+'</span></div>' : '';
|
|
||||||
var triggerCtx = (r.market_context && r.market_context.trigger_context) || (r.sector_context && r.sector_context.trigger_context) || ep.trigger_context || {};
|
|
||||||
var curTriggers = Array.isArray(triggerCtx.current_triggers) ? triggerCtx.current_triggers : [];
|
|
||||||
var staleTriggers = Array.isArray(triggerCtx.stale_background) ? triggerCtx.stale_background : [];
|
|
||||||
var triggerBadgeHtml = '';
|
|
||||||
if (triggerCtx.trigger_status || curTriggers.length || staleTriggers.length) {
|
|
||||||
var tCls = /news/.test(triggerCtx.trigger_status || '') ? 'event' : (/stale/.test(triggerCtx.trigger_status || '') ? 'stale' : 'current');
|
|
||||||
var tLabel = triggerCtx.trigger_label || (curTriggers.length ? '当前触发' : '历史背景');
|
|
||||||
if (tCls === 'stale') tLabel = '历史背景';
|
|
||||||
var firstCur = curTriggers[0] || {};
|
|
||||||
var sub = firstCur.title || firstCur.label || (staleTriggers[0] && staleTriggers[0].label) || '';
|
|
||||||
triggerBadgeHtml = '<div class="trigger-meta '+tCls+'"><span>'+cleanDisplayText(tLabel).slice(0,32)+'</span>'+(sub?'<small>'+cleanDisplayText(sub).slice(0,72)+'</small>':'')+'</div>';
|
|
||||||
}
|
|
||||||
var contextHtml = (triggerCauseHtml || triggerBadgeHtml) ? '<div class="signal-context">'+triggerCauseHtml+triggerBadgeHtml+'</div>' : '';
|
|
||||||
return '<div class="card '+(isWeakObserve?'weak-observe':'')+'"><div class="card-bar"><div class="coin-left"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span></div></div><div class="badge-group">'+actionBadge+'<span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">'+st.label+'</span></span></div></div>'+
|
return '<div class="card '+(isWeakObserve?'weak-observe':'')+'"><div class="card-bar"><div class="coin-left"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span></div></div><div class="badge-group">'+actionBadge+'<span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">'+st.label+'</span></span></div></div>'+
|
||||||
'<div class="price-bar"><span class="price">$'+priceFmt+'</span>'+changeHtml+'</div>'+
|
'<div class="price-bar"><span class="price">$'+priceFmt+'</span>'+changeHtml+'</div>'+
|
||||||
|
decisionHtml+
|
||||||
'<div class="kline-wrap"><div class="kline-int-bar"><button class="kline-int-btn" data-int="15m" onclick="switchKlineInterval(this);event.stopPropagation()">15m</button><button class="kline-int-btn active" data-int="1h" onclick="switchKlineInterval(this);event.stopPropagation()">1H</button><button class="kline-int-btn" data-int="4h" onclick="switchKlineInterval(this);event.stopPropagation()">4H</button><button class="kline-int-btn" data-int="1d" onclick="switchKlineInterval(this);event.stopPropagation()">1D</button></div><div class="kline-container loading" data-symbol="'+r.symbol+'" data-entry-price="'+klineEntryPrice+'" data-stop-loss="'+klineStopLoss+'" data-tp1="'+klineTp1+'" data-rec-time="'+entryTime+'" data-tp1-time="'+tp1EventTime+'" data-sl-time="'+slEventTime+'" data-ref-price="'+price+'" data-action-status="'+(r.action_status||'')+'"><div class="chart-loading"><svg class="spin" width="16" height="16" color="#8e91a0"><use href="#svg-spinner"/></svg></div></div></div>'+
|
'<div class="kline-wrap"><div class="kline-int-bar"><button class="kline-int-btn" data-int="15m" onclick="switchKlineInterval(this);event.stopPropagation()">15m</button><button class="kline-int-btn active" data-int="1h" onclick="switchKlineInterval(this);event.stopPropagation()">1H</button><button class="kline-int-btn" data-int="4h" onclick="switchKlineInterval(this);event.stopPropagation()">4H</button><button class="kline-int-btn" data-int="1d" onclick="switchKlineInterval(this);event.stopPropagation()">1D</button></div><div class="kline-container loading" data-symbol="'+r.symbol+'" data-entry-price="'+klineEntryPrice+'" data-stop-loss="'+klineStopLoss+'" data-tp1="'+klineTp1+'" data-rec-time="'+entryTime+'" data-tp1-time="'+tp1EventTime+'" data-sl-time="'+slEventTime+'" data-ref-price="'+price+'" data-action-status="'+(r.action_status||'')+'"><div class="chart-loading"><svg class="spin" width="16" height="16" color="#8e91a0"><use href="#svg-spinner"/></svg></div></div></div>'+
|
||||||
(isWeakObserve ? weakNoteHtml : entryPlanHtml)+
|
(isWeakObserve ? weakNoteHtml : entryPlanHtml)+
|
||||||
(!isWeakObserve && trustHtml?'<div class="trust-row">'+trustHtml+'</div>':'')+
|
|
||||||
contextHtml+
|
|
||||||
(sigHtml?'<div class="signals-row">'+sigHtml+'</div>':'')+
|
(sigHtml?'<div class="signals-row">'+sigHtml+'</div>':'')+
|
||||||
'<div class="card-footer"><span>'+fmtTime(r.rec_time)+'</span><span class="card-ver">'+ver+'</span>'+(isTpOrSl?'<span class="pnl-block '+pnlCls+'">'+pnlSign+pnl.toFixed(1)+'%</span>':'')+'</div></div>';
|
'<div class="card-footer"><span>'+fmtTime(r.rec_time)+'</span><span class="card-ver">'+ver+'</span>'+(isTpOrSl?'<span class="pnl-block '+pnlCls+'">'+pnlSign+pnl.toFixed(1)+'%</span>':'')+'</div></div>';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,6 +65,7 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.sidebar-link.active { color: var(--on-primary); background: var(--primary); font-weight: 600; }
|
.sidebar-link.active { color: var(--on-primary); background: var(--primary); font-weight: 600; }
|
||||||
.sidebar-link .link-icon { width: 18px; height: 18px; flex-shrink: 0; opacity: .6; }
|
.sidebar-link .link-icon { width: 18px; height: 18px; flex-shrink: 0; opacity: .6; }
|
||||||
.sidebar-link.active .link-icon { opacity: 1; }
|
.sidebar-link.active .link-icon { opacity: 1; }
|
||||||
|
.sidebar-section-label { padding: 12px 14px 5px; color: var(--muted); font-size: 10px; font-weight: 900; letter-spacing: .08em; }
|
||||||
|
|
||||||
.sidebar-user {
|
.sidebar-user {
|
||||||
padding: 14px 16px calc(14px + var(--safe-bottom)); border-top: 1px solid var(--hairline-soft);
|
padding: 14px 16px calc(14px + var(--safe-bottom)); border-top: 1px solid var(--hairline-soft);
|
||||||
@ -151,6 +152,7 @@ a { color: inherit; text-decoration: none; }
|
|||||||
<symbol id="svg-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></symbol>
|
<symbol id="svg-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></symbol>
|
||||||
<symbol id="svg-spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" stroke-dasharray="31.4 31.4" stroke-linecap="round"/></symbol>
|
<symbol id="svg-spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" stroke-dasharray="31.4 31.4" stroke-linecap="round"/></symbol>
|
||||||
<symbol id="svg-dashboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></symbol>
|
<symbol id="svg-dashboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></symbol>
|
||||||
|
<symbol id="svg-pipeline" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="5" cy="6" r="2"/><circle cx="19" cy="6" r="2"/><circle cx="12" cy="18" r="2"/><path d="M7 6h10"/><path d="M6.5 7.7 11 16"/><path d="M17.5 7.7 13 16"/></symbol>
|
||||||
<symbol id="svg-iterate" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></symbol>
|
<symbol id="svg-iterate" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></symbol>
|
||||||
<symbol id="svg-sentiment" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z"/><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></symbol>
|
<symbol id="svg-sentiment" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z"/><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></symbol>
|
||||||
<symbol id="svg-subscribe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></symbol>
|
<symbol id="svg-subscribe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></symbol>
|
||||||
@ -169,12 +171,13 @@ a { color: inherit; text-decoration: none; }
|
|||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
{% block nav_links %}
|
{% block nav_links %}
|
||||||
<a class="sidebar-link active" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
<a class="sidebar-link active" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||||
<a class="sidebar-link" href="/watchlist"><svg class="link-icon"><use href="#svg-star"/></svg>关注</a>
|
|
||||||
<a class="sidebar-link" href="/strategy"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
|
||||||
<a class="sidebar-link" href="/iteration"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
|
||||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||||
|
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||||
|
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@ -2,12 +2,13 @@
|
|||||||
{% block title %}AlphaX — 策略进化{% endblock %}
|
{% block title %}AlphaX — 策略进化{% endblock %}
|
||||||
{% block nav_links %}
|
{% block nav_links %}
|
||||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||||
<a class="sidebar-link" href="/watchlist"><svg class="link-icon"><use href="#svg-star"/></svg>关注</a>
|
|
||||||
<a class="sidebar-link" href="/strategy"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
|
||||||
<a class="sidebar-link active" href="/iteration"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
|
||||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||||
|
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||||
|
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||||
|
<a class="sidebar-link active admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
62
static/pipeline.html
Normal file
62
static/pipeline.html
Normal file
File diff suppressed because one or more lines are too long
@ -3,12 +3,13 @@
|
|||||||
|
|
||||||
{% block nav_links %}
|
{% block nav_links %}
|
||||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||||
<a class="sidebar-link" href="/watchlist"><svg class="link-icon"><use href="#svg-star"/></svg>关注</a>
|
|
||||||
<a class="sidebar-link" href="/strategy"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
|
||||||
<a class="sidebar-link" href="/iteration"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
|
||||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||||
<a class="sidebar-link active" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
<a class="sidebar-link active" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||||
|
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||||
|
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@ -2,12 +2,13 @@
|
|||||||
{% block title %}AlphaX — 舆情雷达{% endblock %}
|
{% block title %}AlphaX — 舆情雷达{% endblock %}
|
||||||
{% block nav_links %}
|
{% block nav_links %}
|
||||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||||
<a class="sidebar-link" href="/watchlist"><svg class="link-icon"><use href="#svg-star"/></svg>关注</a>
|
|
||||||
<a class="sidebar-link" href="/strategy"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
|
||||||
<a class="sidebar-link" href="/iteration"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
|
||||||
<a class="sidebar-link active" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
<a class="sidebar-link active" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||||
|
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||||
|
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block extra_head_css %}
|
{% block extra_head_css %}
|
||||||
@ -283,4 +284,4 @@ loadFeed();
|
|||||||
// Auto-refresh every 5 minutes
|
// Auto-refresh every 5 minutes
|
||||||
setInterval(loadFeed, 300000);
|
setInterval(loadFeed, 300000);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -2,12 +2,13 @@
|
|||||||
{% block title %}策略 — AlphaX{% endblock %}
|
{% block title %}策略 — AlphaX{% endblock %}
|
||||||
{% block nav_links %}
|
{% block nav_links %}
|
||||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||||
<a class="sidebar-link" href="/watchlist"><svg class="link-icon"><use href="#svg-star"/></svg>关注</a>
|
|
||||||
<a class="sidebar-link active" href="/strategy"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
|
||||||
<a class="sidebar-link" href="/iteration"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
|
||||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||||
|
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||||
|
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||||
|
<a class="sidebar-link active admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block extra_head_css %}
|
{% block extra_head_css %}
|
||||||
|
|||||||
@ -2,12 +2,13 @@
|
|||||||
{% block title %}订阅中心 — AlphaX{% endblock %}
|
{% block title %}订阅中心 — AlphaX{% endblock %}
|
||||||
{% block nav_links %}
|
{% block nav_links %}
|
||||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||||
<a class="sidebar-link" href="/watchlist"><svg class="link-icon"><use href="#svg-star"/></svg>关注</a>
|
|
||||||
<a class="sidebar-link" href="/strategy"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
|
||||||
<a class="sidebar-link" href="/iteration"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
|
||||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||||
<a class="sidebar-link active" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
<a class="sidebar-link active" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||||
|
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||||
|
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block extra_head_css %}
|
{% block extra_head_css %}
|
||||||
@ -253,4 +254,4 @@ async function claimFreeTrial() {
|
|||||||
loadUser();
|
loadUser();
|
||||||
loadMe();
|
loadMe();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -2,12 +2,13 @@
|
|||||||
{% block title %}关注 — AlphaX{% endblock %}
|
{% block title %}关注 — AlphaX{% endblock %}
|
||||||
{% block nav_links %}
|
{% block nav_links %}
|
||||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||||
<a class="sidebar-link active" href="/watchlist"><svg class="link-icon"><use href="#svg-star"/></svg>关注</a>
|
|
||||||
<a class="sidebar-link" href="/strategy"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
|
||||||
<a class="sidebar-link" href="/iteration"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
|
||||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||||
|
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||||
|
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block extra_head_css %}
|
{% block extra_head_css %}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import sys
|
|||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
from app.core.opportunity_lifecycle import apply_entry_quality_gate
|
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
|
from legacy import price_tracker_ws
|
||||||
|
|
||||||
|
|
||||||
@ -28,6 +29,46 @@ def test_risk_reward_false_blocks_buy_now():
|
|||||||
assert any('risk_reward_ok=false' in r for r in reasons)
|
assert any('risk_reward_ok=false' in r for r in reasons)
|
||||||
|
|
||||||
|
|
||||||
|
def test_buy_now_with_bad_rr_sets_real_pullback_price():
|
||||||
|
action, plan, reasons = apply_entry_quality_gate(
|
||||||
|
action_status='可即刻买入',
|
||||||
|
entry_plan={
|
||||||
|
'entry_action': '即刻买入',
|
||||||
|
'entry_price': 0.11455,
|
||||||
|
'current_price': 0.11455,
|
||||||
|
'stop_loss': 0.107457,
|
||||||
|
'tp1': 0.120089,
|
||||||
|
'risk_reward_ok': False,
|
||||||
|
'rr1': 0.83,
|
||||||
|
},
|
||||||
|
signals=['🟢 15min即刻入场信号', '日线 站稳突破位+19.2%'],
|
||||||
|
current_price=0.11455,
|
||||||
|
market_context={'change_24h': 3.1},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert action == '等回踩'
|
||||||
|
assert plan['entry_price'] < 0.11455
|
||||||
|
assert round(plan['entry_price'], 6) == 0.113199
|
||||||
|
assert plan['rr_target_entry'] == plan['entry_price']
|
||||||
|
assert any('现价不买' in r for r in reasons)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tracker_gate_downgrade_removes_provisional_buy_signal():
|
||||||
|
signals = reconcile_buy_signals_after_gate(
|
||||||
|
[
|
||||||
|
'🟢 回踩确认完毕!可即刻入场(15min动K确认)',
|
||||||
|
'其他背景信号',
|
||||||
|
],
|
||||||
|
'等回踩',
|
||||||
|
{'rr_target_entry': 0.11322245, 'entry_price': 0.11322245},
|
||||||
|
['rr1=0.82 < 1.2,禁止现价买入', '现价不买,等回落到0.11322245附近再评估'],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert all('可即刻入场' not in signal for signal in signals)
|
||||||
|
assert all('回踩确认完毕' not in signal for signal in signals)
|
||||||
|
assert any('现价不买' in signal and '$0.1132' in signal for signal in signals)
|
||||||
|
|
||||||
|
|
||||||
def test_breakout_distance_over_60_forces_observe():
|
def test_breakout_distance_over_60_forces_observe():
|
||||||
action, plan, reasons = apply_entry_quality_gate(
|
action, plan, reasons = apply_entry_quality_gate(
|
||||||
action_status='可即刻买入',
|
action_status='可即刻买入',
|
||||||
|
|||||||
276
tests/test_pipeline_runs_api.py
Normal file
276
tests/test_pipeline_runs_api.py
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
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.db import altcoin_db
|
||||||
|
from app.db.analytics import get_pipeline_run_detail, get_pipeline_runs
|
||||||
|
from app.web import web_server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_db(monkeypatch, tmp_path):
|
||||||
|
db_path = tmp_path / "altcoin_monitor.db"
|
||||||
|
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
|
||||||
|
monkeypatch.setattr(web_server, "init_db", altcoin_db.init_db)
|
||||||
|
altcoin_db.init_db()
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_screening(db_path, scan_time, layer, symbol, state="蓄力", score=6):
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO screening_log (
|
||||||
|
scan_time, layer, symbol, state, score, price, signals,
|
||||||
|
sector, leader_status, is_meme, change_24h, funding_rate, detail_json
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
scan_time,
|
||||||
|
layer,
|
||||||
|
symbol,
|
||||||
|
state,
|
||||||
|
score,
|
||||||
|
1.23,
|
||||||
|
'["vp_fly_1h_current"]',
|
||||||
|
"AI",
|
||||||
|
"leader",
|
||||||
|
0,
|
||||||
|
8.8,
|
||||||
|
0.01,
|
||||||
|
'{"reason":"volume current"}',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_coin_state(db_path, symbol, state, score, detected_at):
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
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, state, score, "", "", "", detected_at, detected_at, "low", "{}"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_recommendation(db_path, rec_time, symbol="AAA/USDT", status="hit_tp1"):
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO recommendation (
|
||||||
|
symbol, rec_time, rec_state, rec_score, entry_price, stop_loss, tp1, tp2,
|
||||||
|
sector, signals, status, current_price, max_price, min_price, pnl_pct,
|
||||||
|
max_pnl_pct, max_drawdown_pct, entry_plan_json, action_status,
|
||||||
|
execution_status, display_bucket, lifecycle_state, entry_triggered,
|
||||||
|
signal_codes_json, signal_labels_json
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
symbol,
|
||||||
|
rec_time,
|
||||||
|
"爆发",
|
||||||
|
82,
|
||||||
|
1.0,
|
||||||
|
0.94,
|
||||||
|
1.08,
|
||||||
|
1.16,
|
||||||
|
"AI",
|
||||||
|
'["1H当前放量"]',
|
||||||
|
status,
|
||||||
|
1.1,
|
||||||
|
1.12,
|
||||||
|
0.98,
|
||||||
|
10,
|
||||||
|
12,
|
||||||
|
-2,
|
||||||
|
'{"entry_action":"可即刻买入"}',
|
||||||
|
"可即刻买入",
|
||||||
|
"buy_now",
|
||||||
|
"actionable",
|
||||||
|
"actionable",
|
||||||
|
1,
|
||||||
|
'["vp_fly_1h_current"]',
|
||||||
|
'["1H当前放量"]',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_review(db_path, rec_id, review_time, outcome="爆发"):
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO review_log (
|
||||||
|
rec_id, symbol, review_time, outcome, pnl_48h, max_pnl_48h,
|
||||||
|
triggered_signals, hit_signals, miss_signals, lesson
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
rec_id,
|
||||||
|
"AAA/USDT",
|
||||||
|
review_time,
|
||||||
|
outcome,
|
||||||
|
6.5,
|
||||||
|
12.0,
|
||||||
|
'["vp_fly_1h_current"]',
|
||||||
|
'["vp_fly_1h_current"]',
|
||||||
|
"[]",
|
||||||
|
"当前放量有效",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_missed(db_path, detect_time):
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO missed_explosions (
|
||||||
|
symbol, detect_time, price_at_detect, price_before,
|
||||||
|
gain_pct, reason_missed, features_detected, lesson
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
("MISS/USDT", detect_time, 2.4, 1.9, 26.3, "确认没过", '{"volume":"high"}', "提高确认层覆盖"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_runs_aggregates_funnel_and_performance(temp_db):
|
||||||
|
base = datetime.now() - timedelta(minutes=40)
|
||||||
|
started = base.isoformat(timespec="seconds")
|
||||||
|
finished = (base + timedelta(seconds=20)).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
altcoin_db.log_cron_run(
|
||||||
|
"事件舆情",
|
||||||
|
"event_driven_screener.py",
|
||||||
|
"success",
|
||||||
|
"processed",
|
||||||
|
started_at=(base - timedelta(minutes=3)).isoformat(timespec="seconds"),
|
||||||
|
finished_at=(base - timedelta(minutes=2)).isoformat(timespec="seconds"),
|
||||||
|
summary={"processed_count": 5},
|
||||||
|
)
|
||||||
|
altcoin_db.log_cron_run(
|
||||||
|
"粗筛",
|
||||||
|
"altcoin_screener.py",
|
||||||
|
"success",
|
||||||
|
"screened",
|
||||||
|
started_at=started,
|
||||||
|
finished_at=finished,
|
||||||
|
duration_ms=20000,
|
||||||
|
summary={"total_candidates": 3, "total_qualified": 2, "alert_count": 2},
|
||||||
|
)
|
||||||
|
altcoin_db.log_cron_run(
|
||||||
|
"确认",
|
||||||
|
"altcoin_confirm.py",
|
||||||
|
"success",
|
||||||
|
"confirmed",
|
||||||
|
started_at=(base + timedelta(minutes=5)).isoformat(timespec="seconds"),
|
||||||
|
finished_at=(base + timedelta(minutes=6)).isoformat(timespec="seconds"),
|
||||||
|
summary={"processed_count": 2, "confirmed_count": 1, "unconfirmed_count": 1},
|
||||||
|
)
|
||||||
|
_insert_screening(temp_db, (base + timedelta(seconds=5)).isoformat(timespec="seconds"), "粗筛", "AAA/USDT")
|
||||||
|
_insert_screening(temp_db, (base + timedelta(seconds=6)).isoformat(timespec="seconds"), "细筛", "AAA/USDT")
|
||||||
|
rec_id = _insert_recommendation(temp_db, (base + timedelta(minutes=7)).isoformat(timespec="seconds"))
|
||||||
|
_insert_review(temp_db, rec_id, (base + timedelta(minutes=8)).isoformat(timespec="seconds"), outcome="爆发")
|
||||||
|
_insert_missed(temp_db, (base + timedelta(minutes=9)).isoformat(timespec="seconds"))
|
||||||
|
|
||||||
|
data = get_pipeline_runs(limit=10, hours=24)
|
||||||
|
assert data["kpi"]["run_count"] == 1
|
||||||
|
assert data["kpi"]["rough_candidates"] == 3
|
||||||
|
assert data["kpi"]["fine_qualified"] == 2
|
||||||
|
assert data["kpi"]["confirm_hits"] == 1
|
||||||
|
assert data["kpi"]["recommendations"] == 1
|
||||||
|
assert data["kpi"]["perf_success"] == 1
|
||||||
|
assert data["kpi"]["missed_count"] == 1
|
||||||
|
|
||||||
|
run = data["runs"][0]
|
||||||
|
detail = get_pipeline_run_detail(run["run_id"])
|
||||||
|
assert detail["stage_counts"]["observation"] == 1
|
||||||
|
assert detail["stage_counts"]["fine"] == 1
|
||||||
|
assert detail["stage_counts"]["recommendation"] == 1
|
||||||
|
assert detail["recommendations"][0]["performance_status"] == "success"
|
||||||
|
assert detail["missed_explosions"][0]["symbol"] == "MISS/USDT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_api_keeps_observation_batch_without_recommendations(temp_db):
|
||||||
|
base = datetime.now() - timedelta(minutes=20)
|
||||||
|
altcoin_db.log_cron_run(
|
||||||
|
"粗筛",
|
||||||
|
"altcoin_screener.py",
|
||||||
|
"success",
|
||||||
|
"screened",
|
||||||
|
started_at=base.isoformat(timespec="seconds"),
|
||||||
|
finished_at=(base + timedelta(seconds=10)).isoformat(timespec="seconds"),
|
||||||
|
summary={"total_candidates": 1, "total_qualified": 0},
|
||||||
|
)
|
||||||
|
_insert_screening(temp_db, (base + timedelta(seconds=2)).isoformat(timespec="seconds"), "粗筛", "OBS/USDT", score=4)
|
||||||
|
|
||||||
|
client = TestClient(web_server.app)
|
||||||
|
resp = client.get("/api/pipeline/runs?hours=24")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["runs"][0]["rough_candidates"] == 1
|
||||||
|
assert data["runs"][0]["recommendations"] == 0
|
||||||
|
|
||||||
|
detail = client.get(f"/api/pipeline/runs/{data['runs'][0]['run_id']}").json()
|
||||||
|
assert detail["screening_items"][0]["symbol"] == "OBS/USDT"
|
||||||
|
assert detail["screening_items"][0]["stage_label"] == "观察候选"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_page_nav_hides_watchlist_entry_and_watchlist_route_survives(temp_db):
|
||||||
|
client = TestClient(web_server.app)
|
||||||
|
|
||||||
|
pipeline_resp = client.get("/pipeline")
|
||||||
|
assert pipeline_resp.status_code == 200
|
||||||
|
html = pipeline_resp.text
|
||||||
|
assert "链路日志" in html
|
||||||
|
assert 'href="/pipeline"' in html
|
||||||
|
assert 'href="/watchlist"' not in html
|
||||||
|
|
||||||
|
watch_resp = client.get("/watchlist")
|
||||||
|
assert watch_resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_nav_keeps_research_pages_in_admin_section(temp_db):
|
||||||
|
client = TestClient(web_server.app)
|
||||||
|
resp = client.get("/app")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.text
|
||||||
|
|
||||||
|
assert 'href="/watchlist"' not in html
|
||||||
|
assert 'href="/pipeline" style="display:none"' in html
|
||||||
|
assert 'href="/strategy" style="display:none"' in html
|
||||||
|
assert 'href="/iteration" style="display:none"' in html
|
||||||
|
assert "研发" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_candidates_prefer_recent_fine_screened_state(temp_db):
|
||||||
|
from app.db.altcoin_db import get_candidates_for_confirm
|
||||||
|
|
||||||
|
old_time = (datetime.now() - timedelta(hours=7)).isoformat(timespec="seconds")
|
||||||
|
recent_time = (datetime.now() - timedelta(minutes=5)).isoformat(timespec="seconds")
|
||||||
|
_insert_coin_state(temp_db, "CHIP/USDT", "蓄力", 5, old_time)
|
||||||
|
_insert_coin_state(temp_db, "DOGE/USDT", "蓄力", 3, recent_time)
|
||||||
|
|
||||||
|
symbols = [item["symbol"] for item in get_candidates_for_confirm()]
|
||||||
|
|
||||||
|
assert symbols == ["DOGE/USDT"]
|
||||||
@ -237,3 +237,45 @@ def test_strong_static_accumulation_can_promote_to_accelerate(monkeypatch):
|
|||||||
assert any("强静K蓄力直升加速" in s for s in qualified["PNT/USDT"]["signals"])
|
assert any("强静K蓄力直升加速" in s for s in qualified["PNT/USDT"]["signals"])
|
||||||
assert qualified["PNT/USDT"]["candidate_stage"] == "confirm_pending"
|
assert qualified["PNT/USDT"]["candidate_stage"] == "confirm_pending"
|
||||||
assert "rec_id" not in qualified["PNT/USDT"]
|
assert "rec_id" not in qualified["PNT/USDT"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_layer1_logs_coarse_candidate_details(monkeypatch):
|
||||||
|
logged = []
|
||||||
|
h4_df = pd.DataFrame({
|
||||||
|
"open": [1.0] * 24,
|
||||||
|
"high": [1.01] * 24,
|
||||||
|
"low": [0.99] * 24,
|
||||||
|
"close": [1.0] * 24,
|
||||||
|
"volume": [1000] * 24,
|
||||||
|
})
|
||||||
|
|
||||||
|
monkeypatch.setattr(altcoin_screener, "fetch_all_tickers", lambda: {
|
||||||
|
"DOGE/USDT": {"volume_24h": 20_000_000, "change_24h": 3.5, "price": 0.1},
|
||||||
|
})
|
||||||
|
monkeypatch.setattr(altcoin_screener, "fetch_funding_rates", lambda: {})
|
||||||
|
monkeypatch.setattr(altcoin_screener, "get_dynamic_weights", _mock_weights)
|
||||||
|
monkeypatch.setattr(altcoin_screener, "is_meme_coin", lambda symbol: False)
|
||||||
|
monkeypatch.setattr(altcoin_screener, "get_burst_threshold", lambda symbol: 20)
|
||||||
|
monkeypatch.setattr(altcoin_screener, "fetch_klines", lambda symbol, timeframe, limit=200: h4_df if timeframe == "4h" else None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
altcoin_screener,
|
||||||
|
"detect_static_accumulation",
|
||||||
|
lambda symbol, df: {"static_count": 6, "vol_ratio": 1.5},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
altcoin_screener,
|
||||||
|
"get_screener_section",
|
||||||
|
lambda name=None: {
|
||||||
|
"static_accumulation_bypass": {"min_volume_24h": 1_000_000, "min_vol_ratio": 1.2},
|
||||||
|
"higher_lows": {"enabled": False},
|
||||||
|
"compression_surge": {"enabled": False},
|
||||||
|
"sentiment": {"enabled": False},
|
||||||
|
}.get(name, {}),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(altcoin_screener.exchange, "fapiPublicGetTicker24hr", lambda: [])
|
||||||
|
monkeypatch.setattr(altcoin_screener, "log_screening", lambda **kwargs: logged.append(kwargs))
|
||||||
|
|
||||||
|
candidates = altcoin_screener.layer1_coarse_filter()
|
||||||
|
|
||||||
|
assert "DOGE/USDT" in candidates
|
||||||
|
assert any(item["layer"] == "粗筛" and item["symbol"] == "DOGE/USDT" for item in logged)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user