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

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
@@ -637,6 +637,15 @@ function registerAdminCommands(deps) {
637
637
  __name(registerAdminCommands, "registerAdminCommands");
638
638
 
639
639
  // src/commands-stock.ts
640
+ function resolveSellFeePercent(amount, tiers) {
641
+ if (!Array.isArray(tiers) || tiers.length === 0) return 0;
642
+ const sorted = [...tiers].sort((a, b) => b.minAmount - a.minAmount);
643
+ const matched = sorted.find((tier) => amount >= tier.minAmount);
644
+ if (!matched) return 0;
645
+ const percent = Number.isFinite(matched.feePercent) ? matched.feePercent : 0;
646
+ return Math.min(100, Math.max(0, percent));
647
+ }
648
+ __name(resolveSellFeePercent, "resolveSellFeePercent");
640
649
  function registerStockCommands(deps) {
641
650
  const {
642
651
  ctx,
@@ -968,7 +977,7 @@ function registerStockCommands(deps) {
968
977
  );
969
978
  }
970
979
  const gain = fmtAmount(currentPrice * amount);
971
- const feePercent = config.sellFeePercent;
980
+ const feePercent = resolveSellFeePercent(amount, config.sellFeeTiers);
972
981
  const fee = feePercent > 0 ? fmtAmount(gain * feePercent / 100) : 0;
973
982
  const netGain = fmtAmount(gain - fee);
974
983
  let freezeMinutes = 0;
@@ -1247,7 +1256,7 @@ var usage = `
1247
1256
  <li><b>基础设置</b>:自定义货币单位(需与 monetary 系统一致)、股票名称、初始价格,以及单人最大持仓限额。</li>
1248
1257
  <li><b>股市开关与时间</b>:控制股市启动状态,支持设定每日开市 <code>openHour</code> 与休市 <code>closeHour</code> 实现自动启停。</li>
1249
1258
  <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>
1259
+ <li><b>手续费与精度</b>:可配置分档卖出手续费 <code>sellFeeTiers</code>(股数越大费率越低);若你使用的通货不支持小数,请开启 <code>precisionInteger</code>。</li>
1251
1260
  <li><b>宏观调控引擎</b>:调整 <code>biasMax</code> 限制期望偏倚的极端值。此外,可以固定每日定期刷新宏观目标的时刻,以便在人流高峰期制造行情的明确转折。</li>
1252
1261
  </ul>
1253
1262
 
@@ -1279,7 +1288,12 @@ var Config = import_koishi2.Schema.intersect([
1279
1288
  maxFreezeTime: import_koishi2.Schema.number().min(0).default(1440).description("最大交易冻结时间(分钟)")
1280
1289
  }).description("冻结机制"),
1281
1290
  import_koishi2.Schema.object({
1282
- sellFeePercent: import_koishi2.Schema.number().min(0).max(100).step(0.01).default(0).description("卖出手续费(%)"),
1291
+ sellFeeTiers: import_koishi2.Schema.array(
1292
+ import_koishi2.Schema.object({
1293
+ minAmount: import_koishi2.Schema.number().min(1).step(1).description("起始股数(含)"),
1294
+ feePercent: import_koishi2.Schema.number().min(0).max(100).step(0.01).description("手续费比例(%)")
1295
+ })
1296
+ ).role("table").default([{ minAmount: 1, feePercent: 0 }]).description("卖出手续费分档(按股数越大费率越低匹配)"),
1283
1297
  precisionInteger: import_koishi2.Schema.boolean().default(false).description("是否将所有计数精度设置为整数")
1284
1298
  }).description("手续费与精度"),
1285
1299
  import_koishi2.Schema.object({
@@ -1287,6 +1301,10 @@ var Config = import_koishi2.Schema.intersect([
1287
1301
  fixedUpdateHour: import_koishi2.Schema.number().min(0).max(23).step(1).default(9).description("固定更新时间(小时,仅 fixedUpdateTime 为 true 时生效)"),
1288
1302
  biasMax: import_koishi2.Schema.number().min(0.1).max(0.9).step(0.01).default(0.45).description("宏观期望上下偏倚的最大值")
1289
1303
  }).description("宏观调控高级设置"),
1304
+ import_koishi2.Schema.object({
1305
+ dividendIntervalDays: import_koishi2.Schema.number().min(1).step(1).default(7).description("分红结算周期(天)"),
1306
+ maxDividendRate: import_koishi2.Schema.number().min(0).max(1).step(0.01).default(0.15).description("最大分红期望利润率(0-1,超出部分用于除息而非派发)")
1307
+ }).description("分红机制"),
1290
1308
  import_koishi2.Schema.object({
1291
1309
  enableDebug: import_koishi2.Schema.boolean().default(false).description("启用调试模式(开启后可使用调试指令)")
1292
1310
  }).description("开发者选项")
@@ -1297,6 +1315,7 @@ function apply(ctx, config) {
1297
1315
  {
1298
1316
  id: "unsigned",
1299
1317
  userId: "string",
1318
+ uid: "unsigned",
1300
1319
  stockId: "string",
1301
1320
  amount: "integer",
1302
1321
  totalCost: "double"
@@ -1340,7 +1359,8 @@ function apply(ctx, config) {
1340
1359
  trendFactor: "double",
1341
1360
  mode: "string",
1342
1361
  endTime: "timestamp",
1343
- marketOpenStatus: "string"
1362
+ marketOpenStatus: "string",
1363
+ lastDividendDate: "timestamp"
1344
1364
  },
1345
1365
  { primary: "key" }
1346
1366
  );
@@ -1369,6 +1389,94 @@ function apply(ctx, config) {
1369
1389
  time: /* @__PURE__ */ new Date()
1370
1390
  });
1371
1391
  }
1392
+ const allHoldings = await ctx.database.get("bourse_holding", {});
1393
+ const withoutUid = allHoldings.filter((h2) => !h2.uid);
1394
+ if (withoutUid.length > 0) {
1395
+ logger.info(
1396
+ `迁移: 发现 ${withoutUid.length} 条持仓记录缺少 uid,开始修复...`
1397
+ );
1398
+ const allPending = await ctx.database.get("bourse_pending", {});
1399
+ const uidMap = /* @__PURE__ */ new Map();
1400
+ for (const p of allPending) {
1401
+ if (p.userId && p.uid && !uidMap.has(p.userId)) {
1402
+ uidMap.set(p.userId, p.uid);
1403
+ }
1404
+ }
1405
+ const userTableUidMap = /* @__PURE__ */ new Map();
1406
+ try {
1407
+ const userTable = ctx.database.tables;
1408
+ if (userTable && "user" in userTable) {
1409
+ const allUsers = await ctx.database.get("user", {});
1410
+ for (const u of allUsers) {
1411
+ if (u.id !== void 0 && u.id !== null) {
1412
+ const strId = String(u.id);
1413
+ if (!userTableUidMap.has(strId)) {
1414
+ userTableUidMap.set(strId, Number(u.id));
1415
+ }
1416
+ }
1417
+ }
1418
+ }
1419
+ } catch {
1420
+ }
1421
+ let fixed = 0;
1422
+ for (const h2 of withoutUid) {
1423
+ const pendingUid = uidMap.get(h2.userId);
1424
+ if (pendingUid) {
1425
+ await ctx.database.set(
1426
+ "bourse_holding",
1427
+ { userId: h2.userId, stockId },
1428
+ { uid: pendingUid }
1429
+ );
1430
+ fixed++;
1431
+ continue;
1432
+ }
1433
+ let userUid = userTableUidMap.get(h2.userId);
1434
+ if (!userUid) {
1435
+ const numMatch = h2.userId.match(/(\d+)$/);
1436
+ if (numMatch) {
1437
+ userUid = userTableUidMap.get(numMatch[1]);
1438
+ }
1439
+ }
1440
+ if (userUid) {
1441
+ await ctx.database.set(
1442
+ "bourse_holding",
1443
+ { userId: h2.userId, stockId },
1444
+ { uid: userUid }
1445
+ );
1446
+ fixed++;
1447
+ continue;
1448
+ }
1449
+ const numericMatch = h2.userId.match(/(\d+)$/);
1450
+ const candidateUid = numericMatch ? Number(numericMatch[1]) : null;
1451
+ if (candidateUid && candidateUid > 0) {
1452
+ try {
1453
+ const monRecords = await ctx.database.get("monetary", {
1454
+ uid: candidateUid,
1455
+ currency: config.currency
1456
+ });
1457
+ if (monRecords.length > 0) {
1458
+ await ctx.database.set(
1459
+ "bourse_holding",
1460
+ { userId: h2.userId, stockId },
1461
+ { uid: candidateUid }
1462
+ );
1463
+ fixed++;
1464
+ logger.info(
1465
+ `迁移: 通过 userId 尾部数字推断 uid=${candidateUid} -> userId=${h2.userId}`
1466
+ );
1467
+ continue;
1468
+ }
1469
+ } catch {
1470
+ }
1471
+ }
1472
+ logger.warn(
1473
+ `迁移: 无法为用户 ${h2.userId} 解析 uid,将在首次买入时自动修复`
1474
+ );
1475
+ }
1476
+ if (fixed > 0) {
1477
+ logger.info(`迁移: 成功修复 ${fixed}/${withoutUid.length} 条记录`);
1478
+ }
1479
+ }
1372
1480
  });
1373
1481
  let wasMarketOpen = false;
1374
1482
  let dailyOpenPrice = null;
@@ -1383,6 +1491,7 @@ function apply(ctx, config) {
1383
1491
  if (!isOpen) return;
1384
1492
  await updatePrice();
1385
1493
  await processPendingTransactions();
1494
+ await checkAndExecuteDividend();
1386
1495
  const oneMonthAgo = new Date(Date.now() - 30 * 24 * 3600 * 1e3);
1387
1496
  await ctx.database.remove("bourse_history", {
1388
1497
  time: { $lt: oneMonthAgo }
@@ -1814,6 +1923,7 @@ function apply(ctx, config) {
1814
1923
  if (holding.length === 0) {
1815
1924
  await ctx.database.create("bourse_holding", {
1816
1925
  userId: txn.userId,
1926
+ uid: txn.uid,
1817
1927
  stockId,
1818
1928
  amount: txn.amount,
1819
1929
  totalCost: fmtAmount(txn.cost)
@@ -1832,7 +1942,9 @@ function apply(ctx, config) {
1832
1942
  { userId: txn.userId, stockId },
1833
1943
  {
1834
1944
  amount: holding[0].amount + txn.amount,
1835
- totalCost: newTotalCost
1945
+ totalCost: newTotalCost,
1946
+ uid: txn.uid
1947
+ // 确保 uid 始终是最新的
1836
1948
  }
1837
1949
  );
1838
1950
  }
@@ -1859,6 +1971,135 @@ function apply(ctx, config) {
1859
1971
  }
1860
1972
  }
1861
1973
  __name(processPendingTransactions, "processPendingTransactions");
1974
+ async function checkAndExecuteDividend() {
1975
+ const now = __testNow ?? /* @__PURE__ */ new Date();
1976
+ let state = (await ctx.database.get("bourse_state", { key: "macro_state" }))[0];
1977
+ if (state && state.lastDividendDate) {
1978
+ const lastDate = new Date(state.lastDividendDate);
1979
+ const elapsed = now.getTime() - lastDate.getTime();
1980
+ const intervalMs = config.dividendIntervalDays * 24 * 3600 * 1e3;
1981
+ if (elapsed < intervalMs) return;
1982
+ } else {
1983
+ if (!state) {
1984
+ await ctx.database.create("bourse_state", {
1985
+ key: "macro_state",
1986
+ lastCycleStart: new Date(Date.now() - 7 * 24 * 3600 * 1e3),
1987
+ startPrice: currentPrice,
1988
+ targetPrice: currentPrice,
1989
+ trendFactor: 0,
1990
+ mode: "auto",
1991
+ endTime: new Date(Date.now() + 7 * 24 * 3600 * 1e3),
1992
+ lastDividendDate: now
1993
+ });
1994
+ } else {
1995
+ await ctx.database.set(
1996
+ "bourse_state",
1997
+ { key: "macro_state" },
1998
+ { lastDividendDate: now }
1999
+ );
2000
+ }
2001
+ logger.info("分红引擎: 首次初始化,将在下个周期执行首次分红");
2002
+ return;
2003
+ }
2004
+ const allHoldings = await ctx.database.get("bourse_holding", {});
2005
+ const totalHoldings = allHoldings.reduce((sum, h2) => sum + h2.amount, 0);
2006
+ if (allHoldings.length === 0 || totalHoldings === 0) {
2007
+ await ctx.database.set(
2008
+ "bourse_state",
2009
+ { key: "macro_state" },
2010
+ { lastDividendDate: now }
2011
+ );
2012
+ logger.info("分红引擎: 无持仓记录,跳过本次分红");
2013
+ return;
2014
+ }
2015
+ const totalMarketValue = fmtAmount(totalHoldings * currentPrice);
2016
+ const totalCost = fmtAmount(
2017
+ allHoldings.reduce((sum, h2) => sum + (h2.totalCost || 0), 0)
2018
+ );
2019
+ const totalProfit = fmtAmount(totalMarketValue - totalCost);
2020
+ const edpr = totalMarketValue > 0 ? totalProfit / totalMarketValue : 0;
2021
+ if (edpr <= 0) {
2022
+ await ctx.database.set(
2023
+ "bourse_state",
2024
+ { key: "macro_state" },
2025
+ { lastDividendDate: now }
2026
+ );
2027
+ logger.info(
2028
+ `分红引擎: 大盘亏损(EDPR=${(edpr * 100).toFixed(2)}%),跳过本次分红`
2029
+ );
2030
+ return;
2031
+ }
2032
+ const priceDropRate = edpr;
2033
+ const effectiveDividendRate = Math.min(edpr, config.maxDividendRate);
2034
+ logger.info(
2035
+ `分红引擎: 开始执行分红 | EDPR=${(edpr * 100).toFixed(2)}% | 除息跌幅=${(priceDropRate * 100).toFixed(2)}% | 实际派息率=${(effectiveDividendRate * 100).toFixed(2)}% | 持仓人数=${allHoldings.length} | 总股数=${totalHoldings}`
2036
+ );
2037
+ let totalDividendPaid = 0;
2038
+ let successCount = 0;
2039
+ let failCount = 0;
2040
+ for (const holding of allHoldings) {
2041
+ const dividendAmount = fmtAmount(
2042
+ holding.amount * effectiveDividendRate * currentPrice
2043
+ );
2044
+ if (dividendAmount <= 0) continue;
2045
+ if (!holding.uid) {
2046
+ logger.warn(
2047
+ `分红引擎: 用户 ${holding.userId} 缺少 uid,无法派发分红 ${dividendAmount.toFixed(2)}`
2048
+ );
2049
+ failCount++;
2050
+ continue;
2051
+ }
2052
+ const success = await changeCashBalance(
2053
+ holding.uid,
2054
+ config.currency,
2055
+ dividendAmount
2056
+ );
2057
+ if (success) {
2058
+ totalDividendPaid += dividendAmount;
2059
+ successCount++;
2060
+ } else {
2061
+ logger.warn(
2062
+ `分红引擎: 用户 ${holding.userId} (uid=${holding.uid}) 分红派发失败`
2063
+ );
2064
+ failCount++;
2065
+ }
2066
+ }
2067
+ const oldPrice = currentPrice;
2068
+ currentPrice = fmtPrice(currentPrice * (1 - priceDropRate));
2069
+ if (currentPrice < 1) currentPrice = 1;
2070
+ await ctx.database.create("bourse_history", {
2071
+ stockId,
2072
+ price: currentPrice,
2073
+ time: now
2074
+ });
2075
+ const fluctuation = 0.25;
2076
+ const targetRatio = 1 + (Math.random() * 2 - 1) * fluctuation;
2077
+ let newTargetPrice = currentPrice * targetRatio;
2078
+ newTargetPrice = Math.max(
2079
+ currentPrice * 0.5,
2080
+ Math.min(currentPrice * 1.5, newTargetPrice)
2081
+ );
2082
+ let newEndTime;
2083
+ if (config.fixedUpdateTime) {
2084
+ newEndTime = new Date(now);
2085
+ newEndTime.setHours(config.fixedUpdateHour, 0, 0, 0);
2086
+ if (newEndTime <= now) newEndTime.setDate(newEndTime.getDate() + 1);
2087
+ } else {
2088
+ newEndTime = new Date(now.getTime() + 7 * 24 * 3600 * 1e3);
2089
+ }
2090
+ await ctx.database.set("bourse_state", { key: "macro_state" }, {
2091
+ startPrice: currentPrice,
2092
+ targetPrice: fmtPrice(newTargetPrice),
2093
+ lastCycleStart: now,
2094
+ endTime: newEndTime,
2095
+ lastDividendDate: now
2096
+ });
2097
+ switchKLinePattern("分红除息");
2098
+ logger.info(
2099
+ `分红引擎: 执行完毕 | 除息前股价=${oldPrice} -> 除息后=${currentPrice} (${(-priceDropRate * 100).toFixed(2)}%) | 派发成功=${successCount}人 | 失败=${failCount}人 | 合计派发=${totalDividendPaid.toFixed(2)} ${config.currency}`
2100
+ );
2101
+ }
2102
+ __name(checkAndExecuteDividend, "checkAndExecuteDividend");
1862
2103
  async function getPriceHistory(limit = 100) {
1863
2104
  const historyData = await ctx.database.get(
1864
2105
  "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.16",
4
4
  "main": "lib/index.js",
5
5
  "typings": "lib/index.d.ts",
6
6
  "files": [