diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py
index 71522d1..a043fc4 100644
--- a/crypto_monitor_binance/app.py
+++ b/crypto_monitor_binance/app.py
@@ -202,6 +202,12 @@ from history_window_lib import (
utc_window_to_utc_sql_strings,
)
from trade_result_lib import count_winning_trades, normalize_result_with_pnl
+from trade_exchange_stats_lib import (
+ attach_exchange_stats_to_trade,
+ filter_position_lifecycle_fills,
+ sum_binance_commission_income,
+ trade_ids_from_fills,
+)
def load_env_file(path):
if not os.path.exists(path):
@@ -1499,6 +1505,8 @@ def init_db():
("exchange_opened_at", "ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT"),
("exchange_closed_at", "ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT"),
("exchange_sync_key", "ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT"),
+ ("exchange_turnover_usdt", "ALTER TABLE trade_records ADD COLUMN exchange_turnover_usdt REAL"),
+ ("exchange_commission_usdt", "ALTER TABLE trade_records ADD COLUMN exchange_commission_usdt REAL"),
):
try:
c.execute(ddl)
@@ -2644,6 +2652,8 @@ def insert_trade_record(
key_signal_type=None,
entry_reason=None,
trend_plan_id=None,
+ exchange_symbol=None,
+ attach_exchange_stats=True,
):
hold_minutes = calc_hold_minutes(hold_seconds)
open_ts = opened_at or app_now_str()
@@ -2670,7 +2680,20 @@ def insert_trade_record(
trend_plan_id,
)
)
- return int(cur.lastrowid or 0)
+ tid = int(cur.lastrowid or 0)
+ if attach_exchange_stats and tid:
+ ex_sym = (exchange_symbol or "").strip() or normalize_exchange_symbol(symbol)
+ _attach_binance_trade_exchange_stats(
+ conn,
+ tid,
+ exchange_symbol=ex_sym,
+ direction=direction,
+ opened_at_str=open_ts,
+ closed_at_str=close_ts,
+ opened_at_ms=open_ts_ms,
+ closed_at_ms=close_ts_ms,
+ )
+ return tid
def calc_duration_text(open_str, close_str):
@@ -4265,6 +4288,88 @@ def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, cl
return _cluster_closing_trades_near_close(all_side_candidates[-5:], int(closed_ms))
+def fetch_all_position_fills_for_record(
+ exchange_symbol,
+ direction,
+ opened_at_str,
+ closed_at_str=None,
+ opened_at_ms=None,
+ closed_at_ms=None,
+):
+ """持仓生命周期内全部 fill(开+平),用于双边成交额与手续费。"""
+ if not (BINANCE_API_KEY and BINANCE_API_SECRET):
+ return []
+ ensure_markets_loaded()
+ since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str)
+ closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None
+ try:
+ trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200)
+ except Exception:
+ trades = []
+ return filter_position_lifecycle_fills(
+ trades or [],
+ direction,
+ since_ms,
+ closed_ms,
+ hedge_mode=(BINANCE_POSITION_MODE == "hedge"),
+ )
+
+
+def _attach_binance_trade_exchange_stats(
+ conn,
+ trade_id,
+ *,
+ exchange_symbol,
+ direction,
+ opened_at_str,
+ closed_at_str,
+ opened_at_ms=None,
+ closed_at_ms=None,
+):
+ if not (BINANCE_API_KEY and BINANCE_API_SECRET):
+ return
+ open_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str)
+ close_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str)
+ contract_size = 1.0
+ try:
+ ensure_markets_loaded()
+ contract_size = float(exchange.market(exchange_symbol).get("contractSize") or 1)
+ except Exception:
+ pass
+
+ def _fetch():
+ return fetch_all_position_fills_for_record(
+ exchange_symbol,
+ direction,
+ opened_at_str,
+ closed_at_str,
+ opened_at_ms=open_ms,
+ closed_at_ms=close_ms,
+ )
+
+ income_comm = None
+ if open_ms and close_ms:
+ fills_preview = _fetch()
+ trade_ids = trade_ids_from_fills(fills_preview)
+ buffer_ms = 3 * 60 * 1000 if trade_ids else 5 * 60 * 1000
+ entries = _fetch_binance_income_entries(
+ exchange_symbol,
+ max(0, int(open_ms) - buffer_ms),
+ int(close_ms) + buffer_ms,
+ )
+ income_comm = sum_binance_commission_income(entries, trade_ids or None)
+ try:
+ attach_exchange_stats_to_trade(
+ conn,
+ trade_id,
+ fetch_fills=_fetch,
+ contract_size=contract_size,
+ income_commission=income_comm,
+ )
+ except Exception:
+ pass
+
+
def calc_weighted_exit_price(trades):
if not trades:
return None
diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py
index e7fcb9d..4bfe51d 100644
--- a/crypto_monitor_gate/app.py
+++ b/crypto_monitor_gate/app.py
@@ -202,6 +202,7 @@ from history_window_lib import (
utc_window_to_utc_sql_strings,
)
from trade_result_lib import count_winning_trades, normalize_result_with_pnl
+from trade_exchange_stats_lib import attach_exchange_stats_to_trade, filter_position_lifecycle_fills
def load_env_file(path):
@@ -1490,6 +1491,8 @@ def init_db():
"ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT",
"ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT",
"ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT",
+ "ALTER TABLE trade_records ADD COLUMN exchange_turnover_usdt REAL",
+ "ALTER TABLE trade_records ADD COLUMN exchange_commission_usdt REAL",
):
try:
c.execute(ddl)
@@ -2358,6 +2361,8 @@ def insert_trade_record(
key_signal_type=None,
entry_reason=None,
trend_plan_id=None,
+ exchange_symbol=None,
+ attach_exchange_stats=True,
):
hold_minutes = calc_hold_minutes(hold_seconds)
open_ts = opened_at or app_now_str()
@@ -2374,7 +2379,7 @@ def insert_trade_record(
or entry_reason_for_monitor_type(monitor_type)
or ""
)
- conn.execute(
+ cur = conn.execute(
"INSERT INTO trade_records (symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,entry_reason,trend_plan_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, take_profit,
@@ -2384,6 +2389,20 @@ def insert_trade_record(
trend_plan_id,
)
)
+ tid = int(cur.lastrowid or 0)
+ if attach_exchange_stats and tid:
+ ex_sym = (exchange_symbol or "").strip() or normalize_exchange_symbol(symbol)
+ _attach_gate_trade_exchange_stats(
+ conn,
+ tid,
+ exchange_symbol=ex_sym,
+ direction=direction,
+ opened_at_str=open_ts,
+ closed_at_str=close_ts,
+ opened_at_ms=open_ts_ms,
+ closed_at_ms=close_ts_ms,
+ )
+ return tid
def calc_duration_text(open_str, close_str):
@@ -3940,6 +3959,60 @@ def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, cl
return all_side_candidates[-20:]
+def fetch_all_position_fills_for_record(
+ exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None
+):
+ if not exchange_private_api_configured():
+ return []
+ ensure_markets_loaded()
+ since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str)
+ closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None
+ if closed_ms is not None:
+ closed_ms += 6 * 60 * 60 * 1000
+ try:
+ trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200)
+ except Exception:
+ trades = []
+ if not trades and since_ms:
+ try:
+ trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=200)
+ except Exception:
+ trades = []
+ return filter_position_lifecycle_fills(
+ trades or [],
+ direction,
+ since_ms,
+ closed_ms,
+ hedge_mode=(GATE_POS_MODE == "hedge"),
+ close_buffer_ms=0,
+ )
+
+
+def _attach_gate_trade_exchange_stats(
+ conn, trade_id, *, exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=None, closed_at_ms=None
+):
+ if not exchange_private_api_configured():
+ return
+ open_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str)
+ close_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str)
+ contract_size = 1.0
+ try:
+ ensure_markets_loaded()
+ contract_size = float(exchange.market(exchange_symbol).get("contractSize") or 1)
+ except Exception:
+ pass
+
+ def _fetch():
+ return fetch_all_position_fills_for_record(
+ exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=open_ms, closed_at_ms=close_ms
+ )
+
+ try:
+ attach_exchange_stats_to_trade(conn, trade_id, fetch_fills=_fetch, contract_size=contract_size)
+ except Exception:
+ pass
+
+
def calc_weighted_exit_price(trades):
if not trades:
return None
diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py
index 5dc4723..0a698c0 100644
--- a/crypto_monitor_gate_bot/app.py
+++ b/crypto_monitor_gate_bot/app.py
@@ -202,6 +202,7 @@ from history_window_lib import (
utc_window_to_utc_sql_strings,
)
from trade_result_lib import count_winning_trades, normalize_result_with_pnl
+from trade_exchange_stats_lib import attach_exchange_stats_to_trade, filter_position_lifecycle_fills
def load_env_file(path):
@@ -1490,6 +1491,8 @@ def init_db():
"ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT",
"ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT",
"ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT",
+ "ALTER TABLE trade_records ADD COLUMN exchange_turnover_usdt REAL",
+ "ALTER TABLE trade_records ADD COLUMN exchange_commission_usdt REAL",
):
try:
c.execute(ddl)
@@ -2358,6 +2361,8 @@ def insert_trade_record(
key_signal_type=None,
entry_reason=None,
trend_plan_id=None,
+ exchange_symbol=None,
+ attach_exchange_stats=True,
):
hold_minutes = calc_hold_minutes(hold_seconds)
open_ts = opened_at or app_now_str()
@@ -2374,7 +2379,7 @@ def insert_trade_record(
or entry_reason_for_monitor_type(monitor_type)
or ""
)
- conn.execute(
+ cur = conn.execute(
"INSERT INTO trade_records (symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,entry_reason,trend_plan_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, take_profit,
@@ -2384,6 +2389,20 @@ def insert_trade_record(
trend_plan_id,
)
)
+ tid = int(cur.lastrowid or 0)
+ if attach_exchange_stats and tid:
+ ex_sym = (exchange_symbol or "").strip() or normalize_exchange_symbol(symbol)
+ _attach_gate_trade_exchange_stats(
+ conn,
+ tid,
+ exchange_symbol=ex_sym,
+ direction=direction,
+ opened_at_str=open_ts,
+ closed_at_str=close_ts,
+ opened_at_ms=open_ts_ms,
+ closed_at_ms=close_ts_ms,
+ )
+ return tid
def calc_duration_text(open_str, close_str):
@@ -3940,6 +3959,60 @@ def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, cl
return all_side_candidates[-20:]
+def fetch_all_position_fills_for_record(
+ exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None
+):
+ if not exchange_private_api_configured():
+ return []
+ ensure_markets_loaded()
+ since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str)
+ closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None
+ if closed_ms is not None:
+ closed_ms += 6 * 60 * 60 * 1000
+ try:
+ trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200)
+ except Exception:
+ trades = []
+ if not trades and since_ms:
+ try:
+ trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=200)
+ except Exception:
+ trades = []
+ return filter_position_lifecycle_fills(
+ trades or [],
+ direction,
+ since_ms,
+ closed_ms,
+ hedge_mode=(GATE_POS_MODE == "hedge"),
+ close_buffer_ms=0,
+ )
+
+
+def _attach_gate_trade_exchange_stats(
+ conn, trade_id, *, exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=None, closed_at_ms=None
+):
+ if not exchange_private_api_configured():
+ return
+ open_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str)
+ close_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str)
+ contract_size = 1.0
+ try:
+ ensure_markets_loaded()
+ contract_size = float(exchange.market(exchange_symbol).get("contractSize") or 1)
+ except Exception:
+ pass
+
+ def _fetch():
+ return fetch_all_position_fills_for_record(
+ exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=open_ms, closed_at_ms=close_ms
+ )
+
+ try:
+ attach_exchange_stats_to_trade(conn, trade_id, fetch_fills=_fetch, contract_size=contract_size)
+ except Exception:
+ pass
+
+
def calc_weighted_exit_price(trades):
if not trades:
return None
diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py
index 88db93b..82e50ff 100644
--- a/crypto_monitor_okx/app.py
+++ b/crypto_monitor_okx/app.py
@@ -201,6 +201,7 @@ from history_window_lib import (
utc_window_to_utc_sql_strings,
)
from trade_result_lib import count_winning_trades, normalize_result_with_pnl
+from trade_exchange_stats_lib import attach_exchange_stats_to_trade, filter_position_lifecycle_fills
def load_env_file(path):
@@ -1411,6 +1412,8 @@ def init_db():
"ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT",
"ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT",
"ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT",
+ "ALTER TABLE trade_records ADD COLUMN exchange_turnover_usdt REAL",
+ "ALTER TABLE trade_records ADD COLUMN exchange_commission_usdt REAL",
):
try:
c.execute(ddl)
@@ -2254,6 +2257,8 @@ def insert_trade_record(
key_signal_type=None,
entry_reason=None,
trend_plan_id=None,
+ exchange_symbol=None,
+ attach_exchange_stats=True,
):
hold_minutes = calc_hold_minutes(hold_seconds)
open_ts = opened_at or app_now_str()
@@ -2270,7 +2275,7 @@ def insert_trade_record(
or entry_reason_for_monitor_type(monitor_type)
or ""
)
- conn.execute(
+ cur = conn.execute(
"INSERT INTO trade_records (symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,entry_reason,trend_plan_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, take_profit,
@@ -2280,6 +2285,20 @@ def insert_trade_record(
trend_plan_id,
)
)
+ tid = int(cur.lastrowid or 0)
+ if attach_exchange_stats and tid:
+ ex_sym = (exchange_symbol or "").strip() or normalize_exchange_symbol(symbol)
+ _attach_okx_trade_exchange_stats(
+ conn,
+ tid,
+ exchange_symbol=ex_sym,
+ direction=direction,
+ opened_at_str=open_ts,
+ closed_at_str=close_ts,
+ opened_at_ms=open_ts_ms,
+ closed_at_ms=close_ts_ms,
+ )
+ return tid
def calc_duration_text(open_str, close_str):
@@ -3392,6 +3411,55 @@ def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, cl
return all_side_candidates[-20:]
+def fetch_all_position_fills_for_record(
+ exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None
+):
+ if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE):
+ return []
+ ensure_markets_loaded()
+ since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str)
+ closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None
+ if closed_ms is not None:
+ closed_ms += 6 * 60 * 60 * 1000
+ try:
+ trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200)
+ except Exception:
+ trades = []
+ return filter_position_lifecycle_fills(
+ trades or [],
+ direction,
+ since_ms,
+ closed_ms,
+ hedge_mode=(OKX_POS_MODE == "hedge"),
+ close_buffer_ms=0,
+ )
+
+
+def _attach_okx_trade_exchange_stats(
+ conn, trade_id, *, exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=None, closed_at_ms=None
+):
+ if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE):
+ return
+ open_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str)
+ close_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str)
+ contract_size = 1.0
+ try:
+ ensure_markets_loaded()
+ contract_size = float(exchange.market(exchange_symbol).get("contractSize") or 1)
+ except Exception:
+ pass
+
+ def _fetch():
+ return fetch_all_position_fills_for_record(
+ exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=open_ms, closed_at_ms=close_ms
+ )
+
+ try:
+ attach_exchange_stats_to_trade(conn, trade_id, fetch_fills=_fetch, contract_size=contract_size)
+ except Exception:
+ pass
+
+
def calc_weighted_exit_price(trades):
if not trades:
return None
diff --git a/hub_symbol_archive_lib.py b/hub_symbol_archive_lib.py
index 2745183..f9e7426 100644
--- a/hub_symbol_archive_lib.py
+++ b/hub_symbol_archive_lib.py
@@ -118,6 +118,8 @@ def init_db(db_path: Path | None = None) -> None:
closed_at_ms INTEGER,
monitor_type TEXT,
entry_reason TEXT,
+ exchange_turnover_usdt REAL,
+ exchange_commission_usdt REAL,
payload_json TEXT,
synced_at INTEGER NOT NULL,
PRIMARY KEY (exchange_key, trade_id)
@@ -159,6 +161,14 @@ def init_db(db_path: Path | None = None) -> None:
ON archive_review_quotes (quote_date DESC)
"""
)
+ for ddl in (
+ "ALTER TABLE archive_trade_cache ADD COLUMN exchange_turnover_usdt REAL",
+ "ALTER TABLE archive_trade_cache ADD COLUMN exchange_commission_usdt REAL",
+ ):
+ try:
+ conn.execute(ddl)
+ except Exception:
+ pass
finally:
conn.close()
@@ -167,6 +177,15 @@ def _now_ms() -> int:
return int(time.time() * 1000)
+def _optional_float(raw: Any) -> float | None:
+ if raw in (None, ""):
+ return None
+ try:
+ return float(raw)
+ except (TypeError, ValueError):
+ return None
+
+
def parse_wall_clock_ms(raw: Any, *, tz: ZoneInfo = CHART_DISPLAY_TZ) -> int | None:
"""将 YYYY-MM-DD[ HH:MM[:SS]] 按指定时区墙钟解析为 UTC 毫秒(默认 UTC+8)。"""
if raw in (None, ""):
@@ -331,8 +350,9 @@ def upsert_trades_cache(
INSERT INTO archive_trade_cache (
exchange_key, trade_id, symbol, direction, result, pnl_amount,
opened_at, closed_at, opened_at_ms, closed_at_ms,
- monitor_type, entry_reason, payload_json, synced_at
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ monitor_type, entry_reason, exchange_turnover_usdt, exchange_commission_usdt,
+ payload_json, synced_at
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(exchange_key, trade_id) DO UPDATE SET
symbol=excluded.symbol,
direction=excluded.direction,
@@ -344,6 +364,8 @@ def upsert_trades_cache(
closed_at_ms=excluded.closed_at_ms,
monitor_type=excluded.monitor_type,
entry_reason=excluded.entry_reason,
+ exchange_turnover_usdt=excluded.exchange_turnover_usdt,
+ exchange_commission_usdt=excluded.exchange_commission_usdt,
payload_json=excluded.payload_json,
synced_at=excluded.synced_at
""",
@@ -360,6 +382,8 @@ def upsert_trades_cache(
t.get("closed_at_ms") or _parse_dt_ms(t.get("closed_at")),
t.get("monitor_type"),
entry_label,
+ _optional_float(t.get("exchange_turnover_usdt")),
+ _optional_float(t.get("exchange_commission_usdt")),
json.dumps(payload, ensure_ascii=False, default=str),
now,
),
@@ -437,6 +461,8 @@ def _trade_row_to_dict(row: sqlite3.Row, overlay: dict | None = None) -> dict[st
"closed_at_ms",
"monitor_type",
"entry_reason",
+ "exchange_turnover_usdt",
+ "exchange_commission_usdt",
"synced_at",
):
if key in d and d[key] not in (None, ""):
@@ -1263,6 +1289,8 @@ def _empty_pnl_bucket() -> dict[str, Any]:
"sick_count": 0,
"pnl_total": 0.0,
"pnl_ex_sick": 0.0,
+ "turnover_total": 0.0,
+ "commission_total": 0.0,
"win_count": 0,
"loss_count": 0,
"avg_win": None,
@@ -1286,6 +1314,8 @@ def _finalize_pnl_bucket(bucket: dict[str, Any]) -> None:
bucket["max_loss"] = round(min(losses), 4) if losses else None
bucket["pnl_total"] = round(float(bucket.get("pnl_total") or 0), 4)
bucket["pnl_ex_sick"] = round(float(bucket.get("pnl_ex_sick") or 0), 4)
+ bucket["turnover_total"] = round(float(bucket.get("turnover_total") or 0), 4)
+ bucket["commission_total"] = round(float(bucket.get("commission_total") or 0), 4)
bucket["win_rate"] = round(win_count / open_count * 100, 1) if open_count else None
avg_win = bucket["avg_win"]
if avg_win is not None and avg_loss is not None and avg_loss != 0:
@@ -1294,9 +1324,18 @@ def _finalize_pnl_bucket(bucket: dict[str, Any]) -> None:
bucket["profit_loss_ratio"] = None
-def _accumulate_trade_stat(bucket: dict[str, Any], *, pnl: float, is_sick: bool) -> None:
+def _accumulate_trade_stat(
+ bucket: dict[str, Any],
+ *,
+ pnl: float,
+ is_sick: bool,
+ turnover: float = 0.0,
+ commission: float = 0.0,
+) -> None:
bucket["open_count"] += 1
bucket["pnl_total"] += pnl
+ bucket["turnover_total"] += turnover
+ bucket["commission_total"] += commission
if is_sick:
bucket["sick_count"] += 1
else:
@@ -1316,10 +1355,16 @@ def _compute_period_stats(trade_rows: list[dict[str, Any]]) -> dict[str, Any]:
pnl = float(td_row.get("pnl_amount") or 0)
tag = str(td_row.get("behavior_tag") or "")
is_sick = tag == "sick"
- _accumulate_trade_stat(total_bucket, pnl=pnl, is_sick=is_sick)
+ turnover = float(td_row.get("exchange_turnover_usdt") or 0)
+ commission = float(td_row.get("exchange_commission_usdt") or 0)
+ _accumulate_trade_stat(
+ total_bucket, pnl=pnl, is_sick=is_sick, turnover=turnover, commission=commission
+ )
if ex not in by_ex:
by_ex[ex] = _empty_pnl_bucket()
- _accumulate_trade_stat(by_ex[ex], pnl=pnl, is_sick=is_sick)
+ _accumulate_trade_stat(
+ by_ex[ex], pnl=pnl, is_sick=is_sick, turnover=turnover, commission=commission
+ )
_finalize_pnl_bucket(total_bucket)
for ex in by_ex:
_finalize_pnl_bucket(by_ex[ex])
@@ -1340,6 +1385,8 @@ def _compute_period_stats(trade_rows: list[dict[str, Any]]) -> dict[str, Any]:
"max_loss": total_bucket["max_loss"],
"win_rate": total_bucket["win_rate"],
"profit_loss_ratio": total_bucket["profit_loss_ratio"],
+ "turnover_total": total_bucket["turnover_total"],
+ "commission_total": total_bucket["commission_total"],
"by_exchange": by_ex,
}
@@ -1537,3 +1584,93 @@ def list_daily_trades(
}
finally:
conn.close()
+
+
+def list_archive_calendar(
+ year: int,
+ month: int,
+ *,
+ exchange_key: str = "",
+ db_path: Path | None = None,
+ reset_hour: int = TRADING_DAY_RESET_HOUR,
+) -> dict[str, Any]:
+ """按月返回每个交易日的盈亏、笔数、犯病标记(08:00 切日)。"""
+ init_db(db_path)
+ y = int(year)
+ m = int(month)
+ if m < 1 or m > 12:
+ raise ValueError("month 无效")
+ first = f"{y:04d}-{m:02d}-01"
+ if m == 12:
+ next_first = datetime(y + 1, 1, 1)
+ else:
+ next_first = datetime(y, m + 1, 1)
+ last = (next_first - timedelta(days=1)).strftime("%Y-%m-%d")
+ start_ms, _ = trading_day_bounds_ms(first, reset_hour=reset_hour)
+ _, end_ms = trading_day_bounds_ms(last, reset_hour=reset_hour)
+ ex_filter = (exchange_key or "").strip().lower()
+ conn = _connect(db_path)
+ try:
+ params: list[Any] = [start_ms, end_ms]
+ where = "closed_at_ms IS NOT NULL AND closed_at_ms >= ? AND closed_at_ms < ?"
+ if ex_filter:
+ where += " AND exchange_key=?"
+ params.append(ex_filter)
+ rows = conn.execute(
+ f"SELECT * FROM archive_trade_cache WHERE {where}",
+ params,
+ ).fetchall()
+ overlays_by_ex: dict[str, dict[int, dict]] = {}
+ days: dict[str, dict[str, Any]] = {}
+ for r in rows:
+ ex_k = r["exchange_key"]
+ if ex_k not in overlays_by_ex:
+ overlays_by_ex[ex_k] = load_overlays(ex_k, db_path=db_path)
+ td_row = _trade_row_to_dict(r, overlays_by_ex[ex_k].get(int(r["trade_id"])))
+ closed_ms = td_row.get("closed_at_ms") or _parse_dt_ms(td_row.get("closed_at"))
+ if not closed_ms:
+ continue
+ day = ms_to_trading_day(int(closed_ms), reset_hour=reset_hour)
+ if not day:
+ continue
+ if day < first or day > last:
+ continue
+ bucket = days.setdefault(
+ day,
+ {
+ "trading_day": day,
+ "open_count": 0,
+ "sick_count": 0,
+ "pnl_total": 0.0,
+ "turnover_total": 0.0,
+ "commission_total": 0.0,
+ "has_sick": False,
+ },
+ )
+ pnl = float(td_row.get("pnl_amount") or 0)
+ tag = str(td_row.get("behavior_tag") or "")
+ is_sick = tag == "sick"
+ bucket["open_count"] += 1
+ bucket["pnl_total"] += pnl
+ bucket["turnover_total"] += float(td_row.get("exchange_turnover_usdt") or 0)
+ bucket["commission_total"] += float(td_row.get("exchange_commission_usdt") or 0)
+ if is_sick:
+ bucket["sick_count"] += 1
+ bucket["has_sick"] = True
+ for d in days.values():
+ d["pnl_total"] = round(float(d["pnl_total"]), 4)
+ d["turnover_total"] = round(float(d["turnover_total"]), 4)
+ d["commission_total"] = round(float(d["commission_total"]), 4)
+ month_pnl = sum(float(d["pnl_total"]) for d in days.values())
+ month_count = sum(int(d["open_count"]) for d in days.values())
+ return {
+ "year": y,
+ "month": m,
+ "date_from": first,
+ "date_to": last,
+ "days": days,
+ "month_pnl_total": round(month_pnl, 4),
+ "month_open_count": month_count,
+ }
+ finally:
+ conn.close()
diff --git a/hub_trades_lib.py b/hub_trades_lib.py
index 9b19765..c25f0b9 100644
--- a/hub_trades_lib.py
+++ b/hub_trades_lib.py
@@ -327,6 +327,8 @@ def _normalize_archive_trade_row(
"take_profit": _effective_field(d, "reviewed_take_profit", "take_profit"),
"reviewed": reviewed,
"trading_day": trading_day_from_dt(close_dt, reset_hour),
+ "exchange_turnover_usdt": d.get("exchange_turnover_usdt"),
+ "exchange_commission_usdt": d.get("exchange_commission_usdt"),
}
@@ -397,6 +399,8 @@ def _archive_trade_select_sql(cols: set[str]) -> str:
"reviewed_take_profit",
"reviewed_at",
"trend_plan_id",
+ "exchange_turnover_usdt",
+ "exchange_commission_usdt",
]
select_cols = [c for c in wanted if c in cols]
if "id" not in select_cols:
diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py
index 35ee0f2..7f4f010 100644
--- a/manual_trading_hub/hub.py
+++ b/manual_trading_hub/hub.py
@@ -50,6 +50,7 @@ from hub_symbol_archive_lib import (
delete_review_quote,
init_db as init_archive_db,
list_daily_trades,
+ list_archive_calendar,
list_review_quotes,
list_symbol_rows,
load_symbol_trades,
@@ -2344,6 +2345,25 @@ def api_archive_daily_trades(
return {"ok": True, **payload}
+@app.get("/api/archive/calendar")
+def api_archive_calendar(
+ year: int = 0,
+ month: int = 0,
+ exchange_key: str = "",
+):
+ init_archive_db()
+ if year <= 0 or month <= 0:
+ td = today_trading_day()
+ parts = td.split("-")
+ year = int(parts[0])
+ month = int(parts[1])
+ try:
+ payload = list_archive_calendar(year, month, exchange_key=exchange_key)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e)) from e
+ return {"ok": True, **payload}
+
+
@app.get("/api/archive/quotes")
def api_archive_quotes():
init_archive_db()
diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css
index 6cd7f47..1f8045a 100644
--- a/manual_trading_hub/static/app.css
+++ b/manual_trading_hub/static/app.css
@@ -6703,6 +6703,107 @@ body.funds-fullscreen-open {
.archive-trades-table td.neg {
color: #ef4444;
}
+.archive-calendar-wrap {
+ margin-top: 4px;
+ padding: 10px 12px;
+ border-radius: 10px;
+ border: 1px solid var(--border-soft);
+ background: rgba(15, 23, 42, 0.35);
+}
+.archive-calendar-head {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ margin-bottom: 8px;
+}
+.archive-cal-title {
+ font-size: 0.95rem;
+ font-weight: 600;
+ min-width: 120px;
+ text-align: center;
+}
+.archive-cal-weekdays {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ gap: 4px;
+ margin-bottom: 4px;
+}
+.archive-cal-wd {
+ text-align: center;
+ font-size: 0.72rem;
+ color: var(--text-muted, #9aa);
+}
+.archive-cal-grid {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ gap: 4px;
+}
+.archive-cal-cell {
+ min-height: 62px;
+ padding: 4px 3px;
+ border-radius: 8px;
+ border: 1px solid transparent;
+ background: rgba(30, 41, 59, 0.45);
+ color: inherit;
+ font: inherit;
+ cursor: default;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 2px;
+}
+.archive-cal-cell.has-trade {
+ cursor: pointer;
+}
+.archive-cal-cell.has-trade:hover {
+ border-color: rgba(99, 102, 241, 0.45);
+ background: rgba(49, 46, 129, 0.25);
+}
+.archive-cal-cell.is-selected {
+ border-color: rgba(99, 102, 241, 0.75);
+ box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.35);
+}
+.archive-cal-cell.is-sick-day {
+ border-color: rgba(239, 68, 68, 0.55);
+ background: rgba(127, 29, 29, 0.22);
+}
+.archive-cal-cell.is-sick-day.is-selected {
+ box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.45);
+}
+.archive-cal-cell.pnl-pos .archive-cal-pnl {
+ color: #22c55e;
+}
+.archive-cal-cell.pnl-neg .archive-cal-pnl {
+ color: #ef4444;
+}
+.archive-cal-day-num {
+ font-size: 0.78rem;
+ font-weight: 600;
+}
+.archive-cal-pnl {
+ font-size: 0.72rem;
+ font-weight: 600;
+ line-height: 1.1;
+}
+.archive-cal-cnt {
+ font-size: 0.65rem;
+ color: var(--text-muted, #9aa);
+}
+.archive-cal-sick-tag {
+ font-size: 0.62rem;
+ padding: 1px 4px;
+ border-radius: 4px;
+ background: rgba(239, 68, 68, 0.25);
+ color: #fca5a5;
+ font-weight: 600;
+}
+.archive-cal-pad {
+ background: transparent;
+ border: none;
+ min-height: 0;
+}
.archive-del-btn {
padding: 3px 8px;
font-size: 0.72rem;
diff --git a/manual_trading_hub/static/archive.js b/manual_trading_hub/static/archive.js
index 9a4e690..c79de28 100644
--- a/manual_trading_hub/static/archive.js
+++ b/manual_trading_hub/static/archive.js
@@ -20,6 +20,11 @@
const elBtnSync = document.getElementById("archive-btn-sync");
const elStatus = document.getElementById("archive-status");
const elStats = document.getElementById("archive-stats");
+ const elCalendarWrap = document.getElementById("archive-calendar-wrap");
+ const elCalendar = document.getElementById("archive-calendar");
+ const elCalTitle = document.getElementById("archive-cal-title");
+ const elCalPrev = document.getElementById("archive-cal-prev");
+ const elCalNext = document.getElementById("archive-cal-next");
const elQuotesList = document.getElementById("archive-quotes-list");
const elQuotesCount = document.getElementById("archive-quotes-count");
const elQuoteForm = document.getElementById("archive-quote-form");
@@ -72,6 +77,10 @@
let chartExchangeSymbol = "";
let chartMarketType = "swap";
let searchTimer = null;
+ let calendarYear = 0;
+ let calendarMonth = 0;
+ let calendarDays = {};
+ let selectedCalendarDay = "";
function esc(s) {
return String(s == null ? "" : s)
@@ -401,6 +410,19 @@
return q.toString();
}
+ function fmtVolStat(v) {
+ const n = Number(v);
+ if (!Number.isFinite(n) || n <= 0) return "—";
+ if (n >= 10000) return (n / 1000).toFixed(1) + "k";
+ return n.toFixed(0) + "U";
+ }
+
+ function fmtFeeStat(v) {
+ const n = Number(v);
+ if (!Number.isFinite(n) || n <= 0) return "—";
+ return n.toFixed(2) + "U";
+ }
+
function fmtPnlStat(v) {
const n = Number(v);
if (!Number.isFinite(n)) return "—";
@@ -475,11 +497,146 @@
"%
" +
fmtPnlStat(e.pnl_total) +
" | " +
+ fmtPnlStat(e.pnl_total) +
+ " | " +
fmtPnlStat(e.pnl_ex_sick) +
+ " | " +
+ fmtVolStat(e.turnover_total) +
+ " | " +
+ fmtFeeStat(e.commission_total) +
" | "
);
}
+ function calendarMonthLabel(y, m) {
+ return y + " 年 " + m + " 月";
+ }
+
+ function ensureCalendarMonthFromUI() {
+ if (calendarYear > 0 && calendarMonth > 0) return;
+ let ref = tradingDay || (elTradingDay && elTradingDay.value) || "";
+ if (!ref && dateFrom) ref = dateFrom;
+ if (!ref) {
+ const now = new Date();
+ calendarYear = now.getFullYear();
+ calendarMonth = now.getMonth() + 1;
+ return;
+ }
+ const p = String(ref).slice(0, 10).split("-");
+ calendarYear = parseInt(p[0], 10) || new Date().getFullYear();
+ calendarMonth = parseInt(p[1], 10) || new Date().getMonth() + 1;
+ }
+
+ function renderCalendar() {
+ if (!elCalendar || !elCalTitle) return;
+ ensureCalendarMonthFromUI();
+ elCalTitle.textContent = calendarMonthLabel(calendarYear, calendarMonth);
+ const first = new Date(calendarYear, calendarMonth - 1, 1);
+ const lastDay = new Date(calendarYear, calendarMonth, 0).getDate();
+ const startWd = first.getDay();
+ const weekdays = ["日", "一", "二", "三", "四", "五", "六"];
+ let html =
+ '' +
+ weekdays.map(function (w) {
+ return '' + w + "";
+ }).join("") +
+ "
";
+ for (let i = 0; i < startWd; i++) {
+ html += '';
+ }
+ for (let d = 1; d <= lastDay; d++) {
+ const dayStr =
+ calendarYear +
+ "-" +
+ String(calendarMonth).padStart(2, "0") +
+ "-" +
+ String(d).padStart(2, "0");
+ const info = calendarDays[dayStr];
+ const hasTrade = info && info.open_count > 0;
+ const sick = info && info.has_sick;
+ const pnl = hasTrade ? Number(info.pnl_total) : null;
+ const cnt = hasTrade ? info.open_count : 0;
+ const cls =
+ "archive-cal-cell" +
+ (hasTrade ? " has-trade" : "") +
+ (sick ? " is-sick-day" : "") +
+ (selectedCalendarDay === dayStr ? " is-selected" : "") +
+ (pnl != null && pnl > 0.0001 ? " pnl-pos" : pnl != null && pnl < -0.0001 ? " pnl-neg" : "");
+ let body = '' + d + "";
+ if (hasTrade) {
+ const pnlTxt = (pnl >= 0 ? "+" : "") + pnl.toFixed(1);
+ body +=
+ '' +
+ esc(pnlTxt) +
+ "" +
+ '' +
+ cnt +
+ "笔";
+ if (sick) body += '犯病';
+ }
+ html +=
+ '";
+ }
+ html += "
";
+ elCalendar.innerHTML = html;
+ elCalendar.querySelectorAll(".archive-cal-cell[data-day]").forEach(function (btn) {
+ btn.addEventListener("click", function () {
+ const day = btn.getAttribute("data-day");
+ if (!day) return;
+ selectedCalendarDay = day;
+ setPeriodMode("today");
+ if (elTradingDay) elTradingDay.value = day;
+ if (elFilterSick) {
+ elFilterSick.checked = btn.getAttribute("data-sick") === "1";
+ }
+ syncPeriodUI();
+ void loadDailyTrades();
+ renderCalendar();
+ });
+ });
+ }
+
+ async function loadCalendar() {
+ ensureCalendarMonthFromUI();
+ const q = new URLSearchParams();
+ q.set("year", String(calendarYear));
+ q.set("month", String(calendarMonth));
+ const ex = (elExchange && elExchange.value) || "";
+ if (ex) q.set("exchange_key", ex);
+ try {
+ const r = await apiFetch("/api/archive/calendar?" + q.toString());
+ const data = await r.json();
+ if (!data.ok) return;
+ calendarDays = data.days || {};
+ renderCalendar();
+ } catch (e) {
+ console.warn("[archive calendar]", e);
+ }
+ }
+
+ function shiftCalendarMonth(delta) {
+ ensureCalendarMonthFromUI();
+ calendarMonth += delta;
+ if (calendarMonth > 12) {
+ calendarMonth = 1;
+ calendarYear += 1;
+ } else if (calendarMonth < 1) {
+ calendarMonth = 12;
+ calendarYear -= 1;
+ }
+ void loadCalendar();
+ }
+
function renderStats() {
if (!elStats) return;
const st = dailyStats || { open_count: 0, by_exchange: {} };
@@ -503,6 +660,8 @@
profit_loss_ratio: st.profit_loss_ratio,
max_win: st.max_win,
max_loss: st.max_loss,
+ turnover_total: st.turnover_total,
+ commission_total: st.commission_total,
},
true
) +
@@ -513,7 +672,7 @@
.join("");
elStats.innerHTML =
'' +
- "| 范围 | 开仓 | 盈利单 | 亏损单 | 胜率 | 平均盈利 | 平均亏损 | 盈亏比 | 最大盈利 | 最大亏损 | 犯病 | 犯病占比 | 盈亏 | 剔除犯病盈亏 | " +
+ "范围 | 开仓 | 盈利单 | 亏损单 | 胜率 | 平均盈利 | 平均亏损 | 盈亏比 | 最大盈利 | 最大亏损 | 犯病 | 犯病占比 | 盈亏 | 剔除犯病盈亏 | 成交额 | 手续费 | " +
"
" +
rows +
"
";
@@ -1147,7 +1306,7 @@
elTrades.innerHTML =
'' +
"| 交易所 | 合约 | 开仓类型 | 开仓时间 | 平仓时间 | 持仓时长 | " +
- "方向 | 结果 | 盈亏 | 标签 | 备注 | 操作 | " +
+ "方向 | 结果 | 盈亏 | 成交额 | 手续费 | 标签 | 备注 | 操作 | " +
"
" +
dailyTrades
.map(function (t) {
@@ -1201,6 +1360,12 @@
'">' +
fmtPnl(t.pnl_amount) +
"" +
+ "" +
+ fmtVolStat(t.exchange_turnover_usdt) +
+ " | " +
+ "" +
+ fmtFeeStat(t.exchange_commission_usdt) +
+ " | " +
' |