koishi-plugin-monetary-bourse 1.1.2-Alpha.6 → 2.0.0-Alpha.8
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 +1 -0
- package/lib/index.js +196 -277
- package/package.json +1 -1
- package/readme.md +119 -83
package/lib/index.d.ts
CHANGED
|
@@ -72,6 +72,7 @@ export interface Config {
|
|
|
72
72
|
minFreezeTime: number;
|
|
73
73
|
maxFreezeTime: number;
|
|
74
74
|
marketStatus: 'open' | 'close' | 'auto';
|
|
75
|
+
enableDebug: boolean;
|
|
75
76
|
}
|
|
76
77
|
export declare const Config: Schema<Config>;
|
|
77
78
|
export declare function apply(ctx: Context, config: Config): void;
|
package/lib/index.js
CHANGED
|
@@ -27,6 +27,8 @@ __export(src_exports, {
|
|
|
27
27
|
});
|
|
28
28
|
module.exports = __toCommonJS(src_exports);
|
|
29
29
|
var import_koishi = require("koishi");
|
|
30
|
+
var import_path = require("path");
|
|
31
|
+
var import_fs = require("fs");
|
|
30
32
|
var name = "monetary-bourse";
|
|
31
33
|
var inject = {
|
|
32
34
|
required: ["database", "puppeteer"],
|
|
@@ -51,7 +53,10 @@ var Config = import_koishi.Schema.intersect([
|
|
|
51
53
|
freezeCostPerMinute: import_koishi.Schema.number().min(1).default(100).description("每多少货币计为1分钟冻结时间"),
|
|
52
54
|
minFreezeTime: import_koishi.Schema.number().min(0).default(10).description("最小冻结时间(分钟)"),
|
|
53
55
|
maxFreezeTime: import_koishi.Schema.number().min(0).default(1440).description("最大交易冻结时间(分钟)")
|
|
54
|
-
}).description("冻结机制")
|
|
56
|
+
}).description("冻结机制"),
|
|
57
|
+
import_koishi.Schema.object({
|
|
58
|
+
enableDebug: import_koishi.Schema.boolean().default(false).description("启用调试模式(开启后可使用调试指令)")
|
|
59
|
+
}).description("开发者选项")
|
|
55
60
|
]);
|
|
56
61
|
function apply(ctx, config) {
|
|
57
62
|
ctx.model.extend("bourse_holding", {
|
|
@@ -105,6 +110,7 @@ function apply(ctx, config) {
|
|
|
105
110
|
let macroWaveCount = 7;
|
|
106
111
|
let macroWeeklyAmplitudeRatio = 0.08;
|
|
107
112
|
let nextMacroSwitchTime = null;
|
|
113
|
+
let __testNow = null;
|
|
108
114
|
ctx.setInterval(async () => {
|
|
109
115
|
const isOpen = await isMarketOpen();
|
|
110
116
|
if (isOpen && !wasMarketOpen) {
|
|
@@ -361,7 +367,7 @@ function apply(ctx, config) {
|
|
|
361
367
|
__name(switchKLinePattern, "switchKLinePattern");
|
|
362
368
|
async function updatePrice() {
|
|
363
369
|
let state = (await ctx.database.get("bourse_state", { key: "macro_state" }))[0];
|
|
364
|
-
const now = /* @__PURE__ */ new Date();
|
|
370
|
+
const now = __testNow ?? /* @__PURE__ */ new Date();
|
|
365
371
|
if (state) {
|
|
366
372
|
if (!state.lastCycleStart) state.lastCycleStart = new Date(Date.now() - 7 * 24 * 3600 * 1e3);
|
|
367
373
|
if (!(state.lastCycleStart instanceof Date)) state.lastCycleStart = new Date(state.lastCycleStart);
|
|
@@ -373,25 +379,22 @@ function apply(ctx, config) {
|
|
|
373
379
|
needNewState = true;
|
|
374
380
|
} else {
|
|
375
381
|
const endTime = state.endTime || new Date(state.lastCycleStart.getTime() + 7 * 24 * 3600 * 1e3);
|
|
376
|
-
if (
|
|
382
|
+
if (now > endTime) needNewState = true;
|
|
377
383
|
}
|
|
378
384
|
const createAutoState = /* @__PURE__ */ __name(async () => {
|
|
379
385
|
const durationHours = 7 * 24;
|
|
380
|
-
const fluctuation = 0.
|
|
386
|
+
const fluctuation = 0.25;
|
|
381
387
|
const targetRatio = 1 + (Math.random() * 2 - 1) * fluctuation;
|
|
382
|
-
let
|
|
383
|
-
|
|
384
|
-
const lowerTarget = currentPrice * 0.5;
|
|
385
|
-
targetPrice = Math.max(lowerTarget, Math.min(upperTarget, targetPrice));
|
|
388
|
+
let targetPrice2 = currentPrice * targetRatio;
|
|
389
|
+
targetPrice2 = Math.max(currentPrice * 0.5, Math.min(currentPrice * 1.5, targetPrice2));
|
|
386
390
|
const endTime = new Date(now.getTime() + durationHours * 3600 * 1e3);
|
|
387
|
-
const minutes = durationHours * 60;
|
|
388
|
-
const trendFactor = (targetPrice - currentPrice) / minutes;
|
|
389
391
|
const newState = {
|
|
390
392
|
key: "macro_state",
|
|
391
393
|
lastCycleStart: now,
|
|
392
394
|
startPrice: currentPrice,
|
|
393
|
-
targetPrice,
|
|
394
|
-
trendFactor,
|
|
395
|
+
targetPrice: targetPrice2,
|
|
396
|
+
trendFactor: 0,
|
|
397
|
+
// 不再使用线性趋势因子
|
|
395
398
|
mode: "auto",
|
|
396
399
|
endTime
|
|
397
400
|
};
|
|
@@ -415,10 +418,10 @@ function apply(ctx, config) {
|
|
|
415
418
|
switchKLinePattern("随机时间");
|
|
416
419
|
}
|
|
417
420
|
const basePrice = state.startPrice;
|
|
421
|
+
const targetPrice = state.targetPrice;
|
|
418
422
|
const totalDuration = state.endTime.getTime() - state.lastCycleStart.getTime();
|
|
419
|
-
const elapsed =
|
|
423
|
+
const elapsed = now.getTime() - state.lastCycleStart.getTime();
|
|
420
424
|
const cycleProgress = Math.max(0, Math.min(1, elapsed / totalDuration));
|
|
421
|
-
const trendPrice = basePrice + (state.targetPrice - basePrice) * cycleProgress;
|
|
422
425
|
const dayStart = new Date(now);
|
|
423
426
|
dayStart.setHours(config.openHour, 0, 0, 0);
|
|
424
427
|
const dayEnd = new Date(now);
|
|
@@ -426,13 +429,33 @@ function apply(ctx, config) {
|
|
|
426
429
|
const dayDuration = dayEnd.getTime() - dayStart.getTime();
|
|
427
430
|
const dayElapsed = now.getTime() - dayStart.getTime();
|
|
428
431
|
const dayProgress = Math.max(0, Math.min(1, dayElapsed / dayDuration));
|
|
432
|
+
const expectedBase = basePrice + (targetPrice - basePrice) * cycleProgress;
|
|
433
|
+
const wavePhaseForMean = 2 * Math.PI * macroWaveCount * cycleProgress;
|
|
434
|
+
const weeklyAmplitudeRatioForMean = Math.min(Math.max(macroWeeklyAmplitudeRatio, 0.04), 0.12);
|
|
435
|
+
const waveMeanBias = Math.sin(wavePhaseForMean) * weeklyAmplitudeRatioForMean;
|
|
436
|
+
const expectedPrice = expectedBase * (1 + waveMeanBias);
|
|
437
|
+
const deviation = (expectedPrice - currentPrice) / currentPrice;
|
|
438
|
+
const meanReversionStrength = 0.05;
|
|
439
|
+
const driftReturn = deviation * meanReversionStrength;
|
|
440
|
+
const getVolatility = /* @__PURE__ */ __name((progress) => {
|
|
441
|
+
const morningVol = Math.exp(-8 * progress);
|
|
442
|
+
const afternoonVol = Math.exp(-8 * (1 - progress));
|
|
443
|
+
const baseVol = 0.3;
|
|
444
|
+
return baseVol + morningVol * 0.5 + afternoonVol * 0.4;
|
|
445
|
+
}, "getVolatility");
|
|
446
|
+
const volatility = getVolatility(dayProgress);
|
|
447
|
+
const u1 = Math.random();
|
|
448
|
+
const u2 = Math.random();
|
|
449
|
+
const normalRandom = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
|
|
450
|
+
const baseVolatilityPerTick = 25e-4;
|
|
451
|
+
const randomReturn = normalRandom * baseVolatilityPerTick * volatility;
|
|
429
452
|
const patternFn = kLinePatterns[currentDayPattern];
|
|
430
|
-
const
|
|
431
|
-
const
|
|
432
|
-
const
|
|
433
|
-
const
|
|
434
|
-
const
|
|
435
|
-
let newPrice =
|
|
453
|
+
const patternValue = patternFn(dayProgress);
|
|
454
|
+
const prevPatternValue = patternFn(Math.max(0, dayProgress - 0.01));
|
|
455
|
+
const patternTrend = (patternValue - prevPatternValue) * 0.8;
|
|
456
|
+
const patternBias = patternTrend * 8e-3;
|
|
457
|
+
const totalReturn = driftReturn + randomReturn + patternBias;
|
|
458
|
+
let newPrice = currentPrice * (1 + totalReturn);
|
|
436
459
|
const dayBase = dailyOpenPrice ?? basePrice;
|
|
437
460
|
const weekUpper = basePrice * 1.5;
|
|
438
461
|
const weekLower = basePrice * 0.5;
|
|
@@ -440,6 +463,14 @@ function apply(ctx, config) {
|
|
|
440
463
|
const dayLower = dayBase * 0.5;
|
|
441
464
|
const upperLimit = Math.min(weekUpper, dayUpper);
|
|
442
465
|
const lowerLimit = Math.max(weekLower, dayLower);
|
|
466
|
+
if (newPrice > upperLimit * 0.95) {
|
|
467
|
+
const overshoot = (newPrice - upperLimit * 0.95) / (upperLimit * 0.05);
|
|
468
|
+
newPrice = upperLimit * 0.95 + upperLimit * 0.05 * Math.tanh(overshoot);
|
|
469
|
+
}
|
|
470
|
+
if (newPrice < lowerLimit * 1.05) {
|
|
471
|
+
const undershoot = (lowerLimit * 1.05 - newPrice) / (lowerLimit * 0.05);
|
|
472
|
+
newPrice = lowerLimit * 1.05 - lowerLimit * 0.05 * Math.tanh(undershoot);
|
|
473
|
+
}
|
|
443
474
|
newPrice = Math.max(lowerLimit, Math.min(upperLimit, newPrice));
|
|
444
475
|
if (newPrice < 1) newPrice = 1;
|
|
445
476
|
newPrice = Number(newPrice.toFixed(2));
|
|
@@ -531,8 +562,8 @@ function apply(ctx, config) {
|
|
|
531
562
|
});
|
|
532
563
|
const high = Math.max(...formattedData.map((d) => d.price));
|
|
533
564
|
const low = Math.min(...formattedData.map((d) => d.price));
|
|
534
|
-
const
|
|
535
|
-
const img = await renderStockImage(ctx, formattedData,
|
|
565
|
+
const viewLabel = interval === "week" ? "周走势" : interval === "day" ? "日走势" : "实时走势";
|
|
566
|
+
const img = await renderStockImage(ctx, formattedData, config.stockName, viewLabel, latest.price, high, low);
|
|
536
567
|
return img;
|
|
537
568
|
});
|
|
538
569
|
ctx.command("stock.buy <amount:number>", "买入股票").userFields(["id"]).action(async ({ session }, amount) => {
|
|
@@ -555,7 +586,15 @@ function apply(ctx, config) {
|
|
|
555
586
|
if (freezeMinutes < config.minFreezeTime) freezeMinutes = config.minFreezeTime;
|
|
556
587
|
}
|
|
557
588
|
const freezeMs = freezeMinutes * 60 * 1e3;
|
|
558
|
-
const
|
|
589
|
+
const userPendingOrders = await ctx.database.get("bourse_pending", { userId: visibleUserId }, { sort: { endTime: "desc" }, limit: 1 });
|
|
590
|
+
let startTime = /* @__PURE__ */ new Date();
|
|
591
|
+
if (userPendingOrders.length > 0) {
|
|
592
|
+
const lastOrderEndTime = userPendingOrders[0].endTime;
|
|
593
|
+
if (lastOrderEndTime > startTime) {
|
|
594
|
+
startTime = lastOrderEndTime;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
const endTime = new Date(startTime.getTime() + freezeMs);
|
|
559
598
|
await ctx.database.create("bourse_pending", {
|
|
560
599
|
userId: visibleUserId,
|
|
561
600
|
uid,
|
|
@@ -564,7 +603,7 @@ function apply(ctx, config) {
|
|
|
564
603
|
amount,
|
|
565
604
|
price: currentPrice,
|
|
566
605
|
cost,
|
|
567
|
-
startTime
|
|
606
|
+
startTime,
|
|
568
607
|
endTime
|
|
569
608
|
});
|
|
570
609
|
if (freezeMinutes === 0) {
|
|
@@ -617,7 +656,15 @@ function apply(ctx, config) {
|
|
|
617
656
|
if (freezeMinutes < config.minFreezeTime) freezeMinutes = config.minFreezeTime;
|
|
618
657
|
}
|
|
619
658
|
const freezeMs = freezeMinutes * 60 * 1e3;
|
|
620
|
-
const
|
|
659
|
+
const userPendingOrders = await ctx.database.get("bourse_pending", { userId: visibleUserId }, { sort: { endTime: "desc" }, limit: 1 });
|
|
660
|
+
let startTime = /* @__PURE__ */ new Date();
|
|
661
|
+
if (userPendingOrders.length > 0) {
|
|
662
|
+
const lastOrderEndTime = userPendingOrders[0].endTime;
|
|
663
|
+
if (lastOrderEndTime > startTime) {
|
|
664
|
+
startTime = lastOrderEndTime;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
const endTime = new Date(startTime.getTime() + freezeMs);
|
|
621
668
|
await ctx.database.create("bourse_pending", {
|
|
622
669
|
userId: visibleUserId,
|
|
623
670
|
uid,
|
|
@@ -626,7 +673,7 @@ function apply(ctx, config) {
|
|
|
626
673
|
amount,
|
|
627
674
|
price: currentPrice,
|
|
628
675
|
cost: gain,
|
|
629
|
-
startTime
|
|
676
|
+
startTime,
|
|
630
677
|
endTime
|
|
631
678
|
});
|
|
632
679
|
if (freezeMinutes === 0) {
|
|
@@ -686,26 +733,26 @@ function apply(ctx, config) {
|
|
|
686
733
|
const duration = hours || 24;
|
|
687
734
|
const now = /* @__PURE__ */ new Date();
|
|
688
735
|
const endTime = new Date(now.getTime() + duration * 3600 * 1e3);
|
|
689
|
-
const
|
|
690
|
-
const
|
|
691
|
-
const
|
|
692
|
-
const
|
|
693
|
-
const lower = Math.max(keepBasePrice * 0.5, dayBase * 0.5);
|
|
736
|
+
const baseStart = currentPrice;
|
|
737
|
+
const dayBase = dailyOpenPrice ?? baseStart;
|
|
738
|
+
const upper = Math.min(baseStart * 1.5, dayBase * 1.5);
|
|
739
|
+
const lower = Math.max(baseStart * 0.5, dayBase * 0.5);
|
|
694
740
|
const targetPriceClamped = Math.max(lower, Math.min(upper, price));
|
|
695
741
|
const minutes = duration * 60;
|
|
696
742
|
const trendFactor = (targetPriceClamped - currentPrice) / minutes;
|
|
697
743
|
const newState = {
|
|
698
744
|
key: "macro_state",
|
|
699
|
-
lastCycleStart:
|
|
700
|
-
//
|
|
701
|
-
startPrice:
|
|
702
|
-
//
|
|
745
|
+
lastCycleStart: now,
|
|
746
|
+
// 开启新周期
|
|
747
|
+
startPrice: currentPrice,
|
|
748
|
+
// 以当前价作为新基准
|
|
703
749
|
targetPrice: targetPriceClamped,
|
|
704
750
|
trendFactor,
|
|
705
751
|
mode: "manual",
|
|
706
752
|
endTime
|
|
707
753
|
};
|
|
708
|
-
|
|
754
|
+
const existing = await ctx.database.get("bourse_state", { key: "macro_state" });
|
|
755
|
+
if (existing.length === 0) {
|
|
709
756
|
await ctx.database.create("bourse_state", newState);
|
|
710
757
|
} else {
|
|
711
758
|
const { key, ...updateFields } = newState;
|
|
@@ -750,6 +797,78 @@ function apply(ctx, config) {
|
|
|
750
797
|
switchKLinePattern("管理员手动");
|
|
751
798
|
return "已切换K线模型。";
|
|
752
799
|
});
|
|
800
|
+
ctx.command("bourse.test.price [ticks:number]", "开发测试:推进价格更新若干次并返回当前价格", { authority: 3 }).action(async ({ session }, ticks) => {
|
|
801
|
+
if (!config.enableDebug) return "调试模式未开启,请在插件配置中启用 enableDebug。";
|
|
802
|
+
const n = typeof ticks === "number" && ticks > 0 ? Math.min(ticks, 500) : 1;
|
|
803
|
+
const stepMs = 2 * 60 * 1e3;
|
|
804
|
+
const startNow = /* @__PURE__ */ new Date();
|
|
805
|
+
__testNow = new Date(startNow);
|
|
806
|
+
let minP = currentPrice, maxP = currentPrice;
|
|
807
|
+
for (let i = 0; i < n; i++) {
|
|
808
|
+
await updatePrice();
|
|
809
|
+
minP = Math.min(minP, currentPrice);
|
|
810
|
+
maxP = Math.max(maxP, currentPrice);
|
|
811
|
+
__testNow = new Date(__testNow.getTime() + stepMs);
|
|
812
|
+
}
|
|
813
|
+
__testNow = null;
|
|
814
|
+
return `测试完成:推进${n}步(每步2分钟)
|
|
815
|
+
当前价格:${Number(currentPrice.toFixed(2))}
|
|
816
|
+
区间最高:${Number(maxP.toFixed(2))} 最低:${Number(minP.toFixed(2))}`;
|
|
817
|
+
});
|
|
818
|
+
ctx.command("bourse.test.run <ticks:number> [step:number]", "开发测试:按虚拟时间推进并统计价格分布", { authority: 3 }).action(async ({ session }, ticks, step) => {
|
|
819
|
+
if (!config.enableDebug) return "调试模式未开启,请在插件配置中启用 enableDebug。";
|
|
820
|
+
const n = Math.max(1, Math.min(Number(ticks) || 1, 2e3));
|
|
821
|
+
const stepSec = Math.max(10, Math.min(Number(step) || 120, 3600));
|
|
822
|
+
const stepMs = stepSec * 1e3;
|
|
823
|
+
const startPrice = currentPrice;
|
|
824
|
+
let minP = startPrice, maxP = startPrice;
|
|
825
|
+
let clampHits = 0;
|
|
826
|
+
const startNow = /* @__PURE__ */ new Date();
|
|
827
|
+
__testNow = new Date(startNow);
|
|
828
|
+
for (let i = 0; i < n; i++) {
|
|
829
|
+
await updatePrice();
|
|
830
|
+
const after = currentPrice;
|
|
831
|
+
minP = Math.min(minP, after);
|
|
832
|
+
maxP = Math.max(maxP, after);
|
|
833
|
+
const baseStart = (await ctx.database.get("bourse_state", { key: "macro_state" }))[0]?.startPrice ?? after;
|
|
834
|
+
const dayBase = dailyOpenPrice ?? baseStart;
|
|
835
|
+
const upper = Math.min(baseStart * 1.5, dayBase * 1.5);
|
|
836
|
+
const lower = Math.max(baseStart * 0.5, dayBase * 0.5);
|
|
837
|
+
if (after >= upper * 0.99 || after <= lower * 1.01) clampHits++;
|
|
838
|
+
__testNow = new Date(__testNow.getTime() + stepMs);
|
|
839
|
+
}
|
|
840
|
+
__testNow = null;
|
|
841
|
+
const drift = Number((currentPrice - startPrice).toFixed(2));
|
|
842
|
+
return `内部测试
|
|
843
|
+
步数:${n};步长:${stepSec}s
|
|
844
|
+
起始:${startPrice.toFixed(2)};结束:${currentPrice.toFixed(2)}(Δ=${drift})
|
|
845
|
+
最高:${maxP.toFixed(2)};最低:${minP.toFixed(2)}
|
|
846
|
+
接近限幅次数:${clampHits}`;
|
|
847
|
+
});
|
|
848
|
+
ctx.command("bourse.test.manualThenAuto <target:number> [hours:number] [ticks:number]", "开发测试:手动周期后切回自动的连续性", { authority: 3 }).action(async ({ session }, target, hours, ticks) => {
|
|
849
|
+
if (!config.enableDebug) return "调试模式未开启,请在插件配置中启用 enableDebug。";
|
|
850
|
+
const dur = Math.max(1, Math.min(Number(hours) || 6, 48));
|
|
851
|
+
const n = Math.max(10, Math.min(Number(ticks) || 300, 5e3));
|
|
852
|
+
await session?.execute?.(`stock.control ${target} ${dur}`);
|
|
853
|
+
const stepMs = 2 * 60 * 1e3;
|
|
854
|
+
__testNow = /* @__PURE__ */ new Date();
|
|
855
|
+
for (let i = 0; i < dur * 30; i++) {
|
|
856
|
+
await updatePrice();
|
|
857
|
+
__testNow = new Date(__testNow.getTime() + stepMs);
|
|
858
|
+
}
|
|
859
|
+
const before = currentPrice;
|
|
860
|
+
for (let i = 0; i < n; i++) {
|
|
861
|
+
await updatePrice();
|
|
862
|
+
__testNow = new Date(__testNow.getTime() + stepMs);
|
|
863
|
+
}
|
|
864
|
+
const after = currentPrice;
|
|
865
|
+
__testNow = null;
|
|
866
|
+
const moved = Math.abs(after - before) >= 0.01;
|
|
867
|
+
return `手动→自动 测试
|
|
868
|
+
目标=${target},期限=${dur}小时
|
|
869
|
+
手动结束价:${before.toFixed(2)};后续${n}步结束:${after.toFixed(2)}
|
|
870
|
+
是否继续波动:${moved ? "是" : "否(需检查)"}`;
|
|
871
|
+
});
|
|
753
872
|
async function renderHoldingImage(ctx2, username, holding, pending, currency) {
|
|
754
873
|
const hasCostData = holding && holding.totalCost !== null;
|
|
755
874
|
const isProfit = hasCostData ? holding.profit >= 0 : true;
|
|
@@ -1092,252 +1211,52 @@ function apply(ctx, config) {
|
|
|
1092
1211
|
return import_koishi.h.image(imgBuf, "image/png");
|
|
1093
1212
|
}
|
|
1094
1213
|
__name(renderHoldingImage, "renderHoldingImage");
|
|
1095
|
-
async function renderStockImage(ctx2, data, name2, current, high, low) {
|
|
1214
|
+
async function renderStockImage(ctx2, data, name2, viewLabel, current, high, low) {
|
|
1096
1215
|
if (data.length < 2) return "数据不足,无法绘制走势图。";
|
|
1097
1216
|
const startPrice = data[0].price;
|
|
1098
1217
|
const change = current - startPrice;
|
|
1099
1218
|
const changePercent = change / startPrice * 100;
|
|
1100
1219
|
const isUp = change >= 0;
|
|
1101
|
-
const
|
|
1102
|
-
|
|
1103
|
-
const
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
</div>
|
|
1142
|
-
<script>
|
|
1143
|
-
const canvas = document.getElementById('chart');
|
|
1144
|
-
const ctx = canvas.getContext('2d');
|
|
1145
|
-
const prices = ${points};
|
|
1146
|
-
const times = ${times};
|
|
1147
|
-
const timestamps = ${timestamps};
|
|
1148
|
-
const width = canvas.width;
|
|
1149
|
-
const height = canvas.height;
|
|
1150
|
-
const padding = { top: 20, bottom: 40, left: 40, right: 100 };
|
|
1151
|
-
|
|
1152
|
-
const max = Math.max(...prices);
|
|
1153
|
-
const min = Math.min(...prices);
|
|
1154
|
-
const range = max - min || 1;
|
|
1155
|
-
const yMin = min - range * 0.1;
|
|
1156
|
-
const yMax = max + range * 0.1;
|
|
1157
|
-
const yRange = yMax - yMin;
|
|
1158
|
-
|
|
1159
|
-
const minTime = timestamps[0];
|
|
1160
|
-
const maxTime = timestamps[timestamps.length - 1];
|
|
1161
|
-
const timeRange = maxTime - minTime || 1;
|
|
1162
|
-
|
|
1163
|
-
function getX(t) { return ((t - minTime) / timeRange) * (width - padding.left - padding.right) + padding.left; }
|
|
1164
|
-
function getY(p) { return height - padding.bottom - ((p - yMin) / yRange) * (height - padding.top - padding.bottom); }
|
|
1165
|
-
|
|
1166
|
-
// 1. Draw Grid
|
|
1167
|
-
ctx.strokeStyle = '#f0f0f0';
|
|
1168
|
-
ctx.lineWidth = 2;
|
|
1169
|
-
ctx.beginPath();
|
|
1170
|
-
const gridSteps = 5;
|
|
1171
|
-
for (let i = 0; i <= gridSteps; i++) {
|
|
1172
|
-
const y = height - padding.bottom - (i / gridSteps) * (height - padding.top - padding.bottom);
|
|
1173
|
-
ctx.moveTo(padding.left, y);
|
|
1174
|
-
ctx.lineTo(width - padding.right, y);
|
|
1175
|
-
}
|
|
1176
|
-
ctx.stroke();
|
|
1177
|
-
|
|
1178
|
-
// 2. Draw Area (Gradient Fill)
|
|
1179
|
-
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
|
1180
|
-
gradient.addColorStop(0, '${isUp ? "rgba(217, 48, 37, 0.15)" : "rgba(24, 128, 56, 0.15)"}');
|
|
1181
|
-
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
|
|
1182
|
-
|
|
1183
|
-
ctx.beginPath();
|
|
1184
|
-
ctx.moveTo(getX(timestamps[0]), height - padding.bottom);
|
|
1185
|
-
// Use Bezier curves for smoothing
|
|
1186
|
-
for (let i = 0; i < prices.length - 1; i++) {
|
|
1187
|
-
const x = getX(timestamps[i]);
|
|
1188
|
-
const y = getY(prices[i]);
|
|
1189
|
-
const nextX = getX(timestamps[i + 1]);
|
|
1190
|
-
const nextY = getY(prices[i + 1]);
|
|
1191
|
-
const cpX = (x + nextX) / 2;
|
|
1192
|
-
if (i === 0) ctx.moveTo(x, y);
|
|
1193
|
-
ctx.quadraticCurveTo(x, y, cpX, (y + nextY) / 2);
|
|
1194
|
-
}
|
|
1195
|
-
// Connect to last point
|
|
1196
|
-
ctx.lineTo(getX(timestamps[prices.length - 1]), getY(prices[prices.length - 1]));
|
|
1197
|
-
|
|
1198
|
-
// Close path for fill
|
|
1199
|
-
ctx.lineTo(getX(timestamps[prices.length - 1]), height - padding.bottom);
|
|
1200
|
-
ctx.closePath();
|
|
1201
|
-
ctx.fillStyle = gradient;
|
|
1202
|
-
ctx.fill();
|
|
1203
|
-
|
|
1204
|
-
// 3. Draw Line (Smooth)
|
|
1205
|
-
ctx.lineWidth = 4;
|
|
1206
|
-
ctx.lineJoin = 'round';
|
|
1207
|
-
ctx.lineCap = 'round';
|
|
1208
|
-
ctx.strokeStyle = '${color}';
|
|
1209
|
-
ctx.shadowColor = '${isUp ? "rgba(217, 48, 37, 0.3)" : "rgba(24, 128, 56, 0.3)"}';
|
|
1210
|
-
ctx.shadowBlur = 10;
|
|
1211
|
-
|
|
1212
|
-
ctx.beginPath();
|
|
1213
|
-
for (let i = 0; i < prices.length - 1; i++) {
|
|
1214
|
-
const x = getX(timestamps[i]);
|
|
1215
|
-
const y = getY(prices[i]);
|
|
1216
|
-
const nextX = getX(timestamps[i + 1]);
|
|
1217
|
-
const nextY = getY(prices[i + 1]);
|
|
1218
|
-
const cpX = (x + nextX) / 2;
|
|
1219
|
-
if (i === 0) ctx.moveTo(x, y);
|
|
1220
|
-
// Use quadratic curve for simple smoothing between points
|
|
1221
|
-
// Actually, to pass through points, we need a different approach or just straight lines for accuracy.
|
|
1222
|
-
// But for "beautify", slight smoothing is okay.
|
|
1223
|
-
// A simple smoothing is to use midpoints as control points.
|
|
1224
|
-
// Let's stick to straight lines for accuracy but add shadow/glow.
|
|
1225
|
-
// Or use a simple spline.
|
|
1226
|
-
// Let's revert to straight lines for financial accuracy but keep the glow.
|
|
1227
|
-
ctx.lineTo(nextX, nextY);
|
|
1228
|
-
}
|
|
1229
|
-
ctx.stroke();
|
|
1230
|
-
ctx.shadowBlur = 0;
|
|
1231
|
-
|
|
1232
|
-
// 4. Draw Last Point Marker
|
|
1233
|
-
const lastX = getX(timestamps[prices.length - 1]);
|
|
1234
|
-
const lastY = getY(prices[prices.length - 1]);
|
|
1235
|
-
|
|
1236
|
-
ctx.beginPath();
|
|
1237
|
-
ctx.arc(lastX, lastY, 6, 0, Math.PI * 2);
|
|
1238
|
-
ctx.fillStyle = 'white';
|
|
1239
|
-
ctx.fill();
|
|
1240
|
-
|
|
1241
|
-
ctx.beginPath();
|
|
1242
|
-
ctx.arc(lastX, lastY, 4, 0, Math.PI * 2);
|
|
1243
|
-
ctx.fillStyle = '${color}';
|
|
1244
|
-
ctx.fill();
|
|
1245
|
-
|
|
1246
|
-
// 5. Draw Dashed Line to Y-Axis
|
|
1247
|
-
ctx.beginPath();
|
|
1248
|
-
ctx.setLineDash([4, 4]);
|
|
1249
|
-
ctx.strokeStyle = '#ccc';
|
|
1250
|
-
ctx.lineWidth = 1;
|
|
1251
|
-
ctx.moveTo(padding.left, lastY);
|
|
1252
|
-
ctx.lineTo(width - padding.right, lastY);
|
|
1253
|
-
ctx.stroke();
|
|
1254
|
-
ctx.setLineDash([]);
|
|
1255
|
-
|
|
1256
|
-
// 6. Draw Axis Labels
|
|
1257
|
-
ctx.fillStyle = '#999';
|
|
1258
|
-
ctx.font = '600 20px "Segoe UI", sans-serif';
|
|
1259
|
-
ctx.textAlign = 'left';
|
|
1260
|
-
ctx.textBaseline = 'middle';
|
|
1261
|
-
|
|
1262
|
-
for (let i = 0; i <= gridSteps; i++) {
|
|
1263
|
-
const val = yMin + (i / gridSteps) * yRange;
|
|
1264
|
-
const y = height - padding.bottom - (i / gridSteps) * (height - padding.top - padding.bottom);
|
|
1265
|
-
ctx.fillText(val.toFixed(2), width - padding.right + 10, y);
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
ctx.fillStyle = '${color}';
|
|
1269
|
-
ctx.font = 'bold 20px "Segoe UI", sans-serif';
|
|
1270
|
-
ctx.fillText(prices[prices.length-1].toFixed(2), width - padding.right + 10, lastY);
|
|
1271
|
-
|
|
1272
|
-
ctx.textAlign = 'center';
|
|
1273
|
-
ctx.fillStyle = '#999';
|
|
1274
|
-
ctx.font = '500 18px "Segoe UI", sans-serif';
|
|
1275
|
-
|
|
1276
|
-
// 动态计算标签间隔,防止重叠
|
|
1277
|
-
// 使用最长的时间标签来估算宽度
|
|
1278
|
-
let maxLabelWidth = 0;
|
|
1279
|
-
for (let i = 0; i < times.length; i++) {
|
|
1280
|
-
const w = ctx.measureText(times[i]).width;
|
|
1281
|
-
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
1282
|
-
}
|
|
1283
|
-
const labelWidth = maxLabelWidth + 40; // 加40px间距确保不重叠
|
|
1284
|
-
const availableWidth = width - padding.left - padding.right;
|
|
1285
|
-
const maxLabels = Math.max(2, Math.floor(availableWidth / labelWidth));
|
|
1286
|
-
const labelCount = Math.min(maxLabels, 5); // 最多显示5个标签
|
|
1287
|
-
const timeStep = Math.max(1, Math.ceil(times.length / labelCount));
|
|
1288
|
-
|
|
1289
|
-
// 选取要绘制的标签索引(均匀分布)
|
|
1290
|
-
const labelIndices = [];
|
|
1291
|
-
for (let i = 0; i < times.length; i += timeStep) {
|
|
1292
|
-
labelIndices.push(i);
|
|
1293
|
-
}
|
|
1294
|
-
// 确保最后一个点在列表中
|
|
1295
|
-
if (labelIndices[labelIndices.length - 1] !== times.length - 1) {
|
|
1296
|
-
labelIndices.push(times.length - 1);
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
// 绘制标签,跳过重叠的
|
|
1300
|
-
const drawnLabels = [];
|
|
1301
|
-
for (const i of labelIndices) {
|
|
1302
|
-
const x = getX(timestamps[i]);
|
|
1303
|
-
const textWidth = ctx.measureText(times[i]).width;
|
|
1304
|
-
|
|
1305
|
-
// 根据textAlign计算实际占用的区域
|
|
1306
|
-
let leftEdge, rightEdge;
|
|
1307
|
-
if (i === 0) {
|
|
1308
|
-
leftEdge = x;
|
|
1309
|
-
rightEdge = x + textWidth;
|
|
1310
|
-
} else if (i === times.length - 1) {
|
|
1311
|
-
leftEdge = x - textWidth;
|
|
1312
|
-
rightEdge = x;
|
|
1313
|
-
} else {
|
|
1314
|
-
leftEdge = x - textWidth / 2;
|
|
1315
|
-
rightEdge = x + textWidth / 2;
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
// 检查是否与已绘制的标签重叠
|
|
1319
|
-
let overlaps = false;
|
|
1320
|
-
for (const drawn of drawnLabels) {
|
|
1321
|
-
// 两个标签之间至少要有15px间隔
|
|
1322
|
-
if (!(rightEdge + 15 < drawn.left || leftEdge - 15 > drawn.right)) {
|
|
1323
|
-
overlaps = true;
|
|
1324
|
-
break;
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
if (overlaps) continue;
|
|
1328
|
-
|
|
1329
|
-
if (i === 0) ctx.textAlign = 'left';
|
|
1330
|
-
else if (i === times.length - 1) ctx.textAlign = 'right';
|
|
1331
|
-
else ctx.textAlign = 'center';
|
|
1332
|
-
|
|
1333
|
-
ctx.fillText(times[i], x, height - 10);
|
|
1334
|
-
drawnLabels.push({ left: leftEdge, right: rightEdge });
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
</script>
|
|
1338
|
-
</body>
|
|
1339
|
-
</html>
|
|
1340
|
-
`;
|
|
1220
|
+
const templatePath = (0, import_path.resolve)(__dirname, "templates", "stock-chart.html");
|
|
1221
|
+
let html = await import_fs.promises.readFile(templatePath, "utf-8");
|
|
1222
|
+
const colorScheme = {
|
|
1223
|
+
mainColor: isUp ? "#f23645" : "#089981",
|
|
1224
|
+
gradientStart: isUp ? "rgba(242, 54, 69, 0.25)" : "rgba(8, 153, 129, 0.25)",
|
|
1225
|
+
gradientEnd: "rgba(255, 255, 255, 0)",
|
|
1226
|
+
glowColor: isUp ? "rgba(242, 54, 69, 0.4)" : "rgba(8, 153, 129, 0.4)",
|
|
1227
|
+
iconGradientStart: isUp ? "#f23645" : "#089981",
|
|
1228
|
+
iconGradientEnd: isUp ? "#ff7e87" : "#40c2aa",
|
|
1229
|
+
iconShadow: isUp ? "rgba(242, 54, 69, 0.3)" : "rgba(8, 153, 129, 0.3)",
|
|
1230
|
+
changeBadgeBg: isUp ? "rgba(242, 54, 69, 0.12)" : "rgba(8, 153, 129, 0.12)"
|
|
1231
|
+
};
|
|
1232
|
+
const replacements = {
|
|
1233
|
+
"{{MAIN_COLOR}}": colorScheme.mainColor,
|
|
1234
|
+
"{{GRADIENT_START}}": colorScheme.gradientStart,
|
|
1235
|
+
"{{GRADIENT_END}}": colorScheme.gradientEnd,
|
|
1236
|
+
"{{GLOW_COLOR}}": colorScheme.glowColor,
|
|
1237
|
+
"{{ICON_GRADIENT_START}}": colorScheme.iconGradientStart,
|
|
1238
|
+
"{{ICON_GRADIENT_END}}": colorScheme.iconGradientEnd,
|
|
1239
|
+
"{{ICON_SHADOW}}": colorScheme.iconShadow,
|
|
1240
|
+
"{{CHANGE_BADGE_BG}}": colorScheme.changeBadgeBg,
|
|
1241
|
+
"{{STOCK_NAME}}": name2,
|
|
1242
|
+
"{{VIEW_LABEL}}": viewLabel,
|
|
1243
|
+
"{{CURRENT_TIME}}": (/* @__PURE__ */ new Date()).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
|
|
1244
|
+
"{{CURRENT_PRICE}}": current.toFixed(2),
|
|
1245
|
+
"{{CHANGE_VALUE}}": `${change >= 0 ? "+" : ""}${change.toFixed(2)}`,
|
|
1246
|
+
"{{CHANGE_ICON}}": change >= 0 ? "↑" : "↓",
|
|
1247
|
+
"{{CHANGE_PERCENT}}": Math.abs(changePercent).toFixed(2),
|
|
1248
|
+
"{{HIGH_PRICE}}": high.toFixed(2),
|
|
1249
|
+
"{{LOW_PRICE}}": low.toFixed(2),
|
|
1250
|
+
"{{AMPLITUDE}}": ((high - low) / startPrice * 100).toFixed(2),
|
|
1251
|
+
"{{START_PRICE}}": startPrice.toFixed(2),
|
|
1252
|
+
"{{UPDATE_TIME}}": (/* @__PURE__ */ new Date()).toLocaleString("zh-CN"),
|
|
1253
|
+
"{{PRICES}}": JSON.stringify(data.map((d) => d.price)),
|
|
1254
|
+
"{{TIMES}}": JSON.stringify(data.map((d) => d.time)),
|
|
1255
|
+
"{{TIMESTAMPS}}": JSON.stringify(data.map((d) => d.timestamp))
|
|
1256
|
+
};
|
|
1257
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
1258
|
+
html = html.replace(new RegExp(key, "g"), value);
|
|
1259
|
+
}
|
|
1341
1260
|
const page = await ctx2.puppeteer.page();
|
|
1342
1261
|
await page.setContent(html);
|
|
1343
1262
|
const element = await page.$(".card");
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -12,58 +12,11 @@
|
|
|
12
12
|
- 内置 **12 种日内走势形态**(如:早盘冲高回落、V型反转、尾盘拉升、M顶/W底等)。
|
|
13
13
|
- 走势不再是单纯的随机波动,每天自动或随机切换不同的操盘剧本,大幅提升观察乐趣。
|
|
14
14
|
- 结合 **周级波浪** 与 **宏观趋势**,模拟真实市场的多周期叠加效应。
|
|
15
|
-
- **📊 精美 K 线与持仓渲染**:使用 Puppeteer 渲染实时/日/周线图和个人持仓信息,
|
|
15
|
+
- **📊 精美 K 线与持仓渲染**:使用 Puppeteer 渲染实时/日/周线图和个人持仓信息,
|
|
16
16
|
- **❄️ 资金冻结机制**:交易采用 T+0 但资金/股票非即时到账模式,根据金额计算冻结时间,增加博弈深度。
|
|
17
17
|
- **🏦 银行联动**:支持与[ `koishi-plugin-monetary-bank`](https://github.com/BYWled/koishi-plugin-monetary-bank)[](https://www.npmjs.com/package/koishi-plugin-monetary-bank) 联动,现金不足时自动扣除银行活期存款。
|
|
18
18
|
- **🕹️ 宏观调控**:管理员可手动干预股价目标,或强制切换当天的 K 线剧本。
|
|
19
19
|
|
|
20
|
-
## 更新记录
|
|
21
|
-
|
|
22
|
-
- **Alpha.6**:
|
|
23
|
-
- **重大重构**:改用百分比波动模式,所有波动(K线、周波浪、噪音)以百分比形式叠加在趋势价格上,彻底解决振幅失控问题。
|
|
24
|
-
- 修复:手动宏观调控不再重置周期基准价和起点,保持现有波动机制持续生效。
|
|
25
|
-
- 优化:K线波动幅度调整为±3%,周波浪和噪音也改为百分比计算,波动更符合真实股票特性。
|
|
26
|
-
- 性能:新的百分比合成模式更稳定,长期运行不会出现价格失控或波动失效。
|
|
27
|
-
|
|
28
|
-
- **Alpha.5**:
|
|
29
|
-
- **重写股票走势引擎**:将增量叠加模式改为绝对价格计算模式,彻底解决长期运行后的高频抖动问题。
|
|
30
|
-
- 修复:走势计算采用绝对值合成(基准价+趋势进度+日内波动+周波浪+噪音),避免累积误差导致的失控。
|
|
31
|
-
- 修复:增强±50%限幅机制,确保所有场景下(实时/日/周)均严格受限。
|
|
32
|
-
- 优化:简化宏观调控状态刷新逻辑,移除复杂的delta计算,提升稳定性。
|
|
33
|
-
- 性能:新引擎更清晰高效,可长期稳定运行,不会出现抖动或限幅失效。
|
|
34
|
-
|
|
35
|
-
- **1.1.1(Alpha.4)**:
|
|
36
|
-
- 新增:硬性涨跌幅上限规则(±50%),同时约束日内与周内视角。随机与手动调控的结果均会在应用前进行限幅。
|
|
37
|
-
- 新增:更随机的自动宏观调控——随机刷新宏观目标(约每 6–24 小时),并随机调整周波浪频率与幅度(波浪段数约 5–10、周幅度约 6%–12%)。
|
|
38
|
-
- 修复:手动宏观调控命令更新状态时,严格排除主键字段,避免 `TypeError: cannot modify primary key`。
|
|
39
|
-
- 变更:移除配置项中的手动宏观调控相关字段(`enableManualControl`、`manualTargetPrice`、`manualDuration`),改为仅通过管理员指令进行宏观调控。
|
|
40
|
-
- 新增:开发测试命令方便快速验证逻辑(`bourse.test.price`、`bourse.test.clamp`),默认注释不启用,若需要请手动修改代码打开调试。
|
|
41
|
-
|
|
42
|
-
- **1.1.0**:
|
|
43
|
-
- Alpha.3版本转为正式版。
|
|
44
|
-
|
|
45
|
-
- **Alpha.3**:
|
|
46
|
-
- 新增:持仓盈亏计算与持仓成本追踪(`totalCost` 字段),在 `stock.my` 中展示成本价、持仓成本、当前市值、盈亏金额与盈亏百分比。
|
|
47
|
-
- 新增:持仓信息 HTML 渲染(`renderHoldingImage`),使用 Puppeteer 输出美观的持仓卡片图像,包含进行中的挂单列表和盈亏高亮显示。
|
|
48
|
-
- 修复:统一所有金额与价格的显示与存储为保留两位小数,避免浮点精度累积导致的计算偏差(涉及 `currentPrice`、历史记录、支付、扣款、银行活期处理等)。
|
|
49
|
-
- 修复:兼容旧版持仓数据(旧记录没有 `totalCost`)——合并新旧持仓时使用合理估算策略以避免成本被稀释:
|
|
50
|
-
- 若旧持仓无成本记录,新买入时以交易单价对旧持仓进行估算(保证合并后平均成本合理);
|
|
51
|
-
- 卖出时若无成本记录则使用当前市价估算以避免错误盈亏展示。
|
|
52
|
-
- 修复:当 `maxFreezeTime = 0`(表示无冻结)时,交易将立即完成并触发即时处理(不再长期挂起)。
|
|
53
|
-
- 修复:支付、现金变更及银行活期扣除流程中均使用两位小数保留,避免因浮点运算导致余额不一致。
|
|
54
|
-
|
|
55
|
-
- **1.0.0**:
|
|
56
|
-
- 修复:解决长期运行时触发的 `TypeError: cannot modify primary key`(更新 `bourse_state` 时排除主键字段,仅按条件写入非主键字段),提高数据库兼容性与稳定性。
|
|
57
|
-
|
|
58
|
-
- **Alpha.2**:
|
|
59
|
-
- 在 Alpha.1 的基础上,扩展并完善了 K 线引擎,新增 12 种日内走势剧本、周级波浪叠加以及坐标轴标签冲突修复等视觉与体验改进。
|
|
60
|
-
- 该版本引入了更丰富的日内走势(例如早盘冲高回落、V 型反转、尾盘拉升、M 顶/W 底等),并改进了 Puppeteer 渲染逻辑以减少横轴标签重叠。
|
|
61
|
-
- 致命问题说明:长期运行时可能触发 `TypeError: cannot modify primary key`(根因:更新持久化 `bourse_state` 时不小心将主键字段包含在更新负载中,某些数据库实现会拒绝修改主键),该问题在后续修复版本中已处理,请在生产环境中尽量升级至稳定版本。
|
|
62
|
-
|
|
63
|
-
- **Alpha.1**:
|
|
64
|
-
- 发布基础功能:买卖、资金冻结、挂单、历史行情存储以及 `stock` 家族指令(`stock` / `stock.buy` / `stock.sell` / `stock.my`)。
|
|
65
|
-
- 支持与 `monetary` 与 `monetary-bank` 的基本联动,提供最小可用的股票交易所模拟能力。
|
|
66
|
-
|
|
67
20
|
## 📦 依赖
|
|
68
21
|
|
|
69
22
|
本插件需要以下服务:
|
|
@@ -71,29 +24,6 @@
|
|
|
71
24
|
- `puppeteer`: 用于渲染股市行情图。
|
|
72
25
|
- `monetary`: (可选) 用于获取用户货币余额(本插件直接操作数据库表,monetary 插件需安装以建立表结构)。
|
|
73
26
|
|
|
74
|
-
## 🔧 配置项
|
|
75
|
-
|
|
76
|
-
可以在控制台插件配置页进行设置:
|
|
77
|
-
|
|
78
|
-
### 基础设置
|
|
79
|
-
- **currency**: 货币单位名称(默认:`信用点`)。
|
|
80
|
-
- **stockName**: 股票名称(默认:`Koishi股份`)。
|
|
81
|
-
- **initialPrice**: 股票初始价格(默认:`1200`)。
|
|
82
|
-
- **maxHoldings**: 单人最大持仓限制(默认:`100000`)。
|
|
83
|
-
|
|
84
|
-
### 交易时间
|
|
85
|
-
- **openHour**: 每日开市时间(小时,0-23,默认 `8` 点)。
|
|
86
|
-
- **closeHour**: 每日休市时间(小时,0-23,默认 `23` 点)。
|
|
87
|
-
- **marketStatus**: 股市总开关,可选 `open` (强制开启)、`close` (强制关闭)、`auto` (自动按时间)。
|
|
88
|
-
|
|
89
|
-
### 冻结机制
|
|
90
|
-
- **freezeCostPerMinute**: 每多少货币金额计为1分钟冻结时间(默认 `100`)。
|
|
91
|
-
- **minFreezeTime**: 最小冻结时间(分钟,默认 `10`)。
|
|
92
|
-
- **maxFreezeTime**: 最大冻结时间(分钟,默认 `1440` 即24小时)。
|
|
93
|
-
|
|
94
|
-
### 宏观调控
|
|
95
|
-
- 已移除配置项中的手动宏观调控字段;请使用管理员指令进行宏观调控。
|
|
96
|
-
|
|
97
27
|
## 🎮 指令说明
|
|
98
28
|
|
|
99
29
|
### 用户指令
|
|
@@ -136,10 +66,16 @@
|
|
|
136
66
|
- 【默认不开启】**`bourse.test.price [ticks]`**
|
|
137
67
|
- 开发测试:推进价格更新若干次并返回当前价格。
|
|
138
68
|
- 参数 `ticks`: 推进次数,默认 1,最大 500。
|
|
69
|
+
- 需要在配置中启用 `enableDebug` 才可使用。
|
|
70
|
+
|
|
71
|
+
- 【默认不开启】**`bourse.test.run <ticks> [step]`**
|
|
72
|
+
- 开发测试:按虚拟时间推进并统计价格分布。
|
|
73
|
+
- 参数 `ticks`: 推进步数;`step`: 每步秒数(默认120秒)。
|
|
74
|
+
- 需要在配置中启用 `enableDebug` 才可使用。
|
|
139
75
|
|
|
140
|
-
- 【默认不开启】**`bourse.test.
|
|
141
|
-
-
|
|
142
|
-
-
|
|
76
|
+
- 【默认不开启】**`bourse.test.manualThenAuto <target> [hours] [ticks]`**
|
|
77
|
+
- 开发测试:测试手动调控后切回自动的连续性。
|
|
78
|
+
- 需要在配置中启用 `enableDebug` 才可使用。
|
|
143
79
|
|
|
144
80
|
## 💡 常见问题
|
|
145
81
|
|
|
@@ -156,17 +92,117 @@ A: 本插件设计了基于交易金额的动态冻结机制。交易额越大
|
|
|
156
92
|
---
|
|
157
93
|
|
|
158
94
|
**Q: 股价是如何波动的?**
|
|
159
|
-
A:
|
|
95
|
+
A: 股价采用**几何布朗运动 + 均值回归**模型(Alpha.7),更贴近真实股票:
|
|
96
|
+
|
|
97
|
+
**核心公式:** `新价格 = 当前价格 × (1 + 总收益率)`
|
|
98
|
+
|
|
99
|
+
**总收益率由以下部分组成:**
|
|
160
100
|
|
|
161
|
-
1.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
4. **微小噪音**:±0.1%的随机扰动避免价格过于规律。
|
|
101
|
+
1. **均值回归项(Drift)**:价格会自然向"预期价格"回归
|
|
102
|
+
- 预期价格 = 周期起始价 → 目标价的线性插值
|
|
103
|
+
- 偏离越大,回归力越强(每次回归5%的偏差,Alpha.8增强)
|
|
165
104
|
|
|
166
|
-
|
|
105
|
+
2. **随机波动项(Random Walk)**:模拟市场的随机性
|
|
106
|
+
- 使用正态分布随机数(Box-Muller变换)
|
|
107
|
+
- 基础波动率约0.25%/tick(Alpha.8增强,原0.12%)
|
|
167
108
|
|
|
168
|
-
|
|
109
|
+
3. **动态波动率**:模拟日内波动率变化
|
|
110
|
+
- 开盘:波动率高(活跃交易)
|
|
111
|
+
- 午盘:波动率低(相对平静)
|
|
112
|
+
- 尾盘:波动率再次升高
|
|
169
113
|
|
|
170
|
-
|
|
114
|
+
4. **K线形态偏置**:12种日内形态提供方向性偏好
|
|
115
|
+
- Alpha.8增强了形态影响力(约3倍),让日内走势更有"剧本感"
|
|
171
116
|
|
|
172
|
-
|
|
117
|
+
5. **周期波浪**:中期波动,模拟市场情绪周期
|
|
118
|
+
- 幅度范围4%-12%(Alpha.8增强,原2%-6%)
|
|
119
|
+
|
|
120
|
+
**限幅机制:** 相对于周期起始价和日开盘价的±50%,采用软着陆方式避免硬切。
|
|
121
|
+
|
|
122
|
+
## 🔧 配置项
|
|
123
|
+
|
|
124
|
+
可以在控制台插件配置页进行设置:
|
|
125
|
+
|
|
126
|
+
### 基础设置
|
|
127
|
+
- **currency**: 货币单位名称(默认:`信用点`)。
|
|
128
|
+
- **stockName**: 股票名称(默认:`Koishi股份`)。
|
|
129
|
+
- **initialPrice**: 股票初始价格(默认:`1200`)。
|
|
130
|
+
- **maxHoldings**: 单人最大持仓限制(默认:`100000`)。
|
|
131
|
+
|
|
132
|
+
### 交易时间
|
|
133
|
+
- **openHour**: 每日开市时间(小时,0-23,默认 `8` 点)。
|
|
134
|
+
- **closeHour**: 每日休市时间(小时,0-23,默认 `23` 点)。
|
|
135
|
+
- **marketStatus**: 股市总开关,可选 `open` (强制开启)、`close` (强制关闭)、`auto` (自动按时间)。
|
|
136
|
+
|
|
137
|
+
### 冻结机制
|
|
138
|
+
- **freezeCostPerMinute**: 每多少货币金额计为1分钟冻结时间(默认 `100`)。
|
|
139
|
+
- **minFreezeTime**: 最小冻结时间(分钟,默认 `10`)。
|
|
140
|
+
- **maxFreezeTime**: 最大冻结时间(分钟,默认 `1440` 即24小时)。
|
|
141
|
+
|
|
142
|
+
### 开发者选项
|
|
143
|
+
- **enableDebug**: 是否启用调试模式(默认 `false`)。开启后可使用 `bourse.test.*` 系列调试指令。
|
|
144
|
+
|
|
145
|
+
### 宏观调控
|
|
146
|
+
- 已移除配置项中的手动宏观调控字段;请使用管理员指令进行宏观调控。
|
|
147
|
+
|
|
148
|
+
## 更新记录
|
|
149
|
+
- **Alpha.8**:
|
|
150
|
+
- **代码与 UI 综合更新**:将股票走势图 HTML 模板提取到独立文件 `src/templates/stock-chart.html` 并改用模板替换渲染,前端采用深色玻璃拟态设计(渐变背景、发光曲线、脉冲指示点),底部新增振幅/开盘价等统计信息,视觉与结构双向优化。进一步调整页面比例为4:3(1024x768),优化字体栈(Inter、SF Pro Display等现代字体),增大字体大小和间距,提升可读性。移除股票名称后的括号后缀,改为动态视图标签(实时走势/日走势/周走势)。修改坐标轴字体为更现代的样式,并修复“数据更新于”行在截图中的可见性问题。
|
|
151
|
+
- **T+0 挂单排队(防绕过机制)**:为避免用户通过拆单(多个小额挂单)绕开冻结时长,本次将挂单(`bourse_pending`)改为按用户串行排队处理。新挂单在创建时会检测该用户最后一个未完成挂单的 `endTime`,若存在则将新挂单的 `startTime` 设置为上一个挂单的 `endTime`,`endTime` 在其基础上累加当前挂单应有的冻结时长。这样同一用户的多个挂单会按先后顺序依次读秒,总冻结时间等于各挂单冻结时长之和,从而与合并为一笔的大额交易保持一致性,避免通过拆单缩短冻结时长的行为。
|
|
152
|
+
- **算法增强**:提升基础波动率(0.12%→0.25%/tick)、增强 K 线形态影响力(约 3 倍)、将周波浪幅度收敛为 4%–12%、并加强均值回归强度(5%/tick);同时改进坐标轴标签算法以减少重叠,整体让走势更生动且可读性更好。
|
|
153
|
+
- **调试与可维护性**:新增 `enableDebug` 配置项(默认关闭),调试命令仅在开启时可用;模板化设计使得 UI 调整无需改动 TypeScript 代码,提高可维护性。
|
|
154
|
+
- 优化:代码更清晰、模板化后便于后续扩展与样式调整。
|
|
155
|
+
|
|
156
|
+
- **Alpha.7**:
|
|
157
|
+
- **采用真实股票模型**:使用几何布朗运动 + 均值回归模型,更贴近真实股票走势。
|
|
158
|
+
- 新增:均值回归机制——价格会自然向"预期价格"回归,而非硬性跳变。
|
|
159
|
+
- 新增:动态波动率——开盘和收盘波动大,午盘相对平静(U型波动率曲线)。
|
|
160
|
+
- 新增:正态分布随机项——使用Box-Muller变换生成更真实的随机波动。
|
|
161
|
+
- 新增:软着陆限幅——接近涨跌幅限制时逐渐减缓,避免硬切导致的不自然走势。
|
|
162
|
+
- 优化:K线形态改为提供方向性偏置,而非直接决定价格位置。
|
|
163
|
+
- 优化:周期波浪改为增量式叠加,更平滑自然。
|
|
164
|
+
|
|
165
|
+
- **Alpha.6**:
|
|
166
|
+
- **再次重构**:改用百分比波动模式,所有波动(K线、周波浪、噪音)以百分比形式叠加在趋势价格上,彻底解决振幅失控问题。
|
|
167
|
+
- 修复:手动宏观调控不再重置周期基准价和起点,保持现有波动机制持续生效。
|
|
168
|
+
- 优化:K线波动幅度调整为±3%,周波浪和噪音也改为百分比计算,波动更符合真实股票特性。
|
|
169
|
+
- 性能:新的百分比合成模式更稳定,长期运行不会出现价格失控或波动失效。
|
|
170
|
+
|
|
171
|
+
- **Alpha.5**:
|
|
172
|
+
- **重写股票走势引擎**:将增量叠加模式改为绝对价格计算模式,彻底解决长期运行后的高频抖动问题。
|
|
173
|
+
- 修复:走势计算采用绝对值合成(基准价+趋势进度+日内波动+周波浪+噪音),避免累积误差导致的失控。
|
|
174
|
+
- 修复:增强±50%限幅机制,确保所有场景下(实时/日/周)均严格受限。
|
|
175
|
+
- 优化:简化宏观调控状态刷新逻辑,移除复杂的delta计算,提升稳定性。
|
|
176
|
+
- 性能:新引擎更清晰高效,可长期稳定运行,不会出现抖动或限幅失效。
|
|
177
|
+
|
|
178
|
+
- **1.1.1(Alpha.4)**:
|
|
179
|
+
- 新增:硬性涨跌幅上限规则(±50%),同时约束日内与周内视角。随机与手动调控的结果均会在应用前进行限幅。
|
|
180
|
+
- 新增:更随机的自动宏观调控——随机刷新宏观目标(约每 6–24 小时),并随机调整周波浪频率与幅度(波浪段数约 5–10、周幅度约 6%–12%)。
|
|
181
|
+
- 修复:手动宏观调控命令更新状态时,严格排除主键字段,避免 `TypeError: cannot modify primary key`。
|
|
182
|
+
- 变更:移除配置项中的手动宏观调控相关字段(`enableManualControl`、`manualTargetPrice`、`manualDuration`),改为仅通过管理员指令进行宏观调控。
|
|
183
|
+
- 新增:开发测试命令方便快速验证逻辑(`bourse.test.price`、`bourse.test.clamp`),默认注释不启用,若需要请手动修改代码打开调试。
|
|
184
|
+
|
|
185
|
+
- **1.1.0**:
|
|
186
|
+
- Alpha.3版本转为正式版。
|
|
187
|
+
|
|
188
|
+
- **Alpha.3**:
|
|
189
|
+
- 新增:持仓盈亏计算与持仓成本追踪(`totalCost` 字段),在 `stock.my` 中展示成本价、持仓成本、当前市值、盈亏金额与盈亏百分比。
|
|
190
|
+
- 新增:持仓信息 HTML 渲染(`renderHoldingImage`),使用 Puppeteer 输出美观的持仓卡片图像,包含进行中的挂单列表和盈亏高亮显示。
|
|
191
|
+
- 修复:统一所有金额与价格的显示与存储为保留两位小数,避免浮点精度累积导致的计算偏差(涉及 `currentPrice`、历史记录、支付、扣款、银行活期处理等)。
|
|
192
|
+
- 修复:兼容旧版持仓数据(旧记录没有 `totalCost`)——合并新旧持仓时使用合理估算策略以避免成本被稀释:
|
|
193
|
+
- 若旧持仓无成本记录,新买入时以交易单价对旧持仓进行估算(保证合并后平均成本合理);
|
|
194
|
+
- 卖出时若无成本记录则使用当前市价估算以避免错误盈亏展示。
|
|
195
|
+
- 修复:当 `maxFreezeTime = 0`(表示无冻结)时,交易将立即完成并触发即时处理(不再长期挂起)。
|
|
196
|
+
- 修复:支付、现金变更及银行活期扣除流程中均使用两位小数保留,避免因浮点运算导致余额不一致。
|
|
197
|
+
|
|
198
|
+
- **1.0.0**:
|
|
199
|
+
- 修复:解决长期运行时触发的 `TypeError: cannot modify primary key`(更新 `bourse_state` 时排除主键字段,仅按条件写入非主键字段),提高数据库兼容性与稳定性。
|
|
200
|
+
|
|
201
|
+
- **Alpha.2**:
|
|
202
|
+
- 在 Alpha.1 的基础上,扩展并完善了 K 线引擎,新增 12 种日内走势剧本、周级波浪叠加以及坐标轴标签冲突修复等视觉与体验改进。
|
|
203
|
+
- 该版本引入了更丰富的日内走势(例如早盘冲高回落、V 型反转、尾盘拉升、M 顶/W 底等),并改进了 Puppeteer 渲染逻辑以减少横轴标签重叠。
|
|
204
|
+
- 致命问题说明:长期运行时可能触发 `TypeError: cannot modify primary key`(根因:更新持久化 `bourse_state` 时不小心将主键字段包含在更新负载中,某些数据库实现会拒绝修改主键),该问题在后续修复版本中已处理,请在生产环境中尽量升级至稳定版本。
|
|
205
|
+
|
|
206
|
+
- **Alpha.1**:
|
|
207
|
+
- 发布基础功能:买卖、资金冻结、挂单、历史行情存储以及 `stock` 家族指令(`stock` / `stock.buy` / `stock.sell` / `stock.my`)。
|
|
208
|
+
- 支持与 `monetary` 与 `monetary-bank` 的基本联动,提供最小可用的股票交易所模拟能力。
|