koishi-plugin-monetary-bourse 1.0.0-alpha.1 → 1.0.0-alpha.2

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.
Files changed (3) hide show
  1. package/lib/index.js +197 -18
  2. package/package.json +1 -1
  3. package/readme.md +86 -2
package/lib/index.js CHANGED
@@ -103,8 +103,14 @@ function apply(ctx, config) {
103
103
  await ctx.database.create("bourse_history", { stockId, price: currentPrice, time: /* @__PURE__ */ new Date() });
104
104
  }
105
105
  });
106
+ let wasMarketOpen = false;
106
107
  ctx.setInterval(async () => {
107
- if (!await isMarketOpen()) return;
108
+ const isOpen = await isMarketOpen();
109
+ if (isOpen && !wasMarketOpen) {
110
+ switchKLinePattern("自动开市");
111
+ }
112
+ wasMarketOpen = isOpen;
113
+ if (!isOpen) return;
108
114
  await updatePrice();
109
115
  await processPendingTransactions();
110
116
  const oneMonthAgo = new Date(Date.now() - 30 * 24 * 3600 * 1e3);
@@ -250,13 +256,111 @@ function apply(ctx, config) {
250
256
  return { success: true };
251
257
  }
252
258
  __name(pay, "pay");
259
+ const kLinePatterns = {
260
+ // 1. 早盘冲高回落:开盘上涨,午后回落
261
+ morningRally: /* @__PURE__ */ __name((p) => {
262
+ if (p < 0.3) return Math.sin(p / 0.3 * Math.PI / 2) * 1;
263
+ return Math.cos((p - 0.3) / 0.7 * Math.PI / 2) * 0.6;
264
+ }, "morningRally"),
265
+ // 2. 早盘低开高走:开盘下跌,之后持续上涨
266
+ vShape: /* @__PURE__ */ __name((p) => {
267
+ if (p < 0.25) return -Math.sin(p / 0.25 * Math.PI / 2) * 0.8;
268
+ return -0.8 + (p - 0.25) / 0.75 * 1.6;
269
+ }, "vShape"),
270
+ // 3. 倒V型:持续上涨后快速下跌
271
+ invertedV: /* @__PURE__ */ __name((p) => {
272
+ if (p < 0.6) return Math.sin(p / 0.6 * Math.PI / 2) * 1;
273
+ return Math.cos((p - 0.6) / 0.4 * Math.PI / 2) * 1;
274
+ }, "invertedV"),
275
+ // 4. 震荡整理:小幅波动,无明显方向
276
+ consolidation: /* @__PURE__ */ __name((p) => {
277
+ return Math.sin(p * Math.PI * 4) * 0.3 + Math.sin(p * Math.PI * 7) * 0.15;
278
+ }, "consolidation"),
279
+ // 5. 阶梯上涨:分段上涨,有回调
280
+ stairUp: /* @__PURE__ */ __name((p) => {
281
+ const step = Math.floor(p * 4);
282
+ const inStep = p * 4 % 1;
283
+ const base = step * 0.25;
284
+ const stepMove = inStep < 0.7 ? Math.sin(inStep / 0.7 * Math.PI / 2) * 0.3 : 0.3 - (inStep - 0.7) / 0.3 * 0.1;
285
+ return base + stepMove;
286
+ }, "stairUp"),
287
+ // 6. 阶梯下跌:分段下跌,有反弹
288
+ stairDown: /* @__PURE__ */ __name((p) => {
289
+ const step = Math.floor(p * 4);
290
+ const inStep = p * 4 % 1;
291
+ const base = -step * 0.25;
292
+ const stepMove = inStep < 0.7 ? -Math.sin(inStep / 0.7 * Math.PI / 2) * 0.3 : -0.3 + (inStep - 0.7) / 0.3 * 0.1;
293
+ return base + stepMove;
294
+ }, "stairDown"),
295
+ // 7. 尾盘拉升:前期平稳,尾盘快速上涨
296
+ lateRally: /* @__PURE__ */ __name((p) => {
297
+ if (p < 0.7) return Math.sin(p / 0.7 * Math.PI * 2) * 0.2;
298
+ return (p - 0.7) / 0.3 * 1;
299
+ }, "lateRally"),
300
+ // 8. 尾盘跳水:前期平稳或上涨,尾盘快速下跌
301
+ lateDive: /* @__PURE__ */ __name((p) => {
302
+ if (p < 0.7) return Math.sin(p / 0.7 * Math.PI / 2) * 0.4;
303
+ return 0.4 - (p - 0.7) / 0.3 * 1.2;
304
+ }, "lateDive"),
305
+ // 9. W底:双底形态
306
+ doubleBottom: /* @__PURE__ */ __name((p) => {
307
+ if (p < 0.25) return -Math.sin(p / 0.25 * Math.PI / 2) * 0.8;
308
+ if (p < 0.5) return -0.8 + Math.sin((p - 0.25) / 0.25 * Math.PI / 2) * 0.5;
309
+ if (p < 0.75) return -0.3 - Math.sin((p - 0.5) / 0.25 * Math.PI / 2) * 0.5;
310
+ return -0.8 + (p - 0.75) / 0.25 * 1.2;
311
+ }, "doubleBottom"),
312
+ // 10. M顶:双顶形态
313
+ doubleTop: /* @__PURE__ */ __name((p) => {
314
+ if (p < 0.25) return Math.sin(p / 0.25 * Math.PI / 2) * 0.8;
315
+ if (p < 0.5) return 0.8 - Math.sin((p - 0.25) / 0.25 * Math.PI / 2) * 0.5;
316
+ if (p < 0.75) return 0.3 + Math.sin((p - 0.5) / 0.25 * Math.PI / 2) * 0.5;
317
+ return 0.8 - (p - 0.75) / 0.25 * 1.2;
318
+ }, "doubleTop"),
319
+ // 11. 单边上涨
320
+ bullish: /* @__PURE__ */ __name((p) => {
321
+ return Math.sin(p * Math.PI / 2) * 0.8 + Math.sin(p * Math.PI * 3) * 0.1;
322
+ }, "bullish"),
323
+ // 12. 单边下跌
324
+ bearish: /* @__PURE__ */ __name((p) => {
325
+ return -Math.sin(p * Math.PI / 2) * 0.8 + Math.sin(p * Math.PI * 3) * 0.1;
326
+ }, "bearish")
327
+ };
328
+ const patternNames = Object.keys(kLinePatterns);
329
+ const patternChineseNames = {
330
+ morningRally: "早盘冲高回落",
331
+ vShape: "V型反转",
332
+ invertedV: "倒V型",
333
+ consolidation: "震荡整理",
334
+ stairUp: "阶梯上涨",
335
+ stairDown: "阶梯下跌",
336
+ lateRally: "尾盘拉升",
337
+ lateDive: "尾盘跳水",
338
+ doubleBottom: "W底(双底)",
339
+ doubleTop: "M顶(双顶)",
340
+ bullish: "单边上涨",
341
+ bearish: "单边下跌"
342
+ };
343
+ let currentDayPattern = patternNames[Math.floor(Math.random() * patternNames.length)];
344
+ let lastPatternSwitchTime = /* @__PURE__ */ new Date();
345
+ let nextPatternSwitchTime = new Date(Date.now() + (1 + Math.random() * 5) * 3600 * 1e3);
346
+ function switchKLinePattern(reason) {
347
+ const oldPattern = currentDayPattern;
348
+ currentDayPattern = patternNames[Math.floor(Math.random() * patternNames.length)];
349
+ const now = /* @__PURE__ */ new Date();
350
+ lastPatternSwitchTime = now;
351
+ const minDuration = 1 * 3600 * 1e3;
352
+ const randomDuration = Math.random() * 5 * 3600 * 1e3;
353
+ nextPatternSwitchTime = new Date(now.getTime() + minDuration + randomDuration);
354
+ logger.info(`${reason}切换K线模型: ${patternChineseNames[oldPattern]}(${oldPattern}) -> ${patternChineseNames[currentDayPattern]}(${currentDayPattern}), 下次随机切换: ${nextPatternSwitchTime.toLocaleString()}`);
355
+ }
356
+ __name(switchKLinePattern, "switchKLinePattern");
253
357
  async function updatePrice() {
254
358
  let state = (await ctx.database.get("bourse_state", { key: "macro_state" }))[0];
255
359
  const now = /* @__PURE__ */ new Date();
256
360
  if (state) {
257
- if (!state.lastCycleStart) state.lastCycleStart = new Date(Date.now() - 24 * 3600 * 1e3);
361
+ if (!state.lastCycleStart) state.lastCycleStart = new Date(Date.now() - 7 * 24 * 3600 * 1e3);
258
362
  if (!(state.lastCycleStart instanceof Date)) state.lastCycleStart = new Date(state.lastCycleStart);
259
- if (!state.endTime) state.endTime = new Date(state.lastCycleStart.getTime() + 24 * 3600 * 1e3);
363
+ if (!state.endTime) state.endTime = new Date(state.lastCycleStart.getTime() + 7 * 24 * 3600 * 1e3);
260
364
  if (!(state.endTime instanceof Date)) state.endTime = new Date(state.endTime);
261
365
  }
262
366
  if (config.enableManualControl) {
@@ -285,14 +389,14 @@ function apply(ctx, config) {
285
389
  if (!state) {
286
390
  needNewState = true;
287
391
  } else {
288
- const endTime = state.endTime || new Date(state.lastCycleStart.getTime() + 24 * 3600 * 1e3);
392
+ const endTime = state.endTime || new Date(state.lastCycleStart.getTime() + 7 * 24 * 3600 * 1e3);
289
393
  if (now > endTime) {
290
394
  needNewState = true;
291
395
  }
292
396
  }
293
397
  if (needNewState) {
294
- const durationHours = 24;
295
- const fluctuation = 0.25;
398
+ const durationHours = 7 * 24;
399
+ const fluctuation = 0.3;
296
400
  const targetRatio = 1 + (Math.random() * 2 - 1) * fluctuation;
297
401
  const targetPrice = currentPrice * targetRatio;
298
402
  const endTime = new Date(now.getTime() + durationHours * 3600 * 1e3);
@@ -315,19 +419,35 @@ function apply(ctx, config) {
315
419
  state = newState;
316
420
  }
317
421
  }
422
+ const timeSinceLastSwitch = now.getTime() - lastPatternSwitchTime.getTime();
423
+ const forceSwitchDuration = 30 * 3600 * 1e3;
424
+ if (now >= nextPatternSwitchTime || timeSinceLastSwitch > forceSwitchDuration) {
425
+ switchKLinePattern("随机时间");
426
+ }
318
427
  const trend = state.trendFactor * 2;
319
- const volatility = currentPrice * 5e-3 * (Math.random() * 2 - 1);
428
+ const volatility = currentPrice * 3e-3 * (Math.random() * 2 - 1);
429
+ const dayStart = new Date(now);
430
+ dayStart.setHours(config.openHour, 0, 0, 0);
431
+ const dayEnd = new Date(now);
432
+ dayEnd.setHours(config.closeHour, 0, 0, 0);
433
+ const dayDuration = dayEnd.getTime() - dayStart.getTime();
434
+ const dayElapsed = now.getTime() - dayStart.getTime();
435
+ const dayProgress = Math.max(0, Math.min(1, dayElapsed / dayDuration));
436
+ const dailyAmplitude = state.startPrice * 0.05;
437
+ const patternFn = kLinePatterns[currentDayPattern];
438
+ const prevDayProgress = Math.max(0, (dayElapsed - 2 * 60 * 1e3) / dayDuration);
439
+ const patternDelta = (patternFn(dayProgress) - patternFn(prevDayProgress)) * dailyAmplitude;
320
440
  const totalDuration = state.endTime.getTime() - state.lastCycleStart.getTime();
321
441
  const elapsed = now.getTime() - state.lastCycleStart.getTime();
322
442
  const prevElapsed = elapsed - 2 * 60 * 1e3;
323
- const waveCount = 3;
324
- const amplitude = state.startPrice * 0.15;
443
+ const waveCount = 7;
444
+ const weeklyAmplitude = state.startPrice * 0.08;
325
445
  const getWaveValue = /* @__PURE__ */ __name((t) => {
326
446
  const progress = t / totalDuration;
327
- return amplitude * Math.sin(2 * Math.PI * waveCount * progress);
447
+ return weeklyAmplitude * (Math.sin(2 * Math.PI * waveCount * progress) * 0.7 + Math.sin(2 * Math.PI * waveCount * 2.5 * progress) * 0.3);
328
448
  }, "getWaveValue");
329
449
  const waveDelta = getWaveValue(elapsed) - getWaveValue(prevElapsed);
330
- let newPrice = currentPrice + trend + volatility + waveDelta;
450
+ let newPrice = currentPrice + trend + volatility + patternDelta + waveDelta;
331
451
  if (newPrice < 1) newPrice = 1;
332
452
  currentPrice = newPrice;
333
453
  await ctx.database.create("bourse_history", { stockId, price: newPrice, time: /* @__PURE__ */ new Date() });
@@ -541,6 +661,7 @@ function apply(ctx, config) {
541
661
  });
542
662
  ctx.command("bourse.admin.market <status>", "设置股市开关状态 (open/close/auto)", { authority: 3 }).action(async ({ session }, status) => {
543
663
  if (!["open", "close", "auto"].includes(status)) return "无效状态,请使用 open, close, 或 auto";
664
+ const wasOpen = await isMarketOpen();
544
665
  const key = "macro_state";
545
666
  const existing = await ctx.database.get("bourse_state", { key });
546
667
  if (existing.length === 0) {
@@ -558,8 +679,18 @@ function apply(ctx, config) {
558
679
  } else {
559
680
  await ctx.database.set("bourse_state", { key }, { marketOpenStatus: status });
560
681
  }
682
+ if (status === "open" && !wasOpen) {
683
+ switchKLinePattern("管理员开市");
684
+ wasMarketOpen = true;
685
+ } else if (status === "close") {
686
+ wasMarketOpen = false;
687
+ }
561
688
  return `股市状态已设置为: ${status}`;
562
689
  });
690
+ ctx.command("stock.pattern", "管理员:强制切换K线模型", { authority: 3 }).action(() => {
691
+ switchKLinePattern("管理员手动");
692
+ return "已切换K线模型。";
693
+ });
563
694
  async function renderStockImage(ctx2, data, name2, current, high, low) {
564
695
  if (data.length < 2) return "数据不足,无法绘制走势图。";
565
696
  const startPrice = data[0].price;
@@ -741,17 +872,65 @@ function apply(ctx, config) {
741
872
  ctx.fillStyle = '#999';
742
873
  ctx.font = '500 18px "Segoe UI", sans-serif';
743
874
 
744
- const timeStep = Math.ceil(times.length / 5);
875
+ // 动态计算标签间隔,防止重叠
876
+ // 使用最长的时间标签来估算宽度
877
+ let maxLabelWidth = 0;
878
+ for (let i = 0; i < times.length; i++) {
879
+ const w = ctx.measureText(times[i]).width;
880
+ if (w > maxLabelWidth) maxLabelWidth = w;
881
+ }
882
+ const labelWidth = maxLabelWidth + 40; // 加40px间距确保不重叠
883
+ const availableWidth = width - padding.left - padding.right;
884
+ const maxLabels = Math.max(2, Math.floor(availableWidth / labelWidth));
885
+ const labelCount = Math.min(maxLabels, 5); // 最多显示5个标签
886
+ const timeStep = Math.max(1, Math.ceil(times.length / labelCount));
887
+
888
+ // 选取要绘制的标签索引(均匀分布)
889
+ const labelIndices = [];
745
890
  for (let i = 0; i < times.length; i += timeStep) {
891
+ labelIndices.push(i);
892
+ }
893
+ // 确保最后一个点在列表中
894
+ if (labelIndices[labelIndices.length - 1] !== times.length - 1) {
895
+ labelIndices.push(times.length - 1);
896
+ }
897
+
898
+ // 绘制标签,跳过重叠的
899
+ const drawnLabels = [];
900
+ for (const i of labelIndices) {
901
+ const x = getX(timestamps[i]);
902
+ const textWidth = ctx.measureText(times[i]).width;
903
+
904
+ // 根据textAlign计算实际占用的区域
905
+ let leftEdge, rightEdge;
906
+ if (i === 0) {
907
+ leftEdge = x;
908
+ rightEdge = x + textWidth;
909
+ } else if (i === times.length - 1) {
910
+ leftEdge = x - textWidth;
911
+ rightEdge = x;
912
+ } else {
913
+ leftEdge = x - textWidth / 2;
914
+ rightEdge = x + textWidth / 2;
915
+ }
916
+
917
+ // 检查是否与已绘制的标签重叠
918
+ let overlaps = false;
919
+ for (const drawn of drawnLabels) {
920
+ // 两个标签之间至少要有15px间隔
921
+ if (!(rightEdge + 15 < drawn.left || leftEdge - 15 > drawn.right)) {
922
+ overlaps = true;
923
+ break;
924
+ }
925
+ }
926
+ if (overlaps) continue;
927
+
746
928
  if (i === 0) ctx.textAlign = 'left';
747
- else if (i >= times.length - 1) ctx.textAlign = 'right';
929
+ else if (i === times.length - 1) ctx.textAlign = 'right';
748
930
  else ctx.textAlign = 'center';
749
931
 
750
- ctx.fillText(times[i], getX(timestamps[i]), height - 10);
751
- }
752
- if ((times.length - 1) % timeStep !== 0) {
753
- ctx.textAlign = 'right';
754
- ctx.fillText(times[times.length-1], getX(timestamps[times.length-1]), height - 10);
932
+ ctx.fillText(times[i], x, height - 10);
933
+ drawnLabels.push({ left: leftEdge, right: rightEdge });
755
934
  }
756
935
 
757
936
  </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-monetary-bourse",
3
- "version": "1.0.0-alpha.1",
3
+ "version": "1.0.0-alpha.2",
4
4
  "main": "lib/index.js",
5
5
  "typings": "lib/index.d.ts",
6
6
  "files": [
package/readme.md CHANGED
@@ -2,6 +2,90 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/koishi-plugin-monetary-bourse?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-monetary-bourse)
4
4
 
5
- Provide exchange functionality for monetary
5
+ Koishi 提供基于 `monetary` 通用货币系统的股票交易所功能。
6
6
 
7
- 为monetary货币提供交易所功能
7
+ 本插件模拟了一个具备自动宏观调控、周期性波动和资金冻结机制的简易股票市场。用户可以使用机器人通用的货币(如信用点)进行股票买卖、炒股理财。
8
+
9
+ ## ✨ 特性
10
+
11
+ - **拟真股市算法**:包含长期趋势、随机波动和周期性波浪算法,模拟真实的股价走势。
12
+ - **可视化K线图**:使用 Puppeteer 渲染精美的实时、日线、周线走势图。
13
+ - **资金冻结机制**:交易并非即时到账,根据交易金额计算冻结时间,增加博弈乐趣。
14
+ - **宏观调控**:管理员可以手动干预股价走势,设定目标价格,系统将平滑过渡到目标价。
15
+ - **银行联动**:支持与 `koishi-plugin-monetary-bank` 联动,若现金不足可自动扣除银行活期存款(需安装对应插件)。
16
+ - **休市机制**:支持设置每日开市/休市时间,周末自动休市。
17
+
18
+ ## 📦 依赖
19
+
20
+ 本插件需要以下服务:
21
+ - `database`: 用于存储持仓、历史行情和挂单记录。
22
+ - `puppeteer`: 用于渲染股市行情图。
23
+ - `monetary`: (可选) 用于获取用户货币余额,实际上本插件直接操作 `monetary` 数据库表。
24
+
25
+ ## 🔧 配置项
26
+
27
+ 可以在控制台插件配置页进行设置:
28
+
29
+ ### 基础设置
30
+ - **currency**: 货币单位名称(默认:`信用点`)。
31
+ - **stockName**: 股票名称(默认:`Koishi股份`)。
32
+ - **initialPrice**: 股票初始价格(默认:`1200`)。
33
+ - **maxHoldings**: 单人最大持仓限制(默认:`100000`)。
34
+
35
+ ### 交易时间
36
+ - **openHour**: 每日开市时间(小时,0-23,默认 `8` 点)。
37
+ - **closeHour**: 每日休市时间(小时,0-23,默认 `23` 点)。
38
+ - **marketStatus**: 股市总开关,可选 `open` (强制开启)、`close` (强制关闭)、`auto` (自动按时间)。
39
+
40
+ ### 冻结机制
41
+ 为了防止高频刷钱,交易后资金/股票需要冻结一段时间。
42
+ - **freezeCostPerMinute**: 每多少货币金额计为1分钟冻结时间(默认 `100`)。
43
+ - **minFreezeTime**: 最小冻结时间(分钟,默认 `10`)。
44
+ - **maxFreezeTime**: 最大冻结时间(分钟,默认 `1440` 即24小时)。
45
+
46
+ ### 宏观调控
47
+ - **enableManualControl**: 是否开启手动强力调控模式(覆盖自动波动)。
48
+ - **manualTargetPrice**: 手动模式下的目标价格。
49
+ - **manualDuration**: 手动调控周期(小时)。
50
+
51
+ ## 🎮 指令说明
52
+
53
+ ### 用户指令
54
+
55
+ - **`stock [interval]`**
56
+ - 查看股市行情。
57
+ - 参数 `interval`: 可选 `day` (日线)、`week` (周线),不填默认为实时走势(最近100条)。
58
+ - 示例:`stock` (查看实时), `stock day` (查看日线)。
59
+
60
+ - **`stock.buy <amount>`**
61
+ - 买入股票。
62
+ - 参数 `amount`: 购买股数(整数)。
63
+ - 说明:扣除现金(或银行活期),股票将在冻结时间结束后到账。
64
+
65
+ - **`stock.sell <amount>`**
66
+ - 卖出股票。
67
+ - 参数 `amount`: 卖出股数(整数)。
68
+ - 说明:扣除持仓,获得的资金将在冻结时间结束后到账。
69
+
70
+ - **`stock.my`**
71
+ - 查看我的账户。
72
+ - 显示当前持仓、市值以及正在进行中(冻结中)的买卖订单。
73
+
74
+ ### 管理员指令 (权限等级 3)
75
+
76
+ - **`stock.control <price> [hours]`**
77
+ - 设置宏观调控目标。
78
+ - 说明:强行引导股价在指定时间内向目标价格移动。
79
+ - 示例:`stock.control 5000 12` (在12小时内让股价涨/跌到5000)。
80
+
81
+ - **`bourse.admin.market <status>`**
82
+ - 设置股市开关状态。
83
+ - 参数 `status`: `open` (开启), `close` (关闭), `auto` (自动)。
84
+
85
+ ## 💡 常见问题
86
+
87
+ **Q: 为什么买了股票没有立刻到账?**
88
+ A: 本插件设计了 T+N 机制(基于金额的动态冻结)。使用 `stock.buy` 后,股票会进入“冻结中”状态,使用 `stock.my` 可以看到剩余解冻时间。卖出股票同理,资金需等待解冻。
89
+
90
+ **Q: 股价是如何波动的?**
91
+ A: 股价由三部分组成:长期趋势(宏观调控决定)、随机波动(模拟市场噪音)和正弦波浪(模拟周期性涨跌)。