koishi-plugin-monetary-bourse 3.0.0-alpha.19 → 3.0.0-alpha.20

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
@@ -43,6 +43,7 @@ export interface BoursePending {
43
43
  amount: number;
44
44
  price: number;
45
45
  cost: number;
46
+ buyCost?: number;
46
47
  startTime: Date;
47
48
  endTime: Date;
48
49
  }
@@ -91,6 +92,7 @@ export interface Config {
91
92
  biasMax: number;
92
93
  dividendIntervalDays: number;
93
94
  maxDividendRate: number;
95
+ dividendBroadcastChannels: string[];
94
96
  newsDeviationThreshold: number;
95
97
  broadcastChannels: string[];
96
98
  positiveNews: NewsItem[];
package/lib/index.js CHANGED
@@ -252,6 +252,33 @@ function formatKLineTime(date) {
252
252
  }
253
253
  __name(formatKLineTime, "formatKLineTime");
254
254
 
255
+ // src/utils/broadcast.ts
256
+ function parseChannelTarget(input) {
257
+ if (!input) return null;
258
+ const trimmed = input.trim();
259
+ if (!trimmed) return null;
260
+ const colonIndex = trimmed.indexOf(":");
261
+ if (colonIndex === -1) {
262
+ return { channelId: trimmed };
263
+ }
264
+ const platform = trimmed.slice(0, colonIndex).trim();
265
+ const channelId = trimmed.slice(colonIndex + 1).trim();
266
+ if (!platform || !channelId) return null;
267
+ return { platform, channelId };
268
+ }
269
+ __name(parseChannelTarget, "parseChannelTarget");
270
+ function chunkLines(header, lines, maxLines) {
271
+ if (!lines.length) return [];
272
+ const safeMax = Math.max(1, Math.floor(maxLines));
273
+ const chunks = [];
274
+ for (let i = 0; i < lines.length; i += safeMax) {
275
+ const block = lines.slice(i, i + safeMax);
276
+ chunks.push([header, ...block].join("\n"));
277
+ }
278
+ return chunks;
279
+ }
280
+ __name(chunkLines, "chunkLines");
281
+
255
282
  // src/pattern.ts
256
283
  var kLinePatterns = {
257
284
  // ==================== 看涨模型 (8种) ====================
@@ -625,6 +652,66 @@ function registerAdminCommands(deps) {
625
652
  }
626
653
  __name(registerAdminCommands, "registerAdminCommands");
627
654
 
655
+ // src/utils/holding-summary.ts
656
+ function buildHoldingSummary(input) {
657
+ const holdingAmount = input.holding?.amount ?? 0;
658
+ const pendingBuys = input.pending.filter((p) => p.type === "buy");
659
+ const pendingSells = input.pending.filter((p) => p.type === "sell");
660
+ const pendingBuyAmount = pendingBuys.reduce((sum, p) => sum + p.amount, 0);
661
+ const pendingSellAmount = pendingSells.reduce((sum, p) => sum + p.amount, 0);
662
+ const totalAmount = holdingAmount + pendingBuyAmount + pendingSellAmount;
663
+ if (totalAmount <= 0) return null;
664
+ const holdingValue = holdingAmount * input.currentPrice;
665
+ const pendingBuyValue = pendingBuyAmount * input.currentPrice;
666
+ const pendingSellValue = pendingSells.reduce(
667
+ (sum, p) => sum + p.amount * p.price,
668
+ 0
669
+ );
670
+ const marketValue = holdingValue + pendingBuyValue + pendingSellValue;
671
+ let hasCostData = true;
672
+ let totalCost = 0;
673
+ if (holdingAmount > 0) {
674
+ const holdingCost = input.holding?.totalCost ?? null;
675
+ if (!holdingCost || holdingCost <= 0) {
676
+ hasCostData = false;
677
+ } else {
678
+ totalCost += holdingCost;
679
+ }
680
+ }
681
+ for (const pending of pendingBuys) {
682
+ if (!pending.cost || pending.cost <= 0) hasCostData = false;
683
+ else totalCost += pending.cost;
684
+ }
685
+ for (const pending of pendingSells) {
686
+ if (!pending.buyCost || pending.buyCost <= 0) hasCostData = false;
687
+ else totalCost += pending.buyCost;
688
+ }
689
+ if (!hasCostData) {
690
+ return {
691
+ amount: totalAmount,
692
+ marketValue,
693
+ totalCost: null,
694
+ avgCost: null,
695
+ profit: null,
696
+ profitPercent: null,
697
+ hasCostData: false
698
+ };
699
+ }
700
+ const avgCost = totalCost / totalAmount;
701
+ const profit = marketValue - totalCost;
702
+ const profitPercent = totalCost > 0 ? profit / totalCost * 100 : 0;
703
+ return {
704
+ amount: totalAmount,
705
+ marketValue,
706
+ totalCost,
707
+ avgCost,
708
+ profit,
709
+ profitPercent,
710
+ hasCostData: true
711
+ };
712
+ }
713
+ __name(buildHoldingSummary, "buildHoldingSummary");
714
+
628
715
  // src/commands-stock.ts
629
716
  function resolveSellFeePercent(amount, tiers) {
630
717
  if (!Array.isArray(tiers) || tiers.length === 0) return 0;
@@ -999,6 +1086,7 @@ function registerStockCommands(deps) {
999
1086
  amount,
1000
1087
  price: currentPrice,
1001
1088
  cost: netGain,
1089
+ buyCost: soldCost,
1002
1090
  startTime,
1003
1091
  endTime
1004
1092
  });
@@ -1074,23 +1162,27 @@ function registerStockCommands(deps) {
1074
1162
  const pending = await ctx.database.get("bourse_pending", { userId });
1075
1163
  let holdingData = null;
1076
1164
  const currentPrice = getCurrentPrice();
1077
- if (holdings.length > 0) {
1078
- const h2 = holdings[0];
1079
- const marketValue = fmtAmount(h2.amount * currentPrice);
1080
- const hasCostData = h2.totalCost !== void 0 && h2.totalCost !== null && h2.totalCost > 0;
1081
- const totalCost = hasCostData ? fmtAmount(h2.totalCost) : 0;
1082
- const avgCost = hasCostData && h2.amount > 0 ? fmtPrice(totalCost / h2.amount) : 0;
1083
- const profit = hasCostData ? fmtAmount(marketValue - totalCost) : null;
1084
- const profitPercent = hasCostData && totalCost > 0 ? Number((profit / totalCost * 100).toFixed(2)) : null;
1165
+ const summary = buildHoldingSummary({
1166
+ currentPrice,
1167
+ holding: holdings.length ? { amount: holdings[0].amount, totalCost: holdings[0].totalCost } : null,
1168
+ pending: pending.map((p) => ({
1169
+ type: p.type,
1170
+ amount: p.amount,
1171
+ price: p.price,
1172
+ cost: p.cost,
1173
+ buyCost: p.buyCost ?? null
1174
+ }))
1175
+ });
1176
+ if (summary) {
1085
1177
  holdingData = {
1086
1178
  stockName: config.stockName,
1087
- amount: h2.amount,
1179
+ amount: summary.amount,
1088
1180
  currentPrice: fmtPrice(currentPrice),
1089
- avgCost: hasCostData ? avgCost : null,
1090
- totalCost: hasCostData ? totalCost : null,
1091
- marketValue,
1092
- profit,
1093
- profitPercent
1181
+ avgCost: summary.hasCostData ? fmtPrice(summary.avgCost) : null,
1182
+ totalCost: summary.hasCostData ? fmtAmount(summary.totalCost) : null,
1183
+ marketValue: fmtAmount(summary.marketValue),
1184
+ profit: summary.hasCostData ? fmtAmount(summary.profit) : null,
1185
+ profitPercent: summary.hasCostData ? Number(summary.profitPercent.toFixed(2)) : null
1094
1186
  };
1095
1187
  }
1096
1188
  const pendingData = pending.map((p) => {
@@ -1294,6 +1386,9 @@ var Config = import_koishi2.Schema.intersect([
1294
1386
  dividendIntervalDays: import_koishi2.Schema.number().min(1).step(1).default(7).description("分红结算周期(天)"),
1295
1387
  maxDividendRate: import_koishi2.Schema.number().min(0).max(1).step(0.01).default(0.15).description("最大分红期望利润率(0-1,超出部分用于除息而非派发)")
1296
1388
  }).description("分红机制"),
1389
+ import_koishi2.Schema.object({
1390
+ dividendBroadcastChannels: import_koishi2.Schema.array(import_koishi2.Schema.string()).default([]).description("分红播报的频道/群聊 ID 列表(支持 onebot:123 或 123)")
1391
+ }).description("分红播报"),
1297
1392
  import_koishi2.Schema.object({
1298
1393
  newsDeviationThreshold: import_koishi2.Schema.number().min(0.05).max(1).step(0.01).default(0.15).description(
1299
1394
  "触发新闻的偏离度阈值(例如 0.15 表示当新目标价与当前价相差 15% 以上时触发)"
@@ -1361,6 +1456,7 @@ function apply(ctx, config) {
1361
1456
  amount: "integer",
1362
1457
  price: "double",
1363
1458
  cost: "double",
1459
+ buyCost: "double",
1364
1460
  startTime: "timestamp",
1365
1461
  endTime: "timestamp"
1366
1462
  },
@@ -1713,6 +1809,27 @@ function apply(ctx, config) {
1713
1809
  return { success: true };
1714
1810
  }
1715
1811
  __name(pay, "pay");
1812
+ async function sendBroadcast(channels, message, label) {
1813
+ if (!channels || channels.length === 0) return;
1814
+ for (const raw of channels) {
1815
+ const target = parseChannelTarget(raw);
1816
+ if (!target) {
1817
+ logger.warn(`${label}: 无效频道配置 ${raw}`);
1818
+ continue;
1819
+ }
1820
+ const bot = target.platform ? ctx.bots.find((b) => b.platform === target.platform) : ctx.bots[0];
1821
+ if (!bot) {
1822
+ logger.warn(`${label}: 未找到可用 bot (${raw})`);
1823
+ continue;
1824
+ }
1825
+ try {
1826
+ await bot.sendMessage(target.channelId, message);
1827
+ } catch (err) {
1828
+ logger.warn(`${label}: 发送失败 channel=${raw}`, err);
1829
+ }
1830
+ }
1831
+ }
1832
+ __name(sendBroadcast, "sendBroadcast");
1716
1833
  async function broadcastMacroNews(targetPrice, basePrice) {
1717
1834
  const deviation = (targetPrice - basePrice) / basePrice;
1718
1835
  if (Math.abs(deviation) < config.newsDeviationThreshold) return;
@@ -1733,13 +1850,7 @@ function apply(ctx, config) {
1733
1850
  logger.info(
1734
1851
  `触发宏观新闻播报: ${newsText} (偏离度=${(deviation * 100).toFixed(2)}%)`
1735
1852
  );
1736
- if (config.broadcastChannels && config.broadcastChannels.length > 0) {
1737
- try {
1738
- await ctx.broadcast(config.broadcastChannels, newsText);
1739
- } catch (err) {
1740
- logger.warn(`新闻播报广播失败:`, err);
1741
- }
1742
- }
1853
+ await sendBroadcast(config.broadcastChannels, newsText, "新闻播报");
1743
1854
  }
1744
1855
  __name(broadcastMacroNews, "broadcastMacroNews");
1745
1856
  const patternsByCategory = {
@@ -2094,6 +2205,7 @@ function apply(ctx, config) {
2094
2205
  let totalDividendPaid = 0;
2095
2206
  let successCount = 0;
2096
2207
  let failCount = 0;
2208
+ const successRecords = [];
2097
2209
  for (const holding of allHoldings) {
2098
2210
  const dividendAmount = fmtAmount(
2099
2211
  holding.amount * effectiveDividendRate * currentPrice
@@ -2114,6 +2226,7 @@ function apply(ctx, config) {
2114
2226
  if (success) {
2115
2227
  totalDividendPaid += dividendAmount;
2116
2228
  successCount++;
2229
+ successRecords.push({ userId: holding.userId, amount: dividendAmount });
2117
2230
  } else {
2118
2231
  logger.warn(
2119
2232
  `分红引擎: 用户 ${holding.userId} (uid=${holding.uid}) 分红派发失败`
@@ -2121,6 +2234,25 @@ function apply(ctx, config) {
2121
2234
  failCount++;
2122
2235
  }
2123
2236
  }
2237
+ if (config.dividendBroadcastChannels && config.dividendBroadcastChannels.length > 0 && successCount > 0) {
2238
+ const summary = `【分红播报】参与人数: ${successCount} | 派息率: ${(effectiveDividendRate * 100).toFixed(2)}% | 总派息: ${totalDividendPaid.toFixed(2)} ${config.currency}`;
2239
+ await sendBroadcast(
2240
+ config.dividendBroadcastChannels,
2241
+ summary,
2242
+ "分红播报"
2243
+ );
2244
+ const detailLines = successRecords.map((record) => {
2245
+ const ratio = totalDividendPaid > 0 ? record.amount / totalDividendPaid * 100 : 0;
2246
+ return `- ${record.userId}:占比 ${ratio.toFixed(2)}% | ${record.amount.toFixed(2)} ${config.currency}`;
2247
+ });
2248
+ for (const chunk of chunkLines("【分红明细】", detailLines, 10)) {
2249
+ await sendBroadcast(
2250
+ config.dividendBroadcastChannels,
2251
+ chunk,
2252
+ "分红明细"
2253
+ );
2254
+ }
2255
+ }
2124
2256
  const oldPrice = currentPrice;
2125
2257
  currentPrice = fmtPrice(currentPrice * (1 - priceDropRate));
2126
2258
  if (currentPrice < 1) currentPrice = 1;
@@ -0,0 +1,6 @@
1
+ export type ChannelTarget = {
2
+ platform?: string;
3
+ channelId: string;
4
+ };
5
+ export declare function parseChannelTarget(input: string): ChannelTarget | null;
6
+ export declare function chunkLines(header: string, lines: string[], maxLines: number): string[];
@@ -0,0 +1,25 @@
1
+ export type HoldingRecord = {
2
+ amount: number;
3
+ totalCost?: number | null;
4
+ };
5
+ export type PendingRecord = {
6
+ type: "buy" | "sell";
7
+ amount: number;
8
+ price: number;
9
+ cost: number;
10
+ buyCost?: number | null;
11
+ };
12
+ export type HoldingSummary = {
13
+ amount: number;
14
+ marketValue: number;
15
+ totalCost: number | null;
16
+ avgCost: number | null;
17
+ profit: number | null;
18
+ profitPercent: number | null;
19
+ hasCostData: boolean;
20
+ };
21
+ export declare function buildHoldingSummary(input: {
22
+ currentPrice: number;
23
+ holding: HoldingRecord | null;
24
+ pending: PendingRecord[];
25
+ }): HoldingSummary | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-monetary-bourse",
3
- "version": "3.0.0-alpha.19",
3
+ "version": "3.0.0-alpha.20",
4
4
  "main": "lib/index.js",
5
5
  "typings": "lib/index.d.ts",
6
6
  "files": [
@@ -8,7 +8,11 @@
8
8
  "dist"
9
9
  ],
10
10
  "scripts": {
11
- "build": "tsc && node -e \"require('fs').cpSync('src/templates', 'lib/templates', {recursive: true})\""
11
+ "build": "tsc && node -e \"require('fs').cpSync('src/templates', 'lib/templates', {recursive: true})\"",
12
+ "test": "node --test --import tsx"
13
+ },
14
+ "devDependencies": {
15
+ "tsx": "^4.7.1"
12
16
  },
13
17
  "koishi": {
14
18
  "description": {
package/readme.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  本插件模拟了一个具备自动宏观调控、25种经典K线形态、智能概率博弈和可视化交割单的深度拟真股票市场。用户可以使用机器人通用的货币(如信用点)进行股票买卖、炒股理财。
8
8
 
9
- > 版本:**3.0.0-alpha.19**
9
+ > 版本:**3.0.0-alpha.20**
10
10
 
11
11
  ## ✨ 特性
12
12
 
@@ -156,6 +156,9 @@ A: 股价采用 **"智能期望模型"** 驱动,更贴近真实博弈:
156
156
  - **dividendIntervalDays**: 分红结算周期(天,默认 `7`)。
157
157
  - **maxDividendRate**: 最大分红期望利润率(0-1,默认 `0.15`,超出部分用于除息而非派发)。
158
158
 
159
+ ### 分红播报
160
+ - **dividendBroadcastChannels**: 分红播报的频道/群聊 ID 列表(支持 `onebot:123` 或 `123`)。
161
+
159
162
  ### 新闻播报机制
160
163
  - **newsDeviationThreshold**: 触发新闻的偏离度阈值(默认 `0.15`,例如 `0.15` 表示偏离 15% 以上触发)。
161
164
  - **broadcastChannels**: 播报新闻的频道/群聊 ID 列表(为空则仅后台日志,不发送群消息)。