feat(trading): K线图增加周/月/年周期

- 前端周期选项增加「周」「月」「年」
- 后端 parsePeriodToMinutes 增加 1w/1M/1y 映射
- getPeriodStart 对日历周期按自然边界对齐:
  - 1w → 周一 00:00 UTC(ISO 周标准)
  - 1M → 月初1日 00:00 UTC
  - 1y → 年初1月1日 00:00 UTC
- 固定周期(1m~1d)仍使用 epoch 模运算,不受影响

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-04 18:42:38 -08:00
parent c24f383501
commit 59acea33fe
4 changed files with 42 additions and 8 deletions

View File

@ -57,7 +57,7 @@ export class PriceController {
name: 'period',
required: false,
type: String,
description: 'K线周期: 1m, 5m, 15m, 30m, 1h, 4h, 1d',
description: 'K线周期: 1m, 5m, 15m, 30m, 1h, 4h, 1d, 1w, 1M, 1y',
example: '1h',
})
@ApiQuery({ name: 'limit', required: false, type: Number, description: '返回数量默认100' })

View File

@ -273,7 +273,7 @@ export class PriceService {
* K线数据OHLC格式
* K线数据
*
* @param period K线周期: 1m, 5m, 15m, 30m, 1h, 4h, 1d
* @param period K线周期: 1m, 5m, 15m, 30m, 1h, 4h, 1d, 1w, 1M, 1y
* @param limit
* @param before K线数据
*/
@ -311,7 +311,7 @@ export class PriceService {
}
// 按周期聚合为K线
const klines = this.aggregateToKlines(snapshots, periodMinutes);
const klines = this.aggregateToKlines(snapshots, periodMinutes, period);
// 返回最近的limit条
return klines.slice(-limit);
@ -319,6 +319,8 @@ export class PriceService {
/**
*
* 1w/1M/1y
* K 线 getPeriodStart()
*/
private parsePeriodToMinutes(period: string): number {
const periodMap: Record<string, number> = {
@ -328,7 +330,10 @@ export class PriceService {
'30m': 30,
'1h': 60,
'4h': 240,
'1d': 1440,
'1d': 1440, // 1天 = 24h
'1w': 10080, // 1周 ≈ 7天近似值实际按周一对齐
'1M': 43200, // 1月 ≈ 30天近似值实际按自然月对齐
'1y': 525600, // 1年 ≈ 365天近似值实际按自然年对齐
};
return periodMap[period] || 60;
}
@ -347,6 +352,7 @@ export class PriceService {
minuteBurnRate: Money;
}>,
periodMinutes: number,
period: string,
): Array<{
time: string;
open: string;
@ -367,7 +373,7 @@ export class PriceService {
// 按周期分组
for (const snapshot of snapshots) {
const periodStart = this.getPeriodStart(snapshot.snapshotTime, periodMinutes);
const periodStart = this.getPeriodStart(snapshot.snapshotTime, periodMinutes, period);
const key = periodStart.getTime();
if (!klineMap.has(key)) {
@ -418,8 +424,31 @@ export class PriceService {
/**
*
*
* 1m ~ 1d Unix epoch
* 1w / 1M / 1y使 K 线//
* - 1w 00:00 UTCISO
* - 1M1 00:00 UTC
* - 1y11 00:00 UTC
*/
private getPeriodStart(time: Date, periodMinutes: number): Date {
private getPeriodStart(time: Date, periodMinutes: number, period: string): Date {
if (period === '1w') {
// 周K线对齐到周一 00:00 UTC
const d = new Date(Date.UTC(time.getUTCFullYear(), time.getUTCMonth(), time.getUTCDate()));
const day = d.getUTCDay(); // 0=Sun, 1=Mon, ..., 6=Sat
const diff = day === 0 ? 6 : day - 1; // 周日距周一6天周一距周一0天
d.setUTCDate(d.getUTCDate() - diff);
return d;
}
if (period === '1M') {
// 月K线对齐到月初1日 00:00 UTC
return new Date(Date.UTC(time.getUTCFullYear(), time.getUTCMonth(), 1));
}
if (period === '1y') {
// 年K线对齐到年初1月1日 00:00 UTC
return new Date(Date.UTC(time.getUTCFullYear(), 0, 1));
}
// 固定周期1m, 5m, 15m, 30m, 1h, 4h, 1depoch 模运算
const msPerPeriod = periodMinutes * 60 * 1000;
const periodStart = Math.floor(time.getTime() / msPerPeriod) * msPerPeriod;
return new Date(periodStart);

View File

@ -42,7 +42,8 @@ class _TradingPageState extends ConsumerState<TradingPage> {
bool _isEditingAmount = false; //
bool _isFullScreen = false; // K线图全屏状态
final List<String> _timeRanges = ['1分', '5分', '15分', '30分', '1时', '4时', ''];
// K线周期选项 ///
final List<String> _timeRanges = ['1分', '5分', '15分', '30分', '1时', '4时', '', '', '', ''];
@override
void dispose() {

View File

@ -34,7 +34,8 @@ final buyEnabledProvider = FutureProvider<bool>((ref) async {
// K线周期选择
final selectedKlinePeriodProvider = StateProvider<String>((ref) => '1h');
// API参数
// API参数
// 1w/1M/1y//
String _periodToApiParam(String period) {
const periodMap = {
'1分': '1m',
@ -44,6 +45,9 @@ String _periodToApiParam(String period) {
'1时': '1h',
'4时': '4h',
'': '1d',
'': '1w', // K线
'': '1M', // K线
'': '1y', // K线
};
return periodMap[period] ?? '1h';
}