diff --git a/app/config/config_loader.py b/app/config/config_loader.py index d6e0b41..b802427 100644 --- a/app/config/config_loader.py +++ b/app/config/config_loader.py @@ -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.yaml(review_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 用)=== diff --git a/app/config/system_config.py b/app/config/system_config.py new file mode 100644 index 0000000..db29c3d --- /dev/null +++ b/app/config/system_config.py @@ -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", +] diff --git a/app/db/auth_db.py b/app/db/auth_db.py index cd27f59..4940ee0 100644 --- a/app/db/auth_db.py +++ b/app/db/auth_db.py @@ -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) diff --git a/app/db/migrations/0007_runtime_config.sql b/app/db/migrations/0007_runtime_config.sql new file mode 100644 index 0000000..777670e --- /dev/null +++ b/app/db/migrations/0007_runtime_config.sql @@ -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); diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index ea20790..f709417 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -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): diff --git a/app/db/runtime_config_db.py b/app/db/runtime_config_db.py new file mode 100644 index 0000000..f409bd9 --- /dev/null +++ b/app/db/runtime_config_db.py @@ -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", +] diff --git a/app/integrations/feishu_push.py b/app/integrations/feishu_push.py index 152a964..6a11799 100644 --- a/app/integrations/feishu_push.py +++ b/app/integrations/feishu_push.py @@ -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, diff --git a/app/services/event_driven_screener.py b/app/services/event_driven_screener.py index d34a902..05b9c1e 100644 --- a/app/services/event_driven_screener.py +++ b/app/services/event_driven_screener.py @@ -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"(?= %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( """ diff --git a/app/web/routes_pages.py b/app/web/routes_pages.py index 36396dd..3b9b89a 100644 --- a/app/web/routes_pages.py +++ b/app/web/routes_pages.py @@ -67,6 +67,17 @@ def build_router(templates, repo_root: Path, stock_report_template: str): return HTMLResponse(content=f"

需要管理员权限

{exc.detail}

返回看板", 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"

需要管理员权限

{exc.detail}

返回看板", 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) diff --git a/app/web/shared.py b/app/web/shared.py index 8d5d707..5735f17 100644 --- a/app/web/shared.py +++ b/app/web/shared.py @@ -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)) diff --git a/docker/scheduler.py b/docker/scheduler.py index 0be4737..c57511c 100755 --- a/docker/scheduler.py +++ b/docker/scheduler.py @@ -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__": diff --git a/rules.yaml b/rules.yaml index d904966..bde69f5 100644 --- a/rules.yaml +++ b/rules.yaml @@ -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: 触发时效治理,旧形态只作背景,消息触发显式标记' diff --git a/static/base.html b/static/base.html index 52a8b9e..2bf00ad 100644 --- a/static/base.html +++ b/static/base.html @@ -162,6 +162,7 @@ a { color: inherit; text-decoration: none; } + @@ -172,21 +173,26 @@ a { color: inherit; text-decoration: none; } AlphaX Agent | Crypto