update
This commit is contained in:
parent
821af1282e
commit
8e331e6bdf
11
.env.example
11
.env.example
@ -8,8 +8,15 @@ PORT=8190
|
||||
# 验证完成后再改为 0。
|
||||
ALPHAX_SCHEDULER_DRY_RUN=1
|
||||
|
||||
# SQLite DB 路径。容器内默认 /app/data/altcoin_monitor.db。
|
||||
ALPHAX_DB_PATH=/app/data/altcoin_monitor.db
|
||||
# 数据库环境。运行时只使用 PostgreSQL;SQLite 只作为一次性历史数据导入源。
|
||||
ALPHAX_ENV=dev
|
||||
ALPHAX_DB_BACKEND=postgres
|
||||
|
||||
# PostgreSQL dev/prod 连接。docker-compose 本地默认 postgres:5432/alphax_dev。
|
||||
POSTGRES_DB=alphax_dev
|
||||
POSTGRES_USER=alphax
|
||||
POSTGRES_PASSWORD=alphax_dev_password
|
||||
DATABASE_URL=postgresql://alphax:alphax_dev_password@postgres:5432/alphax_dev
|
||||
|
||||
# 全新空库启动时创建默认管理员。已有用户/迁移旧库时不会覆盖。
|
||||
ALPHAX_BOOTSTRAP_ADMIN=1
|
||||
|
||||
@ -11,6 +11,7 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
postgresql-client \
|
||||
tzdata \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
197
README_DOCKER.md
197
README_DOCKER.md
@ -1,39 +1,39 @@
|
||||
# AlphaX Agent | Crypto Docker 化副本
|
||||
# AlphaX Agent | Crypto Docker 部署
|
||||
|
||||
这是从当前运行中的 `/home/ubuntu/quant_monitor/altcoin` 复制出来的独立 Docker 化副本,目录:
|
||||
|
||||
```text
|
||||
/home/ubuntu/quant_monitor/alphax-docker
|
||||
```
|
||||
这是 AlphaX Agent | Crypto 的 Docker 化运行目录。当前运行时数据库已经一次性切换为 PostgreSQL;SQLite 只作为历史数据导入来源,不再作为应用运行时数据库。
|
||||
|
||||
## 重要原则
|
||||
|
||||
- 这个目录是副本,不影响当前正在运行的 AlphaX Agent | Crypto。
|
||||
- 默认 `docker-compose.yml` 将 Web 暴露到宿主机 `8191`,避免占用当前线上 `8190`。
|
||||
- 调度器默认 `ALPHAX_SCHEDULER_DRY_RUN=1`,第一次启动不会真的跑筛选/确认/跟踪任务。
|
||||
- SQLite 数据挂载在 `./data/altcoin_monitor.db`,容器内路径为 `/app/data/altcoin_monitor.db`。
|
||||
- 镜像构建上下文通过 `.dockerignore` 排除了 `data/`、`archive/`、真实 `.env` 和所有 DB 文件,避免把数据库/密钥打进镜像。
|
||||
- Web 默认暴露到宿主机 `8191`,容器内端口 `8190`。
|
||||
- 运行时数据库是 PostgreSQL,compose 内置 `postgres:16` 服务。
|
||||
- `DATABASE_URL` 是应用唯一运行时数据库连接入口。
|
||||
- 调度器以并发子进程运行,并通过业务锁组避免主推荐写入冲突。
|
||||
- `.dockerignore` 排除了 `data/`、真实 `.env` 和所有 DB 文件,避免把数据库/密钥打进镜像。
|
||||
|
||||
## 快速启动
|
||||
|
||||
```bash
|
||||
cd /home/ubuntu/quant_monitor/alphax-docker
|
||||
cp .env.example .env
|
||||
# 如需推送,编辑 .env 填 ALTCOIN_FEISHU_WEBHOOK
|
||||
# 按需编辑 .env 中的 POSTGRES_* / DATABASE_URL / 推送 / LLM / 链上 API key
|
||||
|
||||
docker compose build
|
||||
docker compose up -d alphax-web
|
||||
curl -s http://127.0.0.1:8191/api/stats
|
||||
docker compose up -d postgres alphax-web alphax-scheduler
|
||||
```
|
||||
|
||||
首次使用空库启动时,Web 会自动创建一个默认管理员账号:
|
||||
访问:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8191
|
||||
```
|
||||
|
||||
首次空库启动时会自动创建默认管理员账号:
|
||||
|
||||
```text
|
||||
邮箱:admin@alphax.local
|
||||
密码:AlphaXAdmin123
|
||||
```
|
||||
|
||||
建议首次登录后立刻在账号设置中修改密码。也可以在 `.env` 中覆盖默认值:
|
||||
建议首次登录后立刻修改密码。也可以在 `.env` 中覆盖:
|
||||
|
||||
```text
|
||||
ALPHAX_BOOTSTRAP_ADMIN=1
|
||||
@ -41,64 +41,85 @@ ALPHAX_DEFAULT_ADMIN_EMAIL=your-admin@example.com
|
||||
ALPHAX_DEFAULT_ADMIN_PASSWORD=change-me-to-a-strong-password
|
||||
```
|
||||
|
||||
该初始化只会在 `app_user` 表完全为空时执行;如果你迁移了旧数据库或已经有用户,不会覆盖任何账号。
|
||||
该初始化只会在 `app_user` 表为空时执行;迁移旧数据后不会覆盖已有账号。
|
||||
|
||||
确认 Web 正常后,如果要启动调度器:
|
||||
## 数据库配置
|
||||
|
||||
```bash
|
||||
docker compose up -d alphax-scheduler
|
||||
```
|
||||
|
||||
调度器默认 dry-run,只打印计划,不写库。确认日志无误后,把 `.env` 或 compose 里的:
|
||||
本地 dev 默认:
|
||||
|
||||
```text
|
||||
ALPHAX_SCHEDULER_DRY_RUN=0
|
||||
POSTGRES_DB=alphax_dev
|
||||
POSTGRES_USER=alphax
|
||||
POSTGRES_PASSWORD=alphax_dev_password
|
||||
DATABASE_URL=postgresql://alphax:alphax_dev_password@postgres:5432/alphax_dev
|
||||
ALPHAX_DB_BACKEND=postgres
|
||||
```
|
||||
|
||||
再重启:
|
||||
生产环境建议只区分 `dev` 和 `production` 两套库:
|
||||
|
||||
```text
|
||||
ALPHAX_ENV=production
|
||||
DATABASE_URL=postgresql://<user>:<password>@<host>:5432/<database>
|
||||
```
|
||||
|
||||
## SQLite 历史数据导入
|
||||
|
||||
SQLite 不再用于运行时。如果需要把旧 `altcoin_monitor.db` / `scheduler_state.db` 导入 PostgreSQL,先启动 PostgreSQL,再运行:
|
||||
|
||||
```bash
|
||||
docker compose up -d alphax-scheduler
|
||||
docker compose up -d postgres
|
||||
docker compose run --rm alphax-web python scripts/postgres/run_migrations.py
|
||||
docker compose run --rm alphax-web python scripts/postgres/import_from_sqlite.py \
|
||||
--sqlite-path /app/data/altcoin_monitor.db \
|
||||
--scheduler-sqlite-path /app/data/scheduler_state.db
|
||||
docker compose run --rm alphax-web python scripts/postgres/validate_import.py \
|
||||
--sqlite-path /app/data/altcoin_monitor.db \
|
||||
--scheduler-sqlite-path /app/data/scheduler_state.db \
|
||||
--all-tables
|
||||
```
|
||||
|
||||
导入完成后,应用运行只读写 PostgreSQL。
|
||||
|
||||
## 服务说明
|
||||
|
||||
- `alphax-web`:FastAPI + 静态页面,容器内 8190,宿主机 8191。
|
||||
- `alphax-scheduler`:轻量 Python 调度器,串行执行任务,避免 SQLite 并发锁。
|
||||
- `postgres`:PostgreSQL 16,数据保存在 compose volume `postgres_data`。
|
||||
- `alphax-web`:FastAPI + 静态页面。
|
||||
- `alphax-scheduler`:调度器,读取 PostgreSQL 中的任务配置和运行状态。
|
||||
|
||||
调度任务与当前线上大致对齐:
|
||||
默认任务周期:
|
||||
|
||||
| 任务 | 脚本 | 间隔 |
|
||||
|---|---|---|
|
||||
| 事件舆情 | `python -m app.services.event_driven_screener --once` | 60s |
|
||||
| 价格跟踪 | `python -m app.services.price_tracker` | 180s |
|
||||
| 爆发确认 | `python -m app.services.altcoin_confirm` | 600s |
|
||||
| 粗筛/细筛 | `python -m app.services.altcoin_screener` | 900s |
|
||||
| 舆情采集 | `python -m app.services.sentiment_monitor --collect` | 1800s |
|
||||
| 复盘 | `python -m app.services.review_engine` | 24h |
|
||||
| 任务 | 间隔 |
|
||||
|---|---:|
|
||||
| 事件舆情 | 60s |
|
||||
| 价格跟踪 | 180s |
|
||||
| 爆发确认 | 600s |
|
||||
| 粗筛/细筛 | 900s |
|
||||
| 舆情采集 | 1800s |
|
||||
| LLM 舆情分析 | 1800s |
|
||||
| 链上追踪 | 1800s |
|
||||
| 复盘 | 86400s |
|
||||
|
||||
## 验证命令
|
||||
## 常用验证
|
||||
|
||||
```bash
|
||||
cd /home/ubuntu/quant_monitor/alphax-docker
|
||||
python3 -m py_compile app/db/altcoin_db.py app/db/auth_db.py app/core/opportunity_lifecycle.py app/services/altcoin_screener.py app/services/altcoin_confirm.py app/services/price_tracker.py app/services/event_driven_screener.py app/services/sentiment_monitor.py app/services/review_engine.py app/web/web_server.py docker/scheduler.py scripts/validate_docker_layout.py
|
||||
python3 scripts/validate_docker_layout.py
|
||||
python3 scripts/validate_state_machine.py
|
||||
python3 scripts/validate_push_state_flow.py
|
||||
python3 scripts/validate_signal_recency.py
|
||||
docker compose config
|
||||
docker compose ps
|
||||
docker compose logs --tail=100 alphax-web
|
||||
docker compose logs --tail=100 alphax-scheduler
|
||||
```
|
||||
|
||||
Docker 配置验证:
|
||||
容器内 API smoke:
|
||||
|
||||
```bash
|
||||
docker compose config
|
||||
docker compose exec alphax-web curl -fsS http://127.0.0.1:8190/api/stats
|
||||
docker compose exec alphax-web curl -fsS 'http://127.0.0.1:8190/api/pipeline/runs?page=1&page_size=5'
|
||||
docker compose exec alphax-web curl -fsS http://127.0.0.1:8190/api/onchain/overview
|
||||
```
|
||||
|
||||
> 当前机器如果没有 Docker,只能做离线文件/语法/DB 校验;到有 Docker 的机器上再执行 build/up。
|
||||
|
||||
## LLM 解释层配置
|
||||
|
||||
LLM 是运行时系统能力,不属于策略参数,不写入 `rules.yaml`。在 `.env` 中配置即可:
|
||||
LLM 是运行时系统能力,不属于策略参数,不写入 `rules.yaml`。在 `.env` 中配置:
|
||||
|
||||
```bash
|
||||
ALPHAX_LLM_ENABLED=1
|
||||
@ -115,84 +136,16 @@ docker compose exec alphax-web python -m app.cli llm-insights --scope sentiment
|
||||
docker compose exec alphax-web python -m app.cli llm-insights --scope review --limit 10
|
||||
```
|
||||
|
||||
## 数据迁移
|
||||
## 备份与恢复
|
||||
|
||||
当前副本是从线上目录复制来的,包含复制时刻的 `altcoin_monitor.db`。为了避免误影响线上,容器读写的是副本目录下的:
|
||||
|
||||
```text
|
||||
./data/altcoin_monitor.db
|
||||
```
|
||||
|
||||
容器内路径通过环境变量配置:
|
||||
|
||||
```text
|
||||
ALPHAX_DB_PATH=/app/data/altcoin_monitor.db
|
||||
```
|
||||
|
||||
如需重新以线上最新数据初始化副本,应停掉副本容器后手动复制 DB:
|
||||
PostgreSQL 备份:
|
||||
|
||||
```bash
|
||||
cd /home/ubuntu/quant_monitor/alphax-docker
|
||||
docker compose down
|
||||
cp /home/ubuntu/quant_monitor/altcoin/altcoin_monitor.db ./data/altcoin_monitor.db
|
||||
docker compose exec postgres pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > alphax_backup.sql
|
||||
```
|
||||
|
||||
不要反向覆盖线上 DB。
|
||||
|
||||
如果旧部署的数据库还在容器内部、尚未挂载到 `./data`,可以使用迁移脚本把容器内 DB 迁移到 compose volume:
|
||||
恢复到空库:
|
||||
|
||||
```bash
|
||||
cd /home/ubuntu/quant_monitor/alphax-docker
|
||||
bash scripts/migrate_container_db_to_volume.sh
|
||||
cat alphax_backup.sql | docker compose exec -T postgres psql -U "$POSTGRES_USER" "$POSTGRES_DB"
|
||||
```
|
||||
|
||||
脚本会:
|
||||
|
||||
- 用容器内 SQLite backup API 导出一致性快照,包含 WAL 中的数据。
|
||||
- 备份已有 `./data/altcoin_monitor.db` 到 `./data/backups/`。
|
||||
- 把容器内 DB 安装到 `./data/altcoin_monitor.db`。
|
||||
- 重建 `alphax-web` / `alphax-scheduler`,让容器使用 volume 中的数据库。
|
||||
|
||||
常用参数:
|
||||
|
||||
```bash
|
||||
# 指定旧容器名
|
||||
SOURCE_CONTAINER=old-alphax-web bash scripts/migrate_container_db_to_volume.sh
|
||||
|
||||
# 旧容器已经停止也可以复制主 DB(如果仍在运行,脚本会用更安全的 SQLite backup)
|
||||
ALLOW_STOPPED=1 SOURCE_CONTAINER=old-alphax-web bash scripts/migrate_container_db_to_volume.sh
|
||||
|
||||
# 只复制,不自动重启 compose 服务
|
||||
RECREATE=0 bash scripts/migrate_container_db_to_volume.sh
|
||||
|
||||
# 如果脚本检测到 /app/data 已经是挂载卷但仍想强制复制
|
||||
FORCE=1 bash scripts/migrate_container_db_to_volume.sh
|
||||
```
|
||||
|
||||
如果脚本提示找不到 `alphax-web`,先查看服务器上的真实容器名:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}'
|
||||
```
|
||||
|
||||
然后用真实容器名执行:
|
||||
|
||||
```bash
|
||||
SOURCE_CONTAINER=<真实容器名> bash scripts/migrate_container_db_to_volume.sh
|
||||
```
|
||||
|
||||
## 打包迁移到新服务器
|
||||
|
||||
建议只打包代码和配置骨架,不把 DB 直接打进镜像。可以用 tar 打包整个副本目录,排除本地缓存和归档备份:
|
||||
|
||||
```bash
|
||||
cd /home/ubuntu/quant_monitor
|
||||
tar --exclude='alphax-docker/.git' \
|
||||
--exclude='alphax-docker/__pycache__' \
|
||||
--exclude='alphax-docker/.pytest_cache' \
|
||||
--exclude='alphax-docker/archive' \
|
||||
-czf alphax-docker.tar.gz alphax-docker
|
||||
```
|
||||
|
||||
如果新服务器要带初始 DB,可以保留 `alphax-docker/data/altcoin_monitor.db`;如果希望空库启动,则删除该文件,容器首次启动会创建空 DB 并由 `init_db()` 补表。
|
||||
|
||||
@ -107,10 +107,11 @@ def fetch_klines_before(symbol, lookback_hours=72, interval="1h"):
|
||||
def get_recommended_symbols(hours=72):
|
||||
"""获取过去N小时内被推荐过的币种列表"""
|
||||
conn = get_conn()
|
||||
cutoff = (datetime.now() - timedelta(hours=float(hours or 72))).isoformat()
|
||||
rows = conn.execute("""
|
||||
SELECT symbol FROM recommendation
|
||||
WHERE julianday(?) - julianday(rec_time) < ?
|
||||
""", (datetime.now().isoformat(), hours / 24.0)).fetchall()
|
||||
WHERE rec_time >= %s
|
||||
""", (cutoff,)).fetchall()
|
||||
conn.close()
|
||||
return set(r["symbol"] for r in rows)
|
||||
|
||||
@ -574,7 +575,7 @@ def run_reverse_analysis():
|
||||
conn = get_conn()
|
||||
screened = conn.execute("""
|
||||
SELECT symbol, state, score, signals FROM screening_log
|
||||
WHERE symbol=? AND layer='细筛'
|
||||
WHERE symbol=%s AND layer='细筛'
|
||||
ORDER BY scan_time DESC LIMIT 1
|
||||
""", (symbol,)).fetchone()
|
||||
conn.close()
|
||||
|
||||
209
app/core/opportunity_funnel.py
Normal file
209
app/core/opportunity_funnel.py
Normal file
@ -0,0 +1,209 @@
|
||||
"""Shared opportunity funnel helpers.
|
||||
|
||||
This module keeps the stage vocabulary and a few lightweight heuristics in one
|
||||
place so screening, confirmation, analytics, and the pipeline log page can use
|
||||
the same language.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Sequence, Tuple
|
||||
|
||||
|
||||
FUNNEL_STAGES = (
|
||||
"universe_gate",
|
||||
"discovery",
|
||||
"quality_filter",
|
||||
"trade_confirm",
|
||||
"tracking",
|
||||
"review",
|
||||
)
|
||||
|
||||
FUNNEL_STAGE_LABELS = {
|
||||
"universe_gate": "交易宇宙过滤",
|
||||
"discovery": "异动发现",
|
||||
"quality_filter": "异动质量验证",
|
||||
"trade_confirm": "当前交易确认",
|
||||
"tracking": "跟踪",
|
||||
"review": "复盘",
|
||||
}
|
||||
|
||||
SCREENING_LAYER_STAGE = {
|
||||
"universe_gate": "universe_gate",
|
||||
"粗筛": "discovery",
|
||||
"细筛": "quality_filter",
|
||||
"确认": "trade_confirm",
|
||||
"舆情触发": "discovery",
|
||||
"跟踪": "tracking",
|
||||
"复盘": "review",
|
||||
}
|
||||
|
||||
SCREENING_STAGE_LABELS = {
|
||||
"universe_gate": "宇宙过滤",
|
||||
"discovery_candidate": "异动发现",
|
||||
"qualified_candidate": "质量通过",
|
||||
"rejected_candidate": "质量淘汰",
|
||||
"trade_confirm": "交易确认",
|
||||
"tracking": "跟踪",
|
||||
"review": "复盘",
|
||||
}
|
||||
|
||||
UNIVERSE_REASON_LABELS = {
|
||||
"stablecoin": "稳定币/锚定资产",
|
||||
"wrapped": "包装资产",
|
||||
"excluded_base": "排除基础资产",
|
||||
"invalid_pair": "交易对异常",
|
||||
"non_ascii": "非标准交易对",
|
||||
"low_turnover": "24h成交额不足",
|
||||
}
|
||||
|
||||
QUALITY_REASON_LABELS = {
|
||||
"low_score": "评分不足",
|
||||
"stale_signal": "旧信号复用",
|
||||
"fake_pump_risk": "疑似拉高出货",
|
||||
"high_chase_risk": "追高风险",
|
||||
"bearish_flow_risk": "空头流风险",
|
||||
"multi_source_resonance": "多源共振",
|
||||
}
|
||||
|
||||
|
||||
def stage_label(stage: str) -> str:
|
||||
return FUNNEL_STAGE_LABELS.get(stage or "", stage or "")
|
||||
|
||||
|
||||
def normalize_trade_action(action: Any) -> str:
|
||||
text = str(action or "").strip()
|
||||
if text in ("可即刻买入", "buy_now"):
|
||||
return "buy_now"
|
||||
if text in ("等回踩", "wait_pullback"):
|
||||
return "wait_pullback"
|
||||
if text in ("观察", "observe"):
|
||||
return "observe"
|
||||
return "invalid"
|
||||
|
||||
|
||||
def screening_stage_meta(layer: str, detail: Dict[str, Any] | None = None, state: str = "", execution_status: str = "") -> Dict[str, str]:
|
||||
detail = detail or {}
|
||||
layer_stage = SCREENING_LAYER_STAGE.get(str(layer or "").strip(), "discovery")
|
||||
candidate_stage = str(detail.get("candidate_stage") or "").strip()
|
||||
if not candidate_stage:
|
||||
if layer_stage == "universe_gate":
|
||||
candidate_stage = "universe_gate"
|
||||
elif layer_stage == "quality_filter":
|
||||
candidate_stage = "qualified_candidate" if str(state or "").strip() in ("蓄力", "加速") else "rejected_candidate"
|
||||
elif layer_stage == "trade_confirm":
|
||||
candidate_stage = "trade_confirm"
|
||||
else:
|
||||
candidate_stage = "discovery_candidate"
|
||||
return {
|
||||
"funnel_stage": layer_stage,
|
||||
"funnel_stage_label": stage_label(layer_stage),
|
||||
"candidate_stage": candidate_stage,
|
||||
"candidate_stage_label": SCREENING_STAGE_LABELS.get(candidate_stage, candidate_stage),
|
||||
}
|
||||
|
||||
|
||||
def discovery_source_types(candidate: Dict[str, Any]) -> List[str]:
|
||||
sources: List[str] = []
|
||||
if candidate.get("vp_data") or candidate.get("turnover_acceleration_1h") or candidate.get("turnover_acceleration_4h"):
|
||||
sources.append("cex")
|
||||
if candidate.get("static_accumulation") or candidate.get("higher_lows") or candidate.get("compression_surge"):
|
||||
sources.append("structure")
|
||||
if candidate.get("sentiment") or candidate.get("sentiment_bonus"):
|
||||
sources.append("sentiment")
|
||||
if candidate.get("funding_rate") not in (None, "", 0, 0.0):
|
||||
sources.append("derivatives")
|
||||
if candidate.get("bypass_origin") in ("higher_lows", "compression_surge"):
|
||||
sources.append("structure")
|
||||
return list(dict.fromkeys(sources)) or ["cex"]
|
||||
|
||||
|
||||
def discovery_reason(candidate: Dict[str, Any]) -> str:
|
||||
signals = candidate.get("anomalies") or candidate.get("signals") or []
|
||||
if isinstance(signals, str):
|
||||
signals = [signals]
|
||||
texts = [str(s).strip() for s in signals if str(s).strip()]
|
||||
if not texts:
|
||||
return "异动发现"
|
||||
return " / ".join(texts[:3])
|
||||
|
||||
|
||||
def quality_filter_reasons(candidate: Dict[str, Any], score: int, threshold: int, signals: Sequence[Any] | None = None) -> Dict[str, List[str]]:
|
||||
signals = list(signals or candidate.get("signals") or candidate.get("anomalies") or [])
|
||||
text = " ".join(str(s) for s in signals)
|
||||
codes: List[str] = []
|
||||
|
||||
if score < threshold:
|
||||
codes.append("low_score")
|
||||
if "历史" in text or "过期" in text:
|
||||
codes.append("stale_signal")
|
||||
if any(keyword in text for keyword in ("量价背离", "冲高回落", "假突破", "多头出货")):
|
||||
codes.append("fake_pump_risk")
|
||||
if any(keyword in text for keyword in ("高位", "追高", "站稳突破位+", "离突破位+")):
|
||||
codes.append("high_chase_risk")
|
||||
if any(keyword in text for keyword in ("空头加速", "放量阴线", "量价背离", "资金费率极端")):
|
||||
codes.append("bearish_flow_risk")
|
||||
if any(keyword in text for keyword in ("板块联动", "舆情共振", "链上", "DEX", "鲸鱼", "聪明钱")):
|
||||
codes.append("multi_source_resonance")
|
||||
|
||||
labels = [QUALITY_REASON_LABELS.get(code, code) for code in dict.fromkeys(codes)]
|
||||
return {"codes": list(dict.fromkeys(codes)), "labels": labels}
|
||||
|
||||
|
||||
def universe_gate_reason(base: str, quote_volume: float, min_volume: float, *, symbol: str = "") -> Dict[str, str]:
|
||||
base = str(base or "").upper().strip()
|
||||
symbol = str(symbol or "").strip()
|
||||
if base in {"USDT", "USDC", "BUSD", "TUSD", "DAI", "FDUSD", "USDP", "PAX", "USD1", "USDE", "USDS", "RLUSD", "PYUSD", "XUSD", "USDUC", "FRAX", "LUSD", "GUSD", "SUSD", "USDD", "EURS", "EUR", "GBP"}:
|
||||
return {"reason_code": "stablecoin", "reason_label": UNIVERSE_REASON_LABELS["stablecoin"]}
|
||||
if base in {"WBTC", "WETH", "RENBTC"}:
|
||||
return {"reason_code": "wrapped", "reason_label": UNIVERSE_REASON_LABELS["wrapped"]}
|
||||
if base in {"BTC", "ETH", "BNB", "XAUT", "PAXG"}:
|
||||
return {"reason_code": "excluded_base", "reason_label": UNIVERSE_REASON_LABELS["excluded_base"]}
|
||||
if not symbol or "/USDT" not in symbol:
|
||||
return {"reason_code": "invalid_pair", "reason_label": UNIVERSE_REASON_LABELS["invalid_pair"]}
|
||||
if not base.isascii():
|
||||
return {"reason_code": "non_ascii", "reason_label": UNIVERSE_REASON_LABELS["non_ascii"]}
|
||||
if float(quote_volume or 0) < float(min_volume or 0):
|
||||
return {
|
||||
"reason_code": "low_turnover",
|
||||
"reason_label": f"{UNIVERSE_REASON_LABELS['low_turnover']}({float(quote_volume or 0):.0f}<{float(min_volume or 0):.0f})",
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
def build_screening_detail(
|
||||
*,
|
||||
layer: str,
|
||||
state: str = "",
|
||||
detail: Dict[str, Any] | None = None,
|
||||
signals: Iterable[Any] | None = None,
|
||||
candidate: Dict[str, Any] | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
detail = dict(detail or {})
|
||||
meta = screening_stage_meta(layer, detail=detail, state=state)
|
||||
detail.setdefault("funnel_stage", meta["funnel_stage"])
|
||||
detail.setdefault("funnel_stage_label", meta["funnel_stage_label"])
|
||||
detail.setdefault("candidate_stage", meta["candidate_stage"])
|
||||
detail.setdefault("candidate_stage_label", meta["candidate_stage_label"])
|
||||
if candidate:
|
||||
detail.setdefault("source_types", discovery_source_types(candidate))
|
||||
detail.setdefault("discovery_reason", discovery_reason(candidate))
|
||||
if signals is not None:
|
||||
detail.setdefault("signal_count", len(list(signals)))
|
||||
return detail
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FUNNEL_STAGES",
|
||||
"FUNNEL_STAGE_LABELS",
|
||||
"QUALITY_REASON_LABELS",
|
||||
"SCREENING_STAGE_LABELS",
|
||||
"build_screening_detail",
|
||||
"discovery_reason",
|
||||
"discovery_source_types",
|
||||
"normalize_trade_action",
|
||||
"quality_filter_reasons",
|
||||
"screening_stage_meta",
|
||||
"stage_label",
|
||||
"universe_gate_reason",
|
||||
]
|
||||
@ -3,12 +3,9 @@
|
||||
全量记录筛选结果 + 价格跟踪 + 盈亏验证
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from app.config.config_loader import get_meta, get_screener_section, confirm_state_cooldown_hours
|
||||
from app.core.opportunity_lifecycle import (
|
||||
@ -19,401 +16,15 @@ from app.core.opportunity_lifecycle import (
|
||||
is_executed_lifecycle,
|
||||
)
|
||||
from app.core.signal_taxonomy import signal_codes as build_signal_codes, signal_labels as build_signal_labels
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
DB_PATH = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db"))
|
||||
|
||||
from app.db.postgres_connection import apply_migrations, connect as pg_connect, table_columns
|
||||
|
||||
def get_conn():
|
||||
# SQLite 并发治理:Web/API/cron 共用同一 DB,连接必须等待写锁而不是立即失败。
|
||||
# journal_mode=WAL 只在初始化/首次连接时设置;busy_timeout 让短写事务排队。
|
||||
conn = sqlite3.connect(DB_PATH, timeout=30, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
return conn
|
||||
return pg_connect()
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = get_conn()
|
||||
|
||||
# 1. 状态跟踪表(原有的)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS coin_state (
|
||||
symbol TEXT PRIMARY KEY,
|
||||
state TEXT NOT NULL DEFAULT '蓄力',
|
||||
score INTEGER DEFAULT 0,
|
||||
anomaly_type TEXT DEFAULT '',
|
||||
sector TEXT DEFAULT '',
|
||||
leader_status TEXT DEFAULT '',
|
||||
detected_at TEXT NOT NULL,
|
||||
last_alert_time TEXT DEFAULT '',
|
||||
last_alert_level TEXT DEFAULT '',
|
||||
detail_json TEXT DEFAULT '{}'
|
||||
)
|
||||
""")
|
||||
|
||||
# 2. 筛选记录表(每次筛选全量写入)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS screening_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scan_time TEXT NOT NULL,
|
||||
layer TEXT NOT NULL, -- '粗筛'/'细筛'/'确认'
|
||||
symbol TEXT NOT NULL,
|
||||
state TEXT NOT NULL, -- 蓄力/加速/爆发/过期
|
||||
score INTEGER DEFAULT 0,
|
||||
price REAL NOT NULL, -- 筛选时价格
|
||||
signals TEXT DEFAULT '', -- 信号列表(json array)
|
||||
sector TEXT DEFAULT '',
|
||||
leader_status TEXT DEFAULT '',
|
||||
is_meme INTEGER DEFAULT 0,
|
||||
change_24h REAL DEFAULT 0,
|
||||
funding_rate REAL DEFAULT 0,
|
||||
detail_json TEXT DEFAULT '{}'
|
||||
)
|
||||
""")
|
||||
|
||||
# 3. 推荐表(加速/爆发时生成推荐记录,跟踪最终盈亏)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS recommendation (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
rec_time TEXT NOT NULL, -- 推荐时间
|
||||
rec_state TEXT NOT NULL, -- 加速/爆发
|
||||
rec_score INTEGER DEFAULT 0,
|
||||
entry_price REAL NOT NULL, -- 推荐时价格
|
||||
stop_loss REAL DEFAULT 0,
|
||||
tp1 REAL DEFAULT 0,
|
||||
tp2 REAL DEFAULT 0,
|
||||
sector TEXT DEFAULT '',
|
||||
signals TEXT DEFAULT '', -- 触发信号(json)
|
||||
is_meme INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'active', -- active/hit_tp1/hit_tp2/stopped_out/expired
|
||||
current_price REAL DEFAULT 0, -- 最新跟踪价格
|
||||
max_price REAL DEFAULT 0, -- 推荐后最高价
|
||||
min_price REAL DEFAULT 0, -- 推荐后最低价
|
||||
pnl_pct REAL DEFAULT 0, -- 当前盈亏%
|
||||
max_pnl_pct REAL DEFAULT 0, -- 最大盈亏%
|
||||
max_drawdown_pct REAL DEFAULT 0, -- 最大回撤%
|
||||
hit_tp1_time TEXT DEFAULT '',
|
||||
hit_tp2_time TEXT DEFAULT '',
|
||||
stopped_out_time TEXT DEFAULT '',
|
||||
expired_time TEXT DEFAULT '',
|
||||
last_track_time TEXT DEFAULT '',
|
||||
entry_plan_json TEXT DEFAULT '{}',
|
||||
action_status TEXT DEFAULT '持有', -- 持有/可即刻买入/等回踩/衰减/止损/止盈1/止盈2/跟踪止盈/反转
|
||||
strategy_version TEXT DEFAULT '',
|
||||
direction TEXT DEFAULT '中性',
|
||||
force_reason TEXT DEFAULT '',
|
||||
base_state TEXT DEFAULT '',
|
||||
sector_signal_count INTEGER DEFAULT 0,
|
||||
market_context_json TEXT DEFAULT '{}',
|
||||
derivatives_context_json TEXT DEFAULT '{}',
|
||||
sector_context_json TEXT DEFAULT '{}'
|
||||
)
|
||||
""")
|
||||
|
||||
# 4. 价格跟踪表(定时快照推荐币的当前价格)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS price_tracking (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rec_id INTEGER NOT NULL, -- 关联recommendation.id
|
||||
symbol TEXT NOT NULL,
|
||||
track_time TEXT NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
pnl_pct REAL DEFAULT 0,
|
||||
FOREIGN KEY (rec_id) REFERENCES recommendation(id)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_price_tracking_rec_id_id ON price_tracking(rec_id, id DESC)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_price_tracking_rec_time ON price_tracking(rec_id, track_time DESC)")
|
||||
|
||||
# 4.1 最新价格缓存表(看板/关注池读取现价用,小表;price_tracking 只保留高频流水/审计用途)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS latest_price_cache (
|
||||
symbol TEXT PRIMARY KEY,
|
||||
price REAL NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
source TEXT DEFAULT 'tracker'
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_latest_price_cache_updated_at ON latest_price_cache(updated_at)")
|
||||
|
||||
# 5. Cron运行日志(每次粗筛/确认/跟踪执行都写一条汇总)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS cron_run_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_name TEXT NOT NULL, -- 粗筛/确认/跟踪
|
||||
script_name TEXT NOT NULL, -- altcoin_screener.py / altcoin_confirm.py / price_tracker.py
|
||||
run_status TEXT NOT NULL, -- success / error
|
||||
result_status TEXT DEFAULT '', -- screened / no_candidates / confirmed / tracked / exception ...
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT NOT NULL,
|
||||
duration_ms INTEGER DEFAULT 0,
|
||||
summary_json TEXT DEFAULT '{}',
|
||||
error_message TEXT DEFAULT ''
|
||||
)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 迁移:为已有recommendation表添加action_status字段
|
||||
try:
|
||||
conn.execute("ALTER TABLE recommendation ADD COLUMN action_status TEXT DEFAULT '持有'")
|
||||
conn.commit()
|
||||
print("DB迁移: recommendation表已添加action_status字段")
|
||||
except Exception:
|
||||
pass # 字段已存在,忽略
|
||||
|
||||
# 迁移:为已有recommendation表添加direction字段(多头启动/空头启动/中性)
|
||||
try:
|
||||
conn.execute("ALTER TABLE recommendation ADD COLUMN direction TEXT DEFAULT '中性'")
|
||||
conn.commit()
|
||||
print("DB迁移: recommendation表已添加direction字段")
|
||||
except Exception:
|
||||
pass # 字段已存在,忽略
|
||||
|
||||
# 迁移:为recommendation补充增强上下文字段
|
||||
for sql, message in [
|
||||
("ALTER TABLE recommendation ADD COLUMN force_reason TEXT DEFAULT ''", "DB迁移: recommendation表已添加force_reason字段"),
|
||||
("ALTER TABLE recommendation ADD COLUMN base_state TEXT DEFAULT ''", "DB迁移: recommendation表已添加base_state字段"),
|
||||
("ALTER TABLE recommendation ADD COLUMN sector_signal_count INTEGER DEFAULT 0", "DB迁移: recommendation表已添加sector_signal_count字段"),
|
||||
("ALTER TABLE recommendation ADD COLUMN market_context_json TEXT DEFAULT '{}'", "DB迁移: recommendation表已添加market_context_json字段"),
|
||||
("ALTER TABLE recommendation ADD COLUMN derivatives_context_json TEXT DEFAULT '{}'", "DB迁移: recommendation表已添加derivatives_context_json字段"),
|
||||
("ALTER TABLE recommendation ADD COLUMN sector_context_json TEXT DEFAULT '{}'", "DB迁移: recommendation表已添加sector_context_json字段"),
|
||||
("ALTER TABLE recommendation ADD COLUMN lifecycle_state TEXT DEFAULT 'watching'", "DB迁移: recommendation表已添加lifecycle_state字段"),
|
||||
("ALTER TABLE recommendation ADD COLUMN display_bucket TEXT DEFAULT 'watch_pool'", "DB迁移: recommendation表已添加display_bucket字段"),
|
||||
("ALTER TABLE recommendation ADD COLUMN execution_status TEXT DEFAULT 'observe'", "DB迁移: recommendation表已添加execution_status字段"),
|
||||
("ALTER TABLE recommendation ADD COLUMN state_reason TEXT DEFAULT ''", "DB迁移: recommendation表已添加state_reason字段"),
|
||||
("ALTER TABLE recommendation ADD COLUMN entry_triggered INTEGER DEFAULT 0", "DB迁移: recommendation表已添加entry_triggered字段"),
|
||||
("ALTER TABLE recommendation ADD COLUMN archived_at TEXT DEFAULT ''", "DB迁移: recommendation表已添加archived_at字段"),
|
||||
("ALTER TABLE recommendation ADD COLUMN signal_codes_json TEXT DEFAULT '[]'", "DB迁移: recommendation表已添加signal_codes_json字段"),
|
||||
("ALTER TABLE recommendation ADD COLUMN signal_labels_json TEXT DEFAULT '[]'", "DB迁移: recommendation表已添加signal_labels_json字段"),
|
||||
]:
|
||||
try:
|
||||
conn.execute(sql)
|
||||
conn.commit()
|
||||
print(message)
|
||||
except Exception:
|
||||
pass # 字段已存在,忽略
|
||||
try:
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_rec_active_symbol_bucket ON recommendation(symbol, status, display_bucket, id DESC)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_rec_display_bucket_time ON recommendation(display_bucket, rec_time DESC)")
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 5. 信号绩效表(每个信号类型的命中率统计,用于动态调权)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS signal_performance (
|
||||
signal_type TEXT PRIMARY KEY, -- 信号名称如"N倍放量"、"MACD金叉"、"动K起爆"
|
||||
category TEXT DEFAULT '', -- 分类: 前瞻/滞后/PA/量价
|
||||
total_count INTEGER DEFAULT 0, -- 总出现次数
|
||||
hit_count INTEGER DEFAULT 0, -- 命中次数(推荐后48h内涨>5%)
|
||||
miss_count INTEGER DEFAULT 0, -- 失手次数(推荐后48h内没涨或跌)
|
||||
hit_rate REAL DEFAULT 0, -- 命中率=hit/(hit+miss)
|
||||
avg_pnl REAL DEFAULT 0, -- 该信号关联推荐的平均盈亏%
|
||||
weight REAL DEFAULT 1.0, -- 当前权重(动态调整)
|
||||
last_updated TEXT DEFAULT '' -- 最后更新时间
|
||||
)
|
||||
""")
|
||||
|
||||
# 6. 复盘记录表(每条推荐的复盘归因)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS review_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rec_id INTEGER NOT NULL, -- 关联recommendation.id
|
||||
symbol TEXT NOT NULL,
|
||||
review_time TEXT NOT NULL, -- 复盘时间
|
||||
outcome TEXT NOT NULL, -- 爆发/横盘/失败
|
||||
pnl_48h REAL DEFAULT 0, -- 48小时盈亏%
|
||||
max_pnl_48h REAL DEFAULT 0, -- 48小时最大盈亏%
|
||||
triggered_signals TEXT DEFAULT '', -- 当时触发的信号(json)
|
||||
hit_signals TEXT DEFAULT '', -- 哪些信号命中了(真正起作用的)
|
||||
miss_signals TEXT DEFAULT '', -- 哪些信号是假信号(没起作用)
|
||||
lesson TEXT DEFAULT '', -- 复盘教训总结
|
||||
FOREIGN KEY (rec_id) REFERENCES recommendation(id)
|
||||
)
|
||||
""")
|
||||
|
||||
# 7. 漏选复盘表(没选但后来爆发的币)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS missed_explosions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
detect_time TEXT NOT NULL, -- 发现时间
|
||||
price_at_detect REAL DEFAULT 0, -- 发现时价格
|
||||
price_before REAL DEFAULT 0, -- 爆发前价格
|
||||
gain_pct REAL DEFAULT 0, -- 爆发涨幅%
|
||||
reason_missed TEXT DEFAULT '', -- 为什么没选(粗筛没过/细筛淘汰/确认没过)
|
||||
features_detected TEXT DEFAULT '', -- 爆发时有什么特征(json)
|
||||
lesson TEXT DEFAULT '' -- 漏选教训
|
||||
)
|
||||
""")
|
||||
|
||||
# 8. 策略迭代日志(每天复盘/优化过程显式落库)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS strategy_iteration_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_date TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
trigger_source TEXT DEFAULT 'daily_review',
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT DEFAULT '',
|
||||
findings_json TEXT DEFAULT '[]',
|
||||
problems_json TEXT DEFAULT '[]',
|
||||
actions_json TEXT DEFAULT '[]',
|
||||
changed_rules_json TEXT DEFAULT '[]',
|
||||
metrics_json TEXT DEFAULT '{}',
|
||||
related_symbols_json TEXT DEFAULT '[]',
|
||||
config_diff_json TEXT DEFAULT '{}',
|
||||
effect_summary_json TEXT DEFAULT '{}',
|
||||
pollution_summary_json TEXT DEFAULT '{}',
|
||||
strategy_version TEXT DEFAULT '',
|
||||
version_change_summary TEXT DEFAULT ''
|
||||
)
|
||||
""")
|
||||
|
||||
# 8.1 候选规则表:研究结论先入候选,不直接发布到主版本
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS strategy_rule_candidate (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
source TEXT DEFAULT '',
|
||||
rule_type TEXT DEFAULT '',
|
||||
signal_name TEXT DEFAULT '',
|
||||
rule_description TEXT DEFAULT '',
|
||||
support_count INTEGER DEFAULT 0,
|
||||
success_count INTEGER DEFAULT 0,
|
||||
fail_count INTEGER DEFAULT 0,
|
||||
avg_pnl REAL DEFAULT 0,
|
||||
max_gain REAL DEFAULT 0,
|
||||
max_drawdown REAL DEFAULT 0,
|
||||
confidence_score REAL DEFAULT 0,
|
||||
sample_size INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'candidate',
|
||||
release_version TEXT DEFAULT '',
|
||||
notes TEXT DEFAULT '',
|
||||
source_ref TEXT DEFAULT ''
|
||||
)
|
||||
""")
|
||||
|
||||
# 8.2 失败模式表:专门记录失败样本与原因分类
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS strategy_failure_pattern (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
version TEXT DEFAULT '',
|
||||
failure_type TEXT DEFAULT '',
|
||||
failure_reason TEXT DEFAULT '',
|
||||
signal_combo TEXT DEFAULT '[]',
|
||||
market_context_json TEXT DEFAULT '{}',
|
||||
entry_quality_issue TEXT DEFAULT '',
|
||||
pnl_pct REAL DEFAULT 0,
|
||||
max_drawdown_pct REAL DEFAULT 0,
|
||||
lesson TEXT DEFAULT ''
|
||||
)
|
||||
""")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS push_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
push_type TEXT NOT NULL,
|
||||
action_status TEXT DEFAULT '',
|
||||
pushed_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
_push_cols = [row[1] for row in conn.execute("PRAGMA table_info(push_log)").fetchall()]
|
||||
if "rec_id" not in _push_cols:
|
||||
conn.execute("ALTER TABLE push_log ADD COLUMN rec_id INTEGER DEFAULT 0")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_push_lookup ON push_log(symbol, push_type, pushed_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_push_log_rec_action ON push_log(rec_id, push_type, action_status, pushed_at)")
|
||||
|
||||
# strategy_iteration_log 表字段补齐迁移
|
||||
iter_cols = [r[1] for r in conn.execute("PRAGMA table_info(strategy_iteration_log)").fetchall()]
|
||||
if iter_cols:
|
||||
if "config_diff_json" not in iter_cols:
|
||||
conn.execute("ALTER TABLE strategy_iteration_log ADD COLUMN config_diff_json TEXT DEFAULT '{}' ")
|
||||
if "effect_summary_json" not in iter_cols:
|
||||
conn.execute("ALTER TABLE strategy_iteration_log ADD COLUMN effect_summary_json TEXT DEFAULT '{}' ")
|
||||
if "pollution_summary_json" not in iter_cols:
|
||||
conn.execute("ALTER TABLE strategy_iteration_log ADD COLUMN pollution_summary_json TEXT DEFAULT '{}' ")
|
||||
if "strategy_version" not in iter_cols:
|
||||
conn.execute("ALTER TABLE strategy_iteration_log ADD COLUMN strategy_version TEXT DEFAULT '' ")
|
||||
if "version_change_summary" not in iter_cols:
|
||||
conn.execute("ALTER TABLE strategy_iteration_log ADD COLUMN version_change_summary TEXT DEFAULT '' ")
|
||||
for col, default in [
|
||||
("success_analysis_json", "'{}'"),
|
||||
("failure_analysis_json", "'{}'"),
|
||||
("candidate_rules_json", "'[]'"),
|
||||
("release_decision", "''"),
|
||||
("release_reason", "''"),
|
||||
("confidence_level", "''"),
|
||||
("promotion_state", "'research_only'"),
|
||||
]:
|
||||
if col not in iter_cols:
|
||||
conn.execute(f"ALTER TABLE strategy_iteration_log ADD COLUMN {col} TEXT DEFAULT {default} ")
|
||||
|
||||
cand_cols = [row["name"] for row in conn.execute("PRAGMA table_info(strategy_rule_candidate)").fetchall()]
|
||||
if cand_cols and "source_ref" not in cand_cols:
|
||||
conn.execute("ALTER TABLE strategy_rule_candidate ADD COLUMN source_ref TEXT DEFAULT '' ")
|
||||
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_rule_candidate_status ON strategy_rule_candidate(status, created_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_failure_pattern_type ON strategy_failure_pattern(failure_type, created_at)")
|
||||
|
||||
# 9. 舆情事件表(sentiment_monitor 采集,供PA共振加权)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sentiment_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
name TEXT DEFAULT '',
|
||||
source TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
trend_rank INTEGER DEFAULT 0,
|
||||
trend_score INTEGER DEFAULT 0,
|
||||
market_cap_rank INTEGER DEFAULT 0,
|
||||
extra_json TEXT DEFAULT '{}',
|
||||
detected_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_sentiment_lookup ON sentiment_events(symbol, source, detected_at)")
|
||||
|
||||
# 10. LLM解释层缓存:只保存异步生成的解释/研究备忘,不参与交易决策。
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS llm_insights (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
insight_type TEXT NOT NULL,
|
||||
prompt_version TEXT NOT NULL,
|
||||
input_hash TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
input_json TEXT DEFAULT '{}',
|
||||
content_json TEXT DEFAULT '{}',
|
||||
error TEXT DEFAULT '',
|
||||
model TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
try:
|
||||
conn.execute("ALTER TABLE llm_insights ADD COLUMN input_json TEXT DEFAULT '{}'")
|
||||
except Exception:
|
||||
pass
|
||||
conn.execute("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_insights_unique
|
||||
ON llm_insights(target_type, target_id, insight_type, input_hash)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_insights_lookup
|
||||
ON llm_insights(target_type, target_id, insight_type, status, updated_at)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
print("DB初始化完成(9+2表)")
|
||||
apply_migrations()
|
||||
print("PostgreSQL schema migrations checked")
|
||||
|
||||
|
||||
# === 推送去重 ===
|
||||
@ -430,7 +41,7 @@ def should_push(symbol: str, push_type: str, action_status: str = "") -> bool:
|
||||
if action_status:
|
||||
# 状态感知:只拦同状态重复推,状态变了永远放行
|
||||
row = conn.execute(
|
||||
"SELECT action_status FROM push_log WHERE symbol=? AND push_type=? AND pushed_at > ? ORDER BY id DESC LIMIT 1",
|
||||
"SELECT action_status FROM push_log WHERE symbol=%s AND push_type=%s AND pushed_at > %s ORDER BY id DESC LIMIT 1",
|
||||
(symbol, push_type, cutoff),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
@ -441,7 +52,7 @@ def should_push(symbol: str, push_type: str, action_status: str = "") -> bool:
|
||||
else:
|
||||
# 无状态(burst):同type任何推过就冷却
|
||||
row = conn.execute(
|
||||
"SELECT id FROM push_log WHERE symbol=? AND push_type=? AND pushed_at > ? ORDER BY id DESC LIMIT 1",
|
||||
"SELECT id FROM push_log WHERE symbol=%s AND push_type=%s AND pushed_at > %s ORDER BY id DESC LIMIT 1",
|
||||
(symbol, push_type, cutoff),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
@ -452,17 +63,10 @@ def log_push(symbol: str, push_type: str, action_status: str = "", rec_id: int =
|
||||
"""记录一次推送。rec_id 可选,作为主链路推荐记录的可追溯来源。"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cols = [row[1] for row in conn.execute("PRAGMA table_info(push_log)").fetchall()]
|
||||
if "rec_id" in cols:
|
||||
conn.execute(
|
||||
"INSERT INTO push_log (symbol, push_type, action_status, rec_id, pushed_at) VALUES (?,?,?,?,?)",
|
||||
(symbol, push_type, action_status, int(rec_id or 0), datetime.now().isoformat()),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO push_log (symbol, push_type, action_status, pushed_at) VALUES (?,?,?,?)",
|
||||
(symbol, push_type, action_status, datetime.now().isoformat()),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO push_log (symbol, push_type, action_status, rec_id, pushed_at) VALUES (%s,%s,%s,%s,%s)",
|
||||
(symbol, push_type, action_status, int(rec_id or 0), datetime.now().isoformat()),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@ -486,7 +90,7 @@ def get_recommendation_for_push(rec_id: int):
|
||||
lpc.updated_at AS latest_cache_updated_at
|
||||
FROM recommendation r
|
||||
LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol
|
||||
WHERE r.id=?
|
||||
WHERE r.id=%s
|
||||
""", (rec_id,)).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
@ -508,7 +112,7 @@ def log_screening(layer, symbol, state, score, price, signals,
|
||||
conn.execute("""
|
||||
INSERT INTO screening_log (scan_time, layer, symbol, state, score, price, signals,
|
||||
sector, leader_status, is_meme, change_24h, funding_rate, detail_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
datetime.now().isoformat(), layer, symbol, state, score, price,
|
||||
json.dumps(signals, ensure_ascii=False) if isinstance(signals, list) else signals,
|
||||
@ -587,7 +191,7 @@ def create_recommendation(symbol, rec_state, rec_score, entry_price,
|
||||
duplicate_cursor = conn.execute(
|
||||
"""
|
||||
SELECT * FROM recommendation
|
||||
WHERE symbol=? AND status='active' AND COALESCE(display_bucket,'watch_pool') != 'history'
|
||||
WHERE symbol=%s AND status='active' AND COALESCE(display_bucket,'watch_pool') != 'history'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(symbol,),
|
||||
@ -602,19 +206,19 @@ def create_recommendation(symbol, rec_state, rec_score, entry_price,
|
||||
merged_score = max(existing_score, rec_score_pct)
|
||||
conn.execute("""
|
||||
UPDATE recommendation
|
||||
SET rec_state=?, rec_score=?, sector=COALESCE(NULLIF(?, ''), sector),
|
||||
signals=?, signal_codes_json=?, signal_labels_json=?, is_meme=?, direction=?, strategy_version=?,
|
||||
force_reason=COALESCE(NULLIF(?, ''), force_reason),
|
||||
base_state=COALESCE(NULLIF(?, ''), base_state),
|
||||
sector_signal_count=MAX(COALESCE(sector_signal_count,0), ?),
|
||||
entry_plan_json=CASE WHEN ? != '{}' THEN ? ELSE entry_plan_json END,
|
||||
market_context_json=?, derivatives_context_json=?, sector_context_json=?,
|
||||
SET rec_state=%s, rec_score=%s, sector=COALESCE(NULLIF(%s, ''), sector),
|
||||
signals=%s, signal_codes_json=%s, signal_labels_json=%s, is_meme=%s, direction=%s, strategy_version=%s,
|
||||
force_reason=COALESCE(NULLIF(%s, ''), force_reason),
|
||||
base_state=COALESCE(NULLIF(%s, ''), base_state),
|
||||
sector_signal_count=GREATEST(COALESCE(sector_signal_count,0), %s),
|
||||
entry_plan_json=CASE WHEN %s != '{}' THEN %s ELSE entry_plan_json END,
|
||||
market_context_json=%s, derivatives_context_json=%s, sector_context_json=%s,
|
||||
action_status=CASE
|
||||
WHEN action_status IN ('止盈1','止盈2','止损','跟踪止盈','衰减','反转') THEN action_status
|
||||
ELSE COALESCE(NULLIF(?, ''), action_status)
|
||||
ELSE COALESCE(NULLIF(%s, ''), action_status)
|
||||
END,
|
||||
execution_status=?, display_bucket=?, lifecycle_state=?, entry_triggered=?, state_reason=?
|
||||
WHERE id=?
|
||||
execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s
|
||||
WHERE id=%s
|
||||
""", (
|
||||
merged_state, merged_score, sector,
|
||||
stored_signals, signal_codes_json, signal_labels_json,
|
||||
@ -641,7 +245,8 @@ def create_recommendation(symbol, rec_state, rec_score, entry_price,
|
||||
market_context_json, derivatives_context_json, sector_context_json,
|
||||
action_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason,
|
||||
strategy_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
symbol, now, rec_state, rec_score_pct, entry_price,
|
||||
stop_loss, tp1, tp2, sector,
|
||||
@ -659,7 +264,7 @@ def create_recommendation(symbol, rec_state, rec_score, entry_price,
|
||||
incoming_exec, incoming_bucket, incoming_lifecycle, incoming_triggered, incoming_reason,
|
||||
strategy_version,
|
||||
))
|
||||
rec_id = cursor.lastrowid
|
||||
rec_id = cursor.fetchone()["id"]
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return rec_id
|
||||
@ -672,7 +277,7 @@ def update_recommendation_tracking(rec_id, current_price):
|
||||
这样 TP1 后继续推高的收益会继续计入 current/max_pnl。
|
||||
"""
|
||||
conn = get_conn()
|
||||
row = conn.execute("SELECT entry_price, max_price, min_price, symbol FROM recommendation WHERE id=?", (rec_id,)).fetchone()
|
||||
row = conn.execute("SELECT entry_price, max_price, min_price, symbol FROM recommendation WHERE id=%s", (rec_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return
|
||||
@ -689,7 +294,7 @@ def update_recommendation_tracking(rec_id, current_price):
|
||||
|
||||
status = "active"
|
||||
tp1_reached = False
|
||||
rec = conn.execute("SELECT stop_loss, tp1, tp2, status, hit_tp1_time FROM recommendation WHERE id=?", (rec_id,)).fetchone()
|
||||
rec = conn.execute("SELECT stop_loss, tp1, tp2, status, hit_tp1_time FROM recommendation WHERE id=%s", (rec_id,)).fetchone()
|
||||
if rec and rec["status"] == "active":
|
||||
if rec["tp2"] and current_price >= rec["tp2"]:
|
||||
status = "hit_tp2"
|
||||
@ -707,23 +312,23 @@ def update_recommendation_tracking(rec_id, current_price):
|
||||
action_for_status = {"hit_tp1": "止盈1", "hit_tp2": "止盈2", "stopped_out": "止损"}.get(status, "持有")
|
||||
execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _derive_minimal_state_fields(status, action_for_status, {})
|
||||
conn.execute("""
|
||||
UPDATE recommendation SET current_price=?, max_price=?, min_price=?,
|
||||
pnl_pct=?, max_pnl_pct=?, max_drawdown_pct=?,
|
||||
status=?, action_status=?, execution_status=?, display_bucket=?, lifecycle_state=?, entry_triggered=?, state_reason=?, last_track_time=?,
|
||||
hit_tp1_time=CASE WHEN ?='hit_tp1' THEN ? ELSE hit_tp1_time END,
|
||||
hit_tp2_time=CASE WHEN ?='hit_tp2' THEN ? ELSE hit_tp2_time END,
|
||||
stopped_out_time=CASE WHEN ?='stopped_out' THEN ? ELSE stopped_out_time END
|
||||
WHERE id=?
|
||||
UPDATE recommendation SET current_price=%s, max_price=%s, min_price=%s,
|
||||
pnl_pct=%s, max_pnl_pct=%s, max_drawdown_pct=%s,
|
||||
status=%s, action_status=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s, last_track_time=%s,
|
||||
hit_tp1_time=CASE WHEN %s='hit_tp1' THEN %s ELSE hit_tp1_time END,
|
||||
hit_tp2_time=CASE WHEN %s='hit_tp2' THEN %s ELSE hit_tp2_time END,
|
||||
stopped_out_time=CASE WHEN %s='stopped_out' THEN %s ELSE stopped_out_time END
|
||||
WHERE id=%s
|
||||
""", (current_price, new_max, new_min, pnl_pct, max_pnl_pct, max_drawdown_pct,
|
||||
status, action_for_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, now,
|
||||
status, now, status, now, status, now, rec_id))
|
||||
else:
|
||||
conn.execute("""
|
||||
UPDATE recommendation SET current_price=?, max_price=?, min_price=?,
|
||||
pnl_pct=?, max_pnl_pct=?, max_drawdown_pct=?,
|
||||
last_track_time=?,
|
||||
hit_tp1_time=CASE WHEN ? THEN COALESCE(NULLIF(hit_tp1_time,''), ?) ELSE hit_tp1_time END
|
||||
WHERE id=?
|
||||
UPDATE recommendation SET current_price=%s, max_price=%s, min_price=%s,
|
||||
pnl_pct=%s, max_pnl_pct=%s, max_drawdown_pct=%s,
|
||||
last_track_time=%s,
|
||||
hit_tp1_time=CASE WHEN %s=1 THEN COALESCE(NULLIF(hit_tp1_time,''), %s) ELSE hit_tp1_time END
|
||||
WHERE id=%s
|
||||
""", (current_price, new_max, new_min, pnl_pct, max_pnl_pct, max_drawdown_pct, now,
|
||||
1 if tp1_reached else 0, now, rec_id))
|
||||
|
||||
@ -732,7 +337,7 @@ def update_recommendation_tracking(rec_id, current_price):
|
||||
|
||||
conn.execute("""
|
||||
INSERT INTO price_tracking (rec_id, symbol, track_time, price, pnl_pct)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""", (rec_id, symbol, now, current_price, pnl_pct))
|
||||
|
||||
conn.commit()
|
||||
@ -743,11 +348,22 @@ def update_recommendation_tracking(rec_id, current_price):
|
||||
def expire_old_recommendations(hours=48):
|
||||
"""超过48小时的active推荐标记为expired"""
|
||||
conn = get_conn()
|
||||
cutoff = datetime.now().timestamp() - hours * 3600
|
||||
cutoff = (datetime.now() - timedelta(hours=float(hours or 48))).isoformat()
|
||||
execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _derive_minimal_state_fields(
|
||||
"expired", "过期", {}
|
||||
)
|
||||
conn.execute("""
|
||||
UPDATE recommendation SET status='expired', expired_time=?
|
||||
WHERE status='active' AND julianday(?) - julianday(rec_time) > ?
|
||||
""", (datetime.now().isoformat(), datetime.now().isoformat(), hours / 24.0))
|
||||
UPDATE recommendation
|
||||
SET status='expired',
|
||||
action_status=CASE WHEN action_status IN ('止盈1','止盈2','止损','跟踪止盈') THEN action_status ELSE '过期' END,
|
||||
expired_time=%s,
|
||||
execution_status=%s,
|
||||
display_bucket=%s,
|
||||
lifecycle_state=%s,
|
||||
entry_triggered=%s,
|
||||
state_reason=%s
|
||||
WHERE status='active' AND rec_time < %s
|
||||
""", (datetime.now().isoformat(), execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, cutoff))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@ -850,7 +466,7 @@ def update_latest_price_cache(symbol, price, updated_at=None, source="tracker",
|
||||
conn = get_conn()
|
||||
conn.execute("""
|
||||
INSERT INTO latest_price_cache (symbol, price, updated_at, source)
|
||||
VALUES (?, ?, ?, ?)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT(symbol) DO UPDATE SET
|
||||
price=excluded.price,
|
||||
updated_at=excluded.updated_at,
|
||||
@ -872,7 +488,7 @@ def get_latest_price_cache(symbols):
|
||||
if not normalized:
|
||||
return {}
|
||||
conn = get_conn()
|
||||
placeholders = ",".join(["?"] * len(normalized))
|
||||
placeholders = ",".join(["%s"] * len(normalized))
|
||||
rows = conn.execute(
|
||||
f"SELECT symbol, price, updated_at, source FROM latest_price_cache WHERE symbol IN ({placeholders})",
|
||||
tuple(normalized),
|
||||
@ -940,7 +556,7 @@ def apply_recommendation_state_transition(rec_id, requested_action, current_pric
|
||||
event_time = event_time or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||
conn = get_conn()
|
||||
row = conn.execute("""
|
||||
SELECT * FROM recommendation WHERE id=?
|
||||
SELECT * FROM recommendation WHERE id=%s
|
||||
""", (rec_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
@ -1014,12 +630,12 @@ def apply_recommendation_state_transition(rec_id, requested_action, current_pric
|
||||
|
||||
conn.execute("""
|
||||
UPDATE recommendation
|
||||
SET action_status=?, entry_plan_json=?, current_price=?, max_price=?, min_price=?,
|
||||
pnl_pct=?, max_pnl_pct=?, max_drawdown_pct=?, last_track_time=?,
|
||||
execution_status=?, display_bucket=?, lifecycle_state=?, entry_triggered=?, state_reason=?,
|
||||
rec_time=CASE WHEN ? THEN ? ELSE rec_time END,
|
||||
entry_price=CASE WHEN ? THEN ? ELSE entry_price END
|
||||
WHERE id=?
|
||||
SET action_status=%s, entry_plan_json=%s, current_price=%s, max_price=%s, min_price=%s,
|
||||
pnl_pct=%s, max_pnl_pct=%s, max_drawdown_pct=%s, last_track_time=%s,
|
||||
execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s,
|
||||
rec_time=CASE WHEN %s=1 THEN %s ELSE rec_time END,
|
||||
entry_price=CASE WHEN %s=1 THEN %s ELSE entry_price END
|
||||
WHERE id=%s
|
||||
""", (
|
||||
final_action, json.dumps(entry_plan, ensure_ascii=False), current_price, max_price, min_price,
|
||||
pnl_pct, max_pnl_pct, max_drawdown_pct, event_time,
|
||||
@ -1085,8 +701,8 @@ def recompute_all_recommendation_state_fields(conn=None):
|
||||
)
|
||||
conn.execute(
|
||||
"""UPDATE recommendation
|
||||
SET action_status=?, execution_status=?, display_bucket=?, lifecycle_state=?, entry_triggered=?, state_reason=?
|
||||
WHERE id=?""",
|
||||
SET action_status=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s
|
||||
WHERE id=%s""",
|
||||
(action, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, row["id"]),
|
||||
)
|
||||
updated += 1
|
||||
@ -1107,7 +723,7 @@ def update_recommendation_action_status(rec_id, action_status):
|
||||
row = conn.execute("""
|
||||
SELECT status, action_status, entry_plan_json, signals, current_price,
|
||||
market_context_json, derivatives_context_json, sector_context_json
|
||||
FROM recommendation WHERE id=?
|
||||
FROM recommendation WHERE id=%s
|
||||
""", (rec_id,)).fetchone()
|
||||
terminal_map = {
|
||||
"hit_tp1": "止盈1",
|
||||
@ -1140,12 +756,12 @@ def update_recommendation_action_status(rec_id, action_status):
|
||||
row["status"] if row else "active", action_status, execution_status, execution_reason
|
||||
)
|
||||
conn.execute("""
|
||||
UPDATE recommendation SET action_status=?, entry_plan_json=?, execution_status=?, display_bucket=?, lifecycle_state=?, entry_triggered=?, state_reason=? WHERE id=?
|
||||
UPDATE recommendation SET action_status=%s, entry_plan_json=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s WHERE id=%s
|
||||
""", (action_status, json.dumps(entry_plan, ensure_ascii=False), execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, rec_id))
|
||||
else:
|
||||
execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _derive_minimal_state_fields(row["status"] if row else "active", action_status, {})
|
||||
conn.execute("""
|
||||
UPDATE recommendation SET action_status=?, execution_status=?, display_bucket=?, lifecycle_state=?, entry_triggered=?, state_reason=? WHERE id=?
|
||||
UPDATE recommendation SET action_status=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s WHERE id=%s
|
||||
""", (action_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, rec_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@ -1155,7 +771,7 @@ def update_entry_timing(rec_id: int, entry_price: float, rec_time: str):
|
||||
"""更新入场到位的时间和价格。当tracker检测到可即刻买入时调用。"""
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
"UPDATE recommendation SET rec_time=?, entry_price=?, current_price=?, pnl_pct=0 WHERE id=?",
|
||||
"UPDATE recommendation SET rec_time=%s, entry_price=%s, current_price=%s, pnl_pct=0 WHERE id=%s",
|
||||
(rec_time, entry_price, entry_price, rec_id)
|
||||
)
|
||||
conn.commit()
|
||||
@ -1435,20 +1051,21 @@ def get_active_recommendations_deduped(actionable_only=True, version="", hours=0
|
||||
params = []
|
||||
version = str(version or "").strip()
|
||||
if version:
|
||||
where += " AND strategy_version=?"
|
||||
where += " AND strategy_version=%s"
|
||||
params.append(version)
|
||||
if watch_symbols:
|
||||
symbols = [str(s).strip().upper() for s in watch_symbols if str(s).strip()]
|
||||
if symbols:
|
||||
where += " AND symbol IN (" + ",".join(["?"] * len(symbols)) + ")"
|
||||
where += " AND symbol IN (" + ",".join(["%s"] * len(symbols)) + ")"
|
||||
params.extend(symbols)
|
||||
try:
|
||||
hours = float(hours or 0)
|
||||
except Exception:
|
||||
hours = 0
|
||||
if hours > 0:
|
||||
where += " AND julianday(?) - julianday(rec_time) <= ?"
|
||||
params.extend([datetime.now().isoformat(), hours / 24.0])
|
||||
cutoff = (datetime.now() - timedelta(hours=hours)).isoformat()
|
||||
where += " AND rec_time >= %s"
|
||||
params.append(cutoff)
|
||||
|
||||
try:
|
||||
limit = max(0, int(limit or 0))
|
||||
@ -1560,9 +1177,9 @@ def get_screening_history(hours=24, limit=100):
|
||||
conn = get_conn()
|
||||
rows = conn.execute("""
|
||||
SELECT * FROM screening_log
|
||||
WHERE layer='细筛' AND julianday(?) - julianday(scan_time) < ?
|
||||
ORDER BY score DESC, scan_time DESC LIMIT ?
|
||||
""", (datetime.now().isoformat(), hours / 24.0, limit)).fetchall()
|
||||
WHERE layer='细筛' AND scan_time >= %s
|
||||
ORDER BY score DESC, scan_time DESC LIMIT %s
|
||||
""", ((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(), limit)).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@ -1580,7 +1197,7 @@ def update_state(symbol, new_state, score=0, anomaly_type="", sector="",
|
||||
leader_status="", detail=None):
|
||||
"""更新币状态(兼容旧接口)"""
|
||||
conn = get_conn()
|
||||
row = conn.execute("SELECT * FROM coin_state WHERE symbol=?", (symbol,)).fetchone()
|
||||
row = conn.execute("SELECT * FROM coin_state WHERE symbol=%s", (symbol,)).fetchone()
|
||||
|
||||
if row:
|
||||
old_state = row["state"]
|
||||
@ -1626,9 +1243,9 @@ def update_state(symbol, new_state, score=0, anomaly_type="", sector="",
|
||||
alert_level = "medium"
|
||||
|
||||
conn.execute("""
|
||||
UPDATE coin_state SET state=?, score=?, anomaly_type=?, sector=?,
|
||||
leader_status=?, detected_at=?, detail_json=?
|
||||
WHERE symbol=?
|
||||
UPDATE coin_state SET state=%s, score=%s, anomaly_type=%s, sector=%s,
|
||||
leader_status=%s, detected_at=%s, detail_json=%s
|
||||
WHERE symbol=%s
|
||||
""", (
|
||||
new_state, score, anomaly_type, sector, leader_status,
|
||||
datetime.now().isoformat(),
|
||||
@ -1638,7 +1255,7 @@ def update_state(symbol, new_state, score=0, anomaly_type="", sector="",
|
||||
|
||||
if should_alert:
|
||||
conn.execute("""
|
||||
UPDATE coin_state SET last_alert_time=?, last_alert_level=? WHERE symbol=?
|
||||
UPDATE coin_state SET last_alert_time=%s, last_alert_level=%s WHERE symbol=%s
|
||||
""", (datetime.now().isoformat(), alert_level, symbol))
|
||||
|
||||
conn.commit()
|
||||
@ -1648,7 +1265,7 @@ def update_state(symbol, new_state, score=0, anomaly_type="", sector="",
|
||||
# 新币,首次检测
|
||||
conn.execute("""
|
||||
INSERT INTO coin_state (symbol, state, score, anomaly_type, sector, leader_status, detected_at, last_alert_time, last_alert_level, detail_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
symbol, new_state, score, anomaly_type, sector, leader_status,
|
||||
datetime.now().isoformat(), datetime.now().isoformat(), "low",
|
||||
@ -1673,10 +1290,10 @@ def get_candidates_for_confirm():
|
||||
rows = conn.execute("""
|
||||
SELECT * FROM coin_state
|
||||
WHERE state IN ('加速', '蓄力')
|
||||
AND score >= ?
|
||||
AND julianday(?) - julianday(detected_at) <= ?
|
||||
AND score >= %s
|
||||
AND detected_at >= %s
|
||||
ORDER BY detected_at DESC, score DESC
|
||||
""", (accumulate_threshold, datetime.now().isoformat(), 45 / 1440.0)).fetchall()
|
||||
""", (accumulate_threshold, (datetime.now() - timedelta(minutes=45)).isoformat())).fetchall()
|
||||
if not rows:
|
||||
rows = conn.execute("""
|
||||
SELECT * FROM coin_state
|
||||
@ -1699,8 +1316,8 @@ def expire_old_states(hours=24):
|
||||
conn = get_conn()
|
||||
conn.execute("""
|
||||
UPDATE coin_state SET state='过期' WHERE state != '过期'
|
||||
AND julianday(?) - julianday(detected_at) > ?
|
||||
""", (datetime.now().isoformat(), hours / 24.0))
|
||||
AND detected_at < %s
|
||||
""", ((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(),))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@ -1714,7 +1331,7 @@ def record_review(rec_id, symbol, outcome, pnl_48h, max_pnl_48h,
|
||||
conn.execute("""
|
||||
INSERT INTO review_log (rec_id, symbol, review_time, outcome, pnl_48h, max_pnl_48h,
|
||||
triggered_signals, hit_signals, miss_signals, lesson)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (rec_id, symbol, datetime.now().isoformat(), outcome, pnl_48h, max_pnl_48h,
|
||||
json.dumps(triggered_signals, ensure_ascii=False) if isinstance(triggered_signals, list) else triggered_signals,
|
||||
json.dumps(hit_signals, ensure_ascii=False) if isinstance(hit_signals, list) else hit_signals,
|
||||
@ -1727,7 +1344,7 @@ def record_review(rec_id, symbol, outcome, pnl_48h, max_pnl_48h,
|
||||
def update_signal_performance(signal_type, category, is_hit, pnl):
|
||||
"""更新信号绩效统计(每次复盘后调用)"""
|
||||
conn = get_conn()
|
||||
row = conn.execute("SELECT * FROM signal_performance WHERE signal_type=?", (signal_type,)).fetchone()
|
||||
row = conn.execute("SELECT * FROM signal_performance WHERE signal_type=%s", (signal_type,)).fetchone()
|
||||
|
||||
if row:
|
||||
total = row["total_count"] + 1
|
||||
@ -1739,15 +1356,15 @@ def update_signal_performance(signal_type, category, is_hit, pnl):
|
||||
hit_rate = round(hits / total * 100, 1) if total > 0 else 0
|
||||
|
||||
conn.execute("""
|
||||
UPDATE signal_performance SET total_count=?, hit_count=?, miss_count=?,
|
||||
hit_rate=?, avg_pnl=?, weight=?, last_updated=?
|
||||
WHERE signal_type=?
|
||||
UPDATE signal_performance SET total_count=%s, hit_count=%s, miss_count=%s,
|
||||
hit_rate=%s, avg_pnl=%s, weight=%s, last_updated=%s
|
||||
WHERE signal_type=%s
|
||||
""", (total, hits, misses, hit_rate, new_avg_pnl, hit_rate / 50, datetime.now().isoformat(), signal_type))
|
||||
else:
|
||||
conn.execute("""
|
||||
INSERT INTO signal_performance (signal_type, category, total_count, hit_count, miss_count,
|
||||
hit_rate, avg_pnl, weight, last_updated)
|
||||
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (%s, %s, 1, %s, %s, %s, %s, %s, %s)
|
||||
""", (signal_type, category, 1 if is_hit else 0, 0 if is_hit else 1,
|
||||
100 if is_hit else 0, pnl, 2.0 if is_hit else 0, datetime.now().isoformat()))
|
||||
|
||||
@ -1770,7 +1387,7 @@ def record_missed_explosion(symbol, price_at_detect, price_before, gain_pct,
|
||||
conn.execute("""
|
||||
INSERT INTO missed_explosions (symbol, detect_time, price_at_detect, price_before,
|
||||
gain_pct, reason_missed, features_detected, lesson)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (symbol, datetime.now().isoformat(), price_at_detect, price_before, gain_pct,
|
||||
json.dumps(reason_missed, ensure_ascii=False) if isinstance(reason_missed, list) else reason_missed,
|
||||
json.dumps(features_detected, ensure_ascii=False) if isinstance(features_detected, list) else features_detected,
|
||||
@ -1854,16 +1471,16 @@ def upsert_strategy_rule_candidate(source, rule_type, signal_name, rule_descript
|
||||
now = datetime.now().isoformat()
|
||||
existing = conn.execute("""
|
||||
SELECT id FROM strategy_rule_candidate
|
||||
WHERE source=? AND rule_type=? AND signal_name=? AND rule_description=?
|
||||
WHERE source=%s AND rule_type=%s AND signal_name=%s AND rule_description=%s
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""", (source or "", rule_type or "", signal_name or "", rule_description or "")).fetchone()
|
||||
if existing:
|
||||
conn.execute("""
|
||||
UPDATE strategy_rule_candidate
|
||||
SET support_count=?, success_count=?, fail_count=?, avg_pnl=?, max_gain=?,
|
||||
max_drawdown=?, confidence_score=?, sample_size=?, status=?,
|
||||
release_version=?, notes=?, source_ref=COALESCE(NULLIF(?, ''), source_ref), created_at=?
|
||||
WHERE id=?
|
||||
SET support_count=%s, success_count=%s, fail_count=%s, avg_pnl=%s, max_gain=%s,
|
||||
max_drawdown=%s, confidence_score=%s, sample_size=%s, status=%s,
|
||||
release_version=%s, notes=%s, source_ref=COALESCE(NULLIF(%s, ''), source_ref), created_at=%s
|
||||
WHERE id=%s
|
||||
""", (support_count, success_count, fail_count, avg_pnl, max_gain, max_drawdown,
|
||||
confidence_score, sample_size, status, release_version or "", notes or "", source_ref or "", now, existing["id"]))
|
||||
candidate_id = existing["id"]
|
||||
@ -1873,11 +1490,12 @@ def upsert_strategy_rule_candidate(source, rule_type, signal_name, rule_descript
|
||||
created_at, source, rule_type, signal_name, rule_description,
|
||||
support_count, success_count, fail_count, avg_pnl, max_gain, max_drawdown,
|
||||
confidence_score, sample_size, status, release_version, notes, source_ref
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (now, source or "", rule_type or "", signal_name or "", rule_description or "",
|
||||
support_count, success_count, fail_count, avg_pnl, max_gain, max_drawdown,
|
||||
confidence_score, sample_size, status, release_version or "", notes or "", source_ref or ""))
|
||||
candidate_id = cur.lastrowid
|
||||
candidate_id = cur.fetchone()["id"]
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return candidate_id
|
||||
@ -1892,7 +1510,7 @@ def record_strategy_failure_pattern(symbol, version="", failure_type="", failure
|
||||
INSERT INTO strategy_failure_pattern (
|
||||
created_at, symbol, version, failure_type, failure_reason, signal_combo,
|
||||
market_context_json, entry_quality_issue, pnl_pct, max_drawdown_pct, lesson
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
datetime.now().isoformat(), symbol or "", version or "", failure_type or "",
|
||||
failure_reason or "", json.dumps(signal_combo or [], ensure_ascii=False, default=str),
|
||||
@ -1909,13 +1527,13 @@ def get_strategy_rule_candidates(limit=50, status=None):
|
||||
params = []
|
||||
where = ""
|
||||
if status:
|
||||
where = "WHERE status=?"
|
||||
where = "WHERE status=%s"
|
||||
params.append(status)
|
||||
rows = conn.execute(f"""
|
||||
SELECT * FROM strategy_rule_candidate
|
||||
{where}
|
||||
ORDER BY confidence_score DESC, sample_size DESC, created_at DESC
|
||||
LIMIT ?
|
||||
LIMIT %s
|
||||
""", (*params, limit)).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
@ -1924,7 +1542,7 @@ def get_strategy_rule_candidates(limit=50, status=None):
|
||||
def update_strategy_rule_candidate_status(candidate_id, status, release_version="", notes_append=""):
|
||||
"""更新候选规则生命周期状态。"""
|
||||
conn = get_conn()
|
||||
row = conn.execute("SELECT notes FROM strategy_rule_candidate WHERE id=?", (candidate_id,)).fetchone()
|
||||
row = conn.execute("SELECT notes FROM strategy_rule_candidate WHERE id=%s", (candidate_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return False
|
||||
@ -1933,8 +1551,8 @@ def update_strategy_rule_candidate_status(candidate_id, status, release_version=
|
||||
notes = (notes + "\n" if notes else "") + f"[{datetime.now().isoformat()}] {notes_append}"
|
||||
conn.execute("""
|
||||
UPDATE strategy_rule_candidate
|
||||
SET status=?, release_version=COALESCE(NULLIF(?, ''), release_version), notes=?, created_at=?
|
||||
WHERE id=?
|
||||
SET status=%s, release_version=COALESCE(NULLIF(%s, ''), release_version), notes=%s, created_at=%s
|
||||
WHERE id=%s
|
||||
""", (status or "candidate", release_version or "", notes, datetime.now().isoformat(), candidate_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@ -1947,7 +1565,7 @@ def get_strategy_failure_patterns(limit=50):
|
||||
rows = conn.execute("""
|
||||
SELECT * FROM strategy_failure_pattern
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
LIMIT %s
|
||||
""", (limit,)).fetchall()
|
||||
conn.close()
|
||||
items = []
|
||||
@ -2064,7 +1682,7 @@ def backfill_strategy_failure_patterns(limit=2000, dry_run=False):
|
||||
LEFT JOIN recommendation r ON r.id = rl.rec_id
|
||||
WHERE rl.outcome IN ('失败','横盘')
|
||||
ORDER BY rl.review_time DESC
|
||||
LIMIT ?
|
||||
LIMIT %s
|
||||
""", (limit,)).fetchall()
|
||||
existing = set()
|
||||
for r in conn.execute("SELECT market_context_json FROM strategy_failure_pattern").fetchall():
|
||||
@ -2094,7 +1712,7 @@ def backfill_strategy_failure_patterns(limit=2000, dry_run=False):
|
||||
INSERT INTO strategy_failure_pattern (
|
||||
created_at, symbol, version, failure_type, failure_reason, signal_combo,
|
||||
market_context_json, entry_quality_issue, pnl_pct, max_drawdown_pct, lesson
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
datetime.now().isoformat(), item.get("symbol") or "", item.get("strategy_version") or "",
|
||||
ftype, reason, json.dumps(triggered, ensure_ascii=False, default=str),
|
||||
@ -2196,7 +1814,7 @@ def dry_run_strategy_candidate_performance(min_gray_samples=10, min_gray_confide
|
||||
SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time
|
||||
FROM review_log rl
|
||||
LEFT JOIN recommendation r ON r.id = rl.rec_id
|
||||
WHERE r.rec_time >= ?
|
||||
WHERE r.rec_time >= %s
|
||||
ORDER BY rl.review_time DESC
|
||||
""", (clean_started_at,)).fetchall()
|
||||
else:
|
||||
@ -2325,7 +1943,7 @@ def refresh_strategy_candidate_performance(min_gray_samples=10, min_gray_confide
|
||||
SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time
|
||||
FROM review_log rl
|
||||
LEFT JOIN recommendation r ON r.id = rl.rec_id
|
||||
WHERE r.rec_time >= ?
|
||||
WHERE r.rec_time >= %s
|
||||
ORDER BY rl.review_time DESC
|
||||
""", (clean_started_at,)).fetchall()
|
||||
else:
|
||||
@ -2411,9 +2029,9 @@ def refresh_strategy_candidate_performance(min_gray_samples=10, min_gray_confide
|
||||
|
||||
conn.execute("""
|
||||
UPDATE strategy_rule_candidate
|
||||
SET support_count=?, success_count=?, fail_count=?, avg_pnl=?, max_gain=?,
|
||||
max_drawdown=?, confidence_score=?, sample_size=?, status=?, notes=?, created_at=?
|
||||
WHERE id=?
|
||||
SET support_count=%s, success_count=%s, fail_count=%s, avg_pnl=%s, max_gain=%s,
|
||||
max_drawdown=%s, confidence_score=%s, sample_size=%s, status=%s, notes=%s, created_at=%s
|
||||
WHERE id=%s
|
||||
""", (sample_size, success_count, fail_count, avg_pnl, max_gain, max_drawdown,
|
||||
confidence, sample_size, new_status, note, datetime.now().isoformat(), cid))
|
||||
updated.append({
|
||||
@ -2499,7 +2117,7 @@ def log_cron_run(job_name, script_name, run_status, result_status="", started_at
|
||||
INSERT INTO cron_run_log (
|
||||
job_name, script_name, run_status, result_status,
|
||||
started_at, finished_at, duration_ms, summary_json, error_message
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
job_name,
|
||||
script_name,
|
||||
@ -2522,12 +2140,12 @@ def get_cron_run_logs(limit=50, job_name=None):
|
||||
SELECT * FROM cron_run_log
|
||||
{where_clause}
|
||||
ORDER BY started_at DESC, id DESC
|
||||
LIMIT ?
|
||||
LIMIT %s
|
||||
"""
|
||||
params = []
|
||||
where_clause = ""
|
||||
if job_name:
|
||||
where_clause = "WHERE job_name = ?"
|
||||
where_clause = "WHERE job_name = %s"
|
||||
params.append(job_name)
|
||||
params.append(limit)
|
||||
rows = conn.execute(sql.format(where_clause=where_clause), tuple(params)).fetchall()
|
||||
@ -2550,9 +2168,9 @@ def get_cron_run_summary(hours=24):
|
||||
now_iso = datetime.now().isoformat()
|
||||
rows = conn.execute("""
|
||||
SELECT * FROM cron_run_log
|
||||
WHERE julianday(?) - julianday(started_at) <= ?
|
||||
WHERE started_at >= %s
|
||||
ORDER BY started_at DESC, id DESC
|
||||
""", (now_iso, hours / 24.0)).fetchall()
|
||||
""", ((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(),)).fetchall()
|
||||
conn.close()
|
||||
|
||||
logs = []
|
||||
|
||||
@ -9,6 +9,7 @@ from app.db.altcoin_db import (
|
||||
_is_actionable_execution_status,
|
||||
_is_executed_trade,
|
||||
)
|
||||
from app.core.opportunity_funnel import screening_stage_meta, stage_label
|
||||
from app.db.schema import get_conn
|
||||
|
||||
|
||||
@ -18,10 +19,10 @@ def get_screening_history(hours=24, limit=100):
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM screening_log
|
||||
WHERE layer='细筛' AND julianday(?) - julianday(scan_time) < ?
|
||||
ORDER BY score DESC, scan_time DESC LIMIT ?
|
||||
WHERE layer='细筛' AND scan_time >= %s
|
||||
ORDER BY score DESC, scan_time DESC LIMIT %s
|
||||
""",
|
||||
(datetime.now().isoformat(), hours / 24.0, limit),
|
||||
((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(), limit),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
@ -117,7 +118,7 @@ def get_observation_candidates(limit=50):
|
||||
SELECT * FROM coin_state
|
||||
WHERE state != '过期'
|
||||
ORDER BY score DESC, detected_at DESC
|
||||
LIMIT ?
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
@ -199,7 +200,7 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0,
|
||||
offset = 0
|
||||
|
||||
result_where = EXECUTED_TRADE_WHERE
|
||||
version_where = " AND strategy_version=?" if version else ""
|
||||
version_where = " AND strategy_version=%s" if version else ""
|
||||
params = [version] if version else []
|
||||
|
||||
total = None
|
||||
@ -302,12 +303,12 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0,
|
||||
+ """
|
||||
GROUP BY symbol
|
||||
) latest ON latest.max_id = r.id
|
||||
ORDER BY r.rec_time DESC LIMIT ? OFFSET ?
|
||||
ORDER BY r.rec_time DESC LIMIT %s OFFSET %s
|
||||
""",
|
||||
tuple(params + [limit, offset]),
|
||||
).fetchall()
|
||||
else:
|
||||
where = "WHERE strategy_version=?" if version else ""
|
||||
where = "WHERE strategy_version=%s" if version else ""
|
||||
if with_meta:
|
||||
total = conn.execute("SELECT COUNT(*) FROM recommendation " + where, tuple(params)).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
@ -316,7 +317,7 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0,
|
||||
"""
|
||||
+ where
|
||||
+ """
|
||||
ORDER BY rec_time DESC LIMIT ? OFFSET ?
|
||||
ORDER BY rec_time DESC LIMIT %s OFFSET %s
|
||||
""",
|
||||
tuple(params + [limit, offset]),
|
||||
).fetchall()
|
||||
@ -533,14 +534,14 @@ def get_stats():
|
||||
SELECT substr(pt.track_time, 1, 13) || ':00:00' AS bucket, AVG(pt.pnl_pct) AS avg_pnl, COUNT(*) AS sample_count
|
||||
FROM price_tracking pt
|
||||
JOIN recommendation r ON r.id = pt.rec_id
|
||||
WHERE julianday(?) - julianday(pt.track_time) <= 1.0
|
||||
WHERE pt.track_time >= %s
|
||||
AND """
|
||||
+ _executed_trade_where("r")
|
||||
+ """
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket ASC
|
||||
""",
|
||||
(now.isoformat(),),
|
||||
((now - timedelta(hours=24)).isoformat(),),
|
||||
).fetchall()
|
||||
for row in rows_24h:
|
||||
points_24h.append({
|
||||
@ -555,14 +556,14 @@ def get_stats():
|
||||
SELECT substr(pt.track_time, 1, 10) AS bucket, AVG(pt.pnl_pct) AS avg_pnl, COUNT(*) AS sample_count
|
||||
FROM price_tracking pt
|
||||
JOIN recommendation r ON r.id = pt.rec_id
|
||||
WHERE julianday(?) - julianday(pt.track_time) <= 7.0
|
||||
WHERE pt.track_time >= %s
|
||||
AND """
|
||||
+ _executed_trade_where("r")
|
||||
+ """
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket ASC
|
||||
""",
|
||||
(now.isoformat(),),
|
||||
((now - timedelta(days=7)).isoformat(),),
|
||||
).fetchall()
|
||||
for row in rows_7d:
|
||||
points_7d.append({
|
||||
@ -738,12 +739,12 @@ def get_cron_run_logs(limit=50, job_name=None):
|
||||
SELECT * FROM cron_run_log
|
||||
{where_clause}
|
||||
ORDER BY started_at DESC, id DESC
|
||||
LIMIT ?
|
||||
LIMIT %s
|
||||
"""
|
||||
params = []
|
||||
where_clause = ""
|
||||
if job_name:
|
||||
where_clause = "WHERE job_name = ?"
|
||||
where_clause = "WHERE job_name = %s"
|
||||
params.append(job_name)
|
||||
params.append(limit)
|
||||
rows = conn.execute(sql.format(where_clause=where_clause), tuple(params)).fetchall()
|
||||
@ -766,10 +767,10 @@ def get_cron_run_summary(hours=24):
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM cron_run_log
|
||||
WHERE julianday(?) - julianday(started_at) <= ?
|
||||
WHERE started_at >= %s
|
||||
ORDER BY started_at DESC, id DESC
|
||||
""",
|
||||
(datetime.now().isoformat(), hours / 24.0),
|
||||
((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(),),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
@ -866,9 +867,18 @@ def _screening_item(row):
|
||||
item = dict(row)
|
||||
item["signals"] = _loads_json(item.get("signals"), [])
|
||||
item["detail_json"] = _loads_json(item.get("detail_json"), {})
|
||||
if item.get("layer") == "细筛":
|
||||
meta = screening_stage_meta(
|
||||
item.get("layer"),
|
||||
detail=item.get("detail_json"),
|
||||
state=item.get("state"),
|
||||
)
|
||||
item.update(meta)
|
||||
if meta["funnel_stage"] == "universe_gate":
|
||||
item["stage_bucket"] = "universe_gate"
|
||||
item["stage_label"] = "宇宙过滤"
|
||||
elif item.get("layer") == "细筛":
|
||||
item["stage_bucket"] = "fine"
|
||||
item["stage_label"] = "细筛通过"
|
||||
item["stage_label"] = "细筛通过" if meta["candidate_stage"] == "qualified_candidate" else "细筛淘汰"
|
||||
elif item.get("layer") == "确认":
|
||||
item["stage_bucket"] = "confirm"
|
||||
item["stage_label"] = "确认记录"
|
||||
@ -919,7 +929,7 @@ def _select_pipeline_rows(conn, run):
|
||||
next_row = conn.execute(
|
||||
"""
|
||||
SELECT started_at FROM cron_run_log
|
||||
WHERE job_name='粗筛' AND started_at > ?
|
||||
WHERE job_name='粗筛' AND started_at > %s
|
||||
ORDER BY started_at ASC, id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
@ -933,11 +943,11 @@ def _select_pipeline_rows(conn, run):
|
||||
cron_rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM cron_run_log
|
||||
WHERE started_at >= ? AND started_at <= ?
|
||||
WHERE started_at >= %s AND started_at <= %s
|
||||
AND (
|
||||
job_name IN ('事件舆情', '跟踪', '复盘')
|
||||
OR (job_name='粗筛' AND id=?)
|
||||
OR (job_name='确认' AND started_at >= ?)
|
||||
OR (job_name='粗筛' AND id=%s)
|
||||
OR (job_name='确认' AND started_at >= %s)
|
||||
)
|
||||
ORDER BY started_at ASC, id ASC
|
||||
""",
|
||||
@ -947,11 +957,11 @@ def _select_pipeline_rows(conn, run):
|
||||
"""
|
||||
SELECT * FROM screening_log
|
||||
WHERE (
|
||||
layer IN ('粗筛', '细筛') AND scan_time >= ? AND scan_time <= ?
|
||||
layer IN ('粗筛', '细筛', 'universe_gate') AND scan_time >= %s AND scan_time <= %s
|
||||
) OR (
|
||||
layer='确认' AND scan_time >= ? AND scan_time <= ?
|
||||
layer='确认' AND scan_time >= %s AND scan_time <= %s
|
||||
) OR (
|
||||
layer='舆情触发' AND scan_time >= ? AND scan_time <= ?
|
||||
layer='舆情触发' AND scan_time >= %s AND scan_time <= %s
|
||||
)
|
||||
ORDER BY scan_time ASC, score DESC, id ASC
|
||||
""",
|
||||
@ -960,7 +970,7 @@ def _select_pipeline_rows(conn, run):
|
||||
rec_rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM recommendation
|
||||
WHERE rec_time >= ? AND rec_time <= ?
|
||||
WHERE rec_time >= %s AND rec_time <= %s
|
||||
ORDER BY rec_time ASC, id ASC
|
||||
""",
|
||||
(run_finished, end_text),
|
||||
@ -968,7 +978,7 @@ def _select_pipeline_rows(conn, run):
|
||||
rec_ids = [row["id"] for row in rec_rows]
|
||||
reviews = []
|
||||
if rec_ids:
|
||||
placeholders = ",".join(["?"] * len(rec_ids))
|
||||
placeholders = ",".join(["%s"] * len(rec_ids))
|
||||
reviews = conn.execute(
|
||||
f"""
|
||||
SELECT * FROM review_log
|
||||
@ -980,7 +990,7 @@ def _select_pipeline_rows(conn, run):
|
||||
review_window_rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM review_log
|
||||
WHERE review_time >= ? AND review_time <= ?
|
||||
WHERE review_time >= %s AND review_time <= %s
|
||||
ORDER BY review_time ASC, id ASC
|
||||
""",
|
||||
(run_finished, end_text),
|
||||
@ -993,7 +1003,7 @@ def _select_pipeline_rows(conn, run):
|
||||
missed_rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM missed_explosions
|
||||
WHERE detect_time >= ? AND detect_time <= ?
|
||||
WHERE detect_time >= %s AND detect_time <= %s
|
||||
ORDER BY detect_time ASC, id ASC
|
||||
""",
|
||||
(run_finished, end_text),
|
||||
@ -1034,10 +1044,15 @@ def _pipeline_summary_for_run(run, related):
|
||||
status = run.get("run_status") or "unknown"
|
||||
rough_candidates = _safe_int(summary.get("total_candidates"))
|
||||
fine_qualified = _safe_int(summary.get("total_qualified"))
|
||||
universe_gate_count = sum(1 for item in related["screening_rows"] if item.get("funnel_stage") == "universe_gate")
|
||||
discovery_count = sum(1 for item in related["screening_rows"] if item.get("funnel_stage") == "discovery")
|
||||
quality_pass_count = sum(1 for item in related["screening_rows"] if item.get("funnel_stage") == "quality_filter" and item.get("candidate_stage") == "qualified_candidate")
|
||||
quality_reject_count = sum(1 for item in related["screening_rows"] if item.get("funnel_stage") == "quality_filter" and item.get("candidate_stage") == "rejected_candidate")
|
||||
trade_confirm_count = sum(1 for item in related["screening_rows"] if item.get("funnel_stage") == "trade_confirm")
|
||||
if not rough_candidates:
|
||||
rough_candidates = sum(1 for item in related["screening_rows"] if item.get("layer") == "粗筛")
|
||||
rough_candidates = discovery_count
|
||||
if not fine_qualified:
|
||||
fine_qualified = sum(1 for item in related["screening_rows"] if item.get("layer") == "细筛")
|
||||
fine_qualified = quality_pass_count
|
||||
|
||||
recommendations = len(related["recommendation_rows"])
|
||||
hit_rate = round(recommendations / fine_qualified * 100, 1) if fine_qualified else 0
|
||||
@ -1054,6 +1069,8 @@ def _pipeline_summary_for_run(run, related):
|
||||
issue_notes.append(f"失败 {perf_counts['failed']}")
|
||||
if related["missed_rows"]:
|
||||
issue_notes.append(f"漏选 {len(related['missed_rows'])}")
|
||||
if universe_gate_count:
|
||||
issue_notes.append(f"宇宙过滤 {universe_gate_count}")
|
||||
|
||||
return {
|
||||
"id": run.get("id"),
|
||||
@ -1073,6 +1090,11 @@ def _pipeline_summary_for_run(run, related):
|
||||
"confirm_processed": confirm_processed,
|
||||
"confirm_hits": confirm_hits,
|
||||
"recommendations": recommendations,
|
||||
"universe_gate_count": universe_gate_count,
|
||||
"discovery_count": discovery_count,
|
||||
"quality_pass_count": quality_pass_count,
|
||||
"quality_reject_count": quality_reject_count,
|
||||
"trade_confirm_count": trade_confirm_count,
|
||||
"perf_success": perf_counts["success"],
|
||||
"perf_failed": perf_counts["failed"],
|
||||
"perf_pending": perf_counts["pending"],
|
||||
@ -1107,29 +1129,29 @@ def get_pipeline_runs(limit=30, hours=24, offset=0):
|
||||
SELECT COUNT(*)
|
||||
FROM cron_run_log
|
||||
WHERE job_name = '粗筛'
|
||||
AND julianday(?) - julianday(started_at) <= ?
|
||||
AND started_at >= %s
|
||||
""",
|
||||
(datetime.now().isoformat(), hours / 24.0),
|
||||
((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(),),
|
||||
).fetchone()[0]
|
||||
run_rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM cron_run_log
|
||||
WHERE job_name = '粗筛'
|
||||
AND julianday(?) - julianday(started_at) <= ?
|
||||
AND started_at >= %s
|
||||
ORDER BY started_at DESC, id DESC
|
||||
LIMIT ?
|
||||
OFFSET ?
|
||||
LIMIT %s
|
||||
OFFSET %s
|
||||
""",
|
||||
(datetime.now().isoformat(), hours / 24.0, limit, offset),
|
||||
((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(), limit, offset),
|
||||
).fetchall()
|
||||
all_run_rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM cron_run_log
|
||||
WHERE job_name = '粗筛'
|
||||
AND julianday(?) - julianday(started_at) <= ?
|
||||
AND started_at >= %s
|
||||
ORDER BY started_at DESC, id DESC
|
||||
""",
|
||||
(datetime.now().isoformat(), hours / 24.0),
|
||||
((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(),),
|
||||
).fetchall()
|
||||
|
||||
runs = []
|
||||
@ -1147,10 +1169,15 @@ def get_pipeline_runs(limit=30, hours=24, offset=0):
|
||||
kpi = {
|
||||
"hours": hours,
|
||||
"run_count": len(all_summaries),
|
||||
"universe_gate_count": sum(item.get("universe_gate_count", 0) for item in all_summaries),
|
||||
"discovery_count": sum(item.get("discovery_count", 0) for item in all_summaries),
|
||||
"rough_candidates": sum(item["rough_candidates"] for item in all_summaries),
|
||||
"quality_pass_count": sum(item.get("quality_pass_count", 0) for item in all_summaries),
|
||||
"quality_reject_count": sum(item.get("quality_reject_count", 0) for item in all_summaries),
|
||||
"fine_qualified": sum(item["fine_qualified"] for item in all_summaries),
|
||||
"confirm_processed": sum(item["confirm_processed"] for item in all_summaries),
|
||||
"confirm_hits": sum(item["confirm_hits"] for item in all_summaries),
|
||||
"trade_confirm_count": sum(item.get("trade_confirm_count", 0) for item in all_summaries),
|
||||
"recommendations": sum(item["recommendations"] for item in all_summaries),
|
||||
"perf_success": sum(item["perf_success"] for item in all_summaries),
|
||||
"perf_failed": sum(item["perf_failed"] for item in all_summaries),
|
||||
@ -1179,7 +1206,7 @@ def get_pipeline_runs(limit=30, hours=24, offset=0):
|
||||
def get_pipeline_run_detail(run_id):
|
||||
"""返回某次粗筛批次的链路明细。"""
|
||||
conn = get_conn()
|
||||
row = conn.execute("SELECT * FROM cron_run_log WHERE id=? AND job_name='粗筛'", (run_id,)).fetchone()
|
||||
row = conn.execute("SELECT * FROM cron_run_log WHERE id=%s AND job_name='粗筛'", (run_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return None
|
||||
@ -1206,10 +1233,20 @@ def get_pipeline_run_detail(run_id):
|
||||
recommendations.append(rec)
|
||||
|
||||
timeline = []
|
||||
cron_stage_map = {
|
||||
"粗筛": "discovery",
|
||||
"确认": "trade_confirm",
|
||||
"跟踪": "tracking",
|
||||
"复盘": "review",
|
||||
"事件舆情": "discovery",
|
||||
}
|
||||
for cron in related["cron_rows"]:
|
||||
s = cron.get("summary_json") or {}
|
||||
stage_code = cron_stage_map.get(cron.get("job_name") or "", cron.get("job_name") or "")
|
||||
timeline.append({
|
||||
"stage": cron.get("job_name") or "任务",
|
||||
"stage": stage_label(stage_code) or cron.get("job_name") or "任务",
|
||||
"stage_code": stage_code,
|
||||
"job_name": cron.get("job_name") or "",
|
||||
"started_at": cron.get("started_at"),
|
||||
"finished_at": cron.get("finished_at"),
|
||||
"duration_ms": _safe_int(cron.get("duration_ms")),
|
||||
@ -1221,6 +1258,13 @@ def get_pipeline_run_detail(run_id):
|
||||
|
||||
screening_items = related["screening_rows"]
|
||||
stage_counts = {
|
||||
"universe_gate": sum(1 for item in screening_items if item.get("funnel_stage") == "universe_gate"),
|
||||
"discovery": sum(1 for item in screening_items if item.get("funnel_stage") == "discovery"),
|
||||
"quality_pass": sum(1 for item in screening_items if item.get("funnel_stage") == "quality_filter" and item.get("candidate_stage") == "qualified_candidate"),
|
||||
"quality_reject": sum(1 for item in screening_items if item.get("funnel_stage") == "quality_filter" and item.get("candidate_stage") == "rejected_candidate"),
|
||||
"trade_confirm": sum(1 for item in screening_items if item.get("funnel_stage") == "trade_confirm"),
|
||||
"tracking": summary["perf_success"] + summary["perf_failed"] + summary["perf_pending"],
|
||||
"review": summary["perf_success"] + summary["perf_failed"],
|
||||
"observation": sum(1 for item in screening_items if item.get("layer") == "粗筛"),
|
||||
"fine": sum(1 for item in screening_items if item.get("layer") == "细筛"),
|
||||
"confirm_rejected": max(0, summary["confirm_processed"] - summary["confirm_hits"]),
|
||||
|
||||
@ -15,14 +15,16 @@ import hmac
|
||||
import os
|
||||
import secrets
|
||||
import smtplib
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from psycopg import IntegrityError
|
||||
|
||||
from app.db.postgres_connection import connect as pg_connect, ensure_migrations_once, table_columns
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
DB_PATH = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db"))
|
||||
SESSION_DAYS = 30
|
||||
VERIFY_CODE_MINUTES = 15
|
||||
FREE_TRIAL_DAYS = 30
|
||||
@ -104,231 +106,11 @@ def _iso(dt: datetime) -> str:
|
||||
|
||||
|
||||
def get_conn():
|
||||
# SQLite 并发治理:Web/API/cron 共用同一 DB,连接必须等待写锁而不是立即失败。
|
||||
# journal_mode=WAL 只在初始化/首次连接时设置;busy_timeout 让短写事务排队。
|
||||
conn = sqlite3.connect(DB_PATH, timeout=30, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
return conn
|
||||
return pg_connect()
|
||||
|
||||
|
||||
def init_auth_db():
|
||||
conn = get_conn()
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS app_user (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
password_salt TEXT NOT NULL,
|
||||
email_verified INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'pending_email_verification',
|
||||
invite_code TEXT NOT NULL UNIQUE,
|
||||
invited_by_user_id INTEGER,
|
||||
free_trial_claimed INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_login_at TEXT DEFAULT '',
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
FOREIGN KEY(invited_by_user_id) REFERENCES app_user(id)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_app_user_email ON app_user(email)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_app_user_invite_code ON app_user(invite_code)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS email_verification_code (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
code_hash TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL DEFAULT 'register',
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES app_user(id)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_email_code_lookup ON email_verification_code(email, purpose, used_at)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_session (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT DEFAULT '',
|
||||
FOREIGN KEY(user_id) REFERENCES app_user(id)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_user_session_token ON user_session(token_hash)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS subscription_plan (
|
||||
code TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
duration_days INTEGER NOT NULL,
|
||||
price_usdt REAL DEFAULT 0,
|
||||
status TEXT DEFAULT 'active',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
INSERT OR IGNORE INTO subscription_plan
|
||||
(code, name, duration_days, price_usdt, status, sort_order, created_at)
|
||||
VALUES
|
||||
('free_trial_1m', '免费体验', 30, 0, 'active', 10, ?),
|
||||
('monthly_usdt', '月付', 30, 99, 'coming_soon', 20, ?),
|
||||
('quarterly_usdt', '季付', 90, 259, 'coming_soon', 30, ?),
|
||||
('yearly_usdt', '年付', 365, 899, 'coming_soon', 40, ?)
|
||||
""", (_iso(_now()), _iso(_now()), _iso(_now()), _iso(_now())))
|
||||
conn.executemany("""
|
||||
UPDATE subscription_plan
|
||||
SET name=?, duration_days=?, price_usdt=?, status=?, sort_order=?
|
||||
WHERE code=?
|
||||
""", [
|
||||
('免费体验', 30, 0, 'active', 10, 'free_trial_1m'),
|
||||
('月付', 30, 99, 'coming_soon', 20, 'monthly_usdt'),
|
||||
('季付', 90, 259, 'coming_soon', 30, 'quarterly_usdt'),
|
||||
('年付', 365, 899, 'coming_soon', 40, 'yearly_usdt'),
|
||||
])
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_subscription (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
plan_code TEXT NOT NULL,
|
||||
start_at TEXT NOT NULL,
|
||||
end_at TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'active',
|
||||
source TEXT NOT NULL,
|
||||
order_id INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES app_user(id),
|
||||
FOREIGN KEY(plan_code) REFERENCES subscription_plan(code)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_user_subscription_user ON user_subscription(user_id, status, end_at)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS payment_order (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
plan_code TEXT NOT NULL,
|
||||
amount_usdt REAL NOT NULL DEFAULT 0,
|
||||
chain TEXT NOT NULL DEFAULT 'TRC20',
|
||||
pay_address TEXT DEFAULT '',
|
||||
txid TEXT DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL,
|
||||
paid_at TEXT DEFAULT '',
|
||||
expire_at TEXT DEFAULT '',
|
||||
admin_note TEXT DEFAULT '',
|
||||
raw_payload_json TEXT DEFAULT '{}',
|
||||
FOREIGN KEY(user_id) REFERENCES app_user(id),
|
||||
FOREIGN KEY(plan_code) REFERENCES subscription_plan(code)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_payment_order_user ON payment_order(user_id, status)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_payment_order_txid ON payment_order(txid)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_payment_order_created ON payment_order(created_at)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS pending_registration (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL,
|
||||
code_hash TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_pending_reg_email ON pending_registration(email, used_at)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS referral_reward (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
inviter_user_id INTEGER NOT NULL,
|
||||
invitee_user_id INTEGER NOT NULL,
|
||||
order_id INTEGER,
|
||||
reward_type TEXT NOT NULL DEFAULT 'days',
|
||||
reward_days INTEGER DEFAULT 0,
|
||||
reward_amount_usdt REAL DEFAULT 0,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(inviter_user_id) REFERENCES app_user(id),
|
||||
FOREIGN KEY(invitee_user_id) REFERENCES app_user(id),
|
||||
FOREIGN KEY(order_id) REFERENCES payment_order(id)
|
||||
)
|
||||
""")
|
||||
# v1.7.8: 管理模块 — is_admin列(已有表补加) + 用户活动表
|
||||
try:
|
||||
conn.execute("ALTER TABLE app_user ADD COLUMN is_admin INTEGER DEFAULT 0")
|
||||
except Exception:
|
||||
pass
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_activity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
page TEXT DEFAULT '',
|
||||
ip TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES app_user(id)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_ua_user ON user_activity(user_id, created_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_ua_date ON user_activity(created_at)")
|
||||
|
||||
# v1.7.9: 个性化交易助手 — 关注池、观察列表、推送规则
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_watchlist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, symbol),
|
||||
FOREIGN KEY(user_id) REFERENCES app_user(id)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_user_watchlist_user ON user_watchlist(user_id, symbol)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_saved_observation (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
rec_id INTEGER NOT NULL,
|
||||
note TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, rec_id),
|
||||
FOREIGN KEY(user_id) REFERENCES app_user(id)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_saved_obs_user ON user_saved_observation(user_id, rec_id)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_push_rule (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
watchlist_only INTEGER DEFAULT 0,
|
||||
min_score INTEGER DEFAULT 0,
|
||||
min_rr REAL DEFAULT 0,
|
||||
push_buy_now INTEGER DEFAULT 1,
|
||||
push_wait_pullback INTEGER DEFAULT 1,
|
||||
push_observe INTEGER DEFAULT 0,
|
||||
quiet_start TEXT DEFAULT '',
|
||||
quiet_end TEXT DEFAULT '',
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES app_user(id)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
ensure_migrations_once()
|
||||
|
||||
|
||||
def _hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]:
|
||||
@ -360,7 +142,7 @@ def _new_invite_code(conn) -> str:
|
||||
code = secrets.token_urlsafe(8).replace("-", "").replace("_", "")[:10].upper()
|
||||
if len(code) < 8:
|
||||
continue
|
||||
if not conn.execute("SELECT id FROM app_user WHERE invite_code=?", (code,)).fetchone():
|
||||
if not conn.execute("SELECT id FROM app_user WHERE invite_code=%s", (code,)).fetchone():
|
||||
return code
|
||||
raise AuthError("邀请码生成失败,请重试")
|
||||
|
||||
@ -408,14 +190,16 @@ def ensure_default_admin() -> dict:
|
||||
return {"created": False, "reason": "users_exist", "user_count": int(existing_count)}
|
||||
now = _iso(_now())
|
||||
invite_code = _new_invite_code(conn)
|
||||
cur = conn.execute("""
|
||||
row = conn.execute("""
|
||||
INSERT INTO app_user (
|
||||
email, password_hash, password_salt, email_verified, status,
|
||||
invite_code, invited_by_user_id, free_trial_claimed, created_at, updated_at, is_admin
|
||||
) VALUES (?, ?, ?, 1, 'active', ?, NULL, 0, ?, ?, 1)
|
||||
) VALUES (%s, %s, %s, 1, 'active', %s, NULL, 0, %s, %s, 1)
|
||||
RETURNING id
|
||||
""", (email, password_hash, salt, invite_code, now, now))
|
||||
user_id = row.fetchone()["id"]
|
||||
conn.commit()
|
||||
return {"created": True, "email": email, "user_id": cur.lastrowid}
|
||||
return {"created": True, "email": email, "user_id": user_id}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@ -424,7 +208,7 @@ def get_user_by_email(email: str):
|
||||
init_auth_db()
|
||||
email = _normalize_email(email)
|
||||
conn = get_conn()
|
||||
row = conn.execute("SELECT * FROM app_user WHERE email=?", (email,)).fetchone()
|
||||
row = conn.execute("SELECT * FROM app_user WHERE email=%s", (email,)).fetchone()
|
||||
conn.close()
|
||||
return _row_to_dict(row)
|
||||
|
||||
@ -432,7 +216,7 @@ def get_user_by_email(email: str):
|
||||
def get_user_by_id(user_id: int):
|
||||
init_auth_db()
|
||||
conn = get_conn()
|
||||
row = conn.execute("SELECT * FROM app_user WHERE id=?", (user_id,)).fetchone()
|
||||
row = conn.execute("SELECT * FROM app_user WHERE id=%s", (user_id,)).fetchone()
|
||||
conn.close()
|
||||
return _row_to_dict(row)
|
||||
|
||||
@ -446,7 +230,7 @@ def send_registration_code(email: str) -> dict:
|
||||
now = _iso(now_dt)
|
||||
|
||||
# 已注册并验证的用户不允许再发注册验证码
|
||||
existing = conn.execute("SELECT * FROM app_user WHERE email=? AND email_verified=1", (email,)).fetchone()
|
||||
existing = conn.execute("SELECT * FROM app_user WHERE email=%s AND email_verified=1", (email,)).fetchone()
|
||||
if existing:
|
||||
conn.close()
|
||||
raise AuthError("该邮箱已注册并验证,请直接登录")
|
||||
@ -454,7 +238,7 @@ def send_registration_code(email: str) -> dict:
|
||||
# 检查冷却
|
||||
latest = conn.execute("""
|
||||
SELECT * FROM pending_registration
|
||||
WHERE email=? AND used_at=''
|
||||
WHERE email=%s AND used_at=''
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""", (email,)).fetchone()
|
||||
if latest:
|
||||
@ -466,7 +250,7 @@ def send_registration_code(email: str) -> dict:
|
||||
code = _new_verify_code()
|
||||
conn.execute("""
|
||||
INSERT INTO pending_registration (email, code_hash, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""", (email, _hash_token(code), _iso(now_dt + timedelta(minutes=VERIFY_CODE_MINUTES)), now))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@ -491,7 +275,7 @@ def complete_registration(email: str, code: str, password: str, invite_code: str
|
||||
# 从 pending_registration 查找验证码
|
||||
row = conn.execute("""
|
||||
SELECT * FROM pending_registration
|
||||
WHERE email=? AND used_at=''
|
||||
WHERE email=%s AND used_at=''
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""", (email,)).fetchone()
|
||||
if not row:
|
||||
@ -505,10 +289,10 @@ def complete_registration(email: str, code: str, password: str, invite_code: str
|
||||
raise AuthError("验证码错误")
|
||||
|
||||
# 验证码通过 → 标记已使用
|
||||
conn.execute("UPDATE pending_registration SET used_at=? WHERE id=?", (now, row["id"]))
|
||||
conn.execute("UPDATE pending_registration SET used_at=%s WHERE id=%s", (now, row["id"]))
|
||||
|
||||
# 检查是否已有用户记录(旧流程遗留的未验证用户)
|
||||
existing = conn.execute("SELECT * FROM app_user WHERE email=?", (email,)).fetchone()
|
||||
existing = conn.execute("SELECT * FROM app_user WHERE email=%s", (email,)).fetchone()
|
||||
if existing:
|
||||
# 已有用户记录:更新密码、邀请码,激活
|
||||
pw_hash, pw_salt = _hash_password(password)
|
||||
@ -518,17 +302,17 @@ def complete_registration(email: str, code: str, password: str, invite_code: str
|
||||
conn.close()
|
||||
raise AuthError("请输入邀请码")
|
||||
if ic and not inviter_id:
|
||||
inviter = conn.execute("SELECT id FROM app_user WHERE invite_code=?", (ic,)).fetchone()
|
||||
inviter = conn.execute("SELECT id FROM app_user WHERE invite_code=%s", (ic,)).fetchone()
|
||||
if not inviter:
|
||||
conn.close()
|
||||
raise AuthError("邀请码无效")
|
||||
inviter_id = inviter["id"]
|
||||
conn.execute("""
|
||||
UPDATE app_user SET email_verified=1, status='active', password_hash=?, password_salt=?,
|
||||
invited_by_user_id=?, updated_at=? WHERE id=?
|
||||
UPDATE app_user SET email_verified=1, status='active', password_hash=%s, password_salt=%s,
|
||||
invited_by_user_id=%s, updated_at=%s WHERE id=%s
|
||||
""", (pw_hash, pw_salt, inviter_id, now, existing["id"]))
|
||||
conn.commit()
|
||||
fresh = conn.execute("SELECT * FROM app_user WHERE id=?", (existing["id"],)).fetchone()
|
||||
fresh = conn.execute("SELECT * FROM app_user WHERE id=%s", (existing["id"],)).fetchone()
|
||||
conn.close()
|
||||
return _public_user(fresh)
|
||||
|
||||
@ -539,21 +323,23 @@ def complete_registration(email: str, code: str, password: str, invite_code: str
|
||||
if not ic:
|
||||
conn.close()
|
||||
raise AuthError("请输入邀请码")
|
||||
inviter = conn.execute("SELECT id FROM app_user WHERE invite_code=?", (ic,)).fetchone()
|
||||
inviter = conn.execute("SELECT id FROM app_user WHERE invite_code=%s", (ic,)).fetchone()
|
||||
if not inviter:
|
||||
conn.close()
|
||||
raise AuthError("邀请码无效")
|
||||
inviter_id = inviter["id"]
|
||||
|
||||
own_invite_code = _new_invite_code(conn)
|
||||
cur = conn.execute("""
|
||||
row = conn.execute("""
|
||||
INSERT INTO app_user (
|
||||
email, password_hash, password_salt, email_verified, status,
|
||||
invite_code, invited_by_user_id, free_trial_claimed, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 1, 'active', ?, ?, 0, ?, ?)
|
||||
) VALUES (%s, %s, %s, 1, 'active', %s, %s, 0, %s, %s)
|
||||
RETURNING id
|
||||
""", (email, pw_hash, pw_salt, own_invite_code, inviter_id, now, now))
|
||||
user_id = row.fetchone()["id"]
|
||||
conn.commit()
|
||||
fresh = conn.execute("SELECT * FROM app_user WHERE id=?", (cur.lastrowid,)).fetchone()
|
||||
fresh = conn.execute("SELECT * FROM app_user WHERE id=%s", (user_id,)).fetchone()
|
||||
conn.close()
|
||||
return _public_user(fresh)
|
||||
|
||||
@ -568,26 +354,27 @@ def register_user(email: str, password: str, invite_code: str = "") -> dict:
|
||||
inviter_id = None
|
||||
invite_code = (invite_code or "").strip().upper()
|
||||
if invite_code:
|
||||
inviter = conn.execute("SELECT id FROM app_user WHERE invite_code=?", (invite_code,)).fetchone()
|
||||
inviter = conn.execute("SELECT id FROM app_user WHERE invite_code=%s", (invite_code,)).fetchone()
|
||||
if not inviter:
|
||||
raise AuthError("邀请码无效")
|
||||
inviter_id = inviter["id"]
|
||||
|
||||
own_invite_code = _new_invite_code(conn)
|
||||
cur = conn.execute("""
|
||||
row = conn.execute("""
|
||||
INSERT INTO app_user (
|
||||
email, password_hash, password_salt, email_verified, status,
|
||||
invite_code, invited_by_user_id, free_trial_claimed, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 0, 'pending_email_verification', ?, ?, 0, ?, ?)
|
||||
) VALUES (%s, %s, %s, 0, 'pending_email_verification', %s, %s, 0, %s, %s)
|
||||
RETURNING id
|
||||
""", (email, password_hash, salt, own_invite_code, inviter_id, now, now))
|
||||
user_id = cur.lastrowid
|
||||
user_id = row.fetchone()["id"]
|
||||
code = _new_verify_code()
|
||||
conn.execute("""
|
||||
INSERT INTO email_verification_code (user_id, email, code_hash, purpose, expires_at, created_at)
|
||||
VALUES (?, ?, ?, 'register', ?, ?)
|
||||
VALUES (%s, %s, %s, 'register', %s, %s)
|
||||
""", (user_id, email, _hash_token(code), _iso(_now() + timedelta(minutes=VERIFY_CODE_MINUTES)), now))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM app_user WHERE id=?", (user_id,)).fetchone()
|
||||
row = conn.execute("SELECT * FROM app_user WHERE id=%s", (user_id,)).fetchone()
|
||||
public = _public_user(row)
|
||||
email_sent = send_verification_email(email, code) if is_smtp_configured() else False
|
||||
public.update({
|
||||
@ -597,7 +384,7 @@ def register_user(email: str, password: str, invite_code: str = "") -> dict:
|
||||
"invited_by_user_id": inviter_id,
|
||||
})
|
||||
return public
|
||||
except sqlite3.IntegrityError:
|
||||
except IntegrityError:
|
||||
raise AuthError("邮箱已注册")
|
||||
finally:
|
||||
conn.close()
|
||||
@ -612,7 +399,7 @@ def resend_verification_code(email: str) -> dict:
|
||||
now = _iso(now_dt)
|
||||
|
||||
# 已验证用户不需要重发
|
||||
user = conn.execute("SELECT * FROM app_user WHERE email=? AND email_verified=1", (email,)).fetchone()
|
||||
user = conn.execute("SELECT * FROM app_user WHERE email=%s AND email_verified=1", (email,)).fetchone()
|
||||
if user:
|
||||
conn.close()
|
||||
raise AuthError("邮箱已验证,无需重复发送")
|
||||
@ -620,7 +407,7 @@ def resend_verification_code(email: str) -> dict:
|
||||
# 查 pending_registration 冷却
|
||||
latest = conn.execute("""
|
||||
SELECT * FROM pending_registration
|
||||
WHERE email=? AND used_at=''
|
||||
WHERE email=%s AND used_at=''
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""", (email,)).fetchone()
|
||||
if latest:
|
||||
@ -631,7 +418,7 @@ def resend_verification_code(email: str) -> dict:
|
||||
else:
|
||||
legacy_latest = conn.execute("""
|
||||
SELECT * FROM email_verification_code
|
||||
WHERE email=? AND purpose='register' AND used_at=''
|
||||
WHERE email=%s AND purpose='register' AND used_at=''
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""", (email,)).fetchone()
|
||||
if legacy_latest:
|
||||
@ -643,7 +430,7 @@ def resend_verification_code(email: str) -> dict:
|
||||
code = _new_verify_code()
|
||||
conn.execute("""
|
||||
INSERT INTO pending_registration (email, code_hash, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""", (email, _hash_token(code), _iso(now_dt + timedelta(minutes=VERIFY_CODE_MINUTES)), now))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@ -660,7 +447,7 @@ def verify_email(email: str, code: str) -> dict:
|
||||
now = _iso(now_dt)
|
||||
row = conn.execute("""
|
||||
SELECT * FROM email_verification_code
|
||||
WHERE email=? AND purpose='register' AND used_at=''
|
||||
WHERE email=%s AND purpose='register' AND used_at=''
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""", (email,)).fetchone()
|
||||
if not row:
|
||||
@ -673,13 +460,13 @@ def verify_email(email: str, code: str) -> dict:
|
||||
conn.close()
|
||||
raise AuthError("验证码错误")
|
||||
|
||||
conn.execute("UPDATE email_verification_code SET used_at=? WHERE id=?", (now, row["id"]))
|
||||
conn.execute("UPDATE email_verification_code SET used_at=%s WHERE id=%s", (now, row["id"]))
|
||||
conn.execute("""
|
||||
UPDATE app_user SET email_verified=1, status='active', updated_at=?
|
||||
WHERE id=?
|
||||
UPDATE app_user SET email_verified=1, status='active', updated_at=%s
|
||||
WHERE id=%s
|
||||
""", (now, row["user_id"]))
|
||||
conn.commit()
|
||||
user = conn.execute("SELECT * FROM app_user WHERE id=?", (row["user_id"],)).fetchone()
|
||||
user = conn.execute("SELECT * FROM app_user WHERE id=%s", (row["user_id"],)).fetchone()
|
||||
conn.close()
|
||||
return _public_user(user)
|
||||
|
||||
@ -688,7 +475,7 @@ def change_password(user_id: int, old_password: str, new_password: str) -> dict:
|
||||
"""修改密码:先验证旧密码,再更新为新密码。"""
|
||||
init_auth_db()
|
||||
conn = get_conn()
|
||||
user = conn.execute("SELECT * FROM app_user WHERE id=?", (user_id,)).fetchone()
|
||||
user = conn.execute("SELECT * FROM app_user WHERE id=%s", (user_id,)).fetchone()
|
||||
if not user:
|
||||
conn.close()
|
||||
raise AuthError("用户不存在")
|
||||
@ -697,7 +484,7 @@ def change_password(user_id: int, old_password: str, new_password: str) -> dict:
|
||||
raise AuthError("旧密码错误")
|
||||
pw_hash, pw_salt = _hash_password(new_password)
|
||||
now = _iso(_now())
|
||||
conn.execute("UPDATE app_user SET password_hash=?, password_salt=?, updated_at=? WHERE id=?",
|
||||
conn.execute("UPDATE app_user SET password_hash=%s, password_salt=%s, updated_at=%s WHERE id=%s",
|
||||
(pw_hash, pw_salt, now, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@ -711,7 +498,7 @@ def logout_user(token: str):
|
||||
return
|
||||
conn = get_conn()
|
||||
now = _iso(_now())
|
||||
conn.execute("UPDATE user_session SET revoked_at=? WHERE token_hash=?", (now, _hash_token(token)))
|
||||
conn.execute("UPDATE user_session SET revoked_at=%s WHERE token_hash=%s", (now, _hash_token(token)))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@ -720,7 +507,7 @@ def login_user(email: str, password: str) -> dict:
|
||||
init_auth_db()
|
||||
email = _normalize_email(email)
|
||||
conn = get_conn()
|
||||
user = conn.execute("SELECT * FROM app_user WHERE email=?", (email,)).fetchone()
|
||||
user = conn.execute("SELECT * FROM app_user WHERE email=%s", (email,)).fetchone()
|
||||
if not user or not _verify_password(password, user["password_hash"], user["password_salt"]):
|
||||
conn.close()
|
||||
raise AuthError("邮箱或密码错误")
|
||||
@ -735,11 +522,11 @@ def login_user(email: str, password: str) -> dict:
|
||||
now = _now()
|
||||
conn.execute("""
|
||||
INSERT INTO user_session (user_id, token_hash, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""", (user["id"], _hash_token(token), _iso(now), _iso(now + timedelta(days=SESSION_DAYS))))
|
||||
conn.execute("UPDATE app_user SET last_login_at=?, updated_at=? WHERE id=?", (_iso(now), _iso(now), user["id"]))
|
||||
conn.execute("UPDATE app_user SET last_login_at=%s, updated_at=%s WHERE id=%s", (_iso(now), _iso(now), user["id"]))
|
||||
conn.commit()
|
||||
fresh = conn.execute("SELECT * FROM app_user WHERE id=?", (user["id"],)).fetchone()
|
||||
fresh = conn.execute("SELECT * FROM app_user WHERE id=%s", (user["id"],)).fetchone()
|
||||
conn.close()
|
||||
return {"token": token, "user": _public_user(fresh), "expires_at": _iso(now + timedelta(days=SESSION_DAYS))}
|
||||
|
||||
@ -752,7 +539,7 @@ def get_user_by_session_token(token: str):
|
||||
row = conn.execute("""
|
||||
SELECT u.* FROM user_session s
|
||||
JOIN app_user u ON u.id=s.user_id
|
||||
WHERE s.token_hash=? AND s.revoked_at='' AND datetime(s.expires_at) > datetime('now')
|
||||
WHERE s.token_hash=%s AND s.revoked_at='' AND s.expires_at > NOW()::TEXT
|
||||
LIMIT 1
|
||||
""", (_hash_token(token),)).fetchone()
|
||||
conn.close()
|
||||
@ -765,7 +552,7 @@ def claim_free_trial(user_id: int) -> dict:
|
||||
conn = get_conn()
|
||||
now_dt = _now()
|
||||
now = _iso(now_dt)
|
||||
user = conn.execute("SELECT * FROM app_user WHERE id=?", (user_id,)).fetchone()
|
||||
user = conn.execute("SELECT * FROM app_user WHERE id=%s", (user_id,)).fetchone()
|
||||
if not user:
|
||||
conn.close()
|
||||
raise AuthError("用户不存在")
|
||||
@ -778,20 +565,23 @@ def claim_free_trial(user_id: int) -> dict:
|
||||
|
||||
start = now_dt
|
||||
end = now_dt + timedelta(days=FREE_TRIAL_DAYS)
|
||||
order_cur = conn.execute("""
|
||||
order_row = conn.execute("""
|
||||
INSERT INTO payment_order (
|
||||
user_id, plan_code, amount_usdt, chain, pay_address, txid,
|
||||
status, created_at, paid_at, expire_at, admin_note, raw_payload_json
|
||||
) VALUES (?, 'free_trial_1m', 0, 'SYSTEM', '', '', 'paid', ?, ?, ?, '免费体验自动开通', '{}')
|
||||
) VALUES (%s, 'free_trial_1m', 0, 'SYSTEM', '', '', 'paid', %s, %s, %s, '免费体验自动开通', '{}')
|
||||
RETURNING id
|
||||
""", (user_id, now, now, _iso(end)))
|
||||
order_id = order_cur.lastrowid
|
||||
sub_cur = conn.execute("""
|
||||
order_id = order_row.fetchone()["id"]
|
||||
sub_row = conn.execute("""
|
||||
INSERT INTO user_subscription (user_id, plan_code, start_at, end_at, status, source, order_id, created_at, updated_at)
|
||||
VALUES (?, 'free_trial_1m', ?, ?, 'active', 'free_trial', ?, ?, ?)
|
||||
VALUES (%s, 'free_trial_1m', %s, %s, 'active', 'free_trial', %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (user_id, _iso(start), _iso(end), order_id, now, now))
|
||||
conn.execute("UPDATE app_user SET free_trial_claimed=1, updated_at=? WHERE id=?", (now, user_id))
|
||||
sub_id = sub_row.fetchone()["id"]
|
||||
conn.execute("UPDATE app_user SET free_trial_claimed=1, updated_at=%s WHERE id=%s", (now, user_id))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM user_subscription WHERE id=?", (sub_cur.lastrowid,)).fetchone()
|
||||
row = conn.execute("SELECT * FROM user_subscription WHERE id=%s", (sub_id,)).fetchone()
|
||||
conn.close()
|
||||
return _row_to_dict(row)
|
||||
|
||||
@ -800,8 +590,8 @@ def get_current_subscription(user_id: int):
|
||||
conn = get_conn()
|
||||
row = conn.execute("""
|
||||
SELECT * FROM user_subscription
|
||||
WHERE user_id=? AND status='active' AND datetime(end_at) > datetime('now')
|
||||
ORDER BY datetime(end_at) DESC LIMIT 1
|
||||
WHERE user_id=%s AND status='active' AND end_at > NOW()::TEXT
|
||||
ORDER BY end_at::timestamp DESC LIMIT 1
|
||||
""", (user_id,)).fetchone()
|
||||
conn.close()
|
||||
return _row_to_dict(row)
|
||||
@ -809,10 +599,7 @@ def get_current_subscription(user_id: int):
|
||||
|
||||
def get_table_columns(table_name: str) -> set[str]:
|
||||
init_auth_db()
|
||||
conn = get_conn()
|
||||
rows = conn.execute(f"PRAGMA table_info({table_name})").fetchall()
|
||||
conn.close()
|
||||
return {r[1] for r in rows}
|
||||
return table_columns(table_name)
|
||||
|
||||
|
||||
# ====== ADMIN ====== (v1.7.8)
|
||||
@ -820,7 +607,7 @@ def get_table_columns(table_name: str) -> set[str]:
|
||||
def set_user_admin(email: str, is_admin: bool = True):
|
||||
"""设置用户为管理员(仅开发者手动调用)"""
|
||||
conn = get_conn()
|
||||
conn.execute("UPDATE app_user SET is_admin=? WHERE email=?", (1 if is_admin else 0, email))
|
||||
conn.execute("UPDATE app_user SET is_admin=%s WHERE email=%s", (1 if is_admin else 0, email))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return True
|
||||
@ -828,7 +615,7 @@ def set_user_admin(email: str, is_admin: bool = True):
|
||||
|
||||
def is_user_admin(user_id: int) -> bool:
|
||||
conn = get_conn()
|
||||
row = conn.execute("SELECT is_admin FROM app_user WHERE id=?", (user_id,)).fetchone()
|
||||
row = conn.execute("SELECT is_admin FROM app_user WHERE id=%s", (user_id,)).fetchone()
|
||||
conn.close()
|
||||
return bool(row and row[0])
|
||||
|
||||
@ -838,7 +625,7 @@ def log_user_activity(user_id: int, action: str, page: str = "", ip: str = ""):
|
||||
try:
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
"INSERT INTO user_activity (user_id, action, page, ip, created_at) VALUES (?,?,?,?,?)",
|
||||
"INSERT INTO user_activity (user_id, action, page, ip, created_at) VALUES (%s,%s,%s,%s,%s)",
|
||||
(user_id, action, page, ip, _iso(_now()))
|
||||
)
|
||||
conn.commit()
|
||||
@ -878,34 +665,34 @@ def get_admin_stats():
|
||||
|
||||
total_users = conn.execute("SELECT COUNT(*) FROM app_user").fetchone()[0]
|
||||
new_users_today = conn.execute(
|
||||
"SELECT COUNT(*) FROM app_user WHERE created_at LIKE ?",
|
||||
"SELECT COUNT(*) FROM app_user WHERE created_at LIKE %s",
|
||||
(today + "%",)
|
||||
).fetchone()[0]
|
||||
|
||||
# PV:page_view 事件数量,不去重。
|
||||
pv_total = conn.execute("SELECT COUNT(*) FROM user_activity WHERE action='page_view'").fetchone()[0]
|
||||
pv_today = conn.execute(
|
||||
"SELECT COUNT(*) FROM user_activity WHERE action='page_view' AND created_at LIKE ?",
|
||||
"SELECT COUNT(*) FROM user_activity WHERE action='page_view' AND created_at LIKE %s",
|
||||
(today + "%",)
|
||||
).fetchone()[0]
|
||||
week_ago = (_now() - timedelta(days=7)).strftime("%Y-%m-%d")
|
||||
pv_7d = conn.execute(
|
||||
"SELECT COUNT(*) FROM user_activity WHERE action='page_view' AND created_at >= ?",
|
||||
"SELECT COUNT(*) FROM user_activity WHERE action='page_view' AND created_at >= %s",
|
||||
(week_ago,)
|
||||
).fetchone()[0]
|
||||
|
||||
# DAU/WAU:去重用户数,保留为辅助指标。
|
||||
dau = conn.execute(
|
||||
"SELECT COUNT(DISTINCT user_id) FROM user_activity WHERE created_at LIKE ?",
|
||||
"SELECT COUNT(DISTINCT user_id) FROM user_activity WHERE created_at LIKE %s",
|
||||
(today + "%",)
|
||||
).fetchone()[0]
|
||||
wau = conn.execute(
|
||||
"SELECT COUNT(DISTINCT user_id) FROM user_activity WHERE created_at >= ?",
|
||||
"SELECT COUNT(DISTINCT user_id) FROM user_activity WHERE created_at >= %s",
|
||||
(week_ago,)
|
||||
).fetchone()[0]
|
||||
|
||||
active_subs = conn.execute(
|
||||
"SELECT COUNT(*) FROM user_subscription WHERE status='active' AND datetime(end_at) > datetime('now')"
|
||||
"SELECT COUNT(*) FROM user_subscription WHERE status='active' AND end_at > NOW()::TEXT"
|
||||
).fetchone()[0]
|
||||
total_orders = conn.execute("SELECT COUNT(*) FROM payment_order").fetchone()[0]
|
||||
paid_orders = conn.execute("SELECT COUNT(*) FROM payment_order WHERE status IN ('paid','confirmed')").fetchone()[0]
|
||||
@ -914,13 +701,13 @@ def get_admin_stats():
|
||||
pv_trend = conn.execute("""
|
||||
SELECT substr(created_at, 1, 10) as day, COUNT(*) as cnt
|
||||
FROM user_activity
|
||||
WHERE action='page_view' AND created_at >= ?
|
||||
WHERE action='page_view' AND created_at >= %s
|
||||
GROUP BY day ORDER BY day
|
||||
""", (thirty_ago,)).fetchall()
|
||||
dau_trend = conn.execute("""
|
||||
SELECT substr(created_at, 1, 10) as day, COUNT(DISTINCT user_id) as cnt
|
||||
FROM user_activity
|
||||
WHERE created_at >= ?
|
||||
WHERE created_at >= %s
|
||||
GROUP BY day ORDER BY day
|
||||
""", (thirty_ago,)).fetchall()
|
||||
|
||||
@ -928,7 +715,7 @@ def get_admin_stats():
|
||||
SELECT DISTINCT u.id, u.email, u.created_at, u.last_login_at
|
||||
FROM user_activity ua
|
||||
JOIN app_user u ON u.id = ua.user_id
|
||||
WHERE ua.created_at LIKE ?
|
||||
WHERE ua.created_at LIKE %s
|
||||
ORDER BY ua.created_at DESC LIMIT 20
|
||||
""", (today + "%",)).fetchall()
|
||||
conn.close()
|
||||
@ -966,11 +753,11 @@ def get_admin_users(search: str = "", offset: int = 0, limit: int = 50, tab: str
|
||||
tab_where = "WHERE (u.email_verified = 0 OR u.status = 'pending_email_verification')"
|
||||
elif tab == "today_active":
|
||||
today = _now().strftime("%Y-%m-%d")
|
||||
tab_where = "WHERE u.id IN (SELECT DISTINCT user_id FROM user_activity WHERE created_at LIKE ?)"
|
||||
tab_where = "WHERE u.id IN (SELECT DISTINCT user_id FROM user_activity WHERE created_at LIKE %s)"
|
||||
tab_params = [today + "%"]
|
||||
|
||||
if search:
|
||||
search_clause = "u.email LIKE ?" if not tab_where else " AND u.email LIKE ?"
|
||||
search_clause = "u.email LIKE %s" if not tab_where else " AND u.email LIKE %s"
|
||||
where = tab_where + search_clause
|
||||
params = tab_params + [f"%{search}%"]
|
||||
else:
|
||||
@ -983,8 +770,8 @@ def get_admin_users(search: str = "", offset: int = 0, limit: int = 50, tab: str
|
||||
SELECT us2.id FROM user_subscription us2
|
||||
WHERE us2.user_id = u.id
|
||||
ORDER BY
|
||||
CASE WHEN us2.status='active' AND datetime(us2.end_at) > datetime('now') THEN 0 ELSE 1 END,
|
||||
datetime(us2.end_at) DESC,
|
||||
CASE WHEN us2.status='active' AND us2.end_at > NOW()::TEXT THEN 0 ELSE 1 END,
|
||||
us2.end_at::timestamp DESC,
|
||||
us2.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
@ -993,7 +780,7 @@ def get_admin_users(search: str = "", offset: int = 0, limit: int = 50, tab: str
|
||||
LEFT JOIN payment_order lo ON lo.id = (
|
||||
SELECT po2.id FROM payment_order po2
|
||||
WHERE po2.user_id = u.id
|
||||
ORDER BY datetime(po2.created_at) DESC, po2.id DESC
|
||||
ORDER BY po2.created_at::timestamp DESC, po2.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
"""
|
||||
@ -1007,7 +794,7 @@ def get_admin_users(search: str = "", offset: int = 0, limit: int = 50, tab: str
|
||||
(SELECT COUNT(*) FROM payment_order po3 WHERE po3.user_id = u.id) AS order_count
|
||||
"""
|
||||
rows = conn.execute(
|
||||
f"{select_cols} {base_from} {where} ORDER BY u.id DESC LIMIT ? OFFSET ?",
|
||||
f"{select_cols} {base_from} {where} ORDER BY u.id DESC LIMIT %s OFFSET %s",
|
||||
(*params, limit, offset)
|
||||
).fetchall()
|
||||
total = conn.execute(f"SELECT COUNT(*) FROM app_user u {where}", (*params,)).fetchone()[0]
|
||||
@ -1056,10 +843,10 @@ def get_admin_orders(search: str = "", offset: int = 0, limit: int = 50, status:
|
||||
params = []
|
||||
status = (status or "all").strip()
|
||||
if status and status != "all":
|
||||
where_parts.append("po.status = ?")
|
||||
where_parts.append("po.status = %s")
|
||||
params.append(status)
|
||||
if search:
|
||||
where_parts.append("(u.email LIKE ? OR po.txid LIKE ? OR CAST(po.id AS TEXT) = ?)")
|
||||
where_parts.append("(u.email LIKE %s OR po.txid LIKE %s OR CAST(po.id AS TEXT) = %s)")
|
||||
params.extend([f"%{search}%", f"%{search}%", str(search).lstrip("#")])
|
||||
where = ("WHERE " + " AND ".join(where_parts)) if where_parts else ""
|
||||
limit = max(1, min(int(limit or 50), 200))
|
||||
@ -1076,8 +863,8 @@ def get_admin_orders(search: str = "", offset: int = 0, limit: int = 50, status:
|
||||
LEFT JOIN subscription_plan sp ON sp.code = po.plan_code
|
||||
LEFT JOIN user_subscription us ON us.order_id = po.id
|
||||
{where}
|
||||
ORDER BY datetime(po.created_at) DESC, po.id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
ORDER BY po.created_at::timestamp DESC, po.id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", (*params, limit, offset)).fetchall()
|
||||
total = conn.execute(f"""
|
||||
SELECT COUNT(*)
|
||||
@ -1122,17 +909,17 @@ def get_referral_stats(user_id: int):
|
||||
conn = get_conn()
|
||||
# 被邀请人数
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM app_user WHERE invited_by_user_id = ?", (user_id,)
|
||||
"SELECT COUNT(*) FROM app_user WHERE invited_by_user_id = %s", (user_id,)
|
||||
).fetchone()[0]
|
||||
# 已注册(至少验证了邮箱或状态为active)
|
||||
registered = conn.execute(
|
||||
"SELECT COUNT(*) FROM app_user WHERE invited_by_user_id = ? AND (email_verified = 1 OR status = 'active')",
|
||||
"SELECT COUNT(*) FROM app_user WHERE invited_by_user_id = %s AND (email_verified = 1 OR status = 'active')",
|
||||
(user_id,)
|
||||
).fetchone()[0]
|
||||
# 被邀请人列表
|
||||
rows = conn.execute(
|
||||
"SELECT email, email_verified, status, created_at "
|
||||
"FROM app_user WHERE invited_by_user_id = ? ORDER BY created_at DESC LIMIT 50",
|
||||
"FROM app_user WHERE invited_by_user_id = %s ORDER BY created_at DESC LIMIT 50",
|
||||
(user_id,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
@ -1164,7 +951,7 @@ def add_watchlist_symbol(user_id: int, symbol: str):
|
||||
return False
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO user_watchlist (user_id, symbol, created_at) VALUES (?, ?, ?)",
|
||||
"INSERT INTO user_watchlist (user_id, symbol, created_at) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
|
||||
(user_id, symbol, _iso(_now()))
|
||||
)
|
||||
conn.commit()
|
||||
@ -1176,7 +963,7 @@ def remove_watchlist_symbol(user_id: int, symbol: str):
|
||||
init_auth_db()
|
||||
symbol = _normalize_symbol(symbol)
|
||||
conn = get_conn()
|
||||
conn.execute("DELETE FROM user_watchlist WHERE user_id=? AND symbol=?", (user_id, symbol))
|
||||
conn.execute("DELETE FROM user_watchlist WHERE user_id=%s AND symbol=%s", (user_id, symbol))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return True
|
||||
@ -1185,7 +972,7 @@ def remove_watchlist_symbol(user_id: int, symbol: str):
|
||||
def get_watchlist_symbols(user_id: int):
|
||||
init_auth_db()
|
||||
conn = get_conn()
|
||||
rows = conn.execute("SELECT symbol FROM user_watchlist WHERE user_id=? ORDER BY symbol", (user_id,)).fetchall()
|
||||
rows = conn.execute("SELECT symbol FROM user_watchlist WHERE user_id=%s ORDER BY symbol", (user_id,)).fetchall()
|
||||
conn.close()
|
||||
return [r["symbol"] for r in rows]
|
||||
|
||||
@ -1196,7 +983,7 @@ def save_observation(user_id: int, rec_id: int, note: str = ""):
|
||||
conn = get_conn()
|
||||
conn.execute("""
|
||||
INSERT INTO user_saved_observation (user_id, rec_id, note, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT(user_id, rec_id) DO UPDATE SET note=excluded.note, updated_at=excluded.updated_at
|
||||
""", (user_id, int(rec_id), (note or "")[:200], now, now))
|
||||
conn.commit()
|
||||
@ -1207,7 +994,7 @@ def save_observation(user_id: int, rec_id: int, note: str = ""):
|
||||
def remove_observation(user_id: int, rec_id: int):
|
||||
init_auth_db()
|
||||
conn = get_conn()
|
||||
conn.execute("DELETE FROM user_saved_observation WHERE user_id=? AND rec_id=?", (user_id, int(rec_id)))
|
||||
conn.execute("DELETE FROM user_saved_observation WHERE user_id=%s AND rec_id=%s", (user_id, int(rec_id)))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return True
|
||||
@ -1219,7 +1006,7 @@ def get_saved_observations(user_id: int):
|
||||
rows = conn.execute("""
|
||||
SELECT id, user_id, rec_id, note, created_at, updated_at
|
||||
FROM user_saved_observation
|
||||
WHERE user_id=?
|
||||
WHERE user_id=%s
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
""", (user_id,)).fetchall()
|
||||
conn.close()
|
||||
@ -1241,7 +1028,7 @@ _DEFAULT_PUSH_RULES = {
|
||||
def get_push_rules(user_id: int):
|
||||
init_auth_db()
|
||||
conn = get_conn()
|
||||
row = conn.execute("SELECT * FROM user_push_rule WHERE user_id=?", (user_id,)).fetchone()
|
||||
row = conn.execute("SELECT * FROM user_push_rule WHERE user_id=%s", (user_id,)).fetchone()
|
||||
conn.close()
|
||||
rules = dict(_DEFAULT_PUSH_RULES)
|
||||
if row:
|
||||
@ -1269,7 +1056,7 @@ def update_push_rules(user_id: int, rules: dict):
|
||||
INSERT INTO user_push_rule (
|
||||
user_id, watchlist_only, min_score, min_rr, push_buy_now,
|
||||
push_wait_pullback, push_observe, quiet_start, quiet_end, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
watchlist_only=excluded.watchlist_only,
|
||||
min_score=excluded.min_score,
|
||||
|
||||
@ -80,10 +80,10 @@ def _load_content(row):
|
||||
|
||||
def get_cached_insight(target_type, target_id, insight_type, input_hash=None, success_only=True):
|
||||
conn = get_conn()
|
||||
where = "target_type=? AND target_id=? AND insight_type=?"
|
||||
where = "target_type=%s AND target_id=%s AND insight_type=%s"
|
||||
params = [str(target_type), str(target_id), str(insight_type)]
|
||||
if input_hash:
|
||||
where += " AND input_hash=?"
|
||||
where += " AND input_hash=%s"
|
||||
params.append(str(input_hash))
|
||||
if success_only:
|
||||
where += " AND status='success'"
|
||||
@ -125,7 +125,7 @@ def upsert_insight(
|
||||
INSERT INTO llm_insights (
|
||||
target_type, target_id, insight_type, prompt_version, input_hash,
|
||||
status, input_json, content_json, error, model, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT(target_type, target_id, insight_type, input_hash) DO UPDATE SET
|
||||
prompt_version=excluded.prompt_version,
|
||||
status=excluded.status,
|
||||
@ -154,7 +154,7 @@ def upsert_insight(
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM llm_insights
|
||||
WHERE target_type=? AND target_id=? AND insight_type=? AND input_hash=?
|
||||
WHERE target_type=%s AND target_id=%s AND insight_type=%s AND input_hash=%s
|
||||
""",
|
||||
(str(target_type), str(target_id), str(insight_type), str(input_hash)),
|
||||
).fetchone()
|
||||
@ -166,12 +166,12 @@ def get_insights_for_targets(target_type, target_ids, insight_type):
|
||||
ids = [str(x) for x in (target_ids or []) if str(x or "").strip()]
|
||||
if not ids:
|
||||
return {}
|
||||
placeholders = ",".join(["?"] * len(ids))
|
||||
placeholders = ",".join(["%s"] * len(ids))
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT * FROM llm_insights
|
||||
WHERE target_type=? AND insight_type=? AND status='success'
|
||||
WHERE target_type=%s AND insight_type=%s AND status='success'
|
||||
AND target_id IN ({placeholders})
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
""",
|
||||
@ -191,7 +191,7 @@ def get_latest_insight_by_type(target_type, insight_type, success_only=True):
|
||||
row = conn.execute(
|
||||
f"""
|
||||
SELECT * FROM llm_insights
|
||||
WHERE target_type=? AND insight_type=? {status_clause}
|
||||
WHERE target_type=%s AND insight_type=%s {status_clause}
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
@ -213,13 +213,13 @@ def list_llm_insights(limit=50, offset=0, target_type="", status="", insight_typ
|
||||
where = []
|
||||
params = []
|
||||
if target_type:
|
||||
where.append("target_type=?")
|
||||
where.append("target_type=%s")
|
||||
params.append(str(target_type))
|
||||
if status:
|
||||
where.append("status=?")
|
||||
where.append("status=%s")
|
||||
params.append(str(status))
|
||||
if insight_type:
|
||||
where.append("insight_type=?")
|
||||
where.append("insight_type=%s")
|
||||
params.append(str(insight_type))
|
||||
clause = ("WHERE " + " AND ".join(where)) if where else ""
|
||||
conn = get_conn()
|
||||
@ -229,7 +229,7 @@ def list_llm_insights(limit=50, offset=0, target_type="", status="", insight_typ
|
||||
SELECT * FROM llm_insights
|
||||
{clause}
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
tuple(params + [limit, offset]),
|
||||
).fetchall()
|
||||
@ -245,7 +245,7 @@ def list_llm_insights(limit=50, offset=0, target_type="", status="", insight_typ
|
||||
|
||||
def get_llm_insight_by_id(insight_id):
|
||||
conn = get_conn()
|
||||
row = conn.execute("SELECT * FROM llm_insights WHERE id=?", (int(insight_id or 0),)).fetchone()
|
||||
row = conn.execute("SELECT * FROM llm_insights WHERE id=%s", (int(insight_id or 0),)).fetchone()
|
||||
conn.close()
|
||||
return _load_content(row) if row else None
|
||||
|
||||
|
||||
589
app/db/migrations/0001_initial_postgres.sql
Normal file
589
app/db/migrations/0001_initial_postgres.sql
Normal file
@ -0,0 +1,589 @@
|
||||
-- AlphaX Agent | Crypto PostgreSQL initial schema.
|
||||
-- Keep column names compatible with the current SQLite data model.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coin_state (
|
||||
symbol TEXT PRIMARY KEY,
|
||||
state TEXT NOT NULL DEFAULT '蓄力',
|
||||
score INTEGER DEFAULT 0,
|
||||
anomaly_type TEXT DEFAULT '',
|
||||
sector TEXT DEFAULT '',
|
||||
leader_status TEXT DEFAULT '',
|
||||
detected_at TEXT NOT NULL,
|
||||
last_alert_time TEXT DEFAULT '',
|
||||
last_alert_level TEXT DEFAULT '',
|
||||
detail_json TEXT DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS screening_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
scan_time TEXT NOT NULL,
|
||||
layer TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
score INTEGER DEFAULT 0,
|
||||
price DOUBLE PRECISION NOT NULL,
|
||||
signals TEXT DEFAULT '',
|
||||
sector TEXT DEFAULT '',
|
||||
leader_status TEXT DEFAULT '',
|
||||
is_meme INTEGER DEFAULT 0,
|
||||
change_24h DOUBLE PRECISION DEFAULT 0,
|
||||
funding_rate DOUBLE PRECISION DEFAULT 0,
|
||||
detail_json TEXT DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_screening_log_time ON screening_log(scan_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_screening_log_layer_time ON screening_log(layer, scan_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_screening_log_symbol_time ON screening_log(symbol, scan_time DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recommendation (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
symbol TEXT NOT NULL,
|
||||
rec_time TEXT NOT NULL,
|
||||
rec_state TEXT NOT NULL,
|
||||
rec_score INTEGER DEFAULT 0,
|
||||
entry_price DOUBLE PRECISION NOT NULL,
|
||||
stop_loss DOUBLE PRECISION DEFAULT 0,
|
||||
tp1 DOUBLE PRECISION DEFAULT 0,
|
||||
tp2 DOUBLE PRECISION DEFAULT 0,
|
||||
sector TEXT DEFAULT '',
|
||||
signals TEXT DEFAULT '',
|
||||
is_meme INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'active',
|
||||
current_price DOUBLE PRECISION DEFAULT 0,
|
||||
max_price DOUBLE PRECISION DEFAULT 0,
|
||||
min_price DOUBLE PRECISION DEFAULT 0,
|
||||
pnl_pct DOUBLE PRECISION DEFAULT 0,
|
||||
max_pnl_pct DOUBLE PRECISION DEFAULT 0,
|
||||
max_drawdown_pct DOUBLE PRECISION DEFAULT 0,
|
||||
hit_tp1_time TEXT DEFAULT '',
|
||||
hit_tp2_time TEXT DEFAULT '',
|
||||
stopped_out_time TEXT DEFAULT '',
|
||||
expired_time TEXT DEFAULT '',
|
||||
last_track_time TEXT DEFAULT '',
|
||||
entry_plan_json TEXT DEFAULT '{}',
|
||||
action_status TEXT DEFAULT '持有',
|
||||
strategy_version TEXT DEFAULT '',
|
||||
direction TEXT DEFAULT '中性',
|
||||
force_reason TEXT DEFAULT '',
|
||||
base_state TEXT DEFAULT '',
|
||||
sector_signal_count INTEGER DEFAULT 0,
|
||||
market_context_json TEXT DEFAULT '{}',
|
||||
derivatives_context_json TEXT DEFAULT '{}',
|
||||
sector_context_json TEXT DEFAULT '{}',
|
||||
lifecycle_state TEXT DEFAULT 'watching',
|
||||
display_bucket TEXT DEFAULT 'watch_pool',
|
||||
execution_status TEXT DEFAULT 'observe',
|
||||
state_reason TEXT DEFAULT '',
|
||||
entry_triggered INTEGER DEFAULT 0,
|
||||
archived_at TEXT DEFAULT '',
|
||||
signal_codes_json TEXT DEFAULT '[]',
|
||||
signal_labels_json TEXT DEFAULT '[]'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_active_symbol_bucket ON recommendation(symbol, status, display_bucket, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_display_bucket_time ON recommendation(display_bucket, rec_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_symbol_time ON recommendation(symbol, rec_time DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS price_tracking (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
rec_id BIGINT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
track_time TEXT NOT NULL,
|
||||
price DOUBLE PRECISION NOT NULL,
|
||||
pnl_pct DOUBLE PRECISION DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_price_tracking_rec_id_id ON price_tracking(rec_id, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_price_tracking_rec_time ON price_tracking(rec_id, track_time DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS latest_price_cache (
|
||||
symbol TEXT PRIMARY KEY,
|
||||
price DOUBLE PRECISION NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
source TEXT DEFAULT 'tracker'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_latest_price_cache_updated_at ON latest_price_cache(updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cron_run_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
job_name TEXT NOT NULL,
|
||||
script_name TEXT NOT NULL,
|
||||
run_status TEXT NOT NULL,
|
||||
result_status TEXT DEFAULT '',
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT NOT NULL,
|
||||
duration_ms INTEGER DEFAULT 0,
|
||||
summary_json TEXT DEFAULT '{}',
|
||||
error_message TEXT DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cron_run_log_job_started ON cron_run_log(job_name, started_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS signal_performance (
|
||||
signal_type TEXT PRIMARY KEY,
|
||||
category TEXT DEFAULT '',
|
||||
total_count INTEGER DEFAULT 0,
|
||||
hit_count INTEGER DEFAULT 0,
|
||||
miss_count INTEGER DEFAULT 0,
|
||||
hit_rate DOUBLE PRECISION DEFAULT 0,
|
||||
avg_pnl DOUBLE PRECISION DEFAULT 0,
|
||||
weight DOUBLE PRECISION DEFAULT 1.0,
|
||||
last_updated TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS review_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
rec_id BIGINT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
review_time TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
pnl_48h DOUBLE PRECISION DEFAULT 0,
|
||||
max_pnl_48h DOUBLE PRECISION DEFAULT 0,
|
||||
triggered_signals TEXT DEFAULT '',
|
||||
hit_signals TEXT DEFAULT '',
|
||||
miss_signals TEXT DEFAULT '',
|
||||
lesson TEXT DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_review_log_rec_id ON review_log(rec_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_review_log_time ON review_log(review_time DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS missed_explosions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
symbol TEXT NOT NULL,
|
||||
detect_time TEXT NOT NULL,
|
||||
price_at_detect DOUBLE PRECISION DEFAULT 0,
|
||||
price_before DOUBLE PRECISION DEFAULT 0,
|
||||
gain_pct DOUBLE PRECISION DEFAULT 0,
|
||||
reason_missed TEXT DEFAULT '',
|
||||
features_detected TEXT DEFAULT '',
|
||||
lesson TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS strategy_iteration_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
run_date TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
trigger_source TEXT DEFAULT 'daily_review',
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT DEFAULT '',
|
||||
findings_json TEXT DEFAULT '[]',
|
||||
problems_json TEXT DEFAULT '[]',
|
||||
actions_json TEXT DEFAULT '[]',
|
||||
changed_rules_json TEXT DEFAULT '[]',
|
||||
metrics_json TEXT DEFAULT '{}',
|
||||
related_symbols_json TEXT DEFAULT '[]',
|
||||
config_diff_json TEXT DEFAULT '{}',
|
||||
effect_summary_json TEXT DEFAULT '{}',
|
||||
pollution_summary_json TEXT DEFAULT '{}',
|
||||
strategy_version TEXT DEFAULT '',
|
||||
version_change_summary TEXT DEFAULT '',
|
||||
success_analysis_json TEXT DEFAULT '{}',
|
||||
failure_analysis_json TEXT DEFAULT '{}',
|
||||
candidate_rules_json TEXT DEFAULT '[]',
|
||||
release_decision TEXT DEFAULT '',
|
||||
release_reason TEXT DEFAULT '',
|
||||
confidence_level TEXT DEFAULT '',
|
||||
promotion_state TEXT DEFAULT 'research_only'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS strategy_rule_candidate (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
source TEXT DEFAULT '',
|
||||
rule_type TEXT DEFAULT '',
|
||||
signal_name TEXT DEFAULT '',
|
||||
rule_description TEXT DEFAULT '',
|
||||
support_count INTEGER DEFAULT 0,
|
||||
success_count INTEGER DEFAULT 0,
|
||||
fail_count INTEGER DEFAULT 0,
|
||||
avg_pnl DOUBLE PRECISION DEFAULT 0,
|
||||
max_gain DOUBLE PRECISION DEFAULT 0,
|
||||
max_drawdown DOUBLE PRECISION DEFAULT 0,
|
||||
confidence_score DOUBLE PRECISION DEFAULT 0,
|
||||
sample_size INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'candidate',
|
||||
release_version TEXT DEFAULT '',
|
||||
notes TEXT DEFAULT '',
|
||||
source_ref TEXT DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rule_candidate_status ON strategy_rule_candidate(status, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS strategy_failure_pattern (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
version TEXT DEFAULT '',
|
||||
failure_type TEXT DEFAULT '',
|
||||
failure_reason TEXT DEFAULT '',
|
||||
signal_combo TEXT DEFAULT '[]',
|
||||
market_context_json TEXT DEFAULT '{}',
|
||||
entry_quality_issue TEXT DEFAULT '',
|
||||
pnl_pct DOUBLE PRECISION DEFAULT 0,
|
||||
max_drawdown_pct DOUBLE PRECISION DEFAULT 0,
|
||||
lesson TEXT DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_failure_pattern_type ON strategy_failure_pattern(failure_type, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
symbol TEXT NOT NULL,
|
||||
push_type TEXT NOT NULL,
|
||||
action_status TEXT DEFAULT '',
|
||||
pushed_at TEXT NOT NULL,
|
||||
rec_id BIGINT DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_lookup ON push_log(symbol, push_type, pushed_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_log_rec_action ON push_log(rec_id, push_type, action_status, pushed_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sentiment_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
symbol TEXT NOT NULL,
|
||||
name TEXT DEFAULT '',
|
||||
source TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
trend_rank INTEGER DEFAULT 0,
|
||||
trend_score INTEGER DEFAULT 0,
|
||||
market_cap_rank INTEGER DEFAULT 0,
|
||||
extra_json TEXT DEFAULT '{}',
|
||||
detected_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sentiment_lookup ON sentiment_events(symbol, source, detected_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS llm_insights (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
insight_type TEXT NOT NULL,
|
||||
prompt_version TEXT NOT NULL,
|
||||
input_hash TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
input_json TEXT DEFAULT '{}',
|
||||
content_json TEXT DEFAULT '{}',
|
||||
error TEXT DEFAULT '',
|
||||
model TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_insights_unique
|
||||
ON llm_insights(target_type, target_id, insight_type, input_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_insights_lookup
|
||||
ON llm_insights(target_type, target_id, insight_type, status, updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_news (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_hash TEXT UNIQUE,
|
||||
source TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT DEFAULT '',
|
||||
published_at TEXT NOT NULL,
|
||||
detected_at TEXT NOT NULL,
|
||||
importance TEXT DEFAULT 'B',
|
||||
event_type TEXT DEFAULT '',
|
||||
raw_json TEXT DEFAULT '{}',
|
||||
processed INTEGER DEFAULT 0,
|
||||
decision TEXT DEFAULT '',
|
||||
tech_score INTEGER DEFAULT 0,
|
||||
rec_id BIGINT DEFAULT 0,
|
||||
pushed INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_news_time ON event_news(published_at, detected_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_news_symbol ON event_news(symbol);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS onchain_token_map (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
symbol TEXT NOT NULL,
|
||||
chain TEXT NOT NULL,
|
||||
contract_address TEXT NOT NULL,
|
||||
source TEXT DEFAULT '',
|
||||
confidence INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
raw_json TEXT DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(symbol, chain, contract_address)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_onchain_token_map_symbol ON onchain_token_map(symbol, confidence, is_active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS onchain_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_hash TEXT UNIQUE,
|
||||
chain TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
contract_address TEXT DEFAULT '',
|
||||
event_type TEXT NOT NULL,
|
||||
signal_code TEXT NOT NULL,
|
||||
signal_label TEXT DEFAULT '',
|
||||
direction TEXT DEFAULT 'neutral',
|
||||
value_usd DOUBLE PRECISION DEFAULT 0,
|
||||
amount DOUBLE PRECISION DEFAULT 0,
|
||||
tx_hash TEXT DEFAULT '',
|
||||
wallet_address TEXT DEFAULT '',
|
||||
wallet_label TEXT DEFAULT '',
|
||||
counterparty_label TEXT DEFAULT '',
|
||||
confidence INTEGER DEFAULT 0,
|
||||
severity TEXT DEFAULT 'B',
|
||||
status TEXT DEFAULT 'new',
|
||||
detected_at TEXT NOT NULL,
|
||||
source TEXT DEFAULT '',
|
||||
url TEXT DEFAULT '',
|
||||
raw_json TEXT DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_onchain_events_time ON onchain_events(detected_at, signal_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_onchain_events_symbol ON onchain_events(symbol, detected_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_onchain_events_chain ON onchain_events(chain, detected_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS onchain_token_metrics (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
symbol TEXT NOT NULL,
|
||||
chain TEXT NOT NULL,
|
||||
contract_address TEXT DEFAULT '',
|
||||
"window" TEXT NOT NULL,
|
||||
metric_time TEXT NOT NULL,
|
||||
dex_volume_usd DOUBLE PRECISION DEFAULT 0,
|
||||
dex_volume_change_pct DOUBLE PRECISION DEFAULT 0,
|
||||
liquidity_usd DOUBLE PRECISION DEFAULT 0,
|
||||
liquidity_change_pct DOUBLE PRECISION DEFAULT 0,
|
||||
exchange_netflow_usd DOUBLE PRECISION DEFAULT 0,
|
||||
whale_accumulation_usd DOUBLE PRECISION DEFAULT 0,
|
||||
holder_delta DOUBLE PRECISION DEFAULT 0,
|
||||
smart_money_score DOUBLE PRECISION DEFAULT 0,
|
||||
onchain_score DOUBLE PRECISION DEFAULT 0,
|
||||
risk_score DOUBLE PRECISION DEFAULT 0,
|
||||
source TEXT DEFAULT '',
|
||||
raw_json TEXT DEFAULT '{}',
|
||||
UNIQUE(symbol, chain, contract_address, "window", metric_time)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_onchain_metrics_symbol ON onchain_token_metrics(symbol, metric_time);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS onchain_raw_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_hash TEXT UNIQUE,
|
||||
source TEXT NOT NULL,
|
||||
chain TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
token_address TEXT DEFAULT '',
|
||||
symbol_guess TEXT DEFAULT '',
|
||||
name TEXT DEFAULT '',
|
||||
title TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
url TEXT DEFAULT '',
|
||||
icon TEXT DEFAULT '',
|
||||
amount DOUBLE PRECISION DEFAULT 0,
|
||||
total_amount DOUBLE PRECISION DEFAULT 0,
|
||||
importance DOUBLE PRECISION DEFAULT 0,
|
||||
mapped_symbol TEXT DEFAULT '',
|
||||
mapping_status TEXT DEFAULT 'unmapped',
|
||||
detected_at TEXT NOT NULL,
|
||||
raw_json TEXT DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_onchain_raw_events_time ON onchain_raw_events(detected_at, importance);
|
||||
CREATE INDEX IF NOT EXISTS idx_onchain_raw_events_chain ON onchain_raw_events(chain, detected_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_onchain_raw_events_mapping ON onchain_raw_events(mapping_status, detected_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_user (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
password_salt TEXT NOT NULL,
|
||||
email_verified INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'pending_email_verification',
|
||||
invite_code TEXT NOT NULL UNIQUE,
|
||||
invited_by_user_id BIGINT,
|
||||
free_trial_claimed INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_login_at TEXT DEFAULT '',
|
||||
is_admin INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_user_email ON app_user(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_user_invite_code ON app_user(invite_code);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_verification_code (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
code_hash TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL DEFAULT 'register',
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_code_lookup ON email_verification_code(email, purpose, used_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_session (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_session_token ON user_session(token_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subscription_plan (
|
||||
code TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
duration_days INTEGER NOT NULL,
|
||||
price_usdt DOUBLE PRECISION DEFAULT 0,
|
||||
status TEXT DEFAULT 'active',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_subscription (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
plan_code TEXT NOT NULL,
|
||||
start_at TEXT NOT NULL,
|
||||
end_at TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'active',
|
||||
source TEXT NOT NULL,
|
||||
order_id BIGINT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_subscription_user ON user_subscription(user_id, status, end_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payment_order (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
plan_code TEXT NOT NULL,
|
||||
amount_usdt DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
chain TEXT NOT NULL DEFAULT 'TRC20',
|
||||
pay_address TEXT DEFAULT '',
|
||||
txid TEXT DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL,
|
||||
paid_at TEXT DEFAULT '',
|
||||
expire_at TEXT DEFAULT '',
|
||||
admin_note TEXT DEFAULT '',
|
||||
raw_payload_json TEXT DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_order_user ON payment_order(user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_order_txid ON payment_order(txid);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_order_created ON payment_order(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pending_registration (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
code_hash TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pending_reg_email ON pending_registration(email, used_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS referral_reward (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
inviter_user_id BIGINT NOT NULL,
|
||||
invitee_user_id BIGINT NOT NULL,
|
||||
order_id BIGINT,
|
||||
reward_type TEXT NOT NULL DEFAULT 'days',
|
||||
reward_days INTEGER DEFAULT 0,
|
||||
reward_amount_usdt DOUBLE PRECISION DEFAULT 0,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_activity (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
page TEXT DEFAULT '',
|
||||
ip TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ua_user ON user_activity(user_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ua_date ON user_activity(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_watchlist (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, symbol)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_watchlist_user ON user_watchlist(user_id, symbol);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_saved_observation (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
rec_id BIGINT NOT NULL,
|
||||
note TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, rec_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_saved_obs_user ON user_saved_observation(user_id, rec_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_push_rule (
|
||||
user_id BIGINT PRIMARY KEY,
|
||||
watchlist_only INTEGER DEFAULT 0,
|
||||
min_score INTEGER DEFAULT 0,
|
||||
min_rr DOUBLE PRECISION DEFAULT 0,
|
||||
push_buy_now INTEGER DEFAULT 1,
|
||||
push_wait_pullback INTEGER DEFAULT 1,
|
||||
push_observe INTEGER DEFAULT 0,
|
||||
quiet_start TEXT DEFAULT '',
|
||||
quiet_end TEXT DEFAULT '',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_reset_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
reset_at TEXT NOT NULL,
|
||||
reason TEXT DEFAULT '',
|
||||
backup_path TEXT DEFAULT '',
|
||||
counts_json TEXT DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_system_reset_log_time ON system_reset_log(reset_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduler_job_config (
|
||||
job_name TEXT PRIMARY KEY,
|
||||
command TEXT NOT NULL,
|
||||
args_json TEXT DEFAULT '[]',
|
||||
enabled INTEGER DEFAULT 1,
|
||||
every_seconds INTEGER NOT NULL,
|
||||
initial_delay INTEGER DEFAULT 0,
|
||||
lock_group TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduler_runtime_status (
|
||||
job_name TEXT PRIMARY KEY,
|
||||
status TEXT DEFAULT 'idle',
|
||||
pid INTEGER DEFAULT 0,
|
||||
run_kind TEXT DEFAULT '',
|
||||
trigger_id BIGINT DEFAULT 0,
|
||||
locked_by TEXT DEFAULT '',
|
||||
next_run_at TEXT DEFAULT '',
|
||||
last_started_at TEXT DEFAULT '',
|
||||
last_finished_at TEXT DEFAULT '',
|
||||
last_exit_code INTEGER DEFAULT 0,
|
||||
last_duration_ms INTEGER DEFAULT 0,
|
||||
last_error TEXT DEFAULT '',
|
||||
output_tail TEXT DEFAULT '',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduler_manual_trigger (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
job_name TEXT NOT NULL,
|
||||
force INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'queued',
|
||||
requested_by TEXT DEFAULT '',
|
||||
requested_at TEXT NOT NULL,
|
||||
started_at TEXT DEFAULT '',
|
||||
finished_at TEXT DEFAULT '',
|
||||
exit_code INTEGER DEFAULT 0,
|
||||
duration_ms INTEGER DEFAULT 0,
|
||||
output_tail TEXT DEFAULT '',
|
||||
error_message TEXT DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduler_trigger_status ON scheduler_manual_trigger(status, requested_at);
|
||||
@ -0,0 +1,9 @@
|
||||
UPDATE recommendation
|
||||
SET action_status=CASE WHEN action_status IN ('止盈1','止盈2','止损','跟踪止盈') THEN action_status ELSE '过期' END,
|
||||
execution_status='invalid',
|
||||
display_bucket='history',
|
||||
lifecycle_state='invalidated',
|
||||
entry_triggered=0,
|
||||
state_reason=COALESCE(NULLIF(state_reason, ''), '机会失效,归入历史复盘')
|
||||
WHERE status IN ('expired', 'invalid', 'archived')
|
||||
AND COALESCE(display_bucket, '') != 'history';
|
||||
@ -6,10 +6,10 @@ mutate trading recommendations directly.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.db.altcoin_db import get_conn
|
||||
from app.db.postgres_connection import ensure_migrations_once
|
||||
|
||||
|
||||
MIN_MAPPING_CONFIDENCE = 70
|
||||
@ -82,125 +82,7 @@ def raw_event_type_label(event_type):
|
||||
|
||||
|
||||
def init_onchain_tables():
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS onchain_token_map (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
chain TEXT NOT NULL,
|
||||
contract_address TEXT NOT NULL,
|
||||
source TEXT DEFAULT '',
|
||||
confidence INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
raw_json TEXT DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_onchain_token_map_unique
|
||||
ON onchain_token_map(symbol, chain, contract_address)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_onchain_token_map_symbol ON onchain_token_map(symbol, confidence, is_active)")
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS onchain_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_hash TEXT UNIQUE,
|
||||
chain TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
contract_address TEXT DEFAULT '',
|
||||
event_type TEXT NOT NULL,
|
||||
signal_code TEXT NOT NULL,
|
||||
signal_label TEXT DEFAULT '',
|
||||
direction TEXT DEFAULT 'neutral',
|
||||
value_usd REAL DEFAULT 0,
|
||||
amount REAL DEFAULT 0,
|
||||
tx_hash TEXT DEFAULT '',
|
||||
wallet_address TEXT DEFAULT '',
|
||||
wallet_label TEXT DEFAULT '',
|
||||
counterparty_label TEXT DEFAULT '',
|
||||
confidence INTEGER DEFAULT 0,
|
||||
severity TEXT DEFAULT 'B',
|
||||
status TEXT DEFAULT 'new',
|
||||
detected_at TEXT NOT NULL,
|
||||
source TEXT DEFAULT '',
|
||||
url TEXT DEFAULT '',
|
||||
raw_json TEXT DEFAULT '{}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_onchain_events_time ON onchain_events(detected_at, signal_code)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_onchain_events_symbol ON onchain_events(symbol, detected_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_onchain_events_chain ON onchain_events(chain, detected_at)")
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS onchain_token_metrics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
chain TEXT NOT NULL,
|
||||
contract_address TEXT DEFAULT '',
|
||||
window TEXT NOT NULL,
|
||||
metric_time TEXT NOT NULL,
|
||||
dex_volume_usd REAL DEFAULT 0,
|
||||
dex_volume_change_pct REAL DEFAULT 0,
|
||||
liquidity_usd REAL DEFAULT 0,
|
||||
liquidity_change_pct REAL DEFAULT 0,
|
||||
exchange_netflow_usd REAL DEFAULT 0,
|
||||
whale_accumulation_usd REAL DEFAULT 0,
|
||||
holder_delta REAL DEFAULT 0,
|
||||
smart_money_score REAL DEFAULT 0,
|
||||
onchain_score REAL DEFAULT 0,
|
||||
risk_score REAL DEFAULT 0,
|
||||
source TEXT DEFAULT '',
|
||||
raw_json TEXT DEFAULT '{}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_onchain_metrics_unique
|
||||
ON onchain_token_metrics(symbol, chain, contract_address, window, metric_time)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_onchain_metrics_symbol ON onchain_token_metrics(symbol, metric_time)")
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS onchain_raw_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_hash TEXT UNIQUE,
|
||||
source TEXT NOT NULL,
|
||||
chain TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
token_address TEXT DEFAULT '',
|
||||
symbol_guess TEXT DEFAULT '',
|
||||
name TEXT DEFAULT '',
|
||||
title TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
url TEXT DEFAULT '',
|
||||
icon TEXT DEFAULT '',
|
||||
amount REAL DEFAULT 0,
|
||||
total_amount REAL DEFAULT 0,
|
||||
importance REAL DEFAULT 0,
|
||||
mapped_symbol TEXT DEFAULT '',
|
||||
mapping_status TEXT DEFAULT 'unmapped',
|
||||
detected_at TEXT NOT NULL,
|
||||
raw_json TEXT DEFAULT '{}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_onchain_raw_events_time ON onchain_raw_events(detected_at, importance)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_onchain_raw_events_chain ON onchain_raw_events(chain, detected_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_onchain_raw_events_mapping ON onchain_raw_events(mapping_status, detected_at)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
ensure_migrations_once()
|
||||
|
||||
|
||||
def upsert_token_mapping(symbol, chain, contract_address, source="", confidence=0, raw=None, is_active=True):
|
||||
@ -216,10 +98,10 @@ def upsert_token_mapping(symbol, chain, contract_address, source="", confidence=
|
||||
"""
|
||||
INSERT INTO onchain_token_map
|
||||
(symbol, chain, contract_address, source, confidence, is_active, raw_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT(symbol, chain, contract_address) DO UPDATE SET
|
||||
source=excluded.source,
|
||||
confidence=MAX(onchain_token_map.confidence, excluded.confidence),
|
||||
confidence=GREATEST(onchain_token_map.confidence, excluded.confidence),
|
||||
is_active=excluded.is_active,
|
||||
raw_json=excluded.raw_json,
|
||||
updated_at=excluded.updated_at
|
||||
@ -228,19 +110,19 @@ def upsert_token_mapping(symbol, chain, contract_address, source="", confidence=
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT id FROM onchain_token_map WHERE symbol=? AND chain=? AND contract_address=?",
|
||||
"SELECT id FROM onchain_token_map WHERE symbol=%s AND chain=%s AND contract_address=%s",
|
||||
(symbol, chain, contract_address),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return int(row["id"] if row else cur.lastrowid or 0)
|
||||
return int(row["id"] if row else 0)
|
||||
|
||||
|
||||
def get_token_mappings(symbol="", min_confidence=MIN_MAPPING_CONFIDENCE, active_only=True):
|
||||
init_onchain_tables()
|
||||
clauses = ["confidence >= ?"]
|
||||
clauses = ["confidence >= %s"]
|
||||
params = [int(min_confidence or 0)]
|
||||
if symbol:
|
||||
clauses.append("symbol=?")
|
||||
clauses.append("symbol=%s")
|
||||
params.append(normalize_symbol(symbol))
|
||||
if active_only:
|
||||
clauses.append("is_active=1")
|
||||
@ -312,7 +194,9 @@ def insert_onchain_event(event):
|
||||
event_hash, chain, symbol, contract_address, event_type, signal_code, signal_label,
|
||||
direction, value_usd, amount, tx_hash, wallet_address, wallet_label,
|
||||
counterparty_label, confidence, severity, status, detected_at, source, url, raw_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT(event_hash) DO NOTHING
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
item["event_hash"],
|
||||
@ -338,10 +222,9 @@ def insert_onchain_event(event):
|
||||
_dump(item.get("raw") or item.get("raw_json") or {}),
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
event_id = int(row["id"] if row else 0)
|
||||
conn.commit()
|
||||
event_id = int(cur.lastrowid or 0)
|
||||
except sqlite3.IntegrityError:
|
||||
event_id = 0
|
||||
finally:
|
||||
conn.close()
|
||||
return event_id
|
||||
@ -358,7 +241,7 @@ def find_mapping_by_contract(chain, contract_address):
|
||||
"""
|
||||
SELECT *
|
||||
FROM onchain_token_map
|
||||
WHERE chain=? AND lower(contract_address)=lower(?) AND is_active=1
|
||||
WHERE chain=%s AND lower(contract_address)=lower(%s) AND is_active=1
|
||||
ORDER BY confidence DESC, updated_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
@ -389,7 +272,9 @@ def insert_onchain_raw_event(event):
|
||||
event_hash, source, chain, event_type, token_address, symbol_guess, name,
|
||||
title, description, url, icon, amount, total_amount, importance,
|
||||
mapped_symbol, mapping_status, detected_at, raw_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT(event_hash) DO NOTHING
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
item["event_hash"],
|
||||
@ -412,10 +297,9 @@ def insert_onchain_raw_event(event):
|
||||
_dump(item.get("raw") or item.get("raw_json") or {}),
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
event_id = int(row["id"] if row else 0)
|
||||
conn.commit()
|
||||
event_id = int(cur.lastrowid or 0)
|
||||
except sqlite3.IntegrityError:
|
||||
event_id = 0
|
||||
finally:
|
||||
conn.close()
|
||||
return event_id
|
||||
@ -434,12 +318,12 @@ def insert_token_metric(metric):
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO onchain_token_metrics (
|
||||
symbol, chain, contract_address, window, metric_time,
|
||||
symbol, chain, contract_address, "window", metric_time,
|
||||
dex_volume_usd, dex_volume_change_pct, liquidity_usd, liquidity_change_pct,
|
||||
exchange_netflow_usd, whale_accumulation_usd, holder_delta, smart_money_score,
|
||||
onchain_score, risk_score, source, raw_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(symbol, chain, contract_address, window, metric_time) DO UPDATE SET
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT(symbol, chain, contract_address, "window", metric_time) DO UPDATE SET
|
||||
dex_volume_usd=excluded.dex_volume_usd,
|
||||
dex_volume_change_pct=excluded.dex_volume_change_pct,
|
||||
liquidity_usd=excluded.liquidity_usd,
|
||||
@ -452,6 +336,7 @@ def insert_token_metric(metric):
|
||||
risk_score=excluded.risk_score,
|
||||
source=excluded.source,
|
||||
raw_json=excluded.raw_json
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
item["symbol"],
|
||||
@ -474,8 +359,9 @@ def insert_token_metric(metric):
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
metric_id = int(cur.fetchone()["id"] or 0)
|
||||
conn.close()
|
||||
return int(cur.lastrowid or 0)
|
||||
return metric_id
|
||||
|
||||
|
||||
def _latest_metrics_subquery(hours=24):
|
||||
@ -485,7 +371,7 @@ def _latest_metrics_subquery(hours=24):
|
||||
JOIN (
|
||||
SELECT symbol, chain, contract_address, MAX(metric_time) AS max_time
|
||||
FROM onchain_token_metrics
|
||||
WHERE metric_time >= ?
|
||||
WHERE metric_time >= %s
|
||||
GROUP BY symbol, chain, contract_address
|
||||
) latest ON latest.symbol=m.symbol
|
||||
AND latest.chain=m.chain
|
||||
@ -498,13 +384,13 @@ def get_onchain_overview(hours=24):
|
||||
init_onchain_tables()
|
||||
cutoff = (datetime.now() - timedelta(hours=int(hours or 24))).isoformat()
|
||||
conn = get_conn()
|
||||
event_rows = conn.execute("SELECT * FROM onchain_events WHERE detected_at >= ?", (cutoff,)).fetchall()
|
||||
raw_rows = conn.execute("SELECT * FROM onchain_raw_events WHERE detected_at >= ?", (cutoff,)).fetchall()
|
||||
event_rows = conn.execute("SELECT * FROM onchain_events WHERE detected_at >= %s", (cutoff,)).fetchall()
|
||||
raw_rows = conn.execute("SELECT * FROM onchain_raw_events WHERE detected_at >= %s", (cutoff,)).fetchall()
|
||||
raw_latest = conn.execute(
|
||||
"""
|
||||
SELECT * FROM onchain_raw_events
|
||||
WHERE detected_at >= ?
|
||||
ORDER BY datetime(detected_at) DESC, importance DESC, id DESC
|
||||
WHERE detected_at >= %s
|
||||
ORDER BY detected_at::timestamp DESC, importance DESC, id DESC
|
||||
LIMIT 12
|
||||
""",
|
||||
(cutoff,),
|
||||
@ -577,7 +463,7 @@ def list_onchain_tokens(limit=30, offset=0, chain="", signal="", hours=24):
|
||||
clauses = []
|
||||
params = []
|
||||
if chain:
|
||||
clauses.append("m.chain=?")
|
||||
clauses.append("m.chain=%s")
|
||||
params.append(str(chain).lower())
|
||||
if signal:
|
||||
clauses.append(
|
||||
@ -585,7 +471,7 @@ def list_onchain_tokens(limit=30, offset=0, chain="", signal="", hours=24):
|
||||
EXISTS (
|
||||
SELECT 1 FROM onchain_events e
|
||||
WHERE e.symbol=m.symbol AND e.chain=m.chain
|
||||
AND e.detected_at >= ? AND e.signal_code=?
|
||||
AND e.detected_at >= %s AND e.signal_code=%s
|
||||
)
|
||||
"""
|
||||
)
|
||||
@ -607,13 +493,13 @@ def list_onchain_tokens(limit=30, offset=0, chain="", signal="", hours=24):
|
||||
f"""
|
||||
SELECT m.*,
|
||||
(SELECT COUNT(*) FROM onchain_events e
|
||||
WHERE e.symbol=m.symbol AND e.chain=m.chain AND e.detected_at >= ?) AS event_count,
|
||||
WHERE e.symbol=m.symbol AND e.chain=m.chain AND e.detected_at >= %s) AS event_count,
|
||||
(SELECT COUNT(*) FROM onchain_events e
|
||||
WHERE e.symbol=m.symbol AND e.chain=m.chain AND e.direction='risk' AND e.detected_at >= ?) AS risk_event_count
|
||||
WHERE e.symbol=m.symbol AND e.chain=m.chain AND e.direction='risk' AND e.detected_at >= %s) AS risk_event_count
|
||||
FROM ({_latest_metrics_subquery(hours)}) m
|
||||
WHERE {where}
|
||||
ORDER BY m.onchain_score DESC, m.risk_score DESC, m.metric_time DESC
|
||||
LIMIT ? OFFSET ?
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(cutoff, cutoff, cutoff, *params, limit, offset),
|
||||
).fetchall()
|
||||
@ -637,13 +523,13 @@ def get_onchain_token_detail(symbol, hours=72):
|
||||
cutoff = (datetime.now() - timedelta(hours=int(hours or 72))).isoformat()
|
||||
conn = get_conn()
|
||||
mappings = conn.execute(
|
||||
"SELECT * FROM onchain_token_map WHERE symbol=? ORDER BY confidence DESC, updated_at DESC",
|
||||
"SELECT * FROM onchain_token_map WHERE symbol=%s ORDER BY confidence DESC, updated_at DESC",
|
||||
(symbol,),
|
||||
).fetchall()
|
||||
events = conn.execute(
|
||||
"""
|
||||
SELECT * FROM onchain_events
|
||||
WHERE symbol=? AND detected_at >= ?
|
||||
WHERE symbol=%s AND detected_at >= %s
|
||||
ORDER BY detected_at DESC, id DESC
|
||||
LIMIT 100
|
||||
""",
|
||||
@ -652,7 +538,7 @@ def get_onchain_token_detail(symbol, hours=72):
|
||||
metrics = conn.execute(
|
||||
"""
|
||||
SELECT * FROM onchain_token_metrics
|
||||
WHERE symbol=? AND metric_time >= ?
|
||||
WHERE symbol=%s AND metric_time >= %s
|
||||
ORDER BY metric_time DESC, id DESC
|
||||
LIMIT 100
|
||||
""",
|
||||
@ -662,7 +548,7 @@ def get_onchain_token_detail(symbol, hours=72):
|
||||
"""
|
||||
SELECT id, rec_time, action_status, execution_status, display_bucket, entry_price, current_price
|
||||
FROM recommendation
|
||||
WHERE symbol=? AND status='active'
|
||||
WHERE symbol=%s AND status='active'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(symbol,),
|
||||
@ -683,16 +569,16 @@ def list_onchain_events(limit=50, offset=0, chain="", signal="", status="", hour
|
||||
limit = max(1, min(int(limit or 50), 200))
|
||||
offset = max(0, int(offset or 0))
|
||||
cutoff = (datetime.now() - timedelta(hours=int(hours or 24))).isoformat()
|
||||
clauses = ["detected_at >= ?"]
|
||||
clauses = ["detected_at >= %s"]
|
||||
params = [cutoff]
|
||||
if chain:
|
||||
clauses.append("chain=?")
|
||||
clauses.append("chain=%s")
|
||||
params.append(str(chain).lower())
|
||||
if signal:
|
||||
clauses.append("signal_code=?")
|
||||
clauses.append("signal_code=%s")
|
||||
params.append(signal)
|
||||
if status:
|
||||
clauses.append("status=?")
|
||||
clauses.append("status=%s")
|
||||
params.append(status)
|
||||
where = " AND ".join(clauses)
|
||||
conn = get_conn()
|
||||
@ -701,8 +587,8 @@ def list_onchain_events(limit=50, offset=0, chain="", signal="", status="", hour
|
||||
f"""
|
||||
SELECT * FROM onchain_events
|
||||
WHERE {where}
|
||||
ORDER BY datetime(detected_at) DESC, id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
ORDER BY detected_at::timestamp DESC, id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(*params, limit, offset),
|
||||
).fetchall()
|
||||
@ -715,19 +601,19 @@ def list_onchain_raw_events(limit=50, offset=0, chain="", source="", event_type=
|
||||
limit = max(1, min(int(limit or 50), 200))
|
||||
offset = max(0, int(offset or 0))
|
||||
cutoff = (datetime.now() - timedelta(hours=int(hours or 24))).isoformat()
|
||||
clauses = ["detected_at >= ?"]
|
||||
clauses = ["detected_at >= %s"]
|
||||
params = [cutoff]
|
||||
if chain:
|
||||
clauses.append("chain=?")
|
||||
clauses.append("chain=%s")
|
||||
params.append(str(chain).lower())
|
||||
if source:
|
||||
clauses.append("source=?")
|
||||
clauses.append("source=%s")
|
||||
params.append(source)
|
||||
if event_type:
|
||||
clauses.append("event_type=?")
|
||||
clauses.append("event_type=%s")
|
||||
params.append(event_type)
|
||||
if mapping_status:
|
||||
clauses.append("mapping_status=?")
|
||||
clauses.append("mapping_status=%s")
|
||||
params.append(mapping_status)
|
||||
where = " AND ".join(clauses)
|
||||
conn = get_conn()
|
||||
@ -736,8 +622,8 @@ def list_onchain_raw_events(limit=50, offset=0, chain="", source="", event_type=
|
||||
f"""
|
||||
SELECT * FROM onchain_raw_events
|
||||
WHERE {where}
|
||||
ORDER BY datetime(detected_at) DESC, importance DESC, id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
ORDER BY detected_at::timestamp DESC, importance DESC, id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(*params, limit, offset),
|
||||
).fetchall()
|
||||
@ -757,7 +643,7 @@ def update_event_status(event_ids, status):
|
||||
init_onchain_tables()
|
||||
conn = get_conn()
|
||||
cur = conn.execute(
|
||||
"UPDATE onchain_events SET status=? WHERE id IN (" + ",".join(["?"] * len(event_ids)) + ")",
|
||||
"UPDATE onchain_events SET status=%s WHERE id IN (" + ",".join(["%s"] * len(event_ids)) + ")",
|
||||
(status, *[int(x) for x in event_ids]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
97
app/db/postgres_connection.py
Normal file
97
app/db/postgres_connection.py
Normal file
@ -0,0 +1,97 @@
|
||||
"""PostgreSQL runtime and migration helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
MIGRATIONS_DIR = REPO_ROOT / "app" / "db" / "migrations"
|
||||
_MIGRATIONS_CHECKED = False
|
||||
|
||||
|
||||
class DbRow(dict):
|
||||
"""Mapping row that also supports legacy positional reads during cutover."""
|
||||
|
||||
__slots__ = ("_values",)
|
||||
|
||||
def __init__(self, names, values):
|
||||
super().__init__(zip(names, values))
|
||||
self._values = tuple(values)
|
||||
|
||||
def __getitem__(self, key):
|
||||
if isinstance(key, int):
|
||||
return self._values[key]
|
||||
return super().__getitem__(key)
|
||||
|
||||
|
||||
def dict_index_row(cursor):
|
||||
if cursor.description is None:
|
||||
return None
|
||||
names = [col.name for col in cursor.description]
|
||||
|
||||
def make_row(values):
|
||||
return DbRow(names, values)
|
||||
|
||||
return make_row
|
||||
|
||||
|
||||
def get_database_url(database_url: str | None = None) -> str:
|
||||
url = database_url or os.getenv("DATABASE_URL", "")
|
||||
if not url:
|
||||
raise RuntimeError("DATABASE_URL is required for AlphaX PostgreSQL runtime")
|
||||
return url
|
||||
|
||||
|
||||
def connect(database_url: str | None = None, *, autocommit: bool = False) -> psycopg.Connection:
|
||||
return psycopg.connect(get_database_url(database_url), autocommit=autocommit, row_factory=dict_index_row)
|
||||
|
||||
|
||||
def apply_migrations(database_url: str | None = None, migrations_dir: Path = MIGRATIONS_DIR) -> list[str]:
|
||||
files = sorted(migrations_dir.glob("*.sql"))
|
||||
if not files:
|
||||
raise RuntimeError(f"No PostgreSQL migration files found in {migrations_dir}")
|
||||
|
||||
applied_now: list[str] = []
|
||||
with connect(database_url) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
applied = {row["version"] for row in conn.execute("SELECT version FROM schema_migrations").fetchall()}
|
||||
for path in files:
|
||||
version = path.name
|
||||
if version in applied:
|
||||
continue
|
||||
with conn.transaction():
|
||||
conn.execute(path.read_text(encoding="utf-8"))
|
||||
conn.execute("INSERT INTO schema_migrations(version) VALUES (%s)", (version,))
|
||||
applied_now.append(version)
|
||||
return applied_now
|
||||
|
||||
|
||||
def ensure_migrations_once() -> None:
|
||||
global _MIGRATIONS_CHECKED
|
||||
if _MIGRATIONS_CHECKED:
|
||||
return
|
||||
apply_migrations()
|
||||
_MIGRATIONS_CHECKED = True
|
||||
|
||||
|
||||
def table_columns(table_name: str) -> set[str]:
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema='public' AND table_name=%s
|
||||
""",
|
||||
(table_name,),
|
||||
).fetchall()
|
||||
return {row["column_name"] for row in rows}
|
||||
@ -20,7 +20,7 @@ def should_push(symbol: str, push_type: str, action_status: str = "") -> bool:
|
||||
cutoff = (datetime.now() - timedelta(hours=PUSH_COOLDOWN_HOURS)).isoformat()
|
||||
if action_status:
|
||||
row = conn.execute(
|
||||
"SELECT action_status FROM push_log WHERE symbol=? AND push_type=? AND pushed_at > ? ORDER BY id DESC LIMIT 1",
|
||||
"SELECT action_status FROM push_log WHERE symbol=%s AND push_type=%s AND pushed_at > %s ORDER BY id DESC LIMIT 1",
|
||||
(symbol, push_type, cutoff),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
@ -29,7 +29,7 @@ def should_push(symbol: str, push_type: str, action_status: str = "") -> bool:
|
||||
return row[0] != action_status
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT id FROM push_log WHERE symbol=? AND push_type=? AND pushed_at > ? ORDER BY id DESC LIMIT 1",
|
||||
"SELECT id FROM push_log WHERE symbol=%s AND push_type=%s AND pushed_at > %s ORDER BY id DESC LIMIT 1",
|
||||
(symbol, push_type, cutoff),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
@ -40,17 +40,10 @@ def log_push(symbol: str, push_type: str, action_status: str = "", rec_id: int =
|
||||
"""记录一次推送,保留推荐来源可追溯性。"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cols = [row[1] for row in conn.execute("PRAGMA table_info(push_log)").fetchall()]
|
||||
if "rec_id" in cols:
|
||||
conn.execute(
|
||||
"INSERT INTO push_log (symbol, push_type, action_status, rec_id, pushed_at) VALUES (?,?,?,?,?)",
|
||||
(symbol, push_type, action_status, int(rec_id or 0), datetime.now().isoformat()),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO push_log (symbol, push_type, action_status, pushed_at) VALUES (?,?,?,?)",
|
||||
(symbol, push_type, action_status, datetime.now().isoformat()),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO push_log (symbol, push_type, action_status, rec_id, pushed_at) VALUES (%s,%s,%s,%s,%s)",
|
||||
(symbol, push_type, action_status, int(rec_id or 0), datetime.now().isoformat()),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@ -73,7 +66,7 @@ def get_recommendation_for_push(rec_id: int):
|
||||
lpc.updated_at AS latest_cache_updated_at
|
||||
FROM recommendation r
|
||||
LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol
|
||||
WHERE r.id=?
|
||||
WHERE r.id=%s
|
||||
""",
|
||||
(rec_id,),
|
||||
).fetchone()
|
||||
@ -115,7 +108,7 @@ def _attach_onchain_context(items):
|
||||
symbols = sorted({item.get("symbol") for item in items if item.get("symbol")})
|
||||
if not symbols:
|
||||
return items
|
||||
placeholders = ",".join(["?"] * len(symbols))
|
||||
placeholders = ",".join(["%s"] * len(symbols))
|
||||
try:
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
@ -136,10 +129,10 @@ def _attach_onchain_context(items):
|
||||
SELECT *
|
||||
FROM onchain_events
|
||||
WHERE symbol IN ({placeholders})
|
||||
AND detected_at >= datetime('now', '-24 hours')
|
||||
ORDER BY datetime(detected_at) DESC, id DESC
|
||||
AND detected_at >= %s
|
||||
ORDER BY detected_at::timestamp DESC, id DESC
|
||||
""",
|
||||
tuple(symbols),
|
||||
(*symbols, (datetime.now() - timedelta(hours=24)).isoformat()),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
except Exception:
|
||||
@ -199,20 +192,20 @@ def get_active_recommendations_deduped(
|
||||
params = []
|
||||
version = str(version or "").strip()
|
||||
if version:
|
||||
where += " AND strategy_version=?"
|
||||
where += " AND strategy_version=%s"
|
||||
params.append(version)
|
||||
if watch_symbols:
|
||||
symbols = [str(s).strip().upper() for s in watch_symbols if str(s).strip()]
|
||||
if symbols:
|
||||
where += " AND symbol IN (" + ",".join(["?"] * len(symbols)) + ")"
|
||||
where += " AND symbol IN (" + ",".join(["%s"] * len(symbols)) + ")"
|
||||
params.extend(symbols)
|
||||
try:
|
||||
hours = float(hours or 0)
|
||||
except Exception:
|
||||
hours = 0
|
||||
if hours > 0:
|
||||
where += " AND julianday(?) - julianday(rec_time) <= ?"
|
||||
params.extend([datetime.now().isoformat(), hours / 24.0])
|
||||
where += " AND rec_time >= %s"
|
||||
params.append((datetime.now() - timedelta(hours=hours)).isoformat())
|
||||
|
||||
try:
|
||||
limit = max(0, int(limit or 0))
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.db.altcoin_db import (
|
||||
_loads_json_field,
|
||||
@ -58,7 +58,7 @@ def log_strategy_iteration(
|
||||
strategy_version, version_change_summary,
|
||||
success_analysis_json, failure_analysis_json, candidate_rules_json,
|
||||
release_decision, release_reason, confidence_level, promotion_state
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
run_date,
|
||||
@ -98,7 +98,7 @@ def get_strategy_iteration_logs(limit=30, conn_provider=None, json_loader=None):
|
||||
"""
|
||||
SELECT * FROM strategy_iteration_log
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
@ -133,14 +133,14 @@ def get_strategy_iteration_summary(days=30, conn_provider=None, json_loader=None
|
||||
conn_factory = conn_provider or get_conn
|
||||
loader = json_loader or _loads_json_field
|
||||
conn = conn_factory()
|
||||
now_iso = datetime.now().isoformat()
|
||||
cutoff = (datetime.now() - timedelta(days=float(days or 30))).isoformat()
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM strategy_iteration_log
|
||||
WHERE julianday(?) - julianday(created_at) <= ?
|
||||
WHERE created_at >= %s
|
||||
ORDER BY created_at DESC, id DESC
|
||||
""",
|
||||
(now_iso, days),
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
rec_rows = conn.execute(
|
||||
"""
|
||||
|
||||
@ -1,27 +1,16 @@
|
||||
"""SQLite-backed scheduler configuration and runtime state."""
|
||||
"""PostgreSQL-backed scheduler configuration and runtime state."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.db import altcoin_db
|
||||
from app.db.postgres_connection import connect as pg_connect, ensure_migrations_once
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SCHEDULER_DB_PATH = os.getenv("ALPHAX_SCHEDULER_DB_PATH", str(REPO_ROOT / "data" / "scheduler_state.db"))
|
||||
_SCHEDULER_INIT_DONE = False
|
||||
|
||||
|
||||
def get_scheduler_conn():
|
||||
path = Path(SCHEDULER_DB_PATH)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(path), timeout=30, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
return conn
|
||||
return pg_connect()
|
||||
|
||||
|
||||
def get_main_conn():
|
||||
@ -127,81 +116,6 @@ def _load(value, fallback=None):
|
||||
return fallback
|
||||
|
||||
|
||||
def _create_runtime_table(conn):
|
||||
conn.execute("DROP TABLE IF EXISTS scheduler_runtime_status")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE scheduler_runtime_status (
|
||||
job_name TEXT PRIMARY KEY,
|
||||
status TEXT DEFAULT 'idle',
|
||||
pid INTEGER DEFAULT 0,
|
||||
run_kind TEXT DEFAULT '',
|
||||
trigger_id INTEGER DEFAULT 0,
|
||||
locked_by TEXT DEFAULT '',
|
||||
next_run_at TEXT DEFAULT '',
|
||||
last_started_at TEXT DEFAULT '',
|
||||
last_finished_at TEXT DEFAULT '',
|
||||
last_exit_code INTEGER DEFAULT 0,
|
||||
last_duration_ms INTEGER DEFAULT 0,
|
||||
last_error TEXT DEFAULT '',
|
||||
output_tail TEXT DEFAULT '',
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _create_config_table(conn):
|
||||
conn.execute("DROP TABLE IF EXISTS scheduler_job_config")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE scheduler_job_config (
|
||||
job_name TEXT PRIMARY KEY,
|
||||
command TEXT NOT NULL,
|
||||
args_json TEXT DEFAULT '[]',
|
||||
enabled INTEGER DEFAULT 1,
|
||||
every_seconds INTEGER NOT NULL,
|
||||
initial_delay INTEGER DEFAULT 0,
|
||||
lock_group TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _create_manual_trigger_table(conn):
|
||||
conn.execute("DROP INDEX IF EXISTS idx_scheduler_trigger_status")
|
||||
conn.execute("DROP TABLE IF EXISTS scheduler_manual_trigger")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE scheduler_manual_trigger (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_name TEXT NOT NULL,
|
||||
force INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'queued',
|
||||
requested_by TEXT DEFAULT '',
|
||||
requested_at TEXT NOT NULL,
|
||||
started_at TEXT DEFAULT '',
|
||||
finished_at TEXT DEFAULT '',
|
||||
exit_code INTEGER DEFAULT 0,
|
||||
duration_ms INTEGER DEFAULT 0,
|
||||
output_tail TEXT DEFAULT '',
|
||||
error_message TEXT DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_scheduler_trigger_status ON scheduler_manual_trigger(status, requested_at)")
|
||||
|
||||
|
||||
def _reset_scheduler_tables(conn):
|
||||
_create_manual_trigger_table(conn)
|
||||
_create_runtime_table(conn)
|
||||
_create_config_table(conn)
|
||||
|
||||
|
||||
def _seed_scheduler_tables(conn):
|
||||
now = _now()
|
||||
for job in DEFAULT_JOBS:
|
||||
@ -210,7 +124,7 @@ def _seed_scheduler_tables(conn):
|
||||
INSERT INTO scheduler_job_config (
|
||||
job_name, command, args_json, enabled, every_seconds, initial_delay,
|
||||
lock_group, description, sort_order, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (%s, %s, %s, 1, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT(job_name) DO UPDATE SET
|
||||
command=excluded.command,
|
||||
args_json=excluded.args_json,
|
||||
@ -236,7 +150,7 @@ def _seed_scheduler_tables(conn):
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO scheduler_runtime_status (job_name, status, updated_at)
|
||||
VALUES (?, 'idle', ?)
|
||||
VALUES (%s, 'idle', %s)
|
||||
ON CONFLICT(job_name) DO NOTHING
|
||||
""",
|
||||
(job["job_name"], now),
|
||||
@ -244,74 +158,16 @@ def _seed_scheduler_tables(conn):
|
||||
|
||||
|
||||
def init_scheduler_tables():
|
||||
global _SCHEDULER_INIT_DONE
|
||||
if not _SCHEDULER_INIT_DONE:
|
||||
ensure_migrations_once()
|
||||
_SCHEDULER_INIT_DONE = True
|
||||
conn = get_scheduler_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS scheduler_job_config (
|
||||
job_name TEXT PRIMARY KEY,
|
||||
command TEXT NOT NULL,
|
||||
args_json TEXT DEFAULT '[]',
|
||||
enabled INTEGER DEFAULT 1,
|
||||
every_seconds INTEGER NOT NULL,
|
||||
initial_delay INTEGER DEFAULT 0,
|
||||
lock_group TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS scheduler_runtime_status (
|
||||
job_name TEXT PRIMARY KEY,
|
||||
status TEXT DEFAULT 'idle',
|
||||
pid INTEGER DEFAULT 0,
|
||||
run_kind TEXT DEFAULT '',
|
||||
trigger_id INTEGER DEFAULT 0,
|
||||
locked_by TEXT DEFAULT '',
|
||||
next_run_at TEXT DEFAULT '',
|
||||
last_started_at TEXT DEFAULT '',
|
||||
last_finished_at TEXT DEFAULT '',
|
||||
last_exit_code INTEGER DEFAULT 0,
|
||||
last_duration_ms INTEGER DEFAULT 0,
|
||||
last_error TEXT DEFAULT '',
|
||||
output_tail TEXT DEFAULT '',
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("SELECT COUNT(*) FROM scheduler_runtime_status").fetchone()
|
||||
except Exception:
|
||||
_create_runtime_table(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS scheduler_manual_trigger (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_name TEXT NOT NULL,
|
||||
force INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'queued',
|
||||
requested_by TEXT DEFAULT '',
|
||||
requested_at TEXT NOT NULL,
|
||||
started_at TEXT DEFAULT '',
|
||||
finished_at TEXT DEFAULT '',
|
||||
exit_code INTEGER DEFAULT 0,
|
||||
duration_ms INTEGER DEFAULT 0,
|
||||
output_tail TEXT DEFAULT '',
|
||||
error_message TEXT DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_scheduler_trigger_status ON scheduler_manual_trigger(status, requested_at)")
|
||||
try:
|
||||
_seed_scheduler_tables(conn)
|
||||
except Exception:
|
||||
_reset_scheduler_tables(conn)
|
||||
_seed_scheduler_tables(conn)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_job_configs():
|
||||
@ -331,7 +187,7 @@ def get_job_configs():
|
||||
def get_job_config(job_name):
|
||||
init_scheduler_tables()
|
||||
conn = get_scheduler_conn()
|
||||
row = conn.execute("SELECT * FROM scheduler_job_config WHERE job_name=?", (job_name,)).fetchone()
|
||||
row = conn.execute("SELECT * FROM scheduler_job_config WHERE job_name=%s", (job_name,)).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
@ -346,7 +202,7 @@ def set_job_enabled(job_name, enabled):
|
||||
now = _now()
|
||||
conn = get_scheduler_conn()
|
||||
cur = conn.execute(
|
||||
"UPDATE scheduler_job_config SET enabled=?, updated_at=? WHERE job_name=?",
|
||||
"UPDATE scheduler_job_config SET enabled=%s, updated_at=%s WHERE job_name=%s",
|
||||
(1 if enabled else 0, now, job_name),
|
||||
)
|
||||
conn.commit()
|
||||
@ -360,7 +216,7 @@ def set_job_interval(job_name, every_seconds):
|
||||
now = _now()
|
||||
conn = get_scheduler_conn()
|
||||
cur = conn.execute(
|
||||
"UPDATE scheduler_job_config SET every_seconds=?, updated_at=? WHERE job_name=?",
|
||||
"UPDATE scheduler_job_config SET every_seconds=%s, updated_at=%s WHERE job_name=%s",
|
||||
(seconds, now, job_name),
|
||||
)
|
||||
conn.commit()
|
||||
@ -380,23 +236,23 @@ def update_runtime(job_name, **fields):
|
||||
conn = get_scheduler_conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO scheduler_runtime_status (job_name, updated_at) VALUES (?, ?) ON CONFLICT(job_name) DO NOTHING",
|
||||
"INSERT INTO scheduler_runtime_status (job_name, updated_at) VALUES (%s, %s) ON CONFLICT(job_name) DO NOTHING",
|
||||
(job_name, values["updated_at"]),
|
||||
)
|
||||
assignments = ", ".join([f"{k}=?" for k in values])
|
||||
assignments = ", ".join([f"{k}=%s" for k in values])
|
||||
conn.execute(
|
||||
f"UPDATE scheduler_runtime_status SET {assignments} WHERE job_name=?",
|
||||
f"UPDATE scheduler_runtime_status SET {assignments} WHERE job_name=%s",
|
||||
(*values.values(), job_name),
|
||||
)
|
||||
except Exception:
|
||||
_create_runtime_table(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO scheduler_runtime_status (job_name, updated_at) VALUES (?, ?)",
|
||||
"INSERT INTO scheduler_runtime_status (job_name, updated_at) VALUES (%s, %s)",
|
||||
(job_name, values["updated_at"]),
|
||||
)
|
||||
assignments = ", ".join([f"{k}=?" for k in values])
|
||||
assignments = ", ".join([f"{k}=%s" for k in values])
|
||||
conn.execute(
|
||||
f"UPDATE scheduler_runtime_status SET {assignments} WHERE job_name=?",
|
||||
f"UPDATE scheduler_runtime_status SET {assignments} WHERE job_name=%s",
|
||||
(*values.values(), job_name),
|
||||
)
|
||||
conn.commit()
|
||||
@ -408,15 +264,16 @@ def enqueue_manual_trigger(job_name, force=False, requested_by=""):
|
||||
if not get_job_config(job_name):
|
||||
return None
|
||||
conn = get_scheduler_conn()
|
||||
cur = conn.execute(
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO scheduler_manual_trigger (job_name, force, status, requested_by, requested_at)
|
||||
VALUES (?, ?, 'queued', ?, ?)
|
||||
VALUES (%s, %s, 'queued', %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(job_name, 1 if force else 0, requested_by or "", _now()),
|
||||
)
|
||||
trigger_id = row.fetchone()["id"]
|
||||
conn.commit()
|
||||
trigger_id = cur.lastrowid
|
||||
conn.close()
|
||||
return trigger_id
|
||||
|
||||
@ -429,7 +286,7 @@ def claim_manual_triggers(limit=10):
|
||||
SELECT * FROM scheduler_manual_trigger
|
||||
WHERE status IN ('queued', 'pending')
|
||||
ORDER BY requested_at ASC, id ASC
|
||||
LIMIT ?
|
||||
LIMIT %s
|
||||
""",
|
||||
(int(limit or 10),),
|
||||
).fetchall()
|
||||
@ -444,9 +301,9 @@ def update_manual_trigger(trigger_id, **fields):
|
||||
if not values:
|
||||
return
|
||||
conn = get_scheduler_conn()
|
||||
assignments = ", ".join([f"{k}=?" for k in values])
|
||||
assignments = ", ".join([f"{k}=%s" for k in values])
|
||||
conn.execute(
|
||||
f"UPDATE scheduler_manual_trigger SET {assignments} WHERE id=?",
|
||||
f"UPDATE scheduler_manual_trigger SET {assignments} WHERE id=%s",
|
||||
(*values.values(), int(trigger_id)),
|
||||
)
|
||||
conn.commit()
|
||||
@ -458,7 +315,7 @@ def list_manual_triggers(limit=30):
|
||||
limit = max(1, min(int(limit or 30), 100))
|
||||
conn = get_scheduler_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM scheduler_manual_trigger ORDER BY requested_at DESC, id DESC LIMIT ?",
|
||||
"SELECT * FROM scheduler_manual_trigger ORDER BY requested_at DESC, id DESC LIMIT %s",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
"""Schema/init-oriented DB API."""
|
||||
"""Schema/init-oriented DB API for the PostgreSQL runtime."""
|
||||
|
||||
from app.db.altcoin_db import get_conn, init_db as _init_main_db
|
||||
from app.db.onchain_db import init_onchain_tables
|
||||
from app.db.postgres_connection import apply_migrations, connect as get_conn
|
||||
|
||||
|
||||
def init_db():
|
||||
_init_main_db()
|
||||
init_onchain_tables()
|
||||
apply_migrations()
|
||||
|
||||
__all__ = ["get_conn", "init_db"]
|
||||
|
||||
@ -42,6 +42,7 @@ from app.config.config_loader import (
|
||||
get_strategy_params,
|
||||
)
|
||||
from app.core.opportunity_lifecycle import apply_entry_quality_gate
|
||||
from app.core.opportunity_funnel import build_screening_detail
|
||||
from app.config.config_loader import _get_section as _get_cfg_section
|
||||
from app.core.pa_engine import (
|
||||
classify_candles, calc_atr, find_supply_demand_zones,
|
||||
@ -66,15 +67,14 @@ def fetch_klines(symbol, timeframe, limit=200):
|
||||
def symbol_recently_closed(symbol: str, hours: int = 8) -> bool:
|
||||
"""检查该币种最近N小时内是否有已完成的交易(止盈/止损)。
|
||||
用于冷却期:刚止盈的币不宜立即追入。"""
|
||||
import sqlite3, os
|
||||
from datetime import datetime, timezone, timedelta
|
||||
db = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db"))
|
||||
conn = sqlite3.connect(db)
|
||||
from app.db.schema import get_conn
|
||||
conn = get_conn()
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
|
||||
row = conn.execute("""
|
||||
SELECT COUNT(*) FROM recommendation
|
||||
WHERE symbol = ? AND status IN ('hit_tp1', 'hit_tp2', 'stopped_out')
|
||||
AND COALESCE(hit_tp1_time, hit_tp2_time, stopped_out_time, '') >= ?
|
||||
WHERE symbol = %s AND status IN ('hit_tp1', 'hit_tp2', 'stopped_out')
|
||||
AND COALESCE(hit_tp1_time, hit_tp2_time, stopped_out_time, '') >= %s
|
||||
""", (symbol, cutoff)).fetchone()
|
||||
conn.close()
|
||||
return (row[0] or 0) > 0
|
||||
@ -1259,6 +1259,19 @@ def main(compact: bool = False):
|
||||
sector=cand_detail.get("sector", cand.get("sector", "")),
|
||||
leader_status=cand_detail.get("leader_status", cand.get("leader_status", "")),
|
||||
is_meme=int(is_meme_coin(symbol)),
|
||||
detail=build_screening_detail(
|
||||
layer="确认",
|
||||
state="爆发",
|
||||
signals=result.get("signals", []),
|
||||
detail={
|
||||
"candidate_stage": "trade_confirm",
|
||||
"confirmation_status": "confirmed",
|
||||
"final_action": (result.get("entry_plan") or {}).get("entry_action", ""),
|
||||
"fresh_reason": result.get("fresh_reason", ""),
|
||||
"trigger_context": result.get("trigger_context") or {},
|
||||
"entry_plan": result.get("entry_plan") or {},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
# 🟢 只做做多!方向永远多头
|
||||
@ -1299,13 +1312,20 @@ def main(compact: bool = False):
|
||||
sector=cand_detail.get("sector", cand.get("sector", "")),
|
||||
leader_status=cand_detail.get("leader_status", cand.get("leader_status", "")),
|
||||
is_meme=int(is_meme_coin(symbol)),
|
||||
detail={
|
||||
"confirmed": False,
|
||||
"reason": "确认未通过",
|
||||
"entry_plan": result.get("entry_plan") or {},
|
||||
"fresh_reason": result.get("fresh_reason", ""),
|
||||
"trigger_context": result.get("trigger_context") or {},
|
||||
},
|
||||
detail=build_screening_detail(
|
||||
layer="确认",
|
||||
state=cand.get("state", "蓄力"),
|
||||
signals=result.get("signals", []),
|
||||
detail={
|
||||
"candidate_stage": "trade_confirm",
|
||||
"confirmed": False,
|
||||
"confirmation_status": "rejected",
|
||||
"reason": "确认未通过",
|
||||
"entry_plan": result.get("entry_plan") or {},
|
||||
"fresh_reason": result.get("fresh_reason", ""),
|
||||
"trigger_context": result.get("trigger_context") or {},
|
||||
},
|
||||
),
|
||||
)
|
||||
result["state_update"] = {"should_alert": False, "reason": "未确认爆发"}
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@ import sys
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
@ -52,6 +52,13 @@ from app.core.pa_engine import (
|
||||
classify_candles, calc_atr, find_supply_demand_zones,
|
||||
find_continuous_k, detect_ignition_point, full_pa_analysis,
|
||||
)
|
||||
from app.core.opportunity_funnel import (
|
||||
build_screening_detail,
|
||||
discovery_source_types,
|
||||
quality_filter_reasons,
|
||||
universe_gate_reason,
|
||||
)
|
||||
from app.core.signal_taxonomy import signal_codes as build_signal_codes
|
||||
|
||||
exchange = ccxt.binance({"enableRateLimit": True})
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
@ -83,18 +90,24 @@ def get_dynamic_weights():
|
||||
def fetch_all_tickers():
|
||||
tickers = exchange.fetch_tickers()
|
||||
usdt_pairs = {}
|
||||
universe_exclusions = []
|
||||
for symbol, info in tickers.items():
|
||||
if "/USDT" in symbol:
|
||||
base = symbol.split("/")[0]
|
||||
vol_usd = info.get("quoteVolume", 0) or 0
|
||||
if base in STABLECOINS or base in WRAPPED or base in BTC_ETH or base in GOLD_METAL or base in BNB_CHAIN:
|
||||
reason = universe_gate_reason(base, vol_usd, 0, symbol=symbol) or {"reason_code": "excluded_base", "reason_label": "排除基础资产"}
|
||||
universe_exclusions.append({"symbol": symbol, "base": base, "price": info.get("last", 0) or 0, "volume_24h": vol_usd, **reason})
|
||||
continue
|
||||
if base in EXCLUDED_BASES:
|
||||
universe_exclusions.append({"symbol": symbol, "base": base, "price": info.get("last", 0) or 0, "volume_24h": vol_usd, "reason_code": "invalid_pair", "reason_label": "交易对异常"})
|
||||
continue
|
||||
if base.endswith(EXCLUDED_BASE_SUFFIXES):
|
||||
universe_exclusions.append({"symbol": symbol, "base": base, "price": info.get("last", 0) or 0, "volume_24h": vol_usd, "reason_code": "invalid_pair", "reason_label": "交易对异常"})
|
||||
continue
|
||||
if not base.isascii():
|
||||
universe_exclusions.append({"symbol": symbol, "base": base, "price": info.get("last", 0) or 0, "volume_24h": vol_usd, "reason_code": "non_ascii", "reason_label": "非标准交易对"})
|
||||
continue
|
||||
vol_usd = info.get("quoteVolume", 0) or 0
|
||||
usdt_pairs[symbol] = {
|
||||
"price": info.get("last", 0),
|
||||
"change_24h": info.get("percentage", 0) or 0,
|
||||
@ -102,6 +115,7 @@ def fetch_all_tickers():
|
||||
"high_24h": info.get("high", 0),
|
||||
"low_24h": info.get("low", 0),
|
||||
}
|
||||
fetch_all_tickers.last_universe_exclusions = universe_exclusions
|
||||
return usdt_pairs
|
||||
|
||||
|
||||
@ -561,12 +575,43 @@ def _build_signal_recency(cand):
|
||||
return {"status": status, "current": current, "stale": stale}
|
||||
|
||||
|
||||
def _log_universe_exclusions(exclusions, max_logs=120):
|
||||
"""把交易宇宙过滤结果写入链路日志,避免页面看不到第一道漏斗。"""
|
||||
logged = 0
|
||||
for item in (exclusions or [])[:max_logs]:
|
||||
detail = build_screening_detail(
|
||||
layer="universe_gate",
|
||||
state="过期",
|
||||
detail={
|
||||
"reason_code": item.get("reason_code", ""),
|
||||
"reason_label": item.get("reason_label", ""),
|
||||
"volume_24h": item.get("volume_24h", 0),
|
||||
"candidate_stage": "universe_gate",
|
||||
},
|
||||
)
|
||||
log_screening(
|
||||
layer="universe_gate",
|
||||
symbol=item.get("symbol", ""),
|
||||
state="过期",
|
||||
score=0,
|
||||
price=item.get("price", 0) or 0,
|
||||
signals=[item.get("reason_label", "交易宇宙过滤")],
|
||||
change_24h=item.get("change_24h", 0) or 0,
|
||||
funding_rate=0,
|
||||
detail=detail,
|
||||
)
|
||||
logged += 1
|
||||
return logged
|
||||
|
||||
|
||||
# ==================== 第一层:粗筛 ====================
|
||||
|
||||
def layer1_coarse_filter():
|
||||
"""粗筛 — 只检测量价行为+布林收窄,不计算任何滞后指标"""
|
||||
print("=== 第一层:粗筛(v11纯前瞻) ===")
|
||||
tickers = fetch_all_tickers()
|
||||
universe_exclusions = list(getattr(fetch_all_tickers, "last_universe_exclusions", []) or [])
|
||||
excluded_symbols = {item.get("symbol", "") for item in universe_exclusions}
|
||||
funding_rates = fetch_funding_rates()
|
||||
weights = get_dynamic_weights()
|
||||
candidates = {}
|
||||
@ -574,14 +619,14 @@ def layer1_coarse_filter():
|
||||
# === 24h筛选历史豁免 (v1.6.9) ===
|
||||
# 过去24h内在screening_log出现过的币,不受"涨太多"过滤限制
|
||||
# 防止ICP/SUI类:系统早已盯上但被burst_threshold×1.5误挡
|
||||
import sqlite3 as _sq
|
||||
_c = _sq.connect(os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db")))
|
||||
from app.db.schema import get_conn as _get_conn
|
||||
_c = _get_conn()
|
||||
_recent = _c.execute("""
|
||||
SELECT DISTINCT symbol FROM screening_log
|
||||
WHERE scan_time >= datetime('now', '-24 hours')
|
||||
""").fetchall()
|
||||
WHERE scan_time >= %s
|
||||
""", ((datetime.now() - timedelta(hours=24)).isoformat(),)).fetchall()
|
||||
_c.close()
|
||||
recently_screened = {r[0] for r in _recent}
|
||||
recently_screened = {r["symbol"] for r in _recent}
|
||||
print(f" 24h已筛选币种: {len(recently_screened)} 只,豁免涨太多过滤")
|
||||
|
||||
try:
|
||||
@ -848,6 +893,27 @@ def layer1_coarse_filter():
|
||||
cs_count_total += 1
|
||||
added = True
|
||||
|
||||
# 第一道漏斗:把明确不可交易/太低成交额的资产写成独立阶段,研发侧可审计,
|
||||
# 但不让它们进入后续机会链路。
|
||||
low_turnover_threshold = min(v for v in [main_min_vol, bypass_min_vol, hl_min_vol, cs_min_vol] if v != float("inf"))
|
||||
for symbol, info in tickers.items():
|
||||
if symbol in candidates or symbol in excluded_symbols:
|
||||
continue
|
||||
if float(info.get("volume_24h") or 0) < low_turnover_threshold:
|
||||
gate = universe_gate_reason(symbol.split("/")[0], info.get("volume_24h") or 0, low_turnover_threshold, symbol=symbol)
|
||||
if gate:
|
||||
universe_exclusions.append({
|
||||
"symbol": symbol,
|
||||
"base": symbol.split("/")[0],
|
||||
"price": info.get("price", 0) or 0,
|
||||
"volume_24h": info.get("volume_24h", 0) or 0,
|
||||
"change_24h": info.get("change_24h", 0) or 0,
|
||||
**gate,
|
||||
})
|
||||
excluded_symbols.add(symbol)
|
||||
|
||||
universe_logged = _log_universe_exclusions(universe_exclusions)
|
||||
|
||||
if bypass_count or hl_count_total or cs_count_total:
|
||||
parts = []
|
||||
if bypass_count:
|
||||
@ -881,27 +947,40 @@ def layer1_coarse_filter():
|
||||
print(f"舆情模块加载失败(非致命): {e}")
|
||||
|
||||
total_bypass = bypass_count + hl_count_total + cs_count_total
|
||||
print(f"粗筛结果: {len(candidates)}个候选(含{total_bypass}个旁路: 静K{bypass_count}+底抬{hl_count_total}+压放{cs_count_total})")
|
||||
print(f"粗筛结果: {len(candidates)}个候选(宇宙过滤{len(universe_exclusions)}个,记录{universe_logged}个;含{total_bypass}个旁路: 静K{bypass_count}+底抬{hl_count_total}+压放{cs_count_total})")
|
||||
for symbol, cand in candidates.items():
|
||||
signals = cand.get("anomalies", [])
|
||||
log_screening(
|
||||
layer="粗筛",
|
||||
symbol=symbol,
|
||||
state="候选",
|
||||
score=cand.get("anomaly_score", 0),
|
||||
price=cand.get("price", 0),
|
||||
signals=cand.get("anomalies", []),
|
||||
signals=signals,
|
||||
is_meme=int(cand.get("is_meme") or 0),
|
||||
change_24h=cand.get("change_24h", 0),
|
||||
funding_rate=cand.get("funding_rate", 0),
|
||||
detail={
|
||||
"candidate_stage": "coarse_candidate",
|
||||
"volume_24h": cand.get("volume_24h", 0),
|
||||
"turnover_acceleration_1h": cand.get("turnover_acceleration_1h", 0),
|
||||
"turnover_acceleration_4h": cand.get("turnover_acceleration_4h", 0),
|
||||
"signal_recency": _build_signal_recency(cand),
|
||||
"bypass_origin": cand.get("bypass_origin", ""),
|
||||
},
|
||||
detail=build_screening_detail(
|
||||
layer="粗筛",
|
||||
state="候选",
|
||||
signals=signals,
|
||||
candidate=cand,
|
||||
detail={
|
||||
"candidate_stage": "discovery_candidate",
|
||||
"volume_24h": cand.get("volume_24h", 0),
|
||||
"turnover_acceleration_1h": cand.get("turnover_acceleration_1h", 0),
|
||||
"turnover_acceleration_4h": cand.get("turnover_acceleration_4h", 0),
|
||||
"signal_recency": _build_signal_recency(cand),
|
||||
"bypass_origin": cand.get("bypass_origin", ""),
|
||||
"source_types": discovery_source_types(cand),
|
||||
"signal_codes": build_signal_codes(signals),
|
||||
},
|
||||
),
|
||||
)
|
||||
layer1_coarse_filter.last_funnel_meta = {
|
||||
"universe_gate_count": len(universe_exclusions),
|
||||
"universe_gate_logged": universe_logged,
|
||||
}
|
||||
return candidates
|
||||
|
||||
|
||||
@ -911,6 +990,7 @@ def layer2_fine_filter(candidates):
|
||||
"""细筛 — 静K蓄力+量价突变(山寨币专用 v1.5)"""
|
||||
print("=== 第二层:细筛(v11纯前瞻) ===")
|
||||
qualified = {}
|
||||
rejected_count = 0
|
||||
weights = get_dynamic_weights()
|
||||
|
||||
# 板块联动检测
|
||||
@ -1098,6 +1178,8 @@ def layer2_fine_filter(candidates):
|
||||
elif origin == "compression_surge" and not force_accumulate_reason:
|
||||
force_accumulate_reason = "压缩放量旁路"
|
||||
|
||||
quality = quality_filter_reasons(cand, int(score or 0), accumulate_threshold, signals)
|
||||
|
||||
if state in ("蓄力", "加速"):
|
||||
sector_str = ",".join(coin_sectors)
|
||||
leader_str = ""
|
||||
@ -1164,6 +1246,8 @@ def layer2_fine_filter(candidates):
|
||||
"leader_status": leader_str,
|
||||
"leader_pct": leader_pct,
|
||||
},
|
||||
"candidate_stage": "qualified_candidate",
|
||||
"next_stage": "trade_confirm",
|
||||
}
|
||||
|
||||
log_screening(
|
||||
@ -1172,14 +1256,64 @@ def layer2_fine_filter(candidates):
|
||||
sector=sector_str, leader_status=leader_str,
|
||||
is_meme=int(meme), change_24h=cand["change_24h"],
|
||||
funding_rate=cand["funding_rate"],
|
||||
detail=build_screening_detail(
|
||||
layer="细筛",
|
||||
state=state,
|
||||
signals=signals,
|
||||
candidate={**cand, "signals": signals},
|
||||
detail={
|
||||
"candidate_stage": "qualified_candidate",
|
||||
"quality_reason_codes": quality["codes"],
|
||||
"quality_reason_labels": quality["labels"],
|
||||
"base_state": base_state,
|
||||
"force_reason": force_accumulate_reason or "",
|
||||
"sector_signal_count": sector_signal_count,
|
||||
"signal_recency": _build_signal_recency(cand),
|
||||
"signal_codes": build_signal_codes(signals),
|
||||
"next_stage": "trade_confirm",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
if state == "加速":
|
||||
# 初筛只负责机会发现和候选入池。交易推荐必须由确认层生成完整 entry_plan 后写入 recommendation,
|
||||
# 避免把“涨幅榜共性候选/观察池”污染成已推荐交易样本。
|
||||
qualified[symbol]["candidate_stage"] = "confirm_pending"
|
||||
qualified[symbol]["next_stage"] = "trade_confirm"
|
||||
else:
|
||||
rejected_count += 1
|
||||
reject_signals = signals or cand.get("anomalies", [])
|
||||
log_screening(
|
||||
layer="细筛",
|
||||
symbol=symbol,
|
||||
state=state,
|
||||
score=score,
|
||||
price=cand.get("price", 0),
|
||||
signals=reject_signals,
|
||||
sector=",".join(coin_sectors),
|
||||
leader_status="",
|
||||
is_meme=int(meme),
|
||||
change_24h=cand.get("change_24h", 0),
|
||||
funding_rate=cand.get("funding_rate", 0),
|
||||
detail=build_screening_detail(
|
||||
layer="细筛",
|
||||
state=state,
|
||||
signals=reject_signals,
|
||||
candidate={**cand, "signals": reject_signals},
|
||||
detail={
|
||||
"candidate_stage": "rejected_candidate",
|
||||
"reject_reason_codes": quality["codes"] or ["low_score"],
|
||||
"reject_reason_labels": quality["labels"] or ["评分不足"],
|
||||
"score": score,
|
||||
"threshold": accumulate_threshold,
|
||||
"base_state": base_state,
|
||||
"signal_recency": _build_signal_recency(cand),
|
||||
"signal_codes": build_signal_codes(reject_signals),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
print(f"细筛结果: {len(qualified)}个候选")
|
||||
layer2_fine_filter.last_funnel_meta = {"quality_rejected_count": rejected_count}
|
||||
print(f"细筛结果: {len(qualified)}个候选,淘汰{rejected_count}个")
|
||||
return qualified, hot_sectors, leaders
|
||||
|
||||
|
||||
@ -1275,23 +1409,28 @@ def main(compact: bool = False):
|
||||
expire_old_recommendations()
|
||||
|
||||
candidates = layer1_coarse_filter()
|
||||
funnel_meta = getattr(layer1_coarse_filter, "last_funnel_meta", {})
|
||||
|
||||
if not candidates:
|
||||
output = {
|
||||
"status": "no_candidates",
|
||||
"message": "粗筛无候选",
|
||||
"universe_gate_count": funnel_meta.get("universe_gate_count", 0),
|
||||
"check_time": datetime.now().isoformat(),
|
||||
}
|
||||
_emit_output(output, compact=compact)
|
||||
return output
|
||||
|
||||
qualified, hot_sectors, leaders = layer2_fine_filter(candidates)
|
||||
fine_meta = getattr(layer2_fine_filter, "last_funnel_meta", {})
|
||||
|
||||
if not qualified:
|
||||
output = {
|
||||
"status": "no_qualified",
|
||||
"message": "细筛无合格候选",
|
||||
"candidates_count": len(candidates),
|
||||
"universe_gate_count": funnel_meta.get("universe_gate_count", 0),
|
||||
"quality_rejected_count": fine_meta.get("quality_rejected_count", 0),
|
||||
"check_time": datetime.now().isoformat(),
|
||||
}
|
||||
_emit_output(output, compact=compact)
|
||||
@ -1320,6 +1459,8 @@ def main(compact: bool = False):
|
||||
"status": "screened",
|
||||
"total_candidates": len(candidates),
|
||||
"total_qualified": len(qualified),
|
||||
"universe_gate_count": funnel_meta.get("universe_gate_count", 0),
|
||||
"quality_rejected_count": fine_meta.get("quality_rejected_count", 0),
|
||||
"alerts": alert_results,
|
||||
"all_qualified": qualified,
|
||||
"check_time": datetime.now().isoformat(),
|
||||
@ -1347,6 +1488,8 @@ def main(compact: bool = False):
|
||||
summary = {
|
||||
"total_candidates": output.get("total_candidates", 0),
|
||||
"total_qualified": output.get("total_qualified", 0),
|
||||
"universe_gate_count": output.get("universe_gate_count", 0),
|
||||
"quality_rejected_count": output.get("quality_rejected_count", 0),
|
||||
"alert_count": len(output.get("alerts", [])),
|
||||
}
|
||||
log_cron_run(
|
||||
|
||||
@ -11,7 +11,6 @@ import sys
|
||||
import json
|
||||
import time
|
||||
import hashlib
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.utils import parsedate_to_datetime
|
||||
from pathlib import Path
|
||||
@ -26,7 +25,9 @@ sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from app.config.config_loader import load_rules, get_meta, get_strategy_direction
|
||||
from app.db.altcoin_db import init_db, get_conn, create_recommendation, log_screening, log_cron_run, get_recommendation_for_push
|
||||
from app.db.postgres_connection import ensure_migrations_once
|
||||
from app.db.llm_insights import repair_mojibake_json, repair_mojibake_text
|
||||
from app.core.opportunity_funnel import build_screening_detail
|
||||
from app.services.altcoin_screener import (
|
||||
fetch_all_tickers,
|
||||
detect_volume_price_fly,
|
||||
@ -43,8 +44,6 @@ from app.services.altcoin_confirm import fetch_derivatives_context
|
||||
from app.core.pa_engine import full_pa_analysis, calc_atr
|
||||
from app.integrations.push_orchestrator import push_mainline_state_update
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
DB_PATH = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db"))
|
||||
exchange = ccxt.binance({"enableRateLimit": True})
|
||||
|
||||
LEVEL_RANK = {"S": 4, "A": 3, "B": 2, "C": 1, "D": 0, "RISK": 5}
|
||||
@ -95,31 +94,7 @@ def _event_hash(source, title, symbol):
|
||||
|
||||
|
||||
def init_event_tables():
|
||||
conn = get_conn()
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS event_news (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_hash TEXT UNIQUE,
|
||||
source TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT DEFAULT '',
|
||||
published_at TEXT NOT NULL,
|
||||
detected_at TEXT NOT NULL,
|
||||
importance TEXT DEFAULT 'B',
|
||||
event_type TEXT DEFAULT '',
|
||||
raw_json TEXT DEFAULT '{}',
|
||||
processed INTEGER DEFAULT 0,
|
||||
decision TEXT DEFAULT '',
|
||||
tech_score INTEGER DEFAULT 0,
|
||||
rec_id INTEGER DEFAULT 0,
|
||||
pushed INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_event_news_time ON event_news(published_at, detected_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_event_news_symbol ON event_news(symbol)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
ensure_migrations_once()
|
||||
|
||||
|
||||
def _symbol_from_title(title):
|
||||
@ -359,19 +334,20 @@ def store_events(events):
|
||||
for e in events:
|
||||
h = _event_hash(e["source"], e["title"], e["symbol"])
|
||||
try:
|
||||
conn.execute("""
|
||||
cur = conn.execute("""
|
||||
INSERT INTO event_news
|
||||
(event_hash, source, symbol, title, url, published_at, detected_at, importance, event_type, raw_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT(event_hash) DO NOTHING
|
||||
RETURNING id
|
||||
""", (
|
||||
h, e["source"], e["symbol"], e["title"], e.get("url", ""),
|
||||
e["published_at"].isoformat(), now, e["importance"], e["event_type"],
|
||||
json.dumps(e.get("raw", {}), ensure_ascii=False),
|
||||
))
|
||||
e["event_hash"] = h
|
||||
stored.append(e)
|
||||
except sqlite3.IntegrityError:
|
||||
continue
|
||||
if cur.fetchone():
|
||||
e["event_hash"] = h
|
||||
stored.append(e)
|
||||
except Exception as ex:
|
||||
print(f"[event] store error {e.get('title')}: {ex}")
|
||||
conn.commit()
|
||||
@ -462,7 +438,7 @@ def enqueue_llm_sentiment_candidates(analysis, source_insight_id="", min_confide
|
||||
recent = conn.execute(
|
||||
"""
|
||||
SELECT id FROM event_news
|
||||
WHERE source='llm_sentiment' AND symbol=? AND detected_at >= ?
|
||||
WHERE source='llm_sentiment' AND symbol=%s AND detected_at >= %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(event["symbol"], cooldown_cutoff),
|
||||
@ -472,11 +448,13 @@ def enqueue_llm_sentiment_candidates(analysis, source_insight_id="", min_confide
|
||||
continue
|
||||
h = _event_hash(event["source"], event["title"], event["symbol"])
|
||||
try:
|
||||
conn.execute(
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO event_news
|
||||
(event_hash, source, symbol, title, url, published_at, detected_at, importance, event_type, raw_json, processed)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 0)
|
||||
ON CONFLICT(event_hash) DO NOTHING
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
h,
|
||||
@ -491,9 +469,10 @@ def enqueue_llm_sentiment_candidates(analysis, source_insight_id="", min_confide
|
||||
json.dumps(event.get("raw", {}), ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
skipped += 1
|
||||
continue
|
||||
queued.append(event["symbol"])
|
||||
except sqlite3.IntegrityError:
|
||||
skipped += 1
|
||||
except Exception as exc:
|
||||
print(f"[event] llm candidate enqueue error {event.get('symbol')}: {exc}")
|
||||
skipped += 1
|
||||
@ -704,6 +683,21 @@ def process_event(event):
|
||||
is_meme=0,
|
||||
change_24h=(result.get("ticker") or {}).get("change_24h", 0),
|
||||
funding_rate=(result.get("derivatives") or {}).get("funding_rate", 0),
|
||||
detail=build_screening_detail(
|
||||
layer="舆情触发",
|
||||
state="爆发" if decision == "recommend" else "蓄力" if decision == "observe" else "风险" if decision == "risk" else "过期",
|
||||
signals=signals,
|
||||
detail={
|
||||
"candidate_stage": "discovery_candidate",
|
||||
"decision": decision,
|
||||
"reason": result.get("reason", ""),
|
||||
"event_source": event.get("source"),
|
||||
"event_title": event.get("title"),
|
||||
"event_importance": event.get("importance"),
|
||||
"trigger_context": result.get("trigger_context") or {},
|
||||
"signal_codes": [],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
if decision == "recommend":
|
||||
@ -746,8 +740,8 @@ def process_event(event):
|
||||
|
||||
conn = get_conn()
|
||||
conn.execute("""
|
||||
UPDATE event_news SET processed=1, decision=?, tech_score=?, rec_id=?, pushed=?
|
||||
WHERE event_hash=?
|
||||
UPDATE event_news SET processed=1, decision=%s, tech_score=%s, rec_id=%s, pushed=%s
|
||||
WHERE event_hash=%s
|
||||
""", (decision, result.get("score", 0), rec_id, int(pushed), event.get("event_hash")))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@ -760,8 +754,8 @@ def load_unprocessed_events(limit=20):
|
||||
cutoff = (_now() - timedelta(hours=_cfg().get("max_event_age_hours", 6))).isoformat()
|
||||
rows = conn.execute("""
|
||||
SELECT * FROM event_news
|
||||
WHERE processed=0 AND published_at >= ?
|
||||
ORDER BY published_at DESC LIMIT ?
|
||||
WHERE processed=0 AND published_at >= %s
|
||||
ORDER BY published_at DESC LIMIT %s
|
||||
""", (cutoff, limit)).fetchall()
|
||||
conn.close()
|
||||
events = []
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import requests
|
||||
|
||||
@ -229,7 +229,6 @@ def _build_sentiment_payload(row):
|
||||
|
||||
def _build_sentiment_batch_payload(hours=24, limit=40):
|
||||
conn = get_conn()
|
||||
conn.row_factory = None
|
||||
events = []
|
||||
try:
|
||||
rows = conn.execute(
|
||||
@ -237,11 +236,11 @@ def _build_sentiment_batch_payload(hours=24, limit=40):
|
||||
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 >= datetime('now', '-' || ? || ' hours')
|
||||
ORDER BY datetime(published_at) DESC, id DESC
|
||||
LIMIT ?
|
||||
WHERE detected_at >= %s
|
||||
ORDER BY published_at::timestamp DESC, id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(int(hours or 24), int(limit or 40)),
|
||||
((datetime.now() - timedelta(hours=int(hours or 24))).isoformat(), int(limit or 40)),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
rows = []
|
||||
@ -421,14 +420,13 @@ def generate_sentiment_insights(limit=30):
|
||||
if not get_llm_module_enabled("sentiment"):
|
||||
return {"status": "skipped", "reason": "module_disabled", "processed": 0}
|
||||
conn = get_conn()
|
||||
conn.row_factory = None
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id AS event_id, source, symbol, title, url, published_at, detected_at, importance,
|
||||
event_type, decision, tech_score, rec_id, pushed
|
||||
FROM event_news
|
||||
ORDER BY datetime(published_at) DESC, id DESC
|
||||
ORDER BY published_at::timestamp DESC, id DESC
|
||||
LIMIT 120
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
@ -144,7 +144,7 @@ def _latest_metric(symbol, chain, contract_address):
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM onchain_token_metrics
|
||||
WHERE symbol=? AND chain=? AND contract_address=? AND window='1h'
|
||||
WHERE symbol=%s AND chain=%s AND contract_address=%s AND "window"='1h'
|
||||
ORDER BY metric_time DESC, id DESC LIMIT 1
|
||||
""",
|
||||
(symbol, chain, contract_address or ""),
|
||||
@ -262,7 +262,7 @@ def _discover_seed_symbols(limit=120):
|
||||
FROM recommendation
|
||||
WHERE status='active' AND COALESCE(display_bucket,'watch_pool') != 'history'
|
||||
ORDER BY rec_time DESC
|
||||
LIMIT ?
|
||||
LIMIT %s
|
||||
""",
|
||||
(int(limit or 120),),
|
||||
).fetchall()
|
||||
@ -276,7 +276,7 @@ def _discover_seed_symbols(limit=120):
|
||||
FROM coin_state
|
||||
WHERE state != '过期'
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT ?
|
||||
LIMIT %s
|
||||
""",
|
||||
(int(limit or 120),),
|
||||
).fetchall()
|
||||
@ -589,19 +589,19 @@ def enqueue_onchain_candidates(min_score=None, min_confidence=None, cooldown_hou
|
||||
COALESCE((
|
||||
SELECT m.onchain_score FROM onchain_token_metrics m
|
||||
WHERE m.symbol=e.symbol AND m.chain=e.chain
|
||||
ORDER BY datetime(m.metric_time) DESC, m.id DESC LIMIT 1
|
||||
ORDER BY m.metric_time::timestamp DESC, m.id DESC LIMIT 1
|
||||
), 0) AS latest_onchain_score,
|
||||
COALESCE((
|
||||
SELECT m.risk_score FROM onchain_token_metrics m
|
||||
WHERE m.symbol=e.symbol AND m.chain=e.chain
|
||||
ORDER BY datetime(m.metric_time) DESC, m.id DESC LIMIT 1
|
||||
ORDER BY m.metric_time::timestamp DESC, m.id DESC LIMIT 1
|
||||
), 0) AS latest_risk_score
|
||||
FROM onchain_events e
|
||||
WHERE e.status IN ('new', 'candidate_failed')
|
||||
AND e.detected_at >= ?
|
||||
AND e.detected_at >= %s
|
||||
AND e.direction='positive'
|
||||
ORDER BY e.confidence DESC, e.value_usd DESC, datetime(e.detected_at) DESC
|
||||
LIMIT ?
|
||||
ORDER BY e.confidence DESC, e.value_usd DESC, e.detected_at::timestamp DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(cutoff, int(limit or 20)),
|
||||
).fetchall()
|
||||
@ -621,7 +621,7 @@ def enqueue_onchain_candidates(min_score=None, min_confidence=None, cooldown_hou
|
||||
recent = conn.execute(
|
||||
"""
|
||||
SELECT id FROM event_news
|
||||
WHERE source='onchain' AND symbol=? AND detected_at >= ?
|
||||
WHERE source='onchain' AND symbol=%s AND detected_at >= %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(symbol, cooldown_cutoff),
|
||||
@ -636,7 +636,7 @@ def enqueue_onchain_candidates(min_score=None, min_confidence=None, cooldown_hou
|
||||
"""
|
||||
INSERT INTO event_news
|
||||
(event_hash, source, symbol, title, url, published_at, detected_at, importance, event_type, raw_json, processed)
|
||||
VALUES (?, 'onchain', ?, ?, ?, ?, ?, ?, 'onchain_candidate', ?, 0)
|
||||
VALUES (%s, 'onchain', %s, %s, %s, %s, %s, %s, 'onchain_candidate', %s, 0)
|
||||
""",
|
||||
(
|
||||
h,
|
||||
@ -661,13 +661,13 @@ def enqueue_onchain_candidates(min_score=None, min_confidence=None, cooldown_hou
|
||||
),
|
||||
),
|
||||
)
|
||||
conn.execute("UPDATE onchain_events SET status='candidate_queued' WHERE id=?", (event.get("id"),))
|
||||
conn.execute("UPDATE onchain_events SET status='candidate_queued' WHERE id=%s", (event.get("id"),))
|
||||
queued.append(symbol)
|
||||
except Exception:
|
||||
skipped_ids.append(event["id"])
|
||||
if skipped_ids:
|
||||
conn.execute(
|
||||
"UPDATE onchain_events SET status='candidate_skipped' WHERE id IN (" + ",".join(["?"] * len(skipped_ids)) + ")",
|
||||
"UPDATE onchain_events SET status='candidate_skipped' WHERE id IN (" + ",".join(["%s"] * len(skipped_ids)) + ")",
|
||||
tuple(skipped_ids),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@ -388,9 +388,9 @@ def track_prices():
|
||||
# v1.7.8: 只要跟踪位有变化就写DB(上移时必变;首次激活也写)
|
||||
if abs(trail_level - old_trail) > 0.000001:
|
||||
entry_plan["trailing_stop_level"] = trail_level
|
||||
import sqlite3 as _sq
|
||||
_c2 = _sq.connect(os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db")))
|
||||
_c2.execute("UPDATE recommendation SET entry_plan_json=? WHERE id=?",
|
||||
from app.db.schema import get_conn as _get_conn
|
||||
_c2 = _get_conn()
|
||||
_c2.execute("UPDATE recommendation SET entry_plan_json=%s WHERE id=%s",
|
||||
(json.dumps(entry_plan, ensure_ascii=False), rec["id"]))
|
||||
_c2.commit()
|
||||
_c2.close()
|
||||
|
||||
@ -136,24 +136,24 @@ def _get_reviewable_recommendations(now=None):
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT * FROM recommendation
|
||||
WHERE julianday(?) - julianday(rec_time) > 1
|
||||
AND rec_time >= ?
|
||||
WHERE rec_time <= %s
|
||||
AND rec_time >= %s
|
||||
AND id NOT IN (SELECT rec_id FROM review_log)
|
||||
{executable_filter}
|
||||
ORDER BY rec_time ASC
|
||||
""",
|
||||
(now.isoformat(), revision_started_at),
|
||||
((now - timedelta(days=1)).isoformat(), revision_started_at),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT * FROM recommendation
|
||||
WHERE julianday(?) - julianday(rec_time) > 1
|
||||
WHERE rec_time <= %s
|
||||
AND id NOT IN (SELECT rec_id FROM review_log)
|
||||
{executable_filter}
|
||||
ORDER BY rec_time ASC
|
||||
""",
|
||||
(now.isoformat(),),
|
||||
((now - timedelta(days=1)).isoformat(),),
|
||||
).fetchall()
|
||||
|
||||
conn.close()
|
||||
@ -216,7 +216,7 @@ def _window_price_metrics(rec, hours=48):
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT price, track_time FROM price_tracking
|
||||
WHERE rec_id=? AND track_time >= ? AND track_time <= ?
|
||||
WHERE rec_id=%s AND track_time >= %s AND track_time <= %s
|
||||
ORDER BY track_time ASC
|
||||
""",
|
||||
(rec_id, start.isoformat(), end.isoformat()),
|
||||
@ -693,28 +693,28 @@ def scan_missed_explosions(now=None):
|
||||
if revision_started_at:
|
||||
recommended = conn.execute("""
|
||||
SELECT symbol FROM recommendation
|
||||
WHERE julianday(?) - julianday(rec_time) < 1
|
||||
AND rec_time >= ?
|
||||
""", (now.isoformat(), revision_started_at)).fetchall()
|
||||
WHERE rec_time >= %s
|
||||
AND rec_time >= %s
|
||||
""", ((now - timedelta(days=1)).isoformat(), revision_started_at)).fetchall()
|
||||
else:
|
||||
recommended = conn.execute("""
|
||||
SELECT symbol FROM recommendation
|
||||
WHERE julianday(?) - julianday(rec_time) < 1
|
||||
""", (now.isoformat(),)).fetchall()
|
||||
WHERE rec_time >= %s
|
||||
""", ((now - timedelta(days=1)).isoformat(),)).fetchall()
|
||||
recommended_symbols = set(r["symbol"] for r in recommended)
|
||||
|
||||
# 获取过去24h筛选过的币
|
||||
if revision_started_at:
|
||||
screened = conn.execute("""
|
||||
SELECT symbol, state, score, signals FROM screening_log
|
||||
WHERE layer='细筛' AND julianday(?) - julianday(scan_time) < 1
|
||||
AND scan_time >= ?
|
||||
""", (now.isoformat(), revision_started_at)).fetchall()
|
||||
WHERE layer='细筛' AND scan_time >= %s
|
||||
AND scan_time >= %s
|
||||
""", ((now - timedelta(days=1)).isoformat(), revision_started_at)).fetchall()
|
||||
else:
|
||||
screened = conn.execute("""
|
||||
SELECT symbol, state, score, signals FROM screening_log
|
||||
WHERE layer='细筛' AND julianday(?) - julianday(scan_time) < 1
|
||||
""", (now.isoformat(),)).fetchall()
|
||||
WHERE layer='细筛' AND scan_time >= %s
|
||||
""", ((now - timedelta(days=1)).isoformat(),)).fetchall()
|
||||
screened_info = {r["symbol"]: dict(r) for r in screened}
|
||||
|
||||
conn.close()
|
||||
@ -925,20 +925,11 @@ def _compute_effect_summary(now, lookback_days=7):
|
||||
start_iso = (now - timedelta(days=lookback_days)).isoformat()
|
||||
revision_started_at = _get_strategy_revision_started_at()
|
||||
effective_start = revision_started_at if revision_started_at and revision_started_at > start_iso else start_iso
|
||||
cols = [row["name"] for row in conn.execute("PRAGMA table_info(review_log)").fetchall()]
|
||||
pnl_col = "pnl" if "pnl" in cols else ("pnl_48h" if "pnl_48h" in cols else None)
|
||||
pnl_col = "pnl_48h"
|
||||
|
||||
rows = conn.execute("""
|
||||
SELECT outcome, pnl_48h FROM review_log
|
||||
WHERE review_time >= ?
|
||||
ORDER BY review_time DESC
|
||||
""", (effective_start,)).fetchall() if pnl_col == "pnl_48h" else conn.execute("""
|
||||
SELECT outcome, pnl FROM review_log
|
||||
WHERE review_time >= ?
|
||||
ORDER BY review_time DESC
|
||||
""", (effective_start,)).fetchall() if pnl_col == "pnl" else conn.execute("""
|
||||
SELECT outcome FROM review_log
|
||||
WHERE review_time >= ?
|
||||
WHERE review_time >= %s
|
||||
ORDER BY review_time DESC
|
||||
""", (effective_start,)).fetchall()
|
||||
conn.close()
|
||||
@ -980,11 +971,11 @@ def _scan_stable_fiat_pollution(now, lookback_days=7):
|
||||
suffixes = tuple(getattr(reverse_analysis, "EXCLUDED_BASE_SUFFIXES", tuple()) or tuple())
|
||||
|
||||
screening_rows = conn.execute(
|
||||
"SELECT layer, symbol, scan_time FROM screening_log WHERE scan_time >= ? ORDER BY scan_time DESC",
|
||||
"SELECT layer, symbol, scan_time FROM screening_log WHERE scan_time >= %s ORDER BY scan_time DESC",
|
||||
(effective_start,),
|
||||
).fetchall()
|
||||
recommendation_rows = conn.execute(
|
||||
"SELECT symbol, rec_time FROM recommendation WHERE rec_time >= ? ORDER BY rec_time DESC",
|
||||
"SELECT symbol, rec_time FROM recommendation WHERE rec_time >= %s ORDER BY rec_time DESC",
|
||||
(effective_start,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
@ -8,25 +8,19 @@ import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import sqlite3
|
||||
import requests
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from app.db.schema import get_conn
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
COINGECKO_TRENDING_URL = "https://api.coingecko.com/api/v3/search/trending"
|
||||
GOOGLE_NEWS_RSS = "https://news.google.com/rss/search?q={query}&hl=en-US&gl=US&ceid=US:en"
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
DB_PATH = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db"))
|
||||
|
||||
|
||||
def _get_conn():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
return get_conn()
|
||||
|
||||
|
||||
def fetch_trending_coins():
|
||||
@ -223,7 +217,7 @@ def collect_and_store():
|
||||
INSERT INTO sentiment_events
|
||||
(symbol, name, source, event_type, trend_rank, trend_score,
|
||||
market_cap_rank, extra_json, detected_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
t["symbol"], t["name"], "coingecko", "trending",
|
||||
t["trend_rank"], t["trend_score"], t["market_cap_rank"],
|
||||
@ -257,7 +251,7 @@ def _get_previous_trending():
|
||||
return []
|
||||
latest_time = row[0]
|
||||
rows = conn.execute(
|
||||
"SELECT symbol, trend_rank FROM sentiment_events WHERE detected_at = ?",
|
||||
"SELECT symbol, trend_rank FROM sentiment_events WHERE detected_at = %s",
|
||||
(latest_time,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
@ -272,7 +266,7 @@ def _get_consecutive_trending_hours(symbol):
|
||||
conn = _get_conn()
|
||||
rows = conn.execute("""
|
||||
SELECT detected_at FROM sentiment_events
|
||||
WHERE symbol = ? AND source = 'coingecko'
|
||||
WHERE symbol = %s AND source = 'coingecko'
|
||||
ORDER BY detected_at DESC LIMIT 20
|
||||
""", (symbol,)).fetchall()
|
||||
conn.close()
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Cookie
|
||||
@ -9,6 +8,68 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
from app.web.shared import require_api_user_with_subscription
|
||||
from app.services.llm_insights import attach_sentiment_insights, get_latest_sentiment_batch_analysis, get_latest_sentiment_batch_attempt
|
||||
from app.db.schema import get_conn
|
||||
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
r = req.get("https://api.alternative.me/fng/?limit=1", timeout=8)
|
||||
if r.status_code == 200:
|
||||
d0 = r.json().get("data", [{}])[0]
|
||||
result["fear_greed"] = {"value": int(d0.get("value", 50)), "classification": d0.get("value_classification", "")}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
r = req.get("https://api.coingecko.com/api/v3/search/trending", timeout=10)
|
||||
if r.status_code == 200:
|
||||
for c in r.json().get("coins", [])[:7]:
|
||||
item = c.get("item", {})
|
||||
result["trending"].append({
|
||||
"name": item.get("name", ""),
|
||||
"symbol": item.get("symbol", ""),
|
||||
"market_cap_rank": item.get("market_cap_rank"),
|
||||
"thumb": item.get("thumb", ""),
|
||||
})
|
||||
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]
|
||||
return result
|
||||
|
||||
|
||||
def build_router(repo_root: Path):
|
||||
@ -17,9 +78,7 @@ def build_router(repo_root: Path):
|
||||
@router.get("/api/sentiment")
|
||||
async def api_sentiment(hours: int = 6, altcoin_session: str = Cookie(default="")):
|
||||
require_api_user_with_subscription(altcoin_session)
|
||||
db = os.getenv("ALPHAX_DB_PATH", str(repo_root / "data" / "altcoin_monitor.db"))
|
||||
conn = sqlite3.connect(db)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn = get_conn()
|
||||
|
||||
active_recs = conn.execute("SELECT DISTINCT symbol FROM recommendation WHERE status='active'").fetchall()
|
||||
active_symbols = {r["symbol"].split("/")[0].upper() for r in active_recs}
|
||||
@ -27,9 +86,9 @@ def build_router(repo_root: Path):
|
||||
recent_screened = conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT symbol FROM screening_log
|
||||
WHERE scan_time >= datetime('now', '-' || ? || ' hours')
|
||||
WHERE scan_time >= %s
|
||||
""",
|
||||
(hours,),
|
||||
((datetime.now() - timedelta(hours=float(hours or 6))).isoformat(),),
|
||||
).fetchall()
|
||||
screened_bases = {r["symbol"].split("/")[0].upper() for r in recent_screened}
|
||||
|
||||
@ -82,16 +141,17 @@ def build_router(repo_root: Path):
|
||||
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 >= datetime('now', '-' || ? || ' hours')
|
||||
ORDER BY datetime(published_at) DESC, id DESC
|
||||
WHERE detected_at >= %s
|
||||
ORDER BY published_at::timestamp DESC, id DESC
|
||||
LIMIT 80
|
||||
""",
|
||||
(hours,),
|
||||
(event_cutoff,),
|
||||
).fetchall()
|
||||
for r in event_rows:
|
||||
base = (r["symbol"] or "").split("/")[0].upper()
|
||||
@ -266,62 +326,6 @@ def build_router(repo_root: Path):
|
||||
@router.get("/api/newsfeed")
|
||||
async def api_newsfeed(altcoin_session: str = Cookie(default="")):
|
||||
require_api_user_with_subscription(altcoin_session)
|
||||
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)
|
||||
try:
|
||||
r = req.get("https://api.alternative.me/fng/?limit=1", timeout=8)
|
||||
if r.status_code == 200:
|
||||
d0 = r.json().get("data", [{}])[0]
|
||||
result["fear_greed"] = {"value": int(d0.get("value", 50)), "classification": d0.get("value_classification", "")}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
r = req.get("https://api.coingecko.com/api/v3/search/trending", timeout=10)
|
||||
if r.status_code == 200:
|
||||
for c in r.json().get("coins", [])[:7]:
|
||||
item = c.get("item", {})
|
||||
result["trending"].append({
|
||||
"name": item.get("name", ""),
|
||||
"symbol": item.get("symbol", ""),
|
||||
"market_cap_rank": item.get("market_cap_rank"),
|
||||
"thumb": item.get("thumb", ""),
|
||||
})
|
||||
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]
|
||||
return result
|
||||
return _newsfeed_payload()
|
||||
|
||||
return router
|
||||
|
||||
48
app/web/routes_market.py
Normal file
48
app/web/routes_market.py
Normal file
@ -0,0 +1,48 @@
|
||||
from fastapi import APIRouter, Cookie
|
||||
|
||||
from app.db.analytics import get_stats
|
||||
from app.db.onchain_db import get_onchain_overview
|
||||
from app.web.shared import require_api_user_with_subscription
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/market/overview")
|
||||
async def api_market_overview(hours: int = 24, altcoin_session: str = Cookie(default="")):
|
||||
require_api_user_with_subscription(altcoin_session)
|
||||
stats = get_stats()
|
||||
onchain = get_onchain_overview(hours=hours)
|
||||
newsfeed = {}
|
||||
ai_analysis = {}
|
||||
try:
|
||||
from app.web.routes_content import _newsfeed_payload
|
||||
|
||||
newsfeed = _newsfeed_payload()
|
||||
except Exception:
|
||||
newsfeed = {}
|
||||
try:
|
||||
from app.db.llm_insights import get_latest_insight_by_type
|
||||
|
||||
latest_sentiment = get_latest_insight_by_type("sentiment_batch_analysis")
|
||||
if latest_sentiment:
|
||||
ai_analysis = {
|
||||
"status": latest_sentiment.get("status"),
|
||||
"updated_at": latest_sentiment.get("updated_at"),
|
||||
"model": latest_sentiment.get("model"),
|
||||
"prompt_version": latest_sentiment.get("prompt_version"),
|
||||
"content": latest_sentiment.get("content") or {},
|
||||
"input": latest_sentiment.get("input") or {},
|
||||
}
|
||||
except Exception:
|
||||
ai_analysis = {}
|
||||
return {
|
||||
"hours": int(hours or 24),
|
||||
"updated_at": stats.get("market_context_overview", {}).get("updated_at") if isinstance(stats, dict) else None,
|
||||
"market": {
|
||||
"stats": stats,
|
||||
"newsfeed": newsfeed,
|
||||
"onchain": onchain,
|
||||
"ai_analysis": ai_analysis,
|
||||
},
|
||||
}
|
||||
@ -102,6 +102,13 @@ def build_router(templates, repo_root: Path, stock_report_template: str):
|
||||
resp.headers["Expires"] = "0"
|
||||
return resp
|
||||
|
||||
@router.get("/market", response_class=HTMLResponse)
|
||||
async def market_page(request: Request):
|
||||
user, redirect = require_page_user(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
return render_page("market.html", request)
|
||||
|
||||
@router.get("/sentiment", response_class=HTMLResponse)
|
||||
async def sentiment_page(request: Request):
|
||||
user, redirect = require_page_user(request)
|
||||
|
||||
@ -84,7 +84,7 @@ async def api_strategy_lifecycle(days: int = 30, altcoin_session: str = Cookie(d
|
||||
async def api_iterations(limit: int = 30, altcoin_session: str = Cookie(default="")):
|
||||
require_api_user_with_subscription(altcoin_session)
|
||||
conn = get_conn()
|
||||
rows = conn.execute("SELECT * FROM strategy_iteration_log ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
||||
rows = conn.execute("SELECT * FROM strategy_iteration_log ORDER BY id DESC LIMIT %s", (limit,)).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ from app.db.recommendation_queries import get_active_recommendations, get_active
|
||||
from app.web.routes_admin import build_router as build_admin_router
|
||||
from app.web.routes_auth import router as auth_router
|
||||
from app.web.routes_content import build_router as build_content_router
|
||||
from app.web.routes_market import router as market_router
|
||||
from app.web.routes_onchain import router as onchain_router
|
||||
from app.web.routes_pages import build_router as build_pages_router
|
||||
from app.web.routes_recommendations import router as recommendations_router
|
||||
@ -43,6 +44,7 @@ app.include_router(auth_router)
|
||||
app.include_router(recommendations_router)
|
||||
app.include_router(strategy_router)
|
||||
app.include_router(onchain_router)
|
||||
app.include_router(market_router)
|
||||
app.include_router(build_admin_router(templates))
|
||||
app.include_router(build_content_router(REPO_ROOT))
|
||||
app.include_router(build_pages_router(templates, REPO_ROOT, STOCK_REPORT_TEMPLATE))
|
||||
|
||||
@ -1,4 +1,24 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
container_name: alphax-postgres
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
POSTGRES_DB: "${POSTGRES_DB:-alphax_dev}"
|
||||
POSTGRES_USER: "${POSTGRES_USER:-alphax}"
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-alphax_dev_password}"
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5433}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-alphax} -d ${POSTGRES_DB:-alphax_dev}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
alphax-web:
|
||||
build:
|
||||
context: .
|
||||
@ -10,7 +30,9 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
PORT: "8190"
|
||||
ALPHAX_DB_PATH: "/app/data/altcoin_monitor.db"
|
||||
ALPHAX_ENV: "${ALPHAX_ENV:-dev}"
|
||||
ALPHAX_DB_BACKEND: "postgres"
|
||||
DATABASE_URL: "${DATABASE_URL:-postgresql://alphax:alphax_dev_password@postgres:5432/alphax_dev}"
|
||||
# 仅当 app_user 表为空时创建默认管理员;已有用户或迁移旧库时不会覆盖。
|
||||
ALPHAX_BOOTSTRAP_ADMIN: "${ALPHAX_BOOTSTRAP_ADMIN:-1}"
|
||||
ALPHAX_DEFAULT_ADMIN_EMAIL: "${ALPHAX_DEFAULT_ADMIN_EMAIL:-admin@alphax.local}"
|
||||
@ -40,11 +62,16 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
# 本地 Docker 副本需要真实跑链路,方便验证筛选/确认/跟踪/复盘结果。
|
||||
# 调度器以后台子进程方式并发执行,并通过业务锁组规避 SQLite 写冲突。
|
||||
# 调度器以后台子进程方式并发执行,并通过业务锁组规避主推荐写入冲突。
|
||||
ALPHAX_SCHEDULER_DRY_RUN: "0"
|
||||
ALPHAX_DB_PATH: "/app/data/altcoin_monitor.db"
|
||||
ALPHAX_ENV: "${ALPHAX_ENV:-dev}"
|
||||
ALPHAX_DB_BACKEND: "postgres"
|
||||
DATABASE_URL: "${DATABASE_URL:-postgresql://alphax:alphax_dev_password@postgres:5432/alphax_dev}"
|
||||
command: ["scheduler"]
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
- ./rules.yaml:/app/rules.yaml
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@ -4,12 +4,6 @@ set -euo pipefail
|
||||
cd /app
|
||||
mkdir -p /app/data /app/logs
|
||||
|
||||
export ALPHAX_DB_PATH="${ALPHAX_DB_PATH:-/app/data/altcoin_monitor.db}"
|
||||
# 若首次启动没有 DB,则创建空文件,init_db 会补表结构。
|
||||
if [ ! -e "$ALPHAX_DB_PATH" ]; then
|
||||
touch "$ALPHAX_DB_PATH"
|
||||
fi
|
||||
|
||||
case "${1:-web}" in
|
||||
web)
|
||||
exec python -m uvicorn app.web.web_server:app --host 0.0.0.0 --port "${PORT:-8190}"
|
||||
|
||||
115
docs/postgres_migration.md
Normal file
115
docs/postgres_migration.md
Normal file
@ -0,0 +1,115 @@
|
||||
# AlphaX PostgreSQL Migration
|
||||
|
||||
This is the first PostgreSQL migration milestone. It prepares a PostgreSQL target, imports the current SQLite data, and validates the result. The application runtime still defaults to SQLite until the data-access layer is explicitly switched.
|
||||
|
||||
## Environments
|
||||
|
||||
Only two environments are planned:
|
||||
|
||||
- `dev`: local Docker and development validation.
|
||||
- `production`: server deployment after dev import and validation pass.
|
||||
|
||||
Recommended variables:
|
||||
|
||||
```bash
|
||||
ALPHAX_ENV=dev
|
||||
ALPHAX_DB_BACKEND=sqlite
|
||||
POSTGRES_DB=alphax_dev
|
||||
POSTGRES_USER=alphax
|
||||
POSTGRES_PASSWORD=alphax_dev_password
|
||||
DATABASE_URL=postgresql://alphax:alphax_dev_password@localhost:5433/alphax_dev
|
||||
```
|
||||
|
||||
Inside Docker, use host `postgres` and port `5432`:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://alphax:alphax_dev_password@postgres:5432/alphax_dev
|
||||
```
|
||||
|
||||
## Dev Migration Flow
|
||||
|
||||
Start PostgreSQL:
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres
|
||||
```
|
||||
|
||||
Recommended Docker-based flow:
|
||||
|
||||
```bash
|
||||
docker compose build alphax-web
|
||||
docker compose run --rm alphax-web python scripts/postgres/run_migrations.py
|
||||
docker compose run --rm alphax-web python scripts/postgres/import_from_sqlite.py \
|
||||
--sqlite-path /app/data/altcoin_monitor.db \
|
||||
--scheduler-sqlite-path /app/data/scheduler_state.db \
|
||||
--truncate
|
||||
docker compose run --rm alphax-web python scripts/postgres/validate_import.py \
|
||||
--sqlite-path /app/data/altcoin_monitor.db \
|
||||
--scheduler-sqlite-path /app/data/scheduler_state.db \
|
||||
--all-tables
|
||||
```
|
||||
|
||||
Host-based flow is also available when local Python has `psycopg` installed.
|
||||
|
||||
Apply schema migrations:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://alphax:alphax_dev_password@localhost:5433/alphax_dev \
|
||||
python3 scripts/postgres/run_migrations.py
|
||||
```
|
||||
|
||||
Import SQLite data:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://alphax:alphax_dev_password@localhost:5433/alphax_dev \
|
||||
python3 scripts/postgres/import_from_sqlite.py \
|
||||
--sqlite-path data/altcoin_monitor.db \
|
||||
--scheduler-sqlite-path data/scheduler_state.db \
|
||||
--truncate
|
||||
```
|
||||
|
||||
Validate counts and key IDs:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://alphax:alphax_dev_password@localhost:5433/alphax_dev \
|
||||
python3 scripts/postgres/validate_import.py --all-tables
|
||||
```
|
||||
|
||||
## Backup And Restore
|
||||
|
||||
Backup:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://alphax:alphax_dev_password@localhost:5433/alphax_dev \
|
||||
bash scripts/postgres/backup.sh
|
||||
```
|
||||
|
||||
Restore:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://alphax:alphax_dev_password@localhost:5433/alphax_dev \
|
||||
bash scripts/postgres/restore.sh backups/postgres/alphax_dev_YYYYmmdd_HHMMSS.dump
|
||||
```
|
||||
|
||||
## Production Cutover Checklist
|
||||
|
||||
1. Stop scheduler writes.
|
||||
2. Back up SQLite and PostgreSQL.
|
||||
3. Apply PostgreSQL migrations.
|
||||
4. Import SQLite with `--truncate`.
|
||||
5. Run validation with `--all-tables`.
|
||||
6. Run read-only API smoke tests against PostgreSQL in a staging shell.
|
||||
7. Switch runtime only after the DB adapter layer is complete.
|
||||
8. Restart web and scheduler.
|
||||
9. Watch cron logs, recommendation stats, auth login, and key pages.
|
||||
|
||||
## Current Boundary
|
||||
|
||||
This milestone does not replace SQLite in the application runtime. It gives us a repeatable PostgreSQL schema, import path, validation report, and backup/restore workflow. Runtime cutover should be a separate phase with focused tests for recommendations, auth, scheduler state, LLM insights, sentiment, and onchain pages.
|
||||
|
||||
Verified locally on 2026-05-16:
|
||||
|
||||
- PostgreSQL container started and became healthy.
|
||||
- `0001_initial_postgres.sql` applied through `scripts/postgres/run_migrations.py`.
|
||||
- Current SQLite main DB and `scheduler_state.db` imported into PostgreSQL.
|
||||
- `validate_import.py --all-tables` passed for 36 tables.
|
||||
@ -10,3 +10,4 @@ python-multipart==0.0.20
|
||||
pytest==8.3.4
|
||||
httpx==0.28.1
|
||||
jinja2==3.1.6
|
||||
psycopg[binary,pool]==3.2.9
|
||||
|
||||
@ -407,11 +407,11 @@ event_driven:
|
||||
note: Solana meme主题扩散
|
||||
meta:
|
||||
version: 1
|
||||
last_review: '2026-05-15T11:52:18.602057'
|
||||
last_reverse_analysis: '2026-05-15T11:52:58.300578'
|
||||
total_reviews: 45
|
||||
last_review: '2026-05-16T11:57:01.719794'
|
||||
last_reverse_analysis: '2026-05-16T11:57:32.488216'
|
||||
total_reviews: 52
|
||||
total_rules_learned: 37
|
||||
iteration_count: 50
|
||||
iteration_count: 57
|
||||
strategy_version: v1.7.11
|
||||
strategy_revision_started_at: '2026-05-09T01:20:00'
|
||||
strategy_revision_note: 'v1.7.11: 触发时效治理,旧形态只作背景,消息触发显式标记'
|
||||
|
||||
16
scripts/postgres/backup.sh
Executable file
16
scripts/postgres/backup.sh
Executable file
@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
BACKUP_DIR="${BACKUP_DIR:-$ROOT_DIR/backups/postgres}"
|
||||
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
OUT_FILE="${1:-$BACKUP_DIR/alphax_${ALPHAX_ENV:-dev}_$TIMESTAMP.dump}"
|
||||
|
||||
if [[ -z "${DATABASE_URL:-}" ]]; then
|
||||
echo "ERROR: DATABASE_URL is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$OUT_FILE")"
|
||||
pg_dump "$DATABASE_URL" --format=custom --file="$OUT_FILE"
|
||||
echo "[backup] wrote $OUT_FILE"
|
||||
284
scripts/postgres/import_from_sqlite.py
Executable file
284
scripts/postgres/import_from_sqlite.py
Executable file
@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Import current SQLite data into the PostgreSQL migration target."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from psycopg import sql
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from app.db.postgres_connection import connect # noqa: E402
|
||||
|
||||
|
||||
DEFAULT_SQLITE_PATH = REPO_ROOT / "data" / "altcoin_monitor.db"
|
||||
DEFAULT_SCHEDULER_SQLITE_PATH = REPO_ROOT / "data" / "scheduler_state.db"
|
||||
EXCLUDED_TABLES = {"sqlite_sequence", "schema_migrations"}
|
||||
|
||||
|
||||
def _sqlite_tables(conn: sqlite3.Connection) -> list[str]:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type='table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
"""
|
||||
).fetchall()
|
||||
return [row["name"] for row in rows if row["name"] not in EXCLUDED_TABLES]
|
||||
|
||||
|
||||
def _sqlite_columns(conn: sqlite3.Connection, table: str) -> list[str]:
|
||||
return [row["name"] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()]
|
||||
|
||||
|
||||
def _postgres_tables(conn) -> set[str]:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema='public'
|
||||
AND table_type='BASE TABLE'
|
||||
"""
|
||||
).fetchall()
|
||||
return {row[0] for row in rows if row[0] not in EXCLUDED_TABLES}
|
||||
|
||||
|
||||
def _postgres_columns(conn, table: str) -> list[str]:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema='public'
|
||||
AND table_name=%s
|
||||
ORDER BY ordinal_position
|
||||
""",
|
||||
(table,),
|
||||
).fetchall()
|
||||
return [row[0] for row in rows]
|
||||
|
||||
|
||||
def _serial_columns(conn) -> list[tuple[str, str]]:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema='public'
|
||||
AND column_default LIKE 'nextval(%'
|
||||
ORDER BY table_name, column_name
|
||||
"""
|
||||
).fetchall()
|
||||
return [(row[0], row[1]) for row in rows]
|
||||
|
||||
|
||||
def _batched(rows: Iterable[sqlite3.Row], size: int) -> Iterable[list[sqlite3.Row]]:
|
||||
batch: list[sqlite3.Row] = []
|
||||
for row in rows:
|
||||
batch.append(row)
|
||||
if len(batch) >= size:
|
||||
yield batch
|
||||
batch = []
|
||||
if batch:
|
||||
yield batch
|
||||
|
||||
|
||||
def _truncate_tables(conn, tables: list[str]) -> None:
|
||||
if not tables:
|
||||
return
|
||||
stmt = sql.SQL("TRUNCATE TABLE {tables} RESTART IDENTITY CASCADE").format(
|
||||
tables=sql.SQL(", ").join(sql.Identifier(t) for t in tables)
|
||||
)
|
||||
conn.execute(stmt)
|
||||
|
||||
|
||||
def _reset_sequences(conn) -> None:
|
||||
for table, column in _serial_columns(conn):
|
||||
conn.execute(
|
||||
sql.SQL(
|
||||
"""
|
||||
SELECT setval(
|
||||
pg_get_serial_sequence({table_name}, {column_name}),
|
||||
COALESCE((SELECT MAX({column}) FROM {table}), 0) + 1,
|
||||
false
|
||||
)
|
||||
"""
|
||||
).format(
|
||||
table_name=sql.Literal(table),
|
||||
column_name=sql.Literal(column),
|
||||
column=sql.Identifier(column),
|
||||
table=sql.Identifier(table),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _apply_post_import_fixes(conn) -> None:
|
||||
"""Bring imported legacy rows up to current PostgreSQL runtime invariants."""
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE recommendation
|
||||
SET display_bucket='history',
|
||||
execution_status='invalid',
|
||||
lifecycle_state='closed',
|
||||
entry_triggered=0,
|
||||
state_reason=COALESCE(NULLIF(state_reason, ''), '机会失效,归入历史复盘')
|
||||
WHERE status IN ('expired', 'invalid', 'archived', 'stopped_out')
|
||||
AND COALESCE(display_bucket, '') != 'history'
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _import_one_sqlite(
|
||||
sqlite_conn: sqlite3.Connection,
|
||||
pg_conn,
|
||||
*,
|
||||
truncate: bool,
|
||||
batch_size: int,
|
||||
skip_conflicts: bool,
|
||||
source_label: str,
|
||||
) -> dict[str, int]:
|
||||
imported: dict[str, int] = {}
|
||||
sqlite_tables = _sqlite_tables(sqlite_conn)
|
||||
pg_tables = _postgres_tables(pg_conn)
|
||||
common_tables = [table for table in sqlite_tables if table in pg_tables]
|
||||
missing_tables = [table for table in sqlite_tables if table not in pg_tables]
|
||||
|
||||
if missing_tables:
|
||||
print(f"[import:{source_label}] skip tables absent in PostgreSQL: {', '.join(missing_tables)}")
|
||||
|
||||
if truncate:
|
||||
print(f"[import:{source_label}] truncate {len(common_tables)} table(s)")
|
||||
_truncate_tables(pg_conn, common_tables)
|
||||
|
||||
for table in common_tables:
|
||||
sqlite_cols = _sqlite_columns(sqlite_conn, table)
|
||||
pg_cols = _postgres_columns(pg_conn, table)
|
||||
columns = [col for col in pg_cols if col in sqlite_cols]
|
||||
if not columns:
|
||||
imported[table] = 0
|
||||
continue
|
||||
|
||||
select_sql = "SELECT {} FROM {}".format(
|
||||
", ".join(f'"{col}"' for col in columns),
|
||||
f'"{table}"',
|
||||
)
|
||||
rows = sqlite_conn.execute(select_sql)
|
||||
insert_sql = sql.SQL("INSERT INTO {table} ({cols}) VALUES ({values}) {conflict}").format(
|
||||
table=sql.Identifier(table),
|
||||
cols=sql.SQL(", ").join(sql.Identifier(col) for col in columns),
|
||||
values=sql.SQL(", ").join(sql.Placeholder() for _ in columns),
|
||||
conflict=sql.SQL("ON CONFLICT DO NOTHING") if skip_conflicts else sql.SQL(""),
|
||||
)
|
||||
count = 0
|
||||
for batch in _batched(rows, batch_size):
|
||||
values = [tuple(row[col] for col in columns) for row in batch]
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.executemany(insert_sql, values)
|
||||
count += len(values)
|
||||
imported[table] = count
|
||||
print(f"[import:{source_label}] {table}: {count}")
|
||||
|
||||
return imported
|
||||
|
||||
|
||||
def import_sqlite(
|
||||
sqlite_path: Path,
|
||||
database_url: str | None = None,
|
||||
*,
|
||||
scheduler_sqlite_path: Path | None = None,
|
||||
truncate: bool = False,
|
||||
batch_size: int = 1000,
|
||||
skip_conflicts: bool = False,
|
||||
) -> dict[str, int]:
|
||||
if not sqlite_path.exists():
|
||||
raise FileNotFoundError(f"SQLite database not found: {sqlite_path}")
|
||||
|
||||
sqlite_conn = sqlite3.connect(str(sqlite_path))
|
||||
sqlite_conn.row_factory = sqlite3.Row
|
||||
imported: dict[str, int] = {}
|
||||
scheduler_conn: sqlite3.Connection | None = None
|
||||
|
||||
try:
|
||||
if scheduler_sqlite_path and scheduler_sqlite_path.exists():
|
||||
scheduler_conn = sqlite3.connect(str(scheduler_sqlite_path))
|
||||
scheduler_conn.row_factory = sqlite3.Row
|
||||
elif scheduler_sqlite_path:
|
||||
print(f"[import:scheduler] skip missing scheduler db: {scheduler_sqlite_path}")
|
||||
|
||||
with connect(database_url) as pg_conn:
|
||||
with pg_conn.transaction():
|
||||
imported.update(
|
||||
_import_one_sqlite(
|
||||
sqlite_conn,
|
||||
pg_conn,
|
||||
truncate=truncate,
|
||||
batch_size=batch_size,
|
||||
skip_conflicts=skip_conflicts,
|
||||
source_label="main",
|
||||
)
|
||||
)
|
||||
if scheduler_conn:
|
||||
imported.update(
|
||||
_import_one_sqlite(
|
||||
scheduler_conn,
|
||||
pg_conn,
|
||||
truncate=truncate,
|
||||
batch_size=batch_size,
|
||||
skip_conflicts=skip_conflicts,
|
||||
source_label="scheduler",
|
||||
)
|
||||
)
|
||||
|
||||
_reset_sequences(pg_conn)
|
||||
_apply_post_import_fixes(pg_conn)
|
||||
finally:
|
||||
sqlite_conn.close()
|
||||
if scheduler_conn:
|
||||
scheduler_conn.close()
|
||||
|
||||
return imported
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Import AlphaX SQLite data into PostgreSQL.")
|
||||
parser.add_argument("--sqlite-path", type=Path, default=DEFAULT_SQLITE_PATH)
|
||||
parser.add_argument(
|
||||
"--scheduler-sqlite-path",
|
||||
type=Path,
|
||||
default=DEFAULT_SCHEDULER_SQLITE_PATH,
|
||||
help="Optional scheduler_state.db path. Use an empty string to skip.",
|
||||
)
|
||||
parser.add_argument("--database-url", default=None, help="Override DATABASE_URL.")
|
||||
parser.add_argument("--truncate", action="store_true", help="Clear target tables before import.")
|
||||
parser.add_argument("--batch-size", type=int, default=1000)
|
||||
parser.add_argument(
|
||||
"--skip-conflicts",
|
||||
action="store_true",
|
||||
help="Use ON CONFLICT DO NOTHING. Prefer --truncate for clean migrations.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
scheduler_sqlite_path = args.scheduler_sqlite_path
|
||||
if str(scheduler_sqlite_path).strip() == "":
|
||||
scheduler_sqlite_path = None
|
||||
|
||||
imported = import_sqlite(
|
||||
args.sqlite_path,
|
||||
args.database_url,
|
||||
scheduler_sqlite_path=scheduler_sqlite_path,
|
||||
truncate=args.truncate,
|
||||
batch_size=args.batch_size,
|
||||
skip_conflicts=args.skip_conflicts,
|
||||
)
|
||||
print(f"[import] completed {len(imported)} table(s), {sum(imported.values())} row(s)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
32
scripts/postgres/restore.sh
Executable file
32
scripts/postgres/restore.sh
Executable file
@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: $0 <backup.dump> [--yes]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
BACKUP_FILE="$1"
|
||||
YES="${2:-}"
|
||||
|
||||
if [[ -z "${DATABASE_URL:-}" ]]; then
|
||||
echo "ERROR: DATABASE_URL is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$BACKUP_FILE" ]]; then
|
||||
echo "ERROR: backup file not found: $BACKUP_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$YES" != "--yes" ]]; then
|
||||
echo "This will restore into DATABASE_URL and may overwrite existing objects."
|
||||
read -r -p "Type RESTORE to continue: " CONFIRM
|
||||
if [[ "$CONFIRM" != "RESTORE" ]]; then
|
||||
echo "restore cancelled"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
pg_restore --clean --if-exists --no-owner --dbname="$DATABASE_URL" "$BACKUP_FILE"
|
||||
echo "[restore] restored $BACKUP_FILE"
|
||||
39
scripts/postgres/run_migrations.py
Executable file
39
scripts/postgres/run_migrations.py
Executable file
@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply PostgreSQL SQL migrations in lexical order."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from app.db.postgres_connection import apply_migrations # noqa: E402
|
||||
|
||||
|
||||
MIGRATIONS_DIR = REPO_ROOT / "app" / "db" / "migrations"
|
||||
|
||||
|
||||
def run_migrations(database_url: str | None = None, migrations_dir: Path = MIGRATIONS_DIR) -> list[str]:
|
||||
return apply_migrations(database_url, migrations_dir)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Apply AlphaX PostgreSQL migrations.")
|
||||
parser.add_argument("--database-url", default=None, help="Override DATABASE_URL.")
|
||||
parser.add_argument("--migrations-dir", type=Path, default=MIGRATIONS_DIR)
|
||||
args = parser.parse_args()
|
||||
|
||||
applied = run_migrations(args.database_url, args.migrations_dir)
|
||||
if applied:
|
||||
print(f"[migrate] applied {len(applied)} migration(s): {', '.join(applied)}")
|
||||
else:
|
||||
print("[migrate] database already up to date")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
198
scripts/postgres/validate_import.py
Executable file
198
scripts/postgres/validate_import.py
Executable file
@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare SQLite and PostgreSQL row counts after import."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from psycopg import sql
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from app.db.postgres_connection import connect # noqa: E402
|
||||
from scripts.postgres.import_from_sqlite import ( # noqa: E402
|
||||
DEFAULT_SCHEDULER_SQLITE_PATH,
|
||||
DEFAULT_SQLITE_PATH,
|
||||
EXCLUDED_TABLES,
|
||||
)
|
||||
|
||||
|
||||
KEY_TABLES = [
|
||||
"recommendation",
|
||||
"price_tracking",
|
||||
"screening_log",
|
||||
"coin_state",
|
||||
"cron_run_log",
|
||||
"review_log",
|
||||
"app_user",
|
||||
"user_subscription",
|
||||
"event_news",
|
||||
"sentiment_events",
|
||||
"onchain_events",
|
||||
"onchain_raw_events",
|
||||
"llm_insights",
|
||||
"system_reset_log",
|
||||
"scheduler_job_config",
|
||||
"scheduler_runtime_status",
|
||||
"scheduler_manual_trigger",
|
||||
]
|
||||
|
||||
|
||||
def _sqlite_tables(conn: sqlite3.Connection) -> set[str]:
|
||||
rows = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
).fetchall()
|
||||
return {row["name"] for row in rows if row["name"] not in EXCLUDED_TABLES}
|
||||
|
||||
|
||||
def _postgres_tables(conn) -> set[str]:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema='public'
|
||||
AND table_type='BASE TABLE'
|
||||
"""
|
||||
).fetchall()
|
||||
return {row[0] for row in rows if row[0] not in EXCLUDED_TABLES}
|
||||
|
||||
|
||||
def _sqlite_count(conn: sqlite3.Connection, table: str) -> int:
|
||||
return int(conn.execute(f'SELECT COUNT(*) AS n FROM "{table}"').fetchone()["n"])
|
||||
|
||||
|
||||
def _sqlite_max_id(conn: sqlite3.Connection, table: str) -> int | None:
|
||||
cols = [row["name"] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()]
|
||||
if "id" not in cols:
|
||||
return None
|
||||
value = conn.execute(f'SELECT MAX(id) AS max_id FROM "{table}"').fetchone()["max_id"]
|
||||
return int(value) if value is not None else None
|
||||
|
||||
|
||||
def _postgres_count(conn, table: str) -> int:
|
||||
return int(conn.execute(sql.SQL("SELECT COUNT(*) FROM {table}").format(table=sql.Identifier(table))).fetchone()[0])
|
||||
|
||||
|
||||
def _postgres_max_id(conn, table: str) -> int | None:
|
||||
has_id = conn.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema='public'
|
||||
AND table_name=%s
|
||||
AND column_name='id'
|
||||
""",
|
||||
(table,),
|
||||
).fetchone()
|
||||
if not has_id:
|
||||
return None
|
||||
value = conn.execute(sql.SQL("SELECT MAX(id) FROM {table}").format(table=sql.Identifier(table))).fetchone()[0]
|
||||
return int(value) if value is not None else None
|
||||
|
||||
|
||||
def _collect_sqlite_sources(sqlite_path: Path, scheduler_sqlite_path: Path | None) -> list[tuple[str, Path]]:
|
||||
if not sqlite_path.exists():
|
||||
raise FileNotFoundError(f"SQLite database not found: {sqlite_path}")
|
||||
sources = [("main", sqlite_path)]
|
||||
if scheduler_sqlite_path and scheduler_sqlite_path.exists():
|
||||
sources.append(("scheduler", scheduler_sqlite_path))
|
||||
return sources
|
||||
|
||||
|
||||
def validate(
|
||||
sqlite_path: Path,
|
||||
database_url: str | None = None,
|
||||
*,
|
||||
scheduler_sqlite_path: Path | None = None,
|
||||
all_tables: bool = False,
|
||||
) -> dict:
|
||||
sources = _collect_sqlite_sources(sqlite_path, scheduler_sqlite_path)
|
||||
sqlite_conns = []
|
||||
try:
|
||||
sqlite_by_table = {}
|
||||
for source, path in sources:
|
||||
conn = sqlite3.connect(str(path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
sqlite_conns.append(conn)
|
||||
for table in _sqlite_tables(conn):
|
||||
sqlite_by_table[table] = (source, conn)
|
||||
|
||||
with connect(database_url) as pg_conn:
|
||||
sqlite_tables = set(sqlite_by_table)
|
||||
pg_tables = _postgres_tables(pg_conn)
|
||||
table_names = sorted(sqlite_tables & pg_tables) if all_tables else [t for t in KEY_TABLES if t in sqlite_tables and t in pg_tables]
|
||||
tables = []
|
||||
ok = True
|
||||
for table in table_names:
|
||||
source, sqlite_conn = sqlite_by_table[table]
|
||||
sqlite_count = _sqlite_count(sqlite_conn, table)
|
||||
pg_count = _postgres_count(pg_conn, table)
|
||||
sqlite_max_id = _sqlite_max_id(sqlite_conn, table)
|
||||
pg_max_id = _postgres_max_id(pg_conn, table)
|
||||
table_ok = sqlite_count == pg_count and sqlite_max_id == pg_max_id
|
||||
ok = ok and table_ok
|
||||
tables.append(
|
||||
{
|
||||
"table": table,
|
||||
"source": source,
|
||||
"sqlite_count": sqlite_count,
|
||||
"postgres_count": pg_count,
|
||||
"sqlite_max_id": sqlite_max_id,
|
||||
"postgres_max_id": pg_max_id,
|
||||
"ok": table_ok,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"ok": ok,
|
||||
"checked_tables": len(tables),
|
||||
"sqlite_only_tables": sorted(sqlite_tables - pg_tables),
|
||||
"postgres_only_tables": sorted(pg_tables - sqlite_tables),
|
||||
"tables": tables,
|
||||
}
|
||||
finally:
|
||||
for conn in sqlite_conns:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate AlphaX SQLite -> PostgreSQL import.")
|
||||
parser.add_argument("--sqlite-path", type=Path, default=DEFAULT_SQLITE_PATH)
|
||||
parser.add_argument("--scheduler-sqlite-path", type=Path, default=DEFAULT_SCHEDULER_SQLITE_PATH)
|
||||
parser.add_argument("--database-url", default=None, help="Override DATABASE_URL.")
|
||||
parser.add_argument("--all-tables", action="store_true")
|
||||
parser.add_argument("--json", action="store_true", help="Print full JSON report.")
|
||||
args = parser.parse_args()
|
||||
|
||||
report = validate(
|
||||
args.sqlite_path,
|
||||
args.database_url,
|
||||
scheduler_sqlite_path=args.scheduler_sqlite_path,
|
||||
all_tables=args.all_tables,
|
||||
)
|
||||
if args.json:
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
status = "PASS" if report["ok"] else "FAIL"
|
||||
print(f"[validate] {status}: checked {report['checked_tables']} table(s)")
|
||||
for item in report["tables"]:
|
||||
mark = "OK" if item["ok"] else "DIFF"
|
||||
print(
|
||||
f"[validate] {mark} {item['table']} ({item['source']}): "
|
||||
f"count {item['sqlite_count']} -> {item['postgres_count']}, "
|
||||
f"max_id {item['sqlite_max_id']} -> {item['postgres_max_id']}"
|
||||
)
|
||||
if report["sqlite_only_tables"]:
|
||||
print(f"[validate] sqlite-only tables: {', '.join(report['sqlite_only_tables'])}")
|
||||
if report["postgres_only_tables"]:
|
||||
print(f"[validate] postgres-only tables: {', '.join(report['postgres_only_tables'])}")
|
||||
return 0 if report["ok"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -23,7 +23,7 @@ for rel in required_files:
|
||||
errors.append({"rule": "missing_file", "path": rel})
|
||||
|
||||
compose = (ROOT / "docker-compose.yml").read_text(errors="ignore") if (ROOT / "docker-compose.yml").exists() else ""
|
||||
for needle in ["8191:8190", "ALPHAX_DB_PATH", "ALPHAX_SCHEDULER_DRY_RUN", "./data:/app/data"]:
|
||||
for needle in ["8191:8190", "DATABASE_URL", "ALPHAX_DB_BACKEND: \"postgres\"", "ALPHAX_SCHEDULER_DRY_RUN"]:
|
||||
if needle not in compose:
|
||||
errors.append({"rule": "compose_missing_expected_setting", "needle": needle})
|
||||
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AlphaX 推送与状态流转口径审计脚本。"""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DB = Path(os.getenv("ALPHAX_DB_PATH", str(ROOT / "data" / "altcoin_monitor.db")))
|
||||
conn = sqlite3.connect(DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from app.db.schema import get_conn, init_db
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
@ -19,7 +20,7 @@ def rows(sql, params=()):
|
||||
|
||||
# 1. 仍在 active 的同一 symbol 只能有一条实时/观察主记录。
|
||||
dups = rows("""
|
||||
SELECT symbol, COUNT(*) c, GROUP_CONCAT(id) ids
|
||||
SELECT symbol, COUNT(*) c, STRING_AGG(id::text, ',') ids
|
||||
FROM recommendation
|
||||
WHERE status='active' AND COALESCE(display_bucket,'watch_pool') != 'history'
|
||||
GROUP BY symbol HAVING COUNT(*) > 1
|
||||
@ -43,7 +44,7 @@ duplicate_push = rows("""
|
||||
SELECT rec_id, symbol, push_type, action_status, COUNT(*) c, MIN(pushed_at) first_push, MAX(pushed_at) last_push
|
||||
FROM push_log
|
||||
WHERE COALESCE(rec_id,0) > 0
|
||||
GROUP BY rec_id, push_type, action_status
|
||||
GROUP BY rec_id, symbol, push_type, action_status
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY c DESC, last_push DESC
|
||||
LIMIT 30
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""审计 AlphaX 技术/消息触发时效,防止旧形态冒充当下交易机会。"""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DB = Path(os.getenv("ALPHAX_DB_PATH", str(ROOT / "data" / "altcoin_monitor.db")))
|
||||
conn = sqlite3.connect(DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from app.db.schema import get_conn, init_db
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AlphaX 状态机口径验收脚本。"""
|
||||
import sqlite3, json, sys, os
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DB = Path(os.getenv('ALPHAX_DB_PATH', str(ROOT / 'data' / 'altcoin_monitor.db')))
|
||||
conn = sqlite3.connect(DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from app.db.schema import get_conn, init_db
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
errors = []
|
||||
|
||||
def scalar(sql, params=()):
|
||||
@ -13,7 +15,7 @@ def scalar(sql, params=()):
|
||||
|
||||
# 1. 当前主状态唯一:同一 symbol 不能有多条非历史 active。
|
||||
dups = conn.execute("""
|
||||
SELECT symbol, COUNT(*) c, GROUP_CONCAT(id) ids
|
||||
SELECT symbol, COUNT(*) c, STRING_AGG(id::text, ',') ids
|
||||
FROM recommendation
|
||||
WHERE status='active' AND COALESCE(display_bucket,'watch_pool') != 'history'
|
||||
GROUP BY symbol HAVING COUNT(*) > 1
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link" href="/market"><svg class="link-icon"><use href="#svg-target"/></svg>市场总览</a>
|
||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上异动</a>
|
||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
|
||||
489
static/app.html
489
static/app.html
@ -273,9 +273,6 @@
|
||||
|
||||
<!-- LIVE VIEW -->
|
||||
<div id="liveView">
|
||||
<section class="dashboard-overview" id="dashboardOverview">
|
||||
<div class="overview-loading">正在汇总市场情绪、热度、动能与机会雷达…</div>
|
||||
</section>
|
||||
<div class="stats-strip" id="liveStats"></div>
|
||||
<div class="cards" id="liveCards"><div class="loading-state"><svg class="spin" width="18" height="18" color="#8e91a0"><use href="#svg-spinner"/></svg> 加载中…</div></div>
|
||||
</div>
|
||||
@ -307,7 +304,6 @@ var liveLimit = 24;
|
||||
var liveHasMore = false;
|
||||
var liveLoading = false;
|
||||
var liveSummary = { buy_now: 0, wait_pullback: 0, observe: 0, expired: 0, total: 0 };
|
||||
var dashboardOverviewLoaded = false;
|
||||
var historyItems = [];
|
||||
var historyOffset = 0;
|
||||
var historyLimit = 24;
|
||||
@ -380,6 +376,20 @@ function cleanDisplayText(s) {
|
||||
.replace(/强烈推荐/g, '强势异动')
|
||||
.trim();
|
||||
}
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
|
||||
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
|
||||
});
|
||||
}
|
||||
function escHtml(s) { return esc(s); }
|
||||
function fmtCompactNumber(v) {
|
||||
v = Number(v || 0);
|
||||
var abs = Math.abs(v);
|
||||
if (abs >= 1e9) return (v / 1e9).toFixed(2) + 'B';
|
||||
if (abs >= 1e6) return (v / 1e6).toFixed(2) + 'M';
|
||||
if (abs >= 1e3) return (v / 1e3).toFixed(1) + 'K';
|
||||
return v.toFixed(abs >= 100 ? 0 : abs >= 10 ? 1 : 2);
|
||||
}
|
||||
function normalizeTriggerCause(s) {
|
||||
return cleanDisplayText(s)
|
||||
.replace(/^15min入场窗口/, '15min 触发')
|
||||
@ -407,119 +417,6 @@ function fmtPrice(p, decimals) {
|
||||
return p.toFixed(d);
|
||||
}
|
||||
|
||||
// ====== DASHBOARD OVERVIEW ======
|
||||
function escHtml(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>'"]/g, function(c){ return ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]); });
|
||||
}
|
||||
function fmtCompactNumber(n) {
|
||||
n = Number(n || 0);
|
||||
if (!n) return '--';
|
||||
if (Math.abs(n) >= 1e9) return (n/1e9).toFixed(1) + 'B';
|
||||
if (Math.abs(n) >= 1e6) return (n/1e6).toFixed(1) + 'M';
|
||||
if (Math.abs(n) >= 1e3) return (n/1e3).toFixed(1) + 'K';
|
||||
return n.toFixed(0);
|
||||
}
|
||||
function fearGreedTone(value) {
|
||||
value = Number(value || 50);
|
||||
if (value <= 24) return {cls:'red', label:'极度恐慌', advice:'风险释放,但需要结构确认'};
|
||||
if (value <= 44) return {cls:'yellow', label:'恐慌', advice:'偏防守,观察强结构'};
|
||||
if (value <= 55) return {cls:'blue', label:'中性', advice:'等待方向选择'};
|
||||
if (value <= 74) return {cls:'green', label:'贪婪', advice:'机会活跃,避免追高'};
|
||||
return {cls:'red', label:'极度贪婪', advice:'热度过高,谨慎追高'};
|
||||
}
|
||||
function fundingTone(funding) {
|
||||
funding = Number(funding || 0);
|
||||
if (funding > 0.0015) return {cls:'red', text:'Funding 过热'};
|
||||
if (funding > 0.0005) return {cls:'yellow', text:'多头偏热'};
|
||||
if (funding < -0.0005) return {cls:'green', text:'空头拥挤'};
|
||||
return {cls:'blue', text:'资金费率中性'};
|
||||
}
|
||||
function deriveMarketStatus(fg, activeItems, stats) {
|
||||
var value = fg && fg.value != null ? Number(fg.value) : 50;
|
||||
var buy = activeItems.filter(function(x){ return x.execution_status === 'buy_now'; }).length;
|
||||
var total = activeItems.length;
|
||||
var m = (stats && stats.market_context_overview) || {};
|
||||
var acc = Math.max(Number(m.avg_turnover_acceleration_1h || 0), Number(m.avg_turnover_acceleration_4h || 0));
|
||||
var funding = Number(m.avg_funding_rate || 0);
|
||||
if (value >= 75 || funding > 0.0015) return '风险升温 · 谨慎追高';
|
||||
if (buy > 0 && acc >= 1.5) return '机会活跃 · 重点看入场窗口';
|
||||
if (total >= 10 && acc >= 1.1) return '结构活跃 · 观察池筛选';
|
||||
if (value <= 30) return '情绪偏冷 · 等待确认';
|
||||
return '中性观察 · 等待强信号';
|
||||
}
|
||||
function renderDashboardOverview(news, stats, activeItems) {
|
||||
news = news || {}; stats = stats || {}; activeItems = Array.isArray(activeItems) ? activeItems : [];
|
||||
var fg = news.fear_greed || {};
|
||||
var fgValue = Number(fg.value || 50);
|
||||
var fgTone = fearGreedTone(fgValue);
|
||||
var total = activeItems.length;
|
||||
var buy = activeItems.filter(function(x){ return x.execution_status === 'buy_now'; }).length;
|
||||
var observe = activeItems.filter(function(x){ return x.execution_status !== 'buy_now'; }).length;
|
||||
var highScore = activeItems.filter(function(x){ return Number(x.rec_score || 0) >= 65; }).length;
|
||||
function avgFromItems(group, key) {
|
||||
var vals = [];
|
||||
activeItems.forEach(function(x){ var obj = x[group] || {}; var v = Number(obj[key]); if (isFinite(v) && v !== 0) vals.push(v); });
|
||||
if (!vals.length) return 0;
|
||||
return vals.reduce(function(a,b){ return a+b; }, 0) / vals.length;
|
||||
}
|
||||
var sectorCounter = {};
|
||||
activeItems.forEach(function(x){ ((x.sector_context || {}).hot_sectors || []).forEach(function(sec){ sectorCounter[sec] = (sectorCounter[sec] || 0) + 1; }); });
|
||||
var topSectors = Object.keys(sectorCounter).sort(function(a,b){ return sectorCounter[b] - sectorCounter[a]; }).slice(0,6).map(function(sec){ return {sector:sec, count:sectorCounter[sec]}; });
|
||||
var market = {
|
||||
actionable_sample_count: total,
|
||||
avg_turnover_acceleration_1h: avgFromItems('market_context', 'turnover_acceleration_1h'),
|
||||
avg_turnover_acceleration_4h: avgFromItems('market_context', 'turnover_acceleration_4h'),
|
||||
avg_volume_24h: avgFromItems('market_context', 'volume_24h'),
|
||||
avg_funding_rate: avgFromItems('derivatives_context', 'funding_rate'),
|
||||
avg_top_trader_long_pct: avgFromItems('derivatives_context', 'top_trader_long_pct'),
|
||||
avg_top_trader_long_short_ratio: avgFromItems('derivatives_context', 'top_trader_long_short_ratio'),
|
||||
top_hot_sectors: topSectors.length ? topSectors : ((stats.market_context_overview || {}).top_hot_sectors || [])
|
||||
};
|
||||
var acc1 = Number(market.avg_turnover_acceleration_1h || 0);
|
||||
var acc4 = Number(market.avg_turnover_acceleration_4h || 0);
|
||||
var funding = Number(market.avg_funding_rate || 0);
|
||||
var fTone = fundingTone(funding);
|
||||
var status = deriveMarketStatus(fg, activeItems, {market_context_overview: market});
|
||||
var activeSymbols = {};
|
||||
activeItems.forEach(function(x){ activeSymbols[String(x.symbol || '').replace('/USDT','').toUpperCase()] = true; });
|
||||
var trending = (news.trending || []).slice(0, 8);
|
||||
var trendHtml = trending.length ? trending.map(function(t, idx){
|
||||
var sym = String(t.symbol || '').toUpperCase();
|
||||
var hot = activeSymbols[sym];
|
||||
return '<span class="trend-chip '+(hot?'hot':'')+'"><span class="rank">#'+(idx+1)+'</span>'+escHtml(sym || t.name || '--')+(hot?'<span>机会池</span>':'')+'</span>';
|
||||
}).join('') : '<span class="trend-chip">暂无热榜数据</span>';
|
||||
var sectors = (market.top_hot_sectors || []).slice(0, 6);
|
||||
var sectorHtml = sectors.length ? sectors.map(function(s){ return '<span class="sector-chip">'+escHtml(s.sector || '--')+'<span class="count">'+(s.count || 0)+'</span></span>'; }).join('') : '<span class="sector-chip">暂无板块聚合</span>';
|
||||
var overlap = trending.filter(function(t){ return activeSymbols[String(t.symbol || '').toUpperCase()]; }).length;
|
||||
var meterCls = fgTone.cls;
|
||||
$('dashboardOverview').innerHTML =
|
||||
'<div class="overview-head"><div><div class="overview-title">市场总览</div><div class="overview-subtitle">先看市场情绪、资金动能与热点方向,再进入具体机会。</div></div><div class="overview-status">'+escHtml(status)+'</div></div>'+
|
||||
'<div class="overview-grid">'+
|
||||
'<div class="overview-card"><div class="ov-label">市场情绪</div><div class="ov-value '+fgTone.cls+'">'+fgValue+'</div><div class="ov-sub">'+escHtml(fg.classification || fgTone.label)+' · '+fgTone.advice+'</div><div class="ov-meter"><span class="'+meterCls+'" style="width:'+Math.max(0,Math.min(100,fgValue))+'%"></span></div></div>'+
|
||||
'<div class="overview-card"><div class="ov-label">市场动能</div><div class="ov-value '+(Math.max(acc1,acc4)>=1.5?'green':'blue')+'">'+(Math.max(acc1,acc4)||0).toFixed(1)+'x</div><div class="ov-sub">1H '+acc1.toFixed(1)+'x · 4H '+acc4.toFixed(1)+'x</div></div>'+
|
||||
'<div class="overview-card"><div class="ov-label">机会雷达</div><div class="ov-value blue">'+total+'</div><div class="ov-sub">入场窗口 '+buy+' · 重点观察 '+(liveSummary.observe_strong!=null?liveSummary.observe_strong:observe)+' · 弱观察 '+(liveSummary.observe_weak||0)+'</div></div>'+
|
||||
'<div class="overview-card"><div class="ov-label">风险温度</div><div class="ov-value '+fTone.cls+'">'+(funding*100).toFixed(3)+'%</div><div class="ov-sub">'+fTone.text+' · 24H均量 '+fmtCompactNumber(market.avg_volume_24h)+'</div></div>'+
|
||||
'</div>'+
|
||||
'<div class="market-panels"><div class="market-panel"><div class="panel-title">市场热度榜<span class="hint">绿色代表已进入机会池</span></div><div class="trending-list">'+trendHtml+'</div></div>'+
|
||||
'<div class="market-panel"><div class="panel-title">热点方向<span class="hint">热榜重叠 '+overlap+'</span></div><div class="sector-list">'+sectorHtml+'</div><div class="risk-notes" style="margin-top:12px"><div class="risk-note"><div class="rn-label">大户多头</div><div class="rn-value">'+Number(market.avg_top_trader_long_pct||0).toFixed(1)+'%</div></div><div class="risk-note"><div class="rn-label">多空比</div><div class="rn-value">'+Number(market.avg_top_trader_long_short_ratio||0).toFixed(2)+'</div></div><div class="risk-note"><div class="rn-label">样本数</div><div class="rn-value">'+Number(market.actionable_sample_count||0)+'</div></div></div></div></div>';
|
||||
}
|
||||
async function loadDashboardOverview(force) {
|
||||
if (dashboardOverviewLoaded && !force) return;
|
||||
dashboardOverviewLoaded = true;
|
||||
try {
|
||||
var activeForOverview = cachedLiveData.slice();
|
||||
var res = await Promise.all([
|
||||
fetch(API + '/api/newsfeed'),
|
||||
fetch(API + '/api/stats')
|
||||
]);
|
||||
var news = res[0].ok ? await res[0].json() : {};
|
||||
var stats = res[1].ok ? await res[1].json() : {};
|
||||
renderDashboardOverview(news, stats, activeForOverview);
|
||||
} catch(e) {
|
||||
$('dashboardOverview').innerHTML = '<div class="overview-loading">市场总览加载失败,机会雷达仍可正常使用。</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ====== LIVE ======
|
||||
function isExpiredRec(r) {
|
||||
return !r || r.display_bucket === 'history' || r.execution_status === 'invalid' || r.status === 'invalid' || r.status === 'expired';
|
||||
@ -558,12 +455,33 @@ async function loadContent(reset) {
|
||||
cachedLiveData = items;
|
||||
liveOffset = items.length;
|
||||
}
|
||||
applyFilterAndRender();
|
||||
loadDashboardOverview(true);
|
||||
try {
|
||||
applyFilterAndRender();
|
||||
} catch (renderErr) {
|
||||
console.error('live render failed', renderErr);
|
||||
var visible = cachedLiveData.filter(function(r){ return isRenderableLiveRec(r); });
|
||||
var weakCount = visible.filter(isWeakObserveRec).length;
|
||||
var fallbackItems = currentFilter === 'weak_observe'
|
||||
? visible.filter(function(r){ return r.observe_tier === 'weak'; })
|
||||
: (currentFilter === 'buy_now'
|
||||
? visible.filter(function(r){ return (r.execution_status === 'buy_now' || r.display_bucket === 'realtime') && !isWeakObserveRec(r); })
|
||||
: currentFilter === 'observe'
|
||||
? visible.filter(function(r){ return (r.display_bucket === 'watch_pool' || (r.execution_status !== 'buy_now' && r.display_bucket !== 'realtime')) && r.observe_tier !== 'weak'; })
|
||||
: visible.filter(function(r){ return !isWeakObserveRec(r); }));
|
||||
renderLiveStats(cachedLiveData);
|
||||
$('liveCards').innerHTML = fallbackItems.map(function(r){ return renderLiveFallbackCard(r); }).join('') + (weakCount && !currentFilter ? '<div class="weak-summary"><span>另有 '+weakCount+' 个弱观察候选已收起。</span><button onclick="setFilter(\'weak_observe\')">查看弱观察</button></div>' : '') + (liveHasMore ? '<div class="load-more-row"><button class="load-more-btn" onclick="loadMoreLive()">加载更多</button></div>' : '<div class="page-hint">已加载全部实时记录</div>');
|
||||
}
|
||||
$('liveCount').textContent = '';
|
||||
// Load K-lines after DOM has fully settled
|
||||
setTimeout(function() { loadAllKlines('#liveCards'); }, 150);
|
||||
} catch(e) { $('liveCards').innerHTML = '<div class="empty-state"><p>加载失败</p></div>'; }
|
||||
} catch(e) {
|
||||
console.error('loadContent failed', e);
|
||||
var visible = cachedLiveData.filter(function(r){ return isRenderableLiveRec(r); });
|
||||
var weakCount = visible.filter(isWeakObserveRec).length;
|
||||
$('liveCards').innerHTML = visible.length
|
||||
? visible.map(function(r){ return renderLiveFallbackCard(r); }).join('') + (weakCount ? '<div class="weak-summary"><span>另有 '+weakCount+' 个弱观察候选已收起。</span><button onclick="setFilter(\'weak_observe\')">查看弱观察</button></div>' : '')
|
||||
: '<div class="empty-state"><p>加载失败<br>请稍后再试</p></div>';
|
||||
}
|
||||
finally { liveLoading = false; }
|
||||
}
|
||||
|
||||
@ -578,8 +496,14 @@ function isObservationRec(r) {
|
||||
return isLiveVisibleRec(r) && r.display_bucket !== 'realtime' && r.execution_status !== 'buy_now';
|
||||
}
|
||||
function isWeakObserveRec(r){ return r && r.observe_tier === 'weak'; }
|
||||
function isRenderableLiveRec(r) {
|
||||
if (!isLiveVisibleRec(r)) return false;
|
||||
if (r.display_bucket === 'realtime' || r.execution_status === 'buy_now') return true;
|
||||
if (r.display_bucket === 'watch_pool' || r.execution_status === 'wait_pullback' || r.execution_status === 'observe') return true;
|
||||
return false;
|
||||
}
|
||||
function applyFilterAndRender() {
|
||||
var visible = cachedLiveData.filter(function(r){ return isLiveVisibleRec(r); });
|
||||
var visible = cachedLiveData.filter(function(r){ return isRenderableLiveRec(r); });
|
||||
var weakCount = visible.filter(isWeakObserveRec).length;
|
||||
var filtered = visible.filter(function(r){ return !isWeakObserveRec(r); });
|
||||
if (!currentFilter) {
|
||||
@ -602,7 +526,12 @@ function setFilter(status) {
|
||||
}
|
||||
|
||||
function renderLiveStats(data) {
|
||||
var visible = (Array.isArray(data) ? data : []).filter(function(r){ return isLiveVisibleRec(r); });
|
||||
var visible = [];
|
||||
try {
|
||||
visible = (Array.isArray(data) ? data : []).filter(function(r){ return isRenderableLiveRec(r); });
|
||||
} catch (e) {
|
||||
console.error('renderLiveStats failed', e);
|
||||
}
|
||||
var total = visible.length;
|
||||
var buy = visible.filter(function(r){ return r.execution_status === 'buy_now' || r.display_bucket === 'realtime'; }).length;
|
||||
var observeStrong = visible.filter(function(r){ return (r.display_bucket === 'watch_pool' || (r.execution_status !== 'buy_now' && r.display_bucket !== 'realtime')) && r.observe_tier !== 'weak'; }).length;
|
||||
@ -633,188 +562,156 @@ function renderLiveCards(data, weakCount) {
|
||||
if (oa !== ob) return oa - ob;
|
||||
return (b.rec_score || 0) - (a.rec_score || 0);
|
||||
});
|
||||
var cardsHtml = items.map(function(r) { return renderRecCard(r); }).join('');
|
||||
var cardsHtml = items.map(function(r) {
|
||||
try { return renderRecCard(r); }
|
||||
catch (e) {
|
||||
console.error('renderRecCard failed', r && r.symbol, e);
|
||||
return renderLiveFallbackCard(r);
|
||||
}
|
||||
}).join('');
|
||||
var weakHint = (!currentFilter && weakCount) ? '<div class="weak-summary"><span>另有 '+weakCount+' 个弱观察候选已收起。</span><button onclick="setFilter(\'weak_observe\')">查看弱观察</button></div>' : '';
|
||||
var moreHtml = liveHasMore ? '<div class="load-more-row"><button class="load-more-btn" onclick="loadMoreLive()">加载更多</button></div>' : '<div class="page-hint">已加载全部实时记录</div>';
|
||||
$('liveCards').innerHTML = cardsHtml + weakHint + moreHtml;
|
||||
}
|
||||
|
||||
function renderLiveFallbackCard(r) {
|
||||
var symbol = (r && r.symbol) || '--';
|
||||
var base = String(symbol).replace('/USDT','');
|
||||
var status = (r && (r.execution_label || r.action_status || r.execution_status)) || '观察';
|
||||
var score = Number((r && r.rec_score) || 0);
|
||||
var price = Number((r && (r.current_price || r.entry_price)) || 0);
|
||||
var entry = Number((r && r.entry_price) || 0);
|
||||
var change = price && entry ? ((price - entry) / entry * 100) : null;
|
||||
var changeHtml = change != null ? '<span class="price-change zero"><span class="pc-label">参考</span><span class="pc-value">'+(change>0?'+':'')+change.toFixed(1)+'%</span></span>' : '';
|
||||
return '<div class="card"><div class="card-bar"><div class="coin-left"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+esc(symbol)+'</span></div></div><div class="badge-group"><span class="action-badge weak">'+esc(status)+'</span><span class="score-badge tier-none"><span class="score-num">'+score+'</span><span class="score-label">评分</span></span></div></div><div class="price-bar"><span class="price">'+(price ? '$'+fmtPrice(price) : '--')+'</span>'+changeHtml+'</div><div class="decision-strip observe"><div class="decision-head"><span class="decision-label">最终建议</span><span class="decision-title">观察</span></div><div class="decision-body"><span class="decision-focus">降级展示</span><span class="decision-reason">该候选存在兼容问题,已用安全卡片显示。</span></div></div><div class="entry-plan"><div class="ep-item"><span class="ep-label">阶段</span><span class="ep-val phase-ref">观察</span><span class="ep-sub">降级展示</span></div><div class="ep-item"><span class="ep-label">当前参考</span><span class="ep-val entry-ref">'+(price ? '$'+fmtPrice(price) : '--')+'</span><span class="ep-sub">基础字段</span></div><div class="ep-item"><span class="ep-label">确认条件</span><span class="ep-val space-ref">--</span><span class="ep-sub">待补充</span></div><div class="ep-item"><span class="ep-label">绩效口径</span><span class="ep-val risk-line">--</span><span class="ep-sub">安全降级</span></div></div><div class="card-footer"><span>'+fmtTime(r && r.rec_time)+'</span><span class="card-ver">'+esc((r && r.strategy_version) || '')+'</span></div></div>';
|
||||
}
|
||||
|
||||
function renderRecCard(r) {
|
||||
var base = (r.symbol||'').replace('/USDT','');
|
||||
function scoreTier(s) {
|
||||
if(s>=80) return{label:'强势异动',cls:'tier-strong'}; if(s>=65) return{label:'值得关注',cls:'tier-good'};
|
||||
if(s>=50) return{label:'有所异动',cls:'tier-ok'}; if(s>=35) return{label:'重点观察',cls:'tier-watch'};
|
||||
if(s>=25) return{label:'弱观察',cls:'tier-weak'}; return{label:'信号不足',cls:'tier-none'};
|
||||
}
|
||||
function opportunityPhase(r, triggerText, sigText) {
|
||||
var text = cleanDisplayText([r.execution_label, r.execution_reason, triggerText, sigText].join(' '));
|
||||
if (r.execution_status === 'buy_now') return {label:'入场窗口', cls:'buy', short:'窗口'};
|
||||
if (r.execution_status === 'wait_pullback' || r.lifecycle_state === 'waiting_entry') return {label:'等回踩', cls:'wait', short:'回踩'};
|
||||
if (r.execution_status === 'observe' || r.display_bucket === 'watch_pool') return (r.observe_tier === 'weak') ? {label:'弱观察', cls:'weak', short:'弱观察'} : {label:'观察中', cls:'obs', short:'观察'};
|
||||
if (/突破|breakout|上破|放量突破|突破确认/i.test(text)) return {label:'等突破', cls:'wait', short:'突破'};
|
||||
if (/确认|静K|收线|站稳|量能|放量|confirm/i.test(text)) return {label:'等确认', cls:'obs', short:'确认'};
|
||||
return (r.observe_tier === 'weak') ? {label:'弱观察', cls:'weak', short:'弱观察'} : {label:'观察中', cls:'obs', short:'观察'};
|
||||
}
|
||||
var ep = r.entry_plan || {};
|
||||
var sigs = Array.isArray(r.signals)?r.signals:[];
|
||||
var entryMethod = ep.entry_method || '';
|
||||
var signalText = sigs.join(' ');
|
||||
var phase = opportunityPhase(r, entryMethod, signalText);
|
||||
var isBuy = r.execution_status === 'buy_now' || r.display_bucket === 'realtime', isWait = phase.label === '等回踩' || r.lifecycle_state === 'waiting_entry', isWeakObserve = r.observe_tier === 'weak', isObs = r.display_bucket === 'watch_pool' || r.execution_status !== 'buy_now';
|
||||
var isExecuted = !!r.entry_triggered || r.display_bucket === 'position' || r.execution_status === 'holding' || r.execution_status === 'completed';
|
||||
var isTradePlan = isBuy || isWait || isExecuted || r.entry_triggered;
|
||||
|
||||
// ---- Action badge with expiry/surge detection ----
|
||||
var recMs = r.rec_time ? new Date(r.rec_time).getTime() : 0;
|
||||
var ageHours = recMs ? (Date.now() - recMs) / 3600000 : 0;
|
||||
var chgSinceRec = r.current_price && r.entry_price && r.entry_price > 0 ? ((r.current_price - r.entry_price) / r.entry_price * 100) : 0;
|
||||
var isOld = ageHours > 12;
|
||||
var hasSurged = chgSinceRec > 3;
|
||||
var actionBadge = '';
|
||||
if (isBuy && isOld && hasSurged) {
|
||||
actionBadge = '<span class="action-badge caution">追高风险(+'+chgSinceRec.toFixed(1)+'%)</span>';
|
||||
} else if (isBuy && isOld) {
|
||||
actionBadge = '<span class="action-badge warn">信号偏弱</span>';
|
||||
} else if (!isOld && hasSurged && isBuy) {
|
||||
actionBadge = '<span class="action-badge caution">追高风险(+'+chgSinceRec.toFixed(1)+'%)</span>';
|
||||
} else if (!isOld && (r.rec_score||0) < 50 && isBuy) {
|
||||
actionBadge = '<span class="action-badge warn">信号偏弱</span>';
|
||||
} else if (isBuy) {
|
||||
actionBadge = '<span class="action-badge buy">入场窗口</span>';
|
||||
} else {
|
||||
actionBadge = '<span class="action-badge '+phase.cls+'">'+phase.label+'</span>';
|
||||
if (isWeakObserve) actionBadge = '<span class="action-badge weak">弱观察</span>';
|
||||
}
|
||||
|
||||
var ePrice = r.entry_price||'';
|
||||
var sl = (r.stop_loss&&r.stop_loss>0) ? r.stop_loss : '';
|
||||
var tp = (r.tp1&&r.tp1>0) ? r.tp1 : '';
|
||||
// 实时看板是机会雷达,不是成交记录。只有“入场窗口”才在 K 线上标记触发价;
|
||||
// 等确认/观察中/等回踩都尚未入场,不能显示蓝色入场价格 marker。
|
||||
var klineEntryPrice = isBuy ? ePrice : '';
|
||||
var klineStopLoss = isBuy ? sl : '';
|
||||
var klineTp1 = isBuy ? tp : '';
|
||||
var entryTime = isBuy ? (r.rec_time||'') : '';
|
||||
var tp1EventTime = (r.status==='hit_tp1'||r.status==='hit_tp2') ? (r.hit_tp1_time||'') : '';
|
||||
var slEventTime = (r.status==='stopped_out') ? (r.stopped_out_time||'') : '';
|
||||
var isTpOrSl = r.status==='hit_tp1'||r.status==='hit_tp2'||r.status==='stopped_out';
|
||||
var price = r.current_price||r.entry_price||0;
|
||||
function fmtP(p) { return fmtPrice(p, priceDecimals(price || p)); }
|
||||
var pnl = r.pnl_pct||0, pnlCls = pnl>0?'pos':pnl<0?'neg':'zero', pnlSign = pnl>0?'+':'';
|
||||
var priceFmt = fmtPrice(price);
|
||||
function displaySignalText(s) {
|
||||
var text = cleanDisplayText(s);
|
||||
if (!isBuy) {
|
||||
text = text
|
||||
.replace(/15min\s*入场窗口信号/g, '15min触发信号')
|
||||
.replace(/入场窗口信号/g, '触发信号')
|
||||
.replace(/入场窗口确认/g, '触发确认');
|
||||
try {
|
||||
var base = (r.symbol||'').replace('/USDT','');
|
||||
function scoreTier(s) {
|
||||
if(s>=80) return{label:'强势异动',cls:'tier-strong'}; if(s>=65) return{label:'值得关注',cls:'tier-good'};
|
||||
if(s>=50) return{label:'有所异动',cls:'tier-ok'}; if(s>=35) return{label:'重点观察',cls:'tier-watch'};
|
||||
if(s>=25) return{label:'弱观察',cls:'tier-weak'}; return{label:'信号不足',cls:'tier-none'};
|
||||
}
|
||||
return text;
|
||||
function opportunityPhase(r, triggerText, sigText) {
|
||||
var text = cleanDisplayText([r.execution_label, r.execution_reason, triggerText, sigText].join(' '));
|
||||
if (r.execution_status === 'buy_now') return {label:'入场窗口', cls:'buy', short:'窗口'};
|
||||
if (r.execution_status === 'wait_pullback' || r.lifecycle_state === 'waiting_entry') return {label:'等回踩', cls:'wait', short:'回踩'};
|
||||
if (r.execution_status === 'observe' || r.display_bucket === 'watch_pool') return (r.observe_tier === 'weak') ? {label:'弱观察', cls:'weak', short:'弱观察'} : {label:'观察中', cls:'obs', short:'观察'};
|
||||
if (/突破|breakout|上破|放量突破|突破确认/i.test(text)) return {label:'等突破', cls:'wait', short:'突破'};
|
||||
if (/确认|静K|收线|站稳|量能|放量|confirm/i.test(text)) return {label:'等确认', cls:'obs', short:'确认'};
|
||||
return (r.observe_tier === 'weak') ? {label:'弱观察', cls:'weak', short:'弱观察'} : {label:'观察中', cls:'obs', short:'观察'};
|
||||
}
|
||||
var ep = r.entry_plan || {};
|
||||
var sigs = Array.isArray(r.signals)?r.signals:[];
|
||||
var entryMethod = ep.entry_method || '';
|
||||
var signalText = sigs.join(' ');
|
||||
var phase = opportunityPhase(r, entryMethod, signalText);
|
||||
var isBuy = r.execution_status === 'buy_now' || r.display_bucket === 'realtime', isWait = phase.label === '等回踩' || r.lifecycle_state === 'waiting_entry', isWeakObserve = r.observe_tier === 'weak';
|
||||
var isExecuted = !!r.entry_triggered || r.display_bucket === 'position' || r.execution_status === 'holding' || r.execution_status === 'completed';
|
||||
var isTradePlan = isBuy || isWait || isExecuted || r.entry_triggered;
|
||||
var recMs = r.rec_time ? new Date(r.rec_time).getTime() : 0;
|
||||
var ageHours = recMs ? (Date.now() - recMs) / 3600000 : 0;
|
||||
var chgSinceRec = r.current_price && r.entry_price && r.entry_price > 0 ? ((r.current_price - r.entry_price) / r.entry_price * 100) : 0;
|
||||
var isOld = ageHours > 12;
|
||||
var hasSurged = chgSinceRec > 3;
|
||||
var actionBadge = '';
|
||||
if (isBuy && isOld && hasSurged) actionBadge = '<span class="action-badge caution">追高风险(+'+chgSinceRec.toFixed(1)+'%)</span>';
|
||||
else if (isBuy && isOld) actionBadge = '<span class="action-badge warn">信号偏弱</span>';
|
||||
else if (!isOld && hasSurged && isBuy) actionBadge = '<span class="action-badge caution">追高风险(+'+chgSinceRec.toFixed(1)+'%)</span>';
|
||||
else if (!isOld && (r.rec_score||0) < 50 && isBuy) actionBadge = '<span class="action-badge warn">信号偏弱</span>';
|
||||
else if (isBuy) actionBadge = '<span class="action-badge buy">入场窗口</span>';
|
||||
else actionBadge = '<span class="action-badge '+phase.cls+'">'+phase.label+'</span>';
|
||||
if (isWeakObserve) actionBadge = '<span class="action-badge weak">弱观察</span>';
|
||||
var ePrice = r.entry_price||'';
|
||||
var sl = (r.stop_loss&&r.stop_loss>0) ? r.stop_loss : '';
|
||||
var tp = (r.tp1&&r.tp1>0) ? r.tp1 : '';
|
||||
var klineEntryPrice = isBuy ? ePrice : '';
|
||||
var klineStopLoss = isBuy ? sl : '';
|
||||
var klineTp1 = isBuy ? tp : '';
|
||||
var entryTime = isBuy ? (r.rec_time||'') : '';
|
||||
var tp1EventTime = (r.status==='hit_tp1'||r.status==='hit_tp2') ? (r.hit_tp1_time||'') : '';
|
||||
var slEventTime = (r.status==='stopped_out') ? (r.stopped_out_time||'') : '';
|
||||
var isTpOrSl = r.status==='hit_tp1'||r.status==='hit_tp2'||r.status==='stopped_out';
|
||||
var price = r.current_price||r.entry_price||0;
|
||||
function fmtP(p) { return fmtPrice(p, priceDecimals(price || p)); }
|
||||
var pnl = r.pnl_pct||0, pnlCls = pnl>0?'pos':pnl<0?'neg':'zero', pnlSign = pnl>0?'+':'';
|
||||
var priceFmt = fmtPrice(price);
|
||||
function displaySignalText(s) {
|
||||
var text = cleanDisplayText(s);
|
||||
if (!isBuy) {
|
||||
text = text.replace(/15min\s*入场窗口信号/g, '15min触发信号').replace(/入场窗口信号/g, '触发信号').replace(/入场窗口确认/g, '触发确认');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
var sigHtml = sigs.slice(0,2).map(function(s){
|
||||
var cls = 'info'; if(/量价齐飞|起爆点|放量/.test(s)) cls='strong';
|
||||
else if(/静K|筑底|回踩|突破|蓄力|底部抬高|压缩/.test(s)) cls='forward';
|
||||
else if(/动K|PA|转折/.test(s)) cls='pa'; else if(/衰减|空头|风险|背离|闸门/.test(s)) cls='warn';
|
||||
return '<span class="sig '+cls+'">'+displaySignalText(s)+'</span>';
|
||||
}).join('');
|
||||
var score = r.rec_score||0, st = scoreTier(score), ver = r.strategy_version||'';
|
||||
var hasQualityGate = ep.entry_quality_gate && Array.isArray(ep.entry_quality_gate.reasons) && ep.entry_quality_gate.reasons.length;
|
||||
var entryLabel = isWait ? '回踩参考' : (hasQualityGate ? '失效参考' : '参考价位');
|
||||
var entryRef = (isWait || hasQualityGate) ? (ep.entry_price || r.entry_price || 0) : (r.entry_price || ep.entry_price || 0);
|
||||
var changeRef = entryRef || r.entry_price || 0;
|
||||
var changeLabel = isExecuted ? '持仓盈亏' : (isWait ? '较回踩参考' : (isBuy ? '较触发价' : '较参考价'));
|
||||
var changePct = price && changeRef ? ((price - changeRef) / changeRef * 100) : null;
|
||||
var changeCls = changePct!=null?(changePct>0?'up':changePct<0?'down':'zero'):'zero';
|
||||
var changeSign = changePct!=null&&changePct>0?'+':'';
|
||||
var changeHtml = changePct!=null ? '<span class="price-change '+changeCls+'" title="当前价相对'+changeLabel+',不是24h涨跌"><span class="pc-label">'+changeLabel+'</span><span class="pc-value">'+changeSign+changePct.toFixed(1)+'%</span></span>' : '';
|
||||
var riskLine = ep.stop_loss || r.stop_loss || 0;
|
||||
var spaceRef = ep.tp1 || r.tp1 || 0;
|
||||
var upsidePct = entryRef && spaceRef ? ((spaceRef / entryRef - 1) * 100) : 0;
|
||||
function entryWindowSummary() {
|
||||
var w = r.entry_window || {};
|
||||
if (!isBuy || !w.status) return '';
|
||||
var mins = Number(w.remaining_minutes || 0);
|
||||
var remain = mins >= 60 ? (Math.floor(mins/60)+'h'+Math.round(mins%60)+'m') : (Math.max(0, Math.round(mins))+'m');
|
||||
var dev = Number(w.deviation_pct || 0);
|
||||
var devText = (dev>0?'+':'') + dev.toFixed(2) + '%';
|
||||
return '剩余 '+remain+' · 偏离 '+devText;
|
||||
}
|
||||
var weakNoteHtml = isWeakObserve ? '<div class="weak-note">'+cleanDisplayText(r.observe_reason || '信号强度不足,仅保留为低优先级观察,不构成实时机会。')+'</div>' : '';
|
||||
var decisionCls = isBuy ? 'buy' : (isWait ? 'wait' : (isWeakObserve ? 'weak' : 'observe'));
|
||||
var decisionTitle = cleanDisplayText(r.execution_label || phase.label);
|
||||
var decisionFocus = isBuy ? ('现价 '+fmtP(price)) : (isWait ? ('等 '+fmtP(entryRef)) : (isWeakObserve ? '低优先级观察' : '等待确认'));
|
||||
var decisionReason = cleanDisplayText(isBuy ? (entryWindowSummary() || '入场窗口有效') : (isWait ? '现价不追,等回踩价附近再评估' : (r.observe_reason || r.state_reason || '未形成入场窗口')));
|
||||
var decisionHtml = '<div class="decision-strip '+decisionCls+'"><div class="decision-head"><span class="decision-label">最终建议</span><span class="decision-title">'+decisionTitle+'</span></div><div class="decision-body"><span class="decision-focus">'+decisionFocus+'</span><span class="decision-reason">'+decisionReason+'</span></div></div>';
|
||||
var aiInsightHtml = '';
|
||||
var aiInsight = r.llm_insight && r.llm_insight.content ? r.llm_insight.content : null;
|
||||
function hasAiText(v) {
|
||||
if (Array.isArray(v)) return v.some(function(x){ return cleanDisplayText(x).replace(/^-+$/,'').trim(); });
|
||||
return !!cleanDisplayText(v).replace(/^-+$/,'').trim();
|
||||
}
|
||||
if (aiInsight && (hasAiText(aiInsight.summary) || hasAiText(aiInsight.why_now_or_not) || hasAiText(aiInsight.key_evidence) || hasAiText(aiInsight.risk_flags) || hasAiText(aiInsight.watch_points) || hasAiText(aiInsight.invalid_if))) {
|
||||
var evidenceHtml = (aiInsight.key_evidence || []).slice(0, 4).map(function(x){ return '<span class="ai-pill">'+cleanDisplayText(x)+'</span>'; }).join('');
|
||||
var riskHtml = (aiInsight.risk_flags || []).slice(0, 4).map(function(x){ return '<span class="ai-pill">'+cleanDisplayText(x)+'</span>'; }).join('');
|
||||
var watchHtml = (aiInsight.watch_points || []).slice(0, 4).map(function(x){ return '<span class="ai-pill">'+cleanDisplayText(x)+'</span>'; }).join('');
|
||||
var invalidHtml = (aiInsight.invalid_if || []).slice(0, 4).map(function(x){ return '<span class="ai-pill">'+cleanDisplayText(x)+'</span>'; }).join('');
|
||||
aiInsightHtml = '<details class="ai-insight"><summary><span>AI 解读</span><span class="ai-tag">缓存</span></summary><div class="ai-body"><div class="ai-summary">'+cleanDisplayText(aiInsight.summary || aiInsight.why_now_or_not || '暂无摘要')+'</div><div class="ai-grid"><div class="ai-item"><div class="ai-label">为什么现在 / 为什么不现在</div><div class="ai-text">'+cleanDisplayText(aiInsight.why_now_or_not || '--')+'</div></div><div class="ai-item"><div class="ai-label">关键证据</div><div class="ai-list">'+(evidenceHtml || '<span class="ai-pill">--</span>')+'</div></div><div class="ai-item"><div class="ai-label">风险提示</div><div class="ai-list">'+(riskHtml || '<span class="ai-pill">--</span>')+'</div></div><div class="ai-item"><div class="ai-label">观察点</div><div class="ai-list">'+(watchHtml || '<span class="ai-pill">--</span>')+'</div></div></div><div class="ai-item"><div class="ai-label">失效条件</div><div class="ai-list">'+(invalidHtml || '<span class="ai-pill">--</span>')+'</div></div></div></details>';
|
||||
}
|
||||
var onchainHtml = '';
|
||||
var oc = r.onchain_context || null;
|
||||
if (oc && (oc.event_count_24h || oc.onchain_score || oc.risk_score)) {
|
||||
var ocRisk = Number(oc.risk_event_count_24h || 0) > 0 || Number(oc.risk_score || 0) >= 60;
|
||||
var ocTitle = cleanDisplayText(oc.headline || (ocRisk ? '链上风险升温' : '链上资金异动'));
|
||||
var ocScore = ocRisk ? Number(oc.risk_score || 0).toFixed(0) : Number(oc.onchain_score || 0).toFixed(0);
|
||||
var ocMeta = [oc.chain || '链上', '24h事件 '+(oc.event_count_24h || 0), oc.dex_volume_usd ? ('DEX量 $'+fmtCompactNumber(oc.dex_volume_usd)) : ''].filter(Boolean).join(' · ');
|
||||
onchainHtml = '<div class="onchain-brief '+(ocRisk?'risk':'')+'"><div class="onchain-head"><span>'+ocTitle+'</span><span class="onchain-score">'+ocScore+'</span></div><div class="onchain-meta">'+escHtml(ocMeta)+'</div></div>';
|
||||
}
|
||||
var entryPlanHtml = '';
|
||||
if (isTradePlan) {
|
||||
entryPlanHtml = '<div class="entry-plan"><div class="ep-item"><span class="ep-label">阶段</span><span class="ep-val phase-ref">'+phase.short+'</span><span class="ep-sub">机会所处阶段</span></div><div class="ep-item"><span class="ep-label">'+entryLabel+'</span><span class="ep-val entry-ref">'+fmtP(entryRef)+'</span><span class="ep-sub">触发/计划价</span></div><div class="ep-item"><span class="ep-label">风险边界</span><span class="ep-val risk-line">'+fmtP(riskLine)+'</span><span class="ep-sub">跌破则逻辑失效</span></div><div class="ep-item"><span class="ep-label">上方空间</span><span class="ep-val space-ref">'+(upsidePct?('+'+upsidePct.toFixed(1)+'%'):'--')+'</span><span class="ep-sub">参考位 '+fmtP(spaceRef)+'</span></div></div>';
|
||||
} else {
|
||||
entryPlanHtml = '<div class="entry-plan"><div class="ep-item"><span class="ep-label">阶段</span><span class="ep-val phase-ref">'+phase.short+'</span><span class="ep-sub">观察池候选</span></div><div class="ep-item"><span class="ep-label">当前参考</span><span class="ep-val entry-ref">'+fmtP(price)+'</span><span class="ep-sub">不是入场价</span></div><div class="ep-item"><span class="ep-label">确认条件</span><span class="ep-val space-ref">待触发</span><span class="ep-sub">需15m/1H当前信号</span></div><div class="ep-item"><span class="ep-label">绩效口径</span><span class="ep-val risk-line">不计入</span><span class="ep-sub">未成交易推荐</span></div></div>';
|
||||
}
|
||||
return '<div class="card '+(isWeakObserve?'weak-observe':'')+'"><div class="card-bar"><div class="coin-left"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span></div></div><div class="badge-group">'+actionBadge+'<span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">'+st.label+'</span></span></div></div><div class="price-bar"><span class="price">$'+priceFmt+'</span>'+changeHtml+'</div>'+decisionHtml+onchainHtml+aiInsightHtml+'<div class="kline-wrap"><div class="kline-int-bar"><button class="kline-int-btn" data-int="15m" onclick="switchKlineInterval(this);event.stopPropagation()">15m</button><button class="kline-int-btn active" data-int="1h" onclick="switchKlineInterval(this);event.stopPropagation()">1H</button><button class="kline-int-btn" data-int="4h" onclick="switchKlineInterval(this);event.stopPropagation()">4H</button><button class="kline-int-btn" data-int="1d" onclick="switchKlineInterval(this);event.stopPropagation()">1D</button></div><div class="kline-container loading" data-symbol="'+r.symbol+'" data-entry-price="'+klineEntryPrice+'" data-stop-loss="'+klineStopLoss+'" data-tp1="'+klineTp1+'" data-rec-time="'+entryTime+'" data-tp1-time="'+tp1EventTime+'" data-sl-time="'+slEventTime+'" data-ref-price="'+price+'" data-action-status="'+(r.action_status||'')+'"><div class="chart-loading"><svg class="spin" width="16" height="16" color="#8e91a0"><use href="#svg-spinner"/></svg></div></div></div>'+(isWeakObserve ? weakNoteHtml : entryPlanHtml)+(sigHtml?'<div class="signals-row">'+sigHtml+'</div>':'')+'<div class="card-footer"><span>'+fmtTime(r.rec_time)+'</span><span class="card-ver">'+ver+'</span>'+(isTpOrSl?'<span class="pnl-block '+pnlCls+'">'+pnlSign+pnl.toFixed(1)+'%</span>':'')+'</div></div>';
|
||||
} catch (e) {
|
||||
console.error('renderRecCard hard fail', r && r.symbol, e);
|
||||
return renderLiveFallbackCard(r);
|
||||
}
|
||||
var sigHtml = sigs.slice(0,2).map(function(s){
|
||||
var cls = 'info'; if(/量价齐飞|起爆点|放量/.test(s)) cls='strong';
|
||||
else if(/静K|筑底|回踩|突破|蓄力|底部抬高|压缩/.test(s)) cls='forward';
|
||||
else if(/动K|PA|转折/.test(s)) cls='pa'; else if(/衰减|空头|风险|背离|闸门/.test(s)) cls='warn';
|
||||
return '<span class="sig '+cls+'">'+displaySignalText(s)+'</span>';
|
||||
}).join('');
|
||||
var score = r.rec_score||0, st = scoreTier(score), ver = r.strategy_version||'';
|
||||
var hasQualityGate = ep.entry_quality_gate && Array.isArray(ep.entry_quality_gate.reasons) && ep.entry_quality_gate.reasons.length;
|
||||
var entryLabel = isWait ? '回踩参考' : (hasQualityGate ? '失效参考' : '参考价位');
|
||||
// 等回踩/观察池卡片的参考价应优先使用 entry_plan 中的计划价,不能用 tracker 重置后的真实触发价,
|
||||
// 否则会出现“回踩参考价=现价”的误导展示。入场窗口则仍以 DB 主链路 entry_price 为准。
|
||||
var entryRef = (isWait || hasQualityGate) ? (ep.entry_price || r.entry_price || 0) : (r.entry_price || ep.entry_price || 0);
|
||||
var changeRef = entryRef || r.entry_price || 0;
|
||||
var changeLabel = isExecuted ? '持仓盈亏' : (isWait ? '较回踩参考' : (isBuy ? '较触发价' : '较参考价'));
|
||||
var changePct = price && changeRef ? ((price - changeRef) / changeRef * 100) : null;
|
||||
var changeCls = changePct!=null?(changePct>0?'up':changePct<0?'down':'zero'):'zero';
|
||||
var changeSign = changePct!=null&&changePct>0?'+':'';
|
||||
var changeHtml = changePct!=null ? '<span class="price-change '+changeCls+'" title="当前价相对'+changeLabel+',不是24h涨跌"><span class="pc-label">'+changeLabel+'</span><span class="pc-value">'+changeSign+changePct.toFixed(1)+'%</span></span>' : '';
|
||||
var riskLine = ep.stop_loss || r.stop_loss || 0;
|
||||
var spaceRef = ep.tp1 || r.tp1 || 0;
|
||||
var upsidePct = entryRef && spaceRef ? ((spaceRef / entryRef - 1) * 100) : 0;
|
||||
function entryWindowSummary() {
|
||||
var w = r.entry_window || {};
|
||||
if (!isBuy || !w.status) return '';
|
||||
var mins = Number(w.remaining_minutes || 0);
|
||||
var remain = mins >= 60 ? (Math.floor(mins/60)+'h'+Math.round(mins%60)+'m') : (Math.max(0, Math.round(mins))+'m');
|
||||
var dev = Number(w.deviation_pct || 0);
|
||||
var devText = (dev>0?'+':'') + dev.toFixed(2) + '%';
|
||||
return '剩余 '+remain+' · 偏离 '+devText;
|
||||
}
|
||||
var weakNoteHtml = isWeakObserve ? '<div class="weak-note">'+cleanDisplayText(r.observe_reason || '信号强度不足,仅保留为低优先级观察,不构成实时机会。')+'</div>' : '';
|
||||
var decisionCls = isBuy ? 'buy' : (isWait ? 'wait' : (isWeakObserve ? 'weak' : 'observe'));
|
||||
var decisionTitle = cleanDisplayText(r.execution_label || phase.label);
|
||||
var decisionFocus = isBuy ? ('现价 '+fmtP(price)) : (isWait ? ('等 '+fmtP(entryRef)) : (isWeakObserve ? '低优先级观察' : '等待确认'));
|
||||
var decisionReason = cleanDisplayText(isBuy ? (entryWindowSummary() || '入场窗口有效') : (isWait ? '现价不追,等回踩价附近再评估' : (r.observe_reason || r.state_reason || '未形成入场窗口')));
|
||||
var decisionHtml = '<div class="decision-strip '+decisionCls+'"><div class="decision-head"><span class="decision-label">最终建议</span><span class="decision-title">'+decisionTitle+'</span></div><div class="decision-body"><span class="decision-focus">'+decisionFocus+'</span><span class="decision-reason">'+decisionReason+'</span></div></div>';
|
||||
var aiInsightHtml = '';
|
||||
var aiInsight = r.llm_insight && r.llm_insight.content ? r.llm_insight.content : null;
|
||||
function hasAiText(v) {
|
||||
if (Array.isArray(v)) return v.some(function(x){ return cleanDisplayText(x).replace(/^-+$/,'').trim(); });
|
||||
return !!cleanDisplayText(v).replace(/^-+$/,'').trim();
|
||||
}
|
||||
if (aiInsight && (
|
||||
hasAiText(aiInsight.summary) ||
|
||||
hasAiText(aiInsight.why_now_or_not) ||
|
||||
hasAiText(aiInsight.key_evidence) ||
|
||||
hasAiText(aiInsight.risk_flags) ||
|
||||
hasAiText(aiInsight.watch_points) ||
|
||||
hasAiText(aiInsight.invalid_if)
|
||||
)) {
|
||||
var evidenceHtml = (aiInsight.key_evidence || []).slice(0, 4).map(function(x){ return '<span class="ai-pill">'+cleanDisplayText(x)+'</span>'; }).join('');
|
||||
var riskHtml = (aiInsight.risk_flags || []).slice(0, 4).map(function(x){ return '<span class="ai-pill">'+cleanDisplayText(x)+'</span>'; }).join('');
|
||||
var watchHtml = (aiInsight.watch_points || []).slice(0, 4).map(function(x){ return '<span class="ai-pill">'+cleanDisplayText(x)+'</span>'; }).join('');
|
||||
var invalidHtml = (aiInsight.invalid_if || []).slice(0, 4).map(function(x){ return '<span class="ai-pill">'+cleanDisplayText(x)+'</span>'; }).join('');
|
||||
aiInsightHtml =
|
||||
'<details class="ai-insight">'+
|
||||
'<summary><span>AI 解读</span><span class="ai-tag">缓存</span></summary>'+
|
||||
'<div class="ai-body">'+
|
||||
'<div class="ai-summary">'+cleanDisplayText(aiInsight.summary || aiInsight.why_now_or_not || '暂无摘要')+'</div>'+
|
||||
'<div class="ai-grid">'+
|
||||
'<div class="ai-item"><div class="ai-label">为什么现在 / 为什么不现在</div><div class="ai-text">'+cleanDisplayText(aiInsight.why_now_or_not || '--')+'</div></div>'+
|
||||
'<div class="ai-item"><div class="ai-label">关键证据</div><div class="ai-list">'+(evidenceHtml || '<span class="ai-pill">--</span>')+'</div></div>'+
|
||||
'<div class="ai-item"><div class="ai-label">风险提示</div><div class="ai-list">'+(riskHtml || '<span class="ai-pill">--</span>')+'</div></div>'+
|
||||
'<div class="ai-item"><div class="ai-label">观察点</div><div class="ai-list">'+(watchHtml || '<span class="ai-pill">--</span>')+'</div></div>'+
|
||||
'</div>'+
|
||||
'<div class="ai-item"><div class="ai-label">失效条件</div><div class="ai-list">'+(invalidHtml || '<span class="ai-pill">--</span>')+'</div></div>'+
|
||||
'</div>'+
|
||||
'</details>';
|
||||
}
|
||||
var onchainHtml = '';
|
||||
var oc = r.onchain_context || null;
|
||||
if (oc && (oc.event_count_24h || oc.onchain_score || oc.risk_score)) {
|
||||
var ocRisk = Number(oc.risk_event_count_24h || 0) > 0 || Number(oc.risk_score || 0) >= 60;
|
||||
var ocTitle = cleanDisplayText(oc.headline || (ocRisk ? '链上风险升温' : '链上资金异动'));
|
||||
var ocScore = ocRisk ? Number(oc.risk_score || 0).toFixed(0) : Number(oc.onchain_score || 0).toFixed(0);
|
||||
var ocMeta = [oc.chain || '链上', '24h事件 '+(oc.event_count_24h || 0), oc.dex_volume_usd ? ('DEX量 $'+fmtCompactNumber(oc.dex_volume_usd)) : ''].filter(Boolean).join(' · ');
|
||||
onchainHtml = '<div class="onchain-brief '+(ocRisk?'risk':'')+'"><div class="onchain-head"><span>'+ocTitle+'</span><span class="onchain-score">'+ocScore+'</span></div><div class="onchain-meta">'+escHtml(ocMeta)+'</div></div>';
|
||||
}
|
||||
var entryPlanHtml = '';
|
||||
if (isTradePlan) {
|
||||
entryPlanHtml = '<div class="entry-plan">' +
|
||||
'<div class="ep-item"><span class="ep-label">阶段</span><span class="ep-val phase-ref">'+phase.short+'</span><span class="ep-sub">机会所处阶段</span></div>'+
|
||||
'<div class="ep-item"><span class="ep-label">'+entryLabel+'</span><span class="ep-val entry-ref">'+fmtP(entryRef)+'</span><span class="ep-sub">触发/计划价</span></div>'+
|
||||
'<div class="ep-item"><span class="ep-label">风险边界</span><span class="ep-val risk-line">'+fmtP(riskLine)+'</span><span class="ep-sub">跌破则逻辑失效</span></div>'+
|
||||
'<div class="ep-item"><span class="ep-label">上方空间</span><span class="ep-val space-ref">'+(upsidePct?('+'+upsidePct.toFixed(1)+'%'):'--')+'</span><span class="ep-sub">参考位 '+fmtP(spaceRef)+'</span></div>'+
|
||||
'</div>';
|
||||
} else {
|
||||
entryPlanHtml = '<div class="entry-plan">' +
|
||||
'<div class="ep-item"><span class="ep-label">阶段</span><span class="ep-val phase-ref">'+phase.short+'</span><span class="ep-sub">观察池候选</span></div>'+
|
||||
'<div class="ep-item"><span class="ep-label">当前参考</span><span class="ep-val entry-ref">'+fmtP(price)+'</span><span class="ep-sub">不是入场价</span></div>'+
|
||||
'<div class="ep-item"><span class="ep-label">确认条件</span><span class="ep-val space-ref">待触发</span><span class="ep-sub">需15m/1H当前信号</span></div>'+
|
||||
'<div class="ep-item"><span class="ep-label">绩效口径</span><span class="ep-val risk-line">不计入</span><span class="ep-sub">未成交易推荐</span></div>'+
|
||||
'</div>';
|
||||
}
|
||||
return '<div class="card '+(isWeakObserve?'weak-observe':'')+'"><div class="card-bar"><div class="coin-left"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span></div></div><div class="badge-group">'+actionBadge+'<span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">'+st.label+'</span></span></div></div>'+
|
||||
'<div class="price-bar"><span class="price">$'+priceFmt+'</span>'+changeHtml+'</div>'+
|
||||
decisionHtml+
|
||||
onchainHtml+
|
||||
aiInsightHtml+
|
||||
'<div class="kline-wrap"><div class="kline-int-bar"><button class="kline-int-btn" data-int="15m" onclick="switchKlineInterval(this);event.stopPropagation()">15m</button><button class="kline-int-btn active" data-int="1h" onclick="switchKlineInterval(this);event.stopPropagation()">1H</button><button class="kline-int-btn" data-int="4h" onclick="switchKlineInterval(this);event.stopPropagation()">4H</button><button class="kline-int-btn" data-int="1d" onclick="switchKlineInterval(this);event.stopPropagation()">1D</button></div><div class="kline-container loading" data-symbol="'+r.symbol+'" data-entry-price="'+klineEntryPrice+'" data-stop-loss="'+klineStopLoss+'" data-tp1="'+klineTp1+'" data-rec-time="'+entryTime+'" data-tp1-time="'+tp1EventTime+'" data-sl-time="'+slEventTime+'" data-ref-price="'+price+'" data-action-status="'+(r.action_status||'')+'"><div class="chart-loading"><svg class="spin" width="16" height="16" color="#8e91a0"><use href="#svg-spinner"/></svg></div></div></div>'+
|
||||
(isWeakObserve ? weakNoteHtml : entryPlanHtml)+
|
||||
(sigHtml?'<div class="signals-row">'+sigHtml+'</div>':'')+
|
||||
'<div class="card-footer"><span>'+fmtTime(r.rec_time)+'</span><span class="card-ver">'+ver+'</span>'+(isTpOrSl?'<span class="pnl-block '+pnlCls+'">'+pnlSign+pnl.toFixed(1)+'%</span>':'')+'</div></div>';
|
||||
}
|
||||
|
||||
// ====== KLINE ======
|
||||
|
||||
@ -173,6 +173,7 @@ a { color: inherit; text-decoration: none; }
|
||||
<nav class="sidebar-nav">
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link active" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link" href="/market"><svg class="link-icon"><use href="#svg-target"/></svg>市场总览</a>
|
||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上异动</a>
|
||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
{% block title %}AlphaX Agent | Crypto — 调度中心{% endblock %}
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link" href="/market"><svg class="link-icon"><use href="#svg-target"/></svg>市场总览</a>
|
||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上异动</a>
|
||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
{% block title %}AlphaX Agent | Crypto — 策略进化{% endblock %}
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link" href="/market"><svg class="link-icon"><use href="#svg-target"/></svg>市场总览</a>
|
||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上异动</a>
|
||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
{% block title %}AlphaX Agent | Crypto — AI 记录{% endblock %}
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link" href="/market"><svg class="link-icon"><use href="#svg-target"/></svg>市场总览</a>
|
||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上异动</a>
|
||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
|
||||
165
static/market.html
Normal file
165
static/market.html
Normal file
@ -0,0 +1,165 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}AlphaX Agent | Crypto — 市场总览{% endblock %}
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link active" href="/market"><svg class="link-icon"><use href="#svg-target"/></svg>市场总览</a>
|
||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上异动</a>
|
||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||
<a class="sidebar-link admin-link" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
|
||||
<a class="sidebar-link admin-link" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
|
||||
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||
{% 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:-.8px;color:var(--ink)}.page-head p{margin-top:5px;color:var(--stone);font-size:13px;line-height:1.55;max-width:880px}.head-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.select,.btn{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}.hint{padding:10px 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}.kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:14px}.kpi{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);padding:13px;min-width:0}.kpi span{display:block;color:var(--stone);font-size:11px;font-weight:900}.kpi b{display:block;margin-top:7px;color:var(--ink);font-size:22px;line-height:1;font-weight:950;letter-spacing:-.5px}.kpi b.green{color:var(--green)}.kpi b.red{color:var(--red)}.kpi b.blue{color:var(--blue)}.kpi b.yellow{color:var(--yellow-dark)}.grid{display:grid;grid-template-columns:1.1fr .9fr;gap:12px;margin-bottom:14px}.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}.panel-body{padding:12px}.mini-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.mini{border:1px solid var(--hairline-soft);background:var(--surface);border-radius:var(--radius-md);padding:10px;min-width:0}.mini span{display:block;color:var(--stone);font-size:10px;font-weight:900}.mini b{display:block;margin-top:4px;color:var(--ink);font-size:15px;font-weight:950;line-height:1.3}.line{display:flex;justify-content:space-between;gap:10px;align-items:center;border:1px solid var(--hairline-soft);background:var(--surface);border-radius:var(--radius-md);padding:10px 11px;margin-bottom:8px}.line .lbl{font-size:12px;font-weight:900;color:var(--ink)}.line .val{font-size:12px;color:var(--slate);font-weight:850;text-align:right}.chips{display:flex;flex-wrap:wrap;gap:8px}.chip{display:inline-flex;align-items:center;gap:6px;padding:7px 10px;border-radius:999px;background:var(--surface);border:1px solid var(--hairline-soft);font-size:12px;font-weight:850;color:var(--slate)}.chip.hot{background:var(--green-light);border-color:rgba(0,180,115,.18);color:var(--green)}.chip.risk{background:var(--red-light);border-color:rgba(229,62,62,.18);color:var(--red)}.chip.blue{background:rgba(66,98,255,.06);border-color:rgba(66,98,255,.16);color:var(--blue)}.raw-list{display:grid;gap:8px}.raw-item{border:1px solid var(--hairline-soft);background:var(--surface);border-radius:var(--radius-md);padding:11px}.raw-item h3{font-size:12px;font-weight:950;color:var(--ink);line-height:1.45}.raw-item .sub{margin-top:6px;color:var(--stone);font-size:11px;line-height:1.45}.raw-tags{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}.tag{display:inline-flex;padding:3px 7px;border-radius:999px;background:var(--canvas);border:1px solid var(--hairline-soft);font-size:10px;font-weight:900;color:var(--blue)}.tag.risk{color:var(--red)}.tag.hot{color:var(--green)}.empty,.loading{padding:34px 16px;text-align:center;color:var(--stone);font-size:13px}.soft-note{padding:10px 12px;border:1px solid var(--hairline-soft);background:var(--surface);border-radius:var(--radius-md);color:var(--slate);font-size:12px;line-height:1.5}.compact-note{color:var(--stone);font-size:12px;line-height:1.5}.status-pill{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px;border-radius:999px;border:1px solid var(--hairline-soft);background:var(--surface);font-size:12px;font-weight:900;color:var(--slate)}.status-pill.ok{background:var(--green-light);border-color:rgba(0,180,115,.18);color:var(--green)}.status-pill.warn{background:var(--yellow-light);border-color:rgba(252,185,0,.22);color:var(--yellow-dark)}.status-pill.bad{background:var(--red-light);border-color:rgba(229,62,62,.18);color:var(--red)}@media(max-width:1080px){.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.grid{grid-template-columns:1fr}}@media(max-width:620px){.shell{width:min(100% - 24px,1280px)}.page-head h1{font-size:22px}.mini-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="shell">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>市场总览</h1>
|
||||
<p>这里不是单币推荐页,而是帮你快速判断当前环境:场内有没有动能,链上有没有真异动,AI 舆情有没有明确倾向。</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<select class="select" id="hoursSel" onchange="reloadAll()">
|
||||
<option value="24">近 24h</option>
|
||||
<option value="72">近 3 天</option>
|
||||
<option value="168">近 7 天</option>
|
||||
</select>
|
||||
<button class="btn" onclick="reloadAll()">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint">只保留高信号信息。空的 AI / 舆情 / 链上统计不占主位,避免页面看起来热闹但没有结论。</div>
|
||||
<div class="kpis" id="kpis"><div class="loading">加载中...</div></div>
|
||||
<div class="grid">
|
||||
<section class="panel">
|
||||
<div class="panel-head"><div class="panel-title">市场结论</div><div class="panel-note">场内 + 链上 + AI 的最短摘要</div></div>
|
||||
<div class="panel-body" id="summaryPanel"><div class="loading">加载中...</div></div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-head"><div class="panel-title">重点板块</div><div class="panel-note">只看有热度的板块</div></div>
|
||||
<div class="panel-body" id="sectorPanel"><div class="loading">加载中...</div></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<section class="panel">
|
||||
<div class="panel-head"><div class="panel-title">链上态势</div><div class="panel-note">原始覆盖与高价值异动</div></div>
|
||||
<div class="panel-body" id="onchainPanel"><div class="loading">加载中...</div></div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-head"><div class="panel-title">原始链上流</div><div class="panel-note">实时事件先看事实</div></div>
|
||||
<div class="panel-body" id="rawPanel"><div class="loading">加载中...</div></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
var API = '';
|
||||
function $(id){return document.getElementById(id)}
|
||||
function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]})}
|
||||
function fmtUsd(v){v=Number(v||0);if(Math.abs(v)>=1e9)return '$'+(v/1e9).toFixed(2)+'B';if(Math.abs(v)>=1e6)return '$'+(v/1e6).toFixed(2)+'M';if(Math.abs(v)>=1e3)return '$'+(v/1e3).toFixed(1)+'K';return '$'+v.toFixed(0)}
|
||||
function fmtNum(v,d){v=Number(v||0);return v.toFixed(d==null?1:d)}
|
||||
function chip(text, cls){return '<span class="chip '+(cls||'')+'">'+esc(text)+'</span>'}
|
||||
function compact(text){return esc(String(text||'').replace(/\s+/g,' ').trim())}
|
||||
function loadKpis(stats, onchain, news, ai){
|
||||
var fg=(news&&news.fear_greed)||{};
|
||||
var market=(stats&&stats.market_context_overview)||{};
|
||||
var onchainKpi=(onchain&&onchain.kpi)||{};
|
||||
var aiStatus=(ai&&ai.status)||'--';
|
||||
$('kpis').innerHTML=[
|
||||
['情绪指数', fg.value!=null?fg.value:'--', 'blue'],
|
||||
['场内热点', market.hot_sector_count||0, 'green'],
|
||||
['链上原始流', onchainKpi.raw_event_count||0, 'blue'],
|
||||
['AI 状态', aiStatus, 'yellow']
|
||||
].map(function(x){return '<div class="kpi"><span>'+x[0]+'</span><b class="'+x[2]+'">'+x[1]+'</b></div>'}).join('');
|
||||
}
|
||||
function renderSummary(stats, onchain, news, ai){
|
||||
var market=(stats&&stats.market_context_overview)||{};
|
||||
var fg=(news&&news.fear_greed)||{};
|
||||
var aiContent=(ai&&ai.content)||{};
|
||||
var summary=(aiContent.summary||aiContent.memo||aiContent.why_now_or_not||'').trim();
|
||||
var mood = '观望';
|
||||
var moodClass = 'warn';
|
||||
var heat = Number(market.avg_turnover_acceleration_1h||0);
|
||||
if (heat >= 1.2) { mood='偏机会'; moodClass='ok'; }
|
||||
else if (heat <= 0.7) { mood='偏谨慎'; moodClass='bad'; }
|
||||
var aiStatus = (ai&&ai.status) || '--';
|
||||
var aiText = summary || '暂无 AI 舆情摘要';
|
||||
$('summaryPanel').innerHTML =
|
||||
'<div class="line"><span class="lbl">市场温度</span><span class="val"><span class="status-pill '+moodClass+'">'+mood+'</span></span></div>'+
|
||||
'<div class="line"><span class="lbl">动能</span><span class="val">'+fmtNum(market.avg_turnover_acceleration_1h||0,1)+'x / '+fmtNum(market.avg_turnover_acceleration_4h||0,1)+'x</span></div>'+
|
||||
'<div class="line"><span class="lbl">成交额</span><span class="val">'+fmtUsd(market.avg_volume_24h||0)+'</span></div>'+
|
||||
'<div class="line"><span class="lbl">情绪指数</span><span class="val">'+esc((fg.classification||'--')+' · '+(fg.value!=null?fg.value:'--'))+'</span></div>'+
|
||||
'<div class="line"><span class="lbl">AI 舆情</span><span class="val">'+esc(aiStatus)+'</span></div>'+
|
||||
'<div class="soft-note">'+compact(aiText)+'</div>';
|
||||
}
|
||||
function renderSectors(stats){
|
||||
var sectors=((stats&&stats.market_context_overview||{}).top_hot_sectors)||[];
|
||||
if (!sectors.length) { $('sectorPanel').innerHTML = '<div class="empty">暂无热点板块</div>'; return; }
|
||||
$('sectorPanel').innerHTML = '<div class="chips">'+sectors.map(function(sec){return chip((sec.sector||'--')+' · '+(sec.count||0),'hot')}).join('')+'</div>';
|
||||
}
|
||||
function renderOnchain(d){
|
||||
var k=(d&&d.kpi)||{};
|
||||
var hot=(d.hot_tokens||[]).slice(0,4);
|
||||
var risk=(d.risk_tokens||[]).slice(0,4);
|
||||
var lines = [];
|
||||
lines.push(['原始事件', k.raw_event_count||0]);
|
||||
lines.push(['已映射', k.raw_mapped_count||0]);
|
||||
lines.push(['覆盖币种', k.token_count||0]);
|
||||
lines.push(['正向异动', k.positive_events||0]);
|
||||
lines.push(['风险事件', k.risk_events||0]);
|
||||
lines.push(['DEX 成交', fmtUsd(k.dex_volume_usd||0)]);
|
||||
var body = '<div class="mini-grid">'+lines.map(function(x){return '<div class="mini"><span>'+x[0]+'</span><b>'+x[1]+'</b></div>'}).join('')+'</div>';
|
||||
var hotHtml = hot.length ? '<div style="margin-top:10px"><div class="panel-note" style="margin-bottom:8px">链上热度</div><div class="chips">'+hot.map(function(t){return chip((t.symbol||'--')+' · '+fmtUsd(t.dex_volume_usd),'hot')}).join('')+'</div></div>' : '';
|
||||
var riskHtml = risk.length ? '<div style="margin-top:10px"><div class="panel-note" style="margin-bottom:8px">风险异动</div><div class="chips">'+risk.map(function(t){return chip((t.symbol||'--')+' · '+Number(t.risk_score||0).toFixed(0),'risk')}).join('')+'</div></div>' : '';
|
||||
$('onchainPanel').innerHTML = body + hotHtml + riskHtml;
|
||||
}
|
||||
function renderRaw(d){
|
||||
var items=(d&&d.raw_events)||[];
|
||||
if (!items.length) { $('rawPanel').innerHTML = '<div class="empty">暂无原始链上流</div>'; return; }
|
||||
$('rawPanel').innerHTML = '<div class="raw-list">'+items.slice(0,6).map(function(e){
|
||||
var mapped = e.mapping_status === 'mapped';
|
||||
return '<div class="raw-item"><h3>'+compact(e.title||e.event_label||e.event_type||'链上原始事件')+' <span class="status-pill '+(mapped?'ok':'warn')+'" style="margin-left:6px;height:22px">'+(mapped?'已映射':'未映射')+'</span></h3><div class="sub">'+esc((e.chain||'--')+' · '+(e.mapped_symbol||'未映射')+' · '+fmtUsd(e.total_amount||e.amount||0)+' · '+(e.detected_at||'--'))+'</div><div class="raw-tags">'+[(e.source||''),(e.event_type||''),(e.url?'来源':'')].filter(Boolean).map(function(t){return '<span class="tag">'+esc(t)+'</span>'}).join('')+'</div></div>';
|
||||
}).join('')+'</div>';
|
||||
}
|
||||
function renderAiStatus(ai){
|
||||
var content=(ai&&ai.content)||{};
|
||||
var summary=(content.summary||content.memo||content.why_now_or_not||'').trim();
|
||||
if (!summary) return '';
|
||||
return '<div class="soft-note" style="margin-top:10px">AI 结论:'+compact(summary)+'</div>';
|
||||
}
|
||||
async function reloadAll(){
|
||||
try{
|
||||
var hours=$('hoursSel').value;
|
||||
var resp=await fetch(API+'/api/market/overview?hours='+hours);
|
||||
var d=await resp.json();
|
||||
var market=(d.market||{}).stats||{};
|
||||
var onchain=(d.market||{}).onchain||{};
|
||||
var news=(d.market||{}).newsfeed||{};
|
||||
var ai=(d.market||{}).ai_analysis||{};
|
||||
loadKpis(market,onchain,news,ai);
|
||||
renderSummary(market,onchain,news,ai);
|
||||
renderSectors(market);
|
||||
renderOnchain(onchain);
|
||||
renderRaw(onchain);
|
||||
var aiTail = renderAiStatus(ai);
|
||||
if (aiTail) $('summaryPanel').innerHTML += aiTail;
|
||||
}catch(e){
|
||||
$('kpis').innerHTML='<div class="empty">市场总览加载失败</div>';
|
||||
$('summaryPanel').innerHTML='';
|
||||
$('sectorPanel').innerHTML='';
|
||||
$('onchainPanel').innerHTML='';
|
||||
$('rawPanel').innerHTML='';
|
||||
}
|
||||
}
|
||||
reloadAll();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -2,6 +2,7 @@
|
||||
{% block title %}AlphaX Agent | Crypto — 链上异动{% endblock %}
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link" href="/market"><svg class="link-icon"><use href="#svg-target"/></svg>市场总览</a>
|
||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link active" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上异动</a>
|
||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -3,6 +3,7 @@
|
||||
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link" href="/market"><svg class="link-icon"><use href="#svg-target"/></svg>市场总览</a>
|
||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上异动</a>
|
||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
{% block title %}AlphaX Agent | Crypto — 舆情雷达{% endblock %}
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link" href="/market"><svg class="link-icon"><use href="#svg-target"/></svg>市场总览</a>
|
||||
<a class="sidebar-link active" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上异动</a>
|
||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
{% block title %}策略 — AlphaX Agent | Crypto{% endblock %}
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link" href="/market"><svg class="link-icon"><use href="#svg-target"/></svg>市场总览</a>
|
||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上异动</a>
|
||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
{% block title %}订阅中心 — AlphaX Agent | Crypto{% endblock %}
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link" href="/market"><svg class="link-icon"><use href="#svg-target"/></svg>市场总览</a>
|
||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上异动</a>
|
||||
<a class="sidebar-link active" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
{% block title %}关注 — AlphaX Agent | Crypto{% endblock %}
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link" href="/market"><svg class="link-icon"><use href="#svg-target"/></svg>市场总览</a>
|
||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上异动</a>
|
||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
|
||||
285
tests/conftest.py
Normal file
285
tests/conftest.py
Normal file
@ -0,0 +1,285 @@
|
||||
"""PostgreSQL test harness.
|
||||
|
||||
The application runtime is PostgreSQL-only. A number of older tests still use
|
||||
``sqlite3.connect(tmp_path)`` as a shorthand for "give me an isolated DB".
|
||||
This file keeps that shorthand test-local by routing it to the PostgreSQL test
|
||||
database and truncating business tables before every test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import pytest
|
||||
import psycopg
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_DIR))
|
||||
|
||||
|
||||
def _test_database_url() -> str:
|
||||
explicit = os.getenv("ALPHAX_TEST_DATABASE_URL", "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
runtime_url = os.getenv("DATABASE_URL", "").strip()
|
||||
if not runtime_url:
|
||||
raise RuntimeError("DATABASE_URL is required for PostgreSQL tests")
|
||||
parts = urlsplit(runtime_url)
|
||||
db_name = parts.path.lstrip("/") or "alphax"
|
||||
test_name = db_name if db_name.endswith("_test") else f"{db_name}_test"
|
||||
return urlunsplit((parts.scheme, parts.netloc, f"/{test_name}", parts.query, parts.fragment))
|
||||
|
||||
|
||||
def _maintenance_url(database_url: str) -> str:
|
||||
parts = urlsplit(database_url)
|
||||
return urlunsplit((parts.scheme, parts.netloc, "/postgres", parts.query, parts.fragment))
|
||||
|
||||
|
||||
def _ensure_test_database(database_url: str) -> None:
|
||||
parts = urlsplit(database_url)
|
||||
db_name = parts.path.lstrip("/")
|
||||
if not db_name:
|
||||
raise RuntimeError("Test database URL must include a database name")
|
||||
with psycopg.connect(_maintenance_url(database_url), autocommit=True) as conn:
|
||||
exists = conn.execute("SELECT 1 FROM pg_database WHERE datname=%s", (db_name,)).fetchone()
|
||||
if not exists:
|
||||
conn.execute(f'CREATE DATABASE "{db_name}"')
|
||||
|
||||
|
||||
TEST_DATABASE_URL = _test_database_url()
|
||||
_ensure_test_database(TEST_DATABASE_URL)
|
||||
os.environ["DATABASE_URL"] = TEST_DATABASE_URL
|
||||
|
||||
from app.db import altcoin_db, auth_db, scheduler_db, schema
|
||||
from app.db import postgres_connection
|
||||
|
||||
|
||||
# Legacy tests patch these attributes. Keep the names available only for tests;
|
||||
# runtime modules no longer read them.
|
||||
altcoin_db.DB_PATH = ""
|
||||
auth_db.DB_PATH = ""
|
||||
scheduler_db.SCHEDULER_DB_PATH = ""
|
||||
schema.DB_PATH = ""
|
||||
|
||||
|
||||
_REAL_SQLITE_CONNECT = sqlite3.connect
|
||||
_ID_TABLES = {
|
||||
"screening_log",
|
||||
"recommendation",
|
||||
"price_tracking",
|
||||
"cron_run_log",
|
||||
"review_log",
|
||||
"missed_explosions",
|
||||
"strategy_iteration_log",
|
||||
"strategy_rule_candidate",
|
||||
"strategy_failure_pattern",
|
||||
"push_log",
|
||||
"sentiment_events",
|
||||
"llm_insights",
|
||||
"event_news",
|
||||
"onchain_token_map",
|
||||
"onchain_events",
|
||||
"onchain_token_metrics",
|
||||
"onchain_raw_events",
|
||||
"app_user",
|
||||
"email_verification_code",
|
||||
"user_session",
|
||||
"user_subscription",
|
||||
"payment_order",
|
||||
"pending_registration",
|
||||
"referral_reward",
|
||||
"user_activity",
|
||||
"user_watchlist",
|
||||
"user_saved_observation",
|
||||
"system_reset_log",
|
||||
"scheduler_manual_trigger",
|
||||
}
|
||||
|
||||
|
||||
def _translate_sql(sql: str) -> str:
|
||||
text = sql
|
||||
text = text.replace("datetime('now')", "to_char(NOW(), 'YYYY-MM-DD\"T\"HH24:MI:SS')")
|
||||
text = text.replace("INSERT OR IGNORE", "INSERT")
|
||||
text = re.sub(r"\?", "%s", text)
|
||||
return text
|
||||
|
||||
|
||||
def _pragma_table_info(sql: str) -> str:
|
||||
match = re.match(r"\s*PRAGMA\s+table_info\((['\"]?)([a-zA-Z_][a-zA-Z0-9_]*)\1\)\s*;?\s*$", sql, re.IGNORECASE)
|
||||
return match.group(2) if match else ""
|
||||
|
||||
|
||||
def _insert_table(sql: str) -> str:
|
||||
match = re.search(r"insert\s+into\s+([a-zA-Z_][a-zA-Z0-9_]*)", sql, re.IGNORECASE)
|
||||
return match.group(1).lower() if match else ""
|
||||
|
||||
|
||||
def _should_add_returning_id(sql: str) -> bool:
|
||||
lowered = sql.strip().lower()
|
||||
if not lowered.startswith("insert") or " returning " in lowered:
|
||||
return False
|
||||
return _insert_table(lowered) in _ID_TABLES
|
||||
|
||||
|
||||
class _PgCursorCompat:
|
||||
def __init__(self, cursor, lastrowid=None):
|
||||
self._cursor = cursor
|
||||
self.lastrowid = lastrowid
|
||||
|
||||
def fetchone(self):
|
||||
return self._cursor.fetchone()
|
||||
|
||||
def fetchall(self):
|
||||
return self._cursor.fetchall()
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._cursor)
|
||||
|
||||
|
||||
class _PgSqliteCompatConnection:
|
||||
row_factory = None
|
||||
|
||||
def __init__(self):
|
||||
self._conn = postgres_connection.connect()
|
||||
self.row_factory = None
|
||||
|
||||
def execute(self, sql, params=()):
|
||||
table_name = _pragma_table_info(str(sql))
|
||||
if table_name:
|
||||
cur = self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
(ordinal_position - 1)::int AS cid,
|
||||
column_name AS name,
|
||||
data_type AS type,
|
||||
CASE WHEN is_nullable='NO' THEN 1 ELSE 0 END AS notnull,
|
||||
column_default AS dflt_value,
|
||||
0 AS pk
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema='public' AND table_name=%s
|
||||
ORDER BY ordinal_position
|
||||
""",
|
||||
(table_name,),
|
||||
)
|
||||
return _PgCursorCompat(cur)
|
||||
translated = _translate_sql(str(sql))
|
||||
lastrowid = None
|
||||
added_returning_id = False
|
||||
if _should_add_returning_id(translated):
|
||||
translated = translated.rstrip().rstrip(";") + " RETURNING id"
|
||||
added_returning_id = True
|
||||
cur = self._conn.execute(translated, tuple(params or ()))
|
||||
try:
|
||||
if added_returning_id:
|
||||
row = cur.fetchone()
|
||||
lastrowid = row["id"] if row else None
|
||||
except Exception:
|
||||
lastrowid = None
|
||||
return _PgCursorCompat(cur, lastrowid=lastrowid)
|
||||
|
||||
def executemany(self, sql, seq_of_params):
|
||||
translated = _translate_sql(str(sql))
|
||||
cur = self._conn.executemany(translated, seq_of_params)
|
||||
return _PgCursorCompat(cur)
|
||||
|
||||
def commit(self):
|
||||
self._conn.commit()
|
||||
|
||||
def rollback(self):
|
||||
self._conn.rollback()
|
||||
|
||||
def close(self):
|
||||
self._conn.close()
|
||||
|
||||
def cursor(self):
|
||||
return self
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
if exc_type:
|
||||
self.rollback()
|
||||
else:
|
||||
self.commit()
|
||||
self.close()
|
||||
|
||||
|
||||
def _pg_sqlite_connect(*args, **kwargs):
|
||||
return _PgSqliteCompatConnection()
|
||||
|
||||
|
||||
def _pg_compat_connect(*args, **kwargs):
|
||||
return _PgSqliteCompatConnection()
|
||||
|
||||
|
||||
def _business_tables() -> list[str]:
|
||||
conn = postgres_connection.connect()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname='public' AND tablename != 'schema_migrations'
|
||||
ORDER BY tablename
|
||||
"""
|
||||
).fetchall()
|
||||
return [row["tablename"] for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _truncate_business_tables():
|
||||
postgres_connection.apply_migrations()
|
||||
tables = _business_tables()
|
||||
if not tables:
|
||||
return
|
||||
conn = postgres_connection.connect()
|
||||
try:
|
||||
quoted = ", ".join(f'"{table}"' for table in tables)
|
||||
conn.execute(f"TRUNCATE TABLE {quoted} RESTART IDENTITY CASCADE")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def postgres_test_db(monkeypatch):
|
||||
monkeypatch.setenv("ALPHAX_BOOTSTRAP_ADMIN", "0")
|
||||
monkeypatch.setattr(sqlite3, "connect", _pg_sqlite_connect)
|
||||
monkeypatch.setattr(altcoin_db, "sqlite3", sqlite3, raising=False)
|
||||
monkeypatch.setattr(altcoin_db, "get_conn", _pg_compat_connect)
|
||||
monkeypatch.setattr(auth_db, "get_conn", _pg_compat_connect)
|
||||
monkeypatch.setattr(schema, "get_conn", _pg_compat_connect)
|
||||
for module_name in (
|
||||
"app.db.analytics",
|
||||
"app.db.llm_insights",
|
||||
"app.db.recommendation_queries",
|
||||
"app.db.review_queries",
|
||||
"app.services.llm_insights",
|
||||
"app.services.review_engine",
|
||||
"app.analysis.reverse_analysis",
|
||||
"app.web.routes_content",
|
||||
"app.web.routes_strategy",
|
||||
):
|
||||
module = sys.modules.get(module_name)
|
||||
if module is not None and hasattr(module, "get_conn"):
|
||||
monkeypatch.setattr(module, "get_conn", _pg_compat_connect, raising=False)
|
||||
_truncate_business_tables()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg_conn():
|
||||
conn = postgres_connection.connect()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
@ -290,6 +290,35 @@ def test_pipeline_api_returns_pagination_meta(temp_db):
|
||||
assert len(data["runs"]) == 1
|
||||
|
||||
|
||||
def test_pipeline_api_reports_new_funnel_stages(temp_db):
|
||||
base = datetime.now() - timedelta(minutes=40)
|
||||
started = base.isoformat(timespec="seconds")
|
||||
finished = (base + timedelta(seconds=25)).isoformat(timespec="seconds")
|
||||
|
||||
altcoin_db.log_cron_run(
|
||||
"粗筛",
|
||||
"altcoin_screener.py",
|
||||
"success",
|
||||
"screened",
|
||||
started_at=started,
|
||||
finished_at=finished,
|
||||
summary={"total_candidates": 2, "total_qualified": 1},
|
||||
)
|
||||
_insert_screening(temp_db, (base + timedelta(seconds=2)).isoformat(timespec="seconds"), "universe_gate", "RISK/USDT", state="过期", score=0)
|
||||
_insert_screening(temp_db, (base + timedelta(seconds=8)).isoformat(timespec="seconds"), "粗筛", "AAA/USDT", state="候选", score=8)
|
||||
_insert_screening(temp_db, (base + timedelta(seconds=12)).isoformat(timespec="seconds"), "细筛", "AAA/USDT", state="过期", score=3)
|
||||
|
||||
data = get_pipeline_runs(limit=10, hours=24)
|
||||
assert data["kpi"]["universe_gate_count"] == 1
|
||||
assert data["kpi"]["quality_reject_count"] == 1
|
||||
|
||||
detail = get_pipeline_run_detail(data["runs"][0]["run_id"])
|
||||
assert detail["stage_counts"]["universe_gate"] == 1
|
||||
assert detail["stage_counts"]["quality_reject"] == 1
|
||||
stages = {item["candidate_stage"] for item in detail["screening_items"]}
|
||||
assert {"universe_gate", "discovery_candidate", "rejected_candidate"} <= stages
|
||||
|
||||
|
||||
def test_pipeline_page_nav_hides_watchlist_entry_and_watchlist_route_survives(temp_db):
|
||||
client = TestClient(web_server.app)
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import shutil
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@ -46,11 +47,12 @@ def test_scheduler_control_api_and_page(monkeypatch, tmp_path):
|
||||
scripts = re.findall(r"<script>([\s\S]*?)</script>", page.text)
|
||||
scheduler_scripts = [s for s in scripts if "/api/scheduler/jobs" in s]
|
||||
assert scheduler_scripts
|
||||
subprocess.run(
|
||||
["node", "-e", f"new Function({scheduler_scripts[-1]!r})"],
|
||||
check=True,
|
||||
cwd=PROJECT_DIR,
|
||||
)
|
||||
if shutil.which("node"):
|
||||
subprocess.run(
|
||||
["node", "-e", f"new Function({scheduler_scripts[-1]!r})"],
|
||||
check=True,
|
||||
cwd=PROJECT_DIR,
|
||||
)
|
||||
assert "data-action=\"trigger\"" in page.text
|
||||
assert "onchange=\"toggleJob" not in page.text
|
||||
assert "onclick=\"triggerJob" not in page.text
|
||||
|
||||
@ -235,7 +235,8 @@ def test_strong_static_accumulation_can_promote_to_accelerate(monkeypatch):
|
||||
assert qualified["PNT/USDT"]["base_state"] == "蓄力"
|
||||
assert qualified["PNT/USDT"]["force_reason"] == "强静K蓄力直升加速"
|
||||
assert any("强静K蓄力直升加速" in s for s in qualified["PNT/USDT"]["signals"])
|
||||
assert qualified["PNT/USDT"]["candidate_stage"] == "confirm_pending"
|
||||
assert qualified["PNT/USDT"]["candidate_stage"] == "qualified_candidate"
|
||||
assert qualified["PNT/USDT"]["next_stage"] == "trade_confirm"
|
||||
assert "rec_id" not in qualified["PNT/USDT"]
|
||||
|
||||
|
||||
|
||||
@ -23,7 +23,11 @@ def test_create_recommendation_persists_strategy_version(monkeypatch):
|
||||
original_meta = altcoin_db.get_meta
|
||||
|
||||
class FakeCursor:
|
||||
lastrowid = 321
|
||||
def __init__(self, row=None):
|
||||
self._row = row
|
||||
|
||||
def fetchone(self):
|
||||
return self._row
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self):
|
||||
@ -35,7 +39,9 @@ def test_create_recommendation_persists_strategy_version(monkeypatch):
|
||||
def execute(self, sql, params=()):
|
||||
self.sql = sql
|
||||
self.params = params
|
||||
return FakeCursor()
|
||||
if str(sql).lstrip().upper().startswith("SELECT"):
|
||||
return FakeCursor()
|
||||
return FakeCursor({"id": 321})
|
||||
|
||||
def commit(self):
|
||||
self.committed = True
|
||||
|
||||
Loading…
Reference in New Issue
Block a user