koishi-plugin-monetary-bourse 3.0.0-alpha.12 → 3.0.0-alpha.14

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
@@ -61,6 +61,7 @@ export interface BourseState {
61
61
  endTime: Date;
62
62
  marketOpenStatus?: "open" | "close" | "auto";
63
63
  }
64
+ export declare const usage = "\n<div style=\"max-width: 800px; font-family: sans-serif; line-height: 1.6;\">\n <div style=\"margin-bottom: 24px;\">\n <h1 style=\"border-bottom: none; margin-bottom: 8px; font-size: 28px;\">\uD83D\uDCC8 monetary-bourse</h1>\n <p style=\"opacity: 0.8; font-size: 14px;\">\u57FA\u4E8E\u8D27\u5E01\u7CFB\u7EDF\u7684\u53EF\u89C6\u5316\u80A1\u7968\u4EA4\u6613\u6240\u63D2\u4EF6\uFF0C\u652F\u6301\u81EA\u52A8\u5B8F\u89C2\u8C03\u63A7\u4E0E\u62DF\u771F K \u7EBF\u5F62\u6001\u3002</p>\n </div>\n\n <h3>\u2699\uFE0F \u914D\u7F6E\u9879</h3>\n <ul style=\"margin-top: 8px; margin-bottom: 20px;\">\n <li><b>\u57FA\u7840\u8BBE\u7F6E</b>\uFF1A\u81EA\u5B9A\u4E49\u8D27\u5E01\u5355\u4F4D\uFF08\u9700\u4E0E monetary \u7CFB\u7EDF\u4E00\u81F4\uFF09\u3001\u80A1\u7968\u540D\u79F0\u3001\u521D\u59CB\u4EF7\u683C\uFF0C\u4EE5\u53CA\u5355\u4EBA\u6700\u5927\u6301\u4ED3\u9650\u989D\u3002</li>\n <li><b>\u80A1\u5E02\u5F00\u5173\u4E0E\u65F6\u95F4</b>\uFF1A\u63A7\u5236\u80A1\u5E02\u542F\u52A8\u72B6\u6001\uFF0C\u652F\u6301\u8BBE\u5B9A\u6BCF\u65E5\u5F00\u5E02 <code>openHour</code> \u4E0E\u4F11\u5E02 <code>closeHour</code> \u5B9E\u73B0\u81EA\u52A8\u542F\u505C\u3002</li>\n <li><b>\u9632\u5237\u51BB\u7ED3\u673A\u5236</b>\uFF1A\u9632\u6B62\u7528\u6237\u4F4E\u4E70\u9AD8\u5356\u9AD8\u9891\u5237\u5355\u3002\u901A\u8FC7 <code>freezeCostPerMinute</code> \u8C03\u6574\u8D44\u91D1\u4E0E\u6392\u961F\u65F6\u95F4\u6BD4\u4F8B\uFF1B\u8BBE\u5B9A <code>minFreezeTime</code> \u4E0E <code>maxFreezeTime</code> \u9632\u6B62\u8FC7\u957F\u6216\u8FC7\u77ED\u6392\u961F\u3002\u53EF\u5C06\u6700\u5C0F\u65F6\u95F4\u8BBE\u4E3A 0 \u4F7F\u5C0F\u989D\u4EA4\u6613\u79D2\u6210\u3002</li>\n <li><b>\u624B\u7EED\u8D39\u4E0E\u7CBE\u5EA6</b>\uFF1A\u53EF\u914D\u7F6E\u5356\u51FA\u624B\u7EED\u8D39 <code>sellFeePercent</code> \u63D0\u5347\u535A\u5F08\u6210\u672C\uFF1B\u82E5\u4F60\u4F7F\u7528\u7684\u901A\u8D27\u4E0D\u652F\u6301\u5C0F\u6570\uFF0C\u8BF7\u5F00\u542F <code>precisionInteger</code>\u3002</li>\n <li><b>\u5B8F\u89C2\u8C03\u63A7\u5F15\u64CE</b>\uFF1A\u8C03\u6574 <code>biasMax</code> \u9650\u5236\u671F\u671B\u504F\u501A\u7684\u6781\u7AEF\u503C\u3002\u6B64\u5916\uFF0C\u53EF\u4EE5\u56FA\u5B9A\u6BCF\u65E5\u5B9A\u671F\u5237\u65B0\u5B8F\u89C2\u76EE\u6807\u7684\u65F6\u523B\uFF0C\u4EE5\u4FBF\u5728\u4EBA\u6D41\u9AD8\u5CF0\u671F\u5236\u9020\u884C\u60C5\u7684\u660E\u786E\u8F6C\u6298\u3002</li>\n </ul>\n\n <h3>\uD83D\uDCD6 \u5F00\u53D1\u8005\u5EFA\u8BAE</h3>\n <p style=\"font-size: 14px; opacity: 0.85;\">\n \u90E8\u7F72\u521D\u671F\uFF0C\u5EFA\u8BAE\u5728\u4E0B\u65B9\u5F00\u542F <code>enableDebug</code>\uFF0C\u901A\u8FC7 <code>bourse.test.price</code> \u6216 <code>bourse.test.run</code> \u6307\u4EE4\u751F\u6210\u672A\u6765\u4E00\u6BB5\u65F6\u95F4\u7684\u6A21\u62DF\u91CF\u4EF7\u5207\u7247\uFF0C\u4EE5\u6B64\u9A8C\u8BC1\u5F53\u524D\u7684\u53C2\u6570\u914D\u7F6E\u662F\u5426\u7B26\u5408\u8D35\u7FA4\u7684\u5E02\u573A\u8282\u594F\u548C\u8D2D\u4E70\u529B\u6C34\u5E73\u3002\u8C03\u6574\u6700\u4F73\u540E\u518D\u5173\u95ED\u8C03\u8BD5\u9009\u9879\u3002\n </p>\n</div>\n";
64
65
  export interface Config {
65
66
  currency: string;
66
67
  stockName: string;
@@ -73,6 +74,11 @@ export interface Config {
73
74
  maxFreezeTime: number;
74
75
  marketStatus: "open" | "close" | "auto";
75
76
  enableDebug: boolean;
77
+ sellFeePercent: number;
78
+ precisionInteger: boolean;
79
+ fixedUpdateTime: boolean;
80
+ fixedUpdateHour: number;
81
+ biasMax: number;
76
82
  }
77
83
  export declare const Config: Schema<Config>;
78
84
  export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js CHANGED
@@ -23,7 +23,8 @@ __export(src_exports, {
23
23
  Config: () => Config,
24
24
  apply: () => apply,
25
25
  inject: () => inject,
26
- name: () => name
26
+ name: () => name,
27
+ usage: () => usage
27
28
  });
28
29
  module.exports = __toCommonJS(src_exports);
29
30
  var import_koishi = require("koishi");
@@ -35,6 +36,28 @@ var inject = {
35
36
  optional: ["monetary"]
36
37
  };
37
38
  var logger = new import_koishi.Logger("bourse");
39
+ var usage = `
40
+ <div style="max-width: 800px; font-family: sans-serif; line-height: 1.6;">
41
+ <div style="margin-bottom: 24px;">
42
+ <h1 style="border-bottom: none; margin-bottom: 8px; font-size: 28px;">📈 monetary-bourse</h1>
43
+ <p style="opacity: 0.8; font-size: 14px;">基于货币系统的可视化股票交易所插件,支持自动宏观调控与拟真 K 线形态。</p>
44
+ </div>
45
+
46
+ <h3>⚙️ 配置项</h3>
47
+ <ul style="margin-top: 8px; margin-bottom: 20px;">
48
+ <li><b>基础设置</b>:自定义货币单位(需与 monetary 系统一致)、股票名称、初始价格,以及单人最大持仓限额。</li>
49
+ <li><b>股市开关与时间</b>:控制股市启动状态,支持设定每日开市 <code>openHour</code> 与休市 <code>closeHour</code> 实现自动启停。</li>
50
+ <li><b>防刷冻结机制</b>:防止用户低买高卖高频刷单。通过 <code>freezeCostPerMinute</code> 调整资金与排队时间比例;设定 <code>minFreezeTime</code> 与 <code>maxFreezeTime</code> 防止过长或过短排队。可将最小时间设为 0 使小额交易秒成。</li>
51
+ <li><b>手续费与精度</b>:可配置卖出手续费 <code>sellFeePercent</code> 提升博弈成本;若你使用的通货不支持小数,请开启 <code>precisionInteger</code>。</li>
52
+ <li><b>宏观调控引擎</b>:调整 <code>biasMax</code> 限制期望偏倚的极端值。此外,可以固定每日定期刷新宏观目标的时刻,以便在人流高峰期制造行情的明确转折。</li>
53
+ </ul>
54
+
55
+ <h3>📖 开发者建议</h3>
56
+ <p style="font-size: 14px; opacity: 0.85;">
57
+ 部署初期,建议在下方开启 <code>enableDebug</code>,通过 <code>bourse.test.price</code> 或 <code>bourse.test.run</code> 指令生成未来一段时间的模拟量价切片,以此验证当前的参数配置是否符合贵群的市场节奏和购买力水平。调整最佳后再关闭调试选项。
58
+ </p>
59
+ </div>
60
+ `;
38
61
  var Config = import_koishi.Schema.intersect([
39
62
  import_koishi.Schema.object({
40
63
  currency: import_koishi.Schema.string().default("信用点").description("货币单位名称"),
@@ -56,6 +79,15 @@ var Config = import_koishi.Schema.intersect([
56
79
  minFreezeTime: import_koishi.Schema.number().min(0).default(10).description("最小冻结时间(分钟)"),
57
80
  maxFreezeTime: import_koishi.Schema.number().min(0).default(1440).description("最大交易冻结时间(分钟)")
58
81
  }).description("冻结机制"),
82
+ import_koishi.Schema.object({
83
+ sellFeePercent: import_koishi.Schema.number().min(0).max(100).step(0.01).default(0).description("卖出手续费(%)"),
84
+ precisionInteger: import_koishi.Schema.boolean().default(false).description("是否将所有计数精度设置为整数")
85
+ }).description("手续费与精度"),
86
+ import_koishi.Schema.object({
87
+ fixedUpdateTime: import_koishi.Schema.boolean().default(false).description("是否固定宏观目标的更新时间"),
88
+ fixedUpdateHour: import_koishi.Schema.number().min(0).max(23).step(1).default(9).description("固定更新时间(小时,仅 fixedUpdateTime 为 true 时生效)"),
89
+ biasMax: import_koishi.Schema.number().min(0.1).max(0.9).step(0.01).default(0.45).description("宏观期望上下偏倚的最大值")
90
+ }).description("宏观调控高级设置"),
59
91
  import_koishi.Schema.object({
60
92
  enableDebug: import_koishi.Schema.boolean().default(false).description("启用调试模式(开启后可使用调试指令)")
61
93
  }).description("开发者选项")
@@ -115,6 +147,14 @@ function apply(ctx, config) {
115
147
  );
116
148
  const stockId = "MAIN";
117
149
  let currentPrice = Number(config.initialPrice.toFixed(2));
150
+ function fmtPrice(value) {
151
+ return config.precisionInteger ? Math.round(value) : Number(value.toFixed(2));
152
+ }
153
+ __name(fmtPrice, "fmtPrice");
154
+ function fmtAmount(value) {
155
+ return config.precisionInteger ? Math.round(value) : Number(value.toFixed(2));
156
+ }
157
+ __name(fmtAmount, "fmtAmount");
118
158
  ctx.on("ready", async () => {
119
159
  const history = await ctx.database.get(
120
160
  "bourse_history",
@@ -122,7 +162,7 @@ function apply(ctx, config) {
122
162
  { limit: 1, sort: { time: "desc" } }
123
163
  );
124
164
  if (history.length > 0) {
125
- currentPrice = Number(history[0].price.toFixed(2));
165
+ currentPrice = fmtPrice(history[0].price);
126
166
  } else {
127
167
  await ctx.database.create("bourse_history", {
128
168
  stockId,
@@ -219,7 +259,7 @@ function apply(ctx, config) {
219
259
  }
220
260
  }
221
261
  const current = Number(records[0].value || 0);
222
- const newValue = Number((current + delta).toFixed(2));
262
+ const newValue = fmtAmount(current + delta);
223
263
  if (newValue < 0) {
224
264
  logger.warn(
225
265
  `changeCashBalance: 余额不足 current=${current}, delta=${delta}`
@@ -279,14 +319,14 @@ function apply(ctx, config) {
279
319
  const tables = ctx.database.tables;
280
320
  if (!tables || !("monetary_bank_int" in tables)) return false;
281
321
  const demandRecords = await ctx.database.select("monetary_bank_int").where({ uid, currency, type: "demand" }).orderBy("settlementDate", "asc").execute();
282
- let remaining = Number(amount.toFixed(2));
322
+ let remaining = fmtAmount(amount);
283
323
  for (const record of demandRecords) {
284
324
  if (remaining <= 0) break;
285
325
  if (record.amount <= remaining) {
286
- remaining = Number((remaining - record.amount).toFixed(2));
326
+ remaining = fmtAmount(remaining - record.amount);
287
327
  await ctx.database.remove("monetary_bank_int", { id: record.id });
288
328
  } else {
289
- const newAmount = Number((record.amount - remaining).toFixed(2));
329
+ const newAmount = fmtAmount(record.amount - remaining);
290
330
  await ctx.database.set(
291
331
  "monetary_bank_int",
292
332
  { id: record.id },
@@ -315,15 +355,15 @@ function apply(ctx, config) {
315
355
  logger.warn(`pay 失败: ${msg}, uid=${uid}`);
316
356
  return { success: false, msg };
317
357
  }
318
- let remainingCost = Number(cost.toFixed(2));
319
- const cashDeduct = Number(Math.min(cash, remainingCost).toFixed(2));
358
+ let remainingCost = fmtAmount(cost);
359
+ const cashDeduct = fmtAmount(Math.min(cash, remainingCost));
320
360
  if (cashDeduct > 0) {
321
361
  const success = await changeCashBalance(uid, currency, -cashDeduct);
322
362
  if (!success) {
323
363
  logger.error(`pay 失败: 扣除现金失败 uid=${uid}, cost=${cashDeduct}`);
324
364
  return { success: false, msg: "扣除现金失败,请重试" };
325
365
  }
326
- remainingCost = Number((remainingCost - cashDeduct).toFixed(2));
366
+ remainingCost = fmtAmount(remainingCost - cashDeduct);
327
367
  }
328
368
  if (remainingCost > 0) {
329
369
  const success = await deductBankDemand(uid, currency, remainingCost);
@@ -617,7 +657,7 @@ function apply(ctx, config) {
617
657
  const deviationThreshold = 0.05;
618
658
  if (Math.abs(deviation) > deviationThreshold) {
619
659
  const adjustmentStrength = Math.min(Math.abs(deviation) / 0.3, 1);
620
- const maxBias = 0.45;
660
+ const maxBias = config.biasMax;
621
661
  if (deviation > 0) {
622
662
  bullishProb = 0.33 + adjustmentStrength * maxBias;
623
663
  bearishProb = 0.33 - adjustmentStrength * maxBias * 0.7;
@@ -703,7 +743,17 @@ function apply(ctx, config) {
703
743
  if (now > endTime) needNewState = true;
704
744
  }
705
745
  const createAutoState = /* @__PURE__ */ __name(async () => {
706
- const durationHours = 7 * 24;
746
+ let endTime;
747
+ if (config.fixedUpdateTime) {
748
+ endTime = new Date(now);
749
+ endTime.setHours(config.fixedUpdateHour, 0, 0, 0);
750
+ if (endTime <= now) {
751
+ endTime.setDate(endTime.getDate() + 1);
752
+ }
753
+ } else {
754
+ const durationHours = 7 * 24;
755
+ endTime = new Date(now.getTime() + durationHours * 3600 * 1e3);
756
+ }
707
757
  const fluctuation = 0.25;
708
758
  const targetRatio = 1 + (Math.random() * 2 - 1) * fluctuation;
709
759
  let targetPrice2 = currentPrice * targetRatio;
@@ -711,7 +761,6 @@ function apply(ctx, config) {
711
761
  currentPrice * 0.5,
712
762
  Math.min(currentPrice * 1.5, targetPrice2)
713
763
  );
714
- const endTime = new Date(now.getTime() + durationHours * 3600 * 1e3);
715
764
  const newState = {
716
765
  key: "macro_state",
717
766
  lastCycleStart: now,
@@ -801,7 +850,7 @@ function apply(ctx, config) {
801
850
  }
802
851
  newPrice = Math.max(lowerLimit, Math.min(upperLimit, newPrice));
803
852
  if (newPrice < 1) newPrice = 1;
804
- newPrice = Number(newPrice.toFixed(2));
853
+ newPrice = fmtPrice(newPrice);
805
854
  currentPrice = newPrice;
806
855
  await ctx.database.create("bourse_history", {
807
856
  stockId,
@@ -826,17 +875,17 @@ function apply(ctx, config) {
826
875
  userId: txn.userId,
827
876
  stockId,
828
877
  amount: txn.amount,
829
- totalCost: Number(txn.cost.toFixed(2))
878
+ totalCost: fmtAmount(txn.cost)
830
879
  });
831
880
  } else {
832
881
  let existingCost = holding[0].totalCost;
833
882
  if (!existingCost || existingCost <= 0) {
834
- existingCost = Number((holding[0].amount * txn.price).toFixed(2));
883
+ existingCost = fmtAmount(holding[0].amount * txn.price);
835
884
  logger.info(
836
885
  `processPendingTransactions: 旧持仓无成本记录,使用交易价格估算: ${holding[0].amount}股 * ${txn.price} = ${existingCost}`
837
886
  );
838
887
  }
839
- const newTotalCost = Number((existingCost + txn.cost).toFixed(2));
888
+ const newTotalCost = fmtAmount(existingCost + txn.cost);
840
889
  await ctx.database.set(
841
890
  "bourse_holding",
842
891
  { userId: txn.userId, stockId },
@@ -848,7 +897,7 @@ function apply(ctx, config) {
848
897
  }
849
898
  } else if (txn.type === "sell") {
850
899
  if (txn.uid !== void 0 && txn.uid !== null && typeof txn.uid === "number" && !Number.isNaN(txn.uid)) {
851
- const amount = Number(txn.cost.toFixed(2));
900
+ const amount = fmtAmount(txn.cost);
852
901
  const success = await changeCashBalance(
853
902
  txn.uid,
854
903
  config.currency,
@@ -901,23 +950,22 @@ function apply(ctx, config) {
901
950
  let history;
902
951
  const now = /* @__PURE__ */ new Date();
903
952
  if (interval === "day") {
904
- const startTime = new Date(now.getTime() - 24 * 3600 * 1e3);
953
+ const todayOpen = new Date(now);
954
+ todayOpen.setHours(config.openHour, 0, 0, 0);
955
+ const dayAgo = new Date(now.getTime() - 24 * 3600 * 1e3);
956
+ let startTime = todayOpen > dayAgo ? todayOpen : dayAgo;
957
+ if (startTime > now) startTime = dayAgo;
905
958
  history = await ctx.database.get(
906
959
  "bourse_history",
907
- {
908
- stockId,
909
- time: { $gte: startTime }
910
- },
960
+ { stockId, time: { $gte: startTime } },
911
961
  { sort: { time: "asc" } }
912
962
  );
913
963
  } else if (interval === "week") {
914
- const startTime = new Date(now.getTime() - 7 * 24 * 3600 * 1e3);
964
+ const weekAgo = new Date(now.getTime() - 7 * 24 * 3600 * 1e3);
965
+ weekAgo.setHours(config.openHour, 0, 0, 0);
915
966
  history = await ctx.database.get(
916
967
  "bourse_history",
917
- {
918
- stockId,
919
- time: { $gte: startTime }
920
- },
968
+ { stockId, time: { $gte: weekAgo } },
921
969
  { sort: { time: "asc" } }
922
970
  );
923
971
  } else {
@@ -1004,7 +1052,7 @@ function apply(ctx, config) {
1004
1052
  );
1005
1053
  return "无法获取用户ID,请稍后重试。";
1006
1054
  }
1007
- const cost = Number((currentPrice * amount).toFixed(2));
1055
+ const cost = fmtAmount(currentPrice * amount);
1008
1056
  const payResult = await pay(uid, cost, config.currency);
1009
1057
  if (!payResult.success) {
1010
1058
  logger.warn(
@@ -1132,17 +1180,15 @@ function apply(ctx, config) {
1132
1180
  const currentHolding = holding[0];
1133
1181
  let existingTotalCost = currentHolding.totalCost;
1134
1182
  if (!existingTotalCost || existingTotalCost <= 0) {
1135
- existingTotalCost = Number(
1136
- (currentHolding.amount * currentPrice).toFixed(2)
1137
- );
1183
+ existingTotalCost = fmtAmount(currentHolding.amount * currentPrice);
1138
1184
  logger.info(
1139
1185
  `stock.sell: 旧持仓无成本记录,使用当前市价估算: ${currentHolding.amount}股 * ${currentPrice} = ${existingTotalCost}`
1140
1186
  );
1141
1187
  }
1142
- const avgCostPerShare = Number(
1143
- (existingTotalCost / currentHolding.amount).toFixed(2)
1188
+ const avgCostPerShare = fmtPrice(
1189
+ existingTotalCost / currentHolding.amount
1144
1190
  );
1145
- const soldCost = Number((avgCostPerShare * amount).toFixed(2));
1191
+ const soldCost = fmtAmount(avgCostPerShare * amount);
1146
1192
  const newAmount = currentHolding.amount - amount;
1147
1193
  if (newAmount === 0) {
1148
1194
  await ctx.database.remove("bourse_holding", {
@@ -1150,7 +1196,7 @@ function apply(ctx, config) {
1150
1196
  stockId
1151
1197
  });
1152
1198
  } else {
1153
- const newTotalCost = Number((existingTotalCost - soldCost).toFixed(2));
1199
+ const newTotalCost = fmtAmount(existingTotalCost - soldCost);
1154
1200
  await ctx.database.set(
1155
1201
  "bourse_holding",
1156
1202
  { userId: visibleUserId, stockId },
@@ -1161,10 +1207,13 @@ function apply(ctx, config) {
1161
1207
  }
1162
1208
  );
1163
1209
  }
1164
- const gain = Number((currentPrice * amount).toFixed(2));
1210
+ const gain = fmtAmount(currentPrice * amount);
1211
+ const feePercent = config.sellFeePercent;
1212
+ const fee = feePercent > 0 ? fmtAmount(gain * feePercent / 100) : 0;
1213
+ const netGain = fmtAmount(gain - fee);
1165
1214
  let freezeMinutes = 0;
1166
1215
  if (config.maxFreezeTime > 0) {
1167
- freezeMinutes = gain / config.freezeCostPerMinute;
1216
+ freezeMinutes = netGain / config.freezeCostPerMinute;
1168
1217
  if (freezeMinutes > config.maxFreezeTime)
1169
1218
  freezeMinutes = config.maxFreezeTime;
1170
1219
  if (freezeMinutes < config.minFreezeTime)
@@ -1191,12 +1240,12 @@ function apply(ctx, config) {
1191
1240
  type: "sell",
1192
1241
  amount,
1193
1242
  price: currentPrice,
1194
- cost: gain,
1243
+ cost: netGain,
1195
1244
  startTime,
1196
1245
  endTime
1197
1246
  });
1198
1247
  const hasCostRecord = existingTotalCost > 0;
1199
- const profit = hasCostRecord ? Number((gain - soldCost).toFixed(2)) : null;
1248
+ const profit = hasCostRecord ? fmtAmount(netGain - soldCost) : null;
1200
1249
  const profitPercent = hasCostRecord && soldCost > 0 ? Number((profit / soldCost * 100).toFixed(2)) : null;
1201
1250
  const tradeMeta = freezeMinutes === 0 ? {
1202
1251
  status: "settled",
@@ -1223,7 +1272,9 @@ function apply(ctx, config) {
1223
1272
  avgBuyPrice: hasCostRecord ? avgCostPerShare : null,
1224
1273
  buyCost: hasCostRecord ? soldCost : null,
1225
1274
  profit,
1226
- profitPercent
1275
+ profitPercent,
1276
+ fee: fee > 0 ? fee : null,
1277
+ feePercent: feePercent > 0 ? feePercent : null
1227
1278
  },
1228
1279
  void 0,
1229
1280
  tradeMeta
@@ -1243,7 +1294,9 @@ function apply(ctx, config) {
1243
1294
  avgBuyPrice: hasCostRecord ? avgCostPerShare : null,
1244
1295
  buyCost: hasCostRecord ? soldCost : null,
1245
1296
  profit,
1246
- profitPercent
1297
+ profitPercent,
1298
+ fee: fee > 0 ? fee : null,
1299
+ feePercent: feePercent > 0 ? feePercent : null
1247
1300
  },
1248
1301
  void 0,
1249
1302
  tradeMeta
@@ -1262,16 +1315,16 @@ function apply(ctx, config) {
1262
1315
  let holdingData = null;
1263
1316
  if (holdings.length > 0) {
1264
1317
  const h2 = holdings[0];
1265
- const marketValue = Number((h2.amount * currentPrice).toFixed(2));
1318
+ const marketValue = fmtAmount(h2.amount * currentPrice);
1266
1319
  const hasCostData = h2.totalCost !== void 0 && h2.totalCost !== null && h2.totalCost > 0;
1267
- const totalCost = hasCostData ? Number(h2.totalCost.toFixed(2)) : 0;
1268
- const avgCost = hasCostData && h2.amount > 0 ? Number((totalCost / h2.amount).toFixed(2)) : 0;
1269
- const profit = hasCostData ? Number((marketValue - totalCost).toFixed(2)) : null;
1320
+ const totalCost = hasCostData ? fmtAmount(h2.totalCost) : 0;
1321
+ const avgCost = hasCostData && h2.amount > 0 ? fmtPrice(totalCost / h2.amount) : 0;
1322
+ const profit = hasCostData ? fmtAmount(marketValue - totalCost) : null;
1270
1323
  const profitPercent = hasCostData && totalCost > 0 ? Number((profit / totalCost * 100).toFixed(2)) : null;
1271
1324
  holdingData = {
1272
1325
  stockName: config.stockName,
1273
1326
  amount: h2.amount,
1274
- currentPrice: Number(currentPrice.toFixed(2)),
1327
+ currentPrice: fmtPrice(currentPrice),
1275
1328
  avgCost: hasCostData ? avgCost : null,
1276
1329
  // null 表示无成本记录
1277
1330
  totalCost: hasCostData ? totalCost : null,
@@ -1291,8 +1344,8 @@ function apply(ctx, config) {
1291
1344
  type: p.type === "buy" ? "买入" : "卖出",
1292
1345
  typeClass: p.type,
1293
1346
  amount: p.amount,
1294
- price: Number(p.price.toFixed(2)),
1295
- cost: Number(p.cost.toFixed(2)),
1347
+ price: fmtPrice(p.price),
1348
+ cost: fmtAmount(p.cost),
1296
1349
  timeLeft: `${minutes}分${seconds}秒`
1297
1350
  };
1298
1351
  });
@@ -1541,6 +1594,8 @@ function apply(ctx, config) {
1541
1594
  buyCost: sellInfo?.buyCost ?? null,
1542
1595
  profit: sellInfo?.profit ?? null,
1543
1596
  profitPercent: sellInfo?.profitPercent ?? null,
1597
+ fee: sellInfo?.fee ?? null,
1598
+ feePercent: sellInfo?.feePercent ?? null,
1544
1599
  // 买入后持仓
1545
1600
  newHolding: newHolding ?? amount,
1546
1601
  status,
@@ -1577,6 +1632,17 @@ function apply(ctx, config) {
1577
1632
  const klineData = chartType === "kline" ? buildKLineData(data, klineBucketMs ?? 2 * 3600 * 1e3) : [];
1578
1633
  const templatePath = (0, import_path.resolve)(__dirname, "templates", "stock-chart.html");
1579
1634
  let html = await import_fs.promises.readFile(templatePath, "utf-8");
1635
+ const g2Path = (0, import_path.resolve)(__dirname, "templates", "g2.min.js");
1636
+ let g2Script = "";
1637
+ try {
1638
+ g2Script = await import_fs.promises.readFile(g2Path, "utf-8");
1639
+ g2Script = g2Script.replace(/<\/script>/g, "<\\/script>");
1640
+ } catch (err) {
1641
+ logger.warn(
1642
+ `renderStockImage: failed to read local G2 script: ${g2Path}`,
1643
+ err
1644
+ );
1645
+ }
1580
1646
  const colorScheme = {
1581
1647
  mainColor: isUp ? "#f23645" : "#089981",
1582
1648
  gradientStart: isUp ? "rgba(242, 54, 69, 0.25)" : "rgba(8, 153, 129, 0.25)",
@@ -1615,10 +1681,11 @@ function apply(ctx, config) {
1615
1681
  "{{PRICES}}": JSON.stringify(data.map((d) => d.price)),
1616
1682
  "{{TIMES}}": JSON.stringify(data.map((d) => d.time)),
1617
1683
  "{{TIMESTAMPS}}": JSON.stringify(data.map((d) => d.timestamp)),
1618
- "{{KLINE_DATA}}": JSON.stringify(klineData)
1684
+ "{{KLINE_DATA}}": JSON.stringify(klineData),
1685
+ "{{G2_SCRIPT}}": g2Script
1619
1686
  };
1620
1687
  for (const [key, value] of Object.entries(replacements)) {
1621
- html = html.replace(new RegExp(key, "g"), value);
1688
+ html = html.replace(new RegExp(key, "g"), () => value);
1622
1689
  }
1623
1690
  const page = await ctx2.puppeteer.page();
1624
1691
  try {
@@ -1698,5 +1765,6 @@ __name(apply, "apply");
1698
1765
  Config,
1699
1766
  apply,
1700
1767
  inject,
1701
- name
1768
+ name,
1769
+ usage
1702
1770
  });
@@ -202,6 +202,10 @@
202
202
  border-top: 1px solid var(--border-color);
203
203
  }
204
204
 
205
+ .profit-section.up { background: var(--profit-up-bg); }
206
+ .profit-section.down { background: var(--profit-down-bg); }
207
+ .profit-section.no-data { background: rgba(255, 255, 255, 0.03); }
208
+
205
209
  .profit-label {
206
210
  font-size: 13px;
207
211
  font-weight: 600;
@@ -214,6 +218,10 @@
214
218
  font-family: 'Roboto Mono';
215
219
  }
216
220
 
221
+ .profit-value.up { color: var(--profit-up); }
222
+ .profit-value.down { color: var(--profit-down); }
223
+ .profit-value.no-data { color: #8b919e; }
224
+
217
225
  .profit-percent {
218
226
  font-size: 13px;
219
227
  font-weight: 600;
@@ -413,13 +421,6 @@
413
421
  // 从 JSON 标签中读取数据
414
422
  const DATA = JSON.parse(document.getElementById('data-source').textContent);
415
423
 
416
- // 统一字体:数字和金额统一使用与其他页面一致的等宽字体
417
- const monoTargets = document.querySelectorAll('.stat-value, .profit-value, .pending-amount, .pending-price, .pending-cost, .pending-type, .stock-amount')
418
- monoTargets.forEach(el => {
419
- el.style.fontFamily = "'Roboto Mono','Trebuchet MS','Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif"
420
- el.style.letterSpacing = '-0.2px'
421
- })
422
-
423
424
  // 渲染用户信息
424
425
  document.getElementById('avatar').textContent = DATA.username.charAt(0).toUpperCase();
425
426
  document.getElementById('username').textContent = DATA.username;
@@ -431,26 +432,16 @@
431
432
  const h = DATA.holding;
432
433
  const hasCostData = h.totalCost !== null;
433
434
  const isProfit = hasCostData ? h.profit >= 0 : true;
434
-
435
- // 适配深色模式的颜色
436
- const profitColor = isProfit ? '#f87171' : '#4ade80';
437
435
  const profitSign = isProfit ? '+' : '';
438
- const profitBgColor = isProfit ? 'rgba(248, 113, 113, 0.1)' : 'rgba(74, 222, 128, 0.1)';
436
+ const profitClass = hasCostData ? (isProfit ? 'up' : 'down') : 'no-data';
439
437
 
440
- const profitSection = hasCostData ? `
441
- <div class="profit-section" style="background: ${profitBgColor}">
442
- <div class="profit-label">盈亏</div>
443
- <div class="profit-value" style="color: ${profitColor}">
444
- ${profitSign}${h.profit.toFixed(2)} ${DATA.currency}
445
- <span class="profit-percent">(${profitSign}${h.profitPercent.toFixed(2)}%)</span>
446
- </div>
447
- </div>
448
- ` : `
449
- <div class="profit-section no-data" style="background: rgba(255, 255, 255, 0.03)">
438
+ const profitSection = `
439
+ <div class="profit-section ${profitClass}">
450
440
  <div class="profit-label">盈亏</div>
451
- <div class="profit-value" style="color: #8b919e">
452
- 暂无成本记录
453
- <span class="profit-hint">(新交易后将自动记录)</span>
441
+ <div class="profit-value ${profitClass}">
442
+ ${hasCostData ? `${profitSign}${h.profit.toFixed(2)} ${DATA.currency}
443
+ <span class="profit-percent">(${profitSign}${h.profitPercent.toFixed(2)}%)</span>` : `暂无成本记录
444
+ <span class="profit-hint">(新交易后将自动记录)</span>`}
454
445
  </div>
455
446
  </div>
456
447
  `;
@@ -8,6 +8,8 @@
8
8
  --icon-gradient-end: {{ICON_GRADIENT_END}};
9
9
  --icon-shadow: {{ICON_SHADOW}};
10
10
  --change-badge-bg: {{CHANGE_BADGE_BG}};
11
+ --rise-color: #f23645;
12
+ --fall-color: #089981;
11
13
 
12
14
  --bg-color: #0c0f15;
13
15
  --card-bg: #161b22;
@@ -256,14 +258,16 @@
256
258
  <div class="update-time">Updated {{UPDATE_TIME}}</div>
257
259
  </div>
258
260
 
261
+ <script>{{G2_SCRIPT}}</script>
259
262
  <script>
260
263
  const chartType = "{{CHART_TYPE}}";
261
- const klineData = JSON.parse("{{KLINE_DATA}}");
262
- const prices = JSON.parse("{{PRICES}}");
263
- const times = JSON.parse("{{TIMES}}");
264
- const timestamps = JSON.parse("{{TIMESTAMPS}}");
265
- const riseColor = "#f23645";
266
- const fallColor = "#089981";
264
+ const klineData = {{KLINE_DATA}};
265
+ const prices = {{PRICES}};
266
+ const times = {{TIMES}};
267
+ const timestamps = {{TIMESTAMPS}};
268
+ const rootStyle = getComputedStyle(document.documentElement);
269
+ const riseColor = rootStyle.getPropertyValue("--rise-color").trim();
270
+ const fallColor = rootStyle.getPropertyValue("--fall-color").trim();
267
271
 
268
272
  function markReady(className) {
269
273
  document.body.classList.add(className || "chart-ready");
@@ -240,11 +240,15 @@
240
240
  /* 增加高度以容纳更大的字体 */
241
241
  }
242
242
 
243
+ .stats-grid.sell-grid {
244
+ grid-template-columns: 1fr 1fr 1fr 1fr;
245
+ }
246
+
243
247
  .stat-item {
244
248
  background: var(--item-bg);
245
249
  border: 1px solid var(--border-color);
246
250
  border-radius: 12px;
247
- padding: 20px 28px;
251
+ padding: 16px 20px;
248
252
  display: flex;
249
253
  flex-direction: column;
250
254
  justify-content: center;
@@ -369,26 +373,6 @@
369
373
  </script>
370
374
 
371
375
  <script>
372
- // 模拟数据用于测试
373
- /*
374
- const TEST_DATA = {
375
- tradeType: 'buy',
376
- stockName: 'NVDA · NVIDIA Corp',
377
- status: 'pending',
378
- pendingMinutes: 5.5,
379
- pendingEndTime: '14:35',
380
- tradePrice: 485.20,
381
- amount: 10,
382
- totalCost: 4852.00,
383
- tradeTime: '2023-10-27 10:30:05',
384
- newHolding: 150,
385
- currency: 'USD',
386
- prices: [480, 482, 481, 483, 485, 484, 486, 485.2, 487, 488, 486],
387
- timestamps: [1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000, 11000],
388
- tradeIndex: 7
389
- };
390
- */
391
-
392
376
  let DATA;
393
377
  try {
394
378
  DATA = JSON.parse(document.getElementById('data-source').textContent);
@@ -400,16 +384,7 @@
400
384
  const status = DATA.status || 'settled';
401
385
  const isPending = status === 'pending';
402
386
 
403
- const colors = {
404
- buy: '#f87171',
405
- sell: '#4ade80',
406
- pending: '#eab308',
407
- grid: '#30363d',
408
- text: '#f0f3f5',
409
- textDim: '#8b949e'
410
- };
411
-
412
- const mainColor = isBuy ? colors.buy : colors.sell;
387
+ const mainColor = isBuy ? '#f87171' : '#4ade80';
413
388
 
414
389
  // --- 1. 渲染顶部基本信息 ---
415
390
  document.getElementById('stock-name').textContent = DATA.stockName || 'Unknown Stock';
@@ -465,7 +440,9 @@
465
440
  const isProfit = profit >= 0;
466
441
  const profitClass = isProfit ? 'profit' : 'loss';
467
442
  const profitSign = isProfit ? '+' : '';
443
+ const hasFee = DATA.fee !== null && DATA.fee > 0;
468
444
 
445
+ statsGrid.classList.add('sell-grid');
469
446
  statsGrid.innerHTML = `
470
447
  <div class="stat-item highlight">
471
448
  <div class="stat-label">Revenue 卖出金额</div>
@@ -473,9 +450,14 @@
473
450
  <div class="stat-sub">${DATA.currency}</div>
474
451
  </div>
475
452
  <div class="stat-item">
476
- <div class="stat-label">Cost Basis 买入成本</div>
477
- <div class="stat-value">${DATA.avgBuyPrice ? DATA.avgBuyPrice.toFixed(2) : '--'}</div>
478
- <div class="stat-sub">每股</div>
453
+ <div class="stat-label">Fee 手续费</div>
454
+ <div class="stat-value">${hasFee ? DATA.fee.toFixed(2) : '--'}</div>
455
+ <div class="stat-sub">${DATA.currency}${hasFee ? ` (${DATA.feePercent}%)` : ''}</div>
456
+ </div>
457
+ <div class="stat-item">
458
+ <div class="stat-label">Net 到账</div>
459
+ <div class="stat-value">${(DATA.totalCost - (DATA.fee || 0)).toFixed(2)}</div>
460
+ <div class="stat-sub">${DATA.currency}</div>
479
461
  </div>
480
462
  <div class="stat-item ${profitClass}">
481
463
  <div class="stat-label">P/L 盈亏</div>
@@ -530,7 +512,7 @@
530
512
 
531
513
  // 价格标签 (字体也同步调整为 Roboto Mono 且加大)
532
514
  const val = yMin + (i / gridLines) * yRange;
533
- ctx.fillStyle = colors.textDim;
515
+ ctx.fillStyle = '#8b949e';
534
516
  // 修改 1 & 2: 增大字号到 14px 并使用 Roboto Mono
535
517
  ctx.font = '500 14px "Roboto Mono", monospace';
536
518
  ctx.textAlign = 'left';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-monetary-bourse",
3
- "version": "3.0.0-alpha.12",
3
+ "version": "3.0.0-alpha.14",
4
4
  "main": "lib/index.js",
5
5
  "typings": "lib/index.d.ts",
6
6
  "files": [
package/readme.md CHANGED
@@ -6,8 +6,7 @@
6
6
 
7
7
  本插件模拟了一个具备自动宏观调控、25种经典K线形态、智能概率博弈和可视化交割单的深度拟真股票市场。用户可以使用机器人通用的货币(如信用点)进行股票买卖、炒股理财。
8
8
 
9
- > 版本:**2.1.1**
10
-
9
+ > 版本:**3.0.0-alpha.14**
11
10
  ## ✨ 特性
12
11
 
13
12
  - **📈 拟真 K 线引擎**:
@@ -21,6 +20,8 @@
21
20
  - **❄️ 资金冻结与挂单排队**:
22
21
  - 交易采用 T+0 机制,但大额资金/股票会根据金额计算**动态冻结时间**。
23
22
  - 挂单采用**串行排队模式**,同一用户的多个挂单需依次读秒,防止通过拆单绕过冻结机制,增加博弈深度。
23
+ - **💸 手续费与精度控制**:支持卖出手续费配置,回单展示净到账金额;可开启整数精度模式,适配不支持小数的货币体系。
24
+ - **🧭 宏观周期固定刷新**:支持固定时刻刷新宏观目标周期,便于在活动时段塑造更清晰的趋势节奏。
24
25
  - **🏦 银行联动**:支持与[ `koishi-plugin-monetary-bank`](https://github.com/BYWled/koishi-plugin-monetary-bank)[![npm](https://img.shields.io/npm/v/koishi-plugin-monetary-bank?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-monetary-bank) 联动,现金不足时自动扣除银行活期存款。
25
26
 
26
27
  ## 📦 依赖
@@ -133,11 +134,17 @@ A: 股价采用 **"智能期望模型"** 驱动,更贴近真实博弈:
133
134
  - **minFreezeTime**: 最小冻结时间(分钟,默认 `10`)。
134
135
  - **maxFreezeTime**: 最大冻结时间(分钟,默认 `1440` 即24小时)。
135
136
 
137
+ ### 手续费与精度
138
+ - **sellFeePercent**: 卖出手续费百分比(默认 `0`)。
139
+ - **precisionInteger**: 是否启用整数精度(默认 `false`)。
140
+
136
141
  ### 开发者选项
137
142
  - **enableDebug**: 是否启用调试模式(默认 `false`)。开启后可使用 `bourse.test.*` 系列调试指令。
138
143
 
139
- ### 宏观调控
140
- - 已移除配置项中的手动宏观调控字段;请使用管理员指令进行宏观调控。
144
+ ### 宏观调控高级设置
145
+ - **fixedUpdateTime**: 是否固定宏观目标的更新时间(默认 `false`)。
146
+ - **fixedUpdateHour**: 固定更新时间(小时,0-23,默认 `9`)。仅在 `fixedUpdateTime` 为 `true` 时生效。
147
+ - **biasMax**: 宏观期望上下偏倚的最大值(默认 `0.45`)。
141
148
 
142
149
  ## 📝 更新日志
143
150