This commit is contained in:
aaron 2026-05-16 23:12:45 +08:00
parent a292982845
commit f3c96c3a72
22 changed files with 1937 additions and 281 deletions

View File

@ -1,6 +1,8 @@
"""
策略配置加载器 rules.yaml 加载所有参数支持热更新
review_engine 调整权重后直接写回 yaml下次运行自动生效
策略配置加载器
rules.yaml 只作为只读 baseline线上运行期变化写 PostgreSQL runtime config
避免代码部署覆盖生产策略迭代状态
"""
import copy
import datetime
@ -16,6 +18,8 @@ RULES_PATH = str(REPO_ROOT / "rules.yaml")
_cache = None
_cache_mtime = None
_yaml_cache = None
_yaml_cache_mtime = None
# 兼容旧代码中的信号名写法
_SIGNAL_NAME_ALIASES = {
@ -27,26 +31,121 @@ _SIGNAL_NAME_ALIASES = {
}
def _load_yaml_baseline(force_reload=False):
global _yaml_cache, _yaml_cache_mtime
mtime = os.path.getmtime(RULES_PATH) if os.path.exists(RULES_PATH) else 0
if not force_reload and _yaml_cache and _yaml_cache_mtime == mtime:
return _yaml_cache
with open(RULES_PATH, "r", encoding="utf-8") as f:
_yaml_cache = validate_rules_payload(yaml.safe_load(f) or {})
_yaml_cache_mtime = mtime
return _yaml_cache
def _runtime_overrides():
try:
from app.db.runtime_config_db import (
deep_merge,
get_event_driven_config,
get_event_sources,
get_learned_rules_config,
get_monitoring_config,
get_sentiment_config,
get_strategy_meta,
get_strategy_override,
set_event_driven_config,
set_event_sources,
set_monitoring_config,
set_sentiment_config,
)
override = get_strategy_override() or {}
meta = get_strategy_meta(default=None)
learned = get_learned_rules_config(default=None)
event_driven = get_event_driven_config(default=None)
event_sources = get_event_sources(default=None)
sentiment = get_sentiment_config(default=None)
monitoring = get_monitoring_config(default=None)
baseline = _yaml_cache or {}
if event_driven is None:
baseline_event = copy.deepcopy(baseline.get("event_driven", {}))
if isinstance(baseline_event, dict) and baseline_event:
if event_sources is not None and isinstance(event_sources, dict):
baseline_event = deep_merge(baseline_event, {"sources": event_sources})
event_driven = set_event_driven_config(baseline_event, source="seed_from_rules_yaml")
baseline_sources = baseline_event.get("sources", {})
if isinstance(baseline_sources, dict) and baseline_sources and get_event_sources(default=None) is None:
set_event_sources(baseline_sources, source="seed_from_rules_yaml")
elif event_sources is not None and isinstance(event_driven, dict):
event_driven = deep_merge(event_driven, {"sources": event_sources})
if sentiment is None:
baseline_sentiment = copy.deepcopy(baseline.get("sentiment", {}))
if isinstance(baseline_sentiment, dict) and baseline_sentiment:
sentiment = set_sentiment_config(baseline_sentiment, source="seed_from_rules_yaml")
if monitoring is None:
baseline_monitoring = copy.deepcopy(baseline.get("monitoring", {}))
if isinstance(baseline_monitoring, dict) and baseline_monitoring:
monitoring = set_monitoring_config(baseline_monitoring, source="seed_from_rules_yaml")
if isinstance(meta, dict) and meta:
override = deep_merge(override, {"meta": meta})
if isinstance(learned, list):
override = deep_merge(override, {"learned_rules": learned})
if isinstance(event_driven, dict) and event_driven:
override = deep_merge(override, {"event_driven": event_driven})
if isinstance(sentiment, dict) and sentiment:
override = deep_merge(override, {"sentiment": sentiment})
if isinstance(monitoring, dict) and monitoring:
override = deep_merge(override, {"monitoring": monitoring})
return override
except Exception:
return {}
def load_rules(force_reload=False):
"""加载 rules.yaml带文件变更检测自动刷新缓存"""
"""加载配置rules.yaml baseline + PostgreSQL runtime override。"""
global _cache, _cache_mtime
baseline = _load_yaml_baseline(force_reload=force_reload)
mtime = os.path.getmtime(RULES_PATH) if os.path.exists(RULES_PATH) else 0
if not force_reload and _cache and _cache_mtime == mtime:
return _cache
with open(RULES_PATH, "r", encoding="utf-8") as f:
_cache = validate_rules_payload(yaml.safe_load(f) or {})
rules = copy.deepcopy(baseline)
try:
from app.db.runtime_config_db import deep_merge
rules = deep_merge(rules, _runtime_overrides())
except Exception:
pass
_cache = validate_rules_payload(rules or {})
_cache_mtime = mtime
return _cache
def save_rules(rules_dict):
"""保存修改后的 rules.yamlreview_engine 调整权重后用)"""
"""保存运行期策略覆盖到 PostgreSQL不再写 rules.yaml。"""
global _cache, _cache_mtime
with open(RULES_PATH, "w", encoding="utf-8") as f:
yaml.dump(rules_dict, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
_cache = rules_dict
_cache_mtime = os.path.getmtime(RULES_PATH)
baseline = _load_yaml_baseline(force_reload=True)
diff = diff_rule_snapshots(baseline, rules_dict)
override = {}
def set_path(target, dotted_path, value):
node = target
parts = dotted_path.split(".") if dotted_path else []
for part in parts[:-1]:
node = node.setdefault(part, {})
if parts:
node[parts[-1]] = copy.deepcopy(value)
for item in diff.get("changed", []) + diff.get("added", []):
set_path(override, item["path"], item.get("new"))
try:
from app.db.runtime_config_db import set_strategy_override
set_strategy_override(override, source="runtime_save_rules")
except Exception as exc:
if os.getenv("ALPHAX_ALLOW_YAML_RUNTIME_WRITE", "0").strip() == "1":
with open(RULES_PATH, "w", encoding="utf-8") as f:
yaml.dump(rules_dict, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
else:
raise RuntimeError("Runtime rules writes must go to PostgreSQL; rules.yaml is read-only") from exc
_cache = validate_rules_payload(copy.deepcopy(rules_dict))
_cache_mtime = os.path.getmtime(RULES_PATH) if os.path.exists(RULES_PATH) else 0
def _get_section(section_name, default=None):
@ -185,8 +284,13 @@ def get_reverse_params():
def get_learned_rules(active_only=True):
"""返回已学习的规律列表"""
rules = load_rules()
learned = copy.deepcopy(rules.get("learned_rules", []))
try:
from app.db.runtime_config_db import get_learned_rules_config
learned = get_learned_rules_config(default=None)
if learned is None:
learned = copy.deepcopy(load_rules().get("learned_rules", []))
except Exception:
learned = copy.deepcopy(load_rules().get("learned_rules", []))
if active_only:
return [r for r in learned if r.get("active", True)]
return learned
@ -194,8 +298,7 @@ def get_learned_rules(active_only=True):
def add_learned_rule(rule_dict):
"""添加一条新学习规律"""
rules = load_rules(force_reload=True)
learned = rules.get("learned_rules", [])
learned = get_learned_rules(active_only=False)
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M")
rule_dict["id"] = f"rule_{ts}_{len(learned)+1:03d}"
rule_dict["created"] = datetime.datetime.now().strftime("%Y-%m-%d")
@ -203,27 +306,31 @@ def add_learned_rule(rule_dict):
rule_dict["miss_count"] = 0
rule_dict["active"] = True
learned.append(rule_dict)
rules["learned_rules"] = learned
rules.setdefault("meta", {})["total_rules_learned"] = len(learned)
save_rules(rules)
try:
from app.db.runtime_config_db import set_learned_rules_config
set_learned_rules_config(learned, source="add_learned_rule")
except Exception:
rules = load_rules(force_reload=True)
rules["learned_rules"] = learned
save_rules(rules)
update_meta("total_rules_learned", len(learned))
return rule_dict["id"]
def update_learned_rule(rule_id, updates):
"""更新一条规律(如 hit_count/miss_count/active 状态)"""
rules = load_rules(force_reload=True)
learned = rules.get("learned_rules", [])
learned = get_learned_rules(active_only=False)
for r in learned:
if r.get("id") == rule_id:
for k, v in updates.items():
r[k] = v
break
rules["learned_rules"] = learned
save_rules(rules)
from app.db.runtime_config_db import set_learned_rules_config
set_learned_rules_config(learned, source="update_learned_rule")
def update_signal_weight(signal_name, new_weight):
"""更新单个信号权重(写回 yaml + DB"""
"""更新单个信号权重(写 DB runtime override + signal_performance"""
canonical_name = normalize_signal_name(signal_name)
rules = load_rules(force_reload=True)
rules.setdefault("signal_weights", {})[canonical_name] = new_weight
@ -237,7 +344,13 @@ def update_signal_weight(signal_name, new_weight):
def get_meta():
"""返回迭代元数据"""
meta = _get_section("meta")
try:
from app.db.runtime_config_db import get_strategy_meta
meta = get_strategy_meta(default=None)
if not isinstance(meta, dict) or not meta:
meta = _get_section("meta")
except Exception:
meta = _get_section("meta")
if not meta.get("strategy_version"):
version_num = meta.get("version", 1)
iteration = meta.get("iteration_count", 0)
@ -289,8 +402,7 @@ def promote_candidate_rule_to_learned_rule(candidate, release_version=""):
desc = (candidate.get("rule_description") or "").strip()
if not desc:
return None
rules = load_rules(force_reload=True)
learned_rules = rules.setdefault("learned_rules", [])
learned_rules = get_learned_rules(active_only=False)
for existing in learned_rules:
if existing.get("description") == desc:
return existing.get("id") or existing.get("rule_id")
@ -312,7 +424,8 @@ def promote_candidate_rule_to_learned_rule(candidate, release_version=""):
"created_at": datetime.datetime.now().isoformat(),
}
learned_rules.append(rule)
save_rules(rules)
from app.db.runtime_config_db import set_learned_rules_config
set_learned_rules_config(learned_rules, source="candidate_release_gate")
return rule_id
@ -334,9 +447,15 @@ def bump_strategy_patch_version(note=""):
def update_meta(key, value):
"""更新迭代元数据"""
rules = load_rules(force_reload=True)
rules.setdefault("meta", {})[key] = value
save_rules(rules)
meta = get_meta()
meta[key] = value
try:
from app.db.runtime_config_db import set_strategy_meta
set_strategy_meta(meta, source="update_meta")
except Exception:
rules = load_rules(force_reload=True)
rules.setdefault("meta", {})[key] = value
save_rules(rules)
# === 快捷取值函数(给各模块直接 import 用)===

382
app/config/system_config.py Normal file
View File

@ -0,0 +1,382 @@
"""System configuration defaults and DB-backed accessors."""
from __future__ import annotations
import os
from app.db.runtime_config_db import (
get_bootstrap_admin_config,
get_email_config,
get_event_driven_config,
get_llm_config,
get_monitoring_config,
get_notification_config,
get_onchain_config,
get_paper_trading_config,
get_scheduler_config,
get_sentiment_config,
seed_system_defaults,
)
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 _env_str(name, default=""):
return os.getenv(name, default).strip()
def _env_list(name, default):
raw = os.getenv(name, ",".join(default))
return [x.strip().lower() for x in raw.split(",") if x.strip()] or list(default)
def default_llm_config():
return {
"enabled": _env_bool("ALPHAX_LLM_ENABLED", False),
"base_url": _env_str("ALPHAX_LLM_BASE_URL", "https://api.openai.com/v1"),
"api_key_env": _env_str("ALPHAX_LLM_API_KEY_ENV", "ALPHAX_LLM_API_KEY"),
"model": _env_str("ALPHAX_LLM_MODEL", "gpt-4o-mini"),
"timeout": _env_int("ALPHAX_LLM_TIMEOUT", 20),
"max_tokens": _env_int("ALPHAX_LLM_MAX_TOKENS", 900),
"modules": {
"recommendations": _env_bool("ALPHAX_LLM_RECOMMENDATIONS_ENABLED", True),
"sentiment": _env_bool("ALPHAX_LLM_SENTIMENT_ENABLED", True),
"review": _env_bool("ALPHAX_LLM_REVIEW_ENABLED", True),
},
}
def default_onchain_config(default_chains=("ethereum", "bsc", "base", "arbitrum", "solana")):
return {
"enabled": _env_bool("ALPHAX_ONCHAIN_ENABLED", False),
"chains": _env_list("ALPHAX_ONCHAIN_CHAINS", 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_env": "ALPHAX_ETHERSCAN_API_KEY",
"helius_api_key_env": "ALPHAX_HELIUS_API_KEY",
}
def default_paper_trading_config():
return {
"enabled": _env_bool("ALPHAX_PAPER_TRADING_ENABLED", True),
"account_equity_usdt": _env_float("ALPHAX_PAPER_ACCOUNT_EQUITY_USDT", 20000),
"trade_notional_usdt": _env_float("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", 5000),
"trade_leverage": _env_float("ALPHAX_PAPER_TRADE_LEVERAGE", 5),
"fee_rate": _env_float("ALPHAX_PAPER_TRADE_FEE_RATE", 0.001),
"slippage_pct": _env_float("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", 0.05),
}
def default_sentiment_config():
return {
"enabled": _env_bool("ALPHAX_SENTIMENT_ENABLED", True),
"provider": _env_str("ALPHAX_SENTIMENT_PROVIDER", "coingecko"),
"max_bonus": _env_float("ALPHAX_SENTIMENT_MAX_BONUS", 2),
"trending_top5_bonus": _env_float("ALPHAX_SENTIMENT_TOP5_BONUS", 2.0),
"trending_top10_bonus": _env_float("ALPHAX_SENTIMENT_TOP10_BONUS", 1.0),
"new_entry_bonus": _env_float("ALPHAX_SENTIMENT_NEW_ENTRY_BONUS", 1.0),
"decay_hours": _env_float("ALPHAX_SENTIMENT_DECAY_HOURS", 6),
"decay_factor": _env_float("ALPHAX_SENTIMENT_DECAY_FACTOR", 0.1),
"min_decay": _env_float("ALPHAX_SENTIMENT_MIN_DECAY", 0.3),
"collect_interval_min": _env_int("ALPHAX_SENTIMENT_COLLECT_INTERVAL_MIN", 30),
"alert_conditions": {
"holding_top3": True,
"new_trending_top10": True,
},
}
def default_event_driven_config():
return {
"enabled": _env_bool("ALPHAX_EVENT_DRIVEN_ENABLED", True),
"poll_interval_min": _env_int("ALPHAX_EVENT_POLL_INTERVAL_MIN", 1),
"decision_target_seconds": _env_int("ALPHAX_EVENT_DECISION_TARGET_SECONDS", 60),
"news_time_window_hours": _env_float("ALPHAX_EVENT_NEWS_TIME_WINDOW_HOURS", 3),
"max_event_age_hours": _env_float("ALPHAX_EVENT_MAX_EVENT_AGE_HOURS", 6),
"dedup_window_hours": _env_float("ALPHAX_EVENT_DEDUP_WINDOW_HOURS", 24),
"min_importance_level": _env_str("ALPHAX_EVENT_MIN_IMPORTANCE_LEVEL", "A"),
"sources": {},
"importance": {
"s_keywords": [
"will list",
"will launch",
"futures will launch",
"perpetual contract",
"launchpool",
"megadrop",
"hodler airdrops",
"coinbase will add",
"upbit listing",
"bithumb listing",
],
"a_keywords": [
"margin will add",
"new trading pairs",
"earn",
"convert",
"roadmap",
"mainnet",
"tokenomics",
"airdrop",
"burn",
"buyback",
"partnership",
"integration",
"upgrade",
],
"negative_keywords": [
"delist",
"suspend trading",
"remove",
"cease trading",
"risk warning",
],
},
"technical_check": {
"min_tech_score_recommend": 6,
"min_tech_score_observe": 3,
"reject_if_24h_gain_gt": 30,
"warn_if_24h_gain_gt": 18,
"reject_if_funding_gt": 0.003,
"allow_static_accumulation": True,
"allow_volume_breakout": True,
"allow_ignition": True,
},
"push": {
"recommend": True,
"observe": True,
"risk": True,
"cooldown_hours": 6,
},
"theme_expansion": {
"enabled": True,
"min_theme_importance": "A",
"max_expanded_symbols": 12,
"static_accumulation_bonus": {
"enabled": True,
"min_static_count": 8,
"score_bonus": 3,
"note": "重大生态事件命中后强静K蓄力币提前升权",
},
"themes": {},
},
}
def default_monitoring_config():
return {
"untouched_rate": {
"description": "未触达率自动监控",
"threshold_pct": 35,
"check_window_days": 2,
"auto_bump": {
"enabled": True,
"min_score_5_to_6": True,
"min_score_max": 6,
"require_human_if_exceeded": True,
},
},
"param_audit": {
"description": "参数变更审计",
"validate_script": "scripts/validate_params.py",
"hash_algorithm": "semantic_sha256",
"critical_sections": ["confirm", "screener", "pa_engine", "signal_weights", "tracker", "sentiment"],
},
}
def default_notification_config():
return {
"enabled": _env_bool("ALPHAX_NOTIFICATION_ENABLED", True),
"feishu": {
"enabled": _env_bool("ALPHAX_FEISHU_ENABLED", True),
"webhook_env": _env_str("ALPHAX_FEISHU_WEBHOOK_ENV", "ALTCOIN_FEISHU_WEBHOOK"),
"timeout": _env_int("ALPHAX_FEISHU_TIMEOUT", 10),
},
}
def default_email_config():
return {
"enabled": _env_bool("ALPHAX_EMAIL_ENABLED", True),
"smtp": {
"host": _env_str("ALPHAX_SMTP_HOST", _env_str("ASTOCK_SMTP_HOST", "")),
"port": _env_int("ALPHAX_SMTP_PORT", _env_int("ASTOCK_SMTP_PORT", 465)),
"username_env": _env_str("ALPHAX_SMTP_USERNAME_ENV", "ASTOCK_SMTP_USERNAME"),
"password_env": _env_str("ALPHAX_SMTP_PASSWORD_ENV", "ASTOCK_SMTP_PASSWORD"),
"sender": _env_str("ALPHAX_SMTP_SENDER", ""),
"sender_env": _env_str("ALPHAX_SMTP_SENDER_ENV", "ASTOCK_SMTP_SENDER"),
"timeout": _env_int("ALPHAX_SMTP_TIMEOUT", 12),
},
}
def default_bootstrap_admin_config():
return {
"enabled": _env_bool("ALPHAX_BOOTSTRAP_ADMIN", True),
"email_env": _env_str("ALPHAX_DEFAULT_ADMIN_EMAIL_ENV", "ALPHAX_DEFAULT_ADMIN_EMAIL"),
"password_env": _env_str("ALPHAX_DEFAULT_ADMIN_PASSWORD_ENV", "ALPHAX_DEFAULT_ADMIN_PASSWORD"),
}
def default_scheduler_config():
return {
"dry_run": _env_bool("ALPHAX_SCHEDULER_DRY_RUN", True),
"poll_seconds": _env_float("ALPHAX_SCHEDULER_POLL_SECONDS", 1.0),
"config_reload_seconds": _env_float("ALPHAX_SCHEDULER_CONFIG_RELOAD_SECONDS", 5.0),
"pending_warn_seconds": _env_float("ALPHAX_SCHEDULER_PENDING_WARN_SECONDS", 30.0),
}
def seed_runtime_system_defaults():
return seed_system_defaults({
"llm": (default_llm_config(), "LLM provider and module switches; API key remains in env"),
"onchain": (default_onchain_config(), "On-chain provider and signal thresholds; API keys remain in env"),
"paper_trading": (default_paper_trading_config(), "Paper trading account and execution model"),
"sentiment": (default_sentiment_config(), "Sentiment monitoring settings"),
"event_driven": (default_event_driven_config(), "Event/news driven screening settings"),
"monitoring": (default_monitoring_config(), "Monitoring and audit settings"),
"notification": (default_notification_config(), "Notification channel switches and env pointers"),
"email": (default_email_config(), "SMTP email settings; password remains in env"),
"bootstrap_admin": (default_bootstrap_admin_config(), "Default admin bootstrap env pointers"),
"scheduler": (default_scheduler_config(), "Scheduler runtime process settings"),
})
def _seed_one(key: str, value, description: str):
return seed_system_defaults({key: (value, description)})
def llm_config():
cfg = get_llm_config(default=None)
if cfg is None:
_seed_one("llm", default_llm_config(), "LLM provider and module switches; API key remains in env")
cfg = get_llm_config(default=None)
return cfg or default_llm_config()
def onchain_config(default_chains=("ethereum", "bsc", "base", "arbitrum", "solana")):
cfg = get_onchain_config(default=None)
if cfg is None:
_seed_one("onchain", default_onchain_config(default_chains), "On-chain provider and signal thresholds; API keys remain in env")
cfg = get_onchain_config(default=None)
return cfg or default_onchain_config(default_chains)
def paper_trading_config():
cfg = get_paper_trading_config(default=None)
if cfg is None:
_seed_one("paper_trading", default_paper_trading_config(), "Paper trading account and execution model")
cfg = get_paper_trading_config(default=None)
return cfg or default_paper_trading_config()
def notification_config():
cfg = get_notification_config(default=None)
if cfg is None:
_seed_one("notification", default_notification_config(), "Notification channel switches and env pointers")
cfg = get_notification_config(default=None)
return cfg or default_notification_config()
def sentiment_config():
cfg = get_sentiment_config(default=None)
if cfg is None:
_seed_one("sentiment", default_sentiment_config(), "Sentiment monitoring settings")
cfg = get_sentiment_config(default=None)
return cfg or default_sentiment_config()
def event_driven_config():
cfg = get_event_driven_config(default=None)
if cfg is None:
_seed_one("event_driven", default_event_driven_config(), "Event/news driven screening settings")
cfg = get_event_driven_config(default=None)
return cfg or default_event_driven_config()
def monitoring_config():
cfg = get_monitoring_config(default=None)
if cfg is None:
_seed_one("monitoring", default_monitoring_config(), "Monitoring and audit settings")
cfg = get_monitoring_config(default=None)
return cfg or default_monitoring_config()
def email_config():
cfg = get_email_config(default=None)
if cfg is None:
_seed_one("email", default_email_config(), "SMTP email settings; password remains in env")
cfg = get_email_config(default=None)
return cfg or default_email_config()
def bootstrap_admin_config():
cfg = get_bootstrap_admin_config(default=None)
if cfg is None:
_seed_one("bootstrap_admin", default_bootstrap_admin_config(), "Default admin bootstrap env pointers")
cfg = get_bootstrap_admin_config(default=None)
return cfg or default_bootstrap_admin_config()
def scheduler_config():
cfg = get_scheduler_config(default=None)
if cfg is None:
_seed_one("scheduler", default_scheduler_config(), "Scheduler runtime process settings")
cfg = get_scheduler_config(default=None)
return cfg or default_scheduler_config()
__all__ = [
"bootstrap_admin_config",
"default_bootstrap_admin_config",
"default_email_config",
"default_event_driven_config",
"default_llm_config",
"default_monitoring_config",
"default_notification_config",
"default_onchain_config",
"default_paper_trading_config",
"default_scheduler_config",
"default_sentiment_config",
"email_config",
"event_driven_config",
"llm_config",
"monitoring_config",
"notification_config",
"onchain_config",
"paper_trading_config",
"scheduler_config",
"sentiment_config",
"seed_runtime_system_defaults",
]

View File

@ -22,6 +22,7 @@ from typing import Optional
from psycopg import IntegrityError
from app.config.system_config import bootstrap_admin_config, email_config
from app.db.postgres_connection import connect as pg_connect, ensure_migrations_once, table_columns
REPO_ROOT = Path(__file__).resolve().parents[2]
@ -31,22 +32,52 @@ FREE_TRIAL_DAYS = 30
RESEND_COOLDOWN_SECONDS = 60
def _env_value(name: str, default: str = "") -> str:
return (os.getenv(str(name or ""), default) or "").strip()
def _smtp_settings() -> dict:
cfg = email_config() or {}
smtp = cfg.get("smtp") or {}
username = _env_value(smtp.get("username_env") or "ASTOCK_SMTP_USERNAME")
password = os.getenv(str(smtp.get("password_env") or "ASTOCK_SMTP_PASSWORD"), "")
sender = (smtp.get("sender") or "").strip() or _env_value(smtp.get("sender_env") or "ASTOCK_SMTP_SENDER", username)
try:
port = int(smtp.get("port") or 465)
except Exception:
port = 465
try:
timeout = int(smtp.get("timeout") or 12)
except Exception:
timeout = 12
return {
"enabled": bool(cfg.get("enabled", True)),
"host": (smtp.get("host") or "").strip(),
"port": port,
"username": username,
"password": password,
"sender": sender,
"timeout": timeout,
}
def is_smtp_configured() -> bool:
return all((os.getenv(k) or "").strip() for k in [
"ASTOCK_SMTP_HOST", "ASTOCK_SMTP_PORT", "ASTOCK_SMTP_USERNAME",
"ASTOCK_SMTP_PASSWORD", "ASTOCK_SMTP_SENDER",
])
settings = _smtp_settings()
if not settings["enabled"]:
return False
return all(str(settings.get(k) or "").strip() for k in ["host", "port", "username", "password", "sender"])
def send_verification_email(to_email: str, code: str) -> bool:
"""发送邮箱验证码。SMTP 凭据只从环境变量读取,不写入代码/数据库。"""
"""发送邮箱验证码。SMTP 密码只从配置指定的环境变量读取"""
if not is_smtp_configured():
return False
host = os.getenv("ASTOCK_SMTP_HOST", "").strip()
port = int(os.getenv("ASTOCK_SMTP_PORT", "465").strip() or "465")
username = os.getenv("ASTOCK_SMTP_USERNAME", "").strip()
password = os.getenv("ASTOCK_SMTP_PASSWORD", "")
sender = os.getenv("ASTOCK_SMTP_SENDER", username).strip()
settings = _smtp_settings()
host = settings["host"]
port = settings["port"]
username = settings["username"]
password = settings["password"]
sender = settings["sender"]
msg = EmailMessage()
msg["Subject"] = "AlphaX Agent Crypto 邮箱验证码"
@ -87,7 +118,7 @@ def send_verification_email(to_email: str, code: str) -> bool:
""",
subtype="html",
)
with smtplib.SMTP_SSL(host, port, timeout=12) as smtp:
with smtplib.SMTP_SSL(host, port, timeout=settings["timeout"]) as smtp:
smtp.login(username, password)
smtp.send_message(msg)
return True
@ -174,11 +205,11 @@ def _public_user(row) -> dict:
def ensure_default_admin() -> dict:
"""在全新空库中创建默认管理员;已有任何用户时不做任何修改。"""
init_auth_db()
email = (os.getenv("ALPHAX_DEFAULT_ADMIN_EMAIL") or "").strip().lower()
password = os.getenv("ALPHAX_DEFAULT_ADMIN_PASSWORD") or ""
enabled = (os.getenv("ALPHAX_BOOTSTRAP_ADMIN", "1") or "1").strip().lower()
if enabled in ("0", "false", "no", "off"):
cfg = bootstrap_admin_config() or {}
if not bool(cfg.get("enabled", True)):
return {"created": False, "reason": "disabled"}
email = _env_value(cfg.get("email_env") or "ALPHAX_DEFAULT_ADMIN_EMAIL").lower()
password = os.getenv(str(cfg.get("password_env") or "ALPHAX_DEFAULT_ADMIN_PASSWORD"), "")
if not email or not password:
return {"created": False, "reason": "missing_env"}
email = _normalize_email(email)

View File

@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS strategy_runtime_config (
config_key TEXT PRIMARY KEY,
config_json TEXT NOT NULL DEFAULT '{}',
description TEXT DEFAULT '',
source TEXT DEFAULT 'system',
updated_by TEXT DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS system_config (
config_key TEXT PRIMARY KEY,
config_json TEXT NOT NULL DEFAULT '{}',
description TEXT DEFAULT '',
source TEXT DEFAULT 'system',
is_secret INTEGER DEFAULT 0,
updated_by TEXT DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_strategy_runtime_config_updated ON strategy_runtime_config(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_system_config_updated ON system_config(updated_at DESC);

View File

@ -6,6 +6,7 @@ import json
import os
from datetime import datetime, timedelta
from app.config.system_config import paper_trading_config
from app.db.schema import get_conn
@ -30,19 +31,19 @@ def _safe_int(value, default: int = 0) -> int:
def paper_trading_enabled() -> bool:
return os.getenv("ALPHAX_PAPER_TRADING_ENABLED", "1").strip().lower() not in {"0", "false", "no", "off"}
return bool(paper_trading_config().get("enabled", True))
def default_account_equity_usdt() -> float:
return max(1.0, _safe_float(os.getenv("ALPHAX_PAPER_ACCOUNT_EQUITY_USDT", "20000"), 20000.0))
return max(1.0, _safe_float(paper_trading_config().get("account_equity_usdt"), 20000.0))
def default_leverage() -> float:
return max(1.0, _safe_float(os.getenv("ALPHAX_PAPER_TRADE_LEVERAGE", "5"), 5.0))
return max(1.0, _safe_float(paper_trading_config().get("trade_leverage"), 5.0))
def default_notional_usdt() -> float:
return max(1.0, _safe_float(os.getenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "5000"), 5000.0))
return max(1.0, _safe_float(paper_trading_config().get("trade_notional_usdt"), 5000.0))
def default_margin_usdt() -> float:
@ -50,11 +51,11 @@ def default_margin_usdt() -> float:
def default_fee_rate() -> float:
return max(0.0, _safe_float(os.getenv("ALPHAX_PAPER_TRADE_FEE_RATE", "0.001"), 0.001))
return max(0.0, _safe_float(paper_trading_config().get("fee_rate"), 0.001))
def default_slippage_pct() -> float:
return max(0.0, _safe_float(os.getenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0.05"), 0.05))
return max(0.0, _safe_float(paper_trading_config().get("slippage_pct"), 0.05))
def _loads_json(value, fallback=None):

301
app/db/runtime_config_db.py Normal file
View File

@ -0,0 +1,301 @@
"""Runtime configuration storage.
rules.yaml is the read-only baseline. Anything that can change online lives
here so code deploys cannot overwrite production state.
"""
from __future__ import annotations
import copy
import json
from datetime import datetime
from app.db.schema import get_conn, init_db
STRATEGY_TABLE = "strategy_runtime_config"
SYSTEM_TABLE = "system_config"
def _now() -> str:
return datetime.now().isoformat()
def _loads(value, fallback=None):
try:
if isinstance(value, str) and value.strip():
return json.loads(value)
if isinstance(value, (dict, list)):
return value
except Exception:
pass
return copy.deepcopy(fallback if fallback is not None else {})
def _dumps(value) -> str:
return json.dumps(value if value is not None else {}, ensure_ascii=False, sort_keys=True, default=str)
def _table(kind: str) -> str:
return SYSTEM_TABLE if kind == "system" else STRATEGY_TABLE
def _ensure():
init_db()
def get_config(kind: str, key: str, default=None):
_ensure()
table = _table(kind)
conn = get_conn()
try:
row = conn.execute(f"SELECT config_json FROM {table} WHERE config_key=%s", (key,)).fetchone()
finally:
conn.close()
if not row:
return copy.deepcopy(default)
return _loads(row["config_json"], default if default is not None else {})
def set_config(kind: str, key: str, value, *, description: str = "", source: str = "manual", updated_by: str = "", is_secret: bool = False):
_ensure()
table = _table(kind)
now = _now()
conn = get_conn()
try:
if table == SYSTEM_TABLE:
conn.execute(
"""
INSERT INTO system_config (config_key, config_json, description, source, is_secret, updated_by, created_at, updated_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT(config_key) DO UPDATE SET
config_json=excluded.config_json,
description=COALESCE(NULLIF(excluded.description, ''), system_config.description),
source=excluded.source,
is_secret=excluded.is_secret,
updated_by=excluded.updated_by,
updated_at=excluded.updated_at
""",
(key, _dumps(value), description, source, int(bool(is_secret)), updated_by, now, now),
)
else:
conn.execute(
"""
INSERT INTO strategy_runtime_config (config_key, config_json, description, source, updated_by, created_at, updated_at)
VALUES (%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT(config_key) DO UPDATE SET
config_json=excluded.config_json,
description=COALESCE(NULLIF(excluded.description, ''), strategy_runtime_config.description),
source=excluded.source,
updated_by=excluded.updated_by,
updated_at=excluded.updated_at
""",
(key, _dumps(value), description, source, updated_by, now, now),
)
conn.commit()
finally:
conn.close()
return get_config(kind, key)
def delete_config(kind: str, key: str) -> bool:
_ensure()
table = _table(kind)
conn = get_conn()
try:
cur = conn.execute(f"DELETE FROM {table} WHERE config_key=%s", (key,))
conn.commit()
return getattr(cur, "rowcount", 0) > 0
finally:
conn.close()
def list_configs(kind: str):
_ensure()
table = _table(kind)
conn = get_conn()
try:
rows = conn.execute(f"SELECT * FROM {table} ORDER BY config_key").fetchall()
finally:
conn.close()
items = []
for row in rows:
item = dict(row)
item["config"] = _loads(item.pop("config_json", "{}"), {})
item["kind"] = kind
if item.get("is_secret"):
item["config"] = {"masked": True}
items.append(item)
return items
def deep_merge(base, override):
if not isinstance(base, dict) or not isinstance(override, dict):
return copy.deepcopy(override)
merged = copy.deepcopy(base)
for key, value in override.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = deep_merge(merged[key], value)
else:
merged[key] = copy.deepcopy(value)
return merged
def get_strategy_override():
return get_config("strategy", "rules_override", default={})
def set_strategy_override(value, updated_by="", source="manual"):
return set_config("strategy", "rules_override", value, description="Runtime strategy override layered on top of rules.yaml", source=source, updated_by=updated_by)
def get_strategy_meta(default=None):
return get_config("strategy", "meta", default=default)
def set_strategy_meta(value, updated_by="", source="runtime"):
return set_config("strategy", "meta", value, description="Runtime strategy iteration metadata", source=source, updated_by=updated_by)
def get_learned_rules_config(default=None):
return get_config("strategy", "learned_rules", default=default)
def set_learned_rules_config(value, updated_by="", source="runtime"):
return set_config("strategy", "learned_rules", value, description="Runtime learned rules released by review gate", source=source, updated_by=updated_by)
def get_event_sources(default=None):
return get_config("system", "event_driven.sources", default=default)
def set_event_sources(value, updated_by="", source="manual"):
return set_config("system", "event_driven.sources", value, description="Runtime news/event sources", source=source, updated_by=updated_by)
def get_event_driven_config(default=None):
return get_config("system", "event_driven", default=default)
def set_event_driven_config(value, updated_by="", source="manual"):
return set_config("system", "event_driven", value, description="Runtime event/news driven screening settings", source=source, updated_by=updated_by)
def get_sentiment_config(default=None):
return get_config("system", "sentiment", default=default)
def set_sentiment_config(value, updated_by="", source="manual"):
return set_config("system", "sentiment", value, description="Runtime sentiment monitoring settings", source=source, updated_by=updated_by)
def get_monitoring_config(default=None):
return get_config("system", "monitoring", default=default)
def set_monitoring_config(value, updated_by="", source="manual"):
return set_config("system", "monitoring", value, description="Runtime monitoring and audit settings", source=source, updated_by=updated_by)
def get_llm_config(default=None):
return get_config("system", "llm", default=default)
def set_llm_config(value, updated_by="", source="manual"):
return set_config("system", "llm", value, description="LLM provider and module switches; API key stays in env", source=source, updated_by=updated_by)
def get_onchain_config(default=None):
return get_config("system", "onchain", default=default)
def set_onchain_config(value, updated_by="", source="manual"):
return set_config("system", "onchain", value, description="On-chain provider and signal thresholds; API keys stay in env", source=source, updated_by=updated_by)
def get_paper_trading_config(default=None):
return get_config("system", "paper_trading", default=default)
def set_paper_trading_config(value, updated_by="", source="manual"):
return set_config("system", "paper_trading", value, description="Paper trading account and execution model", source=source, updated_by=updated_by)
def get_notification_config(default=None):
return get_config("system", "notification", default=default)
def set_notification_config(value, updated_by="", source="manual"):
return set_config("system", "notification", value, description="Notification channel switches and env pointers", source=source, updated_by=updated_by)
def get_email_config(default=None):
return get_config("system", "email", default=default)
def set_email_config(value, updated_by="", source="manual"):
return set_config("system", "email", value, description="SMTP email settings; password stays in env", source=source, updated_by=updated_by)
def get_bootstrap_admin_config(default=None):
return get_config("system", "bootstrap_admin", default=default)
def set_bootstrap_admin_config(value, updated_by="", source="manual"):
return set_config("system", "bootstrap_admin", value, description="Default admin bootstrap env pointers", source=source, updated_by=updated_by)
def get_scheduler_config(default=None):
return get_config("system", "scheduler", default=default)
def set_scheduler_config(value, updated_by="", source="manual"):
return set_config("system", "scheduler", value, description="Scheduler runtime process settings", source=source, updated_by=updated_by)
def seed_system_defaults(defaults: dict[str, tuple[object, str]]):
seeded = []
for key, item in (defaults or {}).items():
value, description = item
if get_config("system", key, default=None) is None:
set_config("system", key, value, description=description, source="seed_default")
seeded.append(key)
return seeded
__all__ = [
"deep_merge",
"delete_config",
"get_config",
"get_event_driven_config",
"get_event_sources",
"get_bootstrap_admin_config",
"get_email_config",
"get_llm_config",
"get_monitoring_config",
"get_notification_config",
"get_onchain_config",
"get_paper_trading_config",
"get_scheduler_config",
"get_sentiment_config",
"get_learned_rules_config",
"get_strategy_meta",
"get_strategy_override",
"list_configs",
"set_config",
"set_event_driven_config",
"set_event_sources",
"set_bootstrap_admin_config",
"set_email_config",
"set_llm_config",
"set_monitoring_config",
"set_notification_config",
"set_onchain_config",
"set_paper_trading_config",
"set_scheduler_config",
"set_sentiment_config",
"seed_system_defaults",
"set_learned_rules_config",
"set_strategy_meta",
"set_strategy_override",
]

View File

@ -9,8 +9,23 @@ import os
import json
import requests
# === 飞书 Webhook URL用户指定的山寨币专用 webhook===
FEISHU_WEBHOOK_URL = os.getenv("ALTCOIN_FEISHU_WEBHOOK", "").strip()
from app.config.system_config import notification_config
def _feishu_settings():
cfg = notification_config() or {}
feishu = cfg.get("feishu") or {}
try:
timeout = int(feishu.get("timeout") or 10)
except Exception:
timeout = 10
webhook_env = str(feishu.get("webhook_env") or "ALTCOIN_FEISHU_WEBHOOK")
return {
"enabled": bool(cfg.get("enabled", True)) and bool(feishu.get("enabled", True)),
"webhook_env": webhook_env,
"webhook_url": os.getenv(webhook_env, "").strip(),
"timeout": timeout,
}
def push_card(card_content):
@ -20,9 +35,12 @@ def push_card(card_content):
"card": card_content,
}
try:
if not FEISHU_WEBHOOK_URL:
return False, "ALTCOIN_FEISHU_WEBHOOK not configured"
r = requests.post(FEISHU_WEBHOOK_URL, json=payload, timeout=10)
settings = _feishu_settings()
if not settings["enabled"]:
return False, "feishu notification disabled"
if not settings["webhook_url"]:
return False, f"{settings['webhook_env']} not configured"
r = requests.post(settings["webhook_url"], json=payload, timeout=settings["timeout"])
result = r.json()
ok = (r.status_code == 200 and result.get("StatusCode") == 0)
return ok, result
@ -425,7 +443,8 @@ def push_event_driven_alert(event, result, rec_id=0):
if __name__ == "__main__":
# 测试推送
print(f"Webhook URL: {FEISHU_WEBHOOK_URL[:50]}...")
settings = _feishu_settings()
print(f"Webhook env: {settings['webhook_env']} configured={bool(settings['webhook_url'])}")
print("\n测试爆发卡片推送...")
ok, result = push_altcoin_burst_alert(
"FET/USDT", 2.15,

View File

@ -15,6 +15,7 @@ from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
from urllib.parse import quote_plus
import xml.etree.ElementTree as ET
import ccxt
import pandas as pd
@ -81,6 +82,49 @@ def _parse_pubdate(value):
return None
def _xml_text(node, names):
for name in names:
child = node.find(name)
if child is not None and child.text:
return child.text.strip()
return ""
def _find_feed_items(root):
items = root.findall(".//item")
if items:
return items
return root.findall(".//{http://www.w3.org/2005/Atom}entry")
def _feed_entry_title(item):
return _xml_text(item, ["title", "{http://www.w3.org/2005/Atom}title"])
def _feed_entry_url(item):
link = item.find("link")
if link is not None:
href = link.attrib.get("href") or ""
if href:
return href.strip()
if link.text:
return link.text.strip()
atom_link = item.find("{http://www.w3.org/2005/Atom}link")
if atom_link is not None:
href = atom_link.attrib.get("href") or ""
if href:
return href.strip()
if atom_link.text:
return atom_link.text.strip()
guid = _xml_text(item, ["guid", "{http://www.w3.org/2005/Atom}id"])
return guid
def _feed_entry_time(item):
value = _xml_text(item, ["pubDate", "published", "updated", "{http://www.w3.org/2005/Atom}published", "{http://www.w3.org/2005/Atom}updated"])
return _parse_pubdate(value)
def _is_recent(dt, max_hours=None):
if not dt:
return False
@ -130,6 +174,23 @@ def _symbol_from_title(title):
return [s for s in sorted(candidates) if _tradable_symbol(s)]
def _symbols_from_text(text, aliases=None):
aliases = aliases or {}
symbols = set(_symbol_from_title(text))
clean = str(text or "")
for base in re.findall(r"(?<![A-Za-z0-9])\$?([A-Z][A-Z0-9]{1,12})(?![A-Za-z0-9])", clean):
symbol = f"{base.upper()}/USDT"
if _tradable_symbol(symbol):
symbols.add(symbol)
low = clean.lower()
for name, base in aliases.items():
if str(name or "").lower() in low:
symbol = f"{str(base or '').upper()}/USDT"
if _tradable_symbol(symbol):
symbols.add(symbol)
return sorted(symbols)
def _tradable_symbol(symbol):
base = symbol.split("/")[0].upper()
if base in STABLECOINS or base in WRAPPED or base in BTC_ETH or base in GOLD_METAL or base in BNB_CHAIN:
@ -315,6 +376,59 @@ def fetch_coingecko_trending_events():
return []
def fetch_rss_events(source_key, source_cfg):
"""Fetch generic RSS/Atom sources such as WuBlockchain."""
if not source_cfg.get("enabled", True):
return []
url = source_cfg.get("url")
if not url:
return []
max_items = int(source_cfg.get("max_items", 30) or 30)
default_importance = str(source_cfg.get("weight") or "B").upper()
symbol_aliases = source_cfg.get("symbol_aliases") or {}
events = []
try:
r = requests.get(url, timeout=int(source_cfg.get("timeout", 12) or 12), headers={"User-Agent": "Mozilla/5.0 AlphaX/1.0"})
if r.status_code != 200:
return []
root = ET.fromstring(r.content)
for item in _find_feed_items(root)[:max_items]:
title = _feed_entry_title(item)
if not title:
continue
pub = _feed_entry_time(item) or _now()
if not _is_recent(pub, _cfg().get("news_time_window_hours", 3)):
continue
symbols = _symbols_from_text(title, symbol_aliases)
if not symbols:
continue
importance, event_type = classify_event(title, source_key)
importance = _level_max(importance, default_importance)
if not _passes_min_importance(importance):
continue
raw = {
"title": title,
"link": _feed_entry_url(item),
"published_at": pub.isoformat(),
"source_label": source_cfg.get("label") or source_key,
}
for symbol in symbols[: int(source_cfg.get("max_symbols_per_item", 6) or 6)]:
events.append({
"source": source_key,
"symbol": symbol,
"title": title,
"url": raw["link"],
"published_at": pub,
"importance": importance,
"event_type": event_type if event_type != "minor_or_unknown" else "news",
"raw": raw,
})
return events
except Exception as e:
print(f"[event] fetch_rss_events error {source_key}: {e}")
return []
def collect_events():
cfg = _cfg()
sources = cfg.get("sources", {})
@ -322,6 +436,9 @@ def collect_events():
for key in ("binance_listing", "binance_latest"):
if key in sources:
events.extend(fetch_binance_events(key, sources[key]))
for key, source_cfg in sources.items():
if source_cfg.get("type") in ("rss", "atom"):
events.extend(fetch_rss_events(key, source_cfg))
events.extend(fetch_coingecko_trending_events())
return expand_theme_events(events)

View File

@ -6,6 +6,7 @@ from datetime import datetime, timedelta
import requests
from app.config.system_config import llm_config
from app.core.opportunity_lifecycle import normalize_action_status
from app.db.altcoin_db import get_conn, _derive_execution_fields
from app.db.llm_insights import compute_input_hash, get_any_insight, get_insights_for_targets, get_latest_insight_by_type, upsert_insight
@ -27,19 +28,25 @@ def _env_bool(name, default=False):
def get_llm_params():
"""Runtime LLM config. This is system config, not strategy config."""
cfg = llm_config()
return {
"enabled": _env_bool("ALPHAX_LLM_ENABLED", False),
"base_url": os.getenv("ALPHAX_LLM_BASE_URL", "https://api.openai.com/v1").strip(),
"api_key_env": os.getenv("ALPHAX_LLM_API_KEY_ENV", "ALPHAX_LLM_API_KEY").strip(),
"model": os.getenv("ALPHAX_LLM_MODEL", "gpt-4o-mini").strip(),
"timeout": int(os.getenv("ALPHAX_LLM_TIMEOUT", "20") or "20"),
"max_tokens": int(os.getenv("ALPHAX_LLM_MAX_TOKENS", "900") or "900"),
"enabled": bool(cfg.get("enabled", False)),
"base_url": str(cfg.get("base_url") or "https://api.openai.com/v1").strip(),
"api_key_env": str(cfg.get("api_key_env") or "ALPHAX_LLM_API_KEY").strip(),
"model": str(cfg.get("model") or "gpt-4o-mini").strip(),
"timeout": int(cfg.get("timeout") or 20),
"max_tokens": int(cfg.get("max_tokens") or 900),
"modules": cfg.get("modules") or {},
}
def get_llm_module_enabled(module_name):
if not _env_bool("ALPHAX_LLM_ENABLED", False):
params = get_llm_params()
if not params.get("enabled", False):
return False
modules = params.get("modules") or {}
if module_name in modules:
return bool(modules.get(module_name))
env_name = f"ALPHAX_LLM_{str(module_name or '').upper()}_ENABLED"
return _env_bool(env_name, True)

View File

@ -11,6 +11,7 @@ from datetime import datetime, timedelta
import requests
from app.config.system_config import onchain_config
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 (
@ -87,24 +88,31 @@ def _env_float(name, 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()]
cfg = onchain_config(DEFAULT_CHAINS)
chains_raw = cfg.get("chains") or list(DEFAULT_CHAINS)
if isinstance(chains_raw, str):
chains = [x.strip().lower() for x in chains_raw.split(",") if x.strip()]
else:
chains = [str(x).strip().lower() for x in chains_raw if str(x).strip()]
etherscan_env = str(cfg.get("etherscan_api_key_env") or "ALPHAX_ETHERSCAN_API_KEY")
helius_env = str(cfg.get("helius_api_key_env") or "ALPHAX_HELIUS_API_KEY")
return {
"enabled": _env_bool("ALPHAX_ONCHAIN_ENABLED", False),
"enabled": bool(cfg.get("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(),
"timeout": int(cfg.get("timeout") or 15),
"candidate_enabled": bool(cfg.get("candidate_enabled", True)),
"candidate_min_score": float(cfg.get("candidate_min_score") or 70),
"candidate_min_confidence": int(cfg.get("candidate_min_confidence") or 70),
"candidate_cooldown_hours": float(cfg.get("candidate_cooldown_hours") or 6),
"dexscreener_enabled": bool(cfg.get("dexscreener_enabled", True)),
"dex_volume_spike_pct": float(cfg.get("dex_volume_spike_pct") or 80),
"dex_min_liquidity_usd": float(cfg.get("dex_min_liquidity_usd") or 100000),
"dex_min_volume_24h_usd": float(cfg.get("dex_min_volume_24h_usd") or 100000),
"liquidity_add_pct": float(cfg.get("liquidity_add_pct") or 25),
"liquidity_remove_pct": float(cfg.get("liquidity_remove_pct") or -25),
"whale_tx_usd": float(cfg.get("whale_tx_usd") or 250000),
"etherscan_api_key": os.getenv(etherscan_env, "").strip(),
"helius_api_key": os.getenv(helius_env, "").strip(),
}

View File

@ -10,8 +10,10 @@ from app.db.scheduler_db import (
set_job_enabled,
set_job_interval,
)
from app.db.runtime_config_db import delete_config, get_config, list_configs, set_config
from app.db.system_logs import get_system_error, get_system_error_stats, list_system_errors
from app.web.shared import (
RuntimeConfigRequest,
SchedulerIntervalRequest,
SchedulerToggleRequest,
SchedulerTriggerRequest,
@ -81,6 +83,37 @@ def build_router(templates):
raise HTTPException(status_code=404, detail="日志不存在")
return item
@router.get("/api/runtime-config")
async def api_runtime_config(kind: str = "all", altcoin_session: str = Cookie(default="")):
require_admin(altcoin_session)
if kind == "strategy":
return {"items": list_configs("strategy")}
if kind == "system":
return {"items": list_configs("system")}
return {"items": list_configs("strategy") + list_configs("system")}
@router.get("/api/runtime-config/{kind}/{config_key:path}")
async def api_runtime_config_detail(kind: str, config_key: str, altcoin_session: str = Cookie(default="")):
require_admin(altcoin_session)
if kind not in ("strategy", "system"):
raise HTTPException(status_code=400, detail="kind must be strategy or system")
return {"kind": kind, "config_key": config_key, "config": get_config(kind, config_key, default={})}
@router.put("/api/runtime-config/{kind}/{config_key:path}")
async def api_runtime_config_update(kind: str, config_key: str, payload: RuntimeConfigRequest, altcoin_session: str = Cookie(default="")):
user = require_admin(altcoin_session)
if kind not in ("strategy", "system"):
raise HTTPException(status_code=400, detail="kind must be strategy or system")
config = set_config(kind, config_key, payload.config, description=payload.description, source="admin_page", updated_by=user.get("email", ""))
return {"ok": True, "kind": kind, "config_key": config_key, "config": config}
@router.delete("/api/runtime-config/{kind}/{config_key:path}")
async def api_runtime_config_delete(kind: str, config_key: str, altcoin_session: str = Cookie(default="")):
require_admin(altcoin_session)
if kind not in ("strategy", "system"):
raise HTTPException(status_code=400, detail="kind must be strategy or system")
return {"ok": delete_config(kind, config_key), "kind": kind, "config_key": config_key}
@router.get("/api/scheduler/jobs")
async def api_scheduler_jobs(altcoin_session: str = Cookie(default="")):
require_admin(altcoin_session)

View File

@ -1,5 +1,4 @@
import json
import os
from datetime import datetime, timezone, timedelta
from pathlib import Path
@ -11,13 +10,120 @@ from app.services.llm_insights import attach_sentiment_insights, get_latest_sent
from app.db.schema import get_conn
SOURCE_LABELS = {
"binance_listing": "Binance上币",
"binance_latest": "Binance公告",
"wublock123": "吴说区块链",
"panewslab": "PANews",
"coingecko_trending": "CoinGecko热度",
"llm_sentiment": "AI舆情",
}
def _source_label(source):
source = str(source or "")
if source in SOURCE_LABELS:
return SOURCE_LABELS[source]
if "binance" in source:
return "Binance公告"
if "coingecko" in source:
return "CoinGecko热度"
return source or "新闻源"
def _parse_time_any(value):
if not value:
return None
text = str(value).strip()
for fmt in ("%a, %d %b %Y %H:%M:%S %Z", "%a, %d %b %Y %H:%M:%S GMT"):
try:
return datetime.strptime(text, fmt).replace(tzinfo=timezone.utc)
except Exception:
pass
try:
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
return dt.astimezone(timezone.utc) if dt.tzinfo else dt
except Exception:
return None
def _age_hours(value):
dt = _parse_time_any(value)
if not dt:
return None
now = datetime.now(timezone.utc) if dt.tzinfo else datetime.now()
age = round((now - dt).total_seconds() / 3600, 1)
return max(0, age)
def _is_internal_sentiment_event(source, event_type, title):
return (
event_type in ("market_heat", "theme_expansion", "theme_direct", "llm_sentiment_candidate")
or source == "llm_sentiment"
or str(title or "").startswith("[主题扩散:")
)
def _event_news_items(hours=24, limit=80, include_internal=False):
hours = max(1, min(int(hours or 24), 168))
limit = max(1, min(int(limit or 80), 200))
cutoff = (datetime.now() - timedelta(hours=hours)).isoformat()
conn = get_conn()
try:
rows = conn.execute(
"""
SELECT id, source, symbol, title, url, published_at, detected_at, importance,
event_type, decision, tech_score, rec_id, pushed
FROM event_news
WHERE detected_at >= %s
ORDER BY published_at::timestamp DESC, id DESC
LIMIT %s
""",
(cutoff, limit),
).fetchall()
finally:
conn.close()
items = []
seen = set()
for r in rows:
source = r["source"] or "event"
event_type = r["event_type"] or "event"
title = r["title"] or ""
if not include_internal and _is_internal_sentiment_event(source, event_type, title):
continue
base = (r["symbol"] or "").split("/")[0].upper()
key = (title.strip().lower(), base, source)
if key in seen:
continue
seen.add(key)
items.append({
"event_id": f"event_news:{r['id']}",
"source": source,
"source_label": _source_label(source),
"event_type": event_type,
"importance": r["importance"] or "B",
"title": title,
"url": r["url"] or "",
"published_at": r["published_at"],
"detected_at": r["detected_at"],
"age_hours": _age_hours(r["published_at"] or r["detected_at"]),
"related_symbol": r["symbol"],
"related_base": base,
"related_name": "",
"decision": r["decision"] or "",
"tech_score": r["tech_score"] or 0,
"rec_id": r["rec_id"] or 0,
"pushed": bool(r["pushed"]),
"lang": "cn" if source in ("wublock123", "panewslab") else "event",
})
return items
def _newsfeed_payload():
import requests as req
import xml.etree.ElementTree as ET
from email.utils import parsedate_to_datetime
result = {"fear_greed": None, "trending": [], "news": []}
now = datetime.now(timezone.utc)
result = {"fear_greed": None, "trending": [], "news": [], "news_sources": []}
try:
r = req.get("https://api.alternative.me/fng/?limit=1", timeout=8)
@ -41,34 +147,13 @@ def _newsfeed_payload():
except Exception:
pass
def fetch_google_news(query, hl, gl, ceid, label):
items = []
try:
url = f"https://news.google.com/rss/search?q={req.utils.quote(query)}&hl={hl}&gl={gl}&ceid={ceid}"
r = req.get(url, timeout=12, headers={"User-Agent": "Mozilla/5.0"})
if r.status_code != 200:
return items
root = ET.fromstring(r.text)
for el in root.findall(".//item")[:15]:
pub_str = el.findtext("pubDate", "")
dt = parsedate_to_datetime(pub_str) if pub_str else None
age_h = round((now - dt).total_seconds() / 3600, 1) if dt else None
if age_h is not None and age_h > 48:
continue
items.append({
"title": (el.findtext("title", "") or "")[:120],
"url": el.findtext("link", "") or "",
"source": (el.findtext("source", "") or "")[:30],
"age_hours": age_h,
"lang": label,
})
except Exception:
pass
return items
en_news = fetch_google_news("cryptocurrency OR bitcoin OR ethereum OR defi OR altcoin when:24h", "en-US", "US", "US:en", "en")
cn_news = fetch_google_news("加密货币 OR 比特币 OR 以太坊 OR DeFi OR Web3 when:24h", "zh-CN", "CN", "CN:zh-Hans", "cn")
result["news"] = sorted(en_news + cn_news, key=lambda x: x.get("age_hours") or 999)[:30]
events = _event_news_items(hours=48, limit=120, include_internal=False)
result["news"] = events[:80]
counts = {}
for item in events:
label = item.get("source_label") or item.get("source") or "新闻源"
counts[label] = counts.get(label, 0) + 1
result["news_sources"] = [{"source": k, "count": v} for k, v in sorted(counts.items(), key=lambda x: x[1], reverse=True)]
return result
@ -93,30 +178,13 @@ def build_router(repo_root: Path):
screened_bases = {r["symbol"].split("/")[0].upper() for r in recent_screened}
events = []
now_utc = datetime.now(timezone.utc)
def _parse_event_time(value):
if not value:
return None
text = str(value).strip()
for fmt in ("%a, %d %b %Y %H:%M:%S %Z", "%a, %d %b %Y %H:%M:%S GMT"):
try:
return datetime.strptime(text, fmt).replace(tzinfo=timezone.utc)
except Exception:
pass
try:
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
except Exception:
return None
def _is_fresh_news(value, max_hours):
dt = _parse_event_time(value)
dt = _parse_time_any(value)
if not dt:
return False
age_hours = (now_utc - dt).total_seconds() / 3600
now = datetime.now(timezone.utc) if dt.tzinfo else datetime.now()
age_hours = (now - dt).total_seconds() / 3600
return 0 <= age_hours <= max_hours
valuable_news_keywords = [
@ -140,52 +208,17 @@ def build_router(repo_root: Path):
return False
return any(k in text for k in valuable_news_keywords)
try:
event_cutoff = (datetime.now() - timedelta(hours=float(hours or 6))).isoformat()
event_rows = conn.execute(
"""
SELECT id, source, symbol, title, url, published_at, detected_at, importance,
event_type, decision, tech_score, rec_id, pushed
FROM event_news
WHERE detected_at >= %s
ORDER BY published_at::timestamp DESC, id DESC
LIMIT 80
""",
(event_cutoff,),
).fetchall()
for r in event_rows:
base = (r["symbol"] or "").split("/")[0].upper()
source = r["source"] or "event"
event_type = r["event_type"] or "event"
title = r["title"] or ""
if event_type in ("market_heat", "theme_expansion", "theme_direct", "llm_sentiment_candidate") or source == "llm_sentiment" or title.startswith("[主题扩散:"):
continue
events.append({
"event_id": f"event_news:{r['id']}",
"source": source,
"source_label": "Binance公告" if "binance" in source else "CoinGecko热度" if "coingecko" in source else source,
"event_type": event_type,
"importance": r["importance"] or "B",
"title": title,
"url": r["url"] or "",
"published_at": r["published_at"],
"detected_at": r["detected_at"],
"related_symbol": r["symbol"],
"related_base": base,
"related_name": "",
"decision": r["decision"] or "",
"tech_score": r["tech_score"] or 0,
"rec_id": r["rec_id"] or 0,
"pushed": bool(r["pushed"]),
"in_active": base in active_symbols,
"in_screened": base in screened_bases,
"price_usd": 0,
"change_24h_pct": 0,
"market_cap_rank": 0,
"trend_rank": None,
})
except Exception:
pass
for e in _event_news_items(hours=hours, limit=100, include_internal=False):
base = e.get("related_base") or ""
e.update({
"in_active": base in active_symbols,
"in_screened": base in screened_bases,
"price_usd": 0,
"change_24h_pct": 0,
"market_cap_rank": 0,
"trend_rank": None,
})
events.append(e)
rows = conn.execute(
"""

View File

@ -67,6 +67,17 @@ def build_router(templates, repo_root: Path, stock_report_template: str):
return HTMLResponse(content=f"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回看板</a>", status_code=exc.status_code)
return render_page("cron.html", request, active_nav="cron")
@router.get("/config", response_class=HTMLResponse)
async def config_page(request: Request):
user, redirect = require_page_user(request)
if redirect:
return redirect
try:
require_admin(request.cookies.get("altcoin_session", ""))
except HTTPException as exc:
return HTMLResponse(content=f"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回看板</a>", status_code=exc.status_code)
return render_page("config.html", request, active_nav="config")
@router.get("/system-logs", response_class=HTMLResponse)
async def system_logs_page(request: Request):
user, redirect = require_page_user(request)

View File

@ -79,6 +79,11 @@ class SchedulerTriggerRequest(BaseModel):
force: bool = False
class RuntimeConfigRequest(BaseModel):
config: dict | list
description: str = ""
def auth_error(exc: Exception, status_code: int = 400):
raise HTTPException(status_code=status_code, detail=str(exc))

View File

@ -25,12 +25,26 @@ from app.db.scheduler_db import (
update_runtime,
)
from app.db.system_logs import record_system_error
from app.config.system_config import scheduler_config
PYTHON = sys.executable
DRY_RUN = os.getenv("ALPHAX_SCHEDULER_DRY_RUN", "1").strip() not in {"0", "false", "False", "no", "NO"}
POLL_SECONDS = 1.0
CONFIG_RELOAD_SECONDS = 5.0
PENDING_WARN_SECONDS = 30.0
DRY_RUN = None # Test-only override; runtime reads system_config.scheduler.dry_run.
def _scheduler_settings() -> dict:
cfg = scheduler_config() or {}
return {
"dry_run": bool(cfg.get("dry_run", True)),
"poll_seconds": float(cfg.get("poll_seconds") or 1.0),
"config_reload_seconds": float(cfg.get("config_reload_seconds") or 5.0),
"pending_warn_seconds": float(cfg.get("pending_warn_seconds") or 30.0),
}
def scheduler_dry_run() -> bool:
if DRY_RUN is not None:
return bool(DRY_RUN)
return bool(_scheduler_settings()["dry_run"])
@dataclass
@ -118,10 +132,11 @@ def _lock_busy(job: Job, running: dict[str, RunningJob]) -> bool:
def _mark_pending(job: Job, reason: str) -> None:
now = time.time()
pending_warn_seconds = _scheduler_settings()["pending_warn_seconds"]
if not job.pending:
job.pending = True
job.pending_since = now
if now - job.last_pending_log >= PENDING_WARN_SECONDS:
if now - job.last_pending_log >= pending_warn_seconds:
print(f"[{now_str()}] [scheduler] pending {job.name}: {reason}", flush=True)
job.last_pending_log = now
update_runtime(job.name, status="pending", locked_by=reason, next_run_at=_next_run_iso(job.next_run))
@ -139,7 +154,7 @@ def start_job(job: Job, running: dict[str, RunningJob], run_kind: str = "auto",
job.pending_since = 0.0
job.last_pending_log = 0.0
if DRY_RUN:
if scheduler_dry_run():
print(f"[{now_str()}] [scheduler] DRY_RUN=1 skip {job.name}", flush=True)
update_runtime(
job.name,
@ -297,15 +312,17 @@ def main() -> None:
running: dict[str, RunningJob] = {}
jobs = load_jobs(jobs, base, running)
last_reload = time.time()
print(f"[{now_str()}] [scheduler] started jobs={len(jobs)} dry_run={DRY_RUN} mode=concurrent", flush=True)
settings = _scheduler_settings()
print(f"[{now_str()}] [scheduler] started jobs={len(jobs)} dry_run={settings['dry_run']} mode=concurrent", flush=True)
while True:
finish_running_jobs(running)
if time.time() - last_reload >= CONFIG_RELOAD_SECONDS:
settings = _scheduler_settings()
if time.time() - last_reload >= settings["config_reload_seconds"]:
jobs = load_jobs(jobs, time.time(), running)
last_reload = time.time()
handle_manual_triggers(jobs, running)
schedule_due_jobs(jobs, running)
time.sleep(POLL_SECONDS)
time.sleep(settings["poll_seconds"])
if __name__ == "__main__":

View File

@ -294,6 +294,62 @@ event_driven:
enabled: true
weight: B
note: 只作为热度源;单独不直接推荐,必须技术确认
wublock123:
enabled: true
type: rss
label: 吴说区块链
weight: A
url: https://www.wublock123.com/feed
max_items: 30
max_symbols_per_item: 6
note: 中文高质量加密新闻源,适合进入 AI 舆情分析与事件驱动技术检查
symbol_aliases:
bitcoin: BTC
btc: BTC
ethereum: ETH
eth: ETH
solana: SOL
sol: SOL
binance coin: BNB
bnb: BNB
ripple: XRP
xrp: XRP
dogecoin: DOGE
doge: DOGE
sui: SUI
ton: TON
chainlink: LINK
avalanche: AVAX
arbitrum: ARB
optimism: OP
panewslab:
enabled: true
type: rss
label: PANews
weight: A
url: https://www.panewslab.com/rss.xml?lang=zh&type=NORMAL%2CNEWS
max_items: 30
max_symbols_per_item: 6
note: 中文加密新闻源,覆盖项目进展、交易所、监管和生态消息;进入 AI 舆情分析与事件驱动技术检查
symbol_aliases:
bitcoin: BTC
btc: BTC
ethereum: ETH
eth: ETH
solana: SOL
sol: SOL
binance coin: BNB
bnb: BNB
ripple: XRP
xrp: XRP
dogecoin: DOGE
doge: DOGE
sui: SUI
ton: TON
chainlink: LINK
avalanche: AVAX
arbitrum: ARB
optimism: OP
google_news_rss:
enabled: false
weight: B
@ -407,11 +463,11 @@ event_driven:
note: Solana meme主题扩散
meta:
version: 1
last_review: '2026-05-16T21:27:46.729074'
last_reverse_analysis: '2026-05-16T21:28:18.838591'
total_reviews: 59
last_review: '2026-05-16T22:13:30.935934'
last_reverse_analysis: '2026-05-16T22:14:07.712776'
total_reviews: 62
total_rules_learned: 37
iteration_count: 64
iteration_count: 67
strategy_version: v1.7.11
strategy_revision_started_at: '2026-05-09T01:20:00'
strategy_revision_note: 'v1.7.11: 触发时效治理,旧形态只作背景,消息触发显式标记'

View File

@ -162,6 +162,7 @@ a { color: inherit; text-decoration: none; }
<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>
<symbol id="svg-config" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h10"/><path d="M4 17h16"/><circle cx="17" cy="7" r="3"/><circle cx="7" cy="17" r="3"/></symbol>
<symbol id="svg-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></symbol>
</svg>
@ -172,21 +173,26 @@ a { color: inherit; text-decoration: none; }
<span class="brand-name">AlphaX Agent Crypto</span>
</a>
<nav class="sidebar-nav">
<div class="sidebar-section-label">交易</div>
<a class="sidebar-link {% if active_nav | default('app') == 'app' %}active{% endif %}" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
<a class="sidebar-link {% if active_nav == 'market' %}active{% endif %}" href="/market"><svg class="link-icon"><use href="#svg-target"/></svg>市场总览</a>
<a class="sidebar-link {% if active_nav == 'sentiment' %}active{% endif %}" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
<a class="sidebar-link {% if active_nav == 'onchain' %}active{% endif %}" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上异动</a>
<a class="sidebar-link {% if active_nav == 'paper_trading' %}active{% endif %}" href="/paper-trading"><svg class="link-icon"><use href="#svg-paper"/></svg>模拟交易</a>
<div class="sidebar-section-label">研究</div>
<a class="sidebar-link {% if active_nav == 'market' %}active{% endif %}" href="/market"><svg class="link-icon"><use href="#svg-target"/></svg>市场总览</a>
<a class="sidebar-link {% if active_nav == 'sentiment' %}active{% endif %}" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>实时舆情</a>
<a class="sidebar-link {% if active_nav == 'onchain' %}active{% endif %}" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上异动</a>
<div class="sidebar-section-label">账户</div>
<a class="sidebar-link {% if active_nav == 'subscription' %}active{% endif %}" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
<a class="sidebar-link {% if active_nav == 'referral' %}active{% endif %}" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
<a class="sidebar-link {% if active_nav == 'referral' %}active{% endif %}" 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 {% if active_nav == 'pipeline' %}active{% endif %}" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
<a class="sidebar-link admin-link {% if active_nav == 'cron' %}active{% endif %}" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
<a class="sidebar-link admin-link {% if active_nav == 'llm_insights' %}active{% endif %}" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
<a class="sidebar-link admin-link {% if active_nav == 'system_logs' %}active{% endif %}" href="/system-logs" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>系统日志</a>
<a class="sidebar-link admin-link {% if active_nav == 'strategy' %}active{% endif %}" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
<a class="sidebar-link admin-link {% if active_nav == 'iteration' %}active{% endif %}" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
<a class="sidebar-link admin-link {% if active_nav == 'admin' %}active{% endif %}" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
<div class="sidebar-section-label admin-link" style="display:none">系统</div>
<a class="sidebar-link admin-link {% if active_nav == 'config' %}active{% endif %}" href="/config" style="display:none"><svg class="link-icon"><use href="#svg-config"/></svg>配置中心</a>
<a class="sidebar-link admin-link {% if active_nav == 'cron' %}active{% endif %}" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
<a class="sidebar-link admin-link {% if active_nav == 'system_logs' %}active{% endif %}" href="/system-logs" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>系统日志</a>
<a class="sidebar-link admin-link {% if active_nav == 'admin' %}active{% endif %}" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>用户管理</a>
</nav>
<div class="sidebar-user" onclick="toggleUserMenu()">
<span class="user-avatar" id="userInitial">?</span>

63
static/config.html Normal file
View File

@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}AlphaX Agent Crypto — 配置中心{% endblock %}
{% block extra_head_css %}
<style>
.shell{width:min(100% - 40px,1280px);margin:0 auto;padding:24px 0 44px}.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap}.page-head h1{font-size:28px;font-weight:950;letter-spacing:-.6px;color:var(--ink)}.page-head p{margin-top:5px;color:var(--stone);font-size:13px;line-height:1.55;max-width:860px}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.btn,.select{height:38px;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:0 12px;font-size:13px;font-weight:850;color:var(--ink)}.btn{cursor:pointer}.layout{display:grid;grid-template-columns:380px minmax(0,1fr);gap:14px;align-items:start}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);overflow:hidden;min-width:0}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:13px 14px;border-bottom:1px solid var(--hairline-soft)}.panel-title{font-size:14px;font-weight:950;color:var(--ink)}.panel-note{font-size:11px;color:var(--stone);font-weight:850}.config-list{display:grid}.config-row{padding:12px 14px;border-bottom:1px solid var(--hairline-soft);cursor:pointer;transition:.12s}.config-row:hover{background:var(--surface)}.config-row.active{background:rgba(66,98,255,.06);box-shadow:inset 3px 0 0 var(--blue)}.key{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:950;color:var(--ink);font-size:13px}.meta{margin-top:5px;color:var(--stone);font-size:11px;line-height:1.45}.badge{display:inline-flex;height:22px;align-items:center;border-radius:999px;border:1px solid var(--hairline-soft);background:var(--surface);padding:0 8px;font-size:11px;font-weight:900;color:var(--slate)}.badge.strategy{color:var(--blue);background:rgba(66,98,255,.07);border-color:rgba(66,98,255,.16)}.badge.system{color:var(--green);background:var(--green-light);border-color:rgba(0,180,115,.18)}.editor{padding:14px}.field{margin-bottom:12px}.field label{display:block;font-size:11px;font-weight:900;color:var(--stone);margin-bottom:6px}.input,.textarea{width:100%;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:10px 12px;font-size:13px;color:var(--ink);outline:none}.textarea{min-height:420px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;line-height:1.55;resize:vertical}.msg{font-size:12px;min-height:18px;margin-top:8px}.msg.ok{color:var(--green)}.msg.err{color:var(--red)}.empty,.loading{padding:34px 16px;text-align:center;color:var(--stone);font-size:13px}.hint{padding:11px 12px;border:1px solid rgba(66,98,255,.14);background:rgba(66,98,255,.045);border-radius:var(--radius-md);color:var(--slate);font-size:12px;line-height:1.55;margin-bottom:14px}@media(max-width:980px){.layout{grid-template-columns:1fr}.shell{width:min(100% - 24px,1280px)}.textarea{min-height:320px}}
</style>
{% endblock %}
{% block content %}
<div class="shell">
<div class="page-head">
<div>
<h1>配置中心</h1>
<p>运行期配置保存在 PostgreSQL不再写回 rules.yaml。代码部署只更新默认模板不覆盖线上策略迭代和系统配置。</p>
</div>
<div class="actions">
<select class="select" id="kindFilter" onchange="loadConfigs()">
<option value="all">全部</option>
<option value="strategy">策略运行时</option>
<option value="system">系统配置</option>
</select>
<button class="btn" onclick="newConfig('strategy')">新增策略</button>
<button class="btn" onclick="newConfig('system')">新增系统</button>
<button class="btn" onclick="loadConfigs()">刷新</button>
</div>
</div>
<div class="hint">建议新闻源、LLM、链上、调度、模拟交易属于系统配置复盘 meta、learned_rules、策略覆盖属于策略运行时配置。</div>
<div class="layout">
<section class="panel">
<div class="panel-head"><div class="panel-title">配置列表</div><div class="panel-note" id="countNote">--</div></div>
<div class="config-list" id="configList"><div class="loading">加载中...</div></div>
</section>
<section class="panel">
<div class="panel-head"><div class="panel-title">编辑</div><div class="panel-note" id="editNote">选择一项配置</div></div>
<div class="editor">
<div class="field"><label>类型</label><select class="select" id="editKind"><option value="strategy">策略运行时</option><option value="system">系统配置</option></select></div>
<div class="field"><label>Key</label><input class="input" id="editKey" placeholder="例如 event_driven.sources 或 rules_override"></div>
<div class="field"><label>说明</label><input class="input" id="editDesc" placeholder="这项配置的用途"></div>
<div class="field"><label>JSON 配置</label><textarea class="textarea" id="editJson" spellcheck="false">{}</textarea></div>
<div class="actions">
<button class="btn" onclick="saveCurrent()">保存</button>
<button class="btn" onclick="deleteCurrent()">删除</button>
</div>
<div class="msg" id="msg"></div>
</div>
</section>
</div>
</div>
{% endblock %}
{% block extra_script %}
<script>
var items=[], selected='';
function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
function fmtTime(t){if(!t)return'--';var d=new Date(t);if(isNaN(d.getTime()))return String(t).slice(0,16).replace('T',' ');return (d.getMonth()+1)+'/'+d.getDate()+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')}
async function api(url,opt){var r=await fetch(url,opt||{});var d=await r.json().catch(function(){return{}});if(!r.ok)throw new Error(d.detail||d.error||'请求失败');return d}
async function loadConfigs(){document.getElementById('configList').innerHTML='<div class="loading">加载中...</div>';try{var kind=document.getElementById('kindFilter').value;var d=await api('/api/runtime-config?kind='+encodeURIComponent(kind));items=d.items||[];document.getElementById('countNote').textContent=items.length+' 项';renderList();if(items.length&&!selected)selectItem(items[0].kind+'::'+items[0].config_key)}catch(e){document.getElementById('configList').innerHTML='<div class="empty">'+esc(e.message)+'</div>'}}
function renderList(){if(!items.length){document.getElementById('configList').innerHTML='<div class="empty">暂无运行期配置</div>';return}document.getElementById('configList').innerHTML=items.map(function(x){var id=x.kind+'::'+x.config_key;return '<div class="config-row '+(id===selected?'active':'')+'" onclick="selectItem(\''+esc(id)+'\')"><div><span class="badge '+esc(x.kind)+'">'+(x.kind==='system'?'系统':'策略')+'</span></div><div class="key">'+esc(x.config_key)+'</div><div class="meta">'+esc(x.description||'--')+'<br>更新 '+fmtTime(x.updated_at)+' · '+esc(x.source||'system')+'</div></div>'}).join('')}
function selectItem(id){selected=id;var x=items.find(function(i){return i.kind+'::'+i.config_key===id});if(!x)return;document.getElementById('editKind').value=x.kind;document.getElementById('editKey').value=x.config_key;document.getElementById('editDesc').value=x.description||'';document.getElementById('editJson').value=JSON.stringify(x.config||{},null,2);document.getElementById('editNote').textContent=x.kind+' / '+x.config_key;document.getElementById('msg').textContent='';renderList()}
function newConfig(kind){selected='';document.getElementById('editKind').value=kind;document.getElementById('editKey').value='';document.getElementById('editDesc').value='';document.getElementById('editJson').value='{}';document.getElementById('editNote').textContent='新增配置';document.getElementById('msg').textContent='';renderList()}
async function saveCurrent(){var msg=document.getElementById('msg');try{var kind=document.getElementById('editKind').value;var key=document.getElementById('editKey').value.trim();if(!key)throw new Error('请填写 Key');var config=JSON.parse(document.getElementById('editJson').value||'{}');await api('/api/runtime-config/'+encodeURIComponent(kind)+'/'+encodeURIComponent(key),{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({config:config,description:document.getElementById('editDesc').value||''})});msg.className='msg ok';msg.textContent='已保存到 PostgreSQL';selected=kind+'::'+key;await loadConfigs()}catch(e){msg.className='msg err';msg.textContent=e.message}}
async function deleteCurrent(){var kind=document.getElementById('editKind').value;var key=document.getElementById('editKey').value.trim();if(!key)return;if(!confirm('确认删除 '+key+' ?'))return;try{await api('/api/runtime-config/'+encodeURIComponent(kind)+'/'+encodeURIComponent(key),{method:'DELETE'});selected='';newConfig(kind);await loadConfigs()}catch(e){document.getElementById('msg').className='msg err';document.getElementById('msg').textContent=e.message}}
loadConfigs();
</script>
{% endblock %}

View File

@ -4,18 +4,15 @@
<style>
/* SHELL */
.shell { position: relative; z-index: 1; width: min(100% - 40px, 960px); margin: 0 auto; padding: 24px 0; }
.shell { position: relative; z-index: 1; width: min(100% - 40px, 1180px); margin: 0 auto; padding: 24px 0 44px; }
/* Page title */
.page-title { font-size: 24px; font-weight: 800; color: var(--ink); margin-bottom: 4px; }
.page-sub { font-size: 13px; color: var(--stone); margin-bottom: 18px; }
.page-sub { font-size: 13px; color: var(--stone); margin-bottom: 16px; }
/* === SECTION: DASHBOARD === */
.market-context {
display: grid; grid-template-columns: minmax(210px, 260px) 1fr;
gap: 10px; margin-bottom: 18px;
}
@media(max-width:720px) { .market-context { grid-template-columns: 1fr; } }
.market-context { display: grid; grid-template-columns: minmax(200px, 250px) 1fr; gap: 10px; margin-bottom: 14px; }
@media(max-width:760px) { .market-context { grid-template-columns: 1fr; } }
/* Fear & Greed */
.fg-card {
@ -47,18 +44,19 @@
.trend-symbol { font-size: 11px; color: var(--stone); margin-left: 4px; }
.trend-rank { font-size: 10px; color: var(--muted); }
/* === SECTION: NEWS FEED === */
.feed-header { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
.feed-header h2 { font-size: 18px; font-weight: 700; }
.feed-header .feed-count { font-size: 12px; color: var(--muted); background: var(--surface); padding: 2px 10px; border-radius: var(--radius-full); }
.sentiment-grid { display:grid; grid-template-columns:minmax(0, 1.05fr) minmax(360px, .95fr); gap:14px; align-items:start; }
.panel { background: var(--canvas); border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); overflow:hidden; min-width:0; }
.panel-head { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:14px 16px; border-bottom:1px solid var(--hairline-soft); }
.panel-title { font-size: 16px; font-weight: 900; color: var(--ink); }
.panel-sub { margin-top:3px; color:var(--stone); font-size:12px; line-height:1.45; }
.feed-count { flex-shrink:0; font-size: 12px; color: var(--muted); background: var(--surface); padding: 3px 10px; border-radius: var(--radius-full); font-weight:800; }
.source-chips { display:flex; flex-wrap:wrap; gap:6px; padding:10px 16px 0; }
.source-chip { display:inline-flex; align-items:center; gap:5px; border:1px solid var(--hairline-soft); border-radius:999px; background:var(--surface); padding:4px 8px; color:var(--slate); font-size:11px; font-weight:850; }
.source-chip b { color:var(--ink); }
.news-feed { display: flex; flex-direction: column; gap: 8px; }
.news-card {
background: var(--canvas); border: 1px solid var(--hairline-soft);
border-radius: var(--radius-xl); padding: 16px 18px;
transition: .15s; cursor: pointer; display: flex; gap: 14px; align-items: flex-start;
}
.news-card { background: var(--canvas); border-top: 1px solid var(--hairline-soft); padding: 14px 16px; transition: .15s; cursor: pointer; display: flex; gap: 12px; align-items: flex-start; }
.news-card:hover { border-color: var(--hairline); box-shadow: 0 2px 8px rgba(5,0,56,.04); }
.news-card:active { transform: scale(.995); }
@ -70,16 +68,12 @@
}
.news-source.cn { color: var(--blue); background: rgba(66,98,255,.06); }
.news-body { flex: 1; min-width: 0; }
.news-title { font-size: 14px; font-weight: 600; color: var(--ink); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 6px; }
.news-title { font-size: 14px; font-weight: 750; color: var(--ink); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 6px; }
.news-meta { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--muted); }
.news-meta .dot { width: 3px; height: 3px; border-radius: 50%; background: var(--hairline); }
.ai-brief { margin-top: 8px; border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); padding: 10px 12px; }
.ai-brief .label { font-size: 10px; color: var(--stone); font-weight: 900; margin-bottom: 4px; }
.ai-brief .text { font-size: 12px; color: var(--slate); line-height: 1.55; }
.ai-brief .chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.ai-brief .chip { display: inline-flex; padding: 3px 7px; border-radius: 999px; border: 1px solid var(--hairline-soft); background: var(--canvas); color: var(--slate); font-size: 10px; }
.analysis-card { background: var(--canvas); border: 1px solid var(--hairline-soft); border-radius: var(--radius-xl); padding: 18px; margin-bottom: 18px; }
.news-card.important .news-source { color:var(--blue); background:rgba(66,98,255,.06); }
.news-card.risk .news-source { color:var(--red); background:var(--red-light); }
.analysis-card { padding: 16px; }
.analysis-head { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:12px; }
.analysis-title { font-size: 16px; font-weight: 900; color: var(--ink); }
.analysis-meta { color: var(--stone); font-size: 11px; font-weight: 800; text-align:right; line-height:1.5; }
@ -87,7 +81,7 @@
.mood.risk_on { color: var(--green); background: var(--green-light); border-color: rgba(0,180,115,.18); }
.mood.risk_off { color: var(--red); background: var(--red-light); border-color: rgba(229,62,62,.18); }
.analysis-summary { color: var(--slate); font-size: 14px; line-height: 1.75; margin-bottom: 14px; }
.analysis-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.analysis-grid { display:grid; grid-template-columns: 1fr; gap: 10px; }
.analysis-section { border:1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); padding: 12px; min-width:0; }
.analysis-section h3 { font-size: 12px; font-weight: 900; color: var(--ink); margin-bottom: 9px; }
.analysis-item { border-top:1px solid var(--hairline-soft); padding:8px 0; color:var(--slate); font-size:12px; line-height:1.55; }
@ -96,6 +90,7 @@
.analysis-item .sub { display:block; margin-top:3px; color:var(--stone); }
.symbol-tags { display:flex; flex-wrap:wrap; gap:4px; margin-top:5px; }
.symbol-tag { display:inline-flex; padding:3px 7px; border-radius:999px; background:var(--canvas); border:1px solid var(--hairline-soft); color:var(--blue); font-size:10px; font-weight:900; }
@media(max-width:1040px){ .sentiment-grid{grid-template-columns:1fr;} .analysis-grid{grid-template-columns:1fr 1fr;} }
@media(max-width:760px){ .analysis-grid{grid-template-columns:1fr;} .analysis-head{display:block;} .analysis-meta{text-align:left;margin-top:8px;} }
/* Empty */
@ -109,7 +104,7 @@
@keyframes spin { to{ transform:rotate(360deg) } }
@media(max-width:640px) {
.shell { width: min(100% - 24px, 960px); }
.shell { width: min(100% - 24px, 1180px); }
.news-card { padding: 14px 14px; gap: 10px; }
.news-source { min-width: 48px; font-size: 9px; padding: 3px 6px; }
}
@ -118,11 +113,7 @@
{% block content %}
<div class="shell">
<h1 class="page-title">实时舆情</h1>
<p class="page-sub">AI 舆情研判 + 本轮分析来源</p>
<div id="aiAnalysis" class="analysis-card">
<div class="empty-state"><p>等待 AI 舆情分析结果...</p></div>
</div>
<p class="page-sub">聚合快讯展示系统正在监控的原始新闻AI 分析展示基于最新新闻源的结构化研判。</p>
<!-- Dashboard -->
<div class="market-context">
@ -141,13 +132,31 @@
</div>
</div>
<!-- Source Feed -->
<div class="feed-header">
<h2>本轮分析来源</h2>
<span class="feed-count" id="feedCount">--</span>
</div>
<div class="news-feed" id="newsFeed">
<div class="empty-state"><p>加载中...</p></div>
<div class="sentiment-grid">
<section class="panel">
<div class="panel-head">
<div>
<div class="panel-title">新闻源快讯</div>
<div class="panel-sub">来自 Binance、吴说、PANews 等入库新闻源的原始信息。</div>
</div>
<span class="feed-count" id="feedCount">--</span>
</div>
<div class="source-chips" id="sourceChips"></div>
<div class="news-feed" id="newsFeed">
<div class="empty-state"><p>加载中...</p></div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<div class="panel-title">新闻舆情 AI 分析</div>
<div class="panel-sub">基于最近新闻源批次生成,只读解释,不直接改变推荐状态。</div>
</div>
</div>
<div id="aiAnalysis" class="analysis-card">
<div class="empty-state"><p>等待 AI 舆情分析结果...</p></div>
</div>
</section>
</div>
</div>
{% endblock %}
@ -215,6 +224,46 @@ function renderAnalysis(resp) {
'</div>';
}
function renderSourceChips(items) {
var box = document.getElementById('sourceChips');
var counts = {};
(items || []).forEach(function(n){
var source = n.source_label || n.source || '新闻源';
counts[source] = (counts[source] || 0) + 1;
});
var chips = Object.keys(counts).sort(function(a,b){ return counts[b]-counts[a]; }).slice(0, 8).map(function(k){
return '<span class="source-chip">'+esc(k)+' <b>'+counts[k]+'</b></span>';
}).join('');
box.innerHTML = chips || '<span class="source-chip">暂无源数据</span>';
}
function renderNewsFeed(items) {
var news = (items || []).slice(0, 80);
document.getElementById('feedCount').textContent = news.length + ' 条';
renderSourceChips(news);
if (!news.length) {
document.getElementById('newsFeed').innerHTML = '<div class="empty-state"><p>暂无新闻源快讯</p></div>';
return;
}
document.getElementById('newsFeed').innerHTML = news.map(function(n) {
var importance = String(n.importance || 'B').toUpperCase();
var cls = importance === 'RISK' ? 'risk' : (importance === 'S' || importance === 'A') ? 'important' : '';
var meta = [
n.related_symbol || n.related_base || '',
importance ? ('重要性 ' + importance) : '',
n.decision ? ('技术检查 ' + n.decision) : '',
ageStr(n.age_hours)
].filter(Boolean).join(' · ');
return '<a class="news-card '+cls+'" href="' + esc(n.url || '#') + '" target="_blank" rel="noopener">' +
'<span class="news-source">' + esc(n.source_label || n.source || '新闻源') + '</span>' +
'<div class="news-body">' +
'<div class="news-title">' + esc(n.title) + '</div>' +
'<div class="news-meta"><span>' + esc(meta || '刚刚更新') + '</span></div>' +
'</div>' +
'</a>';
}).join('');
}
async function loadFeed() {
try {
var resp = await fetch(API + '/api/newsfeed');
@ -257,50 +306,7 @@ async function loadFeed() {
document.getElementById('trendList').innerHTML = '<span style="color:var(--muted);font-size:12px">暂无数据</span>';
}
// Source feed: only show the events/news that fed the latest AI analysis.
var events = ((analysisResp && analysisResp.source_events) || []).map(function(e){
return {
title: e.title,
url: e.url,
source: e.source_label || e.source || '事件',
age_hours: null,
lang: e.related_base || '事件',
llm_insight: null,
relation_tag: e.related_symbol || e.related_base || '',
importance: e.importance
};
});
var news = events.slice(0, 50);
document.getElementById('feedCount').textContent = news.length + ' 条';
if (news.length) {
document.getElementById('newsFeed').innerHTML = news.map(function(n) {
var isCn = n.lang === 'cn';
var langLabel = n.relation_tag || (isCn ? '中文' : (n.importance ? ('重要性 ' + n.importance) : 'EN'));
var ai = n.llm_insight && n.llm_insight.content ? n.llm_insight.content : null;
var tags = ai ? (ai.key_tags || ai.theme_tags || ai.risk_types || []) : [];
var aiHtml = ai ? (
'<div class="ai-brief">' +
'<div class="label">AI 解读</div>' +
'<div class="text">' + esc(ai.summary || ai.why_now_or_not || '暂无摘要') + '</div>' +
'<div class="chips">' + (tags.slice(0,4).map(function(x){ return '<span class="chip">'+esc(x)+'</span>'; }).join('') || '') + '</div>' +
'</div>'
) : '';
return '<a class="news-card" href="' + esc(n.url || '#') + '" target="_blank" rel="noopener">' +
'<span class="news-source' + (isCn ? ' cn' : '') + '">' + esc(n.source) + '</span>' +
'<div class="news-body">' +
'<div class="news-title">' + esc(n.title) + '</div>' +
'<div class="news-meta">' +
'<span>' + esc(langLabel) + '</span>' +
'<span class="dot"></span>' +
'<span>' + ageStr(n.age_hours) + '</span>' +
'</div>' +
aiHtml +
'</div>' +
'</a>';
}).join('');
} else {
document.getElementById('newsFeed').innerHTML = '<div class="empty-state"><p>暂无新闻数据</p></div>';
}
renderNewsFeed(data.news || []);
} catch(e) {
document.getElementById('newsFeed').innerHTML = '<div class="empty-state"><p>加载失败,请稍后重试</p></div>';
}

View File

@ -102,6 +102,8 @@ _ID_TABLES = {
"user_saved_observation",
"system_reset_log",
"scheduler_manual_trigger",
"strategy_runtime_config",
"system_config",
}

View File

@ -90,6 +90,70 @@ def test_theme_expansion_spreads_ton_news_to_ecosystem_symbols():
assert "主题扩散:ton_ecosystem" in by_symbol["DOGS/USDT"]["title"]
def test_wublock_atom_feed_events_are_parsed_and_symbolized(monkeypatch):
xml = """<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<title>SUI ecosystem project announces major upgrade and airdrop</title>
<link href="https://www.wublock123.com/example"/>
<updated>2026-05-16T10:00:00Z</updated>
</entry>
</feed>
"""
class Resp:
status_code = 200
content = xml.encode("utf-8")
monkeypatch.setattr(ed, "_now", lambda: datetime(2026, 5, 16, 18, 0, 0))
with patch.object(ed.requests, "get", return_value=Resp()):
events = ed.fetch_rss_events("wublock123", {
"enabled": True,
"type": "rss",
"url": "https://www.wublock123.com/feed",
"weight": "A",
"symbol_aliases": {"sui": "SUI"},
})
assert len(events) == 1
assert events[0]["source"] == "wublock123"
assert events[0]["symbol"] == "SUI/USDT"
assert events[0]["importance"] == "A"
assert events[0]["url"] == "https://www.wublock123.com/example"
def test_panews_rss_feed_events_are_parsed_and_symbolized(monkeypatch):
xml = """<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<item>
<title>Solana 生态 Meme 项目成交量大幅上升SUI 同步活跃</title>
<link>https://www.panewslab.com/example</link>
<pubDate>Sat, 16 May 2026 10:00:00 GMT</pubDate>
</item>
</channel>
</rss>
"""
class Resp:
status_code = 200
content = xml.encode("utf-8")
monkeypatch.setattr(ed, "_now", lambda: datetime(2026, 5, 16, 18, 0, 0))
with patch.object(ed.requests, "get", return_value=Resp()):
events = ed.fetch_rss_events("panewslab", {
"enabled": True,
"type": "rss",
"url": "https://www.panewslab.com/rss.xml?lang=zh&type=NORMAL%2CNEWS",
"weight": "A",
"symbol_aliases": {"solana": "SOL", "sui": "SUI"},
})
assert {e["symbol"] for e in events} == {"SOL/USDT", "SUI/USDT"}
assert {e["source"] for e in events} == {"panewslab"}
assert all(e["importance"] == "A" for e in events)
def _fake_ohlcv(rows=60):
return pd.DataFrame({
"timestamp": pd.date_range("2026-05-01", periods=rows, freq="h"),

View File

@ -0,0 +1,352 @@
import json
from fastapi.testclient import TestClient
from app.config import config_loader
from app.config.system_config import bootstrap_admin_config, email_config, notification_config, scheduler_config
from app.db import auth_db
from app.db.paper_trading import get_paper_trading_summary
from app.db.runtime_config_db import get_event_sources, set_config, set_event_driven_config, set_event_sources, set_strategy_meta
from app.integrations import feishu_push
from app.services.llm_insights import get_llm_module_enabled, get_llm_params
from app.services.onchain_monitor import get_onchain_params
from app.web import web_server
import docker.scheduler as scheduler
def _reset_config_cache():
config_loader._cache = None
config_loader._cache_mtime = None
config_loader._yaml_cache = None
config_loader._yaml_cache_mtime = None
def test_runtime_meta_and_event_sources_override_rules_without_writing_yaml(tmp_path, monkeypatch):
rules_path = tmp_path / "rules.yaml"
rules_path.write_text(
"""
strategy:
mode: long_only
direction: 多头启动
allow_short: false
screener: {}
confirm: {}
tracker: {}
signal_weights: {}
review: {}
reverse_analysis: {}
event_driven:
enabled: true
sources:
google_news_rss:
enabled: false
meta:
strategy_version: v-yaml
learned_rules: []
""".strip(),
encoding="utf-8",
)
monkeypatch.setattr(config_loader, "RULES_PATH", str(rules_path))
_reset_config_cache()
before = rules_path.read_text(encoding="utf-8")
set_strategy_meta({"strategy_version": "v-db", "iteration_count": 7}, source="test")
set_event_sources({"panewslab": {"enabled": True, "type": "rss", "url": "https://example.com/rss"}}, source="test")
rules = config_loader.load_rules(force_reload=True)
assert rules["meta"]["strategy_version"] == "v-db"
assert rules["event_driven"]["sources"]["panewslab"]["enabled"] is True
assert rules_path.read_text(encoding="utf-8") == before
def test_event_sentiment_monitoring_seed_from_rules_into_system_config(tmp_path, monkeypatch):
rules_path = tmp_path / "rules.yaml"
rules_path.write_text(
"""
strategy: {}
screener: {}
confirm: {}
tracker: {}
signal_weights: {}
review: {}
reverse_analysis: {}
event_driven:
enabled: true
news_time_window_hours: 2
sources:
panewslab:
enabled: true
url: https://example.com/rss
sentiment:
enabled: true
max_bonus: 3
monitoring:
untouched_rate:
threshold_pct: 40
meta: {}
learned_rules: []
""".strip(),
encoding="utf-8",
)
monkeypatch.setattr(config_loader, "RULES_PATH", str(rules_path))
_reset_config_cache()
rules = config_loader.load_rules(force_reload=True)
assert rules["event_driven"]["news_time_window_hours"] == 2
assert rules["event_driven"]["sources"]["panewslab"]["enabled"] is True
assert rules["sentiment"]["max_bonus"] == 3
assert rules["monitoring"]["untouched_rate"]["threshold_pct"] == 40
def test_event_driven_runtime_config_overrides_full_section(tmp_path, monkeypatch):
rules_path = tmp_path / "rules.yaml"
rules_path.write_text(
"""
strategy: {}
screener: {}
confirm: {}
tracker: {}
signal_weights: {}
review: {}
reverse_analysis: {}
event_driven:
enabled: true
news_time_window_hours: 2
sources:
old:
enabled: true
sentiment: {}
meta: {}
learned_rules: []
""".strip(),
encoding="utf-8",
)
monkeypatch.setattr(config_loader, "RULES_PATH", str(rules_path))
_reset_config_cache()
set_event_driven_config({
"enabled": False,
"news_time_window_hours": 8,
"sources": {"runtime": {"enabled": True}},
}, source="test")
rules = config_loader.load_rules(force_reload=True)
assert rules["event_driven"]["enabled"] is False
assert rules["event_driven"]["news_time_window_hours"] == 8
assert rules["event_driven"]["sources"]["runtime"]["enabled"] is True
def test_update_meta_persists_to_db_not_rules_yaml(tmp_path, monkeypatch):
rules_path = tmp_path / "rules.yaml"
rules_path.write_text(
"""
strategy: {}
screener: {}
confirm: {}
tracker: {}
signal_weights: {}
review: {}
reverse_analysis: {}
meta:
strategy_version: v-yaml
learned_rules: []
""".strip(),
encoding="utf-8",
)
monkeypatch.setattr(config_loader, "RULES_PATH", str(rules_path))
_reset_config_cache()
before = rules_path.read_text(encoding="utf-8")
config_loader.update_meta("strategy_version", "v-runtime")
assert config_loader.get_meta()["strategy_version"] == "v-runtime"
assert rules_path.read_text(encoding="utf-8") == before
def test_runtime_config_api_can_manage_system_config():
client = TestClient(web_server.app)
payload = {"config": {"enabled": True, "url": "https://example.com/feed"}, "description": "测试源"}
resp = client.put("/api/runtime-config/system/event_driven.sources", json=payload)
assert resp.status_code == 200
assert resp.json()["config"]["url"] == "https://example.com/feed"
listed = client.get("/api/runtime-config?kind=system")
assert listed.status_code == 200
keys = [x["config_key"] for x in listed.json()["items"]]
assert "event_driven.sources" in keys
def test_llm_system_config_overrides_env_defaults(monkeypatch):
monkeypatch.setenv("ALPHAX_LLM_ENABLED", "0")
set_config("system", "llm", {
"enabled": True,
"base_url": "https://llm.example/v1",
"api_key_env": "TEST_LLM_KEY",
"model": "test-model",
"timeout": 33,
"max_tokens": 444,
"modules": {"sentiment": False, "review": True},
})
params = get_llm_params()
assert params["enabled"] is True
assert params["base_url"] == "https://llm.example/v1"
assert params["model"] == "test-model"
assert params["timeout"] == 33
assert params["max_tokens"] == 444
assert get_llm_module_enabled("sentiment") is False
assert get_llm_module_enabled("review") is True
def test_onchain_system_config_overrides_env(monkeypatch):
monkeypatch.setenv("ALPHAX_ONCHAIN_ENABLED", "0")
monkeypatch.setenv("TEST_ETHERSCAN_KEY", "etherscan-secret")
set_config("system", "onchain", {
"enabled": True,
"chains": ["base", "solana"],
"timeout": 9,
"candidate_min_score": 88,
"dex_min_liquidity_usd": 123456,
"etherscan_api_key_env": "TEST_ETHERSCAN_KEY",
"helius_api_key_env": "TEST_HELIUS_KEY",
})
params = get_onchain_params()
assert params["enabled"] is True
assert params["chains"] == ["base", "solana"]
assert params["timeout"] == 9
assert params["candidate_min_score"] == 88
assert params["dex_min_liquidity_usd"] == 123456
assert params["etherscan_api_key"] == "etherscan-secret"
def test_paper_trading_system_config_controls_account_model(monkeypatch):
monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "999")
set_config("system", "paper_trading", {
"enabled": True,
"account_equity_usdt": 30000,
"trade_notional_usdt": 6000,
"trade_leverage": 3,
"fee_rate": 0,
"slippage_pct": 0,
})
summary = get_paper_trading_summary(days=30)
assert summary["account_equity_usdt"] == 30000
assert summary["notional_usdt"] == 6000
assert summary["leverage"] == 3
assert summary["margin_usdt"] == 2000
def test_notification_system_config_controls_feishu_webhook(monkeypatch):
calls = []
monkeypatch.setenv("TEST_FEISHU_WEBHOOK", "https://open.feishu.test/hook")
set_config("system", "notification", {
"enabled": True,
"feishu": {"enabled": True, "webhook_env": "TEST_FEISHU_WEBHOOK", "timeout": 3},
})
class FakeResponse:
status_code = 200
def json(self):
return {"StatusCode": 0}
def fake_post(url, json=None, timeout=None):
calls.append({"url": url, "json": json, "timeout": timeout})
return FakeResponse()
monkeypatch.setattr(feishu_push.requests, "post", fake_post)
ok, result = feishu_push.push_card({"elements": []})
assert ok is True
assert result["StatusCode"] == 0
assert calls[0]["url"] == "https://open.feishu.test/hook"
assert calls[0]["timeout"] == 3
assert notification_config()["feishu"]["webhook_env"] == "TEST_FEISHU_WEBHOOK"
set_config("system", "notification", {"enabled": False, "feishu": {"enabled": True, "webhook_env": "TEST_FEISHU_WEBHOOK"}})
ok, reason = feishu_push.push_card({"elements": []})
assert ok is False
assert "disabled" in reason
def test_email_system_config_uses_env_pointers(monkeypatch):
sent = []
monkeypatch.setenv("SMTP_USER_ENV", "noreply@example.com")
monkeypatch.setenv("SMTP_PASS_ENV", "secret")
monkeypatch.setenv("SMTP_SENDER_ENV", "sender@example.com")
set_config("system", "email", {
"enabled": True,
"smtp": {
"host": "smtp.example.com",
"port": 465,
"username_env": "SMTP_USER_ENV",
"password_env": "SMTP_PASS_ENV",
"sender_env": "SMTP_SENDER_ENV",
"timeout": 7,
},
})
class FakeSMTP:
def __init__(self, host, port, timeout=None):
sent.append({"host": host, "port": port, "timeout": timeout})
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def login(self, username, password):
sent[-1]["login"] = (username, password)
def send_message(self, msg):
sent[-1]["from"] = msg["From"]
sent[-1]["to"] = msg["To"]
monkeypatch.setattr(auth_db.smtplib, "SMTP_SSL", FakeSMTP)
assert auth_db.is_smtp_configured() is True
assert auth_db.send_verification_email("user@example.com", "123456") is True
assert sent[0]["host"] == "smtp.example.com"
assert sent[0]["timeout"] == 7
assert sent[0]["login"] == ("noreply@example.com", "secret")
assert sent[0]["from"] == "sender@example.com"
assert email_config()["smtp"]["username_env"] == "SMTP_USER_ENV"
def test_bootstrap_admin_system_config_uses_env_pointers(monkeypatch):
monkeypatch.setenv("BOOT_EMAIL", "admin-runtime@alphax.local")
monkeypatch.setenv("BOOT_PASSWORD", "AlphaXAdmin123")
set_config("system", "bootstrap_admin", {
"enabled": True,
"email_env": "BOOT_EMAIL",
"password_env": "BOOT_PASSWORD",
})
created = auth_db.ensure_default_admin()
assert created["created"] is True
assert created["email"] == "admin-runtime@alphax.local"
assert auth_db.login_user("admin-runtime@alphax.local", "AlphaXAdmin123")["user"]["is_admin"] is True
assert bootstrap_admin_config()["email_env"] == "BOOT_EMAIL"
def test_scheduler_system_config_controls_dry_run(monkeypatch):
monkeypatch.setattr(scheduler, "DRY_RUN", None)
set_config("system", "scheduler", {
"dry_run": False,
"poll_seconds": 2,
"config_reload_seconds": 6,
"pending_warn_seconds": 9,
})
assert scheduler.scheduler_dry_run() is False
assert scheduler._scheduler_settings()["poll_seconds"] == 2
assert scheduler_config()["dry_run"] is False