koishi-plugin-monetary-bourse 1.0.0-alpha.2 → 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 CHANGED
@@ -31,6 +31,7 @@ export interface BourseHolding {
31
31
  userId: string;
32
32
  stockId: string;
33
33
  amount: number;
34
+ totalCost: number;
34
35
  }
35
36
  export interface BoursePending {
36
37
  id: number;
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 -= record.amount;
221
+ remaining = Number((remaining - record.amount).toFixed(2));
220
222
  await ctx.database.remove("monetary_bank_int", { id: record.id });
221
223
  } else {
222
- await ctx.database.set("monetary_bank_int", { id: record.id }, { amount: record.amount - remaining });
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 -= cashDeduct;
250
+ remainingCost = Number((remainingCost - cashDeduct).toFixed(2));
248
251
  }
249
252
  if (remainingCost > 0) {
250
253
  const success = await deductBankDemand(uid, currency, remainingCost);
@@ -380,7 +383,10 @@ function apply(ctx, config) {
380
383
  endTime
381
384
  };
382
385
  if (!state) await ctx.database.create("bourse_state", newState);
383
- else await ctx.database.set("bourse_state", "macro_state", newState);
386
+ else {
387
+ const { key, ...updateFields } = newState;
388
+ await ctx.database.set("bourse_state", { key: "macro_state" }, updateFields);
389
+ }
384
390
  state = newState;
385
391
  }
386
392
  }
@@ -414,7 +420,8 @@ function apply(ctx, config) {
414
420
  if (!state) {
415
421
  await ctx.database.create("bourse_state", newState);
416
422
  } else {
417
- await ctx.database.set("bourse_state", "macro_state", newState);
423
+ const { key, ...updateFields } = newState;
424
+ await ctx.database.set("bourse_state", { key: "macro_state" }, updateFields);
418
425
  }
419
426
  state = newState;
420
427
  }
@@ -449,6 +456,7 @@ function apply(ctx, config) {
449
456
  const waveDelta = getWaveValue(elapsed) - getWaveValue(prevElapsed);
450
457
  let newPrice = currentPrice + trend + volatility + patternDelta + waveDelta;
451
458
  if (newPrice < 1) newPrice = 1;
459
+ newPrice = Number(newPrice.toFixed(2));
452
460
  currentPrice = newPrice;
453
461
  await ctx.database.create("bourse_history", { stockId, price: newPrice, time: /* @__PURE__ */ new Date() });
454
462
  }
@@ -460,9 +468,23 @@ function apply(ctx, config) {
460
468
  if (txn.type === "buy") {
461
469
  const holding = await ctx.database.get("bourse_holding", { userId: txn.userId, stockId });
462
470
  if (holding.length === 0) {
463
- await ctx.database.create("bourse_holding", { userId: txn.userId, stockId, amount: txn.amount });
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
+ });
464
477
  } else {
465
- await ctx.database.set("bourse_holding", { userId: txn.userId, stockId }, { amount: holding[0].amount + txn.amount });
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
+ });
466
488
  }
467
489
  } else if (txn.type === "sell") {
468
490
  if (txn.uid && typeof txn.uid === "number") {
@@ -540,9 +562,12 @@ function apply(ctx, config) {
540
562
  if (!payResult.success) {
541
563
  return payResult.msg;
542
564
  }
543
- let freezeMinutes = cost / config.freezeCostPerMinute;
544
- if (freezeMinutes < config.minFreezeTime) freezeMinutes = config.minFreezeTime;
545
- if (freezeMinutes > config.maxFreezeTime) freezeMinutes = config.maxFreezeTime;
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
+ }
546
571
  const freezeMs = freezeMinutes * 60 * 1e3;
547
572
  const endTime = new Date(Date.now() + freezeMs);
548
573
  await ctx.database.create("bourse_pending", {
@@ -556,6 +581,12 @@ function apply(ctx, config) {
556
581
  startTime: /* @__PURE__ */ new Date(),
557
582
  endTime
558
583
  });
584
+ if (freezeMinutes === 0) {
585
+ await processPendingTransactions();
586
+ return `交易已完成!
587
+ 花费: ${cost.toFixed(2)} ${config.currency}
588
+ 股票已到账。`;
589
+ }
559
590
  return `交易申请已提交!
560
591
  花费: ${cost.toFixed(2)} ${config.currency}
561
592
  冻结时间: ${freezeMinutes.toFixed(1)}分钟
@@ -573,16 +604,32 @@ function apply(ctx, config) {
573
604
  if (holding.length === 0 || holding[0].amount < amount) {
574
605
  return `持仓不足!当前持有: ${holding.length ? holding[0].amount : 0} 股。`;
575
606
  }
576
- const newAmount = holding[0].amount - amount;
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;
577
616
  if (newAmount === 0) {
578
617
  await ctx.database.remove("bourse_holding", { userId: visibleUserId, stockId });
579
618
  } else {
580
- await ctx.database.set("bourse_holding", { userId: visibleUserId, stockId }, { amount: newAmount });
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
+ });
581
625
  }
582
626
  const gain = Number((currentPrice * amount).toFixed(2));
583
- let freezeMinutes = gain / config.freezeCostPerMinute;
584
- if (freezeMinutes < config.minFreezeTime) freezeMinutes = config.minFreezeTime;
585
- if (freezeMinutes > config.maxFreezeTime) freezeMinutes = config.maxFreezeTime;
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
+ }
586
633
  const freezeMs = freezeMinutes * 60 * 1e3;
587
634
  const endTime = new Date(Date.now() + freezeMs);
588
635
  await ctx.database.create("bourse_pending", {
@@ -596,6 +643,12 @@ function apply(ctx, config) {
596
643
  startTime: /* @__PURE__ */ new Date(),
597
644
  endTime
598
645
  });
646
+ if (freezeMinutes === 0) {
647
+ await processPendingTransactions();
648
+ return `卖出已完成!
649
+ 收益: ${gain.toFixed(2)} ${config.currency}
650
+ 资金已到账。`;
651
+ }
599
652
  return `卖出挂单已提交!
600
653
  预计收益: ${gain.toFixed(2)} ${config.currency}
601
654
  资金冻结: ${freezeMinutes.toFixed(1)}分钟
@@ -605,31 +658,42 @@ function apply(ctx, config) {
605
658
  const userId = session.userId;
606
659
  const holdings = await ctx.database.get("bourse_holding", { userId });
607
660
  const pending = await ctx.database.get("bourse_pending", { userId });
608
- let msg = `=== ${session.username} 的股票账户 ===
609
- `;
661
+ let holdingData = null;
610
662
  if (holdings.length > 0) {
611
663
  const h2 = holdings[0];
612
- const value = h2.amount * currentPrice;
613
- msg += `持仓: ${config.stockName} x${h2.amount}
614
- `;
615
- msg += `当前市值: ${value.toFixed(2)} ${config.currency}
616
- `;
617
- } else {
618
- msg += `持仓: 无
619
- `;
620
- }
621
- if (pending.length > 0) {
622
- msg += `
623
- --- 进行中的交易 ---
624
- `;
625
- for (const p of pending) {
626
- const timeLeft = Math.max(0, Math.ceil((p.endTime.getTime() - Date.now()) / 1e3));
627
- const typeStr = p.type === "buy" ? "买入" : "卖出";
628
- msg += `[${typeStr}] ${p.amount}股 | 剩余冻结: ${timeLeft}秒
629
- `;
630
- }
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
+ };
631
681
  }
632
- return msg;
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;
633
697
  });
634
698
  ctx.command("stock.control <price:number> [hours:number]", "管理员:设置宏观调控目标", { authority: 3 }).action(async ({ session }, price, hours) => {
635
699
  if (!price || price <= 0) return "请输入有效的目标价格。";
@@ -691,6 +755,348 @@ function apply(ctx, config) {
691
755
  switchKLinePattern("管理员手动");
692
756
  return "已切换K线模型。";
693
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");
694
1100
  async function renderStockImage(ctx2, data, name2, current, high, low) {
695
1101
  if (data.length < 2) return "数据不足,无法绘制走势图。";
696
1102
  const startPrice = data[0].price;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-monetary-bourse",
3
- "version": "1.0.0-alpha.2",
3
+ "version": "1.1.0-alpha.3",
4
4
  "main": "lib/index.js",
5
5
  "typings": "lib/index.d.ts",
6
6
  "files": [
package/readme.md CHANGED
@@ -4,23 +4,49 @@
4
4
 
5
5
  为 Koishi 提供基于 `monetary` 通用货币系统的股票交易所功能。
6
6
 
7
- 本插件模拟了一个具备自动宏观调控、周期性波动和资金冻结机制的简易股票市场。用户可以使用机器人通用的货币(如信用点)进行股票买卖、炒股理财。
7
+ 本插件模拟了一个具备**自动宏观调控**、**丰富日内走势形态**和**资金冻结机制**的拟真股票市场。用户可以使用机器人通用的货币(如信用点)进行股票买卖、炒股理财。
8
8
 
9
9
  ## ✨ 特性
10
10
 
11
- - **拟真股市算法**:包含长期趋势、随机波动和周期性波浪算法,模拟真实的股价走势。
12
- - **可视化K线图**:使用 Puppeteer 渲染精美的实时、日线、周线走势图。
13
- - **资金冻结机制**:交易并非即时到账,根据交易金额计算冻结时间,增加博弈乐趣。
14
- - **宏观调控**:管理员可以手动干预股价走势,设定目标价格,系统将平滑过渡到目标价。
15
- - **银行联动**:支持与 `koishi-plugin-monetary-bank` 联动,若现金不足可自动扣除银行活期存款(需安装对应插件)。
16
- - **休市机制**:支持设置每日开市/休市时间,周末自动休市。
11
+ - **📈 拟真 K 线引擎**:
12
+ - 内置 **12 种日内走势形态**(如:早盘冲高回落、V型反转、尾盘拉升、M顶/W底等)。
13
+ - 走势不再是单纯的随机波动,每天自动或随机切换不同的操盘剧本,大幅提升观察乐趣。
14
+ - 结合 **周级波浪** 与 **宏观趋势**,模拟真实市场的多周期叠加效应。
15
+ - **📊 精美 K 线渲染**:使用 Puppeteer 渲染实时/日/周线图,Alpha 2 版本优化了坐标轴标签算法,防止文字重叠,观感更佳。
16
+ - **❄️ 资金冻结机制**:交易采用 T+0 但资金/股票非即时到账模式(根据金额计算冻结时间),增加博弈深度。
17
+ - **🏦 银行联动**:支持与 `koishi-plugin-monetary-bank` 联动,现金不足时自动扣除银行活期存款。
18
+ - **🕹️ 宏观调控**:管理员可手动干预股价目标,或强制切换当天的 K 线剧本。
19
+
20
+ ## 更新记录
21
+
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**:
33
+ - 修复:解决长期运行时触发的 `TypeError: cannot modify primary key`(更新 `bourse_state` 时排除主键字段,仅按条件写入非主键字段),提高数据库兼容性与稳定性。
34
+
35
+ - **Alpha.2**:
36
+ - 在 Alpha.1 的基础上,扩展并完善了 K 线引擎,新增 12 种日内走势剧本、周级波浪叠加以及坐标轴标签冲突修复等视觉与体验改进。
37
+ - 该版本引入了更丰富的日内走势(例如早盘冲高回落、V 型反转、尾盘拉升、M 顶/W 底等),并改进了 Puppeteer 渲染逻辑以减少横轴标签重叠。
38
+ - 致命问题说明:长期运行时可能触发 `TypeError: cannot modify primary key`(根因:更新持久化 `bourse_state` 时不小心将主键字段包含在更新负载中,某些数据库实现会拒绝修改主键),该问题在后续修复版本中已处理,请在生产环境中尽量升级至稳定版本。
39
+
40
+ - **Alpha.1**:
41
+ - 发布基础功能:买卖、资金冻结、挂单、历史行情存储以及 `stock` 家族指令(`stock` / `stock.buy` / `stock.sell` / `stock.my`)。
42
+ - 支持与 `monetary` 与 `monetary-bank` 的基本联动,提供最小可用的股票交易所模拟能力。
17
43
 
18
44
  ## 📦 依赖
19
45
 
20
46
  本插件需要以下服务:
21
47
  - `database`: 用于存储持仓、历史行情和挂单记录。
22
48
  - `puppeteer`: 用于渲染股市行情图。
23
- - `monetary`: (可选) 用于获取用户货币余额,实际上本插件直接操作 `monetary` 数据库表。
49
+ - `monetary`: (可选) 用于获取用户货币余额(本插件直接操作数据库表,monetary 插件需安装以建立表结构)。
24
50
 
25
51
  ## 🔧 配置项
26
52
 
@@ -38,7 +64,6 @@
38
64
  - **marketStatus**: 股市总开关,可选 `open` (强制开启)、`close` (强制关闭)、`auto` (自动按时间)。
39
65
 
40
66
  ### 冻结机制
41
- 为了防止高频刷钱,交易后资金/股票需要冻结一段时间。
42
67
  - **freezeCostPerMinute**: 每多少货币金额计为1分钟冻结时间(默认 `100`)。
43
68
  - **minFreezeTime**: 最小冻结时间(分钟,默认 `10`)。
44
69
  - **maxFreezeTime**: 最大冻结时间(分钟,默认 `1440` 即24小时)。
@@ -60,7 +85,7 @@
60
85
  - **`stock.buy <amount>`**
61
86
  - 买入股票。
62
87
  - 参数 `amount`: 购买股数(整数)。
63
- - 说明:扣除现金(或银行活期),股票将在冻结时间结束后到账。
88
+ - 说明:扣除现金(优先)或银行活期,股票将在冻结时间结束后到账。
64
89
 
65
90
  - **`stock.sell <amount>`**
66
91
  - 卖出股票。
@@ -78,14 +103,23 @@
78
103
  - 说明:强行引导股价在指定时间内向目标价格移动。
79
104
  - 示例:`stock.control 5000 12` (在12小时内让股价涨/跌到5000)。
80
105
 
106
+ - **`stock.pattern`** *(Alpha 2 新增)*
107
+ - 强制切换 K 线模型。
108
+ - 说明:手动随机切换当前使用的日内走势剧本(如从“单边下跌”切换为“尾盘拉升”)。
109
+
81
110
  - **`bourse.admin.market <status>`**
82
111
  - 设置股市开关状态。
83
112
  - 参数 `status`: `open` (开启), `close` (关闭), `auto` (自动)。
113
+ - 说明:手动开市时会自动重置并切换一个新的日内 K 线形态。
84
114
 
85
115
  ## 💡 常见问题
86
116
 
87
117
  **Q: 为什么买了股票没有立刻到账?**
88
- A: 本插件设计了 T+N 机制(基于金额的动态冻结)。使用 `stock.buy` 后,股票会进入“冻结中”状态,使用 `stock.my` 可以看到剩余解冻时间。卖出股票同理,资金需等待解冻。
118
+ A: 本插件设计了基于交易金额的动态冻结机制。交易额越大,冻结时间越长(可配置)。请使用 `stock.my` 查看剩余解冻时间。
89
119
 
90
120
  **Q: 股价是如何波动的?**
91
- A: 股价由三部分组成:长期趋势(宏观调控决定)、随机波动(模拟市场噪音)和正弦波浪(模拟周期性涨跌)。
121
+ A: 股价由四部分叠加而成:
122
+ 1. **宏观趋势**:根据自动或手动的目标价格计算的线性趋势。
123
+ 2. **周级波浪**:7天为一个大周期的正弦复合波浪。
124
+ 3. **日内形态**:每天随机从 12 种剧本中选择多种(如 V 型、倒 V 型、阶梯上涨等)。
125
+ 4. **随机噪音**:微小的随机波动。