feat: 档案统计独立卡片、共用交易日历与四所统计页日历
内照明心统计表移至顶部卡片,右侧为日历/图表/交易记录;日历样式适配浅深主题,四所统计分析页同步展示按月盈亏日历。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9617,7 +9617,7 @@ try:
|
|||||||
_repo_root = Path(__file__).resolve().parent.parent
|
_repo_root = Path(__file__).resolve().parent.parent
|
||||||
if str(_repo_root) not in sys.path:
|
if str(_repo_root) not in sys.path:
|
||||||
sys.path.insert(0, str(_repo_root))
|
sys.path.insert(0, str(_repo_root))
|
||||||
from hub_bridge import install_on_app
|
from hub_bridge import install_on_app, register_trade_stats_calendar_route
|
||||||
|
|
||||||
install_on_app(
|
install_on_app(
|
||||||
app,
|
app,
|
||||||
@@ -9637,6 +9637,13 @@ try:
|
|||||||
render_main_page_fn=render_main_page,
|
render_main_page_fn=render_main_page,
|
||||||
login_required_fn=login_required,
|
login_required_fn=login_required,
|
||||||
)
|
)
|
||||||
|
register_trade_stats_calendar_route(
|
||||||
|
app,
|
||||||
|
login_required_fn=login_required,
|
||||||
|
load_pnls_fn=_load_completed_trade_pnls,
|
||||||
|
row_matches_segment_fn=_pnl_row_matches_segment,
|
||||||
|
reset_hour=TRADING_DAY_RESET_HOUR,
|
||||||
|
)
|
||||||
except Exception as _hub_err:
|
except Exception as _hub_err:
|
||||||
print(f"[hub_bridge] binance: {_hub_err}")
|
print(f"[hub_bridge] binance: {_hub_err}")
|
||||||
|
|
||||||
|
|||||||
@@ -9537,7 +9537,7 @@ try:
|
|||||||
_repo_root = Path(__file__).resolve().parent.parent
|
_repo_root = Path(__file__).resolve().parent.parent
|
||||||
if str(_repo_root) not in sys.path:
|
if str(_repo_root) not in sys.path:
|
||||||
sys.path.insert(0, str(_repo_root))
|
sys.path.insert(0, str(_repo_root))
|
||||||
from hub_bridge import install_on_app
|
from hub_bridge import install_on_app, register_trade_stats_calendar_route
|
||||||
|
|
||||||
install_on_app(
|
install_on_app(
|
||||||
app,
|
app,
|
||||||
@@ -9558,6 +9558,13 @@ try:
|
|||||||
render_main_page_fn=render_main_page,
|
render_main_page_fn=render_main_page,
|
||||||
login_required_fn=login_required,
|
login_required_fn=login_required,
|
||||||
)
|
)
|
||||||
|
register_trade_stats_calendar_route(
|
||||||
|
app,
|
||||||
|
login_required_fn=login_required,
|
||||||
|
load_pnls_fn=_load_completed_trade_pnls,
|
||||||
|
row_matches_segment_fn=_pnl_row_matches_segment,
|
||||||
|
reset_hour=TRADING_DAY_RESET_HOUR,
|
||||||
|
)
|
||||||
except Exception as _hub_err:
|
except Exception as _hub_err:
|
||||||
print(f"[hub_bridge] gate: {_hub_err}")
|
print(f"[hub_bridge] gate: {_hub_err}")
|
||||||
|
|
||||||
|
|||||||
@@ -9533,7 +9533,7 @@ try:
|
|||||||
_repo_root = Path(__file__).resolve().parent.parent
|
_repo_root = Path(__file__).resolve().parent.parent
|
||||||
if str(_repo_root) not in sys.path:
|
if str(_repo_root) not in sys.path:
|
||||||
sys.path.insert(0, str(_repo_root))
|
sys.path.insert(0, str(_repo_root))
|
||||||
from hub_bridge import install_on_app
|
from hub_bridge import install_on_app, register_trade_stats_calendar_route
|
||||||
|
|
||||||
install_on_app(
|
install_on_app(
|
||||||
app,
|
app,
|
||||||
@@ -9554,6 +9554,13 @@ try:
|
|||||||
render_main_page_fn=render_main_page,
|
render_main_page_fn=render_main_page,
|
||||||
login_required_fn=login_required,
|
login_required_fn=login_required,
|
||||||
)
|
)
|
||||||
|
register_trade_stats_calendar_route(
|
||||||
|
app,
|
||||||
|
login_required_fn=login_required,
|
||||||
|
load_pnls_fn=_load_completed_trade_pnls,
|
||||||
|
row_matches_segment_fn=_pnl_row_matches_segment,
|
||||||
|
reset_hour=TRADING_DAY_RESET_HOUR,
|
||||||
|
)
|
||||||
except Exception as _hub_err:
|
except Exception as _hub_err:
|
||||||
print(f"[hub_bridge] gate_bot: {_hub_err}")
|
print(f"[hub_bridge] gate_bot: {_hub_err}")
|
||||||
|
|
||||||
|
|||||||
@@ -8998,7 +8998,7 @@ try:
|
|||||||
_repo_root = Path(__file__).resolve().parent.parent
|
_repo_root = Path(__file__).resolve().parent.parent
|
||||||
if str(_repo_root) not in sys.path:
|
if str(_repo_root) not in sys.path:
|
||||||
sys.path.insert(0, str(_repo_root))
|
sys.path.insert(0, str(_repo_root))
|
||||||
from hub_bridge import install_on_app
|
from hub_bridge import install_on_app, register_trade_stats_calendar_route
|
||||||
|
|
||||||
install_on_app(
|
install_on_app(
|
||||||
app,
|
app,
|
||||||
@@ -9018,6 +9018,13 @@ try:
|
|||||||
render_main_page_fn=render_main_page,
|
render_main_page_fn=render_main_page,
|
||||||
login_required_fn=login_required,
|
login_required_fn=login_required,
|
||||||
)
|
)
|
||||||
|
register_trade_stats_calendar_route(
|
||||||
|
app,
|
||||||
|
login_required_fn=login_required,
|
||||||
|
load_pnls_fn=_load_completed_trade_pnls,
|
||||||
|
row_matches_segment_fn=_pnl_row_matches_segment,
|
||||||
|
reset_hour=TRADING_DAY_RESET_HOUR,
|
||||||
|
)
|
||||||
except Exception as _hub_err:
|
except Exception as _hub_err:
|
||||||
print(f"[hub_bridge] okx: {_hub_err}")
|
print(f"[hub_bridge] okx: {_hub_err}")
|
||||||
|
|
||||||
|
|||||||
@@ -664,6 +664,36 @@ function switchStatsSegment(){
|
|||||||
q.set("stats_segment", key);
|
q.set("stats_segment", key);
|
||||||
const qs = q.toString();
|
const qs = q.toString();
|
||||||
history.replaceState(null, "", qs ? (window.location.pathname + "?" + qs) : window.location.pathname);
|
history.replaceState(null, "", qs ? (window.location.pathname + "?" + qs) : window.location.pathname);
|
||||||
|
if(statsCalendarWidget) statsCalendarWidget.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
let statsCalendarWidget = null;
|
||||||
|
|
||||||
|
function initStatsCalendarWidget(){
|
||||||
|
const grid = document.getElementById("stats-calendar");
|
||||||
|
if(!grid || !window.TradeStatsCalendar) return;
|
||||||
|
statsCalendarWidget = new TradeStatsCalendar({
|
||||||
|
gridEl: grid,
|
||||||
|
titleEl: document.getElementById("stats-cal-title"),
|
||||||
|
prevBtn: document.getElementById("stats-cal-prev"),
|
||||||
|
nextBtn: document.getElementById("stats-cal-next"),
|
||||||
|
apiUrl: "/api/stats/calendar",
|
||||||
|
showSick: false,
|
||||||
|
buildQuery: function(year, month){
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set("year", String(year));
|
||||||
|
q.set("month", String(month));
|
||||||
|
const sel = document.getElementById("stats-segment-select");
|
||||||
|
if(sel) q.set("segment", sel.value || "all");
|
||||||
|
return q;
|
||||||
|
},
|
||||||
|
parseResponse: function(data){
|
||||||
|
if(data && data.ok === false) return {};
|
||||||
|
return (data && data.days) || {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
statsCalendarWidget.ensureMonth(new Date());
|
||||||
|
statsCalendarWidget.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
function initStatsSegmentFromUrl(){
|
function initStatsSegmentFromUrl(){
|
||||||
@@ -700,6 +730,7 @@ attachListWindowToExports();
|
|||||||
toggleListWindowCustom();
|
toggleListWindowCustom();
|
||||||
bindListWindowDateAutoCustom();
|
bindListWindowDateAutoCustom();
|
||||||
initStatsSegmentFromUrl();
|
initStatsSegmentFromUrl();
|
||||||
|
initStatsCalendarWidget();
|
||||||
if(document.getElementById("journal-list")) loadJournals();
|
if(document.getElementById("journal-list")) loadJournals();
|
||||||
if(document.getElementById("review-list")) loadReviews();
|
if(document.getElementById("review-list")) loadReviews();
|
||||||
const reviewToggle = document.getElementById("review-mode-toggle");
|
const reviewToggle = document.getElementById("review-mode-toggle");
|
||||||
|
|||||||
@@ -452,6 +452,14 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="stats-calendar-wrap" class="trade-cal-wrap stats-calendar-wrap">
|
||||||
|
<div class="trade-cal-head">
|
||||||
|
<button type="button" id="stats-cal-prev" class="btn" title="上一月">‹</button>
|
||||||
|
<span id="stats-cal-title" class="trade-cal-title"></span>
|
||||||
|
<button type="button" id="stats-cal-next" class="btn" title="下一月">›</button>
|
||||||
|
</div>
|
||||||
|
<div id="stats-calendar" class="trade-cal-grid-host" role="grid" aria-label="交易日历"></div>
|
||||||
|
</div>
|
||||||
{% for seg in stats_bundle.segments %}
|
{% for seg in stats_bundle.segments %}
|
||||||
<div class="stats-segment-block stats-segment-panel" data-stats-segment="{{ seg.key }}"{% if not loop.first %} style="display:none"{% endif %}>
|
<div class="stats-segment-block stats-segment-panel" data-stats-segment="{{ seg.key }}"{% if not loop.first %} style="display:none"{% endif %}>
|
||||||
{{ period_stats("日统计", seg.day) }}
|
{{ period_stats("日统计", seg.day) }}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
||||||
<link rel="stylesheet" href="/static/instance_page.css?v=2">
|
<link rel="stylesheet" href="/static/instance_page.css?v=2">
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=18">
|
<link rel="stylesheet" href="/static/instance_theme.css?v=18">
|
||||||
|
<link rel="stylesheet" href="/static/trade_stats_calendar.css?v=1">
|
||||||
<script src="/static/account_risk_badge.js?v=4"></script>
|
<script src="/static/account_risk_badge.js?v=4"></script>
|
||||||
<meta name="theme-color" content="#0b0d14">
|
<meta name="theme-color" content="#0b0d14">
|
||||||
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
|
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
|
||||||
@@ -120,6 +121,7 @@
|
|||||||
<script src="/static/manual_order_rr_preview.js?v=5"></script>
|
<script src="/static/manual_order_rr_preview.js?v=5"></script>
|
||||||
<script src="/static/strategy_roll.js?v=5"></script>
|
<script src="/static/strategy_roll.js?v=5"></script>
|
||||||
<script src="/static/key_monitor_form.js?v=2"></script>
|
<script src="/static/key_monitor_form.js?v=2"></script>
|
||||||
|
<script src="/static/trade_stats_calendar.js?v=1"></script>
|
||||||
{% include 'embed_boot_scripts.html' %}
|
{% include 'embed_boot_scripts.html' %}
|
||||||
<script src="/static/instance_embed.js?v=4"></script>
|
<script src="/static/instance_embed.js?v=4"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ def install_instance_theme_static(app) -> None:
|
|||||||
"instance_embed.js": "application/javascript; charset=utf-8",
|
"instance_embed.js": "application/javascript; charset=utf-8",
|
||||||
"focus_chart_page.js": "application/javascript; charset=utf-8",
|
"focus_chart_page.js": "application/javascript; charset=utf-8",
|
||||||
"focus_chart_page.css": "text/css; charset=utf-8",
|
"focus_chart_page.css": "text/css; charset=utf-8",
|
||||||
|
"trade_stats_calendar.js": "application/javascript; charset=utf-8",
|
||||||
|
"trade_stats_calendar.css": "text/css; charset=utf-8",
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, mime in assets.items():
|
for name, mime in assets.items():
|
||||||
@@ -83,6 +85,53 @@ def install_instance_theme_static(app) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_trade_stats_calendar_route(
|
||||||
|
app,
|
||||||
|
*,
|
||||||
|
login_required_fn,
|
||||||
|
load_pnls_fn,
|
||||||
|
row_matches_segment_fn,
|
||||||
|
reset_hour: int,
|
||||||
|
):
|
||||||
|
"""四所统计分析页:按月返回各交易日盈亏/笔数。"""
|
||||||
|
from flask import jsonify, request
|
||||||
|
|
||||||
|
from trade_stats_calendar_lib import build_trade_stats_calendar
|
||||||
|
|
||||||
|
@app.route("/api/stats/calendar")
|
||||||
|
@login_required_fn
|
||||||
|
def api_stats_calendar():
|
||||||
|
year = request.args.get("year", type=int)
|
||||||
|
month = request.args.get("month", type=int)
|
||||||
|
segment = (request.args.get("segment") or "all").strip() or "all"
|
||||||
|
if not year or not month:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
year = year or now.year
|
||||||
|
month = month or now.month
|
||||||
|
get_db = (app.config.get("HUB_CTX") or {}).get("get_db")
|
||||||
|
if not get_db:
|
||||||
|
return jsonify({"ok": False, "msg": "未配置数据库"}), 500
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
pnls = load_pnls_fn(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
try:
|
||||||
|
payload = build_trade_stats_calendar(
|
||||||
|
pnls,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
segment,
|
||||||
|
row_matches_segment_fn,
|
||||||
|
reset_hour=int(reset_hour),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"ok": False, "msg": str(exc)}), 400
|
||||||
|
return jsonify({"ok": True, **payload})
|
||||||
|
|
||||||
|
|
||||||
def _hub_auth_required(f):
|
def _hub_auth_required(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def wrapped(*args, **kwargs):
|
def wrapped(*args, **kwargs):
|
||||||
|
|||||||
@@ -564,6 +564,8 @@ app = FastAPI(title="复盘系统中控", docs_url=None, redoc_url=None, lifespa
|
|||||||
STATIC_DIR = DIR / "static"
|
STATIC_DIR = DIR / "static"
|
||||||
_REPO_STATIC = _REPO_ROOT / "static"
|
_REPO_STATIC = _REPO_ROOT / "static"
|
||||||
_AI_REVIEW_RENDER_JS = _REPO_STATIC / "ai_review_render.js"
|
_AI_REVIEW_RENDER_JS = _REPO_STATIC / "ai_review_render.js"
|
||||||
|
_TRADE_STATS_CALENDAR_CSS = _REPO_STATIC / "trade_stats_calendar.css"
|
||||||
|
_TRADE_STATS_CALENDAR_JS = _REPO_STATIC / "trade_stats_calendar.js"
|
||||||
_ACCOUNT_RISK_BADGE_CSS = _REPO_STATIC / "account_risk_badge.css"
|
_ACCOUNT_RISK_BADGE_CSS = _REPO_STATIC / "account_risk_badge.css"
|
||||||
_ACCOUNT_RISK_BADGE_JS = _REPO_STATIC / "account_risk_badge.js"
|
_ACCOUNT_RISK_BADGE_JS = _REPO_STATIC / "account_risk_badge.js"
|
||||||
|
|
||||||
@@ -601,6 +603,26 @@ def hub_ai_review_render_js():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/assets/trade_stats_calendar.css")
|
||||||
|
def hub_trade_stats_calendar_css():
|
||||||
|
if not _TRADE_STATS_CALENDAR_CSS.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="trade_stats_calendar.css not found")
|
||||||
|
return FileResponse(
|
||||||
|
str(_TRADE_STATS_CALENDAR_CSS),
|
||||||
|
media_type="text/css; charset=utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/assets/trade_stats_calendar.js")
|
||||||
|
def hub_trade_stats_calendar_js():
|
||||||
|
if not _TRADE_STATS_CALENDAR_JS.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="trade_stats_calendar.js not found")
|
||||||
|
return FileResponse(
|
||||||
|
str(_TRADE_STATS_CALENDAR_JS),
|
||||||
|
media_type="application/javascript; charset=utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if STATIC_DIR.is_dir():
|
if STATIC_DIR.is_dir():
|
||||||
app.mount("/assets", StaticFiles(directory=str(STATIC_DIR)), name="assets")
|
app.mount("/assets", StaticFiles(directory=str(STATIC_DIR)), name="assets")
|
||||||
|
|
||||||
|
|||||||
@@ -6460,6 +6460,22 @@ body.funds-fullscreen-open {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
.archive-stats-card {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.archive-stats-card-head h2 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.archive-stats-card .archive-stats-bar {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
.archive-overview-panel {
|
.archive-overview-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -6703,107 +6719,6 @@ body.funds-fullscreen-open {
|
|||||||
.archive-trades-table td.neg {
|
.archive-trades-table td.neg {
|
||||||
color: #ef4444;
|
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 {
|
.archive-del-btn {
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
@@ -6875,13 +6790,10 @@ body.funds-fullscreen-open {
|
|||||||
order: 2;
|
order: 2;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
gap: 0;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
#page-archive .archive-overview-panel {
|
#page-archive .archive-stats-card {
|
||||||
width: 100%;
|
margin-bottom: 10px;
|
||||||
}
|
|
||||||
#page-archive .archive-overview-panel > .archive-panel-head {
|
|
||||||
display: flex;
|
|
||||||
}
|
}
|
||||||
#page-archive .archive-quotes-list {
|
#page-archive .archive-quotes-list {
|
||||||
min-height: 120px;
|
min-height: 120px;
|
||||||
|
|||||||
@@ -77,9 +77,7 @@
|
|||||||
let chartExchangeSymbol = "";
|
let chartExchangeSymbol = "";
|
||||||
let chartMarketType = "swap";
|
let chartMarketType = "swap";
|
||||||
let searchTimer = null;
|
let searchTimer = null;
|
||||||
let calendarYear = 0;
|
let calendarWidget = null;
|
||||||
let calendarMonth = 0;
|
|
||||||
let calendarDays = {};
|
|
||||||
let selectedCalendarDay = "";
|
let selectedCalendarDay = "";
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
@@ -508,133 +506,54 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function calendarMonthLabel(y, m) {
|
function calendarRefDate() {
|
||||||
return y + " 年 " + m + " 月";
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureCalendarMonthFromUI() {
|
|
||||||
if (calendarYear > 0 && calendarMonth > 0) return;
|
|
||||||
let ref = tradingDay || (elTradingDay && elTradingDay.value) || "";
|
let ref = tradingDay || (elTradingDay && elTradingDay.value) || "";
|
||||||
if (!ref && dateFrom) ref = dateFrom;
|
if (!ref && dateFrom) ref = dateFrom;
|
||||||
if (!ref) {
|
return ref || new Date();
|
||||||
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() {
|
function ensureCalendarWidget() {
|
||||||
if (!elCalendar || !elCalTitle) return;
|
if (calendarWidget || !window.TradeStatsCalendar || !elCalendar) return calendarWidget;
|
||||||
ensureCalendarMonthFromUI();
|
calendarWidget = new TradeStatsCalendar({
|
||||||
elCalTitle.textContent = calendarMonthLabel(calendarYear, calendarMonth);
|
gridEl: elCalendar,
|
||||||
const first = new Date(calendarYear, calendarMonth - 1, 1);
|
titleEl: elCalTitle,
|
||||||
const lastDay = new Date(calendarYear, calendarMonth, 0).getDate();
|
prevBtn: elCalPrev,
|
||||||
const startWd = first.getDay();
|
nextBtn: elCalNext,
|
||||||
const weekdays = ["日", "一", "二", "三", "四", "五", "六"];
|
showSick: true,
|
||||||
let html =
|
buildQuery: function (year, month) {
|
||||||
'<div class="archive-cal-weekdays">' +
|
const q = new URLSearchParams();
|
||||||
weekdays.map(function (w) {
|
q.set("year", String(year));
|
||||||
return '<span class="archive-cal-wd">' + w + "</span>";
|
q.set("month", String(month));
|
||||||
}).join("") +
|
const ex = (elExchange && elExchange.value) || "";
|
||||||
"</div><div class=\"archive-cal-grid\">";
|
if (ex) q.set("exchange_key", ex);
|
||||||
for (let i = 0; i < startWd; i++) {
|
return q;
|
||||||
html += '<span class="archive-cal-cell archive-cal-pad"></span>';
|
},
|
||||||
}
|
fetchFn: async function (q) {
|
||||||
for (let d = 1; d <= lastDay; d++) {
|
const r = await apiFetch("/api/archive/calendar?" + q.toString());
|
||||||
const dayStr =
|
return r.json();
|
||||||
calendarYear +
|
},
|
||||||
"-" +
|
parseResponse: function (data) {
|
||||||
String(calendarMonth).padStart(2, "0") +
|
if (!data || !data.ok) return {};
|
||||||
"-" +
|
return data.days || {};
|
||||||
String(d).padStart(2, "0");
|
},
|
||||||
const info = calendarDays[dayStr];
|
onDayClick: function (day, sick) {
|
||||||
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;
|
selectedCalendarDay = day;
|
||||||
setPeriodMode("today");
|
setPeriodMode("today");
|
||||||
if (elTradingDay) elTradingDay.value = day;
|
if (elTradingDay) elTradingDay.value = day;
|
||||||
if (elFilterSick) {
|
if (elFilterSick) elFilterSick.checked = sick;
|
||||||
elFilterSick.checked = btn.getAttribute("data-sick") === "1";
|
|
||||||
}
|
|
||||||
syncPeriodUI();
|
syncPeriodUI();
|
||||||
void loadDailyTrades();
|
void loadDailyTrades();
|
||||||
renderCalendar();
|
},
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
calendarWidget.ensureMonth(calendarRefDate());
|
||||||
|
return calendarWidget;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCalendar() {
|
async function loadCalendar() {
|
||||||
ensureCalendarMonthFromUI();
|
const cal = ensureCalendarWidget();
|
||||||
const q = new URLSearchParams();
|
if (!cal) return;
|
||||||
q.set("year", String(calendarYear));
|
cal.selectedDay = selectedCalendarDay;
|
||||||
q.set("month", String(calendarMonth));
|
await cal.load();
|
||||||
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() {
|
function renderStats() {
|
||||||
@@ -1527,7 +1446,10 @@
|
|||||||
syncPeriodUI();
|
syncPeriodUI();
|
||||||
dailyTrades = j.trades || [];
|
dailyTrades = j.trades || [];
|
||||||
dailyStats = j.stats || { open_count: 0, by_exchange: {} };
|
dailyStats = j.stats || { open_count: 0, by_exchange: {} };
|
||||||
if (periodMode === "today" && tradingDay) selectedCalendarDay = tradingDay;
|
if (periodMode === "today" && tradingDay) {
|
||||||
|
selectedCalendarDay = tradingDay;
|
||||||
|
if (calendarWidget) calendarWidget.selectedDay = tradingDay;
|
||||||
|
}
|
||||||
renderStats();
|
renderStats();
|
||||||
renderTrades();
|
renderTrades();
|
||||||
void loadCalendar();
|
void loadCalendar();
|
||||||
@@ -1600,8 +1522,6 @@
|
|||||||
void loadCalendar();
|
void loadCalendar();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (elCalPrev) elCalPrev.addEventListener("click", function () { shiftCalendarMonth(-1); });
|
|
||||||
if (elCalNext) elCalNext.addEventListener("click", function () { shiftCalendarMonth(1); });
|
|
||||||
if (elPeriodTabs) {
|
if (elPeriodTabs) {
|
||||||
elPeriodTabs.addEventListener("click", function (ev) {
|
elPeriodTabs.addEventListener("click", function (ev) {
|
||||||
const btn = ev.target.closest(".archive-period-btn");
|
const btn = ev.target.closest(".archive-period-btn");
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
||||||
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
||||||
<link rel="stylesheet" href="/assets/app.css?v=20260614-plan-detail" />
|
<link rel="stylesheet" href="/assets/app.css?v=20260614-plan-detail" />
|
||||||
|
<link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=1" />
|
||||||
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
|
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
|
||||||
<script src="/assets/account_risk_badge.js?v=4"></script>
|
<script src="/assets/account_risk_badge.js?v=4"></script>
|
||||||
<link rel="stylesheet" href="/assets/dashboard.css?v=20260612-dash-monitor-count" />
|
<link rel="stylesheet" href="/assets/dashboard.css?v=20260612-dash-monitor-count" />
|
||||||
@@ -469,6 +470,12 @@
|
|||||||
<button type="button" id="archive-btn-sync" class="ghost">同步</button>
|
<button type="button" id="archive-btn-sync" class="ghost">同步</button>
|
||||||
<span id="archive-status" class="toolbar-meta"></span>
|
<span id="archive-status" class="toolbar-meta"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<section class="archive-stats-card card">
|
||||||
|
<div class="archive-stats-card-head">
|
||||||
|
<h2>统计分析</h2>
|
||||||
|
</div>
|
||||||
|
<div id="archive-stats" class="archive-stats-bar"></div>
|
||||||
|
</section>
|
||||||
<div class="archive-layout">
|
<div class="archive-layout">
|
||||||
<aside class="archive-quotes-panel">
|
<aside class="archive-quotes-panel">
|
||||||
<div class="archive-panel-head">
|
<div class="archive-panel-head">
|
||||||
@@ -483,20 +490,14 @@
|
|||||||
<div id="archive-quotes-list" class="archive-quotes-list"></div>
|
<div id="archive-quotes-list" class="archive-quotes-list"></div>
|
||||||
</aside>
|
</aside>
|
||||||
<main class="archive-main-panel">
|
<main class="archive-main-panel">
|
||||||
<section class="archive-overview-panel">
|
<div id="archive-calendar-wrap" class="trade-cal-wrap">
|
||||||
<div class="archive-panel-head">
|
<div class="trade-cal-head">
|
||||||
<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>
|
<button type="button" id="archive-cal-prev" class="ghost" title="上一月">‹</button>
|
||||||
<span id="archive-cal-title" class="archive-cal-title"></span>
|
<span id="archive-cal-title" class="trade-cal-title"></span>
|
||||||
<button type="button" id="archive-cal-next" class="ghost" title="下一月">›</button>
|
<button type="button" id="archive-cal-next" class="ghost" title="下一月">›</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="archive-calendar" class="archive-calendar" role="grid" aria-label="交易日历"></div>
|
<div id="archive-calendar" class="trade-cal-grid-host" role="grid" aria-label="交易日历"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
<details id="archive-chart-section" class="archive-acc-section archive-chart-section archive-panel-desktop">
|
<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>
|
<summary class="archive-acc-summary">K 线图表 <span id="archive-chart-title" class="archive-acc-sub">—</span></summary>
|
||||||
<div class="archive-chart-toolbar toolbar">
|
<div class="archive-chart-toolbar toolbar">
|
||||||
@@ -1057,7 +1058,8 @@
|
|||||||
<script src="/assets/chart.js?v=20260626-market-tail-patch"></script>
|
<script src="/assets/chart.js?v=20260626-market-tail-patch"></script>
|
||||||
<script src="/assets/plan.js?v=20260614-plan-refresh"></script>
|
<script src="/assets/plan.js?v=20260614-plan-refresh"></script>
|
||||||
<script src="/assets/calculator.js?v=3"></script>
|
<script src="/assets/calculator.js?v=3"></script>
|
||||||
<script src="/assets/archive.js?v=20260612-archive-ai-chat"></script>
|
<script src="/assets/trade_stats_calendar.js?v=1"></script>
|
||||||
|
<script src="/assets/archive.js?v=20260626-archive-layout"></script>
|
||||||
<script src="/assets/funds.js?v=20260609-hub-funds-fold"></script>
|
<script src="/assets/funds.js?v=20260609-hub-funds-fold"></script>
|
||||||
<script src="/assets/dashboard.js?v=20260612-dash-monitor-count"></script>
|
<script src="/assets/dashboard.js?v=20260612-dash-monitor-count"></script>
|
||||||
<script src="/assets/ai_review_render.js?v=3"></script>
|
<script src="/assets/ai_review_render.js?v=3"></script>
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/* 交易日历:内照明心 + 四所统计分析共用,随 data-theme 浅/深切换 */
|
||||||
|
.trade-cal-wrap {
|
||||||
|
--trade-cal-wrap-bg: var(--inset-surface, rgba(0, 0, 0, 0.22));
|
||||||
|
--trade-cal-cell-bg: var(--section-surface, var(--inset-surface, rgba(0, 0, 0, 0.32)));
|
||||||
|
--trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #6366f1) 12%, var(--trade-cal-cell-bg));
|
||||||
|
--trade-cal-cell-hover-border: color-mix(in srgb, var(--accent, #6366f1) 45%, transparent);
|
||||||
|
--trade-cal-selected-border: color-mix(in srgb, var(--accent, #6366f1) 75%, transparent);
|
||||||
|
--trade-cal-selected-shadow: color-mix(in srgb, var(--accent, #6366f1) 35%, transparent);
|
||||||
|
--trade-cal-sick-bg: color-mix(in srgb, var(--red, #ef4444) 14%, var(--trade-cal-cell-bg));
|
||||||
|
--trade-cal-sick-border: color-mix(in srgb, var(--red, #ef4444) 55%, transparent);
|
||||||
|
--trade-cal-sick-shadow: color-mix(in srgb, var(--red, #ef4444) 45%, transparent);
|
||||||
|
--trade-cal-sick-tag-bg: color-mix(in srgb, var(--red, #ef4444) 25%, transparent);
|
||||||
|
--trade-cal-sick-tag-fg: color-mix(in srgb, var(--red, #ef4444) 70%, #fff);
|
||||||
|
--trade-cal-pos: var(--green, #22c55e);
|
||||||
|
--trade-cal-neg: var(--red, #ef4444);
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border-soft, rgba(120, 140, 200, 0.28));
|
||||||
|
background: var(--trade-cal-wrap-bg);
|
||||||
|
}
|
||||||
|
.stats-calendar-wrap {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.trade-cal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.trade-cal-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 120px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text, #e8ecff);
|
||||||
|
}
|
||||||
|
.trade-cal-weekdays {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.trade-cal-wd {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--muted, #8892b0);
|
||||||
|
}
|
||||||
|
.trade-cal-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.trade-cal-cell {
|
||||||
|
min-height: 62px;
|
||||||
|
padding: 4px 3px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: var(--trade-cal-cell-bg);
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: default;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.trade-cal-cell.has-trade {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.trade-cal-cell.has-trade:hover {
|
||||||
|
border-color: var(--trade-cal-cell-hover-border);
|
||||||
|
background: var(--trade-cal-cell-hover-bg);
|
||||||
|
}
|
||||||
|
.trade-cal-cell.is-selected {
|
||||||
|
border-color: var(--trade-cal-selected-border);
|
||||||
|
box-shadow: 0 0 0 1px var(--trade-cal-selected-shadow);
|
||||||
|
}
|
||||||
|
.trade-cal-cell.is-sick-day {
|
||||||
|
border-color: var(--trade-cal-sick-border);
|
||||||
|
background: var(--trade-cal-sick-bg);
|
||||||
|
}
|
||||||
|
.trade-cal-cell.is-sick-day.is-selected {
|
||||||
|
box-shadow: 0 0 0 2px var(--trade-cal-sick-shadow);
|
||||||
|
}
|
||||||
|
.trade-cal-cell.pnl-pos .trade-cal-pnl {
|
||||||
|
color: var(--trade-cal-pos);
|
||||||
|
}
|
||||||
|
.trade-cal-cell.pnl-neg .trade-cal-pnl {
|
||||||
|
color: var(--trade-cal-neg);
|
||||||
|
}
|
||||||
|
.trade-cal-day-num {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text, #e8ecff);
|
||||||
|
}
|
||||||
|
.trade-cal-pnl {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.trade-cal-cnt {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--muted, #8892b0);
|
||||||
|
}
|
||||||
|
.trade-cal-sick-tag {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--trade-cal-sick-tag-bg);
|
||||||
|
color: var(--trade-cal-sick-tag-fg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.trade-cal-pad {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .trade-cal-wrap {
|
||||||
|
--trade-cal-wrap-bg: var(--inset-surface, #eef3f8);
|
||||||
|
--trade-cal-cell-bg: var(--section-surface, #f6f9fc);
|
||||||
|
--trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #2563eb) 10%, #f6f9fc);
|
||||||
|
--trade-cal-sick-tag-fg: #b91c1c;
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* 交易日历组件:内照明心档案 + 四所统计分析共用。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthLabel(y, m) {
|
||||||
|
return y + "年" + m + "月";
|
||||||
|
}
|
||||||
|
|
||||||
|
function TradeStatsCalendar(config) {
|
||||||
|
this.gridEl = config.gridEl;
|
||||||
|
this.titleEl = config.titleEl;
|
||||||
|
this.prevBtn = config.prevBtn || null;
|
||||||
|
this.nextBtn = config.nextBtn || null;
|
||||||
|
this.apiUrl = config.apiUrl || "/api/stats/calendar";
|
||||||
|
this.buildQuery =
|
||||||
|
config.buildQuery ||
|
||||||
|
function (year, month) {
|
||||||
|
var q = new URLSearchParams();
|
||||||
|
q.set("year", String(year));
|
||||||
|
q.set("month", String(month));
|
||||||
|
return q;
|
||||||
|
};
|
||||||
|
this.parseResponse =
|
||||||
|
config.parseResponse ||
|
||||||
|
function (data) {
|
||||||
|
if (data && data.ok === false) return {};
|
||||||
|
return (data && data.days) || {};
|
||||||
|
};
|
||||||
|
this.fetchFn = config.fetchFn || null;
|
||||||
|
this.showSick = config.showSick !== false;
|
||||||
|
this.selectedDay = config.selectedDay || "";
|
||||||
|
this.onDayClick = config.onDayClick || null;
|
||||||
|
this.onMonthChange = config.onMonthChange || null;
|
||||||
|
this.year = config.year || 0;
|
||||||
|
this.month = config.month || 0;
|
||||||
|
this.days = {};
|
||||||
|
this._navBound = false;
|
||||||
|
this._bindNav();
|
||||||
|
}
|
||||||
|
|
||||||
|
TradeStatsCalendar.prototype.ensureMonth = function (ref) {
|
||||||
|
if (this.year > 0 && this.month > 0) return;
|
||||||
|
var d;
|
||||||
|
if (ref instanceof Date) d = ref;
|
||||||
|
else if (typeof ref === "string" && ref.length >= 7) {
|
||||||
|
var p = ref.slice(0, 10).split("-");
|
||||||
|
this.year = parseInt(p[0], 10) || new Date().getFullYear();
|
||||||
|
this.month = parseInt(p[1], 10) || new Date().getMonth() + 1;
|
||||||
|
return;
|
||||||
|
} else d = new Date();
|
||||||
|
this.year = d.getFullYear();
|
||||||
|
this.month = d.getMonth() + 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
TradeStatsCalendar.prototype.setSelectedDay = function (day) {
|
||||||
|
this.selectedDay = day || "";
|
||||||
|
this.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
TradeStatsCalendar.prototype.render = function () {
|
||||||
|
if (!this.gridEl || !this.titleEl) return;
|
||||||
|
this.ensureMonth(new Date());
|
||||||
|
this.titleEl.textContent = monthLabel(this.year, this.month);
|
||||||
|
var first = new Date(this.year, this.month - 1, 1);
|
||||||
|
var lastDay = new Date(this.year, this.month, 0).getDate();
|
||||||
|
var startWd = first.getDay();
|
||||||
|
var html =
|
||||||
|
'<div class="trade-cal-weekdays">' +
|
||||||
|
WEEKDAYS.map(function (w) {
|
||||||
|
return '<span class="trade-cal-wd">' + w + "</span>";
|
||||||
|
}).join("") +
|
||||||
|
'</div><div class="trade-cal-grid">';
|
||||||
|
var i;
|
||||||
|
for (i = 0; i < startWd; i++) {
|
||||||
|
html += '<span class="trade-cal-cell trade-cal-pad"></span>';
|
||||||
|
}
|
||||||
|
for (var d = 1; d <= lastDay; d++) {
|
||||||
|
var dayStr =
|
||||||
|
this.year +
|
||||||
|
"-" +
|
||||||
|
String(this.month).padStart(2, "0") +
|
||||||
|
"-" +
|
||||||
|
String(d).padStart(2, "0");
|
||||||
|
var info = this.days[dayStr];
|
||||||
|
var hasTrade = info && info.open_count > 0;
|
||||||
|
var sick = this.showSick && info && info.has_sick;
|
||||||
|
var pnl = hasTrade ? Number(info.pnl_total) : null;
|
||||||
|
var cnt = hasTrade ? info.open_count : 0;
|
||||||
|
var cls =
|
||||||
|
"trade-cal-cell" +
|
||||||
|
(hasTrade ? " has-trade" : "") +
|
||||||
|
(sick ? " is-sick-day" : "") +
|
||||||
|
(this.selectedDay === dayStr ? " is-selected" : "") +
|
||||||
|
(pnl != null && pnl > 0.0001
|
||||||
|
? " pnl-pos"
|
||||||
|
: pnl != null && pnl < -0.0001
|
||||||
|
? " pnl-neg"
|
||||||
|
: "");
|
||||||
|
var body = '<span class="trade-cal-day-num">' + d + "</span>";
|
||||||
|
if (hasTrade) {
|
||||||
|
var pnlTxt = (pnl >= 0 ? "+" : "") + pnl.toFixed(1);
|
||||||
|
body +=
|
||||||
|
'<span class="trade-cal-pnl">' +
|
||||||
|
esc(pnlTxt) +
|
||||||
|
"</span>" +
|
||||||
|
'<span class="trade-cal-cnt">' +
|
||||||
|
cnt +
|
||||||
|
"笔</span>";
|
||||||
|
if (sick) body += '<span class="trade-cal-sick-tag">犯病</span>';
|
||||||
|
}
|
||||||
|
html +=
|
||||||
|
'<button type="button" class="' +
|
||||||
|
cls +
|
||||||
|
'" data-day="' +
|
||||||
|
dayStr +
|
||||||
|
'" data-sick="' +
|
||||||
|
(sick ? "1" : "0") +
|
||||||
|
'"' +
|
||||||
|
(hasTrade ? "" : " disabled") +
|
||||||
|
">" +
|
||||||
|
body +
|
||||||
|
"</button>";
|
||||||
|
}
|
||||||
|
html += "</div>";
|
||||||
|
this.gridEl.innerHTML = html;
|
||||||
|
var self = this;
|
||||||
|
this.gridEl.querySelectorAll(".trade-cal-cell[data-day]").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
var day = btn.getAttribute("data-day");
|
||||||
|
if (!day || !self.onDayClick) return;
|
||||||
|
self.selectedDay = day;
|
||||||
|
self.render();
|
||||||
|
self.onDayClick(day, btn.getAttribute("data-sick") === "1", self.days[day] || null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
TradeStatsCalendar.prototype.load = async function () {
|
||||||
|
this.ensureMonth(new Date());
|
||||||
|
var q = this.buildQuery(this.year, this.month);
|
||||||
|
if (!q.has("year")) q.set("year", String(this.year));
|
||||||
|
if (!q.has("month")) q.set("month", String(this.month));
|
||||||
|
try {
|
||||||
|
var data;
|
||||||
|
if (this.fetchFn) {
|
||||||
|
data = await this.fetchFn(q);
|
||||||
|
} else {
|
||||||
|
var resp = await fetch(this.apiUrl + "?" + q.toString(), {
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
data = await resp.json();
|
||||||
|
}
|
||||||
|
this.days = this.parseResponse(data) || {};
|
||||||
|
this.render();
|
||||||
|
if (this.onMonthChange) this.onMonthChange(this.year, this.month, this.days);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[trade calendar]", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
TradeStatsCalendar.prototype.shiftMonth = function (delta) {
|
||||||
|
this.ensureMonth(new Date());
|
||||||
|
this.month += delta;
|
||||||
|
if (this.month > 12) {
|
||||||
|
this.month = 1;
|
||||||
|
this.year += 1;
|
||||||
|
} else if (this.month < 1) {
|
||||||
|
this.month = 12;
|
||||||
|
this.year -= 1;
|
||||||
|
}
|
||||||
|
void this.load();
|
||||||
|
};
|
||||||
|
|
||||||
|
TradeStatsCalendar.prototype._bindNav = function () {
|
||||||
|
if (this._navBound) return;
|
||||||
|
var self = this;
|
||||||
|
if (this.prevBtn) {
|
||||||
|
this.prevBtn.addEventListener("click", function () {
|
||||||
|
self.shiftMonth(-1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this.nextBtn) {
|
||||||
|
this.nextBtn.addEventListener("click", function () {
|
||||||
|
self.shiftMonth(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this._navBound = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
global.TradeStatsCalendar = TradeStatsCalendar;
|
||||||
|
})(window);
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from trade_stats_calendar_lib import build_trade_stats_calendar
|
||||||
|
|
||||||
|
|
||||||
|
def _row(**kwargs):
|
||||||
|
base = {
|
||||||
|
"monitor_type": "",
|
||||||
|
"key_signal_type": "",
|
||||||
|
"exchange_turnover_usdt": None,
|
||||||
|
"exchange_commission_usdt": None,
|
||||||
|
}
|
||||||
|
base.update(kwargs)
|
||||||
|
return SimpleNamespace(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_all(row, segment_key):
|
||||||
|
return segment_key == "all"
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_manual(row, segment_key):
|
||||||
|
if segment_key == "all":
|
||||||
|
return True
|
||||||
|
if segment_key == "manual":
|
||||||
|
return (row.monitor_type or "").strip() == "手动" and not (row.key_signal_type or "").strip()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class TradeStatsCalendarLibTests(unittest.TestCase):
|
||||||
|
def test_groups_by_trading_day_and_segment(self):
|
||||||
|
pnls = [
|
||||||
|
(10.0, None, "2026-06-18", _row(monitor_type="手动")),
|
||||||
|
(-3.0, None, "2026-06-18", _row(monitor_type="手动")),
|
||||||
|
(5.0, None, "2026-06-19", _row(monitor_type="自动", key_signal_type="箱体突破")),
|
||||||
|
]
|
||||||
|
payload = build_trade_stats_calendar(
|
||||||
|
pnls,
|
||||||
|
2026,
|
||||||
|
6,
|
||||||
|
"manual",
|
||||||
|
_matches_manual,
|
||||||
|
reset_hour=8,
|
||||||
|
)
|
||||||
|
self.assertEqual(payload["month"], 6)
|
||||||
|
self.assertEqual(payload["month_open_count"], 2)
|
||||||
|
days = payload["days"]
|
||||||
|
self.assertIn("2026-06-18", days)
|
||||||
|
self.assertNotIn("2026-06-19", days)
|
||||||
|
self.assertEqual(days["2026-06-18"]["open_count"], 2)
|
||||||
|
self.assertAlmostEqual(days["2026-06-18"]["pnl_total"], 7.0)
|
||||||
|
|
||||||
|
def test_invalid_month_raises(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
build_trade_stats_calendar([], 2026, 13, "all", _matches_all)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""按交易日聚合实例 trade_records 盈亏,供统计分析页日历 API 使用。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
|
def build_trade_stats_calendar(
|
||||||
|
pnls: list[tuple],
|
||||||
|
year: int,
|
||||||
|
month: int,
|
||||||
|
segment_key: str,
|
||||||
|
row_matches_fn: Callable[[Any, str], bool],
|
||||||
|
*,
|
||||||
|
reset_hour: int = 8,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""pnls: _load_completed_trade_pnls 返回值 (pnl, close_dt, trading_day, row)。"""
|
||||||
|
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")
|
||||||
|
seg = (segment_key or "all").strip() or "all"
|
||||||
|
days: dict[str, dict[str, Any]] = {}
|
||||||
|
for pnl, _close_dt, td, row in pnls:
|
||||||
|
if not td or td < first or td > last:
|
||||||
|
continue
|
||||||
|
if not row_matches_fn(row, seg):
|
||||||
|
continue
|
||||||
|
bucket = days.setdefault(
|
||||||
|
td,
|
||||||
|
{
|
||||||
|
"trading_day": td,
|
||||||
|
"open_count": 0,
|
||||||
|
"pnl_total": 0.0,
|
||||||
|
"turnover_total": 0.0,
|
||||||
|
"commission_total": 0.0,
|
||||||
|
"has_sick": False,
|
||||||
|
"sick_count": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
bucket["open_count"] += 1
|
||||||
|
bucket["pnl_total"] += float(pnl or 0)
|
||||||
|
try:
|
||||||
|
bucket["turnover_total"] += float(row["exchange_turnover_usdt"] or 0)
|
||||||
|
except (TypeError, ValueError, KeyError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
bucket["commission_total"] += float(row["exchange_commission_usdt"] or 0)
|
||||||
|
except (TypeError, ValueError, KeyError):
|
||||||
|
pass
|
||||||
|
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,
|
||||||
|
"segment": seg,
|
||||||
|
"reset_hour": int(reset_hour),
|
||||||
|
"days": days,
|
||||||
|
"month_pnl_total": round(month_pnl, 4),
|
||||||
|
"month_open_count": month_count,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user