更新
This commit is contained in:
parent
a292982845
commit
f3c96c3a72
@ -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 用)===
|
||||
|
||||
382
app/config/system_config.py
Normal file
382
app/config/system_config.py
Normal 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",
|
||||
]
|
||||
@ -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)
|
||||
|
||||
23
app/db/migrations/0007_runtime_config.sql
Normal file
23
app/db/migrations/0007_runtime_config.sql
Normal 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);
|
||||
@ -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
301
app/db/runtime_config_db.py
Normal 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",
|
||||
]
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
"""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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__":
|
||||
|
||||
64
rules.yaml
64
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: 触发时效治理,旧形态只作背景,消息触发显式标记'
|
||||
|
||||
@ -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
63
static/config.html
Normal 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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|
||||
@ -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>';
|
||||
}
|
||||
|
||||
@ -102,6 +102,8 @@ _ID_TABLES = {
|
||||
"user_saved_observation",
|
||||
"system_reset_log",
|
||||
"scheduler_manual_trigger",
|
||||
"strategy_runtime_config",
|
||||
"system_config",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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"),
|
||||
|
||||
352
tests/test_runtime_config.py
Normal file
352
tests/test_runtime_config.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user