koishi-plugin-monetary-bourse 1.0.0 → 1.1.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.d.ts +1 -0
- package/lib/index.js +445 -43
- package/package.json +1 -1
- package/readme.md +14 -4
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -63,7 +63,9 @@ function apply(ctx, config) {
|
|
|
63
63
|
id: "unsigned",
|
|
64
64
|
userId: "string",
|
|
65
65
|
stockId: "string",
|
|
66
|
-
amount: "integer"
|
|
66
|
+
amount: "integer",
|
|
67
|
+
totalCost: "double"
|
|
68
|
+
// 买入总成本
|
|
67
69
|
}, { primary: ["userId", "stockId"] });
|
|
68
70
|
ctx.model.extend("bourse_pending", {
|
|
69
71
|
id: "unsigned",
|
|
@@ -94,11 +96,11 @@ function apply(ctx, config) {
|
|
|
94
96
|
marketOpenStatus: "string"
|
|
95
97
|
}, { primary: "key" });
|
|
96
98
|
const stockId = "MAIN";
|
|
97
|
-
let currentPrice = config.initialPrice;
|
|
99
|
+
let currentPrice = Number(config.initialPrice.toFixed(2));
|
|
98
100
|
ctx.on("ready", async () => {
|
|
99
101
|
const history = await ctx.database.get("bourse_history", { stockId }, { limit: 1, sort: { time: "desc" } });
|
|
100
102
|
if (history.length > 0) {
|
|
101
|
-
currentPrice = history[0].price;
|
|
103
|
+
currentPrice = Number(history[0].price.toFixed(2));
|
|
102
104
|
} else {
|
|
103
105
|
await ctx.database.create("bourse_history", { stockId, price: currentPrice, time: /* @__PURE__ */ new Date() });
|
|
104
106
|
}
|
|
@@ -171,7 +173,7 @@ function apply(ctx, config) {
|
|
|
171
173
|
}
|
|
172
174
|
}
|
|
173
175
|
const current = Number(records[0].value || 0);
|
|
174
|
-
const newValue = current + delta;
|
|
176
|
+
const newValue = Number((current + delta).toFixed(2));
|
|
175
177
|
if (newValue < 0) {
|
|
176
178
|
logger.warn(`changeCashBalance: 余额不足 current=${current}, delta=${delta}`);
|
|
177
179
|
return false;
|
|
@@ -212,14 +214,15 @@ function apply(ctx, config) {
|
|
|
212
214
|
const tables = ctx.database.tables;
|
|
213
215
|
if (!tables || !("monetary_bank_int" in tables)) return false;
|
|
214
216
|
const demandRecords = await ctx.database.select("monetary_bank_int").where({ uid, currency, type: "demand" }).orderBy("settlementDate", "asc").execute();
|
|
215
|
-
let remaining = amount;
|
|
217
|
+
let remaining = Number(amount.toFixed(2));
|
|
216
218
|
for (const record of demandRecords) {
|
|
217
219
|
if (remaining <= 0) break;
|
|
218
220
|
if (record.amount <= remaining) {
|
|
219
|
-
remaining
|
|
221
|
+
remaining = Number((remaining - record.amount).toFixed(2));
|
|
220
222
|
await ctx.database.remove("monetary_bank_int", { id: record.id });
|
|
221
223
|
} else {
|
|
222
|
-
|
|
224
|
+
const newAmount = Number((record.amount - remaining).toFixed(2));
|
|
225
|
+
await ctx.database.set("monetary_bank_int", { id: record.id }, { amount: newAmount });
|
|
223
226
|
remaining = 0;
|
|
224
227
|
}
|
|
225
228
|
}
|
|
@@ -237,14 +240,14 @@ function apply(ctx, config) {
|
|
|
237
240
|
const bankDemand = await getBankDemandBalance(uid, currency);
|
|
238
241
|
logger.info(`pay: 现金=${cash}, 活期=${bankDemand}, 需要=${cost}`);
|
|
239
242
|
if (cash + bankDemand < cost) {
|
|
240
|
-
return { success: false, msg: `资金不足!需要 ${cost.toFixed(2)},当前现金 ${cash} + 活期 ${bankDemand}` };
|
|
243
|
+
return { success: false, msg: `资金不足!需要 ${cost.toFixed(2)},当前现金 ${cash.toFixed(2)} + 活期 ${bankDemand.toFixed(2)}` };
|
|
241
244
|
}
|
|
242
|
-
let remainingCost = cost;
|
|
243
|
-
const cashDeduct = Math.min(cash, remainingCost);
|
|
245
|
+
let remainingCost = Number(cost.toFixed(2));
|
|
246
|
+
const cashDeduct = Number(Math.min(cash, remainingCost).toFixed(2));
|
|
244
247
|
if (cashDeduct > 0) {
|
|
245
248
|
const success = await changeCashBalance(uid, currency, -cashDeduct);
|
|
246
249
|
if (!success) return { success: false, msg: "扣除现金失败,请重试" };
|
|
247
|
-
remainingCost
|
|
250
|
+
remainingCost = Number((remainingCost - cashDeduct).toFixed(2));
|
|
248
251
|
}
|
|
249
252
|
if (remainingCost > 0) {
|
|
250
253
|
const success = await deductBankDemand(uid, currency, remainingCost);
|
|
@@ -453,6 +456,7 @@ function apply(ctx, config) {
|
|
|
453
456
|
const waveDelta = getWaveValue(elapsed) - getWaveValue(prevElapsed);
|
|
454
457
|
let newPrice = currentPrice + trend + volatility + patternDelta + waveDelta;
|
|
455
458
|
if (newPrice < 1) newPrice = 1;
|
|
459
|
+
newPrice = Number(newPrice.toFixed(2));
|
|
456
460
|
currentPrice = newPrice;
|
|
457
461
|
await ctx.database.create("bourse_history", { stockId, price: newPrice, time: /* @__PURE__ */ new Date() });
|
|
458
462
|
}
|
|
@@ -464,9 +468,23 @@ function apply(ctx, config) {
|
|
|
464
468
|
if (txn.type === "buy") {
|
|
465
469
|
const holding = await ctx.database.get("bourse_holding", { userId: txn.userId, stockId });
|
|
466
470
|
if (holding.length === 0) {
|
|
467
|
-
await ctx.database.create("bourse_holding", {
|
|
471
|
+
await ctx.database.create("bourse_holding", {
|
|
472
|
+
userId: txn.userId,
|
|
473
|
+
stockId,
|
|
474
|
+
amount: txn.amount,
|
|
475
|
+
totalCost: Number(txn.cost.toFixed(2))
|
|
476
|
+
});
|
|
468
477
|
} else {
|
|
469
|
-
|
|
478
|
+
let existingCost = holding[0].totalCost;
|
|
479
|
+
if (!existingCost || existingCost <= 0) {
|
|
480
|
+
existingCost = Number((holding[0].amount * txn.price).toFixed(2));
|
|
481
|
+
logger.info(`processPendingTransactions: 旧持仓无成本记录,使用交易价格估算: ${holding[0].amount}股 * ${txn.price} = ${existingCost}`);
|
|
482
|
+
}
|
|
483
|
+
const newTotalCost = Number((existingCost + txn.cost).toFixed(2));
|
|
484
|
+
await ctx.database.set("bourse_holding", { userId: txn.userId, stockId }, {
|
|
485
|
+
amount: holding[0].amount + txn.amount,
|
|
486
|
+
totalCost: newTotalCost
|
|
487
|
+
});
|
|
470
488
|
}
|
|
471
489
|
} else if (txn.type === "sell") {
|
|
472
490
|
if (txn.uid && typeof txn.uid === "number") {
|
|
@@ -544,9 +562,12 @@ function apply(ctx, config) {
|
|
|
544
562
|
if (!payResult.success) {
|
|
545
563
|
return payResult.msg;
|
|
546
564
|
}
|
|
547
|
-
let freezeMinutes =
|
|
548
|
-
if (
|
|
549
|
-
|
|
565
|
+
let freezeMinutes = 0;
|
|
566
|
+
if (config.maxFreezeTime > 0) {
|
|
567
|
+
freezeMinutes = cost / config.freezeCostPerMinute;
|
|
568
|
+
if (freezeMinutes > config.maxFreezeTime) freezeMinutes = config.maxFreezeTime;
|
|
569
|
+
if (freezeMinutes < config.minFreezeTime) freezeMinutes = config.minFreezeTime;
|
|
570
|
+
}
|
|
550
571
|
const freezeMs = freezeMinutes * 60 * 1e3;
|
|
551
572
|
const endTime = new Date(Date.now() + freezeMs);
|
|
552
573
|
await ctx.database.create("bourse_pending", {
|
|
@@ -560,6 +581,12 @@ function apply(ctx, config) {
|
|
|
560
581
|
startTime: /* @__PURE__ */ new Date(),
|
|
561
582
|
endTime
|
|
562
583
|
});
|
|
584
|
+
if (freezeMinutes === 0) {
|
|
585
|
+
await processPendingTransactions();
|
|
586
|
+
return `交易已完成!
|
|
587
|
+
花费: ${cost.toFixed(2)} ${config.currency}
|
|
588
|
+
股票已到账。`;
|
|
589
|
+
}
|
|
563
590
|
return `交易申请已提交!
|
|
564
591
|
花费: ${cost.toFixed(2)} ${config.currency}
|
|
565
592
|
冻结时间: ${freezeMinutes.toFixed(1)}分钟
|
|
@@ -577,16 +604,32 @@ function apply(ctx, config) {
|
|
|
577
604
|
if (holding.length === 0 || holding[0].amount < amount) {
|
|
578
605
|
return `持仓不足!当前持有: ${holding.length ? holding[0].amount : 0} 股。`;
|
|
579
606
|
}
|
|
580
|
-
const
|
|
607
|
+
const currentHolding = holding[0];
|
|
608
|
+
let existingTotalCost = currentHolding.totalCost;
|
|
609
|
+
if (!existingTotalCost || existingTotalCost <= 0) {
|
|
610
|
+
existingTotalCost = Number((currentHolding.amount * currentPrice).toFixed(2));
|
|
611
|
+
logger.info(`stock.sell: 旧持仓无成本记录,使用当前市价估算: ${currentHolding.amount}股 * ${currentPrice} = ${existingTotalCost}`);
|
|
612
|
+
}
|
|
613
|
+
const avgCostPerShare = Number((existingTotalCost / currentHolding.amount).toFixed(2));
|
|
614
|
+
const soldCost = Number((avgCostPerShare * amount).toFixed(2));
|
|
615
|
+
const newAmount = currentHolding.amount - amount;
|
|
581
616
|
if (newAmount === 0) {
|
|
582
617
|
await ctx.database.remove("bourse_holding", { userId: visibleUserId, stockId });
|
|
583
618
|
} else {
|
|
584
|
-
|
|
619
|
+
const newTotalCost = Number((existingTotalCost - soldCost).toFixed(2));
|
|
620
|
+
await ctx.database.set("bourse_holding", { userId: visibleUserId, stockId }, {
|
|
621
|
+
amount: newAmount,
|
|
622
|
+
totalCost: Math.max(0, newTotalCost)
|
|
623
|
+
// 确保不为负数
|
|
624
|
+
});
|
|
585
625
|
}
|
|
586
626
|
const gain = Number((currentPrice * amount).toFixed(2));
|
|
587
|
-
let freezeMinutes =
|
|
588
|
-
if (
|
|
589
|
-
|
|
627
|
+
let freezeMinutes = 0;
|
|
628
|
+
if (config.maxFreezeTime > 0) {
|
|
629
|
+
freezeMinutes = gain / config.freezeCostPerMinute;
|
|
630
|
+
if (freezeMinutes > config.maxFreezeTime) freezeMinutes = config.maxFreezeTime;
|
|
631
|
+
if (freezeMinutes < config.minFreezeTime) freezeMinutes = config.minFreezeTime;
|
|
632
|
+
}
|
|
590
633
|
const freezeMs = freezeMinutes * 60 * 1e3;
|
|
591
634
|
const endTime = new Date(Date.now() + freezeMs);
|
|
592
635
|
await ctx.database.create("bourse_pending", {
|
|
@@ -600,6 +643,12 @@ function apply(ctx, config) {
|
|
|
600
643
|
startTime: /* @__PURE__ */ new Date(),
|
|
601
644
|
endTime
|
|
602
645
|
});
|
|
646
|
+
if (freezeMinutes === 0) {
|
|
647
|
+
await processPendingTransactions();
|
|
648
|
+
return `卖出已完成!
|
|
649
|
+
收益: ${gain.toFixed(2)} ${config.currency}
|
|
650
|
+
资金已到账。`;
|
|
651
|
+
}
|
|
603
652
|
return `卖出挂单已提交!
|
|
604
653
|
预计收益: ${gain.toFixed(2)} ${config.currency}
|
|
605
654
|
资金冻结: ${freezeMinutes.toFixed(1)}分钟
|
|
@@ -609,31 +658,42 @@ function apply(ctx, config) {
|
|
|
609
658
|
const userId = session.userId;
|
|
610
659
|
const holdings = await ctx.database.get("bourse_holding", { userId });
|
|
611
660
|
const pending = await ctx.database.get("bourse_pending", { userId });
|
|
612
|
-
let
|
|
613
|
-
`;
|
|
661
|
+
let holdingData = null;
|
|
614
662
|
if (holdings.length > 0) {
|
|
615
663
|
const h2 = holdings[0];
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
`;
|
|
634
|
-
}
|
|
664
|
+
const marketValue = Number((h2.amount * currentPrice).toFixed(2));
|
|
665
|
+
const hasCostData = h2.totalCost !== void 0 && h2.totalCost !== null && h2.totalCost > 0;
|
|
666
|
+
const totalCost = hasCostData ? Number(h2.totalCost.toFixed(2)) : 0;
|
|
667
|
+
const avgCost = hasCostData && h2.amount > 0 ? Number((totalCost / h2.amount).toFixed(2)) : 0;
|
|
668
|
+
const profit = hasCostData ? Number((marketValue - totalCost).toFixed(2)) : null;
|
|
669
|
+
const profitPercent = hasCostData && totalCost > 0 ? Number((profit / totalCost * 100).toFixed(2)) : null;
|
|
670
|
+
holdingData = {
|
|
671
|
+
stockName: config.stockName,
|
|
672
|
+
amount: h2.amount,
|
|
673
|
+
currentPrice: Number(currentPrice.toFixed(2)),
|
|
674
|
+
avgCost: hasCostData ? avgCost : null,
|
|
675
|
+
// null 表示无成本记录
|
|
676
|
+
totalCost: hasCostData ? totalCost : null,
|
|
677
|
+
marketValue,
|
|
678
|
+
profit,
|
|
679
|
+
profitPercent
|
|
680
|
+
};
|
|
635
681
|
}
|
|
636
|
-
|
|
682
|
+
const pendingData = pending.map((p) => {
|
|
683
|
+
const timeLeft = Math.max(0, Math.ceil((p.endTime.getTime() - Date.now()) / 1e3));
|
|
684
|
+
const minutes = Math.floor(timeLeft / 60);
|
|
685
|
+
const seconds = timeLeft % 60;
|
|
686
|
+
return {
|
|
687
|
+
type: p.type === "buy" ? "买入" : "卖出",
|
|
688
|
+
typeClass: p.type,
|
|
689
|
+
amount: p.amount,
|
|
690
|
+
price: Number(p.price.toFixed(2)),
|
|
691
|
+
cost: Number(p.cost.toFixed(2)),
|
|
692
|
+
timeLeft: `${minutes}分${seconds}秒`
|
|
693
|
+
};
|
|
694
|
+
});
|
|
695
|
+
const img = await renderHoldingImage(ctx, session.username, holdingData, pendingData, config.currency);
|
|
696
|
+
return img;
|
|
637
697
|
});
|
|
638
698
|
ctx.command("stock.control <price:number> [hours:number]", "管理员:设置宏观调控目标", { authority: 3 }).action(async ({ session }, price, hours) => {
|
|
639
699
|
if (!price || price <= 0) return "请输入有效的目标价格。";
|
|
@@ -695,6 +755,348 @@ function apply(ctx, config) {
|
|
|
695
755
|
switchKLinePattern("管理员手动");
|
|
696
756
|
return "已切换K线模型。";
|
|
697
757
|
});
|
|
758
|
+
async function renderHoldingImage(ctx2, username, holding, pending, currency) {
|
|
759
|
+
const hasCostData = holding && holding.totalCost !== null;
|
|
760
|
+
const isProfit = hasCostData ? holding.profit >= 0 : true;
|
|
761
|
+
const profitColor = isProfit ? "#d93025" : "#188038";
|
|
762
|
+
const profitSign = isProfit ? "+" : "";
|
|
763
|
+
const profitSectionHtml = hasCostData ? `
|
|
764
|
+
<div class="profit-section" style="background: ${isProfit ? "rgba(217, 48, 37, 0.08)" : "rgba(24, 128, 56, 0.08)"}">
|
|
765
|
+
<div class="profit-label">盈亏</div>
|
|
766
|
+
<div class="profit-value" style="color: ${profitColor}">
|
|
767
|
+
${profitSign}${holding.profit.toFixed(2)} ${currency}
|
|
768
|
+
<span class="profit-percent">(${profitSign}${holding.profitPercent.toFixed(2)}%)</span>
|
|
769
|
+
</div>
|
|
770
|
+
</div>
|
|
771
|
+
` : `
|
|
772
|
+
<div class="profit-section no-data" style="background: rgba(128, 128, 128, 0.08)">
|
|
773
|
+
<div class="profit-label">盈亏</div>
|
|
774
|
+
<div class="profit-value" style="color: #888">
|
|
775
|
+
暂无成本记录
|
|
776
|
+
<span class="profit-hint">(新交易后将自动记录)</span>
|
|
777
|
+
</div>
|
|
778
|
+
</div>
|
|
779
|
+
`;
|
|
780
|
+
const holdingHtml = holding ? `
|
|
781
|
+
<div class="section">
|
|
782
|
+
<div class="section-title">📈 持仓详情</div>
|
|
783
|
+
<div class="stock-card">
|
|
784
|
+
<div class="stock-header">
|
|
785
|
+
<div class="stock-name">${holding.stockName}</div>
|
|
786
|
+
<div class="stock-amount">${holding.amount} 股</div>
|
|
787
|
+
</div>
|
|
788
|
+
<div class="stock-body">
|
|
789
|
+
<div class="stat-row">
|
|
790
|
+
<div class="stat-item">
|
|
791
|
+
<div class="stat-label">现价</div>
|
|
792
|
+
<div class="stat-value">${holding.currentPrice.toFixed(2)}</div>
|
|
793
|
+
</div>
|
|
794
|
+
<div class="stat-item">
|
|
795
|
+
<div class="stat-label">成本价</div>
|
|
796
|
+
<div class="stat-value">${hasCostData ? holding.avgCost.toFixed(2) : "--"}</div>
|
|
797
|
+
</div>
|
|
798
|
+
</div>
|
|
799
|
+
<div class="stat-row">
|
|
800
|
+
<div class="stat-item">
|
|
801
|
+
<div class="stat-label">持仓成本</div>
|
|
802
|
+
<div class="stat-value">${hasCostData ? holding.totalCost.toFixed(2) : "--"}</div>
|
|
803
|
+
</div>
|
|
804
|
+
<div class="stat-item">
|
|
805
|
+
<div class="stat-label">市值</div>
|
|
806
|
+
<div class="stat-value highlight">${holding.marketValue.toFixed(2)}</div>
|
|
807
|
+
</div>
|
|
808
|
+
</div>
|
|
809
|
+
</div>
|
|
810
|
+
${profitSectionHtml}
|
|
811
|
+
</div>
|
|
812
|
+
</div>
|
|
813
|
+
` : `
|
|
814
|
+
<div class="section">
|
|
815
|
+
<div class="section-title">📈 持仓详情</div>
|
|
816
|
+
<div class="empty-state">
|
|
817
|
+
<div class="empty-icon">📭</div>
|
|
818
|
+
<div class="empty-text">暂无持仓</div>
|
|
819
|
+
</div>
|
|
820
|
+
</div>
|
|
821
|
+
`;
|
|
822
|
+
const pendingHtml = pending.length > 0 ? `
|
|
823
|
+
<div class="section">
|
|
824
|
+
<div class="section-title">⏳ 进行中的交易</div>
|
|
825
|
+
${pending.map((p) => `
|
|
826
|
+
<div class="pending-item ${p.typeClass}">
|
|
827
|
+
<div class="pending-left">
|
|
828
|
+
<span class="pending-type ${p.typeClass}">${p.type}</span>
|
|
829
|
+
<span class="pending-amount">${p.amount} 股</span>
|
|
830
|
+
</div>
|
|
831
|
+
<div class="pending-center">
|
|
832
|
+
<span class="pending-price">单价 ${p.price.toFixed(2)}</span>
|
|
833
|
+
<span class="pending-cost">总额 ${p.cost.toFixed(2)}</span>
|
|
834
|
+
</div>
|
|
835
|
+
<div class="pending-right">
|
|
836
|
+
<span class="pending-time">⏱ ${p.timeLeft}</span>
|
|
837
|
+
</div>
|
|
838
|
+
</div>
|
|
839
|
+
`).join("")}
|
|
840
|
+
</div>
|
|
841
|
+
` : "";
|
|
842
|
+
const html = `
|
|
843
|
+
<html>
|
|
844
|
+
<head>
|
|
845
|
+
<style>
|
|
846
|
+
body {
|
|
847
|
+
margin: 0;
|
|
848
|
+
padding: 20px;
|
|
849
|
+
font-family: 'Segoe UI', 'Microsoft YaHei', Roboto, sans-serif;
|
|
850
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
851
|
+
width: 450px;
|
|
852
|
+
box-sizing: border-box;
|
|
853
|
+
}
|
|
854
|
+
.card {
|
|
855
|
+
background: white;
|
|
856
|
+
padding: 25px;
|
|
857
|
+
border-radius: 20px;
|
|
858
|
+
box-shadow: 0 20px 40px rgba(0,0,0,0.15);
|
|
859
|
+
}
|
|
860
|
+
.header {
|
|
861
|
+
display: flex;
|
|
862
|
+
align-items: center;
|
|
863
|
+
gap: 12px;
|
|
864
|
+
margin-bottom: 20px;
|
|
865
|
+
padding-bottom: 15px;
|
|
866
|
+
border-bottom: 2px solid #f0f2f5;
|
|
867
|
+
}
|
|
868
|
+
.avatar {
|
|
869
|
+
width: 48px;
|
|
870
|
+
height: 48px;
|
|
871
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
872
|
+
border-radius: 50%;
|
|
873
|
+
display: flex;
|
|
874
|
+
align-items: center;
|
|
875
|
+
justify-content: center;
|
|
876
|
+
color: white;
|
|
877
|
+
font-size: 20px;
|
|
878
|
+
font-weight: bold;
|
|
879
|
+
}
|
|
880
|
+
.user-info {
|
|
881
|
+
flex: 1;
|
|
882
|
+
}
|
|
883
|
+
.username {
|
|
884
|
+
font-size: 22px;
|
|
885
|
+
font-weight: 700;
|
|
886
|
+
color: #1a1a1a;
|
|
887
|
+
}
|
|
888
|
+
.account-label {
|
|
889
|
+
font-size: 13px;
|
|
890
|
+
color: #888;
|
|
891
|
+
margin-top: 2px;
|
|
892
|
+
}
|
|
893
|
+
.section {
|
|
894
|
+
margin-bottom: 20px;
|
|
895
|
+
}
|
|
896
|
+
.section:last-child {
|
|
897
|
+
margin-bottom: 0;
|
|
898
|
+
}
|
|
899
|
+
.section-title {
|
|
900
|
+
font-size: 14px;
|
|
901
|
+
font-weight: 600;
|
|
902
|
+
color: #666;
|
|
903
|
+
margin-bottom: 12px;
|
|
904
|
+
text-transform: uppercase;
|
|
905
|
+
letter-spacing: 0.5px;
|
|
906
|
+
}
|
|
907
|
+
.stock-card {
|
|
908
|
+
background: #f8f9fc;
|
|
909
|
+
border-radius: 16px;
|
|
910
|
+
overflow: hidden;
|
|
911
|
+
}
|
|
912
|
+
.stock-header {
|
|
913
|
+
display: flex;
|
|
914
|
+
justify-content: space-between;
|
|
915
|
+
align-items: center;
|
|
916
|
+
padding: 16px 20px;
|
|
917
|
+
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
|
|
918
|
+
color: white;
|
|
919
|
+
}
|
|
920
|
+
.stock-name {
|
|
921
|
+
font-size: 18px;
|
|
922
|
+
font-weight: 700;
|
|
923
|
+
}
|
|
924
|
+
.stock-amount {
|
|
925
|
+
font-size: 16px;
|
|
926
|
+
font-weight: 600;
|
|
927
|
+
background: rgba(255,255,255,0.2);
|
|
928
|
+
padding: 4px 12px;
|
|
929
|
+
border-radius: 20px;
|
|
930
|
+
}
|
|
931
|
+
.stock-body {
|
|
932
|
+
padding: 16px 20px;
|
|
933
|
+
}
|
|
934
|
+
.stat-row {
|
|
935
|
+
display: flex;
|
|
936
|
+
justify-content: space-between;
|
|
937
|
+
margin-bottom: 12px;
|
|
938
|
+
}
|
|
939
|
+
.stat-row:last-child {
|
|
940
|
+
margin-bottom: 0;
|
|
941
|
+
}
|
|
942
|
+
.stat-item {
|
|
943
|
+
text-align: center;
|
|
944
|
+
flex: 1;
|
|
945
|
+
}
|
|
946
|
+
.stat-label {
|
|
947
|
+
font-size: 12px;
|
|
948
|
+
color: #888;
|
|
949
|
+
margin-bottom: 4px;
|
|
950
|
+
}
|
|
951
|
+
.stat-value {
|
|
952
|
+
font-size: 18px;
|
|
953
|
+
font-weight: 700;
|
|
954
|
+
color: #333;
|
|
955
|
+
}
|
|
956
|
+
.stat-value.highlight {
|
|
957
|
+
color: #667eea;
|
|
958
|
+
}
|
|
959
|
+
.profit-section {
|
|
960
|
+
display: flex;
|
|
961
|
+
justify-content: space-between;
|
|
962
|
+
align-items: center;
|
|
963
|
+
padding: 16px 20px;
|
|
964
|
+
border-top: 1px solid #eee;
|
|
965
|
+
}
|
|
966
|
+
.profit-label {
|
|
967
|
+
font-size: 14px;
|
|
968
|
+
font-weight: 600;
|
|
969
|
+
color: #666;
|
|
970
|
+
}
|
|
971
|
+
.profit-value {
|
|
972
|
+
font-size: 22px;
|
|
973
|
+
font-weight: 800;
|
|
974
|
+
}
|
|
975
|
+
.profit-percent {
|
|
976
|
+
font-size: 14px;
|
|
977
|
+
font-weight: 600;
|
|
978
|
+
margin-left: 6px;
|
|
979
|
+
}
|
|
980
|
+
.profit-hint {
|
|
981
|
+
font-size: 12px;
|
|
982
|
+
font-weight: 400;
|
|
983
|
+
display: block;
|
|
984
|
+
margin-top: 4px;
|
|
985
|
+
}
|
|
986
|
+
.profit-section.no-data .profit-value {
|
|
987
|
+
font-size: 16px;
|
|
988
|
+
font-weight: 600;
|
|
989
|
+
}
|
|
990
|
+
.empty-state {
|
|
991
|
+
background: #f8f9fc;
|
|
992
|
+
border-radius: 16px;
|
|
993
|
+
padding: 40px 20px;
|
|
994
|
+
text-align: center;
|
|
995
|
+
}
|
|
996
|
+
.empty-icon {
|
|
997
|
+
font-size: 48px;
|
|
998
|
+
margin-bottom: 12px;
|
|
999
|
+
}
|
|
1000
|
+
.empty-text {
|
|
1001
|
+
font-size: 16px;
|
|
1002
|
+
color: #888;
|
|
1003
|
+
}
|
|
1004
|
+
.pending-item {
|
|
1005
|
+
display: flex;
|
|
1006
|
+
justify-content: space-between;
|
|
1007
|
+
align-items: center;
|
|
1008
|
+
background: #f8f9fc;
|
|
1009
|
+
border-radius: 12px;
|
|
1010
|
+
padding: 14px 16px;
|
|
1011
|
+
margin-bottom: 10px;
|
|
1012
|
+
border-left: 4px solid #ccc;
|
|
1013
|
+
}
|
|
1014
|
+
.pending-item.buy {
|
|
1015
|
+
border-left-color: #d93025;
|
|
1016
|
+
}
|
|
1017
|
+
.pending-item.sell {
|
|
1018
|
+
border-left-color: #188038;
|
|
1019
|
+
}
|
|
1020
|
+
.pending-item:last-child {
|
|
1021
|
+
margin-bottom: 0;
|
|
1022
|
+
}
|
|
1023
|
+
.pending-left {
|
|
1024
|
+
display: flex;
|
|
1025
|
+
align-items: center;
|
|
1026
|
+
gap: 10px;
|
|
1027
|
+
}
|
|
1028
|
+
.pending-type {
|
|
1029
|
+
font-size: 12px;
|
|
1030
|
+
font-weight: 700;
|
|
1031
|
+
padding: 3px 8px;
|
|
1032
|
+
border-radius: 6px;
|
|
1033
|
+
color: white;
|
|
1034
|
+
}
|
|
1035
|
+
.pending-type.buy {
|
|
1036
|
+
background: #d93025;
|
|
1037
|
+
}
|
|
1038
|
+
.pending-type.sell {
|
|
1039
|
+
background: #188038;
|
|
1040
|
+
}
|
|
1041
|
+
.pending-amount {
|
|
1042
|
+
font-size: 15px;
|
|
1043
|
+
font-weight: 600;
|
|
1044
|
+
color: #333;
|
|
1045
|
+
}
|
|
1046
|
+
.pending-center {
|
|
1047
|
+
display: flex;
|
|
1048
|
+
flex-direction: column;
|
|
1049
|
+
align-items: center;
|
|
1050
|
+
gap: 2px;
|
|
1051
|
+
}
|
|
1052
|
+
.pending-price, .pending-cost {
|
|
1053
|
+
font-size: 12px;
|
|
1054
|
+
color: #666;
|
|
1055
|
+
}
|
|
1056
|
+
.pending-right {
|
|
1057
|
+
text-align: right;
|
|
1058
|
+
}
|
|
1059
|
+
.pending-time {
|
|
1060
|
+
font-size: 13px;
|
|
1061
|
+
font-weight: 600;
|
|
1062
|
+
color: #f39c12;
|
|
1063
|
+
}
|
|
1064
|
+
.footer {
|
|
1065
|
+
margin-top: 20px;
|
|
1066
|
+
padding-top: 15px;
|
|
1067
|
+
border-top: 1px solid #f0f2f5;
|
|
1068
|
+
text-align: center;
|
|
1069
|
+
font-size: 11px;
|
|
1070
|
+
color: #bbb;
|
|
1071
|
+
}
|
|
1072
|
+
</style>
|
|
1073
|
+
</head>
|
|
1074
|
+
<body>
|
|
1075
|
+
<div class="card">
|
|
1076
|
+
<div class="header">
|
|
1077
|
+
<div class="avatar">${username.charAt(0).toUpperCase()}</div>
|
|
1078
|
+
<div class="user-info">
|
|
1079
|
+
<div class="username">${username}</div>
|
|
1080
|
+
<div class="account-label">股票账户</div>
|
|
1081
|
+
</div>
|
|
1082
|
+
</div>
|
|
1083
|
+
${holdingHtml}
|
|
1084
|
+
${pendingHtml}
|
|
1085
|
+
<div class="footer">
|
|
1086
|
+
数据更新于 ${(/* @__PURE__ */ new Date()).toLocaleString("zh-CN")}
|
|
1087
|
+
</div>
|
|
1088
|
+
</div>
|
|
1089
|
+
</body>
|
|
1090
|
+
</html>
|
|
1091
|
+
`;
|
|
1092
|
+
const page = await ctx2.puppeteer.page();
|
|
1093
|
+
await page.setContent(html);
|
|
1094
|
+
const element = await page.$(".card");
|
|
1095
|
+
const imgBuf = await element?.screenshot({ encoding: "binary" });
|
|
1096
|
+
await page.close();
|
|
1097
|
+
return import_koishi.h.image(imgBuf, "image/png");
|
|
1098
|
+
}
|
|
1099
|
+
__name(renderHoldingImage, "renderHoldingImage");
|
|
698
1100
|
async function renderStockImage(ctx2, data, name2, current, high, low) {
|
|
699
1101
|
if (data.length < 2) return "数据不足,无法绘制走势图。";
|
|
700
1102
|
const startPrice = data[0].price;
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
本插件模拟了一个具备**自动宏观调控**、**丰富日内走势形态**和**资金冻结机制**的拟真股票市场。用户可以使用机器人通用的货币(如信用点)进行股票买卖、炒股理财。
|
|
8
8
|
|
|
9
|
-
## ✨ 特性
|
|
9
|
+
## ✨ 特性
|
|
10
10
|
|
|
11
11
|
- **📈 拟真 K 线引擎**:
|
|
12
12
|
- 内置 **12 种日内走势形态**(如:早盘冲高回落、V型反转、尾盘拉升、M顶/W底等)。
|
|
@@ -19,15 +19,25 @@
|
|
|
19
19
|
|
|
20
20
|
## 更新记录
|
|
21
21
|
|
|
22
|
-
- **
|
|
22
|
+
- **Alpha.3**:
|
|
23
|
+
- 新增:持仓盈亏计算与持仓成本追踪(`totalCost` 字段),在 `stock.my` 中展示成本价、持仓成本、当前市值、盈亏金额与盈亏百分比。
|
|
24
|
+
- 新增:持仓信息 HTML 渲染(`renderHoldingImage`),使用 Puppeteer 输出美观的持仓卡片图像,包含进行中的挂单列表和盈亏高亮显示。
|
|
25
|
+
- 修复:统一所有金额与价格的显示与存储为保留两位小数,避免浮点精度累积导致的计算偏差(涉及 `currentPrice`、历史记录、支付、扣款、银行活期处理等)。
|
|
26
|
+
- 修复:兼容旧版持仓数据(旧记录没有 `totalCost`)——合并新旧持仓时使用合理估算策略以避免成本被稀释:
|
|
27
|
+
- 若旧持仓无成本记录,新买入时以交易单价对旧持仓进行估算(保证合并后平均成本合理);
|
|
28
|
+
- 卖出时若无成本记录则使用当前市价估算以避免错误盈亏展示。
|
|
29
|
+
- 修复:当 `maxFreezeTime = 0`(表示无冻结)时,交易将立即完成并触发即时处理(不再长期挂起)。
|
|
30
|
+
- 修复:支付、现金变更及银行活期扣除流程中均使用两位小数保留,避免因浮点运算导致余额不一致。
|
|
31
|
+
|
|
32
|
+
- **1.0.0**:
|
|
23
33
|
- 修复:解决长期运行时触发的 `TypeError: cannot modify primary key`(更新 `bourse_state` 时排除主键字段,仅按条件写入非主键字段),提高数据库兼容性与稳定性。
|
|
24
34
|
|
|
25
|
-
- **Alpha.2
|
|
35
|
+
- **Alpha.2**:
|
|
26
36
|
- 在 Alpha.1 的基础上,扩展并完善了 K 线引擎,新增 12 种日内走势剧本、周级波浪叠加以及坐标轴标签冲突修复等视觉与体验改进。
|
|
27
37
|
- 该版本引入了更丰富的日内走势(例如早盘冲高回落、V 型反转、尾盘拉升、M 顶/W 底等),并改进了 Puppeteer 渲染逻辑以减少横轴标签重叠。
|
|
28
38
|
- 致命问题说明:长期运行时可能触发 `TypeError: cannot modify primary key`(根因:更新持久化 `bourse_state` 时不小心将主键字段包含在更新负载中,某些数据库实现会拒绝修改主键),该问题在后续修复版本中已处理,请在生产环境中尽量升级至稳定版本。
|
|
29
39
|
|
|
30
|
-
- **Alpha.1
|
|
40
|
+
- **Alpha.1**:
|
|
31
41
|
- 发布基础功能:买卖、资金冻结、挂单、历史行情存储以及 `stock` 家族指令(`stock` / `stock.buy` / `stock.sell` / `stock.my`)。
|
|
32
42
|
- 支持与 `monetary` 与 `monetary-bank` 的基本联动,提供最小可用的股票交易所模拟能力。
|
|
33
43
|
|