111
This commit is contained in:
Binary file not shown.
@@ -1 +1 @@
|
|||||||
{"version":2,"files":[{"path":"D:\\flutter\\bin\\cache\\dart-sdk\\version","hash":"800169ad7335b889bf428af171476466"},{"path":"D:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\.dart_tool\\package_config.json","hash":"210a143189f8879d1701d1cbd9f101c4"},{"path":"D:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\pubspec.yaml","hash":"e1161312ba8c4e95e1db1322589118d8"},{"path":"D:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\build\\9a5d09bec60a9bd952a3f584c1b9bd3b\\dart_build_result.json","hash":"afae0876d3b33abce85dc7253e57b534"},{"path":"D:\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\native_assets.dart","hash":"f78c405bcece3968277b212042da9ed6"},{"path":"d:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\.dart_tool\\package_config.json","hash":"210a143189f8879d1701d1cbd9f101c4"}]}
|
{"version":2,"files":[{"path":"D:\\flutter\\bin\\cache\\dart-sdk\\version","hash":"800169ad7335b889bf428af171476466"},{"path":"D:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\.dart_tool\\package_config.json","hash":"d6b4a7aa67aeb750be9e5aec884f1f73"},{"path":"D:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\pubspec.yaml","hash":"03c567345af5a72ca098cfa0a67b3423"},{"path":"D:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\build\\9a5d09bec60a9bd952a3f584c1b9bd3b\\dart_build_result.json","hash":"92b3cbdc74276047205a9d2c62d9222f"},{"path":"D:\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\native_assets.dart","hash":"f78c405bcece3968277b212042da9ed6"},{"path":"d:\\workspace\\project\\com-rattan-spccloud\\flutter_monisuo\\.dart_tool\\package_config.json","hash":"d6b4a7aa67aeb750be9e5aec884f1f73"}]}
|
||||||
@@ -1 +1 @@
|
|||||||
{"build_start":"2026-04-06T13:57:48.988866","build_end":"2026-04-06T13:57:52.932920","dependencies":["file:///D:/flutter/bin/cache/dart-sdk/version","file:///D:/workspace/project/com-rattan-spccloud/flutter_monisuo/.dart_tool/package_config.json","file:///D:/workspace/project/com-rattan-spccloud/flutter_monisuo/pubspec.yaml","file:///d:/workspace/project/com-rattan-spccloud/flutter_monisuo/.dart_tool/package_config.json"],"code_assets":[],"data_assets":[]}
|
{"build_start":"2026-04-06T18:39:07.915511","build_end":"2026-04-06T18:39:11.314962","dependencies":["file:///D:/flutter/bin/cache/dart-sdk/version","file:///D:/workspace/project/com-rattan-spccloud/flutter_monisuo/.dart_tool/package_config.json","file:///D:/workspace/project/com-rattan-spccloud/flutter_monisuo/pubspec.yaml","file:///d:/workspace/project/com-rattan-spccloud/flutter_monisuo/.dart_tool/package_config.json"],"code_assets":[],"data_assets":[]}
|
||||||
@@ -108,20 +108,4 @@ class ApiEndpoints {
|
|||||||
|
|
||||||
/// 每日盈亏
|
/// 每日盈亏
|
||||||
static const String dailyProfit = '/api/asset/daily-profit';
|
static const String dailyProfit = '/api/asset/daily-profit';
|
||||||
|
|
||||||
// ==================== K线模块 ====================
|
|
||||||
|
|
||||||
/// K线历史数据
|
|
||||||
static const String klineHistory = '/api/kline/history';
|
|
||||||
|
|
||||||
/// 当前K线
|
|
||||||
static const String klineCurrent = '/api/kline/current';
|
|
||||||
|
|
||||||
/// 支持的K线周期
|
|
||||||
static const String klineIntervals = '/api/kline/intervals';
|
|
||||||
|
|
||||||
/// K线 WebSocket 地址
|
|
||||||
static const String klineWs = '${isProduction ? 'ws' : 'ws'}://'
|
|
||||||
'${isProduction ? '8.155.172.147:5010' : 'localhost:5010'}'
|
|
||||||
'/ws/kline';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
/// K线蜡烛数据模型
|
|
||||||
class KlineCandle {
|
|
||||||
final String coinCode;
|
|
||||||
final String interval;
|
|
||||||
final int openTime;
|
|
||||||
final double openPrice;
|
|
||||||
final double highPrice;
|
|
||||||
final double lowPrice;
|
|
||||||
final double closePrice;
|
|
||||||
final double volume;
|
|
||||||
final int closeTime;
|
|
||||||
final bool isClosed;
|
|
||||||
final int? timestamp;
|
|
||||||
|
|
||||||
const KlineCandle({
|
|
||||||
required this.coinCode,
|
|
||||||
required this.interval,
|
|
||||||
required this.openTime,
|
|
||||||
required this.openPrice,
|
|
||||||
required this.highPrice,
|
|
||||||
required this.lowPrice,
|
|
||||||
required this.closePrice,
|
|
||||||
required this.volume,
|
|
||||||
required this.closeTime,
|
|
||||||
this.isClosed = true,
|
|
||||||
this.timestamp,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory KlineCandle.fromJson(Map<String, dynamic> json) {
|
|
||||||
return KlineCandle(
|
|
||||||
coinCode: json['coinCode'] as String? ?? '',
|
|
||||||
interval: json['interval'] as String? ?? '1h',
|
|
||||||
openTime: json['openTime'] as int? ?? 0,
|
|
||||||
openPrice: _toDouble(json['openPrice']),
|
|
||||||
highPrice: _toDouble(json['highPrice']),
|
|
||||||
lowPrice: _toDouble(json['lowPrice']),
|
|
||||||
closePrice: _toDouble(json['closePrice']),
|
|
||||||
volume: _toDouble(json['volume']),
|
|
||||||
closeTime: json['closeTime'] as int? ?? 0,
|
|
||||||
isClosed: json['isClosed'] as bool? ?? true,
|
|
||||||
timestamp: json['timestamp'] as int?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static double _toDouble(dynamic v) {
|
|
||||||
if (v == null) return 0.0;
|
|
||||||
if (v is double) return v;
|
|
||||||
if (v is int) return v.toDouble();
|
|
||||||
if (v is String) return double.tryParse(v) ?? 0.0;
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 转换为 k_chart 库的 KLineEntity 格式
|
|
||||||
Map<String, dynamic> toKLineEntityMap() {
|
|
||||||
return {
|
|
||||||
'open': openPrice,
|
|
||||||
'high': highPrice,
|
|
||||||
'low': lowPrice,
|
|
||||||
'close': closePrice,
|
|
||||||
'vol': volume,
|
|
||||||
'amount': closePrice * volume,
|
|
||||||
'time': openTime,
|
|
||||||
'id': openTime,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 转换为 k_chart KLineEntity 对象
|
|
||||||
dynamic toKLineEntity() {
|
|
||||||
// k_chart KLineEntity.fromCustom 构造器
|
|
||||||
return null; // placeholder, actual conversion in kline_page
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 REST API JSON 转换(历史K线)
|
|
||||||
factory KlineCandle.fromHistoryJson(Map<String, dynamic> json) {
|
|
||||||
return KlineCandle(
|
|
||||||
coinCode: json['coinCode'] as String? ?? '',
|
|
||||||
interval: json['interval'] as String? ?? '1h',
|
|
||||||
openTime: json['openTime'] as int? ?? 0,
|
|
||||||
openPrice: _toDouble(json['openPrice']),
|
|
||||||
highPrice: _toDouble(json['highPrice']),
|
|
||||||
lowPrice: _toDouble(json['lowPrice']),
|
|
||||||
closePrice: _toDouble(json['closePrice']),
|
|
||||||
volume: _toDouble(json['volume']),
|
|
||||||
closeTime: json['closeTime'] as int? ?? 0,
|
|
||||||
isClosed: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import '../../core/constants/api_endpoints.dart';
|
|
||||||
import '../../core/network/api_response.dart';
|
|
||||||
import '../../core/network/dio_client.dart';
|
|
||||||
import '../models/kline_candle.dart';
|
|
||||||
|
|
||||||
/// K线 REST API 服务
|
|
||||||
class KlineService {
|
|
||||||
final DioClient _client;
|
|
||||||
|
|
||||||
KlineService(this._client);
|
|
||||||
|
|
||||||
/// 获取历史K线数据
|
|
||||||
Future<ApiResponse<List<KlineCandle>>> fetchHistory({
|
|
||||||
required String coinCode,
|
|
||||||
required String interval,
|
|
||||||
int limit = 200,
|
|
||||||
int? before,
|
|
||||||
}) async {
|
|
||||||
final params = <String, dynamic>{
|
|
||||||
'coinCode': coinCode,
|
|
||||||
'interval': interval,
|
|
||||||
'limit': limit,
|
|
||||||
};
|
|
||||||
if (before != null) params['before'] = before;
|
|
||||||
|
|
||||||
final response = await _client.get<Map<String, dynamic>>(
|
|
||||||
ApiEndpoints.klineHistory,
|
|
||||||
queryParameters: params,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.success && response.data != null) {
|
|
||||||
final list = response.data!['list'] as List? ?? [];
|
|
||||||
final candles = list
|
|
||||||
.map((e) => KlineCandle.fromHistoryJson(e as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
return ApiResponse.success(candles, response.message);
|
|
||||||
}
|
|
||||||
return ApiResponse.fail(response.message ?? '获取K线数据失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取当前进行中的K线
|
|
||||||
Future<ApiResponse<KlineCandle>> fetchCurrentCandle({
|
|
||||||
required String coinCode,
|
|
||||||
required String interval,
|
|
||||||
}) async {
|
|
||||||
final response = await _client.get<Map<String, dynamic>>(
|
|
||||||
ApiEndpoints.klineCurrent,
|
|
||||||
queryParameters: {'coinCode': coinCode, 'interval': interval},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.success && response.data != null) {
|
|
||||||
return ApiResponse.success(
|
|
||||||
KlineCandle.fromJson(response.data!),
|
|
||||||
response.message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return ApiResponse.fail(response.message ?? '获取当前K线失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取支持的周期列表
|
|
||||||
Future<ApiResponse<List<String>>> fetchIntervals() async {
|
|
||||||
final response = await _client.get<Map<String, dynamic>>(
|
|
||||||
ApiEndpoints.klineIntervals,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.success && response.data != null) {
|
|
||||||
final list = response.data!['list'] as List? ?? [];
|
|
||||||
return ApiResponse.success(list.cast<String>(), response.message);
|
|
||||||
}
|
|
||||||
return ApiResponse.fail(response.message ?? '获取周期列表失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:stomp_dart_client/stomp_dart_client.dart';
|
|
||||||
import '../../core/constants/api_endpoints.dart';
|
|
||||||
import '../models/kline_candle.dart';
|
|
||||||
|
|
||||||
/// K线 WebSocket 服务(STOMP 协议)
|
|
||||||
class KlineWebSocketService {
|
|
||||||
StompClient? _stompClient;
|
|
||||||
final Map<String, dynamic> _subscriptions = {};
|
|
||||||
final Map<String, StreamController<KlineCandle>> _controllers = {};
|
|
||||||
|
|
||||||
bool _isConnected = false;
|
|
||||||
bool _isConnecting = false;
|
|
||||||
int _reconnectDelay = 2000; // 初始重连延迟
|
|
||||||
static const int _maxReconnectDelay = 30000;
|
|
||||||
|
|
||||||
/// 订阅某个币种的K线数据
|
|
||||||
Stream<KlineCandle> subscribe(String coinCode) {
|
|
||||||
final key = coinCode.toUpperCase();
|
|
||||||
if (!_controllers.containsKey(key)) {
|
|
||||||
_controllers[key] = StreamController<KlineCandle>.broadcast();
|
|
||||||
}
|
|
||||||
_doSubscribe(key);
|
|
||||||
return _controllers[key]!.stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 取消订阅
|
|
||||||
void unsubscribe(String coinCode) {
|
|
||||||
final key = coinCode.toUpperCase();
|
|
||||||
_subscriptions[key]?.call();
|
|
||||||
_subscriptions.remove(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 连接状态
|
|
||||||
bool get isConnected => _isConnected;
|
|
||||||
|
|
||||||
/// 连接 WebSocket
|
|
||||||
void connect() {
|
|
||||||
if (_isConnecting || _isConnected) return;
|
|
||||||
_isConnecting = true;
|
|
||||||
|
|
||||||
final wsUrl = ApiEndpoints.klineWs;
|
|
||||||
|
|
||||||
_stompClient = StompClient(
|
|
||||||
config: StompConfig(
|
|
||||||
url: wsUrl,
|
|
||||||
onConnect: _onConnect,
|
|
||||||
onDisconnect: _onDisconnect,
|
|
||||||
onStompError: _onError,
|
|
||||||
onWebSocketError: _onError,
|
|
||||||
reconnectDelay: const Duration(milliseconds: 5000),
|
|
||||||
heartbeatIncoming: const Duration(seconds: 20),
|
|
||||||
heartbeatOutgoing: const Duration(seconds: 20),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
_stompClient!.activate();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 断开连接
|
|
||||||
void disconnect() {
|
|
||||||
_isConnecting = false;
|
|
||||||
_stompClient?.deactivate();
|
|
||||||
_stompClient = null;
|
|
||||||
_isConnected = false;
|
|
||||||
_subscriptions.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onConnect(StompFrame frame) {
|
|
||||||
_isConnected = true;
|
|
||||||
_isConnecting = false;
|
|
||||||
_reconnectDelay = 2000; // 重置重连延迟
|
|
||||||
|
|
||||||
// 重新订阅所有已注册的币种
|
|
||||||
for (final key in _controllers.keys) {
|
|
||||||
_doSubscribe(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onDisconnect(StompFrame? frame) {
|
|
||||||
_isConnected = false;
|
|
||||||
_isConnecting = false;
|
|
||||||
_scheduleReconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onError(dynamic error) {
|
|
||||||
_isConnected = false;
|
|
||||||
_isConnecting = false;
|
|
||||||
_scheduleReconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _scheduleReconnect() {
|
|
||||||
Future.delayed(Duration(milliseconds: _reconnectDelay), () {
|
|
||||||
if (!_isConnected && !_isConnecting) {
|
|
||||||
_reconnectDelay = (_reconnectDelay * 2).clamp(2000, _maxReconnectDelay);
|
|
||||||
connect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _doSubscribe(String coinCode) {
|
|
||||||
if (_stompClient == null || !_isConnected) {
|
|
||||||
connect(); // 触发连接,连接成功后会自动重新订阅
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 避免重复订阅
|
|
||||||
if (_subscriptions.containsKey(coinCode)) return;
|
|
||||||
|
|
||||||
final dest = '/topic/kline/$coinCode';
|
|
||||||
final sub = _stompClient!.subscribe(
|
|
||||||
destination: dest,
|
|
||||||
callback: (StompFrame frame) {
|
|
||||||
if (frame.body != null) {
|
|
||||||
try {
|
|
||||||
final json = jsonDecode(frame.body!) as Map<String, dynamic>;
|
|
||||||
final candle = KlineCandle.fromJson(json);
|
|
||||||
_controllers[coinCode]?.add(candle);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
_subscriptions[coinCode] = sub;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 释放资源
|
|
||||||
void dispose() {
|
|
||||||
disconnect();
|
|
||||||
for (final controller in _controllers.values) {
|
|
||||||
controller.close();
|
|
||||||
}
|
|
||||||
_controllers.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,12 +18,9 @@ import 'data/services/trade_service.dart';
|
|||||||
import 'data/services/asset_service.dart';
|
import 'data/services/asset_service.dart';
|
||||||
import 'data/services/fund_service.dart';
|
import 'data/services/fund_service.dart';
|
||||||
import 'data/services/bonus_service.dart';
|
import 'data/services/bonus_service.dart';
|
||||||
import 'data/services/kline_service.dart';
|
|
||||||
import 'data/services/kline_websocket_service.dart';
|
|
||||||
import 'providers/auth_provider.dart';
|
import 'providers/auth_provider.dart';
|
||||||
import 'providers/market_provider.dart';
|
import 'providers/market_provider.dart';
|
||||||
import 'providers/asset_provider.dart';
|
import 'providers/asset_provider.dart';
|
||||||
import 'providers/kline_provider.dart';
|
|
||||||
import 'providers/theme_provider.dart';
|
import 'providers/theme_provider.dart';
|
||||||
import 'ui/pages/auth/login_page.dart';
|
import 'ui/pages/auth/login_page.dart';
|
||||||
import 'ui/pages/main/main_page.dart';
|
import 'ui/pages/main/main_page.dart';
|
||||||
@@ -104,8 +101,6 @@ class MyApp extends StatelessWidget {
|
|||||||
Provider<AssetService>(create: (_) => AssetService(dioClient)),
|
Provider<AssetService>(create: (_) => AssetService(dioClient)),
|
||||||
Provider<FundService>(create: (_) => FundService(dioClient)),
|
Provider<FundService>(create: (_) => FundService(dioClient)),
|
||||||
Provider<BonusService>(create: (_) => BonusService(dioClient)),
|
Provider<BonusService>(create: (_) => BonusService(dioClient)),
|
||||||
Provider<KlineService>(create: (_) => KlineService(dioClient)),
|
|
||||||
Provider<KlineWebSocketService>(create: (_) => KlineWebSocketService()),
|
|
||||||
// State Management
|
// State Management
|
||||||
ChangeNotifierProvider<AuthProvider>(
|
ChangeNotifierProvider<AuthProvider>(
|
||||||
create: (ctx) {
|
create: (ctx) {
|
||||||
@@ -125,12 +120,6 @@ class MyApp extends StatelessWidget {
|
|||||||
ctx.read<AppEventBus>(),
|
ctx.read<AppEventBus>(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider<KlineProvider>(
|
|
||||||
create: (ctx) => KlineProvider(
|
|
||||||
ctx.read<KlineService>(),
|
|
||||||
ctx.read<KlineWebSocketService>(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../data/models/kline_candle.dart';
|
|
||||||
import '../data/services/kline_service.dart';
|
|
||||||
import '../data/services/kline_websocket_service.dart';
|
|
||||||
|
|
||||||
/// K线状态管理
|
|
||||||
class KlineProvider extends ChangeNotifier {
|
|
||||||
final KlineService _klineService;
|
|
||||||
final KlineWebSocketService _wsService;
|
|
||||||
|
|
||||||
KlineProvider(this._klineService, this._wsService);
|
|
||||||
|
|
||||||
List<KlineCandle> _candles = [];
|
|
||||||
KlineCandle? _currentCandle;
|
|
||||||
String _interval = '1h';
|
|
||||||
String _coinCode = '';
|
|
||||||
bool _isLoading = false;
|
|
||||||
bool _isLoadingMore = false;
|
|
||||||
bool _isConnected = false;
|
|
||||||
String? _error;
|
|
||||||
|
|
||||||
StreamSubscription<KlineCandle>? _wsSubscription;
|
|
||||||
Timer? _pollingTimer;
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
List<KlineCandle> get candles => _candles;
|
|
||||||
KlineCandle? get currentCandle => _currentCandle;
|
|
||||||
String get interval => _interval;
|
|
||||||
String get coinCode => _coinCode;
|
|
||||||
bool get isLoading => _isLoading;
|
|
||||||
bool get isLoadingMore => _isLoadingMore;
|
|
||||||
bool get isConnected => _isConnected;
|
|
||||||
String? get error => _error;
|
|
||||||
|
|
||||||
/// 加载某个币种的K线数据
|
|
||||||
Future<void> loadCoin(String coinCode, {String? interval}) async {
|
|
||||||
_coinCode = coinCode;
|
|
||||||
if (interval != null) _interval = interval;
|
|
||||||
_candles = [];
|
|
||||||
_currentCandle = null;
|
|
||||||
_error = null;
|
|
||||||
_isLoading = true;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 获取历史K线
|
|
||||||
final response = await _klineService.fetchHistory(
|
|
||||||
coinCode: _coinCode,
|
|
||||||
interval: _interval,
|
|
||||||
limit: 200,
|
|
||||||
);
|
|
||||||
if (response.success && response.data != null) {
|
|
||||||
_candles = response.data!;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 获取当前K线
|
|
||||||
final currentResponse = await _klineService.fetchCurrentCandle(
|
|
||||||
coinCode: _coinCode,
|
|
||||||
interval: _interval,
|
|
||||||
);
|
|
||||||
if (currentResponse.success && currentResponse.data != null) {
|
|
||||||
_currentCandle = currentResponse.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 连接 WebSocket
|
|
||||||
_connectWebSocket();
|
|
||||||
|
|
||||||
_isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
_error = '加载K线数据失败: $e';
|
|
||||||
_isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 切换周期
|
|
||||||
Future<void> changeInterval(String newInterval) async {
|
|
||||||
if (newInterval == _interval) return;
|
|
||||||
|
|
||||||
_interval = newInterval;
|
|
||||||
_candles = [];
|
|
||||||
_currentCandle = null;
|
|
||||||
|
|
||||||
// 重新加载
|
|
||||||
await loadCoin(_coinCode, interval: newInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 加载更多历史数据(分页)
|
|
||||||
Future<void> loadMore() async {
|
|
||||||
if (_isLoadingMore || _candles.isEmpty) return;
|
|
||||||
_isLoadingMore = true;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
try {
|
|
||||||
final oldestTime = _candles.first.closeTime;
|
|
||||||
final response = await _klineService.fetchHistory(
|
|
||||||
coinCode: _coinCode,
|
|
||||||
interval: _interval,
|
|
||||||
limit: 200,
|
|
||||||
before: oldestTime,
|
|
||||||
);
|
|
||||||
if (response.success && response.data != null) {
|
|
||||||
_candles = [...response.data!, ..._candles];
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
_isLoadingMore = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _connectWebSocket() {
|
|
||||||
// 取消之前的订阅
|
|
||||||
_wsSubscription?.cancel();
|
|
||||||
_wsService.unsubscribe(_coinCode);
|
|
||||||
|
|
||||||
// 订阅新币种
|
|
||||||
_wsService.connect();
|
|
||||||
_wsSubscription = _wsService.subscribe(_coinCode).listen(
|
|
||||||
_onTick,
|
|
||||||
onError: (_) => _startPolling(),
|
|
||||||
onDone: () => _startPolling(),
|
|
||||||
);
|
|
||||||
|
|
||||||
_isConnected = _wsService.isConnected;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onTick(KlineCandle tick) {
|
|
||||||
if (tick.interval != _interval) return;
|
|
||||||
|
|
||||||
_isConnected = true;
|
|
||||||
|
|
||||||
if (tick.isClosed) {
|
|
||||||
// 收盘 tick → 添加到历史列表
|
|
||||||
_candles.add(tick);
|
|
||||||
_currentCandle = null;
|
|
||||||
|
|
||||||
// 停止轮询(如果之前在轮询)
|
|
||||||
_pollingTimer?.cancel();
|
|
||||||
_pollingTimer = null;
|
|
||||||
} else {
|
|
||||||
// 进行中的 tick → 更新当前K线
|
|
||||||
_currentCandle = tick;
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// WebSocket 断连时降级为 HTTP 轮询
|
|
||||||
void _startPolling() {
|
|
||||||
_isConnected = false;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
_pollingTimer?.cancel();
|
|
||||||
_pollingTimer = Timer.periodic(
|
|
||||||
const Duration(seconds: 5),
|
|
||||||
(_) => _pollCurrentCandle(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _pollCurrentCandle() async {
|
|
||||||
try {
|
|
||||||
final response = await _klineService.fetchCurrentCandle(
|
|
||||||
coinCode: _coinCode,
|
|
||||||
interval: _interval,
|
|
||||||
);
|
|
||||||
if (response.success && response.data != null) {
|
|
||||||
_currentCandle = response.data;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_wsSubscription?.cancel();
|
|
||||||
_wsService.unsubscribe(_coinCode);
|
|
||||||
_pollingTimer?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../../../../core/theme/app_theme.dart';
|
|
||||||
import '../../../../core/theme/app_theme_extension.dart';
|
|
||||||
|
|
||||||
/// K线周期选择器:15m / 1h / 4h / 1d / 1M
|
|
||||||
class IntervalSelector extends StatelessWidget {
|
|
||||||
final String selected;
|
|
||||||
final ValueChanged<String> onChanged;
|
|
||||||
|
|
||||||
static const List<MapEntry<String, String>> intervals = [
|
|
||||||
MapEntry('15m', '15分'),
|
|
||||||
MapEntry('1h', '1时'),
|
|
||||||
MapEntry('4h', '4时'),
|
|
||||||
MapEntry('1d', '日线'),
|
|
||||||
MapEntry('1M', '月线'),
|
|
||||||
];
|
|
||||||
|
|
||||||
const IntervalSelector({
|
|
||||||
super.key,
|
|
||||||
required this.selected,
|
|
||||||
required this.onChanged,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
children: intervals.map((e) {
|
|
||||||
final isSelected = e.key == selected;
|
|
||||||
return Expanded(
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () => onChanged(e.key),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected
|
|
||||||
? context.colors.primary.withValues(alpha: 0.15)
|
|
||||||
: Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(
|
|
||||||
e.value,
|
|
||||||
style: AppTextStyles.bodyMedium(context).copyWith(
|
|
||||||
color: isSelected
|
|
||||||
? context.colors.primary
|
|
||||||
: context.appColors.onSurfaceMuted,
|
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../../../../core/theme/app_theme.dart';
|
|
||||||
import '../../../../core/theme/app_theme_extension.dart';
|
|
||||||
import '../../../../data/models/kline_candle.dart';
|
|
||||||
|
|
||||||
/// K线 OHLC 信息栏
|
|
||||||
class KlineStatsBar extends StatelessWidget {
|
|
||||||
final KlineCandle? candle;
|
|
||||||
|
|
||||||
const KlineStatsBar({super.key, this.candle});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (candle == null) return const SizedBox.shrink();
|
|
||||||
|
|
||||||
final c = candle!;
|
|
||||||
final change = c.closePrice - c.openPrice;
|
|
||||||
final changePct = c.openPrice > 0 ? (change / c.openPrice * 100) : 0.0;
|
|
||||||
final isUp = change >= 0;
|
|
||||||
final color = isUp ? context.appColors.up : context.appColors.down;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
_statItem(context, '开', _fmt(c.openPrice), color),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
_statItem(context, '高', _fmt(c.highPrice), color),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
_statItem(context, '低', _fmt(c.lowPrice), color),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
_statItem(context, '收', _fmt(c.closePrice), color),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
_statItem(context, '量', _fmtVol(c.volume), color),
|
|
||||||
const Spacer(),
|
|
||||||
Text(
|
|
||||||
'${isUp ? '+' : ''}${changePct.toStringAsFixed(2)}%',
|
|
||||||
style: AppTextStyles.labelLarge(context).copyWith(
|
|
||||||
color: color,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _statItem(BuildContext context, String label, String value, Color color) {
|
|
||||||
return Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(label,
|
|
||||||
style: AppTextStyles.bodySmall(context).copyWith(
|
|
||||||
color: context.appColors.onSurfaceMuted,
|
|
||||||
)),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
Text(value,
|
|
||||||
style: AppTextStyles.bodySmall(context).copyWith(
|
|
||||||
color: color,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _fmt(double v) {
|
|
||||||
if (v >= 1000) return v.toStringAsFixed(2);
|
|
||||||
if (v >= 1) return v.toStringAsFixed(4);
|
|
||||||
return v.toStringAsFixed(6);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _fmtVol(double v) {
|
|
||||||
if (v >= 1000000) return '${(v / 1000000).toStringAsFixed(1)}M';
|
|
||||||
if (v >= 1000) return '${(v / 1000).toStringAsFixed(1)}K';
|
|
||||||
return v.toStringAsFixed(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
|
||||||
import 'package:k_chart/flutter_k_chart.dart';
|
|
||||||
import '../../../core/theme/app_theme.dart';
|
|
||||||
import '../../../core/theme/app_theme_extension.dart';
|
|
||||||
import '../../../core/theme/app_spacing.dart';
|
|
||||||
import '../../../data/models/coin.dart';
|
|
||||||
import '../../../providers/kline_provider.dart';
|
|
||||||
import '../main/main_page.dart';
|
|
||||||
import 'components/interval_selector.dart';
|
|
||||||
import 'components/kline_stats_bar.dart';
|
|
||||||
|
|
||||||
/// K线图表页面
|
|
||||||
class KlinePage extends StatefulWidget {
|
|
||||||
final Coin coin;
|
|
||||||
|
|
||||||
const KlinePage({super.key, required this.coin});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<KlinePage> createState() => _KlinePageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _KlinePageState extends State<KlinePage> {
|
|
||||||
List<KLineEntity>? _kLineEntities;
|
|
||||||
final ChartColors _chartColors = ChartColors();
|
|
||||||
final ChartStyle _chartStyle = ChartStyle();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final isDark = context.isDark;
|
|
||||||
_chartColors.bgColor = [isDark ? const Color(0xff1a1a2e) : Colors.white, isDark ? const Color(0xff1a1a2e) : Colors.white];
|
|
||||||
_chartColors.gridColor = isDark ? const Color(0xff2d2d44) : const Color(0xffe0e0e0);
|
|
||||||
_chartColors.upColor = context.appColors.up;
|
|
||||||
_chartColors.dnColor = context.appColors.down;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: context.colors.background,
|
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: context.colors.surface,
|
|
||||||
elevation: 0,
|
|
||||||
scrolledUnderElevation: 0,
|
|
||||||
leading: IconButton(
|
|
||||||
icon: Icon(LucideIcons.arrowLeft, color: context.colors.onSurface),
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
),
|
|
||||||
title: Row(
|
|
||||||
children: [
|
|
||||||
Text(widget.coin.code,
|
|
||||||
style: AppTextStyles.headlineLarge(context).copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
)),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(widget.coin.formattedPrice,
|
|
||||||
style: AppTextStyles.headlineMedium(context).copyWith(
|
|
||||||
color: context.colors.primary,
|
|
||||||
)),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
_ChangeBadge(coin: widget.coin),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: Consumer<KlineProvider>(
|
|
||||||
builder: (context, provider, _) {
|
|
||||||
// 数据转换
|
|
||||||
_updateEntities(provider);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
// 周期选择器
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: AppSpacing.md,
|
|
||||||
vertical: AppSpacing.sm,
|
|
||||||
),
|
|
||||||
child: IntervalSelector(
|
|
||||||
selected: provider.interval,
|
|
||||||
onChanged: (v) => provider.changeInterval(v),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// OHLC 信息栏
|
|
||||||
KlineStatsBar(candle: provider.currentCandle),
|
|
||||||
const Divider(height: 1),
|
|
||||||
// K线图表
|
|
||||||
Expanded(
|
|
||||||
child: _buildChart(provider),
|
|
||||||
),
|
|
||||||
// 底部操作栏
|
|
||||||
_BottomActionBar(coin: widget.coin),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _updateEntities(KlineProvider provider) {
|
|
||||||
final allCandles = [...provider.candles];
|
|
||||||
if (provider.currentCandle != null) {
|
|
||||||
allCandles.add(provider.currentCandle!);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allCandles.isEmpty) {
|
|
||||||
_kLineEntities = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_kLineEntities = allCandles.map((c) {
|
|
||||||
return KLineEntity.fromJson({
|
|
||||||
'open': c.openPrice,
|
|
||||||
'high': c.highPrice,
|
|
||||||
'low': c.lowPrice,
|
|
||||||
'close': c.closePrice,
|
|
||||||
'vol': c.volume,
|
|
||||||
'amount': c.closePrice * c.volume,
|
|
||||||
'time': c.openTime,
|
|
||||||
'id': c.openTime,
|
|
||||||
});
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
DataUtil.calculate(_kLineEntities!);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildChart(KlineProvider provider) {
|
|
||||||
if (provider.isLoading) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_kLineEntities == null || _kLineEntities!.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(LucideIcons.chartNoAxesColumn,
|
|
||||||
size: 48,
|
|
||||||
color: context.appColors.onSurfaceMuted.withValues(alpha: 0.4)),
|
|
||||||
const SizedBox(height: AppSpacing.md),
|
|
||||||
Text('暂无K线数据',
|
|
||||||
style: AppTextStyles.headlineMedium(context).copyWith(
|
|
||||||
color: context.appColors.onSurfaceMuted,
|
|
||||||
)),
|
|
||||||
const SizedBox(height: AppSpacing.sm),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
height: double.infinity,
|
|
||||||
width: double.infinity,
|
|
||||||
child: KChartWidget(
|
|
||||||
_kLineEntities,
|
|
||||||
_chartStyle,
|
|
||||||
_chartColors,
|
|
||||||
isLine: false,
|
|
||||||
isTrendLine: false,
|
|
||||||
mainState: MainState.MA,
|
|
||||||
volHidden: false,
|
|
||||||
secondaryState: SecondaryState.MACD,
|
|
||||||
fixedLength: 4,
|
|
||||||
timeFormat: TimeFormat.YEAR_MONTH_DAY,
|
|
||||||
showNowPrice: true,
|
|
||||||
hideGrid: false,
|
|
||||||
isTapShowInfoDialog: false,
|
|
||||||
onSecondaryTap: () {},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 涨跌标签
|
|
||||||
class _ChangeBadge extends StatelessWidget {
|
|
||||||
final Coin coin;
|
|
||||||
const _ChangeBadge({required this.coin});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final isUp = coin.isUp;
|
|
||||||
final color = isUp ? context.appColors.up : context.appColors.down;
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
coin.formattedChange,
|
|
||||||
style: AppTextStyles.bodySmall(context).copyWith(
|
|
||||||
color: color,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 底部交易操作栏
|
|
||||||
class _BottomActionBar extends StatelessWidget {
|
|
||||||
final Coin coin;
|
|
||||||
const _BottomActionBar({required this.coin});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(
|
|
||||||
AppSpacing.lg, AppSpacing.sm, AppSpacing.lg, AppSpacing.lg,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.colors.surface,
|
|
||||||
border: Border(
|
|
||||||
top: BorderSide(
|
|
||||||
color: context.colors.outlineVariant.withValues(alpha: 0.2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: SizedBox(
|
|
||||||
height: 44,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: () => _navigateToTrade(context, isBuy: true),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: context.appColors.up,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
elevation: 0,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text('买入',
|
|
||||||
style: AppTextStyles.headlineMedium(context).copyWith(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: AppSpacing.md),
|
|
||||||
Expanded(
|
|
||||||
child: SizedBox(
|
|
||||||
height: 44,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: () => _navigateToTrade(context, isBuy: false),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: context.appColors.down,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
elevation: 0,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text('卖出',
|
|
||||||
style: AppTextStyles.headlineMedium(context).copyWith(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _navigateToTrade(BuildContext context, {required bool isBuy}) {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
final mainState = context.findAncestorStateOfType<MainPageState>();
|
|
||||||
mainState?.switchToTrade(coin.code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,11 @@ import '../../../../core/theme/app_spacing.dart';
|
|||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../core/theme/app_theme_extension.dart';
|
import '../../../../core/theme/app_theme_extension.dart';
|
||||||
import '../../../../data/models/coin.dart';
|
import '../../../../data/models/coin.dart';
|
||||||
import '../../kline/kline_page.dart';
|
|
||||||
import 'coin_avatar.dart';
|
import 'coin_avatar.dart';
|
||||||
|
|
||||||
/// 币种选择器组件
|
/// 币种选择器组件
|
||||||
///
|
///
|
||||||
/// 显示当前选中的币种交易对,点击弹出底部弹窗选择币种。
|
/// 显示当前选中的币种交易对,点击弹出底部弹窗选择币种。
|
||||||
/// 卡片背景 + 圆角lg + border + padding:16
|
|
||||||
/// 横向布局:coinInfo(竖向 pair+name) + chevronDown
|
|
||||||
class CoinSelector extends StatelessWidget {
|
class CoinSelector extends StatelessWidget {
|
||||||
final Coin? selectedCoin;
|
final Coin? selectedCoin;
|
||||||
final List<Coin> coins;
|
final List<Coin> coins;
|
||||||
@@ -61,22 +58,6 @@ class CoinSelector extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// K线图标(仅选中币种后显示)
|
|
||||||
if (selectedCoin != null)
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => _navigateToKline(context),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(AppSpacing.sm),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.appColors.surfaceCard,
|
|
||||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
|
||||||
border: Border.all(color: context.appColors.ghostBorder),
|
|
||||||
),
|
|
||||||
child: Icon(LucideIcons.chartNoAxesColumn,
|
|
||||||
size: 20, color: context.colors.primary),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: AppSpacing.sm),
|
|
||||||
// 下拉箭头
|
// 下拉箭头
|
||||||
Icon(LucideIcons.chevronDown,
|
Icon(LucideIcons.chevronDown,
|
||||||
size: 16, color: context.colors.onSurfaceVariant),
|
size: 16, color: context.colors.onSurfaceVariant),
|
||||||
@@ -86,14 +67,6 @@ class CoinSelector extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToKline(BuildContext context) {
|
|
||||||
if (selectedCoin == null) return;
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (_) => KlinePage(coin: selectedCoin!)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showCoinPicker(BuildContext context) {
|
void _showCoinPicker(BuildContext context) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -373,14 +373,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.2"
|
version: "0.7.2"
|
||||||
k_chart:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: k_chart
|
|
||||||
sha256: "059163563285cc001dc0257880f598774c63791155a7e0f3a37064dda89c9168"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.7.1"
|
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -706,14 +698,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.12.1"
|
version: "1.12.1"
|
||||||
stomp_dart_client:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: stomp_dart_client
|
|
||||||
sha256: "9ca00600a212f1e08fda614cf6815437829b1d08d8911ff5c798f130a2fa2d59"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.3"
|
|
||||||
stream_channel:
|
stream_channel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -834,22 +818,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
web_socket:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: web_socket
|
|
||||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.1"
|
|
||||||
web_socket_channel:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: web_socket_channel
|
|
||||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.3"
|
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -40,13 +40,6 @@ dependencies:
|
|||||||
# 字体
|
# 字体
|
||||||
google_fonts: ^6.2.1
|
google_fonts: ^6.2.1
|
||||||
|
|
||||||
# K线图表
|
|
||||||
k_chart: ^0.7.1
|
|
||||||
|
|
||||||
# WebSocket (STOMP)
|
|
||||||
stomp_dart_client: ^2.0.0
|
|
||||||
web_socket_channel: ^3.0.1
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CandlestickChart, CircleDollarSign, Coins, DollarSign, Palette, Receipt, Settings, ShieldCheck, TrendingUp, Users } from 'lucide-vue-next'
|
import { CircleDollarSign, Coins, DollarSign, Palette, Receipt, Settings, ShieldCheck, TrendingUp, Users } from 'lucide-vue-next'
|
||||||
|
|
||||||
import type { NavGroup } from '@/components/app-sidebar/types'
|
import type { NavGroup } from '@/components/app-sidebar/types'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -22,7 +22,6 @@ export function useSidebar() {
|
|||||||
{ title: '订单审批', url: '/monisuo/orders', icon: Receipt, roles: [1, 2] },
|
{ title: '订单审批', url: '/monisuo/orders', icon: Receipt, roles: [1, 2] },
|
||||||
{ title: '财务审批', url: '/monisuo/finance-orders', icon: CircleDollarSign, roles: [1, 3] },
|
{ title: '财务审批', url: '/monisuo/finance-orders', icon: CircleDollarSign, roles: [1, 3] },
|
||||||
{ title: '业务分析', url: '/monisuo/analytics', icon: TrendingUp, roles: [1] },
|
{ title: '业务分析', url: '/monisuo/analytics', icon: TrendingUp, roles: [1] },
|
||||||
{ title: 'K线配置', url: '/monisuo/kline-config', icon: CandlestickChart, roles: [1] },
|
|
||||||
{ title: '管理员管理', url: '/monisuo/admins', icon: ShieldCheck, roles: [1] },
|
{ title: '管理员管理', url: '/monisuo/admins', icon: ShieldCheck, roles: [1] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,324 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, computed, watch } from 'vue'
|
|
||||||
import { toast } from 'vue-sonner'
|
|
||||||
import {
|
|
||||||
getKlineConfigs,
|
|
||||||
saveKlineConfig,
|
|
||||||
getKlinePreview,
|
|
||||||
type KlineConfig,
|
|
||||||
} from '../../services/api/monisuo-kline.api.ts'
|
|
||||||
import VChart from 'vue-echarts'
|
|
||||||
import { use } from 'echarts/core'
|
|
||||||
import { CandlestickChart } from 'echarts/charts'
|
|
||||||
import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent } from 'echarts/components'
|
|
||||||
import { CanvasRenderer } from 'echarts/renderers'
|
|
||||||
|
|
||||||
use([CandlestickChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, CanvasRenderer])
|
|
||||||
|
|
||||||
// ==================== State ====================
|
|
||||||
const configs = ref<KlineConfig[]>([])
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const editingConfig = ref<KlineConfig | null>(null)
|
|
||||||
const showEditDialog = ref(false)
|
|
||||||
const showPreviewDialog = ref(false)
|
|
||||||
const previewCoinCode = ref('')
|
|
||||||
const previewInterval = ref('1h')
|
|
||||||
const previewData = ref<any[]>([])
|
|
||||||
const previewVolumes = ref<number[]>([])
|
|
||||||
const previewDates = ref<string[]>([])
|
|
||||||
|
|
||||||
// ==================== Load ====================
|
|
||||||
async function loadConfigs() {
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
configs.value = await getKlineConfigs()
|
|
||||||
}
|
|
||||||
catch (e: any) {
|
|
||||||
toast.error(`加载失败: ${e.message}`)
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadConfigs)
|
|
||||||
|
|
||||||
// ==================== Edit ====================
|
|
||||||
function openEdit(config: KlineConfig) {
|
|
||||||
editingConfig.value = { ...config }
|
|
||||||
showEditDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEdit() {
|
|
||||||
if (!editingConfig.value) return
|
|
||||||
try {
|
|
||||||
await saveKlineConfig({
|
|
||||||
coinCode: editingConfig.value.coinCode,
|
|
||||||
tradeStartTime: editingConfig.value.tradeStartTime,
|
|
||||||
tradeEndTime: editingConfig.value.tradeEndTime,
|
|
||||||
priceMin: editingConfig.value.priceMin,
|
|
||||||
priceMax: editingConfig.value.priceMax,
|
|
||||||
simulationEnabled: editingConfig.value.simulationEnabled,
|
|
||||||
})
|
|
||||||
toast.success('保存成功')
|
|
||||||
showEditDialog.value = false
|
|
||||||
loadConfigs()
|
|
||||||
}
|
|
||||||
catch (e: any) {
|
|
||||||
toast.error(`保存失败: ${e.message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSimulation(config: KlineConfig) {
|
|
||||||
const newConfig = { ...config }
|
|
||||||
newConfig.simulationEnabled = config.simulationEnabled === 1 ? 0 : 1
|
|
||||||
saveKlineConfig({
|
|
||||||
coinCode: newConfig.coinCode,
|
|
||||||
simulationEnabled: newConfig.simulationEnabled,
|
|
||||||
}).then(() => {
|
|
||||||
toast.success(newConfig.simulationEnabled ? '已启用模拟' : '已关闭模拟')
|
|
||||||
loadConfigs()
|
|
||||||
}).catch((e: any) => {
|
|
||||||
toast.error(`操作失败: ${e.message}`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Preview ====================
|
|
||||||
async function openPreview(config: KlineConfig) {
|
|
||||||
previewCoinCode.value = config.coinCode
|
|
||||||
previewInterval.value = '1h'
|
|
||||||
showPreviewDialog.value = true
|
|
||||||
await loadPreviewData()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPreviewData() {
|
|
||||||
try {
|
|
||||||
const data = await getKlinePreview(previewCoinCode.value, previewInterval.value, 100)
|
|
||||||
previewData.value = data.map((d) => [d.openPrice, d.closePrice, d.lowPrice, d.highPrice])
|
|
||||||
previewVolumes.value = data.map(d => d.volume)
|
|
||||||
previewDates.value = data.map((d) => {
|
|
||||||
const date = new Date(d.openTime)
|
|
||||||
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
previewData.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(previewInterval, loadPreviewData)
|
|
||||||
|
|
||||||
const chartOption = computed(() => ({
|
|
||||||
animation: false,
|
|
||||||
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
|
||||||
grid: [
|
|
||||||
{ left: '10%', right: '5%', top: '5%', height: '55%' },
|
|
||||||
{ left: '10%', right: '5%', top: '68%', height: '20%' },
|
|
||||||
],
|
|
||||||
xAxis: [
|
|
||||||
{ type: 'category', data: previewDates.value, gridIndex: 0, axisLabel: { show: false } },
|
|
||||||
{ type: 'category', data: previewDates.value, gridIndex: 1 },
|
|
||||||
],
|
|
||||||
yAxis: [
|
|
||||||
{ type: 'value', gridIndex: 0, scale: true },
|
|
||||||
{ type: 'value', gridIndex: 1, scale: true },
|
|
||||||
],
|
|
||||||
dataZoom: [
|
|
||||||
{ type: 'inside', xAxisIndex: [0, 1], start: 50, end: 100 },
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'K线',
|
|
||||||
type: 'candlestick',
|
|
||||||
data: previewData.value,
|
|
||||||
xAxisIndex: 0,
|
|
||||||
yAxisIndex: 0,
|
|
||||||
itemStyle: {
|
|
||||||
color: '#ef5350',
|
|
||||||
color0: '#26a69a',
|
|
||||||
borderColor: '#ef5350',
|
|
||||||
borderColor0: '#26a69a',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '成交量',
|
|
||||||
type: 'bar',
|
|
||||||
data: previewVolumes.value,
|
|
||||||
xAxisIndex: 1,
|
|
||||||
yAxisIndex: 1,
|
|
||||||
itemStyle: { color: '#7986cb' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="p-6 space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold">K线配置</h1>
|
|
||||||
<p class="text-muted-foreground mt-1">管理币种K线模拟参数,配置交易时段和价格区间</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 币种配置表格 -->
|
|
||||||
<div class="border rounded-lg">
|
|
||||||
<table class="w-full">
|
|
||||||
<thead class="bg-muted/50">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium text-muted-foreground">币种</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium text-muted-foreground">当前价格</th>
|
|
||||||
<th class="px-4 py-3 text-center text-sm font-medium text-muted-foreground">模拟状态</th>
|
|
||||||
<th class="px-4 py-3 text-center text-sm font-medium text-muted-foreground">交易时段</th>
|
|
||||||
<th class="px-4 py-3 text-center text-sm font-medium text-muted-foreground">价格区间</th>
|
|
||||||
<th class="px-4 py-3 text-center text-sm font-medium text-muted-foreground">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="config in configs" :key="config.coinCode" class="border-t hover:bg-muted/30">
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<div class="font-medium">{{ config.coinCode }}</div>
|
|
||||||
<div class="text-xs text-muted-foreground">{{ config.coinName }}</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 font-mono">{{ config.currentPrice?.toFixed(4) ?? '-' }}</td>
|
|
||||||
<td class="px-4 py-3 text-center">
|
|
||||||
<button
|
|
||||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
|
||||||
:class="config.simulationEnabled ? 'bg-primary' : 'bg-muted'"
|
|
||||||
@click="toggleSimulation(config)"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform"
|
|
||||||
:class="config.simulationEnabled ? 'translate-x-6' : 'translate-x-1'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-center text-sm">
|
|
||||||
{{ config.tradeStartTime ?? '09:00' }} - {{ config.tradeEndTime ?? '23:00' }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-center text-sm font-mono">
|
|
||||||
<template v-if="config.priceMin != null && config.priceMax != null">
|
|
||||||
{{ config.priceMin }} ~ {{ config.priceMax }}
|
|
||||||
</template>
|
|
||||||
<template v-else>-</template>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-center">
|
|
||||||
<div class="flex items-center justify-center gap-2">
|
|
||||||
<button
|
|
||||||
class="text-sm text-primary hover:underline"
|
|
||||||
@click="openEdit(config)"
|
|
||||||
>
|
|
||||||
配置
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="text-sm text-primary hover:underline"
|
|
||||||
@click="openPreview(config)"
|
|
||||||
>
|
|
||||||
预览
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="configs.length === 0">
|
|
||||||
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground">
|
|
||||||
暂无币种数据
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 编辑弹窗 -->
|
|
||||||
<UiDialog v-model:open="showEditDialog">
|
|
||||||
<UiDialogContent class="max-w-md">
|
|
||||||
<UiDialogHeader>
|
|
||||||
<UiDialogTitle>K线配置 — {{ editingConfig?.coinCode }}</UiDialogTitle>
|
|
||||||
</UiDialogHeader>
|
|
||||||
<div v-if="editingConfig" class="space-y-4 py-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<label class="text-sm font-medium">启用模拟</label>
|
|
||||||
<button
|
|
||||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
|
||||||
:class="editingConfig.simulationEnabled ? 'bg-primary' : 'bg-muted'"
|
|
||||||
@click="editingConfig.simulationEnabled = editingConfig.simulationEnabled === 1 ? 0 : 1"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform"
|
|
||||||
:class="editingConfig.simulationEnabled ? 'translate-x-6' : 'translate-x-1'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="text-sm font-medium">交易开始时间</label>
|
|
||||||
<input
|
|
||||||
v-model="editingConfig.tradeStartTime"
|
|
||||||
type="time"
|
|
||||||
class="mt-1 w-full rounded-md border px-3 py-2 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-sm font-medium">交易结束时间</label>
|
|
||||||
<input
|
|
||||||
v-model="editingConfig.tradeEndTime"
|
|
||||||
type="time"
|
|
||||||
class="mt-1 w-full rounded-md border px-3 py-2 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="text-sm font-medium">最低价</label>
|
|
||||||
<input
|
|
||||||
v-model.number="editingConfig.priceMin"
|
|
||||||
type="number"
|
|
||||||
step="0.0001"
|
|
||||||
min="0"
|
|
||||||
class="mt-1 w-full rounded-md border px-3 py-2 text-sm"
|
|
||||||
placeholder="最低价"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-sm font-medium">最高价</label>
|
|
||||||
<input
|
|
||||||
v-model.number="editingConfig.priceMax"
|
|
||||||
type="number"
|
|
||||||
step="0.0001"
|
|
||||||
min="0"
|
|
||||||
class="mt-1 w-full rounded-md border px-3 py-2 text-sm"
|
|
||||||
placeholder="最高价"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<UiDialogFooter>
|
|
||||||
<UiButton variant="outline" @click="showEditDialog = false">取消</UiButton>
|
|
||||||
<UiButton @click="saveEdit">保存</UiButton>
|
|
||||||
</UiDialogFooter>
|
|
||||||
</UiDialogContent>
|
|
||||||
</UiDialog>
|
|
||||||
|
|
||||||
<!-- K线预览弹窗 -->
|
|
||||||
<UiDialog v-model:open="showPreviewDialog">
|
|
||||||
<UiDialogContent class="max-w-3xl max-h-[80vh]">
|
|
||||||
<UiDialogHeader>
|
|
||||||
<UiDialogTitle>K线预览 — {{ previewCoinCode }}</UiDialogTitle>
|
|
||||||
</UiDialogHeader>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<select
|
|
||||||
v-model="previewInterval"
|
|
||||||
class="rounded-md border px-3 py-1.5 text-sm"
|
|
||||||
>
|
|
||||||
<option value="15m">15分钟</option>
|
|
||||||
<option value="1h">1小时</option>
|
|
||||||
<option value="4h">4小时</option>
|
|
||||||
<option value="1d">日线</option>
|
|
||||||
<option value="1M">月线</option>
|
|
||||||
</select>
|
|
||||||
<VChart v-if="previewData.length" :option="chartOption" autoresize style="height: 400px" />
|
|
||||||
<div v-else class="py-8 text-center text-muted-foreground">暂无数据</div>
|
|
||||||
</div>
|
|
||||||
</UiDialogContent>
|
|
||||||
</UiDialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { useAxios } from '../../composables/use-axios'
|
|
||||||
|
|
||||||
// ==================== Types ====================
|
|
||||||
|
|
||||||
export interface KlineConfig {
|
|
||||||
coinId: number
|
|
||||||
coinCode: string
|
|
||||||
coinName: string
|
|
||||||
simulationEnabled: number // 0 or 1
|
|
||||||
tradeStartTime: string // HH:mm
|
|
||||||
tradeEndTime: string // HH:mm
|
|
||||||
priceMin: number
|
|
||||||
priceMax: number
|
|
||||||
currentPrice: number
|
|
||||||
priceType: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KlineConfigUpdate {
|
|
||||||
coinCode: string
|
|
||||||
tradeStartTime?: string
|
|
||||||
tradeEndTime?: string
|
|
||||||
priceMin?: number
|
|
||||||
priceMax?: number
|
|
||||||
simulationEnabled?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KlineCandle {
|
|
||||||
coinCode: string
|
|
||||||
interval: string
|
|
||||||
openTime: number
|
|
||||||
openPrice: number
|
|
||||||
highPrice: number
|
|
||||||
lowPrice: number
|
|
||||||
closePrice: number
|
|
||||||
volume: number
|
|
||||||
closeTime: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== API Functions ====================
|
|
||||||
|
|
||||||
/** 获取所有币种K线配置 */
|
|
||||||
export async function getKlineConfigs(): Promise<KlineConfig[]> {
|
|
||||||
const { axiosInstance } = useAxios()
|
|
||||||
const { data } = await axiosInstance.get('/admin/kline/config')
|
|
||||||
const list: any[] = (data as any)?.data ?? []
|
|
||||||
return list.map(item => ({
|
|
||||||
coinId: item.id,
|
|
||||||
coinCode: item.code,
|
|
||||||
coinName: item.name,
|
|
||||||
simulationEnabled: item.simulationEnabled ?? 0,
|
|
||||||
tradeStartTime: item.tradeStartTime,
|
|
||||||
tradeEndTime: item.tradeEndTime,
|
|
||||||
priceMin: item.priceMin,
|
|
||||||
priceMax: item.priceMax,
|
|
||||||
currentPrice: item.price,
|
|
||||||
priceType: item.priceType,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 保存K线配置 */
|
|
||||||
export async function saveKlineConfig(config: KlineConfigUpdate): Promise<void> {
|
|
||||||
const { axiosInstance } = useAxios()
|
|
||||||
await axiosInstance.post('/admin/kline/config', config)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取K线预览数据(用于 echarts) */
|
|
||||||
export async function getKlinePreview(coinCode: string, interval: string = '1h', limit: number = 100): Promise<KlineCandle[]> {
|
|
||||||
const { axiosInstance } = useAxios()
|
|
||||||
const { data } = await axiosInstance.get('/admin/kline/preview', { params: { coinCode, interval, limit } })
|
|
||||||
return (data as any)?.data ?? []
|
|
||||||
}
|
|
||||||
13
monisuo-admin/src/types/route-map.d.ts
vendored
13
monisuo-admin/src/types/route-map.d.ts
vendored
@@ -96,13 +96,6 @@ declare module 'vue-router/auto-routes' {
|
|||||||
Record<never, never>,
|
Record<never, never>,
|
||||||
| never
|
| never
|
||||||
>,
|
>,
|
||||||
'/monisuo/kline-config': RouteRecordInfo<
|
|
||||||
'/monisuo/kline-config',
|
|
||||||
'/monisuo/kline-config',
|
|
||||||
Record<never, never>,
|
|
||||||
Record<never, never>,
|
|
||||||
| never
|
|
||||||
>,
|
|
||||||
'/monisuo/orders': RouteRecordInfo<
|
'/monisuo/orders': RouteRecordInfo<
|
||||||
'/monisuo/orders',
|
'/monisuo/orders',
|
||||||
'/monisuo/orders',
|
'/monisuo/orders',
|
||||||
@@ -219,12 +212,6 @@ declare module 'vue-router/auto-routes' {
|
|||||||
views:
|
views:
|
||||||
| never
|
| never
|
||||||
}
|
}
|
||||||
'src/pages/monisuo/kline-config.vue': {
|
|
||||||
routes:
|
|
||||||
| '/monisuo/kline-config'
|
|
||||||
views:
|
|
||||||
| never
|
|
||||||
}
|
|
||||||
'src/pages/monisuo/orders.vue': {
|
'src/pages/monisuo/orders.vue': {
|
||||||
routes:
|
routes:
|
||||||
| '/monisuo/orders'
|
| '/monisuo/orders'
|
||||||
|
|||||||
20
pom.xml
20
pom.xml
@@ -95,26 +95,6 @@
|
|||||||
<version>2.9.2</version>
|
<version>2.9.2</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- WebSocket STOMP (K线实时推送) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
|
||||||
<version>2.2.4.RELEASE</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Redis (K线数据持久化 + 价格缓存) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
|
||||||
<version>2.2.4.RELEASE</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.commons</groupId>
|
|
||||||
<artifactId>commons-pool2</artifactId>
|
|
||||||
<version>2.8.0</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
-- =============================================
|
|
||||||
-- K线模拟功能 — 数据库补丁
|
|
||||||
-- =============================================
|
|
||||||
|
|
||||||
-- 1. 新建 coin_kline 表(K线蜡烛数据)
|
|
||||||
CREATE TABLE IF NOT EXISTS `coin_kline` (
|
|
||||||
`id` bigint(20) NOT NULL AUTO_INCREMENT,
|
|
||||||
`coin_code` varchar(20) NOT NULL COMMENT '币种代码',
|
|
||||||
`interval` varchar(5) NOT NULL COMMENT '周期: 15m/1h/4h/1d/1M',
|
|
||||||
`open_time` bigint(20) NOT NULL COMMENT '开盘时间戳(ms)',
|
|
||||||
`open_price` decimal(20,8) NOT NULL,
|
|
||||||
`high_price` decimal(20,8) NOT NULL,
|
|
||||||
`low_price` decimal(20,8) NOT NULL,
|
|
||||||
`close_price` decimal(20,8) NOT NULL,
|
|
||||||
`volume` decimal(20,4) DEFAULT 0 COMMENT '模拟成交量',
|
|
||||||
`close_time` bigint(20) NOT NULL COMMENT '收盘时间戳(ms)',
|
|
||||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
UNIQUE KEY `uk_coin_interval_open` (`coin_code`, `interval`, `open_time`),
|
|
||||||
KEY `idx_coin_interval_close` (`coin_code`, `interval`, `close_time`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='K线蜡烛数据';
|
|
||||||
|
|
||||||
-- 2. coin 表新增K线配置字段
|
|
||||||
ALTER TABLE `coin`
|
|
||||||
ADD COLUMN `trade_start_time` varchar(5) DEFAULT '09:00' COMMENT '交易开始时间 HH:mm',
|
|
||||||
ADD COLUMN `trade_end_time` varchar(5) DEFAULT '23:00' COMMENT '交易结束时间 HH:mm',
|
|
||||||
ADD COLUMN `max_change_percent` decimal(5,2) DEFAULT 5.00 COMMENT '每日最大涨跌幅(%)',
|
|
||||||
ADD COLUMN `simulation_enabled` tinyint(1) DEFAULT 0 COMMENT '1=启用K线模拟';
|
|
||||||
@@ -7,7 +7,6 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|||||||
|
|
||||||
import org.springframework.boot.web.servlet.ServletComponentScan;
|
import org.springframework.boot.web.servlet.ServletComponentScan;
|
||||||
import org.springframework.context.annotation.ComponentScan;
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
|
||||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||||
|
|
||||||
|
|
||||||
@@ -15,7 +14,6 @@ import org.springframework.transaction.annotation.EnableTransactionManagement;
|
|||||||
@ServletComponentScan(basePackages ={"com.it.rattan"})
|
@ServletComponentScan(basePackages ={"com.it.rattan"})
|
||||||
@ComponentScan(basePackages ={"com.it.rattan"})
|
@ComponentScan(basePackages ={"com.it.rattan"})
|
||||||
@EnableTransactionManagement
|
@EnableTransactionManagement
|
||||||
@EnableScheduling
|
|
||||||
/*@EnableAsync
|
/*@EnableAsync
|
||||||
@EnableAspectJAutoProxy*/
|
@EnableAspectJAutoProxy*/
|
||||||
public class SpcCloudApplication {
|
public class SpcCloudApplication {
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
package com.it.rattan.monisuo.config;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
|
||||||
import com.fasterxml.jackson.annotation.PropertyAccessor;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
|
||||||
import org.springframework.data.redis.core.RedisTemplate;
|
|
||||||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
|
||||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redis 配置
|
|
||||||
*/
|
|
||||||
@Configuration
|
|
||||||
public class RedisConfig {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
|
|
||||||
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
|
||||||
template.setConnectionFactory(factory);
|
|
||||||
|
|
||||||
ObjectMapper om = new ObjectMapper();
|
|
||||||
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
|
|
||||||
om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
|
|
||||||
|
|
||||||
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(om);
|
|
||||||
StringRedisSerializer stringSerializer = new StringRedisSerializer();
|
|
||||||
|
|
||||||
// key 用 String
|
|
||||||
template.setKeySerializer(stringSerializer);
|
|
||||||
template.setHashKeySerializer(stringSerializer);
|
|
||||||
// value 用 JSON
|
|
||||||
template.setValueSerializer(jsonSerializer);
|
|
||||||
template.setHashValueSerializer(jsonSerializer);
|
|
||||||
|
|
||||||
template.afterPropertiesSet();
|
|
||||||
return template;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package com.it.rattan.monisuo.config;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
|
||||||
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
|
||||||
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
|
||||||
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket 配置 — K线实时推送
|
|
||||||
*
|
|
||||||
* 端点: /ws/kline
|
|
||||||
* 主题: /topic/kline/{coinCode}
|
|
||||||
*
|
|
||||||
* Flutter 使用原生 STOMP 连接,浏览器可用 SockJS 降级
|
|
||||||
*/
|
|
||||||
@Configuration
|
|
||||||
@EnableWebSocketMessageBroker
|
|
||||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void configureMessageBroker(MessageBrokerRegistry config) {
|
|
||||||
// 服务端推送主题前缀
|
|
||||||
config.enableSimpleBroker("/topic");
|
|
||||||
// 客户端发送前缀(本场景不需要客户端主动发消息,预留)
|
|
||||||
config.setApplicationDestinationPrefixes("/app");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
|
||||||
// 原生 WebSocket 端点(Flutter 用)
|
|
||||||
registry.addEndpoint("/ws/kline")
|
|
||||||
.setAllowedOrigins("*");
|
|
||||||
// SockJS 降级端点(浏览器用)
|
|
||||||
registry.addEndpoint("/ws/kline")
|
|
||||||
.setAllowedOrigins("*")
|
|
||||||
.withSockJS();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package com.it.rattan.monisuo.controller;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
||||||
import com.it.rattan.monisuo.common.Result;
|
|
||||||
import com.it.rattan.monisuo.dto.KlineConfigUpdate;
|
|
||||||
import com.it.rattan.monisuo.entity.Coin;
|
|
||||||
import com.it.rattan.monisuo.entity.CoinKline;
|
|
||||||
import com.it.rattan.monisuo.mapper.CoinMapper;
|
|
||||||
import com.it.rattan.monisuo.service.CoinKlineService;
|
|
||||||
import com.it.rattan.monisuo.service.CoinService;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理端K线配置 API
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@RestController
|
|
||||||
public class AdminKlineController {
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private CoinKlineService coinKlineService;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private CoinMapper coinMapper;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有币种K线配置
|
|
||||||
*/
|
|
||||||
@GetMapping("/admin/kline/config")
|
|
||||||
public Result<List<Coin>> getKlineConfigs() {
|
|
||||||
LambdaQueryWrapper<Coin> wrapper = new LambdaQueryWrapper<>();
|
|
||||||
wrapper.orderByDesc(Coin::getSort);
|
|
||||||
List<Coin> coins = coinMapper.selectList(wrapper);
|
|
||||||
return Result.success(coins);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存币种K线配置
|
|
||||||
*/
|
|
||||||
@PostMapping("/admin/kline/config")
|
|
||||||
public Result<Void> saveKlineConfig(@RequestBody KlineConfigUpdate config) {
|
|
||||||
try {
|
|
||||||
coinKlineService.updateConfig(config);
|
|
||||||
return Result.success("配置已保存", null);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return Result.fail(e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取K线预览数据(用于 admin echarts 图表)
|
|
||||||
*/
|
|
||||||
@GetMapping("/admin/kline/preview")
|
|
||||||
public Result<List<CoinKline>> getKlinePreview(
|
|
||||||
@RequestParam String coinCode,
|
|
||||||
@RequestParam(defaultValue = "1h") String interval,
|
|
||||||
@RequestParam(defaultValue = "100") int limit) {
|
|
||||||
List<CoinKline> candles = coinKlineService.getHistory(
|
|
||||||
coinCode.toUpperCase(), interval, limit, null);
|
|
||||||
return Result.success(candles);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package com.it.rattan.monisuo.controller;
|
|
||||||
|
|
||||||
import com.it.rattan.monisuo.common.Result;
|
|
||||||
import com.it.rattan.monisuo.entity.CoinKline;
|
|
||||||
import com.it.rattan.monisuo.service.CoinKlineService;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.HashMap;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用户端K线 API
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
public class KlineController {
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private CoinKlineService coinKlineService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取支持的K线周期列表
|
|
||||||
*/
|
|
||||||
@GetMapping("/api/kline/intervals")
|
|
||||||
public Result<List<String>> getIntervals() {
|
|
||||||
return Result.success(coinKlineService.getIntervals());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取历史K线数据(分页)
|
|
||||||
*
|
|
||||||
* @param coinCode 币种代码
|
|
||||||
* @param interval 周期 (15m/1h/4h/1d/1M)
|
|
||||||
* @param limit 数量 (默认200, 最大500)
|
|
||||||
* @param before 查询此时间戳(ms)之前的K线(分页用)
|
|
||||||
*/
|
|
||||||
@GetMapping("/api/kline/history")
|
|
||||||
public Result<Map<String, Object>> getHistory(
|
|
||||||
@RequestParam String coinCode,
|
|
||||||
@RequestParam(defaultValue = "1h") String interval,
|
|
||||||
@RequestParam(defaultValue = "200") int limit,
|
|
||||||
@RequestParam(required = false) Long before) {
|
|
||||||
|
|
||||||
List<CoinKline> candles = coinKlineService.getHistory(
|
|
||||||
coinCode.toUpperCase(), interval, limit, before);
|
|
||||||
|
|
||||||
Map<String, Object> data = new HashMap<>();
|
|
||||||
data.put("list", candles);
|
|
||||||
data.put("coinCode", coinCode.toUpperCase());
|
|
||||||
data.put("interval", interval);
|
|
||||||
return Result.success(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前进行中的K线
|
|
||||||
*/
|
|
||||||
@GetMapping("/api/kline/current")
|
|
||||||
public Result<CoinKline> getCurrentCandle(
|
|
||||||
@RequestParam String coinCode,
|
|
||||||
@RequestParam(defaultValue = "1h") String interval) {
|
|
||||||
CoinKline candle = coinKlineService.getCurrentCandle(
|
|
||||||
coinCode.toUpperCase(), interval);
|
|
||||||
if (candle == null) {
|
|
||||||
return Result.fail("暂无K线数据");
|
|
||||||
}
|
|
||||||
return Result.success(candle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package com.it.rattan.monisuo.dto;
|
|
||||||
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.Setter;
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理员K线配置更新 DTO
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
public class KlineConfigUpdate implements Serializable {
|
|
||||||
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
/** 币种代码 */
|
|
||||||
private String coinCode;
|
|
||||||
|
|
||||||
/** 交易开始时间 HH:mm */
|
|
||||||
private String tradeStartTime;
|
|
||||||
|
|
||||||
/** 交易结束时间 HH:mm */
|
|
||||||
private String tradeEndTime;
|
|
||||||
|
|
||||||
/** 每日最大涨跌幅(%) */
|
|
||||||
private BigDecimal maxChangePercent;
|
|
||||||
|
|
||||||
/** K线模拟最低价 */
|
|
||||||
private BigDecimal priceMin;
|
|
||||||
|
|
||||||
/** K线模拟最高价 */
|
|
||||||
private BigDecimal priceMax;
|
|
||||||
|
|
||||||
/** 1=启用模拟 */
|
|
||||||
private Integer simulationEnabled;
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package com.it.rattan.monisuo.dto;
|
|
||||||
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.Setter;
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* K线 Tick 推送 DTO(WebSocket 广播用)
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
public class KlineTick implements Serializable {
|
|
||||||
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
/** 币种代码 */
|
|
||||||
private String coinCode;
|
|
||||||
|
|
||||||
/** 周期 */
|
|
||||||
private String interval;
|
|
||||||
|
|
||||||
/** 开盘时间戳(ms) */
|
|
||||||
private Long openTime;
|
|
||||||
|
|
||||||
/** 开盘价 */
|
|
||||||
private BigDecimal openPrice;
|
|
||||||
|
|
||||||
/** 最高价 */
|
|
||||||
private BigDecimal highPrice;
|
|
||||||
|
|
||||||
/** 最低价 */
|
|
||||||
private BigDecimal lowPrice;
|
|
||||||
|
|
||||||
/** 收盘价(最新价) */
|
|
||||||
private BigDecimal closePrice;
|
|
||||||
|
|
||||||
/** 成交量 */
|
|
||||||
private BigDecimal volume;
|
|
||||||
|
|
||||||
/** 收盘时间戳(ms) */
|
|
||||||
private Long closeTime;
|
|
||||||
|
|
||||||
/** 是否已收盘 */
|
|
||||||
private Boolean isClosed;
|
|
||||||
|
|
||||||
/** 当前时间戳 */
|
|
||||||
private Long timestamp;
|
|
||||||
|
|
||||||
public KlineTick() {}
|
|
||||||
|
|
||||||
public static KlineTick fromEntity(com.it.rattan.monisuo.entity.CoinKline kline, boolean isClosed) {
|
|
||||||
KlineTick tick = new KlineTick();
|
|
||||||
tick.setCoinCode(kline.getCoinCode());
|
|
||||||
tick.setInterval(kline.getInterval());
|
|
||||||
tick.setOpenTime(kline.getOpenTime());
|
|
||||||
tick.setOpenPrice(kline.getOpenPrice());
|
|
||||||
tick.setHighPrice(kline.getHighPrice());
|
|
||||||
tick.setLowPrice(kline.getLowPrice());
|
|
||||||
tick.setClosePrice(kline.getClosePrice());
|
|
||||||
tick.setVolume(kline.getVolume());
|
|
||||||
tick.setCloseTime(kline.getCloseTime());
|
|
||||||
tick.setIsClosed(isClosed);
|
|
||||||
tick.setTimestamp(System.currentTimeMillis());
|
|
||||||
return tick;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -99,15 +99,6 @@ public class Coin implements Serializable {
|
|||||||
/** 每日最大涨跌幅(%) */
|
/** 每日最大涨跌幅(%) */
|
||||||
private BigDecimal maxChangePercent;
|
private BigDecimal maxChangePercent;
|
||||||
|
|
||||||
/** K线模拟最低价 */
|
|
||||||
private BigDecimal priceMin;
|
|
||||||
|
|
||||||
/** K线模拟最高价 */
|
|
||||||
private BigDecimal priceMax;
|
|
||||||
|
|
||||||
/** 1=启用K线模拟 */
|
|
||||||
private Integer simulationEnabled;
|
|
||||||
|
|
||||||
/** 创建时间 */
|
/** 创建时间 */
|
||||||
@TableField(fill = FieldFill.INSERT)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
package com.it.rattan.monisuo.entity;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.*;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.Setter;
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* K线蜡烛数据实体
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
@TableName("coin_kline")
|
|
||||||
public class CoinKline implements Serializable {
|
|
||||||
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
@TableId(type = IdType.AUTO)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
/** 币种代码 */
|
|
||||||
private String coinCode;
|
|
||||||
|
|
||||||
/** 周期: 15m/1h/4h/1d/1M */
|
|
||||||
private String interval;
|
|
||||||
|
|
||||||
/** 开盘时间戳(ms) */
|
|
||||||
private Long openTime;
|
|
||||||
|
|
||||||
/** 开盘价 */
|
|
||||||
private BigDecimal openPrice;
|
|
||||||
|
|
||||||
/** 最高价 */
|
|
||||||
private BigDecimal highPrice;
|
|
||||||
|
|
||||||
/** 最低价 */
|
|
||||||
private BigDecimal lowPrice;
|
|
||||||
|
|
||||||
/** 收盘价 */
|
|
||||||
private BigDecimal closePrice;
|
|
||||||
|
|
||||||
/** 模拟成交量 */
|
|
||||||
private BigDecimal volume;
|
|
||||||
|
|
||||||
/** 收盘时间戳(ms) */
|
|
||||||
private Long closeTime;
|
|
||||||
|
|
||||||
/** 创建时间 */
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
|
||||||
private LocalDateTime createTime;
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package com.it.rattan.monisuo.mapper;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
||||||
import com.it.rattan.monisuo.entity.CoinKline;
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
import org.apache.ibatis.annotations.Select;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* K线数据 Mapper
|
|
||||||
*/
|
|
||||||
@Mapper
|
|
||||||
public interface CoinKlineMapper extends BaseMapper<CoinKline> {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询最近的K线数据(按时间倒序)
|
|
||||||
*/
|
|
||||||
@Select("SELECT * FROM coin_kline WHERE coin_code = #{coinCode} " +
|
|
||||||
"AND `interval` = #{interval} " +
|
|
||||||
"AND close_time < #{beforeCloseTime} " +
|
|
||||||
"ORDER BY open_time DESC LIMIT #{limit}")
|
|
||||||
List<CoinKline> selectRecentCandles(@Param("coinCode") String coinCode,
|
|
||||||
@Param("interval") String interval,
|
|
||||||
@Param("beforeCloseTime") long beforeCloseTime,
|
|
||||||
@Param("limit") int limit);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询最近的K线数据(不限 before)
|
|
||||||
*/
|
|
||||||
@Select("SELECT * FROM coin_kline WHERE coin_code = #{coinCode} " +
|
|
||||||
"AND `interval` = #{interval} " +
|
|
||||||
"ORDER BY open_time DESC LIMIT #{limit}")
|
|
||||||
List<CoinKline> selectLatestCandles(@Param("coinCode") String coinCode,
|
|
||||||
@Param("interval") String interval,
|
|
||||||
@Param("limit") int limit);
|
|
||||||
}
|
|
||||||
@@ -92,10 +92,8 @@ public class AssetService {
|
|||||||
result.put("tradeBalance", tradeBalance);
|
result.put("tradeBalance", tradeBalance);
|
||||||
BigDecimal totalAsset = fundBalance.add(tradeBalance);
|
BigDecimal totalAsset = fundBalance.add(tradeBalance);
|
||||||
result.put("totalAsset", totalAsset);
|
result.put("totalAsset", totalAsset);
|
||||||
// 总盈亏 = 总资产 + 累计提现 - 累计充值(净投入 = 充值 - 提现,总资产超出净投入即为盈利)
|
// 总盈亏 = 所有持仓的未实现盈亏之和(当前市值 - 持仓成本)
|
||||||
BigDecimal totalDeposit = fund.getTotalDeposit() != null ? fund.getTotalDeposit() : BigDecimal.ZERO;
|
result.put("totalProfit", totalValue.subtract(totalCost));
|
||||||
BigDecimal totalWithdraw = fund.getTotalWithdraw() != null ? fund.getTotalWithdraw() : BigDecimal.ZERO;
|
|
||||||
result.put("totalProfit", totalAsset.add(totalWithdraw).subtract(totalDeposit));
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,482 +0,0 @@
|
|||||||
package com.it.rattan.monisuo.service;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
||||||
import com.it.rattan.monisuo.dto.KlineConfigUpdate;
|
|
||||||
import com.it.rattan.monisuo.dto.KlineTick;
|
|
||||||
import com.it.rattan.monisuo.entity.Coin;
|
|
||||||
import com.it.rattan.monisuo.entity.CoinKline;
|
|
||||||
import com.it.rattan.monisuo.mapper.CoinKlineMapper;
|
|
||||||
import com.it.rattan.monisuo.mapper.CoinMapper;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.data.redis.core.RedisTemplate;
|
|
||||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import javax.annotation.PostConstruct;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.math.RoundingMode;
|
|
||||||
import java.time.*;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.concurrent.ThreadLocalRandom;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* K线模拟引擎
|
|
||||||
*
|
|
||||||
* 双写策略:ConcurrentHashMap(高性能读写) + Redis(持久化/重启恢复)
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
public class CoinKlineService {
|
|
||||||
|
|
||||||
private static final String REDIS_CANDLE_PREFIX = "kline:candle:";
|
|
||||||
private static final String REDIS_PRICE_PREFIX = "kline:price:";
|
|
||||||
|
|
||||||
/** 本地内存 — tick 级别高频读写 */
|
|
||||||
private final ConcurrentHashMap<String, CoinKline> currentCandles = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
private static final List<String> INTERVALS = Arrays.asList("15m", "1h", "4h", "1d", "1M");
|
|
||||||
private static final BigDecimal TICK_VOLUME_BASE = new BigDecimal("100");
|
|
||||||
private static final double DAILY_TICKS = 9600.0;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private CoinMapper coinMapper;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private CoinKlineMapper coinKlineMapper;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private CoinService coinService;
|
|
||||||
|
|
||||||
@Autowired(required = false)
|
|
||||||
private SimpMessagingTemplate messagingTemplate;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private RedisTemplate<String, Object> redisTemplate;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动时从 Redis 恢复当前进行中的蜡烛
|
|
||||||
*/
|
|
||||||
@PostConstruct
|
|
||||||
public void init() {
|
|
||||||
Set<String> keys = redisTemplate.keys(REDIS_CANDLE_PREFIX + "*");
|
|
||||||
if (keys != null && !keys.isEmpty()) {
|
|
||||||
for (String key : keys) {
|
|
||||||
try {
|
|
||||||
Object obj = redisTemplate.opsForValue().get(key);
|
|
||||||
if (obj instanceof CoinKline) {
|
|
||||||
CoinKline candle = (CoinKline) obj;
|
|
||||||
String mapKey = candle.getCoinCode() + ":" + candle.getInterval();
|
|
||||||
currentCandles.put(mapKey, candle);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("恢复K线数据失败: key={}, error={}", key, e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.info("从Redis恢复了 {} 个进行中的K线蜡烛", currentCandles.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 恢复模拟价格到 CoinService 缓存
|
|
||||||
Set<String> priceKeys = redisTemplate.keys(REDIS_PRICE_PREFIX + "*");
|
|
||||||
if (priceKeys != null && !priceKeys.isEmpty()) {
|
|
||||||
for (String key : priceKeys) {
|
|
||||||
try {
|
|
||||||
String coinCode = key.substring(REDIS_PRICE_PREFIX.length());
|
|
||||||
Object priceObj = redisTemplate.opsForValue().get(key);
|
|
||||||
if (priceObj instanceof BigDecimal) {
|
|
||||||
coinService.updateCachedPrice(coinCode, (BigDecimal) priceObj);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("恢复价格失败: key={}, error={}", key, e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.info("从Redis恢复了 {} 个币种的模拟价格", priceKeys.size());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================ 公开方法 ============================
|
|
||||||
|
|
||||||
public List<String> getIntervals() {
|
|
||||||
return INTERVALS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取历史K线(分页)
|
|
||||||
*/
|
|
||||||
public List<CoinKline> getHistory(String coinCode, String interval, int limit, Long beforeTime) {
|
|
||||||
if (limit <= 0 || limit > 500) limit = 200;
|
|
||||||
if (beforeTime == null || beforeTime <= 0) {
|
|
||||||
beforeTime = System.currentTimeMillis();
|
|
||||||
}
|
|
||||||
List<CoinKline> candles = coinKlineMapper.selectRecentCandles(coinCode, interval, beforeTime, limit);
|
|
||||||
Collections.reverse(candles);
|
|
||||||
return candles;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前进行中的K线
|
|
||||||
*/
|
|
||||||
public CoinKline getCurrentCandle(String coinCode, String interval) {
|
|
||||||
String key = coinCode.toUpperCase() + ":" + interval;
|
|
||||||
CoinKline kline = currentCandles.get(key);
|
|
||||||
if (kline != null) {
|
|
||||||
return copyKline(kline);
|
|
||||||
}
|
|
||||||
List<CoinKline> latest = coinKlineMapper.selectLatestCandles(coinCode.toUpperCase(), interval, 1);
|
|
||||||
if (!latest.isEmpty()) {
|
|
||||||
return latest.get(0);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有启用模拟的币种
|
|
||||||
*/
|
|
||||||
public List<Coin> getSimulatedCoins() {
|
|
||||||
LambdaQueryWrapper<Coin> wrapper = new LambdaQueryWrapper<>();
|
|
||||||
wrapper.eq(Coin::getSimulationEnabled, 1)
|
|
||||||
.eq(Coin::getStatus, 1);
|
|
||||||
return coinMapper.selectList(wrapper);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理员更新K线配置
|
|
||||||
*/
|
|
||||||
public void updateConfig(KlineConfigUpdate config) {
|
|
||||||
Coin coin = coinService.getCoinByCode(config.getCoinCode());
|
|
||||||
if (coin == null) throw new RuntimeException("币种不存在");
|
|
||||||
|
|
||||||
if (config.getTradeStartTime() != null) coin.setTradeStartTime(config.getTradeStartTime());
|
|
||||||
if (config.getTradeEndTime() != null) coin.setTradeEndTime(config.getTradeEndTime());
|
|
||||||
if (config.getPriceMin() != null) coin.setPriceMin(config.getPriceMin());
|
|
||||||
if (config.getPriceMax() != null) coin.setPriceMax(config.getPriceMax());
|
|
||||||
if (config.getSimulationEnabled() != null) coin.setSimulationEnabled(config.getSimulationEnabled());
|
|
||||||
|
|
||||||
if (coin.getPriceMin() != null && coin.getPriceMax() != null
|
|
||||||
&& coin.getPriceMin().compareTo(coin.getPriceMax()) >= 0) {
|
|
||||||
throw new RuntimeException("最低价必须小于最高价");
|
|
||||||
}
|
|
||||||
|
|
||||||
coin.setUpdateTime(LocalDateTime.now());
|
|
||||||
coinMapper.updateById(coin);
|
|
||||||
coinService.clearCache(coin.getCode());
|
|
||||||
|
|
||||||
log.info("K线配置已更新: {} simulation={} priceMin={} priceMax={}",
|
|
||||||
coin.getCode(), coin.getSimulationEnabled(), coin.getPriceMin(), coin.getPriceMax());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================ Tick 生成 ============================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 每 3 秒调用:为所有启用模拟的币种生成一个价格 tick
|
|
||||||
*/
|
|
||||||
public List<KlineTick> generateTicks() {
|
|
||||||
List<Coin> coins = getSimulatedCoins();
|
|
||||||
if (coins.isEmpty()) return Collections.emptyList();
|
|
||||||
|
|
||||||
LocalTime now = LocalTime.now();
|
|
||||||
List<KlineTick> ticks = new ArrayList<>();
|
|
||||||
|
|
||||||
for (Coin coin : coins) {
|
|
||||||
if (!isInTradingHours(coin, now)) continue;
|
|
||||||
if (coin.getPriceMin() == null || coin.getPriceMax() == null) continue;
|
|
||||||
if (coin.getPriceMin().compareTo(coin.getPriceMax()) >= 0) continue;
|
|
||||||
|
|
||||||
BigDecimal currentPrice = coin.getPrice();
|
|
||||||
if (currentPrice == null || currentPrice.compareTo(BigDecimal.ZERO) <= 0) {
|
|
||||||
currentPrice = coin.getPriceMin().add(coin.getPriceMax())
|
|
||||||
.divide(new BigDecimal("2"), 8, RoundingMode.DOWN);
|
|
||||||
}
|
|
||||||
final BigDecimal tickOpenPrice = currentPrice;
|
|
||||||
|
|
||||||
BigDecimal newPrice = simulatePrice(coin, currentPrice);
|
|
||||||
|
|
||||||
// 更新内存价格
|
|
||||||
coin.setPrice(newPrice);
|
|
||||||
|
|
||||||
// 同步到 CoinService 本地缓存 + Redis
|
|
||||||
coinService.updateCachedPrice(coin.getCode(), newPrice);
|
|
||||||
savePriceToRedis(coin.getCode(), newPrice);
|
|
||||||
|
|
||||||
// 为每个周期更新内存K线
|
|
||||||
for (String interval : INTERVALS) {
|
|
||||||
String key = coin.getCode() + ":" + interval;
|
|
||||||
CoinKline candle = currentCandles.computeIfAbsent(key, k -> {
|
|
||||||
long[] times = calculateIntervalTimes(interval);
|
|
||||||
CoinKline c = new CoinKline();
|
|
||||||
c.setCoinCode(coin.getCode());
|
|
||||||
c.setInterval(interval);
|
|
||||||
c.setOpenTime(times[0]);
|
|
||||||
c.setCloseTime(times[1]);
|
|
||||||
c.setOpenPrice(tickOpenPrice);
|
|
||||||
c.setHighPrice(tickOpenPrice);
|
|
||||||
c.setLowPrice(tickOpenPrice);
|
|
||||||
c.setClosePrice(tickOpenPrice);
|
|
||||||
c.setVolume(BigDecimal.ZERO);
|
|
||||||
return c;
|
|
||||||
});
|
|
||||||
|
|
||||||
candle.setClosePrice(newPrice);
|
|
||||||
if (newPrice.compareTo(candle.getHighPrice()) > 0) {
|
|
||||||
candle.setHighPrice(newPrice);
|
|
||||||
}
|
|
||||||
if (newPrice.compareTo(candle.getLowPrice()) < 0) {
|
|
||||||
candle.setLowPrice(newPrice);
|
|
||||||
}
|
|
||||||
BigDecimal volIncrement = TICK_VOLUME_BASE.multiply(
|
|
||||||
new BigDecimal(ThreadLocalRandom.current().nextDouble(0.5, 2.0))
|
|
||||||
.setScale(4, RoundingMode.DOWN)
|
|
||||||
);
|
|
||||||
candle.setVolume(candle.getVolume().add(volIncrement));
|
|
||||||
|
|
||||||
// 每 tick 写入 Redis(异步安全)
|
|
||||||
saveCandleToRedis(key, candle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为所有周期生成 tick 事件
|
|
||||||
for (String interval : INTERVALS) {
|
|
||||||
String tickKey = coin.getCode() + ":" + interval;
|
|
||||||
CoinKline tickCandle = currentCandles.get(tickKey);
|
|
||||||
if (tickCandle != null) {
|
|
||||||
ticks.add(KlineTick.fromEntity(tickCandle, false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ticks;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================ K线收盘 ============================
|
|
||||||
|
|
||||||
public List<KlineTick> closeCandlesForInterval(String closedInterval) {
|
|
||||||
List<Coin> coins = getSimulatedCoins();
|
|
||||||
List<KlineTick> closedTicks = new ArrayList<>();
|
|
||||||
|
|
||||||
for (Coin coin : coins) {
|
|
||||||
String key = coin.getCode() + ":" + closedInterval;
|
|
||||||
CoinKline candle = currentCandles.remove(key);
|
|
||||||
if (candle == null) continue;
|
|
||||||
|
|
||||||
if (candle.getClosePrice() != null && candle.getClosePrice().compareTo(BigDecimal.ZERO) > 0) {
|
|
||||||
coinKlineMapper.insert(candle);
|
|
||||||
log.debug("K线已收盘: {} {} open={}", coin.getCode(), closedInterval, candle.getOpenPrice());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从 Redis 移除已收盘的蜡烛
|
|
||||||
removeCandleFromRedis(key);
|
|
||||||
|
|
||||||
closedTicks.add(KlineTick.fromEntity(candle, true));
|
|
||||||
|
|
||||||
if ("1d".equals(closedInterval)) {
|
|
||||||
updateCoinDailyStats(coin, candle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return closedTicks;
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<String> detectIntervalCrossings(long previousTickTime) {
|
|
||||||
List<String> crossed = new ArrayList<>();
|
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
|
|
||||||
if (differentIntervalSlot(previousTickTime, now, 15 * 60 * 1000L)) crossed.add("15m");
|
|
||||||
if (differentIntervalSlot(previousTickTime, now, 60 * 60 * 1000L)) crossed.add("1h");
|
|
||||||
if (different4hSlot(previousTickTime, now)) crossed.add("4h");
|
|
||||||
if (differentDaySlot(previousTickTime, now)) crossed.add("1d");
|
|
||||||
if (differentMonthSlot(previousTickTime, now)) crossed.add("1M");
|
|
||||||
|
|
||||||
return crossed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================ Redis 操作 ============================
|
|
||||||
|
|
||||||
private void saveCandleToRedis(String key, CoinKline candle) {
|
|
||||||
try {
|
|
||||||
redisTemplate.opsForValue().set(REDIS_CANDLE_PREFIX + key, candle);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Redis写入蜡烛失败: key={}, error={}", key, e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void removeCandleFromRedis(String key) {
|
|
||||||
try {
|
|
||||||
redisTemplate.delete(REDIS_CANDLE_PREFIX + key);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Redis删除蜡烛失败: key={}, error={}", key, e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void savePriceToRedis(String coinCode, BigDecimal price) {
|
|
||||||
try {
|
|
||||||
redisTemplate.opsForValue().set(REDIS_PRICE_PREFIX + coinCode.toUpperCase(), price);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Redis写入价格失败: coin={}, error={}", coinCode, e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================ GBM 模拟算法 ============================
|
|
||||||
|
|
||||||
private BigDecimal simulatePrice(Coin coin, BigDecimal currentPrice) {
|
|
||||||
double S = currentPrice.doubleValue();
|
|
||||||
double priceMin = coin.getPriceMin().doubleValue();
|
|
||||||
double priceMax = coin.getPriceMax().doubleValue();
|
|
||||||
double midpoint = (priceMin + priceMax) / 2.0;
|
|
||||||
double halfRange = (priceMax - priceMin) / 2.0;
|
|
||||||
|
|
||||||
double sigma = halfRange / Math.max(midpoint, 0.00000001) / Math.sqrt(DAILY_TICKS);
|
|
||||||
double dt = 1.0 / DAILY_TICKS;
|
|
||||||
|
|
||||||
double u1 = ThreadLocalRandom.current().nextDouble();
|
|
||||||
double u2 = ThreadLocalRandom.current().nextDouble();
|
|
||||||
double Z = Math.sqrt(-2.0 * Math.log(Math.max(u1, 1e-10))) * Math.cos(2.0 * Math.PI * u2);
|
|
||||||
|
|
||||||
double mu = 0;
|
|
||||||
double deviation = (S - midpoint) / halfRange;
|
|
||||||
double absDeviation = Math.abs(deviation);
|
|
||||||
if (absDeviation > 0.85) {
|
|
||||||
mu = -deviation * 0.02;
|
|
||||||
} else if (absDeviation > 0.65) {
|
|
||||||
mu = -deviation * 0.005;
|
|
||||||
}
|
|
||||||
|
|
||||||
double dS = mu * S * dt + sigma * S * Math.sqrt(dt) * Z;
|
|
||||||
double newPrice = S + dS;
|
|
||||||
|
|
||||||
newPrice = Math.max(priceMin, Math.min(priceMax, newPrice));
|
|
||||||
newPrice = Math.max(newPrice, 0.00000001);
|
|
||||||
|
|
||||||
return new BigDecimal(newPrice).setScale(8, RoundingMode.DOWN);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================ 辅助方法 ============================
|
|
||||||
|
|
||||||
private boolean isInTradingHours(Coin coin, LocalTime now) {
|
|
||||||
String startStr = coin.getTradeStartTime();
|
|
||||||
String endStr = coin.getTradeEndTime();
|
|
||||||
if (startStr == null || endStr == null) return true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
LocalTime start = LocalTime.parse(startStr, DateTimeFormatter.ofPattern("HH:mm"));
|
|
||||||
LocalTime end = LocalTime.parse(endStr, DateTimeFormatter.ofPattern("HH:mm"));
|
|
||||||
if (end.isAfter(start)) {
|
|
||||||
return !now.isBefore(start) && !now.isAfter(end);
|
|
||||||
} else {
|
|
||||||
return !now.isBefore(start) || !now.isAfter(end);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private long[] calculateIntervalTimes(String interval) {
|
|
||||||
Instant now = Instant.now();
|
|
||||||
ZoneId zone = ZoneId.of("Asia/Shanghai");
|
|
||||||
ZonedDateTime zdt = now.atZone(zone);
|
|
||||||
|
|
||||||
long openTime, closeTime;
|
|
||||||
switch (interval) {
|
|
||||||
case "15m": {
|
|
||||||
int minute = zdt.getMinute();
|
|
||||||
int slot = (minute / 15) * 15;
|
|
||||||
ZonedDateTime open = zdt.withMinute(slot).withSecond(0).withNano(0);
|
|
||||||
openTime = open.toInstant().toEpochMilli();
|
|
||||||
closeTime = open.plusMinutes(15).toInstant().toEpochMilli();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "1h": {
|
|
||||||
ZonedDateTime open = zdt.withMinute(0).withSecond(0).withNano(0);
|
|
||||||
openTime = open.toInstant().toEpochMilli();
|
|
||||||
closeTime = open.plusHours(1).toInstant().toEpochMilli();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "4h": {
|
|
||||||
int hour = zdt.getHour();
|
|
||||||
int slot = (hour / 4) * 4;
|
|
||||||
ZonedDateTime open = zdt.withHour(slot).withMinute(0).withSecond(0).withNano(0);
|
|
||||||
openTime = open.toInstant().toEpochMilli();
|
|
||||||
closeTime = open.plusHours(4).toInstant().toEpochMilli();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "1d": {
|
|
||||||
ZonedDateTime open = zdt.withHour(0).withMinute(0).withSecond(0).withNano(0);
|
|
||||||
openTime = open.toInstant().toEpochMilli();
|
|
||||||
closeTime = open.plusDays(1).toInstant().toEpochMilli();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "1M": {
|
|
||||||
ZonedDateTime open = zdt.withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0);
|
|
||||||
openTime = open.toInstant().toEpochMilli();
|
|
||||||
closeTime = open.plusMonths(1).toInstant().toEpochMilli();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw new IllegalArgumentException("不支持的周期: " + interval);
|
|
||||||
}
|
|
||||||
return new long[]{openTime, closeTime};
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean differentIntervalSlot(long t1, long t2, long intervalMs) {
|
|
||||||
return (t1 / intervalMs) != (t2 / intervalMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean different4hSlot(long t1, long t2) {
|
|
||||||
ZoneId zone = ZoneId.of("Asia/Shanghai");
|
|
||||||
int h1 = Instant.ofEpochMilli(t1).atZone(zone).getHour() / 4;
|
|
||||||
int h2 = Instant.ofEpochMilli(t2).atZone(zone).getHour() / 4;
|
|
||||||
int d1 = Instant.ofEpochMilli(t1).atZone(zone).getDayOfYear();
|
|
||||||
int d2 = Instant.ofEpochMilli(t2).atZone(zone).getDayOfYear();
|
|
||||||
return d1 != d2 || h1 != h2;
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean differentDaySlot(long t1, long t2) {
|
|
||||||
ZoneId zone = ZoneId.of("Asia/Shanghai");
|
|
||||||
return Instant.ofEpochMilli(t1).atZone(zone).toLocalDate()
|
|
||||||
.isBefore(Instant.ofEpochMilli(t2).atZone(zone).toLocalDate());
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean differentMonthSlot(long t1, long t2) {
|
|
||||||
ZoneId zone = ZoneId.of("Asia/Shanghai");
|
|
||||||
ZonedDateTime z1 = Instant.ofEpochMilli(t1).atZone(zone);
|
|
||||||
ZonedDateTime z2 = Instant.ofEpochMilli(t2).atZone(zone);
|
|
||||||
return z1.getYear() != z2.getYear() || z1.getMonthValue() != z2.getMonthValue();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void updateCoinDailyStats(Coin coin, CoinKline dailyCandle) {
|
|
||||||
coin.setPrice(dailyCandle.getClosePrice());
|
|
||||||
BigDecimal change = dailyCandle.getClosePrice()
|
|
||||||
.subtract(dailyCandle.getOpenPrice())
|
|
||||||
.divide(dailyCandle.getOpenPrice(), 8, RoundingMode.DOWN)
|
|
||||||
.multiply(new BigDecimal("100"));
|
|
||||||
coin.setChange24h(change);
|
|
||||||
coin.setHigh24h(dailyCandle.getHighPrice());
|
|
||||||
coin.setLow24h(dailyCandle.getLowPrice());
|
|
||||||
coin.setVolume24h(dailyCandle.getVolume());
|
|
||||||
coin.setUpdateTime(LocalDateTime.now());
|
|
||||||
coinMapper.updateById(coin);
|
|
||||||
coinService.clearCache(coin.getCode());
|
|
||||||
|
|
||||||
// 日线收盘后清理 Redis 中的价格(下次 tick 会重新写入)
|
|
||||||
try {
|
|
||||||
redisTemplate.delete(REDIS_PRICE_PREFIX + coin.getCode().toUpperCase());
|
|
||||||
} catch (Exception ignored) {}
|
|
||||||
|
|
||||||
log.info("日线收盘更新: {} price={} change={}%", coin.getCode(), coin.getPrice(), change);
|
|
||||||
}
|
|
||||||
|
|
||||||
private CoinKline copyKline(CoinKline source) {
|
|
||||||
CoinKline copy = new CoinKline();
|
|
||||||
copy.setId(source.getId());
|
|
||||||
copy.setCoinCode(source.getCoinCode());
|
|
||||||
copy.setInterval(source.getInterval());
|
|
||||||
copy.setOpenTime(source.getOpenTime());
|
|
||||||
copy.setOpenPrice(source.getOpenPrice());
|
|
||||||
copy.setHighPrice(source.getHighPrice());
|
|
||||||
copy.setLowPrice(source.getLowPrice());
|
|
||||||
copy.setClosePrice(source.getClosePrice());
|
|
||||||
copy.setVolume(source.getVolume());
|
|
||||||
copy.setCloseTime(source.getCloseTime());
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -132,25 +132,6 @@ public class CoinService extends ServiceImpl<CoinMapper, Coin> {
|
|||||||
return list(wrapper);
|
return list(wrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新缓存中的币种价格(K线模拟专用,不写DB)
|
|
||||||
*/
|
|
||||||
public void updateCachedPrice(String code, BigDecimal price) {
|
|
||||||
String key = code.toUpperCase();
|
|
||||||
Coin cached = coinCodeCache.get(key);
|
|
||||||
if (cached != null) {
|
|
||||||
cached.setPrice(price);
|
|
||||||
}
|
|
||||||
if (cachedActiveCoins != null) {
|
|
||||||
for (Coin coin : cachedActiveCoins) {
|
|
||||||
if (coin.getCode().equalsIgnoreCase(key)) {
|
|
||||||
coin.setPrice(price);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清除所有缓存(币种数据变更时调用)
|
* 清除所有缓存(币种数据变更时调用)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
package com.it.rattan.monisuo.service;
|
|
||||||
|
|
||||||
import com.it.rattan.monisuo.dto.KlineTick;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* K线调度器 — 定时生成 tick + 检测周期边界
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
public class KlineScheduler {
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private CoinKlineService coinKlineService;
|
|
||||||
|
|
||||||
@Autowired(required = false)
|
|
||||||
private SimpMessagingTemplate messagingTemplate;
|
|
||||||
|
|
||||||
/** 上次 tick 的时间戳,用于检测周期边界跨越 */
|
|
||||||
private long lastTickTime = System.currentTimeMillis();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 每 3 秒执行一次:生成价格 tick + WebSocket 广播
|
|
||||||
*/
|
|
||||||
@Scheduled(fixedRate = 3000)
|
|
||||||
public void tick() {
|
|
||||||
try {
|
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
|
|
||||||
// 1. 检测周期边界跨越
|
|
||||||
List<String> crossedIntervals = coinKlineService.detectIntervalCrossings(lastTickTime);
|
|
||||||
if (!crossedIntervals.isEmpty()) {
|
|
||||||
for (String interval : crossedIntervals) {
|
|
||||||
log.info("K线周期跨越: {}", interval);
|
|
||||||
List<KlineTick> closedTicks = coinKlineService.closeCandlesForInterval(interval);
|
|
||||||
// 广播收盘 tick
|
|
||||||
for (KlineTick tick : closedTicks) {
|
|
||||||
broadcastTick(tick);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 生成新的价格 tick
|
|
||||||
List<KlineTick> ticks = coinKlineService.generateTicks();
|
|
||||||
|
|
||||||
// 3. WebSocket 广播
|
|
||||||
for (KlineTick tick : ticks) {
|
|
||||||
broadcastTick(tick);
|
|
||||||
}
|
|
||||||
|
|
||||||
lastTickTime = now;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("K线 tick 调度异常", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void broadcastTick(KlineTick tick) {
|
|
||||||
if (messagingTemplate != null) {
|
|
||||||
try {
|
|
||||||
messagingTemplate.convertAndSend("/topic/kline/" + tick.getCoinCode(), tick);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("WebSocket 广播失败: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,8 +14,6 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.LocalTime;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,9 +48,6 @@ public class TradeService {
|
|||||||
throw new RuntimeException("该币种已下架");
|
throw new RuntimeException("该币种已下架");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模拟币种交易校验
|
|
||||||
validateSimulationTrade(coin, price);
|
|
||||||
|
|
||||||
// 计算金额
|
// 计算金额
|
||||||
BigDecimal amount = price.multiply(quantity).setScale(8, RoundingMode.DOWN);
|
BigDecimal amount = price.multiply(quantity).setScale(8, RoundingMode.DOWN);
|
||||||
|
|
||||||
@@ -133,9 +128,6 @@ public class TradeService {
|
|||||||
throw new RuntimeException("该币种已下架");
|
throw new RuntimeException("该币种已下架");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模拟币种交易校验
|
|
||||||
validateSimulationTrade(coin, price);
|
|
||||||
|
|
||||||
// 检查持仓
|
// 检查持仓
|
||||||
AccountTrade coinAccount = assetService.getOrCreateTradeAccount(userId, coinCode);
|
AccountTrade coinAccount = assetService.getOrCreateTradeAccount(userId, coinCode);
|
||||||
if (coinAccount.getQuantity().compareTo(quantity) < 0) {
|
if (coinAccount.getQuantity().compareTo(quantity) < 0) {
|
||||||
@@ -222,43 +214,4 @@ public class TradeService {
|
|||||||
.eq(OrderTrade::getOrderNo, orderNo);
|
.eq(OrderTrade::getOrderNo, orderNo);
|
||||||
return orderTradeMapper.selectOne(wrapper);
|
return orderTradeMapper.selectOne(wrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 模拟币种交易校验:交易时段 + 价格一致性
|
|
||||||
*/
|
|
||||||
private void validateSimulationTrade(Coin coin, BigDecimal tradePrice) {
|
|
||||||
if (coin.getSimulationEnabled() == null || coin.getSimulationEnabled() != 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 校验交易时段
|
|
||||||
if (coin.getTradeStartTime() != null && coin.getTradeEndTime() != null) {
|
|
||||||
try {
|
|
||||||
LocalTime now = LocalTime.now();
|
|
||||||
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("HH:mm");
|
|
||||||
LocalTime start = LocalTime.parse(coin.getTradeStartTime(), fmt);
|
|
||||||
LocalTime end = LocalTime.parse(coin.getTradeEndTime(), fmt);
|
|
||||||
boolean inRange;
|
|
||||||
if (end.isAfter(start)) {
|
|
||||||
inRange = !now.isBefore(start) && !now.isAfter(end);
|
|
||||||
} else {
|
|
||||||
inRange = !now.isBefore(start) || !now.isAfter(end);
|
|
||||||
}
|
|
||||||
if (!inRange) {
|
|
||||||
throw new RuntimeException("当前不在交易时段内(" + coin.getTradeStartTime() + " - " + coin.getTradeEndTime() + ")");
|
|
||||||
}
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
throw e;
|
|
||||||
} catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 校验价格一致性(允许 0.1% 滑点)
|
|
||||||
if (coin.getPrice() != null && coin.getPrice().compareTo(BigDecimal.ZERO) > 0) {
|
|
||||||
BigDecimal diff = tradePrice.subtract(coin.getPrice()).abs();
|
|
||||||
BigDecimal threshold = coin.getPrice().multiply(new BigDecimal("0.001"));
|
|
||||||
if (diff.compareTo(threshold) > 0) {
|
|
||||||
throw new RuntimeException("交易价格已变化,请刷新后重试");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,6 @@ spring:
|
|||||||
enabled: true
|
enabled: true
|
||||||
max-file-size: 5MB
|
max-file-size: 5MB
|
||||||
max-request-size: 10MB
|
max-request-size: 10MB
|
||||||
redis:
|
|
||||||
host: 8.155.172.147
|
|
||||||
port: 6379
|
|
||||||
password: sion+Rui!$
|
|
||||||
database: 1
|
|
||||||
timeout: 3000ms
|
|
||||||
lettuce:
|
|
||||||
pool:
|
|
||||||
max-active: 8
|
|
||||||
max-idle: 8
|
|
||||||
min-idle: 2
|
|
||||||
datasource:
|
datasource:
|
||||||
username: monisuo
|
username: monisuo
|
||||||
password: JPJ8wYicSGC8aRnk
|
password: JPJ8wYicSGC8aRnk
|
||||||
|
|||||||
Reference in New Issue
Block a user