4f784d09ac
日历格子重置实例全局 button 样式,日期格展示 +X.XU 与 N 笔,标题栏汇总当月盈亏与总笔数。 Co-authored-by: Cursor <cursoragent@cursor.com>
283 lines
8.7 KiB
JavaScript
283 lines
8.7 KiB
JavaScript
/**
|
|
* 交易日历组件:内照明心档案 + 四所统计分析共用。
|
|
*/
|
|
(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 formatCalPnl(pnl) {
|
|
var n = Number(pnl);
|
|
if (!Number.isFinite(n)) n = 0;
|
|
return (n >= 0 ? "+" : "") + n.toFixed(1) + "U";
|
|
}
|
|
|
|
function dayHasTrade(info) {
|
|
if (!info) return false;
|
|
var cnt = Number(info.open_count);
|
|
if (Number.isFinite(cnt) && cnt > 0) return true;
|
|
var pnl = Number(info.pnl_total);
|
|
return Number.isFinite(pnl) && Math.abs(pnl) > 0.0001;
|
|
}
|
|
|
|
function dayOpenCount(info) {
|
|
var cnt = Number(info && info.open_count);
|
|
return Number.isFinite(cnt) && cnt > 0 ? cnt : 0;
|
|
}
|
|
|
|
function dayPnl(info) {
|
|
return Number(info && info.pnl_total) || 0;
|
|
}
|
|
|
|
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.monthPnlTotal = 0;
|
|
this.monthOpenCount = 0;
|
|
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;
|
|
if (this.year <= 0 || this.month <= 0) this.ensureMonth(new Date());
|
|
var title = monthLabel(this.year, this.month);
|
|
if (this.monthOpenCount > 0) {
|
|
title +=
|
|
" · " + formatCalPnl(this.monthPnlTotal) + " · " + this.monthOpenCount + "笔";
|
|
}
|
|
this.titleEl.textContent = title;
|
|
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 = dayHasTrade(info);
|
|
var sick = this.showSick && info && info.has_sick;
|
|
var pnl = hasTrade ? dayPnl(info) : null;
|
|
var cnt = hasTrade ? dayOpenCount(info) : 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) {
|
|
body +=
|
|
'<span class="trade-cal-pnl">' +
|
|
esc(formatCalPnl(pnl)) +
|
|
"</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());
|
|
this.render();
|
|
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",
|
|
});
|
|
if (!resp.ok) {
|
|
console.warn("[trade calendar] api", resp.status);
|
|
return;
|
|
}
|
|
data = await resp.json();
|
|
}
|
|
this.days = this.parseResponse(data) || {};
|
|
this.monthPnlTotal = Number(data && data.month_pnl_total) || 0;
|
|
this.monthOpenCount = Number(data && data.month_open_count) || 0;
|
|
if (!this.monthOpenCount) {
|
|
var self = this;
|
|
Object.keys(this.days).forEach(function (k) {
|
|
if (dayHasTrade(self.days[k])) {
|
|
self.monthOpenCount += dayOpenCount(self.days[k]);
|
|
self.monthPnlTotal += dayPnl(self.days[k]);
|
|
}
|
|
});
|
|
this.monthPnlTotal = Math.round(this.monthPnlTotal * 10000) / 10000;
|
|
}
|
|
this.render();
|
|
if (this.onMonthChange) this.onMonthChange(this.year, this.month, this.days);
|
|
} catch (e) {
|
|
console.warn("[trade calendar]", e);
|
|
this.render();
|
|
}
|
|
};
|
|
|
|
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;
|
|
|
|
global.statsCalendarWidget = null;
|
|
|
|
global.initInstanceStatsCalendar = function () {
|
|
var grid = document.getElementById("stats-calendar");
|
|
if (!grid || !global.TradeStatsCalendar) return null;
|
|
global.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) {
|
|
var q = new URLSearchParams();
|
|
q.set("year", String(year));
|
|
q.set("month", String(month));
|
|
var 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) || {};
|
|
},
|
|
});
|
|
global.statsCalendarWidget.render();
|
|
void global.statsCalendarWidget.load();
|
|
return global.statsCalendarWidget;
|
|
};
|
|
|
|
global.initStatsCalendarWidget = global.initInstanceStatsCalendar;
|
|
})(window);
|