koishi-plugin-monetary-bourse 1.0.0-alpha.1
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 +80 -0
- package/lib/index.js +777 -0
- package/package.json +59 -0
- package/readme.md +7 -0
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Context, Schema } from 'koishi';
|
|
2
|
+
export declare const name = "monetary-bourse";
|
|
3
|
+
export declare const inject: {
|
|
4
|
+
required: string[];
|
|
5
|
+
optional: string[];
|
|
6
|
+
};
|
|
7
|
+
interface MonetaryBankInterest {
|
|
8
|
+
id: number;
|
|
9
|
+
uid: number;
|
|
10
|
+
currency: string;
|
|
11
|
+
amount: number;
|
|
12
|
+
type: 'demand' | 'fixed';
|
|
13
|
+
rate: number;
|
|
14
|
+
cycle: 'day' | 'week' | 'month';
|
|
15
|
+
settlementDate: Date;
|
|
16
|
+
extendRequested: boolean;
|
|
17
|
+
nextRate?: number;
|
|
18
|
+
nextCycle?: 'day' | 'week' | 'month';
|
|
19
|
+
}
|
|
20
|
+
declare module 'koishi' {
|
|
21
|
+
interface Tables {
|
|
22
|
+
bourse_holding: BourseHolding;
|
|
23
|
+
bourse_pending: BoursePending;
|
|
24
|
+
bourse_history: BourseHistory;
|
|
25
|
+
bourse_state: BourseState;
|
|
26
|
+
monetary_bank_int: MonetaryBankInterest;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export interface BourseHolding {
|
|
30
|
+
id: number;
|
|
31
|
+
userId: string;
|
|
32
|
+
stockId: string;
|
|
33
|
+
amount: number;
|
|
34
|
+
}
|
|
35
|
+
export interface BoursePending {
|
|
36
|
+
id: number;
|
|
37
|
+
userId: string;
|
|
38
|
+
uid: number;
|
|
39
|
+
stockId: string;
|
|
40
|
+
type: 'buy' | 'sell';
|
|
41
|
+
amount: number;
|
|
42
|
+
price: number;
|
|
43
|
+
cost: number;
|
|
44
|
+
startTime: Date;
|
|
45
|
+
endTime: Date;
|
|
46
|
+
}
|
|
47
|
+
export interface BourseHistory {
|
|
48
|
+
id: number;
|
|
49
|
+
stockId: string;
|
|
50
|
+
price: number;
|
|
51
|
+
time: Date;
|
|
52
|
+
}
|
|
53
|
+
export interface BourseState {
|
|
54
|
+
key: string;
|
|
55
|
+
lastCycleStart: Date;
|
|
56
|
+
startPrice: number;
|
|
57
|
+
targetPrice: number;
|
|
58
|
+
trendFactor: number;
|
|
59
|
+
mode: 'auto' | 'manual';
|
|
60
|
+
endTime: Date;
|
|
61
|
+
marketOpenStatus?: 'open' | 'close' | 'auto';
|
|
62
|
+
}
|
|
63
|
+
export interface Config {
|
|
64
|
+
currency: string;
|
|
65
|
+
stockName: string;
|
|
66
|
+
initialPrice: number;
|
|
67
|
+
maxHoldings: number;
|
|
68
|
+
openHour: number;
|
|
69
|
+
closeHour: number;
|
|
70
|
+
freezeCostPerMinute: number;
|
|
71
|
+
minFreezeTime: number;
|
|
72
|
+
maxFreezeTime: number;
|
|
73
|
+
enableManualControl: boolean;
|
|
74
|
+
manualTargetPrice: number;
|
|
75
|
+
manualDuration: number;
|
|
76
|
+
marketStatus: 'open' | 'close' | 'auto';
|
|
77
|
+
}
|
|
78
|
+
export declare const Config: Schema<Config>;
|
|
79
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
80
|
+
export {};
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name2 in all)
|
|
8
|
+
__defProp(target, name2, { get: all[name2], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var src_exports = {};
|
|
22
|
+
__export(src_exports, {
|
|
23
|
+
Config: () => Config,
|
|
24
|
+
apply: () => apply,
|
|
25
|
+
inject: () => inject,
|
|
26
|
+
name: () => name
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(src_exports);
|
|
29
|
+
var import_koishi = require("koishi");
|
|
30
|
+
var name = "monetary-bourse";
|
|
31
|
+
var inject = {
|
|
32
|
+
required: ["database", "puppeteer"],
|
|
33
|
+
optional: ["monetary"]
|
|
34
|
+
};
|
|
35
|
+
var logger = new import_koishi.Logger("bourse");
|
|
36
|
+
var Config = import_koishi.Schema.intersect([
|
|
37
|
+
import_koishi.Schema.object({
|
|
38
|
+
currency: import_koishi.Schema.string().default("信用点").description("货币单位名称"),
|
|
39
|
+
stockName: import_koishi.Schema.string().default("Koishi股份").description("股票名称"),
|
|
40
|
+
initialPrice: import_koishi.Schema.number().min(0.01).default(1200).description("股票初始价格"),
|
|
41
|
+
maxHoldings: import_koishi.Schema.number().min(1).step(1).default(1e5).description("单人最大持仓限制")
|
|
42
|
+
}).description("基础设置"),
|
|
43
|
+
import_koishi.Schema.object({
|
|
44
|
+
marketStatus: import_koishi.Schema.union(["open", "close", "auto"]).default("auto").description("股市开关状态:open=强制开启,close=强制关闭,auto=按时间自动")
|
|
45
|
+
}).description("股市开关"),
|
|
46
|
+
import_koishi.Schema.object({
|
|
47
|
+
openHour: import_koishi.Schema.number().min(0).max(23).step(1).default(8).description("开市时间 (小时)"),
|
|
48
|
+
closeHour: import_koishi.Schema.number().min(0).max(23).step(1).default(23).description("休市时间 (小时)")
|
|
49
|
+
}).description("交易时间"),
|
|
50
|
+
import_koishi.Schema.object({
|
|
51
|
+
freezeCostPerMinute: import_koishi.Schema.number().min(1).default(100).description("每多少货币计为1分钟冻结时间"),
|
|
52
|
+
minFreezeTime: import_koishi.Schema.number().min(0).default(10).description("最小冻结时间(分钟)"),
|
|
53
|
+
maxFreezeTime: import_koishi.Schema.number().min(0).default(1440).description("最大交易冻结时间(分钟)")
|
|
54
|
+
}).description("冻结机制"),
|
|
55
|
+
import_koishi.Schema.object({
|
|
56
|
+
enableManualControl: import_koishi.Schema.boolean().default(false).description("开启手动宏观调控(覆盖自动)"),
|
|
57
|
+
manualTargetPrice: import_koishi.Schema.number().min(0.01).default(1e3).description("手动目标价格"),
|
|
58
|
+
manualDuration: import_koishi.Schema.number().min(1).default(24).description("手动调控周期(小时)")
|
|
59
|
+
}).description("手动宏观调控")
|
|
60
|
+
]);
|
|
61
|
+
function apply(ctx, config) {
|
|
62
|
+
ctx.model.extend("bourse_holding", {
|
|
63
|
+
id: "unsigned",
|
|
64
|
+
userId: "string",
|
|
65
|
+
stockId: "string",
|
|
66
|
+
amount: "integer"
|
|
67
|
+
}, { primary: ["userId", "stockId"] });
|
|
68
|
+
ctx.model.extend("bourse_pending", {
|
|
69
|
+
id: "unsigned",
|
|
70
|
+
userId: "string",
|
|
71
|
+
uid: "unsigned",
|
|
72
|
+
stockId: "string",
|
|
73
|
+
type: "string",
|
|
74
|
+
amount: "integer",
|
|
75
|
+
price: "double",
|
|
76
|
+
cost: "double",
|
|
77
|
+
startTime: "timestamp",
|
|
78
|
+
endTime: "timestamp"
|
|
79
|
+
}, { autoInc: true });
|
|
80
|
+
ctx.model.extend("bourse_history", {
|
|
81
|
+
id: "unsigned",
|
|
82
|
+
stockId: "string",
|
|
83
|
+
price: "double",
|
|
84
|
+
time: "timestamp"
|
|
85
|
+
}, { autoInc: true });
|
|
86
|
+
ctx.model.extend("bourse_state", {
|
|
87
|
+
key: "string",
|
|
88
|
+
lastCycleStart: "timestamp",
|
|
89
|
+
startPrice: "double",
|
|
90
|
+
targetPrice: "double",
|
|
91
|
+
trendFactor: "double",
|
|
92
|
+
mode: "string",
|
|
93
|
+
endTime: "timestamp",
|
|
94
|
+
marketOpenStatus: "string"
|
|
95
|
+
}, { primary: "key" });
|
|
96
|
+
const stockId = "MAIN";
|
|
97
|
+
let currentPrice = config.initialPrice;
|
|
98
|
+
ctx.on("ready", async () => {
|
|
99
|
+
const history = await ctx.database.get("bourse_history", { stockId }, { limit: 1, sort: { time: "desc" } });
|
|
100
|
+
if (history.length > 0) {
|
|
101
|
+
currentPrice = history[0].price;
|
|
102
|
+
} else {
|
|
103
|
+
await ctx.database.create("bourse_history", { stockId, price: currentPrice, time: /* @__PURE__ */ new Date() });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
ctx.setInterval(async () => {
|
|
107
|
+
if (!await isMarketOpen()) return;
|
|
108
|
+
await updatePrice();
|
|
109
|
+
await processPendingTransactions();
|
|
110
|
+
const oneMonthAgo = new Date(Date.now() - 30 * 24 * 3600 * 1e3);
|
|
111
|
+
await ctx.database.remove("bourse_history", { time: { $lt: oneMonthAgo } });
|
|
112
|
+
}, 2 * 60 * 1e3);
|
|
113
|
+
async function isMarketOpen() {
|
|
114
|
+
if (config.marketStatus === "open") return true;
|
|
115
|
+
if (config.marketStatus === "close") return false;
|
|
116
|
+
const states = await ctx.database.get("bourse_state", { key: "macro_state" });
|
|
117
|
+
const state = states[0];
|
|
118
|
+
if (state && state.marketOpenStatus) {
|
|
119
|
+
if (state.marketOpenStatus === "open") return true;
|
|
120
|
+
if (state.marketOpenStatus === "close") return false;
|
|
121
|
+
}
|
|
122
|
+
const now = /* @__PURE__ */ new Date();
|
|
123
|
+
const day = now.getDay();
|
|
124
|
+
const hour = now.getHours();
|
|
125
|
+
if (day === 0 || day === 6) return false;
|
|
126
|
+
if (hour < config.openHour || hour >= config.closeHour) return false;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
__name(isMarketOpen, "isMarketOpen");
|
|
130
|
+
async function getCashBalance(uid, currency) {
|
|
131
|
+
if (!uid || typeof uid !== "number" || Number.isNaN(uid)) {
|
|
132
|
+
logger.warn(`getCashBalance: 无效的uid: ${uid}`);
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const records = await ctx.database.get("monetary", { uid, currency });
|
|
137
|
+
logger.info(`getCashBalance: uid=${uid}, currency=${currency}, records=${JSON.stringify(records)}`);
|
|
138
|
+
if (records && records.length > 0) {
|
|
139
|
+
const value = Number(records[0].value || 0);
|
|
140
|
+
return Number.isNaN(value) ? 0 : value;
|
|
141
|
+
}
|
|
142
|
+
return 0;
|
|
143
|
+
} catch (err) {
|
|
144
|
+
logger.error(`getCashBalance 失败: uid=${uid}, currency=${currency}`, err);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
__name(getCashBalance, "getCashBalance");
|
|
149
|
+
async function changeCashBalance(uid, currency, delta) {
|
|
150
|
+
if (!uid || typeof uid !== "number" || Number.isNaN(uid)) {
|
|
151
|
+
logger.warn(`changeCashBalance: 无效的uid: ${uid}`);
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const records = await ctx.database.get("monetary", { uid, currency });
|
|
156
|
+
if (!records || records.length === 0) {
|
|
157
|
+
if (delta < 0) return false;
|
|
158
|
+
try {
|
|
159
|
+
await ctx.database.create("monetary", { uid, currency, value: delta });
|
|
160
|
+
logger.info(`changeCashBalance: 创建新记录 uid=${uid}, currency=${currency}, value=${delta}`);
|
|
161
|
+
return true;
|
|
162
|
+
} catch (createErr) {
|
|
163
|
+
logger.error(`changeCashBalance 创建记录失败:`, createErr);
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const current = Number(records[0].value || 0);
|
|
168
|
+
const newValue = current + delta;
|
|
169
|
+
if (newValue < 0) {
|
|
170
|
+
logger.warn(`changeCashBalance: 余额不足 current=${current}, delta=${delta}`);
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
await ctx.database.set("monetary", { uid, currency }, { value: newValue });
|
|
174
|
+
logger.info(`changeCashBalance: uid=${uid}, currency=${currency}, ${current} -> ${newValue}`);
|
|
175
|
+
return true;
|
|
176
|
+
} catch (err) {
|
|
177
|
+
logger.error(`changeCashBalance 失败: uid=${uid}, currency=${currency}, delta=${delta}`, err);
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
__name(changeCashBalance, "changeCashBalance");
|
|
182
|
+
async function getBankDemandBalance(uid, currency) {
|
|
183
|
+
if (!uid || typeof uid !== "number" || Number.isNaN(uid)) return 0;
|
|
184
|
+
try {
|
|
185
|
+
const tables = ctx.database.tables;
|
|
186
|
+
if (!tables || !("monetary_bank_int" in tables)) {
|
|
187
|
+
logger.info("getBankDemandBalance: monetary_bank_int 表不存在");
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
const records = await ctx.database.get("monetary_bank_int", { uid, currency, type: "demand" });
|
|
191
|
+
logger.info(`getBankDemandBalance: uid=${uid}, currency=${currency}, records=${records.length}`);
|
|
192
|
+
let total = 0;
|
|
193
|
+
for (const record of records) {
|
|
194
|
+
total += Number(record.amount || 0);
|
|
195
|
+
}
|
|
196
|
+
return total;
|
|
197
|
+
} catch (err) {
|
|
198
|
+
logger.warn(`getBankDemandBalance 失败: uid=${uid}`, err);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
__name(getBankDemandBalance, "getBankDemandBalance");
|
|
203
|
+
async function deductBankDemand(uid, currency, amount) {
|
|
204
|
+
if (!uid || typeof uid !== "number" || Number.isNaN(uid) || amount <= 0) return false;
|
|
205
|
+
try {
|
|
206
|
+
const tables = ctx.database.tables;
|
|
207
|
+
if (!tables || !("monetary_bank_int" in tables)) return false;
|
|
208
|
+
const demandRecords = await ctx.database.select("monetary_bank_int").where({ uid, currency, type: "demand" }).orderBy("settlementDate", "asc").execute();
|
|
209
|
+
let remaining = amount;
|
|
210
|
+
for (const record of demandRecords) {
|
|
211
|
+
if (remaining <= 0) break;
|
|
212
|
+
if (record.amount <= remaining) {
|
|
213
|
+
remaining -= record.amount;
|
|
214
|
+
await ctx.database.remove("monetary_bank_int", { id: record.id });
|
|
215
|
+
} else {
|
|
216
|
+
await ctx.database.set("monetary_bank_int", { id: record.id }, { amount: record.amount - remaining });
|
|
217
|
+
remaining = 0;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
logger.info(`deductBankDemand: uid=${uid}, amount=${amount}, remaining=${remaining}`);
|
|
221
|
+
return remaining === 0;
|
|
222
|
+
} catch (err) {
|
|
223
|
+
logger.error(`deductBankDemand 失败:`, err);
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
__name(deductBankDemand, "deductBankDemand");
|
|
228
|
+
async function pay(uid, cost, currency) {
|
|
229
|
+
logger.info(`pay: uid=${uid}, cost=${cost}, currency=${currency}`);
|
|
230
|
+
const cash = await getCashBalance(uid, currency);
|
|
231
|
+
const bankDemand = await getBankDemandBalance(uid, currency);
|
|
232
|
+
logger.info(`pay: 现金=${cash}, 活期=${bankDemand}, 需要=${cost}`);
|
|
233
|
+
if (cash + bankDemand < cost) {
|
|
234
|
+
return { success: false, msg: `资金不足!需要 ${cost.toFixed(2)},当前现金 ${cash} + 活期 ${bankDemand}` };
|
|
235
|
+
}
|
|
236
|
+
let remainingCost = cost;
|
|
237
|
+
const cashDeduct = Math.min(cash, remainingCost);
|
|
238
|
+
if (cashDeduct > 0) {
|
|
239
|
+
const success = await changeCashBalance(uid, currency, -cashDeduct);
|
|
240
|
+
if (!success) return { success: false, msg: "扣除现金失败,请重试" };
|
|
241
|
+
remainingCost -= cashDeduct;
|
|
242
|
+
}
|
|
243
|
+
if (remainingCost > 0) {
|
|
244
|
+
const success = await deductBankDemand(uid, currency, remainingCost);
|
|
245
|
+
if (!success) {
|
|
246
|
+
if (cashDeduct > 0) await changeCashBalance(uid, currency, cashDeduct);
|
|
247
|
+
return { success: false, msg: "银行活期扣款失败" };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { success: true };
|
|
251
|
+
}
|
|
252
|
+
__name(pay, "pay");
|
|
253
|
+
async function updatePrice() {
|
|
254
|
+
let state = (await ctx.database.get("bourse_state", { key: "macro_state" }))[0];
|
|
255
|
+
const now = /* @__PURE__ */ new Date();
|
|
256
|
+
if (state) {
|
|
257
|
+
if (!state.lastCycleStart) state.lastCycleStart = new Date(Date.now() - 24 * 3600 * 1e3);
|
|
258
|
+
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);
|
|
260
|
+
if (!(state.endTime instanceof Date)) state.endTime = new Date(state.endTime);
|
|
261
|
+
}
|
|
262
|
+
if (config.enableManualControl) {
|
|
263
|
+
if (!state || state.mode !== "manual" || Math.abs(state.targetPrice - config.manualTargetPrice) > 0.01) {
|
|
264
|
+
const durationHours = config.manualDuration;
|
|
265
|
+
const targetPrice = config.manualTargetPrice;
|
|
266
|
+
const endTime = new Date(now.getTime() + durationHours * 3600 * 1e3);
|
|
267
|
+
const minutes = durationHours * 60;
|
|
268
|
+
const trendFactor = (targetPrice - currentPrice) / minutes;
|
|
269
|
+
const newState = {
|
|
270
|
+
key: "macro_state",
|
|
271
|
+
lastCycleStart: now,
|
|
272
|
+
startPrice: currentPrice,
|
|
273
|
+
targetPrice,
|
|
274
|
+
trendFactor,
|
|
275
|
+
mode: "manual",
|
|
276
|
+
endTime
|
|
277
|
+
};
|
|
278
|
+
if (!state) await ctx.database.create("bourse_state", newState);
|
|
279
|
+
else await ctx.database.set("bourse_state", "macro_state", newState);
|
|
280
|
+
state = newState;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
let needNewState = false;
|
|
284
|
+
if (!config.enableManualControl) {
|
|
285
|
+
if (!state) {
|
|
286
|
+
needNewState = true;
|
|
287
|
+
} else {
|
|
288
|
+
const endTime = state.endTime || new Date(state.lastCycleStart.getTime() + 24 * 3600 * 1e3);
|
|
289
|
+
if (now > endTime) {
|
|
290
|
+
needNewState = true;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (needNewState) {
|
|
294
|
+
const durationHours = 24;
|
|
295
|
+
const fluctuation = 0.25;
|
|
296
|
+
const targetRatio = 1 + (Math.random() * 2 - 1) * fluctuation;
|
|
297
|
+
const targetPrice = currentPrice * targetRatio;
|
|
298
|
+
const endTime = new Date(now.getTime() + durationHours * 3600 * 1e3);
|
|
299
|
+
const minutes = durationHours * 60;
|
|
300
|
+
const trendFactor = (targetPrice - currentPrice) / minutes;
|
|
301
|
+
const newState = {
|
|
302
|
+
key: "macro_state",
|
|
303
|
+
lastCycleStart: now,
|
|
304
|
+
startPrice: currentPrice,
|
|
305
|
+
targetPrice,
|
|
306
|
+
trendFactor,
|
|
307
|
+
mode: "auto",
|
|
308
|
+
endTime
|
|
309
|
+
};
|
|
310
|
+
if (!state) {
|
|
311
|
+
await ctx.database.create("bourse_state", newState);
|
|
312
|
+
} else {
|
|
313
|
+
await ctx.database.set("bourse_state", "macro_state", newState);
|
|
314
|
+
}
|
|
315
|
+
state = newState;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const trend = state.trendFactor * 2;
|
|
319
|
+
const volatility = currentPrice * 5e-3 * (Math.random() * 2 - 1);
|
|
320
|
+
const totalDuration = state.endTime.getTime() - state.lastCycleStart.getTime();
|
|
321
|
+
const elapsed = now.getTime() - state.lastCycleStart.getTime();
|
|
322
|
+
const prevElapsed = elapsed - 2 * 60 * 1e3;
|
|
323
|
+
const waveCount = 3;
|
|
324
|
+
const amplitude = state.startPrice * 0.15;
|
|
325
|
+
const getWaveValue = /* @__PURE__ */ __name((t) => {
|
|
326
|
+
const progress = t / totalDuration;
|
|
327
|
+
return amplitude * Math.sin(2 * Math.PI * waveCount * progress);
|
|
328
|
+
}, "getWaveValue");
|
|
329
|
+
const waveDelta = getWaveValue(elapsed) - getWaveValue(prevElapsed);
|
|
330
|
+
let newPrice = currentPrice + trend + volatility + waveDelta;
|
|
331
|
+
if (newPrice < 1) newPrice = 1;
|
|
332
|
+
currentPrice = newPrice;
|
|
333
|
+
await ctx.database.create("bourse_history", { stockId, price: newPrice, time: /* @__PURE__ */ new Date() });
|
|
334
|
+
}
|
|
335
|
+
__name(updatePrice, "updatePrice");
|
|
336
|
+
async function processPendingTransactions() {
|
|
337
|
+
const now = /* @__PURE__ */ new Date();
|
|
338
|
+
const pending = await ctx.database.get("bourse_pending", { endTime: { $lte: now } });
|
|
339
|
+
for (const txn of pending) {
|
|
340
|
+
if (txn.type === "buy") {
|
|
341
|
+
const holding = await ctx.database.get("bourse_holding", { userId: txn.userId, stockId });
|
|
342
|
+
if (holding.length === 0) {
|
|
343
|
+
await ctx.database.create("bourse_holding", { userId: txn.userId, stockId, amount: txn.amount });
|
|
344
|
+
} else {
|
|
345
|
+
await ctx.database.set("bourse_holding", { userId: txn.userId, stockId }, { amount: holding[0].amount + txn.amount });
|
|
346
|
+
}
|
|
347
|
+
} else if (txn.type === "sell") {
|
|
348
|
+
if (txn.uid && typeof txn.uid === "number") {
|
|
349
|
+
const amount = Number(txn.cost.toFixed(2));
|
|
350
|
+
await changeCashBalance(txn.uid, config.currency, amount);
|
|
351
|
+
} else {
|
|
352
|
+
logger.warn(`processPendingTransactions: 卖出订单缺少有效uid, txn.id=${txn.id}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
await ctx.database.remove("bourse_pending", { id: txn.id });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
__name(processPendingTransactions, "processPendingTransactions");
|
|
359
|
+
ctx.command("stock [interval:string]", "查看股市行情").action(async ({ session }, interval) => {
|
|
360
|
+
if (["buy", "sell", "my"].includes(interval)) {
|
|
361
|
+
const parts = session.content.trim().split(/\s+/).slice(2);
|
|
362
|
+
const rest = parts.join(" ");
|
|
363
|
+
return session.execute(`stock.${interval} ${rest}`);
|
|
364
|
+
}
|
|
365
|
+
if (!await isMarketOpen()) return "股市目前休市中。(开放时间:工作日 " + config.openHour + ":00 - " + config.closeHour + ":00)";
|
|
366
|
+
let history;
|
|
367
|
+
const now = /* @__PURE__ */ new Date();
|
|
368
|
+
if (interval === "day") {
|
|
369
|
+
const startTime = new Date(now.getTime() - 24 * 3600 * 1e3);
|
|
370
|
+
history = await ctx.database.get("bourse_history", {
|
|
371
|
+
stockId,
|
|
372
|
+
time: { $gte: startTime }
|
|
373
|
+
}, { sort: { time: "asc" } });
|
|
374
|
+
} else if (interval === "week") {
|
|
375
|
+
const startTime = new Date(now.getTime() - 7 * 24 * 3600 * 1e3);
|
|
376
|
+
history = await ctx.database.get("bourse_history", {
|
|
377
|
+
stockId,
|
|
378
|
+
time: { $gte: startTime }
|
|
379
|
+
}, { sort: { time: "asc" } });
|
|
380
|
+
} else {
|
|
381
|
+
history = await ctx.database.get("bourse_history", { stockId }, {
|
|
382
|
+
limit: 100,
|
|
383
|
+
sort: { time: "desc" }
|
|
384
|
+
});
|
|
385
|
+
history = history.reverse();
|
|
386
|
+
}
|
|
387
|
+
if (history.length === 0) return "暂无行情数据。";
|
|
388
|
+
if (history.length > 300) {
|
|
389
|
+
const step = Math.ceil(history.length / 300);
|
|
390
|
+
history = history.filter((_, index) => index % step === 0);
|
|
391
|
+
}
|
|
392
|
+
const latest = history[history.length - 1];
|
|
393
|
+
const formattedData = history.map((h2) => {
|
|
394
|
+
let timeStr = h2.time.toLocaleTimeString();
|
|
395
|
+
if (interval === "week" || interval === "day") {
|
|
396
|
+
timeStr = `${h2.time.getMonth() + 1}-${h2.time.getDate()} ${h2.time.getHours()}:${h2.time.getMinutes().toString().padStart(2, "0")}`;
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
time: timeStr,
|
|
400
|
+
price: h2.price,
|
|
401
|
+
timestamp: h2.time.getTime()
|
|
402
|
+
};
|
|
403
|
+
});
|
|
404
|
+
const high = Math.max(...formattedData.map((d) => d.price));
|
|
405
|
+
const low = Math.min(...formattedData.map((d) => d.price));
|
|
406
|
+
const title = config.stockName + (interval === "week" ? " (周走势)" : interval === "day" ? " (日走势)" : " (实时)");
|
|
407
|
+
const img = await renderStockImage(ctx, formattedData, title, latest.price, high, low);
|
|
408
|
+
return img;
|
|
409
|
+
});
|
|
410
|
+
ctx.command("stock.buy <amount:number>", "买入股票").userFields(["id"]).action(async ({ session }, amount) => {
|
|
411
|
+
if (!amount || amount <= 0 || !Number.isInteger(amount)) return "请输入有效的购买股数(整数)。";
|
|
412
|
+
if (!await isMarketOpen()) return "休市中,无法交易。";
|
|
413
|
+
const uid = session.user?.id;
|
|
414
|
+
const visibleUserId = session.userId;
|
|
415
|
+
if (!uid || typeof uid !== "number") {
|
|
416
|
+
return "无法获取用户ID,请稍后重试。";
|
|
417
|
+
}
|
|
418
|
+
const cost = Number((currentPrice * amount).toFixed(2));
|
|
419
|
+
const payResult = await pay(uid, cost, config.currency);
|
|
420
|
+
if (!payResult.success) {
|
|
421
|
+
return payResult.msg;
|
|
422
|
+
}
|
|
423
|
+
let freezeMinutes = cost / config.freezeCostPerMinute;
|
|
424
|
+
if (freezeMinutes < config.minFreezeTime) freezeMinutes = config.minFreezeTime;
|
|
425
|
+
if (freezeMinutes > config.maxFreezeTime) freezeMinutes = config.maxFreezeTime;
|
|
426
|
+
const freezeMs = freezeMinutes * 60 * 1e3;
|
|
427
|
+
const endTime = new Date(Date.now() + freezeMs);
|
|
428
|
+
await ctx.database.create("bourse_pending", {
|
|
429
|
+
userId: visibleUserId,
|
|
430
|
+
uid,
|
|
431
|
+
stockId,
|
|
432
|
+
type: "buy",
|
|
433
|
+
amount,
|
|
434
|
+
price: currentPrice,
|
|
435
|
+
cost,
|
|
436
|
+
startTime: /* @__PURE__ */ new Date(),
|
|
437
|
+
endTime
|
|
438
|
+
});
|
|
439
|
+
return `交易申请已提交!
|
|
440
|
+
花费: ${cost.toFixed(2)} ${config.currency}
|
|
441
|
+
冻结时间: ${freezeMinutes.toFixed(1)}分钟
|
|
442
|
+
股票将在解冻后到账。`;
|
|
443
|
+
});
|
|
444
|
+
ctx.command("stock.sell <amount:number>", "卖出股票").userFields(["id"]).action(async ({ session }, amount) => {
|
|
445
|
+
if (!amount || amount <= 0 || !Number.isInteger(amount)) return "请输入有效的卖出股数。";
|
|
446
|
+
if (!await isMarketOpen()) return "休市中,无法交易。";
|
|
447
|
+
const uid = session.user?.id;
|
|
448
|
+
const visibleUserId = session.userId;
|
|
449
|
+
if (!uid || typeof uid !== "number") {
|
|
450
|
+
return "无法获取用户ID,请稍后重试。";
|
|
451
|
+
}
|
|
452
|
+
const holding = await ctx.database.get("bourse_holding", { userId: visibleUserId, stockId });
|
|
453
|
+
if (holding.length === 0 || holding[0].amount < amount) {
|
|
454
|
+
return `持仓不足!当前持有: ${holding.length ? holding[0].amount : 0} 股。`;
|
|
455
|
+
}
|
|
456
|
+
const newAmount = holding[0].amount - amount;
|
|
457
|
+
if (newAmount === 0) {
|
|
458
|
+
await ctx.database.remove("bourse_holding", { userId: visibleUserId, stockId });
|
|
459
|
+
} else {
|
|
460
|
+
await ctx.database.set("bourse_holding", { userId: visibleUserId, stockId }, { amount: newAmount });
|
|
461
|
+
}
|
|
462
|
+
const gain = Number((currentPrice * amount).toFixed(2));
|
|
463
|
+
let freezeMinutes = gain / config.freezeCostPerMinute;
|
|
464
|
+
if (freezeMinutes < config.minFreezeTime) freezeMinutes = config.minFreezeTime;
|
|
465
|
+
if (freezeMinutes > config.maxFreezeTime) freezeMinutes = config.maxFreezeTime;
|
|
466
|
+
const freezeMs = freezeMinutes * 60 * 1e3;
|
|
467
|
+
const endTime = new Date(Date.now() + freezeMs);
|
|
468
|
+
await ctx.database.create("bourse_pending", {
|
|
469
|
+
userId: visibleUserId,
|
|
470
|
+
uid,
|
|
471
|
+
stockId,
|
|
472
|
+
type: "sell",
|
|
473
|
+
amount,
|
|
474
|
+
price: currentPrice,
|
|
475
|
+
cost: gain,
|
|
476
|
+
startTime: /* @__PURE__ */ new Date(),
|
|
477
|
+
endTime
|
|
478
|
+
});
|
|
479
|
+
return `卖出挂单已提交!
|
|
480
|
+
预计收益: ${gain.toFixed(2)} ${config.currency}
|
|
481
|
+
资金冻结: ${freezeMinutes.toFixed(1)}分钟
|
|
482
|
+
资金将在解冻后到账。`;
|
|
483
|
+
});
|
|
484
|
+
ctx.command("stock.my", "我的持仓").action(async ({ session }) => {
|
|
485
|
+
const userId = session.userId;
|
|
486
|
+
const holdings = await ctx.database.get("bourse_holding", { userId });
|
|
487
|
+
const pending = await ctx.database.get("bourse_pending", { userId });
|
|
488
|
+
let msg = `=== ${session.username} 的股票账户 ===
|
|
489
|
+
`;
|
|
490
|
+
if (holdings.length > 0) {
|
|
491
|
+
const h2 = holdings[0];
|
|
492
|
+
const value = h2.amount * currentPrice;
|
|
493
|
+
msg += `持仓: ${config.stockName} x${h2.amount} 股
|
|
494
|
+
`;
|
|
495
|
+
msg += `当前市值: ${value.toFixed(2)} ${config.currency}
|
|
496
|
+
`;
|
|
497
|
+
} else {
|
|
498
|
+
msg += `持仓: 无
|
|
499
|
+
`;
|
|
500
|
+
}
|
|
501
|
+
if (pending.length > 0) {
|
|
502
|
+
msg += `
|
|
503
|
+
--- 进行中的交易 ---
|
|
504
|
+
`;
|
|
505
|
+
for (const p of pending) {
|
|
506
|
+
const timeLeft = Math.max(0, Math.ceil((p.endTime.getTime() - Date.now()) / 1e3));
|
|
507
|
+
const typeStr = p.type === "buy" ? "买入" : "卖出";
|
|
508
|
+
msg += `[${typeStr}] ${p.amount}股 | 剩余冻结: ${timeLeft}秒
|
|
509
|
+
`;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return msg;
|
|
513
|
+
});
|
|
514
|
+
ctx.command("stock.control <price:number> [hours:number]", "管理员:设置宏观调控目标", { authority: 3 }).action(async ({ session }, price, hours) => {
|
|
515
|
+
if (!price || price <= 0) return "请输入有效的目标价格。";
|
|
516
|
+
const duration = hours || 24;
|
|
517
|
+
const now = /* @__PURE__ */ new Date();
|
|
518
|
+
const endTime = new Date(now.getTime() + duration * 3600 * 1e3);
|
|
519
|
+
const minutes = duration * 60;
|
|
520
|
+
const trendFactor = (price - currentPrice) / minutes;
|
|
521
|
+
const newState = {
|
|
522
|
+
key: "macro_state",
|
|
523
|
+
lastCycleStart: now,
|
|
524
|
+
startPrice: currentPrice,
|
|
525
|
+
targetPrice: price,
|
|
526
|
+
trendFactor,
|
|
527
|
+
mode: "manual",
|
|
528
|
+
endTime
|
|
529
|
+
};
|
|
530
|
+
const existing = await ctx.database.get("bourse_state", { key: "macro_state" });
|
|
531
|
+
if (existing.length === 0) {
|
|
532
|
+
await ctx.database.create("bourse_state", newState);
|
|
533
|
+
} else {
|
|
534
|
+
await ctx.database.set("bourse_state", "macro_state", newState);
|
|
535
|
+
}
|
|
536
|
+
return `宏观调控已设置:
|
|
537
|
+
目标价格:${price}
|
|
538
|
+
期限:${duration}小时
|
|
539
|
+
模式:手动干预
|
|
540
|
+
到期后将自动切回随机调控。`;
|
|
541
|
+
});
|
|
542
|
+
ctx.command("bourse.admin.market <status>", "设置股市开关状态 (open/close/auto)", { authority: 3 }).action(async ({ session }, status) => {
|
|
543
|
+
if (!["open", "close", "auto"].includes(status)) return "无效状态,请使用 open, close, 或 auto";
|
|
544
|
+
const key = "macro_state";
|
|
545
|
+
const existing = await ctx.database.get("bourse_state", { key });
|
|
546
|
+
if (existing.length === 0) {
|
|
547
|
+
const now = /* @__PURE__ */ new Date();
|
|
548
|
+
await ctx.database.create("bourse_state", {
|
|
549
|
+
key,
|
|
550
|
+
lastCycleStart: now,
|
|
551
|
+
startPrice: config.initialPrice,
|
|
552
|
+
targetPrice: config.initialPrice,
|
|
553
|
+
trendFactor: 0,
|
|
554
|
+
mode: "auto",
|
|
555
|
+
endTime: new Date(now.getTime() + 24 * 3600 * 1e3),
|
|
556
|
+
marketOpenStatus: status
|
|
557
|
+
});
|
|
558
|
+
} else {
|
|
559
|
+
await ctx.database.set("bourse_state", { key }, { marketOpenStatus: status });
|
|
560
|
+
}
|
|
561
|
+
return `股市状态已设置为: ${status}`;
|
|
562
|
+
});
|
|
563
|
+
async function renderStockImage(ctx2, data, name2, current, high, low) {
|
|
564
|
+
if (data.length < 2) return "数据不足,无法绘制走势图。";
|
|
565
|
+
const startPrice = data[0].price;
|
|
566
|
+
const change = current - startPrice;
|
|
567
|
+
const changePercent = change / startPrice * 100;
|
|
568
|
+
const isUp = change >= 0;
|
|
569
|
+
const color = isUp ? "#d93025" : "#188038";
|
|
570
|
+
const points = JSON.stringify(data.map((d) => d.price));
|
|
571
|
+
const times = JSON.stringify(data.map((d) => d.time));
|
|
572
|
+
const timestamps = JSON.stringify(data.map((d) => d.timestamp));
|
|
573
|
+
const html = `
|
|
574
|
+
<html>
|
|
575
|
+
<head>
|
|
576
|
+
<style>
|
|
577
|
+
body { margin: 0; padding: 20px; font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background: #f5f7fa; width: 700px; box-sizing: border-box; }
|
|
578
|
+
.card { background: white; padding: 25px; border-radius: 16px; box-shadow: 0 10px 25px rgba(0,0,0,0.05); }
|
|
579
|
+
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; }
|
|
580
|
+
.title-group { display: flex; flex-direction: column; }
|
|
581
|
+
.title { font-size: 28px; font-weight: 800; color: #1a1a1a; letter-spacing: -0.5px; }
|
|
582
|
+
.sub-info { font-size: 14px; color: #888; margin-top: 5px; font-weight: 500; }
|
|
583
|
+
.price-group { text-align: right; }
|
|
584
|
+
.price { font-size: 42px; font-weight: 800; color: ${color}; letter-spacing: -1px; line-height: 1; }
|
|
585
|
+
.change { font-size: 18px; font-weight: 600; color: ${color}; margin-top: 5px; display: flex; align-items: center; justify-content: flex-end; gap: 5px; }
|
|
586
|
+
.badge { background: #f0f2f5; padding: 4px 8px; border-radius: 6px; font-size: 12px; color: #555; font-weight: 600; }
|
|
587
|
+
canvas { width: 100%; height: 350px; }
|
|
588
|
+
</style>
|
|
589
|
+
</head>
|
|
590
|
+
<body>
|
|
591
|
+
<div class="card">
|
|
592
|
+
<div class="header">
|
|
593
|
+
<div class="title-group">
|
|
594
|
+
<div class="title">${name2}</div>
|
|
595
|
+
<div class="sub-info">
|
|
596
|
+
<span class="badge">High: ${high.toFixed(2)}</span>
|
|
597
|
+
<span class="badge">Low: ${low.toFixed(2)}</span>
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
<div class="price-group">
|
|
601
|
+
<div class="price">${current.toFixed(2)}</div>
|
|
602
|
+
<div class="change">
|
|
603
|
+
<span>${change >= 0 ? "+" : ""}${change.toFixed(2)}</span>
|
|
604
|
+
<span>(${changePercent.toFixed(2)}%)</span>
|
|
605
|
+
</div>
|
|
606
|
+
</div>
|
|
607
|
+
</div>
|
|
608
|
+
<canvas id="chart" width="1300" height="700"></canvas>
|
|
609
|
+
</div>
|
|
610
|
+
<script>
|
|
611
|
+
const canvas = document.getElementById('chart');
|
|
612
|
+
const ctx = canvas.getContext('2d');
|
|
613
|
+
const prices = ${points};
|
|
614
|
+
const times = ${times};
|
|
615
|
+
const timestamps = ${timestamps};
|
|
616
|
+
const width = canvas.width;
|
|
617
|
+
const height = canvas.height;
|
|
618
|
+
const padding = { top: 20, bottom: 40, left: 40, right: 100 };
|
|
619
|
+
|
|
620
|
+
const max = Math.max(...prices);
|
|
621
|
+
const min = Math.min(...prices);
|
|
622
|
+
const range = max - min || 1;
|
|
623
|
+
const yMin = min - range * 0.1;
|
|
624
|
+
const yMax = max + range * 0.1;
|
|
625
|
+
const yRange = yMax - yMin;
|
|
626
|
+
|
|
627
|
+
const minTime = timestamps[0];
|
|
628
|
+
const maxTime = timestamps[timestamps.length - 1];
|
|
629
|
+
const timeRange = maxTime - minTime || 1;
|
|
630
|
+
|
|
631
|
+
function getX(t) { return ((t - minTime) / timeRange) * (width - padding.left - padding.right) + padding.left; }
|
|
632
|
+
function getY(p) { return height - padding.bottom - ((p - yMin) / yRange) * (height - padding.top - padding.bottom); }
|
|
633
|
+
|
|
634
|
+
// 1. Draw Grid
|
|
635
|
+
ctx.strokeStyle = '#f0f0f0';
|
|
636
|
+
ctx.lineWidth = 2;
|
|
637
|
+
ctx.beginPath();
|
|
638
|
+
const gridSteps = 5;
|
|
639
|
+
for (let i = 0; i <= gridSteps; i++) {
|
|
640
|
+
const y = height - padding.bottom - (i / gridSteps) * (height - padding.top - padding.bottom);
|
|
641
|
+
ctx.moveTo(padding.left, y);
|
|
642
|
+
ctx.lineTo(width - padding.right, y);
|
|
643
|
+
}
|
|
644
|
+
ctx.stroke();
|
|
645
|
+
|
|
646
|
+
// 2. Draw Area (Gradient Fill)
|
|
647
|
+
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
|
648
|
+
gradient.addColorStop(0, '${isUp ? "rgba(217, 48, 37, 0.15)" : "rgba(24, 128, 56, 0.15)"}');
|
|
649
|
+
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
|
|
650
|
+
|
|
651
|
+
ctx.beginPath();
|
|
652
|
+
ctx.moveTo(getX(timestamps[0]), height - padding.bottom);
|
|
653
|
+
// Use Bezier curves for smoothing
|
|
654
|
+
for (let i = 0; i < prices.length - 1; i++) {
|
|
655
|
+
const x = getX(timestamps[i]);
|
|
656
|
+
const y = getY(prices[i]);
|
|
657
|
+
const nextX = getX(timestamps[i + 1]);
|
|
658
|
+
const nextY = getY(prices[i + 1]);
|
|
659
|
+
const cpX = (x + nextX) / 2;
|
|
660
|
+
if (i === 0) ctx.moveTo(x, y);
|
|
661
|
+
ctx.quadraticCurveTo(x, y, cpX, (y + nextY) / 2);
|
|
662
|
+
}
|
|
663
|
+
// Connect to last point
|
|
664
|
+
ctx.lineTo(getX(timestamps[prices.length - 1]), getY(prices[prices.length - 1]));
|
|
665
|
+
|
|
666
|
+
// Close path for fill
|
|
667
|
+
ctx.lineTo(getX(timestamps[prices.length - 1]), height - padding.bottom);
|
|
668
|
+
ctx.closePath();
|
|
669
|
+
ctx.fillStyle = gradient;
|
|
670
|
+
ctx.fill();
|
|
671
|
+
|
|
672
|
+
// 3. Draw Line (Smooth)
|
|
673
|
+
ctx.lineWidth = 4;
|
|
674
|
+
ctx.lineJoin = 'round';
|
|
675
|
+
ctx.lineCap = 'round';
|
|
676
|
+
ctx.strokeStyle = '${color}';
|
|
677
|
+
ctx.shadowColor = '${isUp ? "rgba(217, 48, 37, 0.3)" : "rgba(24, 128, 56, 0.3)"}';
|
|
678
|
+
ctx.shadowBlur = 10;
|
|
679
|
+
|
|
680
|
+
ctx.beginPath();
|
|
681
|
+
for (let i = 0; i < prices.length - 1; i++) {
|
|
682
|
+
const x = getX(timestamps[i]);
|
|
683
|
+
const y = getY(prices[i]);
|
|
684
|
+
const nextX = getX(timestamps[i + 1]);
|
|
685
|
+
const nextY = getY(prices[i + 1]);
|
|
686
|
+
const cpX = (x + nextX) / 2;
|
|
687
|
+
if (i === 0) ctx.moveTo(x, y);
|
|
688
|
+
// Use quadratic curve for simple smoothing between points
|
|
689
|
+
// Actually, to pass through points, we need a different approach or just straight lines for accuracy.
|
|
690
|
+
// But for "beautify", slight smoothing is okay.
|
|
691
|
+
// A simple smoothing is to use midpoints as control points.
|
|
692
|
+
// Let's stick to straight lines for accuracy but add shadow/glow.
|
|
693
|
+
// Or use a simple spline.
|
|
694
|
+
// Let's revert to straight lines for financial accuracy but keep the glow.
|
|
695
|
+
ctx.lineTo(nextX, nextY);
|
|
696
|
+
}
|
|
697
|
+
ctx.stroke();
|
|
698
|
+
ctx.shadowBlur = 0;
|
|
699
|
+
|
|
700
|
+
// 4. Draw Last Point Marker
|
|
701
|
+
const lastX = getX(timestamps[prices.length - 1]);
|
|
702
|
+
const lastY = getY(prices[prices.length - 1]);
|
|
703
|
+
|
|
704
|
+
ctx.beginPath();
|
|
705
|
+
ctx.arc(lastX, lastY, 6, 0, Math.PI * 2);
|
|
706
|
+
ctx.fillStyle = 'white';
|
|
707
|
+
ctx.fill();
|
|
708
|
+
|
|
709
|
+
ctx.beginPath();
|
|
710
|
+
ctx.arc(lastX, lastY, 4, 0, Math.PI * 2);
|
|
711
|
+
ctx.fillStyle = '${color}';
|
|
712
|
+
ctx.fill();
|
|
713
|
+
|
|
714
|
+
// 5. Draw Dashed Line to Y-Axis
|
|
715
|
+
ctx.beginPath();
|
|
716
|
+
ctx.setLineDash([4, 4]);
|
|
717
|
+
ctx.strokeStyle = '#ccc';
|
|
718
|
+
ctx.lineWidth = 1;
|
|
719
|
+
ctx.moveTo(padding.left, lastY);
|
|
720
|
+
ctx.lineTo(width - padding.right, lastY);
|
|
721
|
+
ctx.stroke();
|
|
722
|
+
ctx.setLineDash([]);
|
|
723
|
+
|
|
724
|
+
// 6. Draw Axis Labels
|
|
725
|
+
ctx.fillStyle = '#999';
|
|
726
|
+
ctx.font = '600 20px "Segoe UI", sans-serif';
|
|
727
|
+
ctx.textAlign = 'left';
|
|
728
|
+
ctx.textBaseline = 'middle';
|
|
729
|
+
|
|
730
|
+
for (let i = 0; i <= gridSteps; i++) {
|
|
731
|
+
const val = yMin + (i / gridSteps) * yRange;
|
|
732
|
+
const y = height - padding.bottom - (i / gridSteps) * (height - padding.top - padding.bottom);
|
|
733
|
+
ctx.fillText(val.toFixed(2), width - padding.right + 10, y);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
ctx.fillStyle = '${color}';
|
|
737
|
+
ctx.font = 'bold 20px "Segoe UI", sans-serif';
|
|
738
|
+
ctx.fillText(prices[prices.length-1].toFixed(2), width - padding.right + 10, lastY);
|
|
739
|
+
|
|
740
|
+
ctx.textAlign = 'center';
|
|
741
|
+
ctx.fillStyle = '#999';
|
|
742
|
+
ctx.font = '500 18px "Segoe UI", sans-serif';
|
|
743
|
+
|
|
744
|
+
const timeStep = Math.ceil(times.length / 5);
|
|
745
|
+
for (let i = 0; i < times.length; i += timeStep) {
|
|
746
|
+
if (i === 0) ctx.textAlign = 'left';
|
|
747
|
+
else if (i >= times.length - 1) ctx.textAlign = 'right';
|
|
748
|
+
else ctx.textAlign = 'center';
|
|
749
|
+
|
|
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);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
</script>
|
|
758
|
+
</body>
|
|
759
|
+
</html>
|
|
760
|
+
`;
|
|
761
|
+
const page = await ctx2.puppeteer.page();
|
|
762
|
+
await page.setContent(html);
|
|
763
|
+
const element = await page.$(".card");
|
|
764
|
+
const imgBuf = await element?.screenshot({ encoding: "binary" });
|
|
765
|
+
await page.close();
|
|
766
|
+
return import_koishi.h.image(imgBuf, "image/png");
|
|
767
|
+
}
|
|
768
|
+
__name(renderStockImage, "renderStockImage");
|
|
769
|
+
}
|
|
770
|
+
__name(apply, "apply");
|
|
771
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
772
|
+
0 && (module.exports = {
|
|
773
|
+
Config,
|
|
774
|
+
apply,
|
|
775
|
+
inject,
|
|
776
|
+
name
|
|
777
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "koishi-plugin-monetary-bourse",
|
|
3
|
+
"version": "1.0.0-alpha.1",
|
|
4
|
+
"main": "lib/index.js",
|
|
5
|
+
"typings": "lib/index.d.ts",
|
|
6
|
+
"files": [
|
|
7
|
+
"lib",
|
|
8
|
+
"dist"
|
|
9
|
+
],
|
|
10
|
+
"koishi": {
|
|
11
|
+
"description": {
|
|
12
|
+
"en": "`Provide exchange functionality for monetary money`",
|
|
13
|
+
"zh": "为monetary货币提供交易所功能"
|
|
14
|
+
},
|
|
15
|
+
"service": {
|
|
16
|
+
"required": [
|
|
17
|
+
"database",
|
|
18
|
+
"monetary"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"preview:": true
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"keywords": [
|
|
25
|
+
"chatbot",
|
|
26
|
+
"koishi",
|
|
27
|
+
"plugin",
|
|
28
|
+
"交易所",
|
|
29
|
+
"stock",
|
|
30
|
+
"股票",
|
|
31
|
+
"金融",
|
|
32
|
+
"monetary",
|
|
33
|
+
"货币",
|
|
34
|
+
"炒股",
|
|
35
|
+
"投资",
|
|
36
|
+
"理财",
|
|
37
|
+
"经济",
|
|
38
|
+
"economy"
|
|
39
|
+
],
|
|
40
|
+
"author": {
|
|
41
|
+
"name": "BYWled",
|
|
42
|
+
"email": "bywled@qq.com",
|
|
43
|
+
"url": "https://github.com/BYWled"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/BYWled/koishi-plugin-monetary-bourse",
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "https://github.com/BYWled/koishi-plugin-monetary-bourse"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"koishi": "^4.17.0",
|
|
52
|
+
"koishi-plugin-monetary": "*",
|
|
53
|
+
"koishi-plugin-puppeteer": "*"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"koishi": "^4.18.0",
|
|
57
|
+
"koishi-plugin-monetary": "^0.1.0"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/readme.md
ADDED