koishi-plugin-monetary-bourse 3.0.0-alpha.15 → 3.0.0-alpha.17

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
@@ -29,6 +29,7 @@ declare module "koishi" {
29
29
  export interface BourseHolding {
30
30
  id: number;
31
31
  userId: string;
32
+ uid: number;
32
33
  stockId: string;
33
34
  amount: number;
34
35
  totalCost: number;
@@ -60,8 +61,13 @@ export interface BourseState {
60
61
  mode: "auto" | "manual";
61
62
  endTime: Date;
62
63
  marketOpenStatus?: "open" | "close" | "auto";
64
+ lastDividendDate?: Date;
63
65
  }
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";
66
+ 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\u5206\u6863\u5356\u51FA\u624B\u7EED\u8D39 <code>sellFeeTiers</code>\uFF08\u80A1\u6570\u8D8A\u5927\u8D39\u7387\u8D8A\u4F4E\uFF09\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";
67
+ export type SellFeeTier = {
68
+ minAmount: number;
69
+ feePercent: number;
70
+ };
65
71
  export interface Config {
66
72
  currency: string;
67
73
  stockName: string;
@@ -74,11 +80,13 @@ export interface Config {
74
80
  maxFreezeTime: number;
75
81
  marketStatus: "open" | "close" | "auto";
76
82
  enableDebug: boolean;
77
- sellFeePercent: number;
83
+ sellFeeTiers: SellFeeTier[];
78
84
  precisionInteger: boolean;
79
85
  fixedUpdateTime: boolean;
80
86
  fixedUpdateHour: number;
81
87
  biasMax: number;
88
+ dividendIntervalDays: number;
89
+ maxDividendRate: number;
82
90
  }
83
91
  export declare const Config: Schema<Config>;
84
92
  export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js CHANGED
@@ -33,12 +33,7 @@ var import_koishi2 = require("koishi");
33
33
  var import_koishi = require("koishi");
34
34
  var import_path = require("path");
35
35
  var import_fs = require("fs");
36
- var import_url = require("url");
37
- var import_meta = {};
38
- var templatesDir = (0, import_path.resolve)(
39
- (0, import_url.fileURLToPath)(new URL(".", import_meta.url)),
40
- "templates"
41
- );
36
+ var templatesDir = (0, import_path.resolve)(__dirname, "templates");
42
37
  async function renderHoldingImage(ctx, logger2, username, holding, pending, currency) {
43
38
  try {
44
39
  const templatePath = (0, import_path.resolve)(templatesDir, "holding-card.html");
@@ -132,14 +127,6 @@ async function renderStockImage(ctx, logger2, data, name2, viewLabel, current, h
132
127
  }) : [];
133
128
  const templatePath = (0, import_path.resolve)(templatesDir, "stock-chart.html");
134
129
  let html = await import_fs.promises.readFile(templatePath, "utf-8");
135
- const g2Path = (0, import_path.resolve)(templatesDir, "g2.min.js");
136
- let g2Script = "";
137
- try {
138
- g2Script = await import_fs.promises.readFile(g2Path, "utf-8");
139
- g2Script = g2Script.replace(/<\/script>/g, "<\\/script>");
140
- } catch (err) {
141
- logger2.warn(`renderStockImage: failed to read local G2 script: ${g2Path}`, err);
142
- }
143
130
  const colorScheme = {
144
131
  mainColor: isUp ? "#f23645" : "#089981",
145
132
  gradientStart: isUp ? "rgba(242, 54, 69, 0.25)" : "rgba(8, 153, 129, 0.25)",
@@ -179,7 +166,7 @@ async function renderStockImage(ctx, logger2, data, name2, viewLabel, current, h
179
166
  "{{TIMES}}": JSON.stringify(data.map((d) => d.time)),
180
167
  "{{TIMESTAMPS}}": JSON.stringify(data.map((d) => d.timestamp)),
181
168
  "{{KLINE_DATA}}": JSON.stringify(klineData),
182
- "{{G2_SCRIPT}}": g2Script
169
+ "{{G2_SCRIPT}}": ""
183
170
  };
184
171
  for (const [key, value] of Object.entries(replacements)) {
185
172
  html = html.replace(new RegExp(key, "g"), () => value);
@@ -637,6 +624,15 @@ function registerAdminCommands(deps) {
637
624
  __name(registerAdminCommands, "registerAdminCommands");
638
625
 
639
626
  // src/commands-stock.ts
627
+ function resolveSellFeePercent(amount, tiers) {
628
+ if (!Array.isArray(tiers) || tiers.length === 0) return 0;
629
+ const sorted = [...tiers].sort((a, b) => b.minAmount - a.minAmount);
630
+ const matched = sorted.find((tier) => amount >= tier.minAmount);
631
+ if (!matched) return 0;
632
+ const percent = Number.isFinite(matched.feePercent) ? matched.feePercent : 0;
633
+ return Math.min(100, Math.max(0, percent));
634
+ }
635
+ __name(resolveSellFeePercent, "resolveSellFeePercent");
640
636
  function registerStockCommands(deps) {
641
637
  const {
642
638
  ctx,
@@ -968,7 +964,7 @@ function registerStockCommands(deps) {
968
964
  );
969
965
  }
970
966
  const gain = fmtAmount(currentPrice * amount);
971
- const feePercent = config.sellFeePercent;
967
+ const feePercent = resolveSellFeePercent(amount, config.sellFeeTiers);
972
968
  const fee = feePercent > 0 ? fmtAmount(gain * feePercent / 100) : 0;
973
969
  const netGain = fmtAmount(gain - fee);
974
970
  let freezeMinutes = 0;
@@ -1247,7 +1243,7 @@ var usage = `
1247
1243
  <li><b>基础设置</b>:自定义货币单位(需与 monetary 系统一致)、股票名称、初始价格,以及单人最大持仓限额。</li>
1248
1244
  <li><b>股市开关与时间</b>:控制股市启动状态,支持设定每日开市 <code>openHour</code> 与休市 <code>closeHour</code> 实现自动启停。</li>
1249
1245
  <li><b>防刷冻结机制</b>:防止用户低买高卖高频刷单。通过 <code>freezeCostPerMinute</code> 调整资金与排队时间比例;设定 <code>minFreezeTime</code> 与 <code>maxFreezeTime</code> 防止过长或过短排队。可将最小时间设为 0 使小额交易秒成。</li>
1250
- <li><b>手续费与精度</b>:可配置卖出手续费 <code>sellFeePercent</code> 提升博弈成本;若你使用的通货不支持小数,请开启 <code>precisionInteger</code>。</li>
1246
+ <li><b>手续费与精度</b>:可配置分档卖出手续费 <code>sellFeeTiers</code>(股数越大费率越低);若你使用的通货不支持小数,请开启 <code>precisionInteger</code>。</li>
1251
1247
  <li><b>宏观调控引擎</b>:调整 <code>biasMax</code> 限制期望偏倚的极端值。此外,可以固定每日定期刷新宏观目标的时刻,以便在人流高峰期制造行情的明确转折。</li>
1252
1248
  </ul>
1253
1249
 
@@ -1279,7 +1275,12 @@ var Config = import_koishi2.Schema.intersect([
1279
1275
  maxFreezeTime: import_koishi2.Schema.number().min(0).default(1440).description("最大交易冻结时间(分钟)")
1280
1276
  }).description("冻结机制"),
1281
1277
  import_koishi2.Schema.object({
1282
- sellFeePercent: import_koishi2.Schema.number().min(0).max(100).step(0.01).default(0).description("卖出手续费(%)"),
1278
+ sellFeeTiers: import_koishi2.Schema.array(
1279
+ import_koishi2.Schema.object({
1280
+ minAmount: import_koishi2.Schema.number().min(1).step(1).description("起始股数(含)"),
1281
+ feePercent: import_koishi2.Schema.number().min(0).max(100).step(0.01).description("手续费比例(%)")
1282
+ })
1283
+ ).role("table").default([{ minAmount: 1, feePercent: 0 }]).description("卖出手续费分档(按股数越大费率越低匹配)"),
1283
1284
  precisionInteger: import_koishi2.Schema.boolean().default(false).description("是否将所有计数精度设置为整数")
1284
1285
  }).description("手续费与精度"),
1285
1286
  import_koishi2.Schema.object({
@@ -1287,6 +1288,10 @@ var Config = import_koishi2.Schema.intersect([
1287
1288
  fixedUpdateHour: import_koishi2.Schema.number().min(0).max(23).step(1).default(9).description("固定更新时间(小时,仅 fixedUpdateTime 为 true 时生效)"),
1288
1289
  biasMax: import_koishi2.Schema.number().min(0.1).max(0.9).step(0.01).default(0.45).description("宏观期望上下偏倚的最大值")
1289
1290
  }).description("宏观调控高级设置"),
1291
+ import_koishi2.Schema.object({
1292
+ dividendIntervalDays: import_koishi2.Schema.number().min(1).step(1).default(7).description("分红结算周期(天)"),
1293
+ maxDividendRate: import_koishi2.Schema.number().min(0).max(1).step(0.01).default(0.15).description("最大分红期望利润率(0-1,超出部分用于除息而非派发)")
1294
+ }).description("分红机制"),
1290
1295
  import_koishi2.Schema.object({
1291
1296
  enableDebug: import_koishi2.Schema.boolean().default(false).description("启用调试模式(开启后可使用调试指令)")
1292
1297
  }).description("开发者选项")
@@ -1297,6 +1302,7 @@ function apply(ctx, config) {
1297
1302
  {
1298
1303
  id: "unsigned",
1299
1304
  userId: "string",
1305
+ uid: "unsigned",
1300
1306
  stockId: "string",
1301
1307
  amount: "integer",
1302
1308
  totalCost: "double"
@@ -1340,7 +1346,8 @@ function apply(ctx, config) {
1340
1346
  trendFactor: "double",
1341
1347
  mode: "string",
1342
1348
  endTime: "timestamp",
1343
- marketOpenStatus: "string"
1349
+ marketOpenStatus: "string",
1350
+ lastDividendDate: "timestamp"
1344
1351
  },
1345
1352
  { primary: "key" }
1346
1353
  );
@@ -1369,6 +1376,94 @@ function apply(ctx, config) {
1369
1376
  time: /* @__PURE__ */ new Date()
1370
1377
  });
1371
1378
  }
1379
+ const allHoldings = await ctx.database.get("bourse_holding", {});
1380
+ const withoutUid = allHoldings.filter((h2) => !h2.uid);
1381
+ if (withoutUid.length > 0) {
1382
+ logger.info(
1383
+ `迁移: 发现 ${withoutUid.length} 条持仓记录缺少 uid,开始修复...`
1384
+ );
1385
+ const allPending = await ctx.database.get("bourse_pending", {});
1386
+ const uidMap = /* @__PURE__ */ new Map();
1387
+ for (const p of allPending) {
1388
+ if (p.userId && p.uid && !uidMap.has(p.userId)) {
1389
+ uidMap.set(p.userId, p.uid);
1390
+ }
1391
+ }
1392
+ const userTableUidMap = /* @__PURE__ */ new Map();
1393
+ try {
1394
+ const userTable = ctx.database.tables;
1395
+ if (userTable && "user" in userTable) {
1396
+ const allUsers = await ctx.database.get("user", {});
1397
+ for (const u of allUsers) {
1398
+ if (u.id !== void 0 && u.id !== null) {
1399
+ const strId = String(u.id);
1400
+ if (!userTableUidMap.has(strId)) {
1401
+ userTableUidMap.set(strId, Number(u.id));
1402
+ }
1403
+ }
1404
+ }
1405
+ }
1406
+ } catch {
1407
+ }
1408
+ let fixed = 0;
1409
+ for (const h2 of withoutUid) {
1410
+ const pendingUid = uidMap.get(h2.userId);
1411
+ if (pendingUid) {
1412
+ await ctx.database.set(
1413
+ "bourse_holding",
1414
+ { userId: h2.userId, stockId },
1415
+ { uid: pendingUid }
1416
+ );
1417
+ fixed++;
1418
+ continue;
1419
+ }
1420
+ let userUid = userTableUidMap.get(h2.userId);
1421
+ if (!userUid) {
1422
+ const numMatch = h2.userId.match(/(\d+)$/);
1423
+ if (numMatch) {
1424
+ userUid = userTableUidMap.get(numMatch[1]);
1425
+ }
1426
+ }
1427
+ if (userUid) {
1428
+ await ctx.database.set(
1429
+ "bourse_holding",
1430
+ { userId: h2.userId, stockId },
1431
+ { uid: userUid }
1432
+ );
1433
+ fixed++;
1434
+ continue;
1435
+ }
1436
+ const numericMatch = h2.userId.match(/(\d+)$/);
1437
+ const candidateUid = numericMatch ? Number(numericMatch[1]) : null;
1438
+ if (candidateUid && candidateUid > 0) {
1439
+ try {
1440
+ const monRecords = await ctx.database.get("monetary", {
1441
+ uid: candidateUid,
1442
+ currency: config.currency
1443
+ });
1444
+ if (monRecords.length > 0) {
1445
+ await ctx.database.set(
1446
+ "bourse_holding",
1447
+ { userId: h2.userId, stockId },
1448
+ { uid: candidateUid }
1449
+ );
1450
+ fixed++;
1451
+ logger.info(
1452
+ `迁移: 通过 userId 尾部数字推断 uid=${candidateUid} -> userId=${h2.userId}`
1453
+ );
1454
+ continue;
1455
+ }
1456
+ } catch {
1457
+ }
1458
+ }
1459
+ logger.warn(
1460
+ `迁移: 无法为用户 ${h2.userId} 解析 uid,将在首次买入时自动修复`
1461
+ );
1462
+ }
1463
+ if (fixed > 0) {
1464
+ logger.info(`迁移: 成功修复 ${fixed}/${withoutUid.length} 条记录`);
1465
+ }
1466
+ }
1372
1467
  });
1373
1468
  let wasMarketOpen = false;
1374
1469
  let dailyOpenPrice = null;
@@ -1383,6 +1478,7 @@ function apply(ctx, config) {
1383
1478
  if (!isOpen) return;
1384
1479
  await updatePrice();
1385
1480
  await processPendingTransactions();
1481
+ await checkAndExecuteDividend();
1386
1482
  const oneMonthAgo = new Date(Date.now() - 30 * 24 * 3600 * 1e3);
1387
1483
  await ctx.database.remove("bourse_history", {
1388
1484
  time: { $lt: oneMonthAgo }
@@ -1814,6 +1910,7 @@ function apply(ctx, config) {
1814
1910
  if (holding.length === 0) {
1815
1911
  await ctx.database.create("bourse_holding", {
1816
1912
  userId: txn.userId,
1913
+ uid: txn.uid,
1817
1914
  stockId,
1818
1915
  amount: txn.amount,
1819
1916
  totalCost: fmtAmount(txn.cost)
@@ -1832,7 +1929,9 @@ function apply(ctx, config) {
1832
1929
  { userId: txn.userId, stockId },
1833
1930
  {
1834
1931
  amount: holding[0].amount + txn.amount,
1835
- totalCost: newTotalCost
1932
+ totalCost: newTotalCost,
1933
+ uid: txn.uid
1934
+ // 确保 uid 始终是最新的
1836
1935
  }
1837
1936
  );
1838
1937
  }
@@ -1859,6 +1958,135 @@ function apply(ctx, config) {
1859
1958
  }
1860
1959
  }
1861
1960
  __name(processPendingTransactions, "processPendingTransactions");
1961
+ async function checkAndExecuteDividend() {
1962
+ const now = __testNow ?? /* @__PURE__ */ new Date();
1963
+ let state = (await ctx.database.get("bourse_state", { key: "macro_state" }))[0];
1964
+ if (state && state.lastDividendDate) {
1965
+ const lastDate = new Date(state.lastDividendDate);
1966
+ const elapsed = now.getTime() - lastDate.getTime();
1967
+ const intervalMs = config.dividendIntervalDays * 24 * 3600 * 1e3;
1968
+ if (elapsed < intervalMs) return;
1969
+ } else {
1970
+ if (!state) {
1971
+ await ctx.database.create("bourse_state", {
1972
+ key: "macro_state",
1973
+ lastCycleStart: new Date(Date.now() - 7 * 24 * 3600 * 1e3),
1974
+ startPrice: currentPrice,
1975
+ targetPrice: currentPrice,
1976
+ trendFactor: 0,
1977
+ mode: "auto",
1978
+ endTime: new Date(Date.now() + 7 * 24 * 3600 * 1e3),
1979
+ lastDividendDate: now
1980
+ });
1981
+ } else {
1982
+ await ctx.database.set(
1983
+ "bourse_state",
1984
+ { key: "macro_state" },
1985
+ { lastDividendDate: now }
1986
+ );
1987
+ }
1988
+ logger.info("分红引擎: 首次初始化,将在下个周期执行首次分红");
1989
+ return;
1990
+ }
1991
+ const allHoldings = await ctx.database.get("bourse_holding", {});
1992
+ const totalHoldings = allHoldings.reduce((sum, h2) => sum + h2.amount, 0);
1993
+ if (allHoldings.length === 0 || totalHoldings === 0) {
1994
+ await ctx.database.set(
1995
+ "bourse_state",
1996
+ { key: "macro_state" },
1997
+ { lastDividendDate: now }
1998
+ );
1999
+ logger.info("分红引擎: 无持仓记录,跳过本次分红");
2000
+ return;
2001
+ }
2002
+ const totalMarketValue = fmtAmount(totalHoldings * currentPrice);
2003
+ const totalCost = fmtAmount(
2004
+ allHoldings.reduce((sum, h2) => sum + (h2.totalCost || 0), 0)
2005
+ );
2006
+ const totalProfit = fmtAmount(totalMarketValue - totalCost);
2007
+ const edpr = totalMarketValue > 0 ? totalProfit / totalMarketValue : 0;
2008
+ if (edpr <= 0) {
2009
+ await ctx.database.set(
2010
+ "bourse_state",
2011
+ { key: "macro_state" },
2012
+ { lastDividendDate: now }
2013
+ );
2014
+ logger.info(
2015
+ `分红引擎: 大盘亏损(EDPR=${(edpr * 100).toFixed(2)}%),跳过本次分红`
2016
+ );
2017
+ return;
2018
+ }
2019
+ const priceDropRate = edpr;
2020
+ const effectiveDividendRate = Math.min(edpr, config.maxDividendRate);
2021
+ logger.info(
2022
+ `分红引擎: 开始执行分红 | EDPR=${(edpr * 100).toFixed(2)}% | 除息跌幅=${(priceDropRate * 100).toFixed(2)}% | 实际派息率=${(effectiveDividendRate * 100).toFixed(2)}% | 持仓人数=${allHoldings.length} | 总股数=${totalHoldings}`
2023
+ );
2024
+ let totalDividendPaid = 0;
2025
+ let successCount = 0;
2026
+ let failCount = 0;
2027
+ for (const holding of allHoldings) {
2028
+ const dividendAmount = fmtAmount(
2029
+ holding.amount * effectiveDividendRate * currentPrice
2030
+ );
2031
+ if (dividendAmount <= 0) continue;
2032
+ if (!holding.uid) {
2033
+ logger.warn(
2034
+ `分红引擎: 用户 ${holding.userId} 缺少 uid,无法派发分红 ${dividendAmount.toFixed(2)}`
2035
+ );
2036
+ failCount++;
2037
+ continue;
2038
+ }
2039
+ const success = await changeCashBalance(
2040
+ holding.uid,
2041
+ config.currency,
2042
+ dividendAmount
2043
+ );
2044
+ if (success) {
2045
+ totalDividendPaid += dividendAmount;
2046
+ successCount++;
2047
+ } else {
2048
+ logger.warn(
2049
+ `分红引擎: 用户 ${holding.userId} (uid=${holding.uid}) 分红派发失败`
2050
+ );
2051
+ failCount++;
2052
+ }
2053
+ }
2054
+ const oldPrice = currentPrice;
2055
+ currentPrice = fmtPrice(currentPrice * (1 - priceDropRate));
2056
+ if (currentPrice < 1) currentPrice = 1;
2057
+ await ctx.database.create("bourse_history", {
2058
+ stockId,
2059
+ price: currentPrice,
2060
+ time: now
2061
+ });
2062
+ const fluctuation = 0.25;
2063
+ const targetRatio = 1 + (Math.random() * 2 - 1) * fluctuation;
2064
+ let newTargetPrice = currentPrice * targetRatio;
2065
+ newTargetPrice = Math.max(
2066
+ currentPrice * 0.5,
2067
+ Math.min(currentPrice * 1.5, newTargetPrice)
2068
+ );
2069
+ let newEndTime;
2070
+ if (config.fixedUpdateTime) {
2071
+ newEndTime = new Date(now);
2072
+ newEndTime.setHours(config.fixedUpdateHour, 0, 0, 0);
2073
+ if (newEndTime <= now) newEndTime.setDate(newEndTime.getDate() + 1);
2074
+ } else {
2075
+ newEndTime = new Date(now.getTime() + 7 * 24 * 3600 * 1e3);
2076
+ }
2077
+ await ctx.database.set("bourse_state", { key: "macro_state" }, {
2078
+ startPrice: currentPrice,
2079
+ targetPrice: fmtPrice(newTargetPrice),
2080
+ lastCycleStart: now,
2081
+ endTime: newEndTime,
2082
+ lastDividendDate: now
2083
+ });
2084
+ switchKLinePattern("分红除息");
2085
+ logger.info(
2086
+ `分红引擎: 执行完毕 | 除息前股价=${oldPrice} -> 除息后=${currentPrice} (${(-priceDropRate * 100).toFixed(2)}%) | 派发成功=${successCount}人 | 失败=${failCount}人 | 合计派发=${totalDividendPaid.toFixed(2)} ${config.currency}`
2087
+ );
2088
+ }
2089
+ __name(checkAndExecuteDividend, "checkAndExecuteDividend");
1862
2090
  async function getPriceHistory(limit = 100) {
1863
2091
  const historyData = await ctx.database.get(
1864
2092
  "bourse_history",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-monetary-bourse",
3
- "version": "3.0.0-alpha.15",
3
+ "version": "3.0.0-alpha.17",
4
4
  "main": "lib/index.js",
5
5
  "typings": "lib/index.d.ts",
6
6
  "files": [