feat: 内照明心交易日历与交易所口径成交额/手续费统计
新增按 08:00 切日的月历(盈亏、笔数、犯病日高亮与点击筛选);平仓时从交易所 fill 写入双边成交额与手续费,统计表与明细同步展示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+142
-5
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 @@
|
||||
"%</td><td>" +
|
||||
fmtPnlStat(e.pnl_total) +
|
||||
"</td><td>" +
|
||||
fmtPnlStat(e.pnl_total) +
|
||||
"</td><td>" +
|
||||
fmtPnlStat(e.pnl_ex_sick) +
|
||||
"</td><td>" +
|
||||
fmtVolStat(e.turnover_total) +
|
||||
"</td><td>" +
|
||||
fmtFeeStat(e.commission_total) +
|
||||
"</td></tr>"
|
||||
);
|
||||
}
|
||||
|
||||
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 =
|
||||
'<div class="archive-cal-weekdays">' +
|
||||
weekdays.map(function (w) {
|
||||
return '<span class="archive-cal-wd">' + w + "</span>";
|
||||
}).join("") +
|
||||
"</div><div class=\"archive-cal-grid\">";
|
||||
for (let i = 0; i < startWd; i++) {
|
||||
html += '<span class="archive-cal-cell archive-cal-pad"></span>';
|
||||
}
|
||||
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 = '<span class="archive-cal-day-num">' + d + "</span>";
|
||||
if (hasTrade) {
|
||||
const pnlTxt = (pnl >= 0 ? "+" : "") + pnl.toFixed(1);
|
||||
body +=
|
||||
'<span class="archive-cal-pnl">' +
|
||||
esc(pnlTxt) +
|
||||
"</span>" +
|
||||
'<span class="archive-cal-cnt">' +
|
||||
cnt +
|
||||
"笔</span>";
|
||||
if (sick) body += '<span class="archive-cal-sick-tag">犯病</span>';
|
||||
}
|
||||
html +=
|
||||
'<button type="button" class="' +
|
||||
cls +
|
||||
'" data-day="' +
|
||||
dayStr +
|
||||
'" data-sick="' +
|
||||
(sick ? "1" : "0") +
|
||||
'"' +
|
||||
(hasTrade ? "" : " disabled") +
|
||||
">" +
|
||||
body +
|
||||
"</button>";
|
||||
}
|
||||
html += "</div>";
|
||||
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 =
|
||||
'<table class="archive-stats-table"><thead><tr>' +
|
||||
"<th>范围</th><th>开仓</th><th>盈利单</th><th>亏损单</th><th>胜率</th><th>平均盈利</th><th>平均亏损</th><th>盈亏比</th><th>最大盈利</th><th>最大亏损</th><th>犯病</th><th>犯病占比</th><th>盈亏</th><th>剔除犯病盈亏</th>" +
|
||||
"<th>范围</th><th>开仓</th><th>盈利单</th><th>亏损单</th><th>胜率</th><th>平均盈利</th><th>平均亏损</th><th>盈亏比</th><th>最大盈利</th><th>最大亏损</th><th>犯病</th><th>犯病占比</th><th>盈亏</th><th>剔除犯病盈亏</th><th>成交额</th><th>手续费</th>" +
|
||||
"</tr></thead><tbody>" +
|
||||
rows +
|
||||
"</tbody></table>";
|
||||
@@ -1147,7 +1306,7 @@
|
||||
elTrades.innerHTML =
|
||||
'<table class="archive-trades-table"><thead><tr>' +
|
||||
"<th>交易所</th><th>合约</th><th>开仓类型</th><th>开仓时间</th><th>平仓时间</th><th>持仓时长</th>" +
|
||||
"<th>方向</th><th>结果</th><th>盈亏</th><th>标签</th><th>备注</th><th>操作</th>" +
|
||||
"<th>方向</th><th>结果</th><th>盈亏</th><th>成交额</th><th>手续费</th><th>标签</th><th>备注</th><th>操作</th>" +
|
||||
"</tr></thead><tbody>" +
|
||||
dailyTrades
|
||||
.map(function (t) {
|
||||
@@ -1201,6 +1360,12 @@
|
||||
'">' +
|
||||
fmtPnl(t.pnl_amount) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmtVolStat(t.exchange_turnover_usdt) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmtFeeStat(t.exchange_commission_usdt) +
|
||||
"</td>" +
|
||||
'<td><select class="archive-tag-select" data-id="' +
|
||||
tid +
|
||||
'" data-ex="' +
|
||||
@@ -1362,8 +1527,10 @@
|
||||
syncPeriodUI();
|
||||
dailyTrades = j.trades || [];
|
||||
dailyStats = j.stats || { open_count: 0, by_exchange: {} };
|
||||
if (periodMode === "today" && tradingDay) selectedCalendarDay = tradingDay;
|
||||
renderStats();
|
||||
renderTrades();
|
||||
void loadCalendar();
|
||||
setStatus(
|
||||
(periodLabel || tradingDay || "当日") +
|
||||
" · 列表 " +
|
||||
@@ -1414,6 +1581,7 @@
|
||||
const j = await r.json();
|
||||
setStatus(formatSyncSummary(j));
|
||||
await loadDailyTrades();
|
||||
await loadCalendar();
|
||||
await loadQuotes();
|
||||
if (isChartOpen() && selected) await loadChart();
|
||||
} catch (e) {
|
||||
@@ -1426,7 +1594,14 @@
|
||||
function bindEvents() {
|
||||
if (elBtnRefresh) elBtnRefresh.addEventListener("click", loadDailyTrades);
|
||||
if (elBtnSync) elBtnSync.addEventListener("click", syncAll);
|
||||
if (elExchange) elExchange.addEventListener("change", loadDailyTrades);
|
||||
if (elExchange) {
|
||||
elExchange.addEventListener("change", function () {
|
||||
void loadDailyTrades();
|
||||
void loadCalendar();
|
||||
});
|
||||
}
|
||||
if (elCalPrev) elCalPrev.addEventListener("click", function () { shiftCalendarMonth(-1); });
|
||||
if (elCalNext) elCalNext.addEventListener("click", function () { shiftCalendarMonth(1); });
|
||||
if (elPeriodTabs) {
|
||||
elPeriodTabs.addEventListener("click", function (ev) {
|
||||
const btn = ev.target.closest(".archive-period-btn");
|
||||
|
||||
@@ -488,6 +488,14 @@
|
||||
<h2>数据总览</h2>
|
||||
</div>
|
||||
<div id="archive-stats" class="archive-stats-bar"></div>
|
||||
<div id="archive-calendar-wrap" class="archive-calendar-wrap">
|
||||
<div class="archive-calendar-head">
|
||||
<button type="button" id="archive-cal-prev" class="ghost" title="上一月">‹</button>
|
||||
<span id="archive-cal-title" class="archive-cal-title"></span>
|
||||
<button type="button" id="archive-cal-next" class="ghost" title="下一月">›</button>
|
||||
</div>
|
||||
<div id="archive-calendar" class="archive-calendar" role="grid" aria-label="交易日历"></div>
|
||||
</div>
|
||||
</section>
|
||||
<details id="archive-chart-section" class="archive-acc-section archive-chart-section archive-panel-desktop">
|
||||
<summary class="archive-acc-summary">K 线图表 <span id="archive-chart-title" class="archive-acc-sub">—</span></summary>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from hub_symbol_archive_lib import init_db, list_archive_calendar, upsert_trades_cache, upsert_trade_overlay
|
||||
|
||||
|
||||
def _bj_ms(y, m, d, hh, mm):
|
||||
dt = datetime(y, m, d, hh, mm, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
|
||||
return int(dt.timestamp() * 1000)
|
||||
|
||||
|
||||
class ArchiveCalendarTests(unittest.TestCase):
|
||||
def test_calendar_groups_by_trading_day_and_sick(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "arch.db"
|
||||
init_db(db)
|
||||
upsert_trades_cache(
|
||||
"binance",
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"symbol": "BTC/USDT",
|
||||
"direction": "long",
|
||||
"result": "止盈",
|
||||
"pnl_amount": 10.0,
|
||||
"opened_at": "2026-06-18 09:00:00",
|
||||
"closed_at": "2026-06-18 10:00:00",
|
||||
"closed_at_ms": _bj_ms(2026, 6, 18, 10, 0),
|
||||
"exchange_turnover_usdt": 2000.0,
|
||||
"exchange_commission_usdt": 0.8,
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"symbol": "ETH/USDT",
|
||||
"direction": "short",
|
||||
"result": "止损",
|
||||
"pnl_amount": -5.0,
|
||||
"opened_at": "2026-06-18 14:00:00",
|
||||
"closed_at": "2026-06-18 15:00:00",
|
||||
"closed_at_ms": _bj_ms(2026, 6, 18, 15, 0),
|
||||
},
|
||||
],
|
||||
db_path=db,
|
||||
)
|
||||
upsert_trade_overlay("binance", 2, behavior_tag="sick", db_path=db)
|
||||
payload = list_archive_calendar(2026, 6, db_path=db)
|
||||
self.assertEqual(payload["month"], 6)
|
||||
days = payload["days"]
|
||||
self.assertTrue(days)
|
||||
sick_days = [d for d in days.values() if d.get("has_sick")]
|
||||
self.assertTrue(sick_days)
|
||||
self.assertGreaterEqual(payload["month_open_count"], 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,48 @@
|
||||
import unittest
|
||||
|
||||
from trade_exchange_stats_lib import (
|
||||
aggregate_bilateral_stats,
|
||||
commission_usdt_from_fill,
|
||||
filter_position_lifecycle_fills,
|
||||
merge_commission_prefer_income,
|
||||
quote_turnover_usdt_from_fill,
|
||||
)
|
||||
|
||||
|
||||
class TradeExchangeStatsTests(unittest.TestCase):
|
||||
def test_turnover_from_cost(self):
|
||||
t = {"cost": 1000.0, "price": 50, "amount": 20}
|
||||
self.assertEqual(quote_turnover_usdt_from_fill(t), 1000.0)
|
||||
|
||||
def test_commission_from_fee(self):
|
||||
t = {"fee": {"cost": -0.42, "currency": "USDT"}}
|
||||
self.assertEqual(commission_usdt_from_fill(t), 0.42)
|
||||
|
||||
def test_bilateral_aggregate(self):
|
||||
fills = [
|
||||
{"side": "buy", "cost": 500, "fee": {"cost": -0.2, "currency": "USDT"}, "timestamp": 1000},
|
||||
{"side": "sell", "cost": 520, "fee": {"cost": -0.21, "currency": "USDT"}, "timestamp": 2000},
|
||||
]
|
||||
stats = aggregate_bilateral_stats(fills)
|
||||
self.assertIsNotNone(stats)
|
||||
self.assertEqual(stats["exchange_turnover_usdt"], 1020.0)
|
||||
self.assertEqual(stats["exchange_commission_usdt"], 0.41)
|
||||
|
||||
def test_filter_long_lifecycle(self):
|
||||
base = 1_700_000_000_000
|
||||
trades = [
|
||||
{"side": "buy", "timestamp": base, "cost": 100},
|
||||
{"side": "sell", "timestamp": base + 60_000, "cost": 110},
|
||||
{"side": "buy", "timestamp": base + 120_000, "cost": 999},
|
||||
]
|
||||
got = filter_position_lifecycle_fills(
|
||||
trades, "long", base - 1000, base + 90_000, close_buffer_ms=0
|
||||
)
|
||||
self.assertEqual(len(got), 2)
|
||||
|
||||
def test_prefer_income_commission(self):
|
||||
self.assertEqual(merge_commission_prefer_income(0.3, 0.45), 0.45)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,229 @@
|
||||
"""平仓交易:交易所口径双边成交额与手续费(四所共用聚合逻辑)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
def _coerce_ts_ms(raw: Any) -> int | None:
|
||||
if raw in (None, ""):
|
||||
return None
|
||||
try:
|
||||
v = int(raw)
|
||||
return v if v > 1_000_000_000_000 else v * 1000
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def quote_turnover_usdt_from_fill(trade: dict, *, contract_size: float = 1.0) -> float:
|
||||
"""单笔成交的报价币成交额(USDT 口径)。"""
|
||||
info = trade.get("info") or {}
|
||||
if not isinstance(info, dict):
|
||||
info = {}
|
||||
for key in ("quoteQty", "quote_qty", "fillNotionalUsd", "notional"):
|
||||
try:
|
||||
v = float(info.get(key) or 0)
|
||||
if v > 0:
|
||||
return abs(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
try:
|
||||
cost = float(trade.get("cost") or 0)
|
||||
if cost > 0:
|
||||
return abs(cost)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
price = float(trade.get("price") or 0)
|
||||
amount = float(trade.get("amount") or 0) * float(contract_size or 1.0)
|
||||
if price > 0 and amount > 0:
|
||||
return abs(price * amount)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def commission_usdt_from_fill(trade: dict) -> float:
|
||||
"""单笔成交手续费(正数表示成本)。"""
|
||||
fee = trade.get("fee")
|
||||
if isinstance(fee, dict):
|
||||
try:
|
||||
cost = float(fee.get("cost") or 0)
|
||||
except (TypeError, ValueError):
|
||||
cost = 0.0
|
||||
if cost != 0:
|
||||
cur = str(fee.get("currency") or "USDT").upper()
|
||||
if cur in ("USDT", "USD", "BUSD", "USDC"):
|
||||
return abs(cost)
|
||||
return abs(cost)
|
||||
info = trade.get("info") or {}
|
||||
if isinstance(info, dict):
|
||||
for key in ("fee", "commission", "fillFee"):
|
||||
try:
|
||||
v = float(info.get(key) or 0)
|
||||
if v != 0:
|
||||
return abs(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return 0.0
|
||||
|
||||
|
||||
def aggregate_bilateral_stats(
|
||||
fills: list[dict],
|
||||
*,
|
||||
contract_size: float = 1.0,
|
||||
) -> dict[str, float] | None:
|
||||
"""双边成交额 = 开+平所有相关 fill 的报价币成交额之和;手续费 = fill fee 之和。"""
|
||||
if not fills:
|
||||
return None
|
||||
turnover = 0.0
|
||||
commission = 0.0
|
||||
for t in fills:
|
||||
turnover += quote_turnover_usdt_from_fill(t, contract_size=contract_size)
|
||||
commission += commission_usdt_from_fill(t)
|
||||
if turnover <= 0 and commission <= 0:
|
||||
return None
|
||||
return {
|
||||
"exchange_turnover_usdt": round(turnover, 4),
|
||||
"exchange_commission_usdt": round(commission, 4),
|
||||
}
|
||||
|
||||
|
||||
def filter_position_lifecycle_fills(
|
||||
trades: list[dict],
|
||||
direction: str,
|
||||
open_ms: int | None,
|
||||
close_ms: int | None,
|
||||
*,
|
||||
hedge_mode: bool = False,
|
||||
close_buffer_ms: int = 15 * 60 * 1000,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
持仓生命周期内 fill:多=开买+平卖;空=开卖+平买。
|
||||
hedge_mode 时按 posSide 与 direction 过滤。
|
||||
"""
|
||||
direction = (direction or "long").strip().lower()
|
||||
open_side = "buy" if direction == "long" else "sell"
|
||||
close_side = "sell" if direction == "long" else "buy"
|
||||
allowed_sides = {open_side, close_side}
|
||||
upper = int(close_ms) + int(close_buffer_ms) if close_ms else None
|
||||
out: list[dict] = []
|
||||
for t in trades or []:
|
||||
side = (t.get("side") or "").lower()
|
||||
if side not in allowed_sides:
|
||||
continue
|
||||
ts = _coerce_ts_ms(t.get("timestamp"))
|
||||
if ts is None:
|
||||
continue
|
||||
if open_ms and ts < int(open_ms) - 60_000:
|
||||
continue
|
||||
if upper and ts > upper:
|
||||
continue
|
||||
if hedge_mode:
|
||||
info = t.get("info") or {}
|
||||
if not isinstance(info, dict):
|
||||
info = {}
|
||||
pos_side = (info.get("posSide") or t.get("posSide") or "").lower()
|
||||
if pos_side in ("long", "short") and pos_side != direction:
|
||||
continue
|
||||
out.append(t)
|
||||
out.sort(key=lambda x: x.get("timestamp") or 0)
|
||||
return out
|
||||
|
||||
|
||||
def sum_binance_commission_income(entries: list[dict], trade_ids: set[str] | None) -> float | None:
|
||||
"""Binance income 流水中 COMMISSION 合计(负值取绝对值为成本)。"""
|
||||
if not entries:
|
||||
return None
|
||||
total = 0.0
|
||||
found = False
|
||||
for e in entries:
|
||||
it = (e.get("incomeType") or e.get("income_type") or "").strip()
|
||||
if it != "COMMISSION":
|
||||
continue
|
||||
if trade_ids:
|
||||
tid = str(e.get("tradeId") or e.get("trade_id") or "").strip()
|
||||
if tid and tid not in trade_ids:
|
||||
continue
|
||||
try:
|
||||
total += float(e.get("income") or 0)
|
||||
found = True
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not found:
|
||||
return None
|
||||
return round(abs(total), 4)
|
||||
|
||||
|
||||
def trade_ids_from_fills(fills: list[dict]) -> set[str]:
|
||||
out: set[str] = set()
|
||||
for t in fills or []:
|
||||
info = t.get("info") or {}
|
||||
if not isinstance(info, dict):
|
||||
info = {}
|
||||
for key in ("id", "tradeId", "trade_id"):
|
||||
raw = t.get(key) if key in t else info.get(key)
|
||||
if raw is not None and str(raw).strip():
|
||||
out.add(str(raw).strip())
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def merge_commission_prefer_income(
|
||||
fill_commission: float,
|
||||
income_commission: float | None,
|
||||
) -> float:
|
||||
if income_commission is not None and income_commission > 0:
|
||||
return round(income_commission, 4)
|
||||
return round(max(fill_commission, 0.0), 4)
|
||||
|
||||
|
||||
def update_trade_record_stats_columns(
|
||||
conn: Any,
|
||||
trade_id: int,
|
||||
turnover_usdt: float | None,
|
||||
commission_usdt: float | None,
|
||||
) -> None:
|
||||
if turnover_usdt is None and commission_usdt is None:
|
||||
return
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE trade_records
|
||||
SET exchange_turnover_usdt = COALESCE(?, exchange_turnover_usdt),
|
||||
exchange_commission_usdt = COALESCE(?, exchange_commission_usdt)
|
||||
WHERE id = ?
|
||||
""",
|
||||
(turnover_usdt, commission_usdt, int(trade_id)),
|
||||
)
|
||||
|
||||
|
||||
def attach_exchange_stats_to_trade(
|
||||
conn: Any,
|
||||
trade_id: int,
|
||||
*,
|
||||
fetch_fills: Callable[[], list[dict]],
|
||||
contract_size: float = 1.0,
|
||||
income_commission: float | None = None,
|
||||
) -> dict[str, float] | None:
|
||||
"""拉 fill 并写库;仅在新单平仓路径调用。"""
|
||||
try:
|
||||
fills = fetch_fills() or []
|
||||
except Exception:
|
||||
fills = []
|
||||
stats = aggregate_bilateral_stats(fills, contract_size=contract_size)
|
||||
if not stats and income_commission is None:
|
||||
return None
|
||||
turnover = stats.get("exchange_turnover_usdt") if stats else None
|
||||
fill_comm = float(stats.get("exchange_commission_usdt") or 0) if stats else 0.0
|
||||
commission = merge_commission_prefer_income(fill_comm, income_commission)
|
||||
update_trade_record_stats_columns(
|
||||
conn,
|
||||
trade_id,
|
||||
turnover,
|
||||
commission if commission > 0 else None,
|
||||
)
|
||||
out = {}
|
||||
if turnover is not None:
|
||||
out["exchange_turnover_usdt"] = turnover
|
||||
if commission > 0:
|
||||
out["exchange_commission_usdt"] = commission
|
||||
return out or None
|
||||
Reference in New Issue
Block a user