Files
gold_monitor/app/db.py
T
dekun e15b1d7491 Auto-refresh review list and stats when trades or annotations change.
Clarify that category/style breakdowns need manual entry tagging.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-23 13:28:27 +08:00

1133 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
from __future__ import annotations
import json
import secrets
import sqlite3
import time
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from typing import Any, Iterator, Optional
from app.config import Config
BJT = timezone(timedelta(hours=8))
# 对账/去重产生的假平仓,不计入盈亏统计
SYNTHETIC_CLOSE_EVENTS = frozenset({"reconcile_closed", "dedupe_closed"})
def _connect() -> sqlite3.Connection:
Config.DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(Config.DATABASE_PATH), check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
@contextmanager
def get_db() -> Iterator[sqlite3.Connection]:
conn = _connect()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def init_db() -> None:
with get_db() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
broker TEXT DEFAULT '',
login_id TEXT DEFAULT '',
account_type TEXT DEFAULT 'demo',
note TEXT DEFAULT '',
enabled INTEGER DEFAULT 1,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS machines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
device_fingerprint TEXT DEFAULT '',
note TEXT DEFAULT '',
enabled INTEGER DEFAULT 1,
last_seen_at REAL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS activations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
account_id INTEGER,
machine_id INTEGER,
token TEXT,
status TEXT DEFAULT 'pending',
bound_at REAL,
created_at REAL NOT NULL,
FOREIGN KEY(account_id) REFERENCES accounts(id),
FOREIGN KEY(machine_id) REFERENCES machines(id)
);
CREATE TABLE IF NOT EXISTS trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket TEXT,
symbol TEXT NOT NULL,
side TEXT NOT NULL,
volume REAL,
open_price REAL,
close_price REAL,
open_time REAL,
close_time REAL,
profit REAL,
sl REAL,
tp REAL,
account_id INTEGER,
machine_id INTEGER,
entry_style TEXT DEFAULT '',
review_issues TEXT DEFAULT '',
review_note TEXT DEFAULT '',
snapshot_json TEXT DEFAULT '{}',
created_at REAL NOT NULL,
FOREIGN KEY(account_id) REFERENCES accounts(id),
FOREIGN KEY(machine_id) REFERENCES machines(id)
);
CREATE TABLE IF NOT EXISTS bars (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
timeframe TEXT NOT NULL,
ts REAL NOT NULL,
open REAL, high REAL, low REAL, close REAL, volume REAL,
UNIQUE(symbol, timeframe, ts)
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
account_id INTEGER UNIQUE,
enabled INTEGER DEFAULT 1,
created_at REAL NOT NULL,
FOREIGN KEY(account_id) REFERENCES accounts(id)
);
"""
)
def now() -> float:
return time.time()
def day_bounds_bjt(day: Optional[str] = None) -> tuple[float, float, str]:
"""北京时间某日 [start, end);day=YYYY-MM-DD,默认今天。"""
if day:
d0 = datetime.strptime(day.strip(), "%Y-%m-%d").replace(
hour=0, minute=0, second=0, microsecond=0, tzinfo=BJT
)
else:
d0 = datetime.now(BJT).replace(hour=0, minute=0, second=0, microsecond=0)
d1 = d0 + timedelta(days=1)
return d0.timestamp(), d1.timestamp(), d0.strftime("%Y-%m-%d")
def _snapshot_event(snapshot_json: Any) -> str:
try:
snap = json.loads(snapshot_json or "{}")
except (TypeError, json.JSONDecodeError):
return ""
if not isinstance(snap, dict):
return ""
return str(snap.get("event") or "")
def is_synthetic_close(snapshot_json: Any) -> bool:
return _snapshot_event(snapshot_json) in SYNTHETIC_CLOSE_EVENTS
def scrub_synthetic_trade_profits(conn: sqlite3.Connection) -> int:
"""把历史假平仓上的「冻结浮盈」清掉,避免脏数据继续进统计。"""
rows = conn.execute(
"""
SELECT id, snapshot_json, profit FROM trades
WHERE close_time IS NOT NULL AND close_time != ''
AND profit IS NOT NULL
"""
).fetchall()
n = 0
for r in rows:
if not is_synthetic_close(r["snapshot_json"]):
continue
conn.execute("UPDATE trades SET profit=NULL WHERE id=?", (r["id"],))
n += 1
return n
def scrub_duplicate_ticket_profits(conn: sqlite3.Connection) -> int:
"""同一账户+单号多条真实平仓时,只保留 id 最大的一条盈亏,其余标为去重。"""
rows = conn.execute(
"""
SELECT id, account_id, ticket, profit, snapshot_json, close_time
FROM trades
WHERE ticket IS NOT NULL AND ticket != ''
AND close_time IS NOT NULL AND close_time != ''
ORDER BY account_id, ticket, id DESC
"""
).fetchall()
keep: set[tuple[Any, str]] = set()
n = 0
now_ts = now()
for r in rows:
if is_synthetic_close(r["snapshot_json"]):
continue
key = (r["account_id"], str(r["ticket"]))
if key not in keep:
keep.add(key)
continue
# 重复平仓副本:清空盈亏并标记
try:
snap = json.loads(r["snapshot_json"] or "{}")
except json.JSONDecodeError:
snap = {}
if not isinstance(snap, dict):
snap = {}
snap["event"] = "dedupe_closed"
snap["deduped_at"] = now_ts
snap["reason"] = "duplicate_ticket_close"
conn.execute(
"UPDATE trades SET profit=NULL, snapshot_json=? WHERE id=?",
(json.dumps(snap, ensure_ascii=False), r["id"]),
)
n += 1
return n
def row_to_dict(row: Optional[sqlite3.Row]) -> Optional[dict]:
if row is None:
return None
return {k: row[k] for k in row.keys()}
def list_accounts(conn: sqlite3.Connection) -> list[dict]:
cur = conn.execute("SELECT * FROM accounts ORDER BY id DESC")
return [row_to_dict(r) for r in cur.fetchall()]
def list_machines(conn: sqlite3.Connection) -> list[dict]:
cur = conn.execute("SELECT * FROM machines ORDER BY id DESC")
return [row_to_dict(r) for r in cur.fetchall()]
# 客户端约 2 秒心跳一次;超过该秒数无心跳视为离线(秒级大屏)
ONLINE_SECONDS = 8
def list_machines_for_user(conn: sqlite3.Connection, user: dict) -> list[dict]:
if user.get("role") == "admin":
items = list_machines(conn)
else:
cur = conn.execute(
"""
SELECT DISTINCT m.* FROM machines m
JOIN activations act ON act.machine_id = m.id
WHERE act.account_id=?
ORDER BY m.id DESC
""",
(user.get("account_id"),),
)
items = [row_to_dict(r) for r in cur.fetchall()]
return annotate_machines_online(items)
def annotate_machines_online(machines: list[dict], now_ts: Optional[float] = None) -> list[dict]:
ts = now_ts if now_ts is not None else now()
for m in machines:
last = m.get("last_seen_at")
try:
online = bool(last and (ts - float(last)) <= ONLINE_SECONDS)
except (TypeError, ValueError):
online = False
m["online"] = online
m["online_label"] = "在线" if online else "离线"
return machines
def create_activation(
conn: sqlite3.Connection,
account_id: int,
machine_id: Optional[int],
created_by_user_id: Optional[int] = None,
) -> str:
if not conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone():
raise ValueError("账户不存在(须由管理员预先创建)")
code = secrets.token_urlsafe(12)
# created_by 存在 activations 可选列:兼容旧库无该列时忽略
cols = {r["name"] for r in conn.execute("PRAGMA table_info(activations)").fetchall()}
if "created_by" in cols:
conn.execute(
"INSERT INTO activations(code, account_id, machine_id, status, created_at, created_by) VALUES(?,?,?,?,?,?)",
(code, account_id, machine_id, "pending", now(), created_by_user_id),
)
else:
conn.execute(
"INSERT INTO activations(code, account_id, machine_id, status, created_at) VALUES(?,?,?,?,?)",
(code, account_id, machine_id, "pending", now()),
)
return code
def activate_device(conn: sqlite3.Connection, code: str, fingerprint: str, machine_name: str) -> dict[str, Any]:
row = conn.execute("SELECT * FROM activations WHERE code=?", (code,)).fetchone()
if row is None:
raise ValueError("激活码无效")
if row["status"] == "revoked":
raise ValueError("激活码已作废")
if not row["account_id"]:
raise ValueError("激活码未绑定账户,请联系管理员重新发放")
if not conn.execute("SELECT id FROM accounts WHERE id=? AND enabled=1", (row["account_id"],)).fetchone():
raise ValueError("绑定账户不存在或已停用")
token = secrets.token_urlsafe(32)
machine_id = row["machine_id"]
if machine_id:
conn.execute(
"UPDATE machines SET device_fingerprint=?, last_seen_at=?, name=COALESCE(NULLIF(name,''), ?) WHERE id=?",
(fingerprint, now(), machine_name or f"PC-{fingerprint[:6]}", machine_id),
)
else:
cur = conn.execute(
"INSERT INTO machines(name, device_fingerprint, note, enabled, last_seen_at, created_at) VALUES(?,?,?,?,?,?)",
(machine_name or f"PC-{fingerprint[:6]}", fingerprint, "激活绑定", 1, now(), now()),
)
machine_id = cur.lastrowid
conn.execute("UPDATE activations SET machine_id=? WHERE id=?", (machine_id, row["id"]))
conn.execute(
"UPDATE activations SET token=?, status='active', bound_at=? WHERE id=?",
(token, now(), row["id"]),
)
return {
"token": token,
"account_id": row["account_id"],
"machine_id": machine_id,
"public_base": Config.PUBLIC_BASE_URL,
}
def auth_token(conn: sqlite3.Connection, token: str) -> Optional[dict]:
if not token:
return None
row = conn.execute(
"SELECT * FROM activations WHERE token=? AND status='active'",
(token,),
).fetchone()
return row_to_dict(row)
def resolve_web_user_for_activation(conn: sqlite3.Connection, act: dict) -> Optional[dict]:
"""设备 token → 网页登录用户:优先账户绑定用户,其次发放激活码的用户。"""
account_id = act.get("account_id")
if account_id:
u = conn.execute(
"SELECT * FROM users WHERE account_id=? AND enabled=1",
(account_id,),
).fetchone()
if u:
return row_to_dict(u)
created_by = act.get("created_by")
if created_by:
u = get_user(conn, int(created_by))
if u and int(u.get("enabled") or 0) == 1:
return u
return None
def _trades_where(
account_id: Optional[int] = None,
machine_id: Optional[int] = None,
open_only: bool = False,
hide_synthetic: bool = False,
) -> tuple[str, list[Any]]:
where: list[str] = []
params: list[Any] = []
if account_id is not None:
where.append("t.account_id=?")
params.append(account_id)
if machine_id is not None:
where.append("t.machine_id=?")
params.append(machine_id)
if open_only:
where.append("(t.close_time IS NULL OR t.close_time='')")
if hide_synthetic:
# 对账/去重产生的影子记录不进交易列表
where.append(
"("
"json_extract(COALESCE(t.snapshot_json, '{}'), '$.event') IS NULL "
"OR json_extract(COALESCE(t.snapshot_json, '{}'), '$.event') "
"NOT IN ('dedupe_closed', 'reconcile_closed')"
")"
)
clause = (" WHERE " + " AND ".join(where)) if where else ""
return clause, params
def count_trades(
conn: sqlite3.Connection,
account_id: Optional[int] = None,
machine_id: Optional[int] = None,
open_only: bool = False,
hide_synthetic: bool = False,
) -> int:
clause, params = _trades_where(
account_id, machine_id, open_only=open_only, hide_synthetic=hide_synthetic
)
sql = f"SELECT COUNT(*) AS c FROM trades t{clause}"
row = conn.execute(sql, tuple(params)).fetchone()
return int(row["c"] if row else 0)
def trades_fingerprint(
conn: sqlite3.Connection,
account_id: Optional[int] = None,
machine_id: Optional[int] = None,
) -> str:
"""列表/统计页轮询用:成交条数、最新 id/平仓时间、标注变化任一变即刷新。"""
clause, params = _trades_where(account_id, machine_id, hide_synthetic=True)
row = conn.execute(
f"""
SELECT COUNT(*) AS c,
COALESCE(MAX(t.id), 0) AS max_id,
COALESCE(MAX(t.close_time), 0) AS max_close,
COALESCE(MAX(t.open_time), 0) AS max_open,
SUM(CASE WHEN IFNULL(t.entry_style,'') != '' THEN 1 ELSE 0 END) AS annotated,
SUM(CASE WHEN IFNULL(t.review_issues,'') != '' THEN 1 ELSE 0 END) AS issues,
SUM(CASE WHEN t.close_time IS NULL OR t.close_time='' THEN 1 ELSE 0 END) AS open_n
FROM trades t
{clause}
""",
tuple(params),
).fetchone()
if not row:
return "0"
return "|".join(
str(x)
for x in (
int(row["c"] or 0),
int(row["max_id"] or 0),
round(float(row["max_close"] or 0), 3),
round(float(row["max_open"] or 0), 3),
int(row["annotated"] or 0),
int(row["issues"] or 0),
int(row["open_n"] or 0),
)
)
def list_trades(
conn: sqlite3.Connection,
limit: int = 200,
account_id: Optional[int] = None,
machine_id: Optional[int] = None,
offset: int = 0,
open_only: bool = False,
hide_synthetic: bool = False,
) -> list[dict]:
clause, params = _trades_where(
account_id, machine_id, open_only=open_only, hide_synthetic=hide_synthetic
)
if open_only:
# 持仓列表:同一账户+单号只显示最新一条
sql = f"""
SELECT t.*, a.name AS account_name, m.name AS machine_name
FROM trades t
LEFT JOIN accounts a ON a.id = t.account_id
LEFT JOIN machines m ON m.id = t.machine_id
{clause}
AND t.id = (
SELECT MAX(t2.id) FROM trades t2
WHERE t2.account_id = t.account_id
AND t2.ticket = t.ticket
AND (t2.close_time IS NULL OR t2.close_time='')
)
ORDER BY COALESCE(t.open_time, t.created_at) DESC
LIMIT ? OFFSET ?
"""
else:
sql = f"""
SELECT t.*, a.name AS account_name, m.name AS machine_name
FROM trades t
LEFT JOIN accounts a ON a.id = t.account_id
LEFT JOIN machines m ON m.id = t.machine_id
{clause}
ORDER BY COALESCE(t.close_time, t.open_time, t.created_at) DESC
LIMIT ? OFFSET ?
"""
params.extend([limit, max(0, int(offset))])
cur = conn.execute(sql, tuple(params))
rows = []
for r in cur.fetchall():
d = row_to_dict(r)
try:
d["snapshot"] = json.loads(d.get("snapshot_json") or "{}")
except json.JSONDecodeError:
d["snapshot"] = {}
rows.append(d)
return rows
# 入场形式:顺势/反转各含三种结构手法;其它=乱作
ENTRY_PATTERN_TYPES = ("窄幅区间突破", "宽幅区间假突破", "波段高低点假突破")
ENTRY_STYLES = (
[f"顺势 · {p}" for p in ENTRY_PATTERN_TYPES]
+ [f"反转 · {p}" for p in ENTRY_PATTERN_TYPES]
+ ["其他 · 乱作"]
)
# 复盘主要问题(少而狠):最多勾 1~2 个
REVIEW_ISSUES = ("位不对", "形不对", "时机差", "出场违纪", "情绪单")
REVIEW_ISSUES_MAX = 2
def parse_review_issues(raw: Any) -> list[str]:
"""逗号分隔 → 有序列表,仅保留标准项。"""
if not raw:
return []
if isinstance(raw, (list, tuple)):
parts = [str(x).strip() for x in raw]
else:
parts = [p.strip() for p in str(raw).replace(",", ",").split(",")]
allowed = set(REVIEW_ISSUES)
seen: set[str] = set()
out: list[str] = []
for p in parts:
if p in allowed and p not in seen:
seen.add(p)
out.append(p)
# 按标准顺序排,方便展示一致
order = {name: i for i, name in enumerate(REVIEW_ISSUES)}
out.sort(key=lambda x: order.get(x, 99))
return out
def serialize_review_issues(items: Any) -> str:
return ",".join(parse_review_issues(items)[:REVIEW_ISSUES_MAX])
def entry_category(style: str) -> str:
s = (style or "").strip()
if not s:
return "未标注"
if s.startswith("顺势"):
return "顺势"
if s.startswith("反转"):
return "反转"
if s.startswith("其他") or "乱作" in s:
return "其他"
return "旧分类"
def _hold_seconds(open_time: Any, close_time: Any, now_ts: Optional[float] = None) -> Optional[float]:
if open_time in (None, ""):
return None
try:
start = float(open_time)
end = float(close_time) if close_time not in (None, "") else float(now_ts or now())
except (TypeError, ValueError):
return None
return max(0.0, end - start)
def _fmt_hold_secs(secs: Optional[float]) -> str:
if secs is None:
return "—"
s = int(secs)
days, rem = divmod(s, 86400)
hours, rem = divmod(rem, 3600)
mins, sec = divmod(rem, 60)
if days > 0:
return f"{days}天{hours}小时{mins}分"
if hours > 0:
return f"{hours}小时{mins}分"
if mins > 0:
return f"{mins}分{sec}秒"
return f"{sec}秒"
def _bucket_key(secs: Optional[float]) -> str:
if secs is None:
return "unknown"
if secs < 300:
return "lt_5m"
if secs < 1800:
return "m5_30"
if secs < 7200:
return "m30_2h"
return "ge_2h"
def _empty_group(label: str) -> dict[str, Any]:
return {
"label": label,
"count": 0,
"wins": 0,
"losses": 0,
"breakeven": 0,
"profit": 0.0,
"hold_sum": 0.0,
"hold_n": 0,
}
def _finalize_group(g: dict[str, Any]) -> dict[str, Any]:
n = g["count"]
decided = g["wins"] + g["losses"]
g["win_rate"] = (g["wins"] / decided * 100.0) if decided else 0.0
g["avg_profit"] = (g["profit"] / n) if n else 0.0
g["avg_hold_secs"] = (g["hold_sum"] / g["hold_n"]) if g["hold_n"] else None
g["avg_hold"] = _fmt_hold_secs(g["avg_hold_secs"])
return g
def _accumulate(g: dict[str, Any], profit: Optional[float], hold: Optional[float]) -> None:
g["count"] += 1
p = float(profit) if profit is not None else 0.0
g["profit"] += p
if profit is not None:
if p > 1e-9:
g["wins"] += 1
elif p < -1e-9:
g["losses"] += 1
else:
g["breakeven"] += 1
if hold is not None:
g["hold_sum"] += hold
g["hold_n"] += 1
def trade_stats(
conn: sqlite3.Connection,
account_id: Optional[int] = None,
machine_id: Optional[int] = None,
day: Optional[str] = None,
period: str = "today",
) -> dict[str, Any]:
"""程序统计看板:默认北京时间「今日」真实平仓;排除对账/去重假平仓。"""
clause, params = _trades_where(account_id, machine_id)
sql = f"""
SELECT t.id, t.side, t.symbol, t.profit, t.open_time, t.close_time,
t.volume, t.machine_id, t.entry_style, t.review_issues, t.snapshot_json,
m.name AS machine_name
FROM trades t
LEFT JOIN machines m ON m.id = t.machine_id
{clause}
ORDER BY COALESCE(t.close_time, t.open_time, t.created_at) DESC
"""
raw_rows = [row_to_dict(r) for r in conn.execute(sql, tuple(params)).fetchall()]
now_ts = now()
period = (period or "today").strip().lower()
day_label = ""
start_ts = end_ts = None
if period != "all":
start_ts, end_ts, day_label = day_bounds_bjt(day)
bucket_labels = {
"lt_5m": "< 5 分钟",
"m5_30": "5–30 分钟",
"m30_2h": "30 分钟–2 小时",
"ge_2h": "≥ 2 小时",
"unknown": "时长未知",
}
by_hold = {k: _empty_group(v) for k, v in bucket_labels.items()}
by_machine: dict[str, dict[str, Any]] = {}
by_side: dict[str, dict[str, Any]] = {}
by_category: dict[str, dict[str, Any]] = {
k: _empty_group(k) for k in ("顺势", "反转", "其他", "未标注", "旧分类")
}
by_style: dict[str, dict[str, Any]] = {s: _empty_group(s) for s in ENTRY_STYLES}
by_issue: dict[str, dict[str, Any]] = {s: _empty_group(s) for s in REVIEW_ISSUES}
issue_tagged = 0
rows: list[dict] = []
skipped_synthetic = 0
skipped_dup_ticket = 0
seen_closed_ticket: set[str] = set()
# raw 已按时间 DESC;再按 id 大的优先,便于同 ticket 去重留最新
raw_rows.sort(
key=lambda t: (
float(t.get("close_time") or t.get("open_time") or t.get("created_at") or 0),
int(t.get("id") or 0),
),
reverse=True,
)
for t in raw_rows:
closed_flag = t.get("close_time") not in (None, "")
if closed_flag and is_synthetic_close(t.get("snapshot_json")):
skipped_synthetic += 1
continue
if period != "all" and start_ts is not None and end_ts is not None:
# 今日:按平仓时间;仍持仓的按开仓时间计入「持仓中」但不进合计盈亏
if closed_flag:
ct = t.get("close_time")
try:
cts = float(ct)
except (TypeError, ValueError):
continue
if cts < start_ts or cts >= end_ts:
continue
else:
ot = t.get("open_time") or t.get("created_at")
try:
ots = float(ot)
except (TypeError, ValueError):
continue
if ots < start_ts or ots >= end_ts:
continue
if closed_flag:
tk = str(t.get("ticket") or "").strip()
if tk:
if tk in seen_closed_ticket:
skipped_dup_ticket += 1
continue
seen_closed_ticket.add(tk)
rows.append(t)
total = len(rows)
closed = 0
open_pos = 0
wins = losses = breakeven = 0
profit_sum = 0.0
gross_win = 0.0
gross_loss = 0.0
hold_sum = 0.0
hold_n = 0
annotated = 0
scored = 0 # 计入盈亏的已平仓笔数
for t in rows:
closed_flag = t.get("close_time") not in (None, "")
hold = _hold_seconds(t.get("open_time"), t.get("close_time"), now_ts)
# 合计盈亏/胜率只统计真实已平仓;持仓浮盈不进合计
profit = t.get("profit") if closed_flag else None
if closed_flag:
closed += 1
else:
open_pos += 1
if closed_flag and profit is not None:
p = float(profit)
profit_sum += p
scored += 1
if p > 1e-9:
wins += 1
gross_win += p
elif p < -1e-9:
losses += 1
gross_loss += abs(p)
else:
breakeven += 1
if closed_flag and hold is not None:
hold_sum += hold
hold_n += 1
style = (t.get("entry_style") or "").strip()
if style:
annotated += 1
issues = parse_review_issues(t.get("review_issues"))
if issues:
issue_tagged += 1
# 分组也只累加已平仓盈亏,避免浮盈干扰分类
cat = entry_category(style)
_accumulate(by_category[cat], profit, hold if closed_flag else None)
if style in by_style:
_accumulate(by_style[style], profit, hold if closed_flag else None)
elif style:
key = f"旧 · {style}"
if key not in by_style:
by_style[key] = _empty_group(key)
_accumulate(by_style[key], profit, hold if closed_flag else None)
if closed_flag:
for iss in issues:
if iss in by_issue:
_accumulate(by_issue[iss], profit, hold)
bk = _bucket_key(hold)
_accumulate(by_hold[bk], profit, hold)
mid = t.get("machine_id")
mkey = str(mid) if mid is not None else "none"
if mkey not in by_machine:
name = t.get("machine_name") or ("未绑定" if mid is None else f"#{mid}")
by_machine[mkey] = _empty_group(name)
_accumulate(by_machine[mkey], profit, hold)
side = (t.get("side") or "—").lower()
if side not in by_side:
by_side[side] = _empty_group(
"买入" if side == "buy" else ("卖出" if side == "sell" else side)
)
_accumulate(by_side[side], profit, hold)
decided = wins + losses
avg_hold = (hold_sum / hold_n) if hold_n else None
style_order = list(ENTRY_STYLES) + [k for k in by_style if k not in ENTRY_STYLES]
return {
"total": total,
"closed": closed,
"open_pos": open_pos,
"wins": wins,
"losses": losses,
"breakeven": breakeven,
"win_rate": (wins / decided * 100.0) if decided else 0.0,
"profit": profit_sum,
"avg_profit": (profit_sum / scored) if scored else 0.0,
"gross_win": gross_win,
"gross_loss": gross_loss,
"profit_factor": (gross_win / gross_loss) if gross_loss > 1e-9 else (None if gross_win <= 0 else 999.0),
"avg_hold_secs": avg_hold,
"avg_hold": _fmt_hold_secs(avg_hold),
"annotated": annotated,
"unannotated": total - annotated,
"issue_tagged": issue_tagged,
"skipped_synthetic": skipped_synthetic,
"skipped_dup_ticket": skipped_dup_ticket,
"period": period,
"day": day_label,
"by_hold": [_finalize_group(by_hold[k]) for k in ("lt_5m", "m5_30", "m30_2h", "ge_2h", "unknown") if by_hold[k]["count"] or k != "unknown"],
"by_machine": sorted(
[_finalize_group(g) for g in by_machine.values()],
key=lambda x: x["profit"],
reverse=True,
),
"by_side": [_finalize_group(g) for g in by_side.values()],
"by_issue": [
_finalize_group(by_issue[k])
for k in REVIEW_ISSUES
if by_issue[k]["count"]
],
"by_category": [
_finalize_group(by_category[k])
for k in ("顺势", "反转", "其他", "未标注", "旧分类")
if by_category[k]["count"] or k in ("顺势", "反转", "其他", "未标注")
],
"by_style": [
_finalize_group(by_style[k])
for k in style_order
if k in by_style and by_style[k]["count"]
],
}
def get_trade(conn: sqlite3.Connection, trade_id: int) -> Optional[dict]:
r = conn.execute(
"""
SELECT t.*, a.name AS account_name, m.name AS machine_name
FROM trades t
LEFT JOIN accounts a ON a.id = t.account_id
LEFT JOIN machines m ON m.id = t.machine_id
WHERE t.id=?
""",
(trade_id,),
).fetchone()
d = row_to_dict(r)
if d:
try:
d["snapshot"] = json.loads(d.get("snapshot_json") or "{}")
except json.JSONDecodeError:
d["snapshot"] = {}
return d
def list_users(conn: sqlite3.Connection) -> list[dict]:
cur = conn.execute(
"""
SELECT u.*, a.name AS account_name
FROM users u
LEFT JOIN accounts a ON a.id = u.account_id
ORDER BY u.id ASC
"""
)
return [row_to_dict(r) for r in cur.fetchall()]
def get_user_by_username(conn: sqlite3.Connection, username: str) -> Optional[dict]:
return row_to_dict(
conn.execute("SELECT * FROM users WHERE username=? AND enabled=1", (username,)).fetchone()
)
def get_user(conn: sqlite3.Connection, user_id: int) -> Optional[dict]:
return row_to_dict(conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone())
def upsert_bars(conn: sqlite3.Connection, symbol: str, timeframe: str, bars: list[dict]) -> int:
n = 0
for b in bars:
conn.execute(
"""
INSERT INTO bars(symbol, timeframe, ts, open, high, low, close, volume)
VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(symbol, timeframe, ts) DO UPDATE SET
open=excluded.open, high=excluded.high, low=excluded.low,
close=excluded.close, volume=excluded.volume
""",
(
symbol,
timeframe,
float(b["ts"]),
float(b["open"]),
float(b["high"]),
float(b["low"]),
float(b["close"]),
float(b.get("volume") or 0),
),
)
n += 1
return n
def get_bars(conn: sqlite3.Connection, symbol: str, timeframe: str, start_ts: float, end_ts: float) -> list[dict]:
cur = conn.execute(
"""
SELECT ts, open, high, low, close, volume FROM bars
WHERE symbol=? AND timeframe=? AND ts>=? AND ts<=?
ORDER BY ts ASC
""",
(symbol, timeframe, start_ts, end_ts),
)
return [row_to_dict(r) for r in cur.fetchall()]
def get_setting(conn: sqlite3.Connection, key: str, default: str = "") -> str:
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
return row["value"] if row else default
def set_setting(conn: sqlite3.Connection, key: str, value: str) -> None:
conn.execute(
"INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, value),
)
def seed_if_empty() -> None:
"""仅确保管理员用户存在;交易账户不自动创建,须管理员手工建。"""
from werkzeug.security import generate_password_hash
from app.config import Config
with get_db() as conn:
# 兼容旧库:补 activations.created_by
cols = {r["name"] for r in conn.execute("PRAGMA table_info(activations)").fetchall()}
if "created_by" not in cols:
try:
conn.execute("ALTER TABLE activations ADD COLUMN created_by INTEGER")
except sqlite3.OperationalError:
pass
trade_cols = {r["name"] for r in conn.execute("PRAGMA table_info(trades)").fetchall()}
if "review_issues" not in trade_cols:
try:
conn.execute("ALTER TABLE trades ADD COLUMN review_issues TEXT DEFAULT ''")
except sqlite3.OperationalError:
pass
n_users = conn.execute("SELECT COUNT(*) AS c FROM users").fetchone()["c"]
if n_users == 0:
# 迁移旧 settings 密码,或使用 .env 默认
old_hash = get_setting(conn, "admin_password_hash")
pwd_hash = old_hash or generate_password_hash(Config.ADMIN_PASSWORD)
username = get_setting(conn, "admin_user") or Config.ADMIN_USER
conn.execute(
"""INSERT INTO users(username, password_hash, role, account_id, enabled, created_at)
VALUES(?,?,?,?,?,?)""",
(username, pwd_hash, "admin", None, 1, now()),
)
def verify_login(username: str, password: str) -> Optional[dict]:
from werkzeug.security import check_password_hash
with get_db() as conn:
user = get_user_by_username(conn, username)
if not user:
return None
if not check_password_hash(user["password_hash"], password):
return None
return user
def change_user_password(user_id: int, old_password: str, new_password: str) -> None:
change_user_credentials(user_id, old_password, new_username=None, new_password=new_password)
def change_user_credentials(
user_id: int,
old_password: str,
new_username: Optional[str] = None,
new_password: Optional[str] = None,
) -> dict:
"""校验当前密码后,可改用户名和/或密码。返回更新后的用户信息。"""
from werkzeug.security import check_password_hash, generate_password_hash
new_username = (new_username or "").strip() or None
new_password = new_password if new_password else None
if new_password is not None and len(new_password) < 6:
raise ValueError("新密码至少 6 位")
if new_username is not None and len(new_username) < 2:
raise ValueError("用户名至少 2 位")
with get_db() as conn:
user = get_user(conn, user_id)
if not user:
raise ValueError("用户不存在")
if not check_password_hash(user["password_hash"], old_password):
raise ValueError("当前密码不正确")
username_changed = bool(new_username and new_username != user["username"])
password_changed = new_password is not None
if not username_changed and not password_changed:
raise ValueError("请修改用户名或填写新密码")
if username_changed:
taken = conn.execute(
"SELECT id FROM users WHERE username=? AND id!=?",
(new_username, user_id),
).fetchone()
if taken:
raise ValueError("用户名已被占用")
conn.execute("UPDATE users SET username=? WHERE id=?", (new_username, user_id))
if password_changed:
conn.execute(
"UPDATE users SET password_hash=? WHERE id=?",
(generate_password_hash(new_password), user_id),
)
# 同步旧 settings 键(兼容)
updated = get_user(conn, user_id)
if updated and updated.get("role") == "admin":
set_setting(conn, "admin_user", updated["username"])
if password_changed:
set_setting(conn, "admin_password_hash", updated["password_hash"])
return updated or user
def delete_account(conn: sqlite3.Connection, account_id: int) -> None:
"""删除交易账户;若仍被用户绑定则拒绝。"""
bound = conn.execute(
"SELECT id, username FROM users WHERE account_id=?", (account_id,)
).fetchone()
if bound:
raise ValueError(f"账户已绑定用户 {bound['username']},请先调整用户绑定")
pending = conn.execute(
"SELECT COUNT(*) AS c FROM activations WHERE account_id=? AND status='pending'",
(account_id,),
).fetchone()["c"]
if pending:
raise ValueError("该账户仍有未使用的激活码,请先作废或用完后再删")
if not conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone():
raise ValueError("账户不存在")
conn.execute("DELETE FROM accounts WHERE id=?", (account_id,))
def delete_machine(conn: sqlite3.Connection, machine_id: int) -> None:
"""删除机器。在线中的不允许删;相关激活/成交仅解除 machine 引用。"""
m = conn.execute("SELECT * FROM machines WHERE id=?", (machine_id,)).fetchone()
if not m:
raise ValueError("机器不存在")
last = m["last_seen_at"]
online = False
try:
online = bool(last and (now() - float(last)) <= ONLINE_SECONDS)
except (TypeError, ValueError):
online = False
if online:
raise ValueError("机器当前在线,请先关闭客户端后再删")
conn.execute("UPDATE activations SET machine_id=NULL WHERE machine_id=?", (machine_id,))
conn.execute("UPDATE trades SET machine_id=NULL WHERE machine_id=?", (machine_id,))
conn.execute("DELETE FROM machines WHERE id=?", (machine_id,))
def create_user(
conn: sqlite3.Connection,
username: str,
password: str,
role: str,
account_id: Optional[int],
) -> int:
from werkzeug.security import generate_password_hash
username = username.strip()
if not username:
raise ValueError("用户名不能为空")
if len(password) < 6:
raise ValueError("密码至少 6 位")
if role not in ("admin", "user"):
raise ValueError("角色无效")
if role == "user":
if not account_id:
raise ValueError("普通用户必须绑定一个交易账户")
if not conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone():
raise ValueError("交易账户不存在")
taken = conn.execute(
"SELECT id FROM users WHERE account_id=? AND role='user'",
(account_id,),
).fetchone()
if taken:
raise ValueError("该交易账户已绑定其他用户(一人一账户)")
else:
account_id = None
try:
cur = conn.execute(
"""INSERT INTO users(username, password_hash, role, account_id, enabled, created_at)
VALUES(?,?,?,?,?,?)""",
(username, generate_password_hash(password), role, account_id, 1, now()),
)
except sqlite3.IntegrityError as exc:
raise ValueError("用户名已存在或账户已被占用") from exc
return int(cur.lastrowid)
def user_can_access_account(user: dict, account_id: Optional[int]) -> bool:
if not user:
return False
if user.get("role") == "admin":
return True
return account_id is not None and int(user.get("account_id") or 0) == int(account_id)
# 兼容旧调用名
def verify_admin(username: str, password: str) -> bool:
u = verify_login(username, password)
return bool(u and u.get("role") == "admin")
def change_admin_password(old_password: str, new_password: str) -> None:
"""兼容:修改名为 admin 的管理员密码。"""
with get_db() as conn:
u = get_user_by_username(conn, "admin") or conn.execute(
"SELECT * FROM users WHERE role='admin' ORDER BY id LIMIT 1"
).fetchone()
if not u:
raise ValueError("未找到管理员用户")
uid = u["id"] if isinstance(u, sqlite3.Row) else u["id"]
change_user_password(uid, old_password, new_password)