新增 onchain

This commit is contained in:
aaron 2026-05-15 11:50:26 +08:00
parent d2e2032352
commit e80431eb89
28 changed files with 2164 additions and 8 deletions

View File

@ -32,6 +32,24 @@ ALPHAX_LLM_RECOMMENDATIONS_ENABLED=1
ALPHAX_LLM_SENTIMENT_ENABLED=1
ALPHAX_LLM_REVIEW_ENABLED=1
# 链上追踪运行时配置。默认关闭;开启后采集结果只作为发现/风控辅助。
ALPHAX_ONCHAIN_ENABLED=0
ALPHAX_ONCHAIN_CHAINS=ethereum,bsc,base,arbitrum,solana
ALPHAX_ONCHAIN_TIMEOUT=15
ALPHAX_ONCHAIN_CANDIDATE_ENABLED=1
ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE=70
ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE=70
ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS=6
ALPHAX_ONCHAIN_DEXSCREENER_ENABLED=1
ALPHAX_ONCHAIN_DEX_VOLUME_SPIKE_PCT=80
ALPHAX_ONCHAIN_DEX_MIN_LIQUIDITY_USD=100000
ALPHAX_ONCHAIN_DEX_MIN_VOLUME_24H_USD=100000
ALPHAX_ONCHAIN_LIQUIDITY_ADD_PCT=25
ALPHAX_ONCHAIN_LIQUIDITY_REMOVE_PCT=-25
ALPHAX_ONCHAIN_WHALE_TX_USD=250000
ALPHAX_ETHERSCAN_API_KEY=
ALPHAX_HELIUS_API_KEY=
# 邮箱验证码 SMTP 配置。没有配置时,注册验证码只会生成,不会发邮件。
ASTOCK_SMTP_HOST=
ASTOCK_SMTP_PORT=465

View File

@ -2,7 +2,7 @@
import argparse
from app.services import altcoin_confirm, altcoin_screener, event_driven_screener, price_tracker, review_engine, sentiment_monitor
from app.services import altcoin_confirm, altcoin_screener, event_driven_screener, onchain_monitor, price_tracker, review_engine, sentiment_monitor
def build_parser():
@ -29,6 +29,9 @@ def build_parser():
sentiment.add_argument("--check", action="store_true", help="输出舆情异动")
sentiment.add_argument("--scores", action="store_true", help="输出评分")
onchain = subparsers.add_parser("onchain", help="运行链上追踪任务")
onchain.add_argument("--limit", type=int, default=60, help="本轮最多处理的 token 映射数量")
llm = subparsers.add_parser("llm-insights", help="异步生成 LLM 缓存解释")
llm.add_argument("--scope", choices=["recommendations", "sentiment", "sentiment-events", "review"], default="recommendations")
llm.add_argument("--limit", type=int, default=30)
@ -74,6 +77,8 @@ def main():
}
print(sentiment_monitor.json.dumps(result, ensure_ascii=False, indent=2))
return result
if args.command == "onchain":
return onchain_monitor.run_once(limit=args.limit)
if args.command == "llm-insights":
from app.services import llm_insights

View File

@ -31,6 +31,14 @@ SIGNAL_CODE_LABELS = {
"top_trader_long": "大户偏多",
"sector_rotation": "板块联动",
"sentiment_resonance": "舆情共振",
"dex_volume_spike": "DEX 放量",
"liquidity_add": "流动性增加",
"liquidity_remove_risk": "流动性撤出风险",
"exchange_outflow": "交易所流出",
"exchange_inflow_risk": "交易所流入风险",
"whale_accumulation": "鲸鱼增持",
"holder_concentration_risk": "持仓集中风险",
"smart_money_buying": "聪明钱买入",
"funding_extreme": "资金费率极端",
"trend_exhaustion": "趋势衰减",
"false_breakout": "假突破",
@ -61,6 +69,14 @@ _PATTERNS = [
("top_trader_long", ("大户偏多",)),
("sector_rotation", ("板块联动", "龙头")),
("sentiment_resonance", ("舆情共振",)),
("dex_volume_spike", ("DEX", "放量")),
("liquidity_add", ("流动性", "增加")),
("liquidity_remove_risk", ("流动性", "撤出")),
("exchange_outflow", ("交易所", "流出")),
("exchange_inflow_risk", ("交易所", "流入")),
("whale_accumulation", ("鲸鱼", "增持")),
("holder_concentration_risk", ("持仓集中",)),
("smart_money_buying", ("聪明钱", "买入")),
("funding_extreme", ("资金费率极端",)),
("trend_exhaustion", ("衰减", "反转", "阴动K")),
("false_breakout", ("假突破", "冲高回落")),

786
app/db/onchain_db.py Normal file
View File

@ -0,0 +1,786 @@
"""On-chain discovery storage and read models.
The on-chain layer is a research/discovery input. It stores normalized external
facts and can enqueue technical-check candidates, but it must not create or
mutate trading recommendations directly.
"""
import json
import sqlite3
from datetime import datetime, timedelta
from app.db.altcoin_db import get_conn
MIN_MAPPING_CONFIDENCE = 70
SIGNAL_LABELS = {
"dex_volume_spike": "DEX 放量",
"liquidity_add": "流动性增加",
"liquidity_remove_risk": "流动性撤出风险",
"exchange_outflow": "交易所流出",
"exchange_inflow_risk": "交易所流入风险",
"whale_accumulation": "鲸鱼增持",
"holder_concentration_risk": "持仓集中风险",
"smart_money_buying": "聪明钱买入",
}
RAW_EVENT_TYPE_LABELS = {
"token_profile_latest": "Token 资料更新",
"token_boost_latest": "DEX 热度 Boost",
"token_boost_top": "DEX Boost 榜",
}
POSITIVE_SIGNALS = {"dex_volume_spike", "liquidity_add", "exchange_outflow", "whale_accumulation", "smart_money_buying"}
RISK_SIGNALS = {"liquidity_remove_risk", "exchange_inflow_risk", "holder_concentration_risk"}
def _now():
return datetime.now().isoformat()
def _dump(value):
return json.dumps(value or {}, ensure_ascii=False, sort_keys=True, default=str)
def _load(value, fallback=None):
try:
if isinstance(value, str) and value.strip():
return json.loads(value)
if value is not None:
return value
except Exception:
pass
return fallback
def _symbol_base(symbol):
return str(symbol or "").upper().replace("/USDT", "").replace("USDT", "").strip()
def normalize_symbol(symbol):
base = _symbol_base(symbol)
return f"{base}/USDT" if base else ""
def signal_label(code):
return SIGNAL_LABELS.get(str(code or ""), str(code or "链上信号"))
def signal_direction(code):
code = str(code or "")
if code in RISK_SIGNALS:
return "risk"
if code in POSITIVE_SIGNALS:
return "positive"
return "neutral"
def raw_event_type_label(event_type):
return RAW_EVENT_TYPE_LABELS.get(str(event_type or ""), str(event_type or "链上原始事件"))
def init_onchain_tables():
conn = get_conn()
conn.execute(
"""
CREATE TABLE IF NOT EXISTS onchain_token_map (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
chain TEXT NOT NULL,
contract_address TEXT NOT NULL,
source TEXT DEFAULT '',
confidence INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
raw_json TEXT DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_onchain_token_map_unique
ON onchain_token_map(symbol, chain, contract_address)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_onchain_token_map_symbol ON onchain_token_map(symbol, confidence, is_active)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS onchain_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_hash TEXT UNIQUE,
chain TEXT NOT NULL,
symbol TEXT NOT NULL,
contract_address TEXT DEFAULT '',
event_type TEXT NOT NULL,
signal_code TEXT NOT NULL,
signal_label TEXT DEFAULT '',
direction TEXT DEFAULT 'neutral',
value_usd REAL DEFAULT 0,
amount REAL DEFAULT 0,
tx_hash TEXT DEFAULT '',
wallet_address TEXT DEFAULT '',
wallet_label TEXT DEFAULT '',
counterparty_label TEXT DEFAULT '',
confidence INTEGER DEFAULT 0,
severity TEXT DEFAULT 'B',
status TEXT DEFAULT 'new',
detected_at TEXT NOT NULL,
source TEXT DEFAULT '',
url TEXT DEFAULT '',
raw_json TEXT DEFAULT '{}'
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_onchain_events_time ON onchain_events(detected_at, signal_code)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_onchain_events_symbol ON onchain_events(symbol, detected_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_onchain_events_chain ON onchain_events(chain, detected_at)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS onchain_token_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
chain TEXT NOT NULL,
contract_address TEXT DEFAULT '',
window TEXT NOT NULL,
metric_time TEXT NOT NULL,
dex_volume_usd REAL DEFAULT 0,
dex_volume_change_pct REAL DEFAULT 0,
liquidity_usd REAL DEFAULT 0,
liquidity_change_pct REAL DEFAULT 0,
exchange_netflow_usd REAL DEFAULT 0,
whale_accumulation_usd REAL DEFAULT 0,
holder_delta REAL DEFAULT 0,
smart_money_score REAL DEFAULT 0,
onchain_score REAL DEFAULT 0,
risk_score REAL DEFAULT 0,
source TEXT DEFAULT '',
raw_json TEXT DEFAULT '{}'
)
"""
)
conn.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_onchain_metrics_unique
ON onchain_token_metrics(symbol, chain, contract_address, window, metric_time)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_onchain_metrics_symbol ON onchain_token_metrics(symbol, metric_time)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS onchain_raw_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_hash TEXT UNIQUE,
source TEXT NOT NULL,
chain TEXT NOT NULL,
event_type TEXT NOT NULL,
token_address TEXT DEFAULT '',
symbol_guess TEXT DEFAULT '',
name TEXT DEFAULT '',
title TEXT DEFAULT '',
description TEXT DEFAULT '',
url TEXT DEFAULT '',
icon TEXT DEFAULT '',
amount REAL DEFAULT 0,
total_amount REAL DEFAULT 0,
importance REAL DEFAULT 0,
mapped_symbol TEXT DEFAULT '',
mapping_status TEXT DEFAULT 'unmapped',
detected_at TEXT NOT NULL,
raw_json TEXT DEFAULT '{}'
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_onchain_raw_events_time ON onchain_raw_events(detected_at, importance)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_onchain_raw_events_chain ON onchain_raw_events(chain, detected_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_onchain_raw_events_mapping ON onchain_raw_events(mapping_status, detected_at)")
conn.commit()
conn.close()
def upsert_token_mapping(symbol, chain, contract_address, source="", confidence=0, raw=None, is_active=True):
init_onchain_tables()
now = _now()
symbol = normalize_symbol(symbol)
chain = str(chain or "").lower().strip()
contract_address = str(contract_address or "").strip()
if not symbol or not chain or not contract_address:
return 0
conn = get_conn()
cur = conn.execute(
"""
INSERT INTO onchain_token_map
(symbol, chain, contract_address, source, confidence, is_active, raw_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(symbol, chain, contract_address) DO UPDATE SET
source=excluded.source,
confidence=MAX(onchain_token_map.confidence, excluded.confidence),
is_active=excluded.is_active,
raw_json=excluded.raw_json,
updated_at=excluded.updated_at
""",
(symbol, chain, contract_address, source or "", int(confidence or 0), 1 if is_active else 0, _dump(raw), now, now),
)
conn.commit()
row = conn.execute(
"SELECT id FROM onchain_token_map WHERE symbol=? AND chain=? AND contract_address=?",
(symbol, chain, contract_address),
).fetchone()
conn.close()
return int(row["id"] if row else cur.lastrowid or 0)
def get_token_mappings(symbol="", min_confidence=MIN_MAPPING_CONFIDENCE, active_only=True):
init_onchain_tables()
clauses = ["confidence >= ?"]
params = [int(min_confidence or 0)]
if symbol:
clauses.append("symbol=?")
params.append(normalize_symbol(symbol))
if active_only:
clauses.append("is_active=1")
conn = get_conn()
rows = conn.execute(
f"""
SELECT * FROM onchain_token_map
WHERE {' AND '.join(clauses)}
ORDER BY confidence DESC, updated_at DESC
""",
tuple(params),
).fetchall()
conn.close()
return [dict(row) for row in rows]
def _event_hash(event):
raw = "|".join(
[
str(event.get("source") or ""),
str(event.get("chain") or ""),
str(event.get("symbol") or ""),
str(event.get("signal_code") or event.get("event_type") or ""),
str(event.get("tx_hash") or event.get("contract_address") or ""),
str(event.get("detected_at") or ""),
str(round(float(event.get("value_usd") or 0), 2)),
]
).lower()
import hashlib
return hashlib.sha256(raw.encode()).hexdigest()[:24]
def _raw_event_hash(event):
raw = "|".join(
[
str(event.get("source") or ""),
str(event.get("chain") or ""),
str(event.get("event_type") or ""),
str(event.get("token_address") or ""),
str(event.get("url") or ""),
str(round(float(event.get("amount") or 0), 4)),
str(round(float(event.get("total_amount") or 0), 4)),
]
).lower()
import hashlib
return hashlib.sha256(raw.encode()).hexdigest()[:24]
def insert_onchain_event(event):
init_onchain_tables()
item = dict(event or {})
item["symbol"] = normalize_symbol(item.get("symbol"))
item["chain"] = str(item.get("chain") or "").lower().strip()
item["signal_code"] = str(item.get("signal_code") or "").strip()
item["event_type"] = str(item.get("event_type") or item["signal_code"] or "onchain_event")
if not item["symbol"] or not item["chain"] or not item["signal_code"]:
return 0
item["signal_label"] = item.get("signal_label") or signal_label(item["signal_code"])
item["direction"] = item.get("direction") or signal_direction(item["signal_code"])
item["detected_at"] = str(item.get("detected_at") or _now())
item["event_hash"] = item.get("event_hash") or _event_hash(item)
conn = get_conn()
try:
cur = conn.execute(
"""
INSERT INTO onchain_events (
event_hash, chain, symbol, contract_address, event_type, signal_code, signal_label,
direction, value_usd, amount, tx_hash, wallet_address, wallet_label,
counterparty_label, confidence, severity, status, detected_at, source, url, raw_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
item["event_hash"],
item["chain"],
item["symbol"],
item.get("contract_address") or "",
item["event_type"],
item["signal_code"],
item["signal_label"],
item["direction"],
float(item.get("value_usd") or 0),
float(item.get("amount") or 0),
item.get("tx_hash") or "",
item.get("wallet_address") or "",
item.get("wallet_label") or "",
item.get("counterparty_label") or "",
int(item.get("confidence") or 0),
item.get("severity") or "B",
item.get("status") or "new",
item["detected_at"],
item.get("source") or "",
item.get("url") or "",
_dump(item.get("raw") or item.get("raw_json") or {}),
),
)
conn.commit()
event_id = int(cur.lastrowid or 0)
except sqlite3.IntegrityError:
event_id = 0
finally:
conn.close()
return event_id
def find_mapping_by_contract(chain, contract_address):
init_onchain_tables()
chain = str(chain or "").lower().strip()
contract_address = str(contract_address or "").strip()
if not chain or not contract_address:
return None
conn = get_conn()
row = conn.execute(
"""
SELECT *
FROM onchain_token_map
WHERE chain=? AND lower(contract_address)=lower(?) AND is_active=1
ORDER BY confidence DESC, updated_at DESC
LIMIT 1
""",
(chain, contract_address),
).fetchone()
conn.close()
return dict(row) if row else None
def insert_onchain_raw_event(event):
init_onchain_tables()
item = dict(event or {})
item["source"] = str(item.get("source") or "").strip()
item["chain"] = str(item.get("chain") or "").lower().strip()
item["event_type"] = str(item.get("event_type") or "onchain_raw_event").strip()
item["token_address"] = str(item.get("token_address") or "").strip()
if not item["source"] or not item["chain"] or not item["event_type"] or not item["token_address"]:
return 0
item["detected_at"] = str(item.get("detected_at") or _now())
item["event_hash"] = item.get("event_hash") or _raw_event_hash(item)
item["mapped_symbol"] = normalize_symbol(item.get("mapped_symbol")) if item.get("mapped_symbol") else ""
item["mapping_status"] = str(item.get("mapping_status") or ("mapped" if item["mapped_symbol"] else "unmapped"))
conn = get_conn()
try:
cur = conn.execute(
"""
INSERT INTO onchain_raw_events (
event_hash, source, chain, event_type, token_address, symbol_guess, name,
title, description, url, icon, amount, total_amount, importance,
mapped_symbol, mapping_status, detected_at, raw_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
item["event_hash"],
item["source"],
item["chain"],
item["event_type"],
item["token_address"],
item.get("symbol_guess") or "",
item.get("name") or "",
item.get("title") or raw_event_type_label(item["event_type"]),
item.get("description") or "",
item.get("url") or "",
item.get("icon") or "",
float(item.get("amount") or 0),
float(item.get("total_amount") or 0),
float(item.get("importance") or 0),
item["mapped_symbol"],
item["mapping_status"],
item["detected_at"],
_dump(item.get("raw") or item.get("raw_json") or {}),
),
)
conn.commit()
event_id = int(cur.lastrowid or 0)
except sqlite3.IntegrityError:
event_id = 0
finally:
conn.close()
return event_id
def insert_token_metric(metric):
init_onchain_tables()
item = dict(metric or {})
item["symbol"] = normalize_symbol(item.get("symbol"))
item["chain"] = str(item.get("chain") or "").lower().strip()
item["window"] = str(item.get("window") or "1h").strip()
item["metric_time"] = str(item.get("metric_time") or _now())
if not item["symbol"] or not item["chain"] or not item["window"]:
return 0
conn = get_conn()
cur = conn.execute(
"""
INSERT INTO onchain_token_metrics (
symbol, chain, contract_address, window, metric_time,
dex_volume_usd, dex_volume_change_pct, liquidity_usd, liquidity_change_pct,
exchange_netflow_usd, whale_accumulation_usd, holder_delta, smart_money_score,
onchain_score, risk_score, source, raw_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(symbol, chain, contract_address, window, metric_time) DO UPDATE SET
dex_volume_usd=excluded.dex_volume_usd,
dex_volume_change_pct=excluded.dex_volume_change_pct,
liquidity_usd=excluded.liquidity_usd,
liquidity_change_pct=excluded.liquidity_change_pct,
exchange_netflow_usd=excluded.exchange_netflow_usd,
whale_accumulation_usd=excluded.whale_accumulation_usd,
holder_delta=excluded.holder_delta,
smart_money_score=excluded.smart_money_score,
onchain_score=excluded.onchain_score,
risk_score=excluded.risk_score,
source=excluded.source,
raw_json=excluded.raw_json
""",
(
item["symbol"],
item["chain"],
item.get("contract_address") or "",
item["window"],
item["metric_time"],
float(item.get("dex_volume_usd") or 0),
float(item.get("dex_volume_change_pct") or 0),
float(item.get("liquidity_usd") or 0),
float(item.get("liquidity_change_pct") or 0),
float(item.get("exchange_netflow_usd") or 0),
float(item.get("whale_accumulation_usd") or 0),
float(item.get("holder_delta") or 0),
float(item.get("smart_money_score") or 0),
float(item.get("onchain_score") or 0),
float(item.get("risk_score") or 0),
item.get("source") or "",
_dump(item.get("raw") or item.get("raw_json") or {}),
),
)
conn.commit()
conn.close()
return int(cur.lastrowid or 0)
def _latest_metrics_subquery(hours=24):
return """
SELECT m.*
FROM onchain_token_metrics m
JOIN (
SELECT symbol, chain, contract_address, MAX(metric_time) AS max_time
FROM onchain_token_metrics
WHERE metric_time >= ?
GROUP BY symbol, chain, contract_address
) latest ON latest.symbol=m.symbol
AND latest.chain=m.chain
AND latest.contract_address=m.contract_address
AND latest.max_time=m.metric_time
"""
def get_onchain_overview(hours=24):
init_onchain_tables()
cutoff = (datetime.now() - timedelta(hours=int(hours or 24))).isoformat()
conn = get_conn()
event_rows = conn.execute("SELECT * FROM onchain_events WHERE detected_at >= ?", (cutoff,)).fetchall()
raw_rows = conn.execute("SELECT * FROM onchain_raw_events WHERE detected_at >= ?", (cutoff,)).fetchall()
raw_latest = conn.execute(
"""
SELECT * FROM onchain_raw_events
WHERE detected_at >= ?
ORDER BY datetime(detected_at) DESC, importance DESC, id DESC
LIMIT 12
""",
(cutoff,),
).fetchall()
metric_rows = conn.execute(_latest_metrics_subquery(hours), (cutoff,)).fetchall()
rec_rows = conn.execute(
"SELECT symbol, id, execution_status, action_status, display_bucket FROM recommendation WHERE status='active'"
).fetchall()
conn.close()
active = {row["symbol"]: dict(row) for row in rec_rows}
events = [dict(row) for row in event_rows]
raw_events = [dict(row) for row in raw_rows]
metrics = [dict(row) for row in metric_rows]
hot = sorted(metrics, key=lambda x: float(x.get("onchain_score") or 0), reverse=True)[:8]
risks = sorted(metrics, key=lambda x: float(x.get("risk_score") or 0), reverse=True)[:8]
total_netflow = sum(float(x.get("exchange_netflow_usd") or 0) for x in metrics)
dex_volume = sum(float(x.get("dex_volume_usd") or 0) for x in metrics)
return {
"hours": int(hours or 24),
"updated_at": _now(),
"kpi": {
"event_count": len(events),
"raw_event_count": len(raw_events),
"raw_unmapped_count": sum(1 for e in raw_events if e.get("mapping_status") == "unmapped"),
"raw_mapped_count": sum(1 for e in raw_events if e.get("mapping_status") == "mapped"),
"token_count": len({(m["symbol"], m["chain"], m.get("contract_address") or "") for m in metrics}),
"positive_events": sum(1 for e in events if e.get("direction") == "positive"),
"risk_events": sum(1 for e in events if e.get("direction") == "risk"),
"exchange_netflow_usd": round(total_netflow, 2),
"dex_volume_usd": round(dex_volume, 2),
},
"hot_tokens": [_format_metric_item(row, active) for row in hot],
"risk_tokens": [_format_metric_item(row, active) for row in risks],
"raw_events": [_format_raw_event(row) for row in raw_latest],
"signals": _signal_counts(events),
}
def _signal_counts(events):
counts = {}
for e in events:
code = e.get("signal_code") or ""
if not code:
continue
counts.setdefault(code, {"signal_code": code, "signal_label": signal_label(code), "count": 0})
counts[code]["count"] += 1
return sorted(counts.values(), key=lambda x: x["count"], reverse=True)
def _format_metric_item(row, active=None):
active = active or {}
item = dict(row)
item["raw"] = _load(item.pop("raw_json", "{}"), {})
rec = active.get(item.get("symbol")) or {}
item["recommendation"] = {
"rec_id": rec.get("id") or 0,
"execution_status": rec.get("execution_status") or "",
"action_status": rec.get("action_status") or "",
"display_bucket": rec.get("display_bucket") or "",
"has_active": bool(rec),
}
return item
def list_onchain_tokens(limit=30, offset=0, chain="", signal="", hours=24):
init_onchain_tables()
limit = max(1, min(int(limit or 30), 100))
offset = max(0, int(offset or 0))
cutoff = (datetime.now() - timedelta(hours=int(hours or 24))).isoformat()
clauses = []
params = []
if chain:
clauses.append("m.chain=?")
params.append(str(chain).lower())
if signal:
clauses.append(
"""
EXISTS (
SELECT 1 FROM onchain_events e
WHERE e.symbol=m.symbol AND e.chain=m.chain
AND e.detected_at >= ? AND e.signal_code=?
)
"""
)
params.extend([cutoff, signal])
where = " AND ".join(clauses) if clauses else "1=1"
conn = get_conn()
total = conn.execute(
f"""
SELECT COUNT(*) FROM (
SELECT m.symbol, m.chain, m.contract_address
FROM onchain_token_metrics m
WHERE {where}
GROUP BY m.symbol, m.chain, m.contract_address
)
""",
tuple(params),
).fetchone()[0]
rows = conn.execute(
f"""
SELECT m.*,
(SELECT COUNT(*) FROM onchain_events e
WHERE e.symbol=m.symbol AND e.chain=m.chain AND e.detected_at >= ?) AS event_count,
(SELECT COUNT(*) FROM onchain_events e
WHERE e.symbol=m.symbol AND e.chain=m.chain AND e.direction='risk' AND e.detected_at >= ?) AS risk_event_count
FROM ({_latest_metrics_subquery(hours)}) m
WHERE {where}
ORDER BY m.onchain_score DESC, m.risk_score DESC, m.metric_time DESC
LIMIT ? OFFSET ?
""",
(cutoff, cutoff, cutoff, *params, limit, offset),
).fetchall()
rec_rows = conn.execute(
"SELECT symbol, id, execution_status, action_status, display_bucket FROM recommendation WHERE status='active'"
).fetchall()
conn.close()
active = {row["symbol"]: dict(row) for row in rec_rows}
return {
"items": [_format_metric_item(row, active) for row in rows],
"total": int(total or 0),
"limit": limit,
"offset": offset,
"has_more": offset + len(rows) < int(total or 0),
}
def get_onchain_token_detail(symbol, hours=72):
init_onchain_tables()
symbol = normalize_symbol(symbol)
cutoff = (datetime.now() - timedelta(hours=int(hours or 72))).isoformat()
conn = get_conn()
mappings = conn.execute(
"SELECT * FROM onchain_token_map WHERE symbol=? ORDER BY confidence DESC, updated_at DESC",
(symbol,),
).fetchall()
events = conn.execute(
"""
SELECT * FROM onchain_events
WHERE symbol=? AND detected_at >= ?
ORDER BY detected_at DESC, id DESC
LIMIT 100
""",
(symbol, cutoff),
).fetchall()
metrics = conn.execute(
"""
SELECT * FROM onchain_token_metrics
WHERE symbol=? AND metric_time >= ?
ORDER BY metric_time DESC, id DESC
LIMIT 100
""",
(symbol, cutoff),
).fetchall()
rec = conn.execute(
"""
SELECT id, rec_time, action_status, execution_status, display_bucket, entry_price, current_price
FROM recommendation
WHERE symbol=? AND status='active'
ORDER BY id DESC LIMIT 1
""",
(symbol,),
).fetchone()
conn.close()
return {
"symbol": symbol,
"hours": int(hours or 72),
"mappings": [_with_raw(row) for row in mappings],
"events": [_with_raw(row) for row in events],
"metrics": [_with_raw(row) for row in metrics],
"recommendation": dict(rec) if rec else None,
}
def list_onchain_events(limit=50, offset=0, chain="", signal="", status="", hours=24):
init_onchain_tables()
limit = max(1, min(int(limit or 50), 200))
offset = max(0, int(offset or 0))
cutoff = (datetime.now() - timedelta(hours=int(hours or 24))).isoformat()
clauses = ["detected_at >= ?"]
params = [cutoff]
if chain:
clauses.append("chain=?")
params.append(str(chain).lower())
if signal:
clauses.append("signal_code=?")
params.append(signal)
if status:
clauses.append("status=?")
params.append(status)
where = " AND ".join(clauses)
conn = get_conn()
total = conn.execute(f"SELECT COUNT(*) FROM onchain_events WHERE {where}", tuple(params)).fetchone()[0]
rows = conn.execute(
f"""
SELECT * FROM onchain_events
WHERE {where}
ORDER BY datetime(detected_at) DESC, id DESC
LIMIT ? OFFSET ?
""",
(*params, limit, offset),
).fetchall()
conn.close()
return {"items": [_with_raw(row) for row in rows], "total": int(total or 0), "limit": limit, "offset": offset, "has_more": offset + len(rows) < int(total or 0)}
def list_onchain_raw_events(limit=50, offset=0, chain="", source="", event_type="", mapping_status="", hours=24):
init_onchain_tables()
limit = max(1, min(int(limit or 50), 200))
offset = max(0, int(offset or 0))
cutoff = (datetime.now() - timedelta(hours=int(hours or 24))).isoformat()
clauses = ["detected_at >= ?"]
params = [cutoff]
if chain:
clauses.append("chain=?")
params.append(str(chain).lower())
if source:
clauses.append("source=?")
params.append(source)
if event_type:
clauses.append("event_type=?")
params.append(event_type)
if mapping_status:
clauses.append("mapping_status=?")
params.append(mapping_status)
where = " AND ".join(clauses)
conn = get_conn()
total = conn.execute(f"SELECT COUNT(*) FROM onchain_raw_events WHERE {where}", tuple(params)).fetchone()[0]
rows = conn.execute(
f"""
SELECT * FROM onchain_raw_events
WHERE {where}
ORDER BY datetime(detected_at) DESC, importance DESC, id DESC
LIMIT ? OFFSET ?
""",
(*params, limit, offset),
).fetchall()
conn.close()
return {
"items": [_format_raw_event(row) for row in rows],
"total": int(total or 0),
"limit": limit,
"offset": offset,
"has_more": offset + len(rows) < int(total or 0),
}
def update_event_status(event_ids, status):
if not event_ids:
return 0
init_onchain_tables()
conn = get_conn()
cur = conn.execute(
"UPDATE onchain_events SET status=? WHERE id IN (" + ",".join(["?"] * len(event_ids)) + ")",
(status, *[int(x) for x in event_ids]),
)
conn.commit()
conn.close()
return int(cur.rowcount or 0)
def _with_raw(row):
item = dict(row)
if "raw_json" in item:
item["raw"] = _load(item.pop("raw_json"), {})
return item
def _format_raw_event(row):
item = _with_raw(row)
item["event_label"] = raw_event_type_label(item.get("event_type"))
item["token_short"] = _short_address(item.get("token_address"))
return item
def _short_address(value):
value = str(value or "")
if len(value) <= 14:
return value
return value[:6] + "..." + value[-4:]

View File

@ -106,7 +106,82 @@ def get_active_recommendations(actionable_only: bool = False):
if actionable_only and not _is_actionable_execution_status(item.get("execution_status")):
continue
result.append(item)
return result
return _attach_onchain_context(result)
def _attach_onchain_context(items):
if not items:
return items
symbols = sorted({item.get("symbol") for item in items if item.get("symbol")})
if not symbols:
return items
placeholders = ",".join(["?"] * len(symbols))
try:
conn = get_conn()
rows = conn.execute(
f"""
SELECT m.*
FROM onchain_token_metrics m
JOIN (
SELECT symbol, MAX(metric_time) AS max_time
FROM onchain_token_metrics
WHERE symbol IN ({placeholders})
GROUP BY symbol
) latest ON latest.symbol=m.symbol AND latest.max_time=m.metric_time
""",
tuple(symbols),
).fetchall()
events = conn.execute(
f"""
SELECT *
FROM onchain_events
WHERE symbol IN ({placeholders})
AND detected_at >= datetime('now', '-24 hours')
ORDER BY datetime(detected_at) DESC, id DESC
""",
tuple(symbols),
).fetchall()
conn.close()
except Exception:
return items
metrics = {row["symbol"]: dict(row) for row in rows}
by_symbol = {}
for row in events:
by_symbol.setdefault(row["symbol"], []).append(dict(row))
for item in items:
metric = metrics.get(item.get("symbol")) or {}
evs = by_symbol.get(item.get("symbol")) or []
if not metric and not evs:
continue
risk_events = [e for e in evs if e.get("direction") == "risk"]
positive_events = [e for e in evs if e.get("direction") == "positive"]
if risk_events:
headline = risk_events[0].get("signal_label") or "链上风险升温"
elif positive_events:
headline = positive_events[0].get("signal_label") or "链上资金异动"
else:
headline = "链上异动"
item["onchain_context"] = {
"headline": headline,
"chain": metric.get("chain") or (evs[0].get("chain") if evs else ""),
"onchain_score": metric.get("onchain_score") or 0,
"risk_score": metric.get("risk_score") or 0,
"dex_volume_usd": metric.get("dex_volume_usd") or 0,
"liquidity_usd": metric.get("liquidity_usd") or 0,
"event_count_24h": len(evs),
"risk_event_count_24h": len(risk_events),
"top_events": [
{
"signal_code": e.get("signal_code"),
"signal_label": e.get("signal_label"),
"direction": e.get("direction"),
"value_usd": e.get("value_usd") or 0,
"detected_at": e.get("detected_at"),
}
for e in evs[:3]
],
}
return items
def get_active_recommendations_deduped(
@ -209,8 +284,10 @@ def get_active_recommendations_deduped(
summary["expired_filtered"] = summary.pop("expired", 0)
if not with_meta:
_attach_onchain_context(all_items)
return attach_recommendation_insights(all_items)
page_items = all_items[offset : offset + limit] if limit else all_items[offset:]
_attach_onchain_context(page_items)
attach_recommendation_insights(page_items)
return {
"items": page_items,

View File

@ -79,6 +79,16 @@ DEFAULT_JOBS = [
"description": "舆情采集",
"sort_order": 50,
},
{
"job_name": "onchain",
"command": "onchain",
"args": [],
"every_seconds": 1800,
"initial_delay": 150,
"lock_group": "onchain_write",
"description": "链上异动追踪",
"sort_order": 55,
},
{
"job_name": "llm-sentiment",
"command": "llm-insights",
@ -497,6 +507,7 @@ def _display_job_name(job_name):
"confirm": "确认",
"screener": "粗筛",
"sentiment": "舆情",
"onchain": "链上",
"llm-sentiment": "AI舆情",
"review": "复盘",
}.get(job_name, job_name)

View File

@ -1,5 +1,11 @@
"""Schema/init-oriented DB API."""
from app.db.altcoin_db import get_conn, init_db
from app.db.altcoin_db import get_conn, init_db as _init_main_db
from app.db.onchain_db import init_onchain_tables
def init_db():
_init_main_db()
init_onchain_tables()
__all__ = ["get_conn", "init_db"]

View File

@ -0,0 +1,744 @@
"""On-chain signal collector and candidate bridge.
V1 deliberately treats on-chain data as a discovery/risk layer. It writes
normalized events/metrics and may request a technical check through event_news,
but it never creates recommendations or changes recommendation state directly.
"""
import json
import os
from datetime import datetime, timedelta
import requests
from app.db import onchain_db
from app.db.altcoin_db import get_conn, init_db, log_cron_run
from app.db.onchain_db import (
MIN_MAPPING_CONFIDENCE,
POSITIVE_SIGNALS,
RISK_SIGNALS,
find_mapping_by_contract,
get_token_mappings,
init_onchain_tables,
insert_onchain_event,
insert_onchain_raw_event,
insert_token_metric,
normalize_symbol,
signal_direction,
signal_label,
)
from app.services.event_driven_screener import _event_hash as event_hash
from app.services.event_driven_screener import _tradable_symbol, init_event_tables
DEFAULT_CHAINS = ("ethereum", "bsc", "base", "arbitrum", "solana")
SOLANA_AUTO_ALLOWLIST = {
"WIF", "BONK", "JUP", "RAY", "PYTH", "PENGU", "JTO", "MEW", "POPCAT", "PNUT",
"FARTCOIN", "RENDER", "HNT", "MOBILE", "ORCA", "KMNO", "DRIFT", "TNSR", "IO",
}
NON_TARGET_NATIVE_BASES = {
"AVAX", "FIL", "SUI", "APT", "DOT", "ADA", "XRP", "LTC", "BCH", "ATOM", "NEAR",
"SEI", "INJ", "TON", "ETC", "ICP", "HBAR", "ALGO", "VET", "TRX", "XLM", "KAS",
"TIA", "EGLD", "FLOW", "KAVA", "MINA", "IOTA", "XMR", "DASH", "ZEC",
}
BRIDGED_TOKEN_MARKERS = (
"wrapped", "wormhole", "portal", "bridged", "bridge", "axelar", "allbridge",
"binance-peg", "multichain", "layerzero", "lz", "wavax", "wfil",
)
DEX_CHAIN_ALIASES = {
"ethereum": "ethereum",
"eth": "ethereum",
"bsc": "bsc",
"bnb": "bsc",
"base": "base",
"arbitrum": "arbitrum",
"arb": "arbitrum",
"solana": "solana",
"sol": "solana",
}
DEXSCREENER_RAW_ENDPOINTS = (
("token_profile_latest", "https://api.dexscreener.com/token-profiles/latest/v1"),
("token_boost_latest", "https://api.dexscreener.com/token-boosts/latest/v1"),
("token_boost_top", "https://api.dexscreener.com/token-boosts/top/v1"),
)
def _env_bool(name, default=False):
value = os.getenv(name)
if value is None:
return default
return str(value).strip().lower() in ("1", "true", "yes", "on")
def _env_int(name, default):
try:
return int(os.getenv(name, str(default)) or default)
except Exception:
return default
def _env_float(name, default):
try:
return float(os.getenv(name, str(default)) or default)
except Exception:
return default
def get_onchain_params():
"""Runtime provider config. Keep this out of rules.yaml."""
chains = [x.strip().lower() for x in os.getenv("ALPHAX_ONCHAIN_CHAINS", ",".join(DEFAULT_CHAINS)).split(",") if x.strip()]
return {
"enabled": _env_bool("ALPHAX_ONCHAIN_ENABLED", False),
"chains": chains or list(DEFAULT_CHAINS),
"timeout": _env_int("ALPHAX_ONCHAIN_TIMEOUT", 15),
"candidate_enabled": _env_bool("ALPHAX_ONCHAIN_CANDIDATE_ENABLED", True),
"candidate_min_score": _env_float("ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE", 70),
"candidate_min_confidence": _env_int("ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE", 70),
"candidate_cooldown_hours": _env_float("ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS", 6),
"dexscreener_enabled": _env_bool("ALPHAX_ONCHAIN_DEXSCREENER_ENABLED", True),
"dex_volume_spike_pct": _env_float("ALPHAX_ONCHAIN_DEX_VOLUME_SPIKE_PCT", 80),
"dex_min_liquidity_usd": _env_float("ALPHAX_ONCHAIN_DEX_MIN_LIQUIDITY_USD", 100000),
"dex_min_volume_24h_usd": _env_float("ALPHAX_ONCHAIN_DEX_MIN_VOLUME_24H_USD", 100000),
"liquidity_add_pct": _env_float("ALPHAX_ONCHAIN_LIQUIDITY_ADD_PCT", 25),
"liquidity_remove_pct": _env_float("ALPHAX_ONCHAIN_LIQUIDITY_REMOVE_PCT", -25),
"whale_tx_usd": _env_float("ALPHAX_ONCHAIN_WHALE_TX_USD", 250000),
"etherscan_api_key": os.getenv("ALPHAX_ETHERSCAN_API_KEY", "").strip(),
"helius_api_key": os.getenv("ALPHAX_HELIUS_API_KEY", "").strip(),
}
def _now():
return datetime.now()
def _request_json(url, params=None, timeout=15):
resp = requests.get(url, params=params or {}, timeout=timeout, headers={"User-Agent": "AlphaX-Agent-Crypto/1.0"})
if resp.status_code >= 400:
raise RuntimeError(f"http_{resp.status_code}:{resp.text[:200]}")
return resp.json()
def _safe_float(value, default=0.0):
try:
return float(value or 0)
except Exception:
return default
def _safe_pct_change(new_value, old_value):
new_value = _safe_float(new_value)
old_value = _safe_float(old_value)
if old_value <= 0:
return 0.0
return (new_value - old_value) / old_value * 100
def _chain_alias(value):
key = str(value or "").lower()
return DEX_CHAIN_ALIASES.get(key, key)
def _latest_metric(symbol, chain, contract_address):
conn = get_conn()
row = conn.execute(
"""
SELECT * FROM onchain_token_metrics
WHERE symbol=? AND chain=? AND contract_address=? AND window='1h'
ORDER BY metric_time DESC, id DESC LIMIT 1
""",
(symbol, chain, contract_address or ""),
).fetchone()
conn.close()
return dict(row) if row else None
def _event_amount(item):
return _safe_float(item.get("amount"))
def _event_total_amount(item):
return _safe_float(item.get("totalAmount") or item.get("total_amount"))
def _raw_importance(event_type, item):
amount = _event_amount(item)
total = _event_total_amount(item)
if event_type == "token_boost_top":
return max(total, amount, 1)
if event_type == "token_boost_latest":
return max(amount, total * 0.5, 1)
return 1
def normalize_dexscreener_raw_event(item, event_type, cfg=None):
cfg = cfg or get_onchain_params()
chain = _chain_alias(item.get("chainId"))
if chain not in set(cfg.get("chains") or DEFAULT_CHAINS):
return None
token_address = str(item.get("tokenAddress") or "").strip()
if not token_address:
return None
mapping = find_mapping_by_contract(chain, token_address)
links = item.get("links") or []
symbol_guess = ""
name = ""
if isinstance(links, list):
for link in links:
if not isinstance(link, dict):
continue
if not name and link.get("label"):
name = str(link.get("label") or "")
raw = {
"chainId": item.get("chainId"),
"tokenAddress": token_address,
"url": item.get("url") or "",
"description": item.get("description") or "",
"icon": item.get("icon") or "",
"header": item.get("header") or "",
"links": links,
"amount": item.get("amount"),
"totalAmount": item.get("totalAmount"),
}
title = "DEX Screener"
if event_type == "token_profile_latest":
title = "Token 资料更新"
elif event_type == "token_boost_latest":
title = "DEX Boost 新增"
elif event_type == "token_boost_top":
title = "DEX Boost 榜单"
return {
"source": "dexscreener",
"chain": chain,
"event_type": event_type,
"token_address": token_address,
"symbol_guess": symbol_guess,
"name": name,
"title": title,
"description": item.get("description") or "",
"url": item.get("url") or "",
"icon": item.get("icon") or "",
"amount": _event_amount(item),
"total_amount": _event_total_amount(item),
"importance": _raw_importance(event_type, item),
"mapped_symbol": mapping.get("symbol") if mapping else "",
"mapping_status": "mapped" if mapping else "unmapped",
"detected_at": _now().isoformat(timespec="seconds"),
"raw": raw,
}
def fetch_dexscreener_raw_events(limit=80):
cfg = get_onchain_params()
if not cfg.get("dexscreener_enabled", True):
return {"raw_events": [], "errors": ["dexscreener_disabled"]}
inserted = []
errors = []
per_source_limit = max(1, int(limit or 80))
for event_type, url in DEXSCREENER_RAW_ENDPOINTS:
try:
data = _request_json(url, timeout=cfg.get("timeout", 15))
items = data if isinstance(data, list) else data.get("items") or data.get("data") or []
for item in items[:per_source_limit]:
if not isinstance(item, dict):
continue
event = normalize_dexscreener_raw_event(item, event_type, cfg=cfg)
if not event:
continue
if insert_onchain_raw_event(event):
inserted.append(event)
except Exception as exc:
errors.append(f"{event_type}:{str(exc)[:160]}")
return {"raw_events": inserted, "errors": errors}
def _discover_seed_symbols(limit=120):
conn = get_conn()
symbols = []
try:
rows = conn.execute(
"""
SELECT DISTINCT symbol
FROM recommendation
WHERE status='active' AND COALESCE(display_bucket,'watch_pool') != 'history'
ORDER BY rec_time DESC
LIMIT ?
""",
(int(limit or 120),),
).fetchall()
symbols.extend([row["symbol"] for row in rows if row["symbol"]])
except Exception:
pass
try:
rows = conn.execute(
"""
SELECT DISTINCT symbol
FROM coin_state
WHERE state != '过期'
ORDER BY detected_at DESC
LIMIT ?
""",
(int(limit or 120),),
).fetchall()
symbols.extend([row["symbol"] for row in rows if row["symbol"]])
except Exception:
pass
conn.close()
seen = set()
ordered = []
for symbol in symbols:
norm = normalize_symbol(symbol)
if not norm or norm in seen or not _tradable_symbol(norm):
continue
seen.add(norm)
ordered.append(norm)
return ordered[: int(limit or 120)]
def _score_pair_candidate(pair, requested_symbol, chains):
base = (pair.get("baseToken") or {})
quote = (pair.get("quoteToken") or {})
base_symbol = str(base.get("symbol") or "").upper()
req_base = str(requested_symbol or "").split("/")[0].upper()
liquidity = _safe_float((pair.get("liquidity") or {}).get("usd"))
volume = _safe_float((pair.get("volume") or {}).get("h24"))
chain = DEX_CHAIN_ALIASES.get(str(pair.get("chainId") or "").lower(), str(pair.get("chainId") or "").lower())
score = 0
if base_symbol == req_base:
score += 50
if chain in set(chains or []):
score += 15
if quote.get("symbol") in ("USDT", "USDC", "USD", "FDUSD", "USDE", "DAI", "USDS"):
score += 10
if liquidity >= 100000:
score += 10
if volume >= 100000:
score += 10
if liquidity >= 500000:
score += 5
return score
def _pair_rejection_reason(pair, requested_symbol, chains):
base = pair.get("baseToken") or {}
quote = pair.get("quoteToken") or {}
req_base = str(requested_symbol or "").split("/")[0].upper()
base_symbol = str(base.get("symbol") or "").upper()
base_name = str(base.get("name") or "").lower()
pair_url = str(pair.get("url") or "").lower()
chain = DEX_CHAIN_ALIASES.get(str(pair.get("chainId") or "").lower(), str(pair.get("chainId") or "").lower())
if base_symbol != req_base:
return "symbol_mismatch"
if chain not in set(chains or []):
return "chain_not_supported"
if req_base in NON_TARGET_NATIVE_BASES:
return "native_chain_not_in_scope"
if chain == "solana" and req_base not in SOLANA_AUTO_ALLOWLIST:
return "solana_not_allowlisted"
text = " ".join([base_name, base_symbol.lower(), str(quote.get("symbol") or "").lower(), pair_url])
if any(marker in text for marker in BRIDGED_TOKEN_MARKERS):
return "bridged_or_wrapped_token"
return ""
def discover_token_mappings(limit=60):
cfg = get_onchain_params()
chains = set(cfg.get("chains") or DEFAULT_CHAINS)
seeds = _discover_seed_symbols(limit=limit)
if not seeds:
return {"inserted": 0, "candidates": [], "errors": ["no_seed_symbols"]}
inserted = []
errors = []
for symbol in seeds:
existing = get_token_mappings(symbol, min_confidence=1, active_only=False)
if existing:
continue
base = symbol.split("/")[0]
try:
data = _request_json("https://api.dexscreener.com/latest/dex/search", params={"q": base}, timeout=cfg.get("timeout", 15))
pairs = data.get("pairs") or []
pair_candidates = []
for pair in pairs:
chain = DEX_CHAIN_ALIASES.get(str(pair.get("chainId") or "").lower(), str(pair.get("chainId") or "").lower())
if chain not in chains:
continue
if _pair_rejection_reason(pair, symbol, chains):
continue
pair_candidates.append((pair, _score_pair_candidate(pair, symbol, chains)))
if not pair_candidates:
continue
pair_candidates.sort(key=lambda x: (x[1], _safe_float((x[0].get("liquidity") or {}).get("usd")), _safe_float((x[0].get("volume") or {}).get("h24"))), reverse=True)
best, score = pair_candidates[0]
if score < 55:
continue
base_token = best.get("baseToken") or {}
chain = DEX_CHAIN_ALIASES.get(str(best.get("chainId") or "").lower(), str(best.get("chainId") or "").lower())
contract = str(base_token.get("address") or "").strip()
if not contract:
continue
confidence = min(95, 60 + score)
mapping_id = onchain_db.upsert_token_mapping(
symbol=symbol,
chain=chain,
contract_address=contract,
source="dexscreener_search",
confidence=confidence,
raw={
"search_query": base,
"matched_pair": {
"pairAddress": best.get("pairAddress") or "",
"dexId": best.get("dexId") or "",
"url": best.get("url") or "",
"liquidity": best.get("liquidity") or {},
"volume": best.get("volume") or {},
"priceChange": best.get("priceChange") or {},
"baseToken": base_token,
"quoteToken": best.get("quoteToken") or {},
},
},
is_active=True,
)
if mapping_id:
inserted.append({"symbol": symbol, "chain": chain, "contract_address": contract, "confidence": confidence})
except Exception as exc:
errors.append(f"{symbol}:{str(exc)[:160]}")
return {"inserted": len(inserted), "candidates": inserted, "errors": errors}
def _score_metric(metric):
score = 0.0
risk = 0.0
vol_change = _safe_float(metric.get("dex_volume_change_pct"))
liq_change = _safe_float(metric.get("liquidity_change_pct"))
netflow = _safe_float(metric.get("exchange_netflow_usd"))
whale = _safe_float(metric.get("whale_accumulation_usd"))
smart = _safe_float(metric.get("smart_money_score"))
if vol_change > 0:
score += min(35, vol_change / 4)
if liq_change > 0:
score += min(20, liq_change / 3)
if netflow < 0:
score += min(20, abs(netflow) / 100000)
if whale > 0:
score += min(20, whale / 100000)
score += min(20, smart)
if liq_change < 0:
risk += min(40, abs(liq_change))
if netflow > 0:
risk += min(35, netflow / 100000)
metric["onchain_score"] = round(min(score, 100), 2)
metric["risk_score"] = round(min(risk, 100), 2)
return metric
def derive_dex_signals(metric, cfg=None):
cfg = cfg or get_onchain_params()
signals = []
vol_change = _safe_float(metric.get("dex_volume_change_pct"))
liq_change = _safe_float(metric.get("liquidity_change_pct"))
if vol_change >= cfg.get("dex_volume_spike_pct", 80):
signals.append("dex_volume_spike")
if liq_change >= cfg.get("liquidity_add_pct", 25):
signals.append("liquidity_add")
if liq_change <= cfg.get("liquidity_remove_pct", -25):
signals.append("liquidity_remove_risk")
return signals
def _event_from_metric(metric, signal_code, source="dexscreener"):
direction = signal_direction(signal_code)
severity = "RISK" if direction == "risk" else "A" if _safe_float(metric.get("onchain_score")) >= 75 else "B"
return {
"chain": metric.get("chain"),
"symbol": metric.get("symbol"),
"contract_address": metric.get("contract_address") or "",
"event_type": "onchain_signal",
"signal_code": signal_code,
"signal_label": signal_label(signal_code),
"direction": direction,
"value_usd": metric.get("dex_volume_usd") or metric.get("whale_accumulation_usd") or abs(metric.get("exchange_netflow_usd") or 0),
"confidence": 75 if direction != "risk" else 80,
"severity": severity,
"detected_at": metric.get("metric_time") or _now().isoformat(),
"source": source,
"url": metric.get("url") or "",
"raw": metric,
}
def normalize_dexscreener_pair(pair, mapping, cfg=None):
cfg = cfg or get_onchain_params()
symbol = normalize_symbol(mapping.get("symbol"))
chain = DEX_CHAIN_ALIASES.get(str(pair.get("chainId") or mapping.get("chain") or "").lower(), str(mapping.get("chain") or "").lower())
contract = mapping.get("contract_address") or (pair.get("baseToken") or {}).get("address") or ""
liquidity = _safe_float((pair.get("liquidity") or {}).get("usd"))
volume = _safe_float((pair.get("volume") or {}).get("h24"))
prev = _latest_metric(symbol, chain, contract)
prev_volume = _safe_float(prev.get("dex_volume_usd") if prev else 0)
prev_liquidity = _safe_float(prev.get("liquidity_usd") if prev else 0)
metric = {
"symbol": symbol,
"chain": chain,
"contract_address": contract,
"window": "1h",
"metric_time": _now().isoformat(timespec="seconds"),
"dex_volume_usd": volume,
"dex_volume_change_pct": _safe_pct_change(volume, prev_volume),
"liquidity_usd": liquidity,
"liquidity_change_pct": _safe_pct_change(liquidity, prev_liquidity),
"exchange_netflow_usd": 0,
"whale_accumulation_usd": 0,
"holder_delta": 0,
"smart_money_score": 0,
"source": "dexscreener",
"url": pair.get("url") or "",
"raw": {
"pair_address": pair.get("pairAddress") or "",
"dex_id": pair.get("dexId") or "",
"price_usd": pair.get("priceUsd") or "",
"fdv": pair.get("fdv") or 0,
"txns": pair.get("txns") or {},
"price_change": pair.get("priceChange") or {},
"volume": pair.get("volume") or {},
"liquidity": pair.get("liquidity") or {},
},
}
return _score_metric(metric)
def fetch_dexscreener_metrics(limit=60):
cfg = get_onchain_params()
if not cfg.get("dexscreener_enabled", True):
return {"metrics": [], "events": [], "errors": ["dexscreener_disabled"]}
mappings = get_token_mappings(min_confidence=MIN_MAPPING_CONFIDENCE)
bootstrap = None
if not mappings:
bootstrap = discover_token_mappings(limit=limit)
mappings = get_token_mappings(min_confidence=MIN_MAPPING_CONFIDENCE)
metrics = []
events = []
errors = []
if bootstrap:
errors.extend(bootstrap.get("errors") or [])
for mapping in mappings[: int(limit or 60)]:
symbol = normalize_symbol(mapping.get("symbol"))
if not symbol or not _tradable_symbol(symbol):
continue
try:
url = "https://api.dexscreener.com/latest/dex/tokens/" + str(mapping.get("contract_address") or "").strip()
data = _request_json(url, timeout=cfg.get("timeout", 15))
pairs = data.get("pairs") or []
wanted_chain = DEX_CHAIN_ALIASES.get(str(mapping.get("chain") or "").lower(), str(mapping.get("chain") or "").lower())
pairs = [p for p in pairs if DEX_CHAIN_ALIASES.get(str(p.get("chainId") or "").lower(), str(p.get("chainId") or "").lower()) == wanted_chain]
if not pairs:
continue
best = max(pairs, key=lambda p: _safe_float((p.get("liquidity") or {}).get("usd")))
metric = normalize_dexscreener_pair(best, mapping, cfg=cfg)
if metric.get("liquidity_usd", 0) < cfg.get("dex_min_liquidity_usd", 100000) and metric.get("dex_volume_usd", 0) < cfg.get("dex_min_volume_24h_usd", 100000):
insert_token_metric(metric)
metrics.append(metric)
continue
insert_token_metric(metric)
metrics.append(metric)
for code in derive_dex_signals(metric, cfg):
event = _event_from_metric(metric, code, source="dexscreener")
if insert_onchain_event(event):
events.append(event)
except Exception as exc:
errors.append(f"{symbol}:{str(exc)[:160]}")
return {"metrics": metrics, "events": events, "errors": errors}
def ingest_normalized_events(events):
"""Test/integration helper for provider adapters."""
init_db()
init_onchain_tables()
inserted = []
for event in events or []:
eid = insert_onchain_event(event)
if eid:
item = dict(event)
item["id"] = eid
inserted.append(item)
queued = enqueue_onchain_candidates()
return {"inserted": len(inserted), "queued": queued.get("queued", 0), "events": inserted, "candidate_result": queued}
def _candidate_title(event):
label = event.get("signal_label") or signal_label(event.get("signal_code"))
value = _safe_float(event.get("value_usd"))
value_txt = f" · ${value:,.0f}" if value > 0 else ""
return f"链上异动 {event.get('symbol')}: {label}{value_txt}"
def enqueue_onchain_candidates(min_score=None, min_confidence=None, cooldown_hours=None, limit=20):
cfg = get_onchain_params()
if not cfg.get("candidate_enabled", True):
return {"queued": 0, "skipped": 0, "symbols": [], "reason": "candidate_disabled"}
min_score = cfg.get("candidate_min_score", 70) if min_score is None else min_score
min_confidence = cfg.get("candidate_min_confidence", 70) if min_confidence is None else min_confidence
cooldown_hours = cfg.get("candidate_cooldown_hours", 6) if cooldown_hours is None else cooldown_hours
init_onchain_tables()
init_event_tables()
cutoff = (_now() - timedelta(hours=24)).isoformat()
conn = get_conn()
rows = conn.execute(
"""
SELECT e.*,
COALESCE((
SELECT m.onchain_score FROM onchain_token_metrics m
WHERE m.symbol=e.symbol AND m.chain=e.chain
ORDER BY datetime(m.metric_time) DESC, m.id DESC LIMIT 1
), 0) AS latest_onchain_score,
COALESCE((
SELECT m.risk_score FROM onchain_token_metrics m
WHERE m.symbol=e.symbol AND m.chain=e.chain
ORDER BY datetime(m.metric_time) DESC, m.id DESC LIMIT 1
), 0) AS latest_risk_score
FROM onchain_events e
WHERE e.status IN ('new', 'candidate_failed')
AND e.detected_at >= ?
AND e.direction='positive'
ORDER BY e.confidence DESC, e.value_usd DESC, datetime(e.detected_at) DESC
LIMIT ?
""",
(cutoff, int(limit or 20)),
).fetchall()
queued = []
skipped_ids = []
now = _now().isoformat(timespec="seconds")
cooldown_cutoff = (_now() - timedelta(hours=float(cooldown_hours or 6))).isoformat()
for row in rows:
event = dict(row)
symbol = normalize_symbol(event.get("symbol"))
if not symbol or not _tradable_symbol(symbol):
skipped_ids.append(event["id"])
continue
score = max(_safe_float(event.get("latest_onchain_score")), _safe_float(event.get("confidence")))
if score < float(min_score or 0) or int(event.get("confidence") or 0) < int(min_confidence or 0):
continue
recent = conn.execute(
"""
SELECT id FROM event_news
WHERE source='onchain' AND symbol=? AND detected_at >= ?
LIMIT 1
""",
(symbol, cooldown_cutoff),
).fetchone()
if recent:
skipped_ids.append(event["id"])
continue
title = _candidate_title(event)
h = event_hash("onchain", title, symbol)
try:
conn.execute(
"""
INSERT INTO event_news
(event_hash, source, symbol, title, url, published_at, detected_at, importance, event_type, raw_json, processed)
VALUES (?, 'onchain', ?, ?, ?, ?, ?, ?, 'onchain_candidate', ?, 0)
""",
(
h,
symbol,
title,
event.get("url") or "",
event.get("detected_at") or now,
now,
event.get("severity") or "A",
json.dumps(
{
"onchain_event_id": event.get("id"),
"chain": event.get("chain"),
"signal_code": event.get("signal_code"),
"signal_label": event.get("signal_label"),
"confidence": event.get("confidence"),
"value_usd": event.get("value_usd"),
"onchain_score": event.get("latest_onchain_score"),
"risk_score": event.get("latest_risk_score"),
},
ensure_ascii=False,
),
),
)
conn.execute("UPDATE onchain_events SET status='candidate_queued' WHERE id=?", (event.get("id"),))
queued.append(symbol)
except Exception:
skipped_ids.append(event["id"])
if skipped_ids:
conn.execute(
"UPDATE onchain_events SET status='candidate_skipped' WHERE id IN (" + ",".join(["?"] * len(skipped_ids)) + ")",
tuple(skipped_ids),
)
conn.commit()
conn.close()
return {"queued": len(queued), "skipped": len(skipped_ids), "symbols": queued}
def run_once(limit=60):
started = _now()
init_db()
init_onchain_tables()
cfg = get_onchain_params()
output = {
"status": "disabled" if not cfg.get("enabled") else "processed",
"metrics_count": 0,
"events_count": 0,
"raw_events_count": 0,
"candidate_queued": 0,
"errors": [],
"check_time": _now().isoformat(),
}
if cfg.get("enabled"):
raw = fetch_dexscreener_raw_events(limit=limit)
output["raw_events_count"] = len(raw.get("raw_events") or [])
output["errors"].extend(raw.get("errors") or [])
dex = fetch_dexscreener_metrics(limit=limit)
output["metrics_count"] += len(dex.get("metrics") or [])
output["events_count"] += len(dex.get("events") or [])
output["errors"].extend(dex.get("errors") or [])
output["discovered_mappings"] = discover_token_mappings(limit=limit).get("inserted", 0) if not get_token_mappings(min_confidence=MIN_MAPPING_CONFIDENCE) else 0
if output.get("discovered_mappings"):
output["status"] = "bootstrapped"
dex = fetch_dexscreener_metrics(limit=limit)
output["metrics_count"] = len(dex.get("metrics") or [])
output["events_count"] = len(dex.get("events") or [])
output["errors"].extend(dex.get("errors") or [])
queued = enqueue_onchain_candidates()
output["candidate_queued"] = queued.get("queued", 0)
output["candidate_symbols"] = queued.get("symbols", [])
if not output["metrics_count"] and not output["events_count"] and not output["raw_events_count"]:
output["status"] = "no_onchain_data"
log_cron_run(
job_name="链上",
script_name="onchain_monitor.py",
run_status="success" if not output["errors"] else "error",
result_status=output["status"],
started_at=started.isoformat(),
finished_at=_now().isoformat(),
duration_ms=int((_now() - started).total_seconds() * 1000),
summary={
"metrics_count": output["metrics_count"],
"events_count": output["events_count"],
"raw_events_count": output["raw_events_count"],
"candidate_queued": output["candidate_queued"],
"enabled": cfg.get("enabled"),
},
error_message="; ".join(output["errors"][:5]),
)
print(json.dumps(output, ensure_ascii=False, indent=2, default=str))
return output
__all__ = [
"POSITIVE_SIGNALS",
"RISK_SIGNALS",
"derive_dex_signals",
"enqueue_onchain_candidates",
"fetch_dexscreener_metrics",
"fetch_dexscreener_raw_events",
"get_onchain_params",
"ingest_normalized_events",
"normalize_dexscreener_pair",
"run_once",
]

75
app/web/routes_onchain.py Normal file
View File

@ -0,0 +1,75 @@
from fastapi import APIRouter, Cookie
from app.db.onchain_db import (
get_onchain_overview,
get_onchain_token_detail,
list_onchain_events,
list_onchain_raw_events,
list_onchain_tokens,
)
from app.web.shared import require_api_user_with_subscription
router = APIRouter()
@router.get("/api/onchain/overview")
async def api_onchain_overview(hours: int = 24, altcoin_session: str = Cookie(default="")):
require_api_user_with_subscription(altcoin_session)
return get_onchain_overview(hours=hours)
@router.get("/api/onchain/tokens")
async def api_onchain_tokens(
limit: int = 30,
offset: int = 0,
chain: str = "",
signal: str = "",
hours: int = 24,
altcoin_session: str = Cookie(default=""),
):
require_api_user_with_subscription(altcoin_session)
return list_onchain_tokens(limit=limit, offset=offset, chain=chain, signal=signal, hours=hours)
@router.get("/api/onchain/tokens/{symbol:path}")
async def api_onchain_token_detail(symbol: str, hours: int = 72, altcoin_session: str = Cookie(default="")):
require_api_user_with_subscription(altcoin_session)
return get_onchain_token_detail(symbol=symbol, hours=hours)
@router.get("/api/onchain/events")
async def api_onchain_events(
limit: int = 50,
offset: int = 0,
chain: str = "",
signal: str = "",
status: str = "",
hours: int = 24,
altcoin_session: str = Cookie(default=""),
):
require_api_user_with_subscription(altcoin_session)
return list_onchain_events(limit=limit, offset=offset, chain=chain, signal=signal, status=status, hours=hours)
@router.get("/api/onchain/raw-events")
async def api_onchain_raw_events(
limit: int = 50,
offset: int = 0,
chain: str = "",
source: str = "",
event_type: str = "",
mapping_status: str = "",
hours: int = 24,
altcoin_session: str = Cookie(default=""),
):
require_api_user_with_subscription(altcoin_session)
return list_onchain_raw_events(
limit=limit,
offset=offset,
chain=chain,
source=source,
event_type=event_type,
mapping_status=mapping_status,
hours=hours,
)

View File

@ -109,6 +109,13 @@ def build_router(templates, repo_root: Path, stock_report_template: str):
return redirect
return render_page("sentiment.html", request)
@router.get("/onchain", response_class=HTMLResponse)
async def onchain_page(request: Request):
user, redirect = require_page_user(request)
if redirect:
return redirect
return render_page("onchain.html", request)
@router.get("/iteration", response_class=HTMLResponse)
async def iteration_page(request: Request):
user, redirect = require_page_user(request)

View File

@ -15,6 +15,7 @@ from app.db.recommendation_queries import get_active_recommendations, get_active
from app.web.routes_admin import build_router as build_admin_router
from app.web.routes_auth import router as auth_router
from app.web.routes_content import build_router as build_content_router
from app.web.routes_onchain import router as onchain_router
from app.web.routes_pages import build_router as build_pages_router
from app.web.routes_recommendations import router as recommendations_router
from app.web.routes_strategy import router as strategy_router
@ -41,6 +42,7 @@ templates = Jinja2Templates(directory=str(REPO_ROOT / "static"))
app.include_router(auth_router)
app.include_router(recommendations_router)
app.include_router(strategy_router)
app.include_router(onchain_router)
app.include_router(build_admin_router(templates))
app.include_router(build_content_router(REPO_ROOT))
app.include_router(build_pages_router(templates, REPO_ROOT, STOCK_REPORT_TEMPLATE))

View File

@ -40,7 +40,7 @@ services:
- .env
environment:
# 本地 Docker 副本需要真实跑链路,方便验证筛选/确认/跟踪/复盘结果。
# 调度器仍然单进程串行执行,避免 SQLite 写锁
# 调度器以后台子进程方式并发执行,并通过业务锁组规避 SQLite 写冲突
ALPHAX_SCHEDULER_DRY_RUN: "0"
ALPHAX_DB_PATH: "/app/data/altcoin_monitor.db"
command: ["scheduler"]

View File

@ -407,11 +407,11 @@ event_driven:
note: Solana meme主题扩散
meta:
version: 1
last_review: '2026-05-15T00:15:38.149520'
last_reverse_analysis: '2026-05-15T00:16:18.257946'
total_reviews: 38
last_review: '2026-05-15T11:16:43.982118'
last_reverse_analysis: '2026-05-15T11:17:20.540656'
total_reviews: 44
total_rules_learned: 37
iteration_count: 43
iteration_count: 49
strategy_version: v1.7.11
strategy_revision_started_at: '2026-05-09T01:20:00'
strategy_revision_note: 'v1.7.11: 触发时效治理,旧形态只作背景,消息触发显式标记'

View File

@ -4,6 +4,7 @@
{% block nav_links %}
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></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>
<div class="sidebar-section-label admin-link" style="display:none">研发</div>

View File

@ -167,6 +167,12 @@
.ai-insight .ai-text { color: var(--ink); font-size: 12px; line-height: 1.45; margin-top: 3px; word-break: break-word; }
.ai-insight .ai-list { display: flex; flex-wrap: wrap; gap: 4px; }
.ai-insight .ai-pill { display: inline-flex; padding: 4px 7px; border-radius: 999px; font-size: 11px; color: var(--slate); background: var(--canvas); border: 1px solid var(--hairline-soft); }
.onchain-brief { margin: 0 18px 8px; border: 1px solid rgba(66,98,255,.14); border-radius: var(--radius-lg); background: rgba(66,98,255,.045); padding: 9px 10px; display: grid; gap: 6px; }
.onchain-brief.risk { border-color: rgba(229,62,62,.18); background: var(--red-light); }
.onchain-head { display:flex; align-items:center; justify-content:space-between; gap:8px; color:var(--ink); font-size:12px; font-weight:900; }
.onchain-meta { color:var(--stone); font-size:11px; line-height:1.45; }
.onchain-score { color:var(--blue); font-weight:950; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
.onchain-brief.risk .onchain-score { color:var(--red); }
/* ===== K-LINE ===== */
.kline-wrap { padding: 0 8px 4px; }
@ -241,6 +247,7 @@
.stats-main { width: 100%; }
.entry-plan { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 8px 14px; }
.decision-strip { margin: 0 14px 8px; grid-template-columns: 86px minmax(0,1fr); }
.onchain-brief { margin: 0 14px 8px; }
}
@media(max-width:360px) {
@ -774,6 +781,15 @@ function renderRecCard(r) {
'</div>'+
'</details>';
}
var onchainHtml = '';
var oc = r.onchain_context || null;
if (oc && (oc.event_count_24h || oc.onchain_score || oc.risk_score)) {
var ocRisk = Number(oc.risk_event_count_24h || 0) > 0 || Number(oc.risk_score || 0) >= 60;
var ocTitle = cleanDisplayText(oc.headline || (ocRisk ? '链上风险升温' : '链上资金异动'));
var ocScore = ocRisk ? Number(oc.risk_score || 0).toFixed(0) : Number(oc.onchain_score || 0).toFixed(0);
var ocMeta = [oc.chain || '链上', '24h事件 '+(oc.event_count_24h || 0), oc.dex_volume_usd ? ('DEX量 $'+fmtCompactNumber(oc.dex_volume_usd)) : ''].filter(Boolean).join(' · ');
onchainHtml = '<div class="onchain-brief '+(ocRisk?'risk':'')+'"><div class="onchain-head"><span>'+ocTitle+'</span><span class="onchain-score">'+ocScore+'</span></div><div class="onchain-meta">'+escHtml(ocMeta)+'</div></div>';
}
var entryPlanHtml = '';
if (isTradePlan) {
entryPlanHtml = '<div class="entry-plan">' +
@ -793,6 +809,7 @@ function renderRecCard(r) {
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>'+
decisionHtml+
onchainHtml+
aiInsightHtml+
'<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)+

View File

@ -157,6 +157,7 @@ a { color: inherit; text-decoration: none; }
<symbol id="svg-ai" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="5" width="14" height="14" rx="3"/><path d="M9 1v4"/><path d="M15 1v4"/><path d="M9 19v4"/><path d="M15 19v4"/><path d="M1 9h4"/><path d="M1 15h4"/><path d="M19 9h4"/><path d="M19 15h4"/><path d="M9 15l1.5-6h3L15 15"/><path d="M10 13h4"/></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-onchain" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="7" r="3"/><circle cx="18" cy="7" r="3"/><circle cx="12" cy="18" r="3"/><path d="M8.6 8.8 10.7 15"/><path d="M15.4 8.8 13.3 15"/><path d="M9 7h6"/></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-admin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M5.3 20h13.4c1.1 0 2-.9 2-2 0-3.3-2.7-6-6-6H9.3c-3.3 0-6 2.7-6 6 0 1.1.9 2 2 2z"/></symbol>
<symbol id="svg-referral" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><polyline points="17 11 19 13 23 9"/></symbol>
@ -173,6 +174,7 @@ a { color: inherit; text-decoration: none; }
{% block nav_links %}
<a class="sidebar-link active" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></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>
<div class="sidebar-section-label admin-link" style="display:none">研发</div>

View File

@ -3,6 +3,7 @@
{% block nav_links %}
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></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>
<div class="sidebar-section-label admin-link" style="display:none">研发</div>

View File

@ -3,6 +3,7 @@
{% block nav_links %}
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></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>
<div class="sidebar-section-label admin-link" style="display:none">研发</div>

View File

@ -3,6 +3,7 @@
{% block nav_links %}
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></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>
<div class="sidebar-section-label admin-link" style="display:none">研发</div>

88
static/onchain.html Normal file

File diff suppressed because one or more lines are too long

View File

@ -3,6 +3,7 @@
{% block nav_links %}
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></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>
<div class="sidebar-section-label admin-link" style="display:none">研发</div>

View File

@ -4,6 +4,7 @@
{% block nav_links %}
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></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>
<div class="sidebar-section-label admin-link" style="display:none">研发</div>

View File

@ -3,6 +3,7 @@
{% block nav_links %}
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
<a class="sidebar-link active" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></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>
<div class="sidebar-section-label admin-link" style="display:none">研发</div>

View File

@ -3,6 +3,7 @@
{% block nav_links %}
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></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>
<div class="sidebar-section-label admin-link" style="display:none">研发</div>

View File

@ -3,6 +3,7 @@
{% block nav_links %}
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></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>
<div class="sidebar-section-label admin-link" style="display:none">研发</div>

View File

@ -3,6 +3,7 @@
{% block nav_links %}
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></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>
<div class="sidebar-section-label admin-link" style="display:none">研发</div>

View File

@ -0,0 +1,281 @@
import json
import os
import sqlite3
import sys
from datetime import datetime
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, onchain_db, scheduler_db
from app.services import onchain_monitor
from app.web import web_server
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()
onchain_db.init_onchain_tables()
return db_path
def test_mapping_requires_confidence_and_preserves_multi_chain(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path)
onchain_db.upsert_token_mapping("ABC", "ethereum", "0xaaa", source="manual", confidence=95)
onchain_db.upsert_token_mapping("ABC", "bsc", "0xbbb", source="manual", confidence=55)
usable = onchain_db.get_token_mappings("ABC", min_confidence=70)
assert len(usable) == 1
assert usable[0]["chain"] == "ethereum"
assert usable[0]["contract_address"] == "0xaaa"
def test_dex_signal_codes_from_metric(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path)
cfg = {
"dex_volume_spike_pct": 80,
"liquidity_add_pct": 25,
"liquidity_remove_pct": -25,
}
assert onchain_monitor.derive_dex_signals({"dex_volume_change_pct": 120, "liquidity_change_pct": 40}, cfg) == [
"dex_volume_spike",
"liquidity_add",
]
assert onchain_monitor.derive_dex_signals({"dex_volume_change_pct": 10, "liquidity_change_pct": -35}, cfg) == [
"liquidity_remove_risk"
]
def test_auto_mapping_rejects_non_target_native_and_wrapped_tokens(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path)
cfg = onchain_monitor.get_onchain_params()
chains = set(cfg.get("chains") or [])
avax_pair = {
"chainId": "solana",
"baseToken": {"symbol": "AVAX", "name": "Avalanche"},
"quoteToken": {"symbol": "USDC"},
"liquidity": {"usd": 500000},
"volume": {"h24": 1000000},
"url": "https://example.com",
}
wrapped_pair = {
"chainId": "ethereum",
"baseToken": {"symbol": "FIL", "name": "Wrapped Filecoin"},
"quoteToken": {"symbol": "USDT"},
"liquidity": {"usd": 500000},
"volume": {"h24": 1000000},
"url": "https://example.com/wrapped-filecoin",
}
assert onchain_monitor._pair_rejection_reason(avax_pair, "AVAX/USDT", chains) == "native_chain_not_in_scope"
assert onchain_monitor._pair_rejection_reason(wrapped_pair, "FIL/USDT", chains) == "native_chain_not_in_scope"
assert onchain_monitor._pair_rejection_reason(
{
"chainId": "solana",
"baseToken": {"symbol": "UNKNOWN", "name": "Unknown"},
"quoteToken": {"symbol": "USDC"},
"liquidity": {"usd": 500000},
"volume": {"h24": 1000000},
"url": "https://example.com",
},
"UNKNOWN/USDT",
chains,
) == "solana_not_allowlisted"
def test_onchain_candidate_enqueues_event_news_not_recommendation(monkeypatch, tmp_path):
db_path = _temp_db(monkeypatch, tmp_path)
onchain_db.insert_token_metric(
{
"symbol": "ABC/USDT",
"chain": "ethereum",
"contract_address": "0xaaa",
"window": "1h",
"metric_time": datetime.now().isoformat(),
"dex_volume_usd": 500000,
"dex_volume_change_pct": 160,
"liquidity_usd": 300000,
"liquidity_change_pct": 35,
"onchain_score": 82,
"risk_score": 0,
"source": "test",
}
)
event_id = onchain_db.insert_onchain_event(
{
"chain": "ethereum",
"symbol": "ABC/USDT",
"contract_address": "0xaaa",
"signal_code": "dex_volume_spike",
"direction": "positive",
"value_usd": 500000,
"confidence": 88,
"severity": "A",
"detected_at": datetime.now().isoformat(),
"source": "test",
}
)
result = onchain_monitor.enqueue_onchain_candidates(min_score=70, min_confidence=70, cooldown_hours=6)
assert event_id > 0
assert result["queued"] == 1
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
news = conn.execute("SELECT * FROM event_news WHERE source='onchain'").fetchone()
rec_count = conn.execute("SELECT COUNT(*) FROM recommendation").fetchone()[0]
status = conn.execute("SELECT status FROM onchain_events WHERE id=?", (event_id,)).fetchone()[0]
conn.close()
assert news["event_type"] == "onchain_candidate"
assert json.loads(news["raw_json"])["signal_code"] == "dex_volume_spike"
assert rec_count == 0
assert status == "candidate_queued"
def test_negative_onchain_signal_is_risk_context_only(monkeypatch, tmp_path):
db_path = _temp_db(monkeypatch, tmp_path)
onchain_db.insert_onchain_event(
{
"chain": "ethereum",
"symbol": "RISK/USDT",
"signal_code": "exchange_inflow_risk",
"direction": "risk",
"value_usd": 900000,
"confidence": 92,
"severity": "RISK",
"detected_at": datetime.now().isoformat(),
"source": "test",
}
)
result = onchain_monitor.enqueue_onchain_candidates(min_score=1, min_confidence=1)
conn = sqlite3.connect(db_path)
news_count = conn.execute("SELECT COUNT(*) FROM event_news WHERE source='onchain'").fetchone()[0]
conn.close()
assert result["queued"] == 0
assert news_count == 0
def test_onchain_api_and_page(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path)
onchain_db.insert_token_metric(
{
"symbol": "ABC/USDT",
"chain": "base",
"contract_address": "0xabc",
"window": "1h",
"metric_time": datetime.now().isoformat(),
"dex_volume_usd": 123000,
"dex_volume_change_pct": 90,
"liquidity_usd": 456000,
"liquidity_change_pct": 12,
"onchain_score": 76,
"risk_score": 8,
"source": "test",
}
)
client = TestClient(web_server.app)
page = client.get("/onchain")
assert page.status_code == 200
assert "链上异动" in page.text
overview = client.get("/api/onchain/overview")
assert overview.status_code == 200
assert overview.json()["kpi"]["token_count"] == 1
tokens = client.get("/api/onchain/tokens")
assert tokens.status_code == 200
assert tokens.json()["items"][0]["symbol"] == "ABC/USDT"
def test_raw_dexscreener_events_store_without_mapping(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path)
monkeypatch.setenv("ALPHAX_ONCHAIN_ENABLED", "1")
monkeypatch.setenv("ALPHAX_ONCHAIN_CHAINS", "ethereum,solana")
def fake_request(url, params=None, timeout=15):
if "token-profiles" in url:
return [
{
"chainId": "ethereum",
"tokenAddress": "0xraw",
"url": "https://dexscreener.com/ethereum/0xraw",
"description": "Unmapped token started trending",
"icon": "https://example.com/icon.png",
}
]
if "token-boosts/latest" in url:
return [
{
"chainId": "solana",
"tokenAddress": "So111",
"url": "https://dexscreener.com/solana/So111",
"amount": 25,
"totalAmount": 100,
}
]
if "token-boosts/top" in url:
return []
return {"pairs": []}
monkeypatch.setattr(onchain_monitor, "_request_json", fake_request)
result = onchain_monitor.fetch_dexscreener_raw_events(limit=10)
assert len(result["raw_events"]) == 2
raw = onchain_db.list_onchain_raw_events(hours=24)
assert raw["total"] == 2
assert raw["items"][0]["mapping_status"] == "unmapped"
assert raw["items"][0]["event_label"]
def test_raw_event_api_and_overview_counts(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path)
onchain_db.upsert_token_mapping("ABC", "base", "0xabc", source="manual", confidence=95)
onchain_db.insert_onchain_raw_event(
{
"source": "dexscreener",
"chain": "base",
"event_type": "token_boost_top",
"token_address": "0xabc",
"title": "DEX Boost 榜单",
"amount": 10,
"total_amount": 80,
"importance": 80,
"mapped_symbol": "ABC/USDT",
"mapping_status": "mapped",
"detected_at": datetime.now().isoformat(),
}
)
client = TestClient(web_server.app)
overview = client.get("/api/onchain/overview")
events = client.get("/api/onchain/raw-events")
assert overview.status_code == 200
assert overview.json()["kpi"]["raw_event_count"] == 1
assert overview.json()["kpi"]["raw_mapped_count"] == 1
assert events.status_code == 200
assert events.json()["items"][0]["mapped_symbol"] == "ABC/USDT"
def test_scheduler_seeds_onchain_job(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path)
sched_path = tmp_path / "scheduler_state.db"
monkeypatch.setattr(scheduler_db, "SCHEDULER_DB_PATH", str(sched_path))
scheduler_db.init_scheduler_tables()
jobs = {item["job_name"]: item for item in scheduler_db.get_job_configs()}
assert jobs["onchain"]["command"] == "onchain"
assert jobs["onchain"]["lock_group"] == "onchain_write"

View File

@ -17,7 +17,9 @@ import docker.scheduler as scheduler
def test_scheduler_tables_seed_defaults(monkeypatch, tmp_path):
db_path = tmp_path / "altcoin_monitor.db"
sched_path = tmp_path / "scheduler_state.db"
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
monkeypatch.setattr(scheduler_db, "SCHEDULER_DB_PATH", str(sched_path))
altcoin_db.init_db()
scheduler_db.init_scheduler_tables()
@ -25,11 +27,14 @@ def test_scheduler_tables_seed_defaults(monkeypatch, tmp_path):
assert jobs["event"]["lock_group"] == "recommendation_write"
assert jobs["confirm"]["lock_group"] == "recommendation_write"
assert jobs["tracker"]["every_seconds"] == 180
assert jobs["onchain"]["lock_group"] == "onchain_write"
def test_scheduler_control_api_and_page(monkeypatch, tmp_path):
db_path = tmp_path / "altcoin_monitor.db"
sched_path = tmp_path / "scheduler_state.db"
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
monkeypatch.setattr(scheduler_db, "SCHEDULER_DB_PATH", str(sched_path))
monkeypatch.setattr(web_server, "init_db", altcoin_db.init_db)
altcoin_db.init_db()
scheduler_db.init_scheduler_tables()
@ -94,7 +99,9 @@ class _FakeProc:
def test_scheduler_starts_different_lock_groups_concurrently(monkeypatch, tmp_path):
db_path = tmp_path / "altcoin_monitor.db"
sched_path = tmp_path / "scheduler_state.db"
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
monkeypatch.setattr(scheduler_db, "SCHEDULER_DB_PATH", str(sched_path))
altcoin_db.init_db()
scheduler_db.init_scheduler_tables()
monkeypatch.setattr(scheduler.subprocess, "Popen", _FakeProc)
@ -113,7 +120,9 @@ def test_scheduler_starts_different_lock_groups_concurrently(monkeypatch, tmp_pa
def test_scheduler_blocks_shared_lock_and_prevents_reentry(monkeypatch, tmp_path):
db_path = tmp_path / "altcoin_monitor.db"
sched_path = tmp_path / "scheduler_state.db"
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
monkeypatch.setattr(scheduler_db, "SCHEDULER_DB_PATH", str(sched_path))
altcoin_db.init_db()
scheduler_db.init_scheduler_tables()
monkeypatch.setattr(scheduler.subprocess, "Popen", _FakeProc)
@ -138,7 +147,9 @@ def test_scheduler_blocks_shared_lock_and_prevents_reentry(monkeypatch, tmp_path
def test_disabled_job_does_not_auto_run_but_manual_force_can_start(monkeypatch, tmp_path):
db_path = tmp_path / "altcoin_monitor.db"
sched_path = tmp_path / "scheduler_state.db"
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
monkeypatch.setattr(scheduler_db, "SCHEDULER_DB_PATH", str(sched_path))
altcoin_db.init_db()
scheduler_db.init_scheduler_tables()
monkeypatch.setattr(scheduler.subprocess, "Popen", _FakeProc)