Restructure into modules/ with single-process CTP and config/ layout.
Move business code under modules/, env template to config/, PM2 single qihuo process, and _legacy shims for old imports. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
from modules.stats.routes import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -0,0 +1,288 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""数据看板:账户、关键位、平仓记录聚合。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
_TZ = ZoneInfo("Asia/Shanghai")
|
||||
_PRICE_CACHE: dict[str, tuple[float, float]] = {}
|
||||
_PRICE_CACHE_TTL = 2.0
|
||||
|
||||
|
||||
def _cached_fetch_price(
|
||||
fetch_price: Callable[[str, str, str], Optional[float]],
|
||||
sym: str,
|
||||
market: str,
|
||||
sina: str,
|
||||
) -> Optional[float]:
|
||||
key = sym or ""
|
||||
now = datetime.now().timestamp()
|
||||
hit = _PRICE_CACHE.get(key)
|
||||
if hit and (now - hit[1]) < _PRICE_CACHE_TTL:
|
||||
return hit[0]
|
||||
price = fetch_price(sym, market, sina)
|
||||
if price is not None:
|
||||
_PRICE_CACHE[key] = (float(price), now)
|
||||
return price
|
||||
|
||||
|
||||
def _direction_label(direction: str) -> str:
|
||||
return "做多" if (direction or "").strip().lower() == "long" else "做空"
|
||||
|
||||
|
||||
def _symbol_fields(ths_code: str) -> dict[str, Any]:
|
||||
from modules.core.symbols import position_symbol_meta
|
||||
|
||||
sym = (ths_code or "").strip()
|
||||
meta = position_symbol_meta(sym)
|
||||
return {
|
||||
"symbol_code": sym,
|
||||
"symbol_name": meta.get("name") or sym,
|
||||
"symbol_exchange": meta.get("exchange") or "",
|
||||
"symbol_is_main": bool(meta.get("is_main")),
|
||||
}
|
||||
|
||||
|
||||
def build_risk_overview(
|
||||
conn,
|
||||
get_setting: Callable[[str, str], str],
|
||||
*,
|
||||
equity: Optional[float] = None,
|
||||
margin_used: Optional[float] = None,
|
||||
) -> dict[str, Any]:
|
||||
from risk.account_risk_lib import (
|
||||
cooling_hours_manual,
|
||||
cooling_hours_manual_journal,
|
||||
count_daily_opens,
|
||||
daily_position_limit,
|
||||
daily_trading_risk_pct_limit,
|
||||
daily_trading_risk_used_pct,
|
||||
ensure_account_risk_schema,
|
||||
get_risk_status,
|
||||
manual_close_daily_limit,
|
||||
max_active_positions,
|
||||
risk_control_enabled,
|
||||
trading_day_label,
|
||||
trading_day_reset_hour,
|
||||
)
|
||||
from modules.core.trading_context import (
|
||||
get_fixed_amount,
|
||||
get_fixed_lots,
|
||||
get_max_margin_pct,
|
||||
get_roll_max_margin_pct,
|
||||
get_sizing_mode,
|
||||
)
|
||||
|
||||
ensure_account_risk_schema(conn)
|
||||
risk = dict(get_risk_status(conn, equity=equity) or {})
|
||||
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
|
||||
td = trading_day_label()
|
||||
stored_td = str(row["trading_day"] or "") if row else ""
|
||||
manual_count = int(row["manual_close_count"] or 0) if row and stored_td == td else 0
|
||||
|
||||
margin_pct_used: Optional[float] = None
|
||||
if equity and equity > 0 and margin_used is not None and margin_used >= 0:
|
||||
margin_pct_used = round(float(margin_used) / float(equity) * 100, 2)
|
||||
|
||||
max_margin = get_max_margin_pct(get_setting)
|
||||
sizing = get_sizing_mode(get_setting)
|
||||
sizing_label = "固定金额" if sizing == "amount" else "固定手数"
|
||||
|
||||
daily_opens = int(risk.get("daily_open_count") or count_daily_opens(conn))
|
||||
daily_risk_used = risk.get("daily_risk_used_pct")
|
||||
if daily_risk_used is None and equity and equity > 0:
|
||||
daily_risk_used = daily_trading_risk_used_pct(conn, float(equity))
|
||||
|
||||
return {
|
||||
"enabled": risk_control_enabled(),
|
||||
"status": risk,
|
||||
"manual_close_count_today": manual_count,
|
||||
"margin_pct_used": margin_pct_used,
|
||||
"daily_open_count": daily_opens,
|
||||
"daily_risk_used_pct": daily_risk_used,
|
||||
"limits": {
|
||||
"max_active_positions": max_active_positions(),
|
||||
"position_mode": "single" if max_active_positions() <= 1 else "multi",
|
||||
"position_mode_label": "单仓模式" if max_active_positions() <= 1 else "多仓模式",
|
||||
"daily_position_limit": daily_position_limit(),
|
||||
"daily_trading_risk_pct_limit": daily_trading_risk_pct_limit(),
|
||||
"manual_close_daily_limit": manual_close_daily_limit(),
|
||||
"cooling_hours_manual": cooling_hours_manual(),
|
||||
"cooling_hours_manual_journal": cooling_hours_manual_journal(),
|
||||
"trading_day_reset_hour": trading_day_reset_hour(),
|
||||
"max_margin_pct": max_margin,
|
||||
"roll_max_margin_pct": get_roll_max_margin_pct(get_setting),
|
||||
"sizing_mode": sizing,
|
||||
"sizing_label": sizing_label,
|
||||
"fixed_lots": get_fixed_lots(get_setting),
|
||||
"fixed_amount": get_fixed_amount(get_setting),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_dashboard_payload(
|
||||
*,
|
||||
get_db: Callable,
|
||||
get_setting: Callable[[str, str], str],
|
||||
fetch_price: Callable[[str, str, str], Optional[float]],
|
||||
closes_limit: int = 40,
|
||||
sync_ctp_trades: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
from modules.core.trading_context import get_account_capital, get_trading_mode, trading_mode_label
|
||||
from modules.ctp.vnpy_bridge import ctp_account_margin_used, ctp_status, get_bridge
|
||||
|
||||
mode = get_trading_mode(get_setting)
|
||||
ctp_st = dict(ctp_status(mode) or {})
|
||||
conn = get_db()
|
||||
try:
|
||||
capital = float(get_account_capital(conn, get_setting) or 0)
|
||||
equity = capital
|
||||
available: Optional[float] = None
|
||||
margin_used: Optional[float] = None
|
||||
|
||||
if ctp_st.get("connected"):
|
||||
if sync_ctp_trades:
|
||||
try:
|
||||
from modules.ctp.ctp_trade_sync import sync_trade_logs_from_ctp
|
||||
|
||||
sync_trade_logs_from_ctp(
|
||||
conn, mode, capital=capital, trading_mode=mode,
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
b = get_bridge()
|
||||
if b.connected_mode == mode and b.ping():
|
||||
acc = b.get_account() or {}
|
||||
else:
|
||||
acc = {}
|
||||
balance = float(acc.get("balance") or 0)
|
||||
if balance > 0:
|
||||
equity = balance
|
||||
avail = acc.get("available")
|
||||
if avail is not None:
|
||||
available = round(float(avail), 2)
|
||||
mu = ctp_account_margin_used(mode)
|
||||
if mu is not None and mu > 0:
|
||||
margin_used = round(float(mu), 2)
|
||||
elif available is not None and equity > 0:
|
||||
margin_used = round(max(0.0, equity - available), 2)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
from modules.core.trading_context import _cached_ctp_account
|
||||
|
||||
cached = _cached_ctp_account(mode)
|
||||
balance = float(cached.get("balance") or 0)
|
||||
if balance > 0:
|
||||
equity = balance
|
||||
avail = cached.get("available")
|
||||
if avail is not None:
|
||||
available = round(float(avail), 2)
|
||||
if equity > 0:
|
||||
margin_used = round(max(0.0, equity - available), 2)
|
||||
|
||||
key_rows = conn.execute(
|
||||
"""
|
||||
SELECT id, symbol, symbol_name, market_code, sina_code,
|
||||
monitor_type, direction, upper, lower, trade_mode,
|
||||
bar_period, trailing_be
|
||||
FROM key_monitors
|
||||
WHERE status='active' OR status IS NULL
|
||||
ORDER BY id DESC
|
||||
"""
|
||||
).fetchall()
|
||||
keys: list[dict[str, Any]] = []
|
||||
for r in key_rows:
|
||||
sym = r["symbol"]
|
||||
market = r["market_code"] or ""
|
||||
sina = r["sina_code"] or ""
|
||||
upper = float(r["upper"] or 0)
|
||||
lower = float(r["lower"] or 0)
|
||||
price = _cached_fetch_price(fetch_price, sym, market, sina)
|
||||
dist_upper = dist_lower = None
|
||||
if price is not None:
|
||||
dist_upper = round(upper - float(price), 2)
|
||||
dist_lower = round(float(price) - lower, 2)
|
||||
mtype = r["monitor_type"] or ""
|
||||
sf = _symbol_fields(sym)
|
||||
keys.append({
|
||||
"id": r["id"],
|
||||
"symbol": sym,
|
||||
**sf,
|
||||
"symbol_name": r["symbol_name"] or sf.get("symbol_name") or sym,
|
||||
"monitor_type": mtype,
|
||||
"direction": r["direction"] or "",
|
||||
"direction_label": _direction_label(r["direction"] or "long")
|
||||
if r["direction"] else "",
|
||||
"upper": upper,
|
||||
"lower": lower,
|
||||
"trade_mode": r["trade_mode"] or "",
|
||||
"bar_period": r["bar_period"] or "5m",
|
||||
"trailing_be": bool(r["trailing_be"]),
|
||||
"price": price,
|
||||
"dist_upper": dist_upper,
|
||||
"dist_lower": dist_lower,
|
||||
})
|
||||
|
||||
close_rows = conn.execute(
|
||||
"""
|
||||
SELECT id, symbol, symbol_name, direction, lots,
|
||||
entry_price, close_price, pnl, pnl_net, fee,
|
||||
close_time, result, source
|
||||
FROM trade_logs
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(max(1, min(200, closes_limit)),),
|
||||
).fetchall()
|
||||
closes: list[dict[str, Any]] = []
|
||||
for r in close_rows:
|
||||
sym_code = r["symbol"] or ""
|
||||
sf = _symbol_fields(sym_code)
|
||||
closes.append({
|
||||
"id": r["id"],
|
||||
"symbol": r["symbol_name"] or sf.get("symbol_name") or sym_code,
|
||||
"symbol_code": sym_code,
|
||||
**sf,
|
||||
"symbol_name": r["symbol_name"] or sf.get("symbol_name") or sym_code,
|
||||
"direction": r["direction"] or "long",
|
||||
"direction_label": _direction_label(r["direction"] or "long"),
|
||||
"lots": float(r["lots"] or 0),
|
||||
"entry_price": float(r["entry_price"] or 0),
|
||||
"close_price": float(r["close_price"] or 0),
|
||||
"pnl": float(r["pnl"] or 0) if r["pnl"] is not None else None,
|
||||
"pnl_net": float(r["pnl_net"] or 0) if r["pnl_net"] is not None else None,
|
||||
"fee": float(r["fee"] or 0) if r["fee"] is not None else None,
|
||||
"close_time": (r["close_time"] or "")[:16].replace("T", " "),
|
||||
"result": r["result"] or "",
|
||||
"source": r["source"] or "",
|
||||
})
|
||||
|
||||
now_iso = datetime.now(_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
||||
risk = build_risk_overview(
|
||||
conn, get_setting, equity=equity, margin_used=margin_used,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"updated_at": now_iso,
|
||||
"trading_mode_label": trading_mode_label(get_setting),
|
||||
"ctp_status": ctp_st,
|
||||
"account": {
|
||||
"equity": round(equity, 2),
|
||||
"margin_used": margin_used,
|
||||
"available": available,
|
||||
"capital_fallback": round(capital, 2),
|
||||
},
|
||||
"risk": risk,
|
||||
"keys": keys,
|
||||
"closes": closes,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,174 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
"""HTTP routes for stats module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from flask import (
|
||||
Response,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
session,
|
||||
stream_with_context,
|
||||
url_for,
|
||||
)
|
||||
|
||||
|
||||
def register(deps) -> None:
|
||||
app = deps.app
|
||||
login_required = deps.login_required
|
||||
require_nav = deps.require_nav
|
||||
get_db = deps.get_db
|
||||
get_setting = deps.get_setting
|
||||
set_setting = deps.set_setting
|
||||
fetch_price = deps.fetch_price
|
||||
send_wechat_msg = deps.send_wechat_msg
|
||||
touch_stats_cache = deps.touch_stats_cache
|
||||
get_stats_data = deps.get_stats_data
|
||||
build_market_quote_payload = deps.build_market_quote_payload
|
||||
today_str = deps.today_str
|
||||
expire_old_plans = deps.expire_old_plans
|
||||
TZ = deps.tz
|
||||
DB_PATH = deps.db_path
|
||||
UPLOAD_DIR = deps.upload_dir
|
||||
OPEN_TYPES = deps.open_types
|
||||
EXIT_TRIGGERS = deps.exit_triggers
|
||||
BEHAVIOR_TAGS = deps.behavior_tags
|
||||
KLINE_PERIODS = deps.kline_periods
|
||||
KLINE_CUTOFFS = deps.kline_cutoffs
|
||||
calc_holding_duration = deps.calc_holding_duration
|
||||
holding_to_minutes = deps.holding_to_minutes
|
||||
classify_close_result = deps.classify_close_result
|
||||
calc_rr_ratio = deps.calc_rr_ratio
|
||||
calc_theoretical_pnl = deps.calc_theoretical_pnl
|
||||
parse_review_date_filter = deps.parse_review_date_filter
|
||||
_trading_mode = deps.trading_mode
|
||||
_ua_is_phone = deps.ua_is_phone
|
||||
_static_asset_v = deps.static_asset_v
|
||||
|
||||
from modules.stats.stats_engine import (
|
||||
STATS_VIEWS,
|
||||
get_calendar_day,
|
||||
get_calendar_month,
|
||||
refresh_stats_cache,
|
||||
)
|
||||
from modules.settings.nav_settings import nav_enabled
|
||||
from modules.stats.dashboard_lib import build_dashboard_payload
|
||||
from modules.core.doc_render import read_doc, render_markdown
|
||||
|
||||
_dashboard_sync_tick = {"n": 0}
|
||||
|
||||
@app.route("/stats")
|
||||
@login_required
|
||||
def stats():
|
||||
return render_template("stats.html")
|
||||
|
||||
|
||||
@app.route("/calendar")
|
||||
@login_required
|
||||
def trade_calendar():
|
||||
return render_template("calendar.html")
|
||||
|
||||
|
||||
@app.route("/api/stats")
|
||||
@login_required
|
||||
def api_stats():
|
||||
return jsonify(get_stats_data())
|
||||
|
||||
|
||||
@app.route("/api/stats/views")
|
||||
@login_required
|
||||
def api_stats_views():
|
||||
return jsonify({"views": STATS_VIEWS})
|
||||
|
||||
|
||||
@app.route("/api/stats/refresh", methods=["POST"])
|
||||
@login_required
|
||||
def api_stats_refresh():
|
||||
conn = get_db()
|
||||
capital = float(get_setting("live_capital", "0") or 0)
|
||||
data = refresh_stats_cache(conn, capital)
|
||||
conn.close()
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@app.route("/api/stats/calendar")
|
||||
@login_required
|
||||
def api_stats_calendar():
|
||||
now = datetime.now(TZ)
|
||||
year = request.args.get("year", type=int) or now.year
|
||||
month = request.args.get("month", type=int) or now.month
|
||||
if month < 1 or month > 12:
|
||||
return jsonify({"error": "invalid month"}), 400
|
||||
conn = get_db()
|
||||
try:
|
||||
data = get_calendar_month(conn, year, month)
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@app.route("/api/stats/calendar/day")
|
||||
@login_required
|
||||
def api_stats_calendar_day():
|
||||
day = (request.args.get("date") or "").strip()
|
||||
if not day:
|
||||
return jsonify({"error": "date required"}), 400
|
||||
try:
|
||||
date.fromisoformat(day)
|
||||
except ValueError:
|
||||
return jsonify({"error": "invalid date"}), 400
|
||||
conn = get_db()
|
||||
try:
|
||||
data = get_calendar_day(conn, day)
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@app.route("/dashboard")
|
||||
@login_required
|
||||
@require_nav("dashboard")
|
||||
def dashboard():
|
||||
return render_template("dashboard.html")
|
||||
|
||||
|
||||
@app.route("/risk-guide")
|
||||
@login_required
|
||||
@require_nav("risk_guide")
|
||||
def risk_guide():
|
||||
from modules.core.doc_render import read_doc, render_markdown
|
||||
|
||||
try:
|
||||
_title, raw = read_doc("risk-guide")
|
||||
except FileNotFoundError:
|
||||
flash("文档不存在")
|
||||
return redirect(url_for("positions"))
|
||||
return render_template("risk_guide.html", doc_html=render_markdown(raw))
|
||||
|
||||
|
||||
@app.route("/api/dashboard/live")
|
||||
@login_required
|
||||
def api_dashboard_live():
|
||||
if not nav_enabled(get_setting, "dashboard"):
|
||||
return jsonify({"ok": False, "error": "数据看板已在系统设置中关闭"}), 403
|
||||
from modules.stats.dashboard_lib import build_dashboard_payload
|
||||
|
||||
_dashboard_sync_tick["n"] += 1
|
||||
sync_trades = _dashboard_sync_tick["n"] % 15 == 0
|
||||
try:
|
||||
payload = build_dashboard_payload(
|
||||
get_db=get_db,
|
||||
get_setting=get_setting,
|
||||
fetch_price=fetch_price,
|
||||
sync_ctp_trades=sync_trades,
|
||||
)
|
||||
return jsonify(payload)
|
||||
except Exception as exc:
|
||||
app.logger.exception("dashboard live: %s", exc)
|
||||
return jsonify({"ok": False, "error": "看板数据暂时不可用"}), 503
|
||||
@@ -0,0 +1,568 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""交易统计计算与缓存结构。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import json
|
||||
import threading
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from modules.core.db_conn import commit_retry, execute_retry
|
||||
|
||||
_stats_refresh_lock = threading.Lock()
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
STATS_VIEWS = [
|
||||
{"key": "by_time", "label": "按时间统计"},
|
||||
{"key": "by_week", "label": "周统计"},
|
||||
{"key": "by_month", "label": "月统计"},
|
||||
{"key": "by_symbol", "label": "按品种统计"},
|
||||
{"key": "by_fee", "label": "按手续费统计"},
|
||||
{"key": "by_direction", "label": "按方向统计"},
|
||||
{"key": "by_trade_type", "label": "按交易类型统计"},
|
||||
{"key": "by_emotion", "label": "情绪单统计"},
|
||||
]
|
||||
|
||||
BREAKDOWN_COLUMNS = [
|
||||
{"key": "label", "label": "维度"},
|
||||
{"key": "count", "label": "交易次数"},
|
||||
{"key": "wins", "label": "盈利笔数"},
|
||||
{"key": "losses", "label": "亏损笔数"},
|
||||
{"key": "win_rate", "label": "胜率(%)"},
|
||||
{"key": "avg_profit", "label": "平均盈利"},
|
||||
{"key": "avg_loss", "label": "平均亏损"},
|
||||
{"key": "profit_loss_ratio", "label": "盈亏比"},
|
||||
{"key": "total_fee", "label": "累计手续费"},
|
||||
{"key": "total_net", "label": "净盈亏合计"},
|
||||
{"key": "max_loss", "label": "最大亏损"},
|
||||
{"key": "max_profit", "label": "最大盈利"},
|
||||
]
|
||||
|
||||
|
||||
def _parse_dt(value: str) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
text = value.strip().replace(" ", "T")
|
||||
try:
|
||||
return datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _row_dict(row) -> dict:
|
||||
return dict(row) if row is not None else {}
|
||||
|
||||
|
||||
def _net_pnl(row: dict) -> float:
|
||||
if row.get("pnl_net") is not None:
|
||||
return float(row["pnl_net"])
|
||||
pnl = float(row.get("pnl") or 0)
|
||||
fee = float(row.get("fee") or 0)
|
||||
return round(pnl - fee, 2)
|
||||
|
||||
|
||||
def _fee(row: dict) -> float:
|
||||
return float(row.get("fee") or 0)
|
||||
|
||||
|
||||
def _margin_pct(pnl_net: float, margin: Optional[float]) -> Optional[float]:
|
||||
if margin and margin > 0:
|
||||
return round(pnl_net / margin * 100, 2)
|
||||
return None
|
||||
|
||||
|
||||
def _agg_group(rows: list[dict], key_fn) -> list[dict]:
|
||||
groups: dict[str, list[dict]] = {}
|
||||
for row in rows:
|
||||
key = key_fn(row) or "未知"
|
||||
groups.setdefault(key, []).append(row)
|
||||
result = []
|
||||
for label, items in sorted(groups.items(), key=lambda x: x[0]):
|
||||
result.append(_agg_metrics(label, items))
|
||||
return result
|
||||
|
||||
|
||||
def _agg_metrics(label: str, items: list[dict]) -> dict:
|
||||
nets = [_net_pnl(r) for r in items]
|
||||
wins = [n for n in nets if n > 0]
|
||||
losses = [n for n in nets if n < 0]
|
||||
count = len(items)
|
||||
win_cnt = len(wins)
|
||||
loss_cnt = len(losses)
|
||||
avg_profit = round(sum(wins) / len(wins), 2) if wins else 0.0
|
||||
avg_loss = round(sum(losses) / len(losses), 2) if losses else 0.0
|
||||
pl_ratio = round(avg_profit / abs(avg_loss), 2) if wins and losses and avg_loss != 0 else 0.0
|
||||
total_fee = round(sum(_fee(r) for r in items), 2)
|
||||
total_net = round(sum(nets), 2)
|
||||
max_loss = round(min(losses), 2) if losses else 0.0
|
||||
max_profit = round(max(wins), 2) if wins else 0.0
|
||||
win_rate = round(win_cnt / count * 100, 2) if count else 0.0
|
||||
return {
|
||||
"label": label,
|
||||
"count": count,
|
||||
"wins": win_cnt,
|
||||
"losses": loss_cnt,
|
||||
"win_rate": win_rate,
|
||||
"avg_profit": avg_profit,
|
||||
"avg_loss": avg_loss,
|
||||
"profit_loss_ratio": pl_ratio,
|
||||
"total_fee": total_fee,
|
||||
"total_net": total_net,
|
||||
"max_loss": max_loss,
|
||||
"max_profit": max_profit,
|
||||
}
|
||||
|
||||
|
||||
def _max_consecutive_losses(nets: list[float]) -> int:
|
||||
streak = 0
|
||||
best = 0
|
||||
for n in nets:
|
||||
if n < 0:
|
||||
streak += 1
|
||||
best = max(best, streak)
|
||||
else:
|
||||
streak = 0
|
||||
return best
|
||||
|
||||
|
||||
def _max_drawdown(nets: list[float], initial_capital: float) -> tuple[float, float]:
|
||||
equity = initial_capital
|
||||
peak = initial_capital
|
||||
max_dd = 0.0
|
||||
max_dd_pct = 0.0
|
||||
for n in nets:
|
||||
equity += n
|
||||
if equity > peak:
|
||||
peak = equity
|
||||
dd = peak - equity
|
||||
if dd > max_dd:
|
||||
max_dd = dd
|
||||
if peak > 0:
|
||||
pct = dd / peak * 100
|
||||
if pct > max_dd_pct:
|
||||
max_dd_pct = pct
|
||||
return round(max_dd, 2), round(max_dd_pct, 2)
|
||||
|
||||
|
||||
def fetch_trade_rows(conn) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM trade_logs ORDER BY close_time ASC, id ASC"
|
||||
).fetchall()
|
||||
return [_row_dict(r) for r in rows]
|
||||
|
||||
|
||||
def fetch_review_rows(conn) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM review_records ORDER BY close_time ASC, id ASC"
|
||||
).fetchall()
|
||||
return [_row_dict(r) for r in rows]
|
||||
|
||||
|
||||
def compute_summary(trades: list[dict], reviews: list[dict], live_capital: float) -> dict:
|
||||
nets = [_net_pnl(t) for t in trades]
|
||||
count = len(trades)
|
||||
wins = [n for n in nets if n > 0]
|
||||
losses = [n for n in nets if n < 0]
|
||||
win_cnt = len(wins)
|
||||
loss_cnt = len(losses)
|
||||
avg_profit = round(sum(wins) / len(wins), 2) if wins else 0.0
|
||||
avg_loss = round(sum(losses) / len(losses), 2) if losses else 0.0
|
||||
pl_ratio = round(avg_profit / abs(avg_loss), 2) if wins and losses and avg_loss != 0 else 0.0
|
||||
total_fee = round(sum(_fee(t) for t in trades) + sum(_fee(r) for r in reviews), 2)
|
||||
max_loss_amt = round(min(losses), 2) if losses else 0.0
|
||||
max_profit_amt = round(max(wins), 2) if wins else 0.0
|
||||
|
||||
margins_loss = [
|
||||
_margin_pct(_net_pnl(t), t.get("margin"))
|
||||
for t in trades
|
||||
if _net_pnl(t) < 0 and t.get("margin")
|
||||
]
|
||||
margins_profit = [
|
||||
_margin_pct(_net_pnl(t), t.get("margin"))
|
||||
for t in trades
|
||||
if _net_pnl(t) > 0 and t.get("margin")
|
||||
]
|
||||
max_loss_pct = round(min(margins_loss), 2) if margins_loss else 0.0
|
||||
max_profit_pct = round(max(margins_profit), 2) if margins_profit else 0.0
|
||||
|
||||
consec_loss = _max_consecutive_losses(nets)
|
||||
max_dd, max_dd_pct = _max_drawdown(nets, live_capital)
|
||||
|
||||
emotion_cnt = sum(1 for r in reviews if r.get("is_emotion"))
|
||||
review_cnt = len(reviews)
|
||||
denom = count if count else review_cnt
|
||||
emotion_ratio = round(emotion_cnt / denom * 100, 2) if denom else 0.0
|
||||
|
||||
return {
|
||||
"total_trades": count,
|
||||
"win_rate": round(win_cnt / count * 100, 2) if count else 0.0,
|
||||
"avg_profit": avg_profit,
|
||||
"avg_loss": avg_loss,
|
||||
"profit_loss_ratio": pl_ratio,
|
||||
"consecutive_losses": consec_loss,
|
||||
"max_drawdown": max_dd,
|
||||
"max_drawdown_pct": max_dd_pct,
|
||||
"max_loss_amount": max_loss_amt,
|
||||
"max_loss_pct": max_loss_pct,
|
||||
"max_profit_amount": max_profit_amt,
|
||||
"max_profit_pct": max_profit_pct,
|
||||
"total_fee": total_fee,
|
||||
"emotion_count": emotion_cnt,
|
||||
"emotion_ratio": emotion_ratio,
|
||||
"review_count": review_cnt,
|
||||
"win_count": win_cnt,
|
||||
"loss_count": loss_cnt,
|
||||
}
|
||||
|
||||
|
||||
def compute_breakdowns(trades: list[dict], reviews: list[dict]) -> dict[str, dict]:
|
||||
def day_key(row: dict) -> str:
|
||||
dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
|
||||
return dt.date().isoformat() if dt else "未知"
|
||||
|
||||
def week_key(row: dict) -> str:
|
||||
dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
|
||||
if not dt:
|
||||
return "未知"
|
||||
iso = dt.isocalendar()
|
||||
return f"{iso.year}-W{iso.week:02d}"
|
||||
|
||||
def month_key(row: dict) -> str:
|
||||
dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
|
||||
return dt.strftime("%Y-%m") if dt else "未知"
|
||||
|
||||
def symbol_key(row: dict) -> str:
|
||||
return row.get("symbol_name") or row.get("symbol") or "未知"
|
||||
|
||||
def direction_key(row: dict) -> str:
|
||||
d = row.get("direction") or ""
|
||||
return "做多" if d == "long" else ("做空" if d == "short" else d or "未知")
|
||||
|
||||
def type_key(row: dict) -> str:
|
||||
return row.get("monitor_type") or "未知"
|
||||
|
||||
by_fee_rows = []
|
||||
fee_groups = {}
|
||||
for t in trades:
|
||||
key = symbol_key(t)
|
||||
fee_groups.setdefault(key, []).append(t)
|
||||
for label, items in sorted(fee_groups.items()):
|
||||
row = _agg_metrics(label, items)
|
||||
row["avg_fee"] = round(row["total_fee"] / row["count"], 2) if row["count"] else 0.0
|
||||
by_fee_rows.append(row)
|
||||
|
||||
emotion_trades = [r for r in reviews if r.get("is_emotion")]
|
||||
non_emotion = [r for r in reviews if not r.get("is_emotion")]
|
||||
emotion_rows = [
|
||||
_agg_metrics("情绪单", emotion_trades),
|
||||
_agg_metrics("非情绪单", non_emotion),
|
||||
]
|
||||
|
||||
fee_columns = BREAKDOWN_COLUMNS + [{"key": "avg_fee", "label": "平均手续费"}]
|
||||
|
||||
return {
|
||||
"by_time": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, day_key)},
|
||||
"by_week": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, week_key)},
|
||||
"by_month": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, month_key)},
|
||||
"by_symbol": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, symbol_key)},
|
||||
"by_fee": {"columns": fee_columns, "rows": by_fee_rows},
|
||||
"by_direction": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, direction_key)},
|
||||
"by_trade_type": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, type_key)},
|
||||
"by_emotion": {"columns": BREAKDOWN_COLUMNS, "rows": emotion_rows},
|
||||
}
|
||||
|
||||
|
||||
def build_all_stats(conn, live_capital: float = 0.0) -> dict:
|
||||
trades = fetch_trade_rows(conn)
|
||||
reviews = fetch_review_rows(conn)
|
||||
summary = compute_summary(trades, reviews, live_capital)
|
||||
breakdowns = compute_breakdowns(trades, reviews)
|
||||
return {
|
||||
"updated_at": datetime.now(TZ).isoformat(timespec="seconds"),
|
||||
"summary": summary,
|
||||
"views": STATS_VIEWS,
|
||||
"breakdowns": breakdowns,
|
||||
}
|
||||
|
||||
|
||||
def save_stats_cache(conn, data: dict) -> None:
|
||||
execute_retry(
|
||||
conn,
|
||||
"""INSERT INTO stats_cache (key, data_json, updated_at)
|
||||
VALUES ('all', ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET data_json=excluded.data_json, updated_at=excluded.updated_at""",
|
||||
(json.dumps(data, ensure_ascii=False), data["updated_at"]),
|
||||
)
|
||||
commit_retry(conn)
|
||||
|
||||
|
||||
def load_stats_cache(conn) -> Optional[dict]:
|
||||
row = conn.execute(
|
||||
"SELECT data_json FROM stats_cache WHERE key='all'"
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
try:
|
||||
return json.loads(row["data_json"])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def refresh_stats_cache(conn, live_capital: float = 0.0) -> dict:
|
||||
with _stats_refresh_lock:
|
||||
data = build_all_stats(conn, live_capital)
|
||||
save_stats_cache(conn, data)
|
||||
return data
|
||||
|
||||
|
||||
def _norm_symbol(symbol: str) -> str:
|
||||
s = (symbol or "").strip().lower()
|
||||
if "." in s:
|
||||
s = s.split(".")[0]
|
||||
return s
|
||||
|
||||
|
||||
def _close_day_key(row: dict) -> str:
|
||||
dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
|
||||
return dt.date().isoformat() if dt else ""
|
||||
|
||||
|
||||
def _close_ts(row: dict) -> float:
|
||||
dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
|
||||
return dt.timestamp() if dt else 0.0
|
||||
|
||||
|
||||
def _direction_label(direction: str) -> str:
|
||||
if direction == "long":
|
||||
return "做多"
|
||||
if direction == "short":
|
||||
return "做空"
|
||||
return direction or ""
|
||||
|
||||
|
||||
def _index_reviews_by_day_sym(reviews: list[dict]) -> dict[tuple[str, str], list[dict]]:
|
||||
index: dict[tuple[str, str], list[dict]] = {}
|
||||
for review in reviews:
|
||||
day = _close_day_key(review)
|
||||
if not day:
|
||||
continue
|
||||
sym = _norm_symbol(review.get("symbol") or "")
|
||||
index.setdefault((day, sym), []).append(review)
|
||||
return index
|
||||
|
||||
|
||||
def _review_match_score(trade: dict, review: dict) -> float:
|
||||
score = abs(_close_ts(trade) - _close_ts(review))
|
||||
lots_t = trade.get("lots")
|
||||
lots_r = review.get("lots")
|
||||
if lots_t is not None and lots_r is not None and float(lots_t) != float(lots_r):
|
||||
score += 86400.0
|
||||
entry_t = trade.get("entry_price")
|
||||
entry_r = review.get("entry_price")
|
||||
if entry_t is not None and entry_r is not None and abs(float(entry_t) - float(entry_r)) > 0.01:
|
||||
score += 3600.0
|
||||
return score
|
||||
|
||||
|
||||
def _find_review_for_trade(
|
||||
trade: dict,
|
||||
review_index: dict[tuple[str, str], list[dict]],
|
||||
used_review_ids: set[int],
|
||||
) -> Optional[dict]:
|
||||
day = _close_day_key(trade)
|
||||
sym = _norm_symbol(trade.get("symbol") or "")
|
||||
candidates = [
|
||||
r for r in review_index.get((day, sym), [])
|
||||
if r.get("id") not in used_review_ids
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
return min(candidates, key=lambda r: _review_match_score(trade, r))
|
||||
|
||||
|
||||
def _format_day_entry(
|
||||
*,
|
||||
trade: Optional[dict] = None,
|
||||
review: Optional[dict] = None,
|
||||
source: str,
|
||||
) -> dict:
|
||||
row = review if source == "review" and review else trade or review or {}
|
||||
symbol = row.get("symbol") or ""
|
||||
pnl_net = _net_pnl(row)
|
||||
tags = (row.get("behavior_tags") or "").strip()
|
||||
is_emotion = bool(row.get("is_emotion"))
|
||||
return {
|
||||
"source": source,
|
||||
"trade_id": trade.get("id") if trade else None,
|
||||
"review_id": review.get("id") if review else None,
|
||||
"symbol": row.get("symbol_name") or symbol,
|
||||
"symbol_code": symbol,
|
||||
"direction": _direction_label(row.get("direction") or ""),
|
||||
"lots": row.get("lots"),
|
||||
"entry_price": row.get("entry_price"),
|
||||
"close_price": row.get("close_price"),
|
||||
"stop_loss": row.get("stop_loss"),
|
||||
"take_profit": row.get("take_profit"),
|
||||
"open_time": row.get("open_time") or "",
|
||||
"close_time": row.get("close_time") or "",
|
||||
"pnl": row.get("pnl"),
|
||||
"fee": row.get("fee"),
|
||||
"pnl_net": pnl_net,
|
||||
"result": row.get("result") if trade else None,
|
||||
"monitor_type": row.get("monitor_type") if trade else None,
|
||||
"is_emotion": is_emotion,
|
||||
"behavior_tags": tags,
|
||||
"open_type": row.get("open_type") if review else None,
|
||||
"exit_trigger": row.get("exit_trigger") if review else None,
|
||||
"exit_supplement": row.get("exit_supplement") if review else None,
|
||||
"holding_duration": row.get("holding_duration") if review else None,
|
||||
"initial_pnl": row.get("initial_pnl") if review else None,
|
||||
"actual_pnl": row.get("actual_pnl") if review else None,
|
||||
"timeframe": row.get("timeframe") if review else None,
|
||||
"notes": row.get("notes") if review else None,
|
||||
"screenshot": row.get("screenshot") if review else None,
|
||||
}
|
||||
|
||||
|
||||
def build_day_detail(trades: list[dict], reviews: list[dict], day: str) -> list[dict]:
|
||||
day_trades = [t for t in trades if _close_day_key(t) == day]
|
||||
day_reviews = [r for r in reviews if _close_day_key(r) == day]
|
||||
review_index = _index_reviews_by_day_sym(day_reviews)
|
||||
used_review_ids: set[int] = set()
|
||||
items: list[dict] = []
|
||||
|
||||
for trade in day_trades:
|
||||
review = _find_review_for_trade(trade, review_index, used_review_ids)
|
||||
if review:
|
||||
used_review_ids.add(int(review["id"]))
|
||||
items.append(_format_day_entry(trade=trade, review=review, source="review"))
|
||||
else:
|
||||
items.append(_format_day_entry(trade=trade, source="trade"))
|
||||
|
||||
for review in day_reviews:
|
||||
if int(review.get("id") or 0) in used_review_ids:
|
||||
continue
|
||||
items.append(_format_day_entry(review=review, source="review"))
|
||||
|
||||
items.sort(key=lambda x: _close_ts(x), reverse=True)
|
||||
return items
|
||||
|
||||
|
||||
def build_calendar_month(trades: list[dict], reviews: list[dict], year: int, month: int) -> dict:
|
||||
review_index = _index_reviews_by_day_sym(reviews)
|
||||
day_map: dict[str, dict] = {}
|
||||
matched_review_ids: dict[str, set[int]] = {}
|
||||
|
||||
for trade in trades:
|
||||
dt = _parse_dt(trade.get("close_time") or "")
|
||||
if not dt or dt.year != year or dt.month != month:
|
||||
continue
|
||||
day = dt.date().isoformat()
|
||||
bucket = day_map.setdefault(
|
||||
day,
|
||||
{
|
||||
"date": day,
|
||||
"count": 0,
|
||||
"total_net": 0.0,
|
||||
"review_count": 0,
|
||||
"emotion_count": 0,
|
||||
"has_emotion": False,
|
||||
},
|
||||
)
|
||||
bucket["count"] += 1
|
||||
used = matched_review_ids.setdefault(day, set())
|
||||
review = _find_review_for_trade(trade, review_index, used)
|
||||
if review:
|
||||
rid = int(review["id"])
|
||||
used.add(rid)
|
||||
bucket["total_net"] = round(bucket["total_net"] + _net_pnl(review), 2)
|
||||
bucket["review_count"] += 1
|
||||
if review.get("is_emotion"):
|
||||
bucket["emotion_count"] += 1
|
||||
bucket["has_emotion"] = True
|
||||
else:
|
||||
bucket["total_net"] = round(bucket["total_net"] + _net_pnl(trade), 2)
|
||||
|
||||
for review in reviews:
|
||||
if not review.get("is_emotion"):
|
||||
continue
|
||||
day = _close_day_key(review)
|
||||
if not day:
|
||||
continue
|
||||
try:
|
||||
dt = date.fromisoformat(day)
|
||||
except ValueError:
|
||||
continue
|
||||
if dt.year != year or dt.month != month:
|
||||
continue
|
||||
bucket = day_map.setdefault(
|
||||
day,
|
||||
{
|
||||
"date": day,
|
||||
"count": 0,
|
||||
"total_net": 0.0,
|
||||
"review_count": 0,
|
||||
"emotion_count": 0,
|
||||
"has_emotion": False,
|
||||
},
|
||||
)
|
||||
bucket["has_emotion"] = True
|
||||
rid = int(review.get("id") or 0)
|
||||
if rid and rid not in matched_review_ids.get(day, set()):
|
||||
bucket["emotion_count"] += 1
|
||||
|
||||
_, last_day = calendar.monthrange(year, month)
|
||||
days = []
|
||||
for d in range(1, last_day + 1):
|
||||
iso = date(year, month, d).isoformat()
|
||||
if iso in day_map:
|
||||
row = day_map[iso]
|
||||
row["total_net"] = round(row["total_net"], 2)
|
||||
days.append(row)
|
||||
else:
|
||||
days.append(
|
||||
{
|
||||
"date": iso,
|
||||
"count": 0,
|
||||
"total_net": 0.0,
|
||||
"review_count": 0,
|
||||
"emotion_count": 0,
|
||||
"has_emotion": False,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"year": year,
|
||||
"month": month,
|
||||
"days": days,
|
||||
"weekday_start": date(year, month, 1).weekday(),
|
||||
}
|
||||
|
||||
|
||||
def get_calendar_month(conn, year: int, month: int) -> dict:
|
||||
trades = fetch_trade_rows(conn)
|
||||
reviews = fetch_review_rows(conn)
|
||||
return build_calendar_month(trades, reviews, year, month)
|
||||
|
||||
|
||||
def get_calendar_day(conn, day: str) -> dict:
|
||||
trades = fetch_trade_rows(conn)
|
||||
reviews = fetch_review_rows(conn)
|
||||
items = build_day_detail(trades, reviews, day)
|
||||
total_net = round(sum(float(i.get("pnl_net") or 0) for i in items), 2)
|
||||
emotion_count = sum(1 for i in items if i.get("is_emotion"))
|
||||
return {
|
||||
"date": day,
|
||||
"count": len(items),
|
||||
"total_net": total_net,
|
||||
"emotion_count": emotion_count,
|
||||
"items": items,
|
||||
}
|
||||
Reference in New Issue
Block a user