Add options stats holding time metrics and lightweight charts.
Show average hold duration for wins and losses, open positions, and CSS ring/bar visualizations in the stats tab. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3254,6 +3254,102 @@ html[data-theme="light"] .options-estimate-row {
|
||||
.options-pos-stats-card {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.options-stats-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
.options-stats-charts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 10px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.opt-stats-chart--ring {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.opt-stats-ring {
|
||||
--win-pct: 0;
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(
|
||||
#4cd97f 0 calc(var(--win-pct) * 1%),
|
||||
#ff6b6b calc(var(--win-pct) * 1%) 100%
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
.opt-stats-ring::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 8px;
|
||||
border-radius: 50%;
|
||||
background: #141923;
|
||||
}
|
||||
.opt-stats-ring-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.opt-stats-chart-caption,
|
||||
.opt-stats-chart-title {
|
||||
font-size: 0.66rem;
|
||||
color: #9aa3bf;
|
||||
text-align: center;
|
||||
}
|
||||
.opt-stats-chart-title {
|
||||
margin-bottom: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
.opt-stats-chart--pnl,
|
||||
.opt-stats-chart--hold {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.opt-stats-bar-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2.2em 1fr auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.opt-stats-bar-row .k {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.opt-stats-bar-row .v {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.opt-stats-bar-track {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
.opt-stats-bar-fill {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
border-radius: 999px;
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
.opt-stats-bar-fill--profit {
|
||||
background: linear-gradient(90deg, #2f9f62, #4cd97f);
|
||||
}
|
||||
.opt-stats-bar-fill--loss {
|
||||
background: linear-gradient(90deg, #c44a4a, #ff6b6b);
|
||||
}
|
||||
.options-stats-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -3690,6 +3786,16 @@ html[data-theme="light"] .opt-pos-tab.active {
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 95, 140, 0.22) !important;
|
||||
}
|
||||
|
||||
html[data-theme="light"] .opt-stats-ring::before {
|
||||
background: #f4f7fb;
|
||||
}
|
||||
html[data-theme="light"] .opt-stats-bar-track {
|
||||
background: rgba(20, 34, 50, 0.08);
|
||||
}
|
||||
html[data-theme="light"] .opt-stats-chart-caption,
|
||||
html[data-theme="light"] .opt-stats-chart-title {
|
||||
color: #5a6a80 !important;
|
||||
}
|
||||
html[data-theme="light"] .opt-moneyness-itm {
|
||||
color: #087a50 !important;
|
||||
background: rgba(8, 122, 80, 0.12) !important;
|
||||
|
||||
@@ -692,10 +692,16 @@
|
||||
const closedEl = document.getElementById("opt-stats-closed");
|
||||
const profitEl = document.getElementById("opt-stats-profit");
|
||||
const lossEl = document.getElementById("opt-stats-loss");
|
||||
const avgHoldEl = document.getElementById("opt-stats-avg-hold");
|
||||
const winHoldEl = document.getElementById("opt-stats-win-hold");
|
||||
const lossHoldEl = document.getElementById("opt-stats-loss-hold");
|
||||
const openHoldEl = document.getElementById("opt-stats-open-hold");
|
||||
const statEls = [winEl, plrEl, closedEl, profitEl, lossEl, avgHoldEl, winHoldEl, lossHoldEl, openHoldEl];
|
||||
if (!d.ok) {
|
||||
[winEl, plrEl, closedEl, profitEl, lossEl].forEach(function (el) {
|
||||
statEls.forEach(function (el) {
|
||||
if (el) el.textContent = "—";
|
||||
});
|
||||
paintStatsCharts(null);
|
||||
return;
|
||||
}
|
||||
if (winEl) winEl.textContent = d.total_closed ? d.win_rate + "%" : "0%";
|
||||
@@ -711,6 +717,96 @@
|
||||
lossEl.textContent = d.total_loss != null && d.total_loss > 0
|
||||
? fmt(d.total_loss, 2) + " USDC" : (d.total_closed ? "0 USDC" : "—");
|
||||
}
|
||||
if (avgHoldEl) avgHoldEl.textContent = fmtDuration(d.avg_hold_sec);
|
||||
if (winHoldEl) winHoldEl.textContent = fmtDuration(d.avg_win_hold_sec);
|
||||
if (lossHoldEl) lossHoldEl.textContent = fmtDuration(d.avg_loss_hold_sec);
|
||||
if (openHoldEl) {
|
||||
const cnt = Number(d.open_count) || 0;
|
||||
if (!cnt) {
|
||||
openHoldEl.textContent = "0 笔";
|
||||
} else {
|
||||
openHoldEl.textContent = cnt + " 笔 · " + fmtDuration(d.avg_open_hold_sec);
|
||||
}
|
||||
}
|
||||
paintStatsCharts(d);
|
||||
}
|
||||
|
||||
function fmtDuration(sec) {
|
||||
if (sec == null || sec === "" || Number.isNaN(Number(sec))) return "—";
|
||||
let s = Math.max(0, Math.round(Number(sec)));
|
||||
if (s < 60) return s + "秒";
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) {
|
||||
const rs = s % 60;
|
||||
return rs ? m + "分" + rs + "秒" : m + "分";
|
||||
}
|
||||
const h = Math.floor(m / 60);
|
||||
const rm = m % 60;
|
||||
if (h < 24) return rm ? h + "时" + rm + "分" : h + "时";
|
||||
const d = Math.floor(h / 24);
|
||||
const rh = h % 24;
|
||||
return rh ? d + "天" + rh + "时" : d + "天";
|
||||
}
|
||||
|
||||
function setBarFill(el, pct) {
|
||||
if (!el) return;
|
||||
const n = Math.max(0, Math.min(100, Number(pct) || 0));
|
||||
el.style.width = n + "%";
|
||||
}
|
||||
|
||||
function paintStatsCharts(d) {
|
||||
const ring = document.getElementById("opt-stats-ring");
|
||||
const ringLabel = document.getElementById("opt-stats-ring-label");
|
||||
const profitBar = document.getElementById("opt-stats-bar-profit");
|
||||
const lossBar = document.getElementById("opt-stats-bar-loss");
|
||||
const profitBarLabel = document.getElementById("opt-stats-bar-profit-label");
|
||||
const lossBarLabel = document.getElementById("opt-stats-bar-loss-label");
|
||||
const winHoldBar = document.getElementById("opt-stats-bar-win-hold");
|
||||
const lossHoldBar = document.getElementById("opt-stats-bar-loss-hold");
|
||||
const winHoldBarLabel = document.getElementById("opt-stats-win-hold-label");
|
||||
const lossHoldBarLabel = document.getElementById("opt-stats-loss-hold-label");
|
||||
if (!d || !d.ok) {
|
||||
if (ring) ring.style.setProperty("--win-pct", "0");
|
||||
if (ringLabel) ringLabel.textContent = "—";
|
||||
[profitBar, lossBar, winHoldBar, lossHoldBar].forEach(function (el) { setBarFill(el, 0); });
|
||||
[profitBarLabel, lossBarLabel, winHoldBarLabel, lossHoldBarLabel].forEach(function (el) {
|
||||
if (el) el.textContent = "—";
|
||||
});
|
||||
return;
|
||||
}
|
||||
const winRate = d.total_closed ? Number(d.win_rate) || 0 : 0;
|
||||
if (ring) ring.style.setProperty("--win-pct", String(winRate));
|
||||
if (ringLabel) ringLabel.textContent = d.total_closed ? winRate.toFixed(0) + "%" : "0%";
|
||||
|
||||
const profit = Math.max(0, Number(d.total_profit) || 0);
|
||||
const loss = Math.max(0, Number(d.total_loss) || 0);
|
||||
const pnlTotal = profit + loss;
|
||||
if (pnlTotal > 0) {
|
||||
setBarFill(profitBar, (profit / pnlTotal) * 100);
|
||||
setBarFill(lossBar, (loss / pnlTotal) * 100);
|
||||
if (profitBarLabel) profitBarLabel.textContent = fmt(profit, 2) + " USDC";
|
||||
if (lossBarLabel) lossBarLabel.textContent = fmt(loss, 2) + " USDC";
|
||||
} else {
|
||||
setBarFill(profitBar, 0);
|
||||
setBarFill(lossBar, 0);
|
||||
if (profitBarLabel) profitBarLabel.textContent = d.total_closed ? "0 USDC" : "—";
|
||||
if (lossBarLabel) lossBarLabel.textContent = d.total_closed ? "0 USDC" : "—";
|
||||
}
|
||||
|
||||
const winHold = Number(d.avg_win_hold_sec) || 0;
|
||||
const lossHold = Number(d.avg_loss_hold_sec) || 0;
|
||||
const holdMax = Math.max(winHold, lossHold);
|
||||
if (holdMax > 0) {
|
||||
setBarFill(winHoldBar, (winHold / holdMax) * 100);
|
||||
setBarFill(lossHoldBar, (lossHold / holdMax) * 100);
|
||||
if (winHoldBarLabel) winHoldBarLabel.textContent = fmtDuration(d.avg_win_hold_sec);
|
||||
if (lossHoldBarLabel) lossHoldBarLabel.textContent = fmtDuration(d.avg_loss_hold_sec);
|
||||
} else {
|
||||
setBarFill(winHoldBar, 0);
|
||||
setBarFill(lossHoldBar, 0);
|
||||
if (winHoldBarLabel) winHoldBarLabel.textContent = "—";
|
||||
if (lossHoldBarLabel) lossHoldBarLabel.textContent = "—";
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHistoryRow(id, status) {
|
||||
|
||||
@@ -4,41 +4,11 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from lib.instance.instance_embed_context_lib import profit_loss_ratio_from_averages
|
||||
from lib.options.options_db import init_options_tables
|
||||
from lib.options.options_stats_lib import compute_options_stats
|
||||
|
||||
|
||||
def _compute_options_stats(get_db) -> dict[str, Any]:
|
||||
conn = get_db()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT realized_pnl FROM options_trades
|
||||
WHERE status = 'closed' AND realized_pnl IS NOT NULL
|
||||
"""
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
wins: list[float] = []
|
||||
losses: list[float] = []
|
||||
for row in rows:
|
||||
pnl = float(row["realized_pnl"])
|
||||
if pnl > 0:
|
||||
wins.append(pnl)
|
||||
elif pnl < 0:
|
||||
losses.append(pnl)
|
||||
total_closed = len(wins) + len(losses)
|
||||
win_rate = round(len(wins) / total_closed * 100, 2) if total_closed else 0
|
||||
avg_win = sum(wins) / len(wins) if wins else None
|
||||
avg_loss = sum(losses) / len(losses) if losses else None
|
||||
return {
|
||||
"total_closed": total_closed,
|
||||
"win_rate": win_rate,
|
||||
"profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss),
|
||||
"total_profit": round(sum(wins), 4) if wins else 0.0,
|
||||
"total_loss": round(abs(sum(losses)), 4) if losses else 0.0,
|
||||
}
|
||||
return compute_options_stats(get_db)
|
||||
|
||||
|
||||
def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@@ -666,43 +666,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
ex, err = _require_options_ex(cfg)
|
||||
if ex is None:
|
||||
return jsonify({"ok": False, "msg": err})
|
||||
from lib.instance.instance_embed_context_lib import profit_loss_ratio_from_averages
|
||||
from lib.options.options_stats_lib import compute_options_stats
|
||||
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT realized_pnl FROM options_trades
|
||||
WHERE status = 'closed' AND realized_pnl IS NOT NULL
|
||||
"""
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
wins: list[float] = []
|
||||
losses: list[float] = []
|
||||
for row in rows:
|
||||
pnl = float(row["realized_pnl"])
|
||||
if pnl > 0:
|
||||
wins.append(pnl)
|
||||
elif pnl < 0:
|
||||
losses.append(pnl)
|
||||
total_closed = len(wins) + len(losses)
|
||||
win_rate = round(len(wins) / total_closed * 100, 2) if total_closed else 0
|
||||
avg_win = sum(wins) / len(wins) if wins else None
|
||||
avg_loss = sum(losses) / len(losses) if losses else None
|
||||
total_profit = round(sum(wins), 4) if wins else 0.0
|
||||
total_loss = round(abs(sum(losses)), 4) if losses else 0.0
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"total_closed": total_closed,
|
||||
"win_rate": win_rate,
|
||||
"profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss),
|
||||
"total_profit": total_profit,
|
||||
"total_loss": total_loss,
|
||||
}
|
||||
)
|
||||
return jsonify({"ok": True, **compute_options_stats(cfg["get_db"])})
|
||||
|
||||
@app.route("/api/options/history/<int:trade_id>", methods=["DELETE"])
|
||||
@lr
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""期权本地交易统计(胜率 / 盈亏 / 持仓时长)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from lib.instance.instance_embed_context_lib import profit_loss_ratio_from_averages
|
||||
from lib.options.options_db import init_options_tables
|
||||
|
||||
|
||||
def _parse_ts(raw: Any) -> datetime | None:
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
s = str(raw).strip().replace(" ", "T", 1)
|
||||
try:
|
||||
return datetime.fromisoformat(s)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _hold_seconds(created_at: Any, closed_at: Any) -> float | None:
|
||||
start = _parse_ts(created_at)
|
||||
end = _parse_ts(closed_at)
|
||||
if start is None or end is None:
|
||||
return None
|
||||
sec = (end - start).total_seconds()
|
||||
return sec if sec >= 0 else None
|
||||
|
||||
|
||||
def _avg_seconds(values: list[float]) -> float | None:
|
||||
if not values:
|
||||
return None
|
||||
return round(sum(values) / len(values), 1)
|
||||
|
||||
|
||||
def compute_options_stats(get_db) -> dict[str, Any]:
|
||||
conn = get_db()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
closed_rows = conn.execute(
|
||||
"""
|
||||
SELECT realized_pnl, created_at, closed_at
|
||||
FROM options_trades
|
||||
WHERE status = 'closed' AND realized_pnl IS NOT NULL
|
||||
"""
|
||||
).fetchall()
|
||||
open_rows = conn.execute(
|
||||
"""
|
||||
SELECT created_at FROM options_trades WHERE status = 'open'
|
||||
"""
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
wins: list[float] = []
|
||||
losses: list[float] = []
|
||||
win_holds: list[float] = []
|
||||
loss_holds: list[float] = []
|
||||
all_holds: list[float] = []
|
||||
now = datetime.now()
|
||||
|
||||
for row in closed_rows:
|
||||
pnl = float(row["realized_pnl"])
|
||||
hold = _hold_seconds(row["created_at"], row["closed_at"])
|
||||
if hold is not None:
|
||||
all_holds.append(hold)
|
||||
if pnl > 0:
|
||||
wins.append(pnl)
|
||||
if hold is not None:
|
||||
win_holds.append(hold)
|
||||
elif pnl < 0:
|
||||
losses.append(pnl)
|
||||
if hold is not None:
|
||||
loss_holds.append(hold)
|
||||
|
||||
open_holds: list[float] = []
|
||||
for row in open_rows:
|
||||
start = _parse_ts(row["created_at"])
|
||||
if start is None:
|
||||
continue
|
||||
sec = (now - start).total_seconds()
|
||||
if sec >= 0:
|
||||
open_holds.append(sec)
|
||||
|
||||
total_closed = len(wins) + len(losses)
|
||||
win_rate = round(len(wins) / total_closed * 100, 2) if total_closed else 0
|
||||
avg_win = sum(wins) / len(wins) if wins else None
|
||||
avg_loss = sum(losses) / len(losses) if losses else None
|
||||
|
||||
return {
|
||||
"total_closed": total_closed,
|
||||
"win_count": len(wins),
|
||||
"loss_count": len(losses),
|
||||
"win_rate": win_rate,
|
||||
"profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss),
|
||||
"total_profit": round(sum(wins), 4) if wins else 0.0,
|
||||
"total_loss": round(abs(sum(losses)), 4) if losses else 0.0,
|
||||
"avg_hold_sec": _avg_seconds(all_holds),
|
||||
"avg_win_hold_sec": _avg_seconds(win_holds),
|
||||
"avg_loss_hold_sec": _avg_seconds(loss_holds),
|
||||
"open_count": len(open_holds),
|
||||
"avg_open_hold_sec": _avg_seconds(open_holds),
|
||||
}
|
||||
@@ -93,26 +93,85 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="options-pos-pane" data-opt-pos-pane="stats" role="tabpanel" aria-labelledby="opt-pos-tab-stats" hidden>
|
||||
<div class="options-stats-grid">
|
||||
<div class="options-stat-item">
|
||||
<span class="k">胜率</span>
|
||||
<span class="v" id="opt-stats-winrate">—</span>
|
||||
<div class="options-stats-panel">
|
||||
<div class="options-stats-charts">
|
||||
<div class="opt-stats-chart opt-stats-chart--ring">
|
||||
<div class="opt-stats-ring" id="opt-stats-ring" style="--win-pct: 0">
|
||||
<span class="opt-stats-ring-label" id="opt-stats-ring-label">—</span>
|
||||
</div>
|
||||
<span class="opt-stats-chart-caption">胜率</span>
|
||||
</div>
|
||||
<div class="opt-stats-chart opt-stats-chart--pnl">
|
||||
<div class="opt-stats-bar-row">
|
||||
<span class="k">盈利</span>
|
||||
<div class="opt-stats-bar-track">
|
||||
<div class="opt-stats-bar-fill opt-stats-bar-fill--profit" id="opt-stats-bar-profit"></div>
|
||||
</div>
|
||||
<span class="v pos-pnl-profit" id="opt-stats-bar-profit-label">—</span>
|
||||
</div>
|
||||
<div class="opt-stats-bar-row">
|
||||
<span class="k">亏损</span>
|
||||
<div class="opt-stats-bar-track">
|
||||
<div class="opt-stats-bar-fill opt-stats-bar-fill--loss" id="opt-stats-bar-loss"></div>
|
||||
</div>
|
||||
<span class="v pos-pnl-loss" id="opt-stats-bar-loss-label">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">盈亏比</span>
|
||||
<span class="v" id="opt-stats-plr">—</span>
|
||||
<div class="opt-stats-chart opt-stats-chart--hold">
|
||||
<div class="opt-stats-chart-title">持仓时长对比</div>
|
||||
<div class="opt-stats-bar-row">
|
||||
<span class="k">盈单</span>
|
||||
<div class="opt-stats-bar-track">
|
||||
<div class="opt-stats-bar-fill opt-stats-bar-fill--profit" id="opt-stats-bar-win-hold"></div>
|
||||
</div>
|
||||
<span class="v" id="opt-stats-win-hold-label">—</span>
|
||||
</div>
|
||||
<div class="opt-stats-bar-row">
|
||||
<span class="k">亏单</span>
|
||||
<div class="opt-stats-bar-track">
|
||||
<div class="opt-stats-bar-fill opt-stats-bar-fill--loss" id="opt-stats-bar-loss-hold"></div>
|
||||
</div>
|
||||
<span class="v" id="opt-stats-loss-hold-label">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">已平笔数</span>
|
||||
<span class="v" id="opt-stats-closed">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">盈利</span>
|
||||
<span class="v pos-pnl-profit" id="opt-stats-profit">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">亏损</span>
|
||||
<span class="v pos-pnl-loss" id="opt-stats-loss">—</span>
|
||||
<div class="options-stats-grid">
|
||||
<div class="options-stat-item">
|
||||
<span class="k">胜率</span>
|
||||
<span class="v" id="opt-stats-winrate">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">盈亏比</span>
|
||||
<span class="v" id="opt-stats-plr">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">已平笔数</span>
|
||||
<span class="v" id="opt-stats-closed">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">盈利</span>
|
||||
<span class="v pos-pnl-profit" id="opt-stats-profit">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">亏损</span>
|
||||
<span class="v pos-pnl-loss" id="opt-stats-loss">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">均持仓</span>
|
||||
<span class="v" id="opt-stats-avg-hold">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">盈单持仓</span>
|
||||
<span class="v" id="opt-stats-win-hold">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">亏单持仓</span>
|
||||
<span class="v" id="opt-stats-loss-hold">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">持仓中</span>
|
||||
<span class="v" id="opt-stats-open-hold">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,4 +200,4 @@
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||
<script src="/static/options_panel.js?v=19"></script>
|
||||
<script src="/static/options_panel.js?v=20"></script>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""期权统计单测."""
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from unittest import TestCase
|
||||
|
||||
from lib.options.options_db import init_options_tables
|
||||
from lib.options.options_stats_lib import compute_options_stats
|
||||
|
||||
|
||||
class OptionsStatsLibTests(TestCase):
|
||||
def _conn(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_options_tables(conn)
|
||||
return conn
|
||||
|
||||
def test_compute_options_stats_empty(self):
|
||||
conn = self._conn()
|
||||
out = compute_options_stats(lambda: conn)
|
||||
self.assertEqual(out["total_closed"], 0)
|
||||
self.assertEqual(out["open_count"], 0)
|
||||
self.assertIsNone(out["avg_hold_sec"])
|
||||
|
||||
def test_compute_options_stats_hold_times(self):
|
||||
conn = self._conn()
|
||||
now = datetime.now()
|
||||
win_open = (now - timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
win_close = (now - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
loss_open = (now - timedelta(hours=4)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
loss_close = (now - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
open_at = (now - timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, sheets, eth_amount, status,
|
||||
realized_pnl, created_at, closed_at)
|
||||
VALUES ('A', 'ETH', 'C', 1800, 1, 0.01, 'closed', 1.2, ?, ?)
|
||||
""",
|
||||
(win_open, win_close),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, sheets, eth_amount, status,
|
||||
realized_pnl, created_at, closed_at)
|
||||
VALUES ('B', 'ETH', 'P', 1700, 1, 0.01, 'closed', -1.0, ?, ?)
|
||||
""",
|
||||
(loss_open, loss_close),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, sheets, eth_amount, status, created_at)
|
||||
VALUES ('C', 'BTC', 'C', 62000, 1, 0.01, 'open', ?)
|
||||
""",
|
||||
(open_at,),
|
||||
)
|
||||
conn.commit()
|
||||
out = compute_options_stats(lambda: conn)
|
||||
self.assertEqual(out["total_closed"], 2)
|
||||
self.assertEqual(out["win_count"], 1)
|
||||
self.assertEqual(out["loss_count"], 1)
|
||||
self.assertEqual(out["win_rate"], 50.0)
|
||||
self.assertAlmostEqual(out["avg_win_hold_sec"], 3600.0, delta=5.0)
|
||||
self.assertAlmostEqual(out["avg_loss_hold_sec"], 3 * 3600.0, delta=5.0)
|
||||
self.assertEqual(out["open_count"], 1)
|
||||
self.assertGreater(out["avg_open_hold_sec"], 1700.0)
|
||||
Reference in New Issue
Block a user