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', name: 'period',
required: false, required: false,
type: String, 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', example: '1h',
}) })
@ApiQuery({ name: 'limit', required: false, type: Number, description: '返回数量默认100' }) @ApiQuery({ name: 'limit', required: false, type: Number, description: '返回数量默认100' })

View File

@ -273,7 +273,7 @@ export class PriceService {
* K线数据OHLC格式 * K线数据OHLC格式
* K线数据 * 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 limit
* @param before K线数据 * @param before K线数据
*/ */
@ -311,7 +311,7 @@ export class PriceService {
} }
// 按周期聚合为K线 // 按周期聚合为K线
const klines = this.aggregateToKlines(snapshots, periodMinutes); const klines = this.aggregateToKlines(snapshots, periodMinutes, period);
// 返回最近的limit条 // 返回最近的limit条
return klines.slice(-limit); return klines.slice(-limit);
@ -319,6 +319,8 @@ export class PriceService {
/** /**
* *
* 1w/1M/1y
* K 线 getPeriodStart()
*/ */
private parsePeriodToMinutes(period: string): number { private parsePeriodToMinutes(period: string): number {
const periodMap: Record<string, number> = { const periodMap: Record<string, number> = {
@ -328,7 +330,10 @@ export class PriceService {
'30m': 30, '30m': 30,
'1h': 60, '1h': 60,
'4h': 240, '4h': 240,
'1d': 1440, '1d': 1440, // 1天 = 24h
'1w': 10080, // 1周 ≈ 7天近似值实际按周一对齐
'1M': 43200, // 1月 ≈ 30天近似值实际按自然月对齐
'1y': 525600, // 1年 ≈ 365天近似值实际按自然年对齐
}; };
return periodMap[period] || 60; return periodMap[period] || 60;
} }
@ -347,6 +352,7 @@ export class PriceService {
minuteBurnRate: Money; minuteBurnRate: Money;
}>, }>,
periodMinutes: number, periodMinutes: number,
period: string,
): Array<{ ): Array<{
time: string; time: string;
open: string; open: string;
@ -367,7 +373,7 @@ export class PriceService {
// 按周期分组 // 按周期分组
for (const snapshot of snapshots) { for (const snapshot of snapshots) {
const periodStart = this.getPeriodStart(snapshot.snapshotTime, periodMinutes); const periodStart = this.getPeriodStart(snapshot.snapshotTime, periodMinutes, period);
const key = periodStart.getTime(); const key = periodStart.getTime();
if (!klineMap.has(key)) { 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 msPerPeriod = periodMinutes * 60 * 1000;
const periodStart = Math.floor(time.getTime() / msPerPeriod) * msPerPeriod; const periodStart = Math.floor(time.getTime() / msPerPeriod) * msPerPeriod;
return new Date(periodStart); return new Date(periodStart);

View File

@ -42,7 +42,8 @@ class _TradingPageState extends ConsumerState<TradingPage> {
bool _isEditingAmount = false; // bool _isEditingAmount = false; //
bool _isFullScreen = false; // K线图全屏状态 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 @override
void dispose() { void dispose() {

View File

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