From 6b872b1f43fe0f986af2875fcf7900b98e6274a5 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 30 Jun 2026 08:05:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=86=85=E7=85=A7=E6=98=8E=E5=BF=83?= =?UTF-8?q?=E4=BA=A4=E6=98=93=E6=97=A5=E5=8E=86=E4=B8=8E=E4=BA=A4=E6=98=93?= =?UTF-8?q?=E6=89=80=E5=8F=A3=E5=BE=84=E6=88=90=E4=BA=A4=E9=A2=9D/?= =?UTF-8?q?=E6=89=8B=E7=BB=AD=E8=B4=B9=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增按 08:00 切日的月历(盈亏、笔数、犯病日高亮与点击筛选);平仓时从交易所 fill 写入双边成交额与手续费,统计表与明细同步展示。 Co-authored-by: Cursor --- crypto_monitor_binance/app.py | 107 +++++++++++- crypto_monitor_gate/app.py | 75 +++++++- crypto_monitor_gate_bot/app.py | 75 +++++++- crypto_monitor_okx/app.py | 70 +++++++- hub_symbol_archive_lib.py | 147 +++++++++++++++- hub_trades_lib.py | 4 + manual_trading_hub/hub.py | 20 +++ manual_trading_hub/static/app.css | 101 +++++++++++ manual_trading_hub/static/archive.js | 181 ++++++++++++++++++- manual_trading_hub/static/index.html | 8 + tests/test_archive_calendar.py | 60 +++++++ tests/test_trade_exchange_stats_lib.py | 48 ++++++ trade_exchange_stats_lib.py | 229 +++++++++++++++++++++++++ 13 files changed, 1113 insertions(+), 12 deletions(-) create mode 100644 tests/test_archive_calendar.py create mode 100644 tests/test_trade_exchange_stats_lib.py create mode 100644 trade_exchange_stats_lib.py 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) + + "