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:
+247
-1
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user