208 lines
8.3 KiB
Python
208 lines
8.3 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Iterator
|
|
|
|
from app.auth import hash_password
|
|
from app.config import Settings
|
|
|
|
|
|
UTC = timezone.utc
|
|
|
|
|
|
def now_iso() -> str:
|
|
return datetime.now(UTC).replace(microsecond=0).isoformat()
|
|
|
|
|
|
def to_json(value: Any) -> str:
|
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
|
|
def from_json(value: str | None, default: Any = None) -> Any:
|
|
if not value:
|
|
return default
|
|
return json.loads(value)
|
|
|
|
|
|
class Database:
|
|
def __init__(self, settings: Settings):
|
|
self.path = settings.database_path
|
|
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
|
|
|
|
@contextmanager
|
|
def connect(self) -> Iterator[sqlite3.Connection]:
|
|
conn = sqlite3.connect(self.path, timeout=30)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
conn.execute("PRAGMA busy_timeout = 30000")
|
|
try:
|
|
yield conn
|
|
conn.commit()
|
|
except Exception:
|
|
conn.rollback()
|
|
raise
|
|
finally:
|
|
conn.close()
|
|
|
|
def migrate(self, settings: Settings) -> None:
|
|
with self.connect() as conn:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS admin_settings (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
password_hash TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS webhook_targets (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
webhook_url TEXT NOT NULL,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS routing_rules (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
timeframe TEXT NOT NULL,
|
|
symbol TEXT NOT NULL,
|
|
strategy TEXT NOT NULL,
|
|
priority INTEGER NOT NULL DEFAULT 100,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
target_ids TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS message_templates (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
card_title_template TEXT NOT NULL,
|
|
card_body_template TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app_state (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_rules_match
|
|
ON routing_rules(enabled, timeframe, symbol, strategy, priority);
|
|
|
|
CREATE TABLE IF NOT EXISTS alerts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
timeframe TEXT NOT NULL,
|
|
symbol TEXT NOT NULL,
|
|
strategy TEXT NOT NULL,
|
|
action TEXT,
|
|
price REAL,
|
|
payload TEXT NOT NULL,
|
|
matched_rule_id INTEGER,
|
|
status TEXT NOT NULL,
|
|
error TEXT,
|
|
created_at TEXT NOT NULL,
|
|
FOREIGN KEY(matched_rule_id) REFERENCES routing_rules(id) ON DELETE SET NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS deliveries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
alert_id INTEGER NOT NULL,
|
|
rule_id INTEGER,
|
|
target_id INTEGER,
|
|
target_name TEXT NOT NULL,
|
|
webhook_url TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
next_attempt_at TEXT,
|
|
last_attempt_at TEXT,
|
|
response_code INTEGER,
|
|
response_body TEXT,
|
|
error TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
FOREIGN KEY(alert_id) REFERENCES alerts(id) ON DELETE CASCADE,
|
|
FOREIGN KEY(rule_id) REFERENCES routing_rules(id) ON DELETE SET NULL,
|
|
FOREIGN KEY(target_id) REFERENCES webhook_targets(id) ON DELETE SET NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_deliveries_retry
|
|
ON deliveries(status, next_attempt_at);
|
|
"""
|
|
)
|
|
existing_columns = {
|
|
row["name"] for row in conn.execute("PRAGMA table_info(routing_rules)").fetchall()
|
|
}
|
|
if "message_type" not in existing_columns:
|
|
conn.execute("ALTER TABLE routing_rules ADD COLUMN message_type TEXT NOT NULL DEFAULT 'card'")
|
|
if "card_title_template" not in existing_columns:
|
|
conn.execute(
|
|
"ALTER TABLE routing_rules ADD COLUMN card_title_template TEXT NOT NULL DEFAULT 'TradingView {{symbol}} {{action}}'"
|
|
)
|
|
if "card_body_template" not in existing_columns:
|
|
conn.execute(
|
|
"ALTER TABLE routing_rules ADD COLUMN card_body_template TEXT NOT NULL DEFAULT '{{symbol}} {{timeframe}} {{strategy}} {{action}} @ {{price}}'"
|
|
)
|
|
now = now_iso()
|
|
conn.execute("UPDATE webhook_targets SET enabled = 1 WHERE enabled <> 1")
|
|
template_count = conn.execute("SELECT COUNT(*) AS c FROM message_templates").fetchone()["c"]
|
|
if template_count == 0:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO message_templates (
|
|
name, description, card_title_template, card_body_template,
|
|
created_at, updated_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
"供需系统通用卡片",
|
|
"适合支撑/阻力区域、确认、成交、浮盈保护等供需系统信号。",
|
|
"{{title}}",
|
|
"**{{symbol}} · {{timeframe}} · {{direction}}**\n\n"
|
|
"> {{signal_type}}\n\n"
|
|
"**价格区**\n"
|
|
"- 试仓位:{{probe_entry}}\n"
|
|
"- 优先位:{{priority_entry}}\n"
|
|
"- 防守位:{{defense_price}}\n"
|
|
"- 止损位:{{stop_loss}}\n"
|
|
"- 当前价:{{current_price}}\n\n"
|
|
"**确认状态**\n"
|
|
"- 区域评分:{{zone_score}}\n"
|
|
"- 触碰次数:{{touch_count}}\n"
|
|
"- 确认状态:{{confirmation_label}}\n"
|
|
"- 交易准备:{{trade_ready}}\n\n"
|
|
"**说明**\n"
|
|
"{{description}}\n\n"
|
|
"**风险提示**\n"
|
|
"{{risk_tip}}\n\n"
|
|
"---\n"
|
|
"{{source}} · {{strategy}} · {{time}}",
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
conn.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO admin_settings (id, password_hash, created_at, updated_at)
|
|
VALUES (1, ?, ?, ?)
|
|
""",
|
|
(hash_password(settings.admin_password), now, now),
|
|
)
|
|
|
|
def cleanup_old_logs(self, retention_days: int) -> int:
|
|
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).replace(microsecond=0).isoformat()
|
|
with self.connect() as conn:
|
|
cur = conn.execute("DELETE FROM alerts WHERE created_at < ?", (cutoff,))
|
|
return cur.rowcount
|