Add stats trading calendar and fix CTP position avg/sync.

Calendar shows daily closed trade count and PnL with emotion-day highlighting; day click loads review-first trade list. Use exchange-only entry average and improve vnpy position sync after CTP reconnect.
This commit is contained in:
dekun
2026-06-30 11:59:25 +08:00
parent d07fc4b70d
commit 8ebad6e8a2
8 changed files with 926 additions and 198 deletions
+247 -1
View File
@@ -6,9 +6,10 @@
"""交易统计计算与缓存结构。"""
from __future__ import annotations
import calendar
import json
import threading
from datetime import datetime
from datetime import date, datetime
from typing import Any, Optional
from zoneinfo import ZoneInfo
@@ -320,3 +321,248 @@ def refresh_stats_cache(conn, live_capital: float = 0.0) -> dict:
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,
}