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 +10 -2
- package/lib/index.js +246 -5
- package/package.json +1 -1
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>
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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",
|