From 61dd0073d53c19eae1502721fee47a0e5b7437d3 Mon Sep 17 00:00:00 2001
From: Lee <1633292@qq.com>
Date: Mon, 13 Jul 2026 19:28:43 +0800
Subject: [PATCH] init
---
pom.xml | 186 +++++
.../backtestify/BacktestifyApplication.java | 12 +
.../backtestify/common/ApiResult.java | 53 ++
.../backtestify/common/BaseEntity.java | 28 +
.../backtestify/common/BaseRequest.java | 22 +
.../backtestify/common/BusinessException.java | 31 +
.../backtestify/common/ErrorCode.java | 38 +
.../common/GlobalExceptionHandler.java | 66 ++
.../backtestify/common/JacksonConfig.java | 53 ++
.../backtestify/common/PageRequest.java | 24 +
.../backtestify/common/PageResult.java | 47 ++
.../common/RequestLoggingAspect.java | 71 ++
.../config/MarketDataProperties.java | 59 ++
.../backtestify/config/MyBatisPlusConfig.java | 24 +
.../config/MyMetaObjectHandler.java | 26 +
.../backtestify/config/RedisConfig.java | 35 +
.../backtestify/config/SchedulingConfig.java | 12 +
.../backtestify/config/WebMvcConfig.java | 62 ++
.../controller/AdminStrategyController.java | 39 +
.../controller/StrategyController.java | 97 +++
.../entity/InstrumentDictionary.java | 35 +
.../backtestify/entity/KLineRecord.java | 33 +
.../backtestify/entity/MarketDataSyncLog.java | 42 ++
.../backtestify/entity/StrategyConfig.java | 52 ++
.../backtestify/entity/StrategyResult.java | 64 ++
.../backtestify/entity/TradeDetail.java | 42 ++
.../backtestify/entity/UserSignal.java | 31 +
.../yangwale/backtestify/enums/Direction.java | 27 +
.../backtestify/enums/KLinePeriod.java | 36 +
.../yangwale/backtestify/enums/StopUnit.java | 27 +
.../backtestify/enums/StrategyStatus.java | 29 +
.../backtestify/enums/TradeAction.java | 20 +
.../backtestify/enums/VolumeUnit.java | 27 +
.../mapper/InstrumentDictionaryMapper.java | 9 +
.../backtestify/mapper/KLineMapper.java | 48 ++
.../mapper/MarketDataSyncLogMapper.java | 9 +
.../mapper/StrategyConfigMapper.java | 9 +
.../mapper/StrategyResultMapper.java | 9 +
.../backtestify/mapper/TradeDetailMapper.java | 9 +
.../backtestify/mapper/UserSignalMapper.java | 9 +
.../model/dto/IndicatorValues.java | 72 ++
.../backtestify/model/dto/KLineData.java | 41 ++
.../backtestify/model/dto/TradeRecordDto.java | 31 +
.../request/AdminStrategyPageRequest.java | 24 +
.../model/request/BacktestRequest.java | 66 ++
.../model/request/SignalQueryRequest.java | 32 +
.../model/response/BacktestResponse.java | 86 +++
.../model/response/SignalPointResponse.java | 28 +
.../response/StrategyDetailResponse.java | 55 ++
.../response/StrategyListItemResponse.java | 32 +
.../backtestify/service/BacktestEngine.java | 15 +
.../service/MarketDataService.java | 28 +
.../backtestify/service/StrategyService.java | 58 ++
.../service/impl/BacktestEngineImpl.java | 529 ++++++++++++++
.../service/impl/MarketDataServiceImpl.java | 85 +++
.../service/impl/StrategyServiceImpl.java | 329 +++++++++
.../market/FakeMarketDataProvider.java | 396 +++++++++++
.../service/market/MarketDataProvider.java | 36 +
.../market/MysqlMarketDataProvider.java | 100 +++
.../market/client/CnQuotationClient.java | 145 ++++
.../market/client/CnQuotationModels.java | 48 ++
.../market/convert/PriceScaleConverter.java | 57 ++
.../indicator/KLineIndicatorCalculator.java | 222 ++++++
.../market/repository/KLineTableResolver.java | 39 +
.../market/sync/MarketDataSyncService.java | 197 ++++++
.../service/signal/ATRSignalStrategy.java | 54 ++
.../service/signal/BBISignalStrategy.java | 51 ++
.../service/signal/BOLLSignalStrategy.java | 52 ++
.../service/signal/CCISignalStrategy.java | 54 ++
.../service/signal/CJLSignalStrategy.java | 60 ++
.../service/signal/DMISignalStrategy.java | 53 ++
.../signal/DonchianSignalStrategy.java | 53 ++
.../service/signal/KDJSignalStrategy.java | 56 ++
.../service/signal/MACDSignalStrategy.java | 63 ++
.../service/signal/MASignalStrategy.java | 51 ++
.../service/signal/RSISignalStrategy.java | 54 ++
.../service/signal/SARSignalStrategy.java | 51 ++
.../service/signal/SignalStrategy.java | 25 +
.../service/signal/SignalStrategyFactory.java | 52 ++
.../signal/VolAmountSignalStrategy.java | 60 ++
.../service/signal/WRSignalStrategy.java | 57 ++
src/main/resources/application.yml | 73 ++
src/main/resources/db/init.sql | 205 ++++++
.../resources/行情接口文档-CnQuotation.md | 669 ++++++++++++++++++
.../AdminStrategyControllerTest.java | 145 ++++
.../controller/StrategyControllerTest.java | 228 ++++++
.../service/BacktestEngineImplTest.java | 235 ++++++
.../service/SignalStrategyTest.java | 216 ++++++
.../service/StrategyServiceImplTest.java | 250 +++++++
.../convert/PriceScaleConverterTest.java | 30 +
.../repository/KLineTableResolverTest.java | 29 +
src/test/resources/application.yml | 51 ++
src/test/resources/schema-test.sql | 197 ++++++
93 files changed, 7477 insertions(+)
create mode 100644 pom.xml
create mode 100644 src/main/java/com/yangwale/backtestify/BacktestifyApplication.java
create mode 100644 src/main/java/com/yangwale/backtestify/common/ApiResult.java
create mode 100644 src/main/java/com/yangwale/backtestify/common/BaseEntity.java
create mode 100644 src/main/java/com/yangwale/backtestify/common/BaseRequest.java
create mode 100644 src/main/java/com/yangwale/backtestify/common/BusinessException.java
create mode 100644 src/main/java/com/yangwale/backtestify/common/ErrorCode.java
create mode 100644 src/main/java/com/yangwale/backtestify/common/GlobalExceptionHandler.java
create mode 100644 src/main/java/com/yangwale/backtestify/common/JacksonConfig.java
create mode 100644 src/main/java/com/yangwale/backtestify/common/PageRequest.java
create mode 100644 src/main/java/com/yangwale/backtestify/common/PageResult.java
create mode 100644 src/main/java/com/yangwale/backtestify/common/RequestLoggingAspect.java
create mode 100644 src/main/java/com/yangwale/backtestify/config/MarketDataProperties.java
create mode 100644 src/main/java/com/yangwale/backtestify/config/MyBatisPlusConfig.java
create mode 100644 src/main/java/com/yangwale/backtestify/config/MyMetaObjectHandler.java
create mode 100644 src/main/java/com/yangwale/backtestify/config/RedisConfig.java
create mode 100644 src/main/java/com/yangwale/backtestify/config/SchedulingConfig.java
create mode 100644 src/main/java/com/yangwale/backtestify/config/WebMvcConfig.java
create mode 100644 src/main/java/com/yangwale/backtestify/controller/AdminStrategyController.java
create mode 100644 src/main/java/com/yangwale/backtestify/controller/StrategyController.java
create mode 100644 src/main/java/com/yangwale/backtestify/entity/InstrumentDictionary.java
create mode 100644 src/main/java/com/yangwale/backtestify/entity/KLineRecord.java
create mode 100644 src/main/java/com/yangwale/backtestify/entity/MarketDataSyncLog.java
create mode 100644 src/main/java/com/yangwale/backtestify/entity/StrategyConfig.java
create mode 100644 src/main/java/com/yangwale/backtestify/entity/StrategyResult.java
create mode 100644 src/main/java/com/yangwale/backtestify/entity/TradeDetail.java
create mode 100644 src/main/java/com/yangwale/backtestify/entity/UserSignal.java
create mode 100644 src/main/java/com/yangwale/backtestify/enums/Direction.java
create mode 100644 src/main/java/com/yangwale/backtestify/enums/KLinePeriod.java
create mode 100644 src/main/java/com/yangwale/backtestify/enums/StopUnit.java
create mode 100644 src/main/java/com/yangwale/backtestify/enums/StrategyStatus.java
create mode 100644 src/main/java/com/yangwale/backtestify/enums/TradeAction.java
create mode 100644 src/main/java/com/yangwale/backtestify/enums/VolumeUnit.java
create mode 100644 src/main/java/com/yangwale/backtestify/mapper/InstrumentDictionaryMapper.java
create mode 100644 src/main/java/com/yangwale/backtestify/mapper/KLineMapper.java
create mode 100644 src/main/java/com/yangwale/backtestify/mapper/MarketDataSyncLogMapper.java
create mode 100644 src/main/java/com/yangwale/backtestify/mapper/StrategyConfigMapper.java
create mode 100644 src/main/java/com/yangwale/backtestify/mapper/StrategyResultMapper.java
create mode 100644 src/main/java/com/yangwale/backtestify/mapper/TradeDetailMapper.java
create mode 100644 src/main/java/com/yangwale/backtestify/mapper/UserSignalMapper.java
create mode 100644 src/main/java/com/yangwale/backtestify/model/dto/IndicatorValues.java
create mode 100644 src/main/java/com/yangwale/backtestify/model/dto/KLineData.java
create mode 100644 src/main/java/com/yangwale/backtestify/model/dto/TradeRecordDto.java
create mode 100644 src/main/java/com/yangwale/backtestify/model/request/AdminStrategyPageRequest.java
create mode 100644 src/main/java/com/yangwale/backtestify/model/request/BacktestRequest.java
create mode 100644 src/main/java/com/yangwale/backtestify/model/request/SignalQueryRequest.java
create mode 100644 src/main/java/com/yangwale/backtestify/model/response/BacktestResponse.java
create mode 100644 src/main/java/com/yangwale/backtestify/model/response/SignalPointResponse.java
create mode 100644 src/main/java/com/yangwale/backtestify/model/response/StrategyDetailResponse.java
create mode 100644 src/main/java/com/yangwale/backtestify/model/response/StrategyListItemResponse.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/BacktestEngine.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/MarketDataService.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/StrategyService.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/impl/BacktestEngineImpl.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/impl/MarketDataServiceImpl.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/impl/StrategyServiceImpl.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/market/FakeMarketDataProvider.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/market/MarketDataProvider.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/market/MysqlMarketDataProvider.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/market/client/CnQuotationClient.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/market/client/CnQuotationModels.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/market/convert/PriceScaleConverter.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/market/indicator/KLineIndicatorCalculator.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/market/repository/KLineTableResolver.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/market/sync/MarketDataSyncService.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/signal/ATRSignalStrategy.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/signal/BBISignalStrategy.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/signal/BOLLSignalStrategy.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/signal/CCISignalStrategy.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/signal/CJLSignalStrategy.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/signal/DMISignalStrategy.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/signal/DonchianSignalStrategy.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/signal/KDJSignalStrategy.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/signal/MACDSignalStrategy.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/signal/MASignalStrategy.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/signal/RSISignalStrategy.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/signal/SARSignalStrategy.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/signal/SignalStrategy.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/signal/SignalStrategyFactory.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/signal/VolAmountSignalStrategy.java
create mode 100644 src/main/java/com/yangwale/backtestify/service/signal/WRSignalStrategy.java
create mode 100644 src/main/resources/application.yml
create mode 100644 src/main/resources/db/init.sql
create mode 100644 src/main/resources/行情接口文档-CnQuotation.md
create mode 100644 src/test/java/com/yangwale/backtestify/controller/AdminStrategyControllerTest.java
create mode 100644 src/test/java/com/yangwale/backtestify/controller/StrategyControllerTest.java
create mode 100644 src/test/java/com/yangwale/backtestify/service/BacktestEngineImplTest.java
create mode 100644 src/test/java/com/yangwale/backtestify/service/SignalStrategyTest.java
create mode 100644 src/test/java/com/yangwale/backtestify/service/StrategyServiceImplTest.java
create mode 100644 src/test/java/com/yangwale/backtestify/service/market/convert/PriceScaleConverterTest.java
create mode 100644 src/test/java/com/yangwale/backtestify/service/market/repository/KLineTableResolverTest.java
create mode 100644 src/test/resources/application.yml
create mode 100644 src/test/resources/schema-test.sql
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..2d57fb9
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,186 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.3.0
+
+
+ com.yangwale
+ backtestify
+ 1.0.0-SNAPSHOT
+ jar
+ backtestify
+ 期货策略回测系统
+
+
+ 21
+ UTF-8
+ 3.5.12
+ 8.0.28
+ 1.2.25
+ 2.0.54
+ 5.0.0-alpha.14
+ 2.6.0
+ 1.18.42
+ 2.13.1
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-aop
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
+
+
+
+ com.baomidou
+ mybatis-plus-spring-boot3-starter
+ ${mybatis-plus.version}
+
+
+ com.baomidou
+ mybatis-plus-jsqlparser
+ ${mybatis-plus.version}
+
+
+
+
+ mysql
+ mysql-connector-java
+ ${mysql.version}
+ runtime
+
+
+
+
+ com.alibaba
+ druid-spring-boot-3-starter
+ ${druid.version}
+
+
+
+
+ com.alibaba.fastjson2
+ fastjson2
+ ${fastjson2.version}
+
+
+
+
+ com.squareup.okhttp3
+ okhttp
+ ${okhttp.version}
+
+
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ ${springdoc.version}
+
+
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+ provided
+
+
+
+
+ org.apache.commons
+ commons-pool2
+ ${commons-pool2.version}
+
+
+
+
+ com.h2database
+ h2
+ test
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+ io.netty
+ netty-all
+
+
+
+ com.baidu
+ jprotobuf
+ 2.4.21
+
+
+ com.google.protobuf
+ protobuf-java
+
+
+
+
+
+ com.google.protobuf
+ protobuf-java
+ 3.21.7
+
+
+ com.baidu
+ jprotobuf-precompile-plugin
+ 2.2.8
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+ ${java.version}
+ ${java.version}
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+
+
+
diff --git a/src/main/java/com/yangwale/backtestify/BacktestifyApplication.java b/src/main/java/com/yangwale/backtestify/BacktestifyApplication.java
new file mode 100644
index 0000000..7901c93
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/BacktestifyApplication.java
@@ -0,0 +1,12 @@
+package com.yangwale.backtestify;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class BacktestifyApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(BacktestifyApplication.class, args);
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/common/ApiResult.java b/src/main/java/com/yangwale/backtestify/common/ApiResult.java
new file mode 100644
index 0000000..53e2b17
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/common/ApiResult.java
@@ -0,0 +1,53 @@
+package com.yangwale.backtestify.common;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+
+/**
+ * 统一响应体
+ */
+@Getter
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@Schema(description = "统一响应体")
+public class ApiResult {
+
+ @Schema(description = "业务状态码", example = "200")
+ private final int code;
+
+ @Schema(description = "响应消息", example = "操作成功")
+ private final String message;
+
+ @Schema(description = "响应数据")
+ private final T data;
+
+ @Schema(description = "响应时间戳", example = "1718000000000")
+ private final long timestamp;
+
+ private ApiResult(int code, String message, T data) {
+ this.code = code;
+ this.message = message;
+ this.data = data;
+ this.timestamp = System.currentTimeMillis();
+ }
+
+ public static ApiResult success(T data) {
+ return new ApiResult<>(ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), data);
+ }
+
+ public static ApiResult success() {
+ return new ApiResult<>(ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), null);
+ }
+
+ public static ApiResult error(ErrorCode errorCode) {
+ return new ApiResult<>(errorCode.getCode(), errorCode.getMessage(), null);
+ }
+
+ public static ApiResult error(ErrorCode errorCode, String message) {
+ return new ApiResult<>(errorCode.getCode(), message, null);
+ }
+
+ public static ApiResult error(int code, String message) {
+ return new ApiResult<>(code, message, null);
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/common/BaseEntity.java b/src/main/java/com/yangwale/backtestify/common/BaseEntity.java
new file mode 100644
index 0000000..3fad9eb
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/common/BaseEntity.java
@@ -0,0 +1,28 @@
+package com.yangwale.backtestify.common;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * 实体基类 - 提供 createTime / updateTime / isDeleted 通用字段
+ * 子类需在表中有对应列名
+ */
+@Getter
+@Setter
+public abstract class BaseEntity {
+
+ @TableField(fill = FieldFill.INSERT)
+ private LocalDateTime createTime;
+
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ private LocalDateTime updateTime;
+
+ @TableLogic
+ @TableField(fill = FieldFill.INSERT)
+ private Integer isDeleted;
+}
diff --git a/src/main/java/com/yangwale/backtestify/common/BaseRequest.java b/src/main/java/com/yangwale/backtestify/common/BaseRequest.java
new file mode 100644
index 0000000..c687f77
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/common/BaseRequest.java
@@ -0,0 +1,22 @@
+package com.yangwale.backtestify.common;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * 请求基类
+ * userId 由拦截器从请求头中提取并注入
+ */
+@Getter
+@Setter
+public abstract class BaseRequest {
+
+ /**
+ * 用户ID,由拦截器从请求头 X-User-Id 注入,前端无需传参
+ */
+ @JsonIgnore
+ @Schema(hidden = true)
+ private Long userId;
+}
diff --git a/src/main/java/com/yangwale/backtestify/common/BusinessException.java b/src/main/java/com/yangwale/backtestify/common/BusinessException.java
new file mode 100644
index 0000000..a61cd5e
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/common/BusinessException.java
@@ -0,0 +1,31 @@
+package com.yangwale.backtestify.common;
+
+import lombok.Getter;
+
+/**
+ * 业务异常,抛出时由 GlobalExceptionHandler 统一处理
+ */
+@Getter
+public class BusinessException extends RuntimeException {
+
+ private final int code;
+ private final String message;
+
+ public BusinessException(ErrorCode errorCode) {
+ super(errorCode.getMessage());
+ this.code = errorCode.getCode();
+ this.message = errorCode.getMessage();
+ }
+
+ public BusinessException(ErrorCode errorCode, String detail) {
+ super(detail);
+ this.code = errorCode.getCode();
+ this.message = detail;
+ }
+
+ public BusinessException(int code, String message) {
+ super(message);
+ this.code = code;
+ this.message = message;
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/common/ErrorCode.java b/src/main/java/com/yangwale/backtestify/common/ErrorCode.java
new file mode 100644
index 0000000..2303dca
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/common/ErrorCode.java
@@ -0,0 +1,38 @@
+package com.yangwale.backtestify.common;
+
+import lombok.Getter;
+
+/**
+ * 统一错误码枚举
+ */
+@Getter
+public enum ErrorCode {
+
+ SUCCESS(200, "操作成功"),
+ BAD_REQUEST(400, "请求参数错误"),
+ UNAUTHORIZED(401, "未授权"),
+ FORBIDDEN(403, "无权限"),
+ NOT_FOUND(404, "资源不存在"),
+ INTERNAL_ERROR(500, "服务器内部错误"),
+
+ // 业务异常 1xxx
+ STRATEGY_NOT_FOUND(1001, "策略不存在"),
+ STRATEGY_SAVE_FAILED(1002, "策略保存失败"),
+ BACKTEST_EXECUTION_FAILED(1003, "回测执行失败"),
+ INDICATOR_NOT_SUPPORTED(1004, "不支持的技术指标"),
+ INDICATOR_MAX_EXCEEDED(1005, "技术指标最多选择3个"),
+ CONTRACT_NOT_FOUND(1006, "合约不存在"),
+ MARKET_DATA_UNAVAILABLE(1007, "行情数据不可用"),
+ SIGNAL_CONFLICT(1008, "信号冲突"),
+ INVALID_DIRECTION(1009, "无效的交易方向"),
+ INVALID_KLINE_PERIOD(1010, "无效的K线周期"),
+ ;
+
+ private final int code;
+ private final String message;
+
+ ErrorCode(int code, String message) {
+ this.code = code;
+ this.message = message;
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/common/GlobalExceptionHandler.java b/src/main/java/com/yangwale/backtestify/common/GlobalExceptionHandler.java
new file mode 100644
index 0000000..31b08b5
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/common/GlobalExceptionHandler.java
@@ -0,0 +1,66 @@
+package com.yangwale.backtestify.common;
+
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.ConstraintViolationException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.util.stream.Collectors;
+
+/**
+ * 全局异常处理器
+ */
+@Slf4j
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ /**
+ * 业务异常
+ */
+ @ExceptionHandler(BusinessException.class)
+ public ApiResult handleBusinessException(BusinessException e) {
+ log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
+ return ApiResult.error(e.getCode(), e.getMessage());
+ }
+
+ /**
+ * 参数校验异常 (RequestBody @Valid)
+ */
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public ApiResult handleValidationException(MethodArgumentNotValidException e) {
+ String message = e.getBindingResult().getFieldErrors().stream()
+ .map(FieldError::getDefaultMessage)
+ .collect(Collectors.joining(", "));
+ log.warn("参数校验失败: {}", message);
+ return ApiResult.error(ErrorCode.BAD_REQUEST, message);
+ }
+
+ /**
+ * 参数校验异常 (PathVariable / RequestParam)
+ */
+ @ExceptionHandler(ConstraintViolationException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public ApiResult handleConstraintViolationException(ConstraintViolationException e) {
+ String message = e.getConstraintViolations().stream()
+ .map(ConstraintViolation::getMessage)
+ .collect(Collectors.joining(", "));
+ log.warn("参数校验失败: {}", message);
+ return ApiResult.error(ErrorCode.BAD_REQUEST, message);
+ }
+
+ /**
+ * 未知异常
+ */
+ @ExceptionHandler(Exception.class)
+ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+ public ApiResult handleException(Exception e) {
+ log.error("系统异常", e);
+ return ApiResult.error(ErrorCode.INTERNAL_ERROR);
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/common/JacksonConfig.java b/src/main/java/com/yangwale/backtestify/common/JacksonConfig.java
new file mode 100644
index 0000000..ea85219
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/common/JacksonConfig.java
@@ -0,0 +1,53 @@
+package com.yangwale.backtestify.common;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
+import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
+import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
+import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.text.SimpleDateFormat;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.TimeZone;
+
+/**
+ * Jackson JSON 序列化配置
+ * 统一日期格式: yyyy-MM-dd HH:mm:ss
+ */
+@Configuration
+public class JacksonConfig {
+
+ public static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
+ public static final String DATE_PATTERN = "yyyy-MM-dd";
+
+ @Bean
+ public ObjectMapper objectMapper() {
+ ObjectMapper mapper = new ObjectMapper();
+
+ // 日期格式
+ mapper.setDateFormat(new SimpleDateFormat(DATE_TIME_PATTERN));
+ mapper.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
+
+ // Java 8 时间模块
+ JavaTimeModule javaTimeModule = new JavaTimeModule();
+ javaTimeModule.addSerializer(LocalDateTime.class,
+ new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DATE_TIME_PATTERN)));
+ javaTimeModule.addDeserializer(LocalDateTime.class,
+ new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DATE_TIME_PATTERN)));
+ javaTimeModule.addSerializer(LocalDate.class,
+ new LocalDateSerializer(DateTimeFormatter.ofPattern(DATE_PATTERN)));
+ javaTimeModule.addDeserializer(LocalDate.class,
+ new LocalDateDeserializer(DateTimeFormatter.ofPattern(DATE_PATTERN)));
+
+ mapper.registerModule(javaTimeModule);
+ mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+
+ return mapper;
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/common/PageRequest.java b/src/main/java/com/yangwale/backtestify/common/PageRequest.java
new file mode 100644
index 0000000..00f3913
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/common/PageRequest.java
@@ -0,0 +1,24 @@
+package com.yangwale.backtestify.common;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * 分页请求基类
+ */
+@Getter
+@Setter
+public abstract class PageRequest extends BaseRequest {
+
+ @Schema(description = "页码,从1开始", example = "1")
+ @Min(value = 1, message = "页码最小为1")
+ private Integer pageNum = 1;
+
+ @Schema(description = "每页条数,最大100", example = "10")
+ @Min(value = 1, message = "每页最少1条")
+ @Max(value = 100, message = "每页最多100条")
+ private Integer pageSize = 10;
+}
diff --git a/src/main/java/com/yangwale/backtestify/common/PageResult.java b/src/main/java/com/yangwale/backtestify/common/PageResult.java
new file mode 100644
index 0000000..551676c
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/common/PageResult.java
@@ -0,0 +1,47 @@
+package com.yangwale.backtestify.common;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 分页响应
+ */
+@Getter
+@Schema(description = "分页响应")
+public class PageResult {
+
+ @Schema(description = "总记录数", example = "100")
+ private final long total;
+
+ @Schema(description = "总页数", example = "10")
+ private final long pages;
+
+ @Schema(description = "当前页码", example = "1")
+ private final int pageNum;
+
+ @Schema(description = "每页条数", example = "10")
+ private final int pageSize;
+
+ @Schema(description = "当前页数据")
+ private final List list;
+
+ private PageResult(long total, long pages, int pageNum, int pageSize, List list) {
+ this.total = total;
+ this.pages = pages;
+ this.pageNum = pageNum;
+ this.pageSize = pageSize;
+ this.list = list;
+ }
+
+ public static PageResult of(long total, int pageNum, int pageSize, List list) {
+ long pages = (total + pageSize - 1) / pageSize;
+ return new PageResult<>(total, pages, pageNum, pageSize, list != null ? list : Collections.emptyList());
+ }
+
+ public static PageResult empty(int pageNum, int pageSize) {
+ return new PageResult<>(0, 0, pageNum, pageSize, Collections.emptyList());
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/common/RequestLoggingAspect.java b/src/main/java/com/yangwale/backtestify/common/RequestLoggingAspect.java
new file mode 100644
index 0000000..65b87df
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/common/RequestLoggingAspect.java
@@ -0,0 +1,71 @@
+package com.yangwale.backtestify.common;
+
+import com.alibaba.fastjson2.JSON;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.springframework.stereotype.Component;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+/**
+ * 请求日志AOP切面
+ * 记录每个Controller方法的入参、出参、耗时
+ */
+@Slf4j
+@Aspect
+@Component
+public class RequestLoggingAspect {
+
+ @Pointcut("execution(* com.yangwale.backtestify.controller..*(..))")
+ public void controllerPointcut() {
+ }
+
+ @Around("controllerPointcut()")
+ public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
+ ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+ String method = joinPoint.getSignature().toShortString();
+ String requestUri = "unknown";
+ String httpMethod = "unknown";
+
+ if (attributes != null) {
+ HttpServletRequest request = attributes.getRequest();
+ requestUri = request.getRequestURI();
+ httpMethod = request.getMethod();
+ }
+
+ // 入参
+ Object[] args = joinPoint.getArgs();
+ String argsJson = "[]";
+ if (args != null && args.length > 0) {
+ try {
+ argsJson = JSON.toJSONString(args);
+ } catch (Exception e) {
+ argsJson = "[serialization error]";
+ }
+ }
+
+ log.info("→ [{}] {} | method={} | args={}", httpMethod, requestUri, method, argsJson);
+
+ long start = System.currentTimeMillis();
+ Object result = joinPoint.proceed();
+ long elapsed = System.currentTimeMillis() - start;
+
+ String resultJson = "void";
+ if (result != null) {
+ try {
+ String json = JSON.toJSONString(result);
+ resultJson = json.length() > 500 ? json.substring(0, 500) + "..." : json;
+ } catch (Exception e) {
+ resultJson = "[serialization error]";
+ }
+ }
+
+ log.info("← [{}] {} | {}ms | result={}", httpMethod, requestUri, elapsed, resultJson);
+
+ return result;
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/config/MarketDataProperties.java b/src/main/java/com/yangwale/backtestify/config/MarketDataProperties.java
new file mode 100644
index 0000000..dcf66e6
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/config/MarketDataProperties.java
@@ -0,0 +1,59 @@
+package com.yangwale.backtestify.config;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.time.ZoneId;
+import java.util.List;
+
+/**
+ * 真实行情数据源配置
+ */
+@Getter
+@Setter
+@Component
+@ConfigurationProperties(prefix = "market-data")
+public class MarketDataProperties {
+
+ /** 行情数据源类型:fake/mysql */
+ private String provider = "fake";
+
+ private Quotation quotation = new Quotation();
+
+ private Sync sync = new Sync();
+
+ @Getter
+ @Setter
+ public static class Quotation {
+ /** 行情接口基础地址 */
+ private String baseUrl = "https://slzqapi.sxslqhsh.com/mobile-api/cn/quotation";
+
+ /** HTTP超时时间,单位秒 */
+ private int timeoutSeconds = 15;
+ }
+
+ @Getter
+ @Setter
+ public static class Sync {
+ /** 是否启用定时同步 */
+ private boolean enabled = false;
+
+ /** 每日增量同步Cron */
+ private String cron = "0 0 6 * * ?";
+
+ /** 同步时区 */
+ private String zone = "Asia/Shanghai";
+
+ /** 每日增量同步窗口结束小时 */
+ private int incrementalWindowEndHour = 6;
+
+ /** 默认同步周期,真实行情不包含3m */
+ private List periods = List.of("1m", "5m", "15m", "30m", "1h", "4h", "1d", "1w");
+
+ public ZoneId zoneId() {
+ return ZoneId.of(zone);
+ }
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/config/MyBatisPlusConfig.java b/src/main/java/com/yangwale/backtestify/config/MyBatisPlusConfig.java
new file mode 100644
index 0000000..bac057e
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/config/MyBatisPlusConfig.java
@@ -0,0 +1,24 @@
+package com.yangwale.backtestify.config;
+
+import com.baomidou.mybatisplus.annotation.DbType;
+import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
+import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * MyBatis-Plus 配置
+ */
+@Configuration
+public class MyBatisPlusConfig {
+
+ /**
+ * 分页插件
+ */
+ @Bean
+ public MybatisPlusInterceptor mybatisPlusInterceptor() {
+ MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
+ interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
+ return interceptor;
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/config/MyMetaObjectHandler.java b/src/main/java/com/yangwale/backtestify/config/MyMetaObjectHandler.java
new file mode 100644
index 0000000..682d786
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/config/MyMetaObjectHandler.java
@@ -0,0 +1,26 @@
+package com.yangwale.backtestify.config;
+
+import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
+import org.apache.ibatis.reflection.MetaObject;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+
+/**
+ * MyBatis-Plus 字段自动填充处理器
+ */
+@Component
+public class MyMetaObjectHandler implements MetaObjectHandler {
+
+ @Override
+ public void insertFill(MetaObject metaObject) {
+ this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
+ this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
+ this.strictInsertFill(metaObject, "isDeleted", Integer.class, 0);
+ }
+
+ @Override
+ public void updateFill(MetaObject metaObject) {
+ this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/config/RedisConfig.java b/src/main/java/com/yangwale/backtestify/config/RedisConfig.java
new file mode 100644
index 0000000..50d0499
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/config/RedisConfig.java
@@ -0,0 +1,35 @@
+package com.yangwale.backtestify.config;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+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 配置
+ * 仅在 RedisConnectionFactory 可用时才创建 RedisTemplate
+ */
+@Configuration
+public class RedisConfig {
+
+ @Bean
+ @ConditionalOnBean(RedisConnectionFactory.class)
+ public RedisTemplate redisTemplate(RedisConnectionFactory factory) {
+ RedisTemplate template = new RedisTemplate<>();
+ template.setConnectionFactory(factory);
+
+ StringRedisSerializer stringSerializer = new StringRedisSerializer();
+ GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
+
+ template.setKeySerializer(stringSerializer);
+ template.setHashKeySerializer(stringSerializer);
+ template.setValueSerializer(jsonSerializer);
+ template.setHashValueSerializer(jsonSerializer);
+
+ template.afterPropertiesSet();
+ return template;
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/config/SchedulingConfig.java b/src/main/java/com/yangwale/backtestify/config/SchedulingConfig.java
new file mode 100644
index 0000000..3f7f556
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/config/SchedulingConfig.java
@@ -0,0 +1,12 @@
+package com.yangwale.backtestify.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+/**
+ * 定时任务配置
+ */
+@Configuration
+@EnableScheduling
+public class SchedulingConfig {
+}
diff --git a/src/main/java/com/yangwale/backtestify/config/WebMvcConfig.java b/src/main/java/com/yangwale/backtestify/config/WebMvcConfig.java
new file mode 100644
index 0000000..6e1992b
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/config/WebMvcConfig.java
@@ -0,0 +1,62 @@
+package com.yangwale.backtestify.config;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.HandlerInterceptor;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * Web MVC 配置
+ * - 拦截器提取请求头 user_id
+ */
+@Configuration
+public class WebMvcConfig implements WebMvcConfigurer {
+
+ public static final String HEADER_USER_ID = "user_id";
+
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ registry.addInterceptor(new UserIdInterceptor()).addPathPatterns("/api/**");
+ }
+
+ /**
+ * 拦截器:将请求头中的 user_id 存入 RequestContext
+ */
+ static class UserIdInterceptor implements HandlerInterceptor {
+ @Override
+ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+ String userId = request.getHeader(HEADER_USER_ID);
+ if (userId != null && !userId.isBlank()) {
+ RequestContextHolder.setUserId(Long.parseLong(userId));
+ }
+ return true;
+ }
+
+ @Override
+ public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
+ Object handler, Exception ex) {
+ RequestContextHolder.clear();
+ }
+ }
+
+ /**
+ * 请求上下文持有者(线程安全)
+ */
+ public static class RequestContextHolder {
+ private static final ThreadLocal USER_ID_HOLDER = new ThreadLocal<>();
+
+ public static void setUserId(Long userId) {
+ USER_ID_HOLDER.set(userId);
+ }
+
+ public static Long getUserId() {
+ return USER_ID_HOLDER.get();
+ }
+
+ public static void clear() {
+ USER_ID_HOLDER.remove();
+ }
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/controller/AdminStrategyController.java b/src/main/java/com/yangwale/backtestify/controller/AdminStrategyController.java
new file mode 100644
index 0000000..7b697a7
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/controller/AdminStrategyController.java
@@ -0,0 +1,39 @@
+package com.yangwale.backtestify.controller;
+
+import com.yangwale.backtestify.common.ApiResult;
+import com.yangwale.backtestify.common.PageResult;
+import com.yangwale.backtestify.model.request.AdminStrategyPageRequest;
+import com.yangwale.backtestify.model.response.StrategyDetailResponse;
+import com.yangwale.backtestify.model.response.StrategyListItemResponse;
+import com.yangwale.backtestify.service.StrategyService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * B端管理接口
+ */
+@Tag(name = "B端-策略管理")
+@RestController
+@RequestMapping("/api/v1/admin/strategy")
+@RequiredArgsConstructor
+public class AdminStrategyController {
+
+ private final StrategyService strategyService;
+
+ @Operation(summary = "分页查询全部策略")
+ @PostMapping("/list")
+ public ApiResult> list(@Valid @RequestBody AdminStrategyPageRequest request) {
+ PageResult result = strategyService.adminList(request);
+ return ApiResult.success(result);
+ }
+
+ @Operation(summary = "策略详情")
+ @GetMapping("/detail/{id}")
+ public ApiResult detail(@PathVariable Long id) {
+ StrategyDetailResponse result = strategyService.detail(id);
+ return ApiResult.success(result);
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/controller/StrategyController.java b/src/main/java/com/yangwale/backtestify/controller/StrategyController.java
new file mode 100644
index 0000000..1bd89d8
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/controller/StrategyController.java
@@ -0,0 +1,97 @@
+package com.yangwale.backtestify.controller;
+
+import com.yangwale.backtestify.common.ApiResult;
+import com.yangwale.backtestify.common.PageResult;
+import com.yangwale.backtestify.config.WebMvcConfig;
+import com.yangwale.backtestify.model.request.BacktestRequest;
+import com.yangwale.backtestify.model.request.SignalQueryRequest;
+import com.yangwale.backtestify.model.response.BacktestResponse;
+import com.yangwale.backtestify.model.response.SignalPointResponse;
+import com.yangwale.backtestify.model.response.StrategyDetailResponse;
+import com.yangwale.backtestify.model.response.StrategyListItemResponse;
+import com.yangwale.backtestify.service.StrategyService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Min;
+import lombok.RequiredArgsConstructor;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * C端策略接口
+ */
+@Tag(name = "C端-策略管理")
+@Validated
+@RestController
+@RequestMapping("/api/v1/strategy")
+@RequiredArgsConstructor
+public class StrategyController {
+
+ private final StrategyService strategyService;
+
+ @Operation(summary = "执行回测")
+ @PostMapping("/backtest")
+ public ApiResult backtest(@Valid @RequestBody BacktestRequest request) {
+ injectUserId(request);
+ BacktestResponse response = strategyService.backtest(request);
+ return ApiResult.success(response);
+ }
+
+ @Operation(summary = "我的策略列表")
+ @GetMapping("/my-list")
+ public ApiResult> myList(
+ @RequestParam(defaultValue = "1") @Min(1) int pageNum,
+ @RequestParam(defaultValue = "10") @Min(1) int pageSize) {
+ Long userId = WebMvcConfig.RequestContextHolder.getUserId();
+ PageResult result = strategyService.myList(userId, pageNum, pageSize);
+ return ApiResult.success(result);
+ }
+
+ @Operation(summary = "策略详情")
+ @GetMapping("/detail/{id}")
+ public ApiResult detail(@PathVariable Long id) {
+ StrategyDetailResponse result = strategyService.detail(id);
+ return ApiResult.success(result);
+ }
+
+ @Operation(summary = "删除策略")
+ @DeleteMapping("/{id}")
+ public ApiResult delete(@PathVariable Long id) {
+ Long userId = WebMvcConfig.RequestContextHolder.getUserId();
+ strategyService.delete(id, userId);
+ return ApiResult.success();
+ }
+
+ @Operation(summary = "切换信号启用/停用")
+ @PutMapping("/{id}/signal-toggle")
+ public ApiResult toggleSignal(@PathVariable Long id) {
+ Long userId = WebMvcConfig.RequestContextHolder.getUserId();
+ strategyService.toggleSignal(id, userId);
+ return ApiResult.success();
+ }
+
+ @Operation(summary = "查询K线图信号标记点")
+ @GetMapping("/signals")
+ public ApiResult> getSignals(@Valid SignalQueryRequest request) {
+ injectUserId(request);
+ List result = strategyService.getSignals(request);
+ return ApiResult.success(result);
+ }
+
+ @Operation(summary = "获取可用指标列表")
+ @GetMapping("/indicators")
+ public ApiResult> getIndicators() {
+ List result = strategyService.getAvailableIndicators();
+ return ApiResult.success(result);
+ }
+
+ private void injectUserId(com.yangwale.backtestify.common.BaseRequest request) {
+ Long userId = WebMvcConfig.RequestContextHolder.getUserId();
+ if (userId != null) {
+ request.setUserId(userId);
+ }
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/entity/InstrumentDictionary.java b/src/main/java/com/yangwale/backtestify/entity/InstrumentDictionary.java
new file mode 100644
index 0000000..cd87abe
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/entity/InstrumentDictionary.java
@@ -0,0 +1,35 @@
+package com.yangwale.backtestify.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.yangwale.backtestify.common.BaseEntity;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * 合约字典表
+ */
+@Getter
+@Setter
+@TableName("t_instrument_dictionary")
+public class InstrumentDictionary extends BaseEntity {
+
+ @TableId(type = IdType.AUTO)
+ private Integer id;
+
+ /** 交易所代码,如 SHFE */
+ private String exchangeId;
+
+ /** 期货品种,如 rb */
+ private String symbol;
+
+ /** 具体合约代码,如 rb2610 */
+ private String contractCode;
+
+ /** 价格放大倍数 */
+ private Integer priceScale;
+
+ /** 是否当前主力合约:0-否,1-是 */
+ private Integer isMain;
+}
diff --git a/src/main/java/com/yangwale/backtestify/entity/KLineRecord.java b/src/main/java/com/yangwale/backtestify/entity/KLineRecord.java
new file mode 100644
index 0000000..52a81e3
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/entity/KLineRecord.java
@@ -0,0 +1,33 @@
+package com.yangwale.backtestify.entity;
+
+import lombok.*;
+
+/**
+ * K线记录,映射多张同结构K线表
+ */
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class KLineRecord {
+
+ private Integer instrumentId;
+
+ /** Unix时间戳,秒级 */
+ private Long timestamp;
+
+ private Integer open;
+
+ private Integer high;
+
+ private Integer low;
+
+ private Integer close;
+
+ private Long volume;
+
+ private Long turnover;
+
+ private Long openInterest;
+}
diff --git a/src/main/java/com/yangwale/backtestify/entity/MarketDataSyncLog.java b/src/main/java/com/yangwale/backtestify/entity/MarketDataSyncLog.java
new file mode 100644
index 0000000..64439dd
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/entity/MarketDataSyncLog.java
@@ -0,0 +1,42 @@
+package com.yangwale.backtestify.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 行情同步日志
+ */
+@Getter
+@Setter
+@TableName("t_market_data_sync_log")
+public class MarketDataSyncLog {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private String syncType;
+
+ private String period;
+
+ private String contractCode;
+
+ private LocalDate syncDate;
+
+ private String status;
+
+ private Integer successCount;
+
+ private String errorMessage;
+
+ private LocalDateTime startTime;
+
+ private LocalDateTime endTime;
+
+ private LocalDateTime createTime;
+}
diff --git a/src/main/java/com/yangwale/backtestify/entity/StrategyConfig.java b/src/main/java/com/yangwale/backtestify/entity/StrategyConfig.java
new file mode 100644
index 0000000..e82f6c2
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/entity/StrategyConfig.java
@@ -0,0 +1,52 @@
+package com.yangwale.backtestify.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.yangwale.backtestify.common.BaseEntity;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+
+/**
+ * 策略配置表
+ */
+@Getter
+@Setter
+@TableName("bt_strategy_config")
+public class StrategyConfig extends BaseEntity {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private Long userId;
+
+ private String contractCode;
+
+ private String contractName;
+
+ private String direction;
+
+ private String klinePeriod;
+
+ /** JSON数组字符串,如 ["MACD","KDJ"] */
+ private String indicators;
+
+ private Integer openVolume;
+
+ private String volumeUnit;
+
+ private BigDecimal stopLossValue;
+
+ private String stopLossUnit;
+
+ private BigDecimal takeProfitValue;
+
+ private String takeProfitUnit;
+
+ private String backtestPeriod;
+
+ /** 0-已保存, 1-已启用信号 */
+ private Integer status;
+}
diff --git a/src/main/java/com/yangwale/backtestify/entity/StrategyResult.java b/src/main/java/com/yangwale/backtestify/entity/StrategyResult.java
new file mode 100644
index 0000000..c58fc92
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/entity/StrategyResult.java
@@ -0,0 +1,64 @@
+package com.yangwale.backtestify.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 回测结果表 — 一次性快照,无 updateTime / isDeleted
+ */
+@Getter
+@Setter
+@TableName("bt_strategy_result")
+public class StrategyResult {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private Long strategyId;
+
+ private BigDecimal initialCapital;
+
+ private BigDecimal finalCapital;
+
+ /** 回测期间最高净值 */
+ private BigDecimal maxEquity;
+
+ /** 回测期间最低净值 */
+ private BigDecimal minEquity;
+
+ /** 总收益率(%) */
+ private BigDecimal totalYield;
+
+ /** 收益金额 */
+ private BigDecimal profitAmount;
+
+ /** 年化收益率(%) */
+ private BigDecimal annualizedYield;
+
+ private Integer tradeCount;
+
+ /** 最大回撤(%) */
+ private BigDecimal maxDrawdown;
+
+ /** 夏普比率 */
+ private BigDecimal sharpeRatio;
+
+ /** 胜率(%) */
+ private BigDecimal winRate;
+
+ private LocalDate startDate;
+
+ private LocalDate endDate;
+
+ /** 每日净值曲线 JSON: [{"date":"2024-01-02","equity":1005000.00,"yield":0.50}] */
+ private String dailyEquityCurve;
+
+ private LocalDateTime createTime;
+}
diff --git a/src/main/java/com/yangwale/backtestify/entity/TradeDetail.java b/src/main/java/com/yangwale/backtestify/entity/TradeDetail.java
new file mode 100644
index 0000000..891a3b9
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/entity/TradeDetail.java
@@ -0,0 +1,42 @@
+package com.yangwale.backtestify.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 交易明细表 — 一次性快照,无 updateTime / isDeleted
+ */
+@Getter
+@Setter
+@TableName("bt_trade_detail")
+public class TradeDetail {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private Long strategyId;
+
+ /** BUY_OPEN / SELL_CLOSE / SELL_OPEN / BUY_CLOSE */
+ private String action;
+
+ private BigDecimal price;
+
+ private Integer volume;
+
+ private BigDecimal turnover;
+
+ private LocalDateTime tradeTime;
+
+ private LocalDateTime klineTime;
+
+ /** B / S */
+ private String signalType;
+
+ private LocalDateTime createTime;
+}
diff --git a/src/main/java/com/yangwale/backtestify/entity/UserSignal.java b/src/main/java/com/yangwale/backtestify/entity/UserSignal.java
new file mode 100644
index 0000000..56ae418
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/entity/UserSignal.java
@@ -0,0 +1,31 @@
+package com.yangwale.backtestify.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.yangwale.backtestify.common.BaseEntity;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * 用户信号标记表
+ */
+@Getter
+@Setter
+@TableName("bt_user_signal")
+public class UserSignal extends BaseEntity {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private Long userId;
+
+ private Long strategyId;
+
+ private String contractCode;
+
+ private String klinePeriod;
+
+ /** 0-停用, 1-启用 */
+ private Integer isActive;
+}
diff --git a/src/main/java/com/yangwale/backtestify/enums/Direction.java b/src/main/java/com/yangwale/backtestify/enums/Direction.java
new file mode 100644
index 0000000..4341827
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/enums/Direction.java
@@ -0,0 +1,27 @@
+package com.yangwale.backtestify.enums;
+
+import lombok.Getter;
+
+/**
+ * 交易方向
+ */
+@Getter
+public enum Direction {
+ LONG("看多"),
+ SHORT("看空");
+
+ private final String label;
+
+ Direction(String label) {
+ this.label = label;
+ }
+
+ public static Direction of(String value) {
+ for (Direction d : values()) {
+ if (d.name().equalsIgnoreCase(value)) {
+ return d;
+ }
+ }
+ throw new IllegalArgumentException("无效的交易方向: " + value + ",有效值: LONG, SHORT");
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/enums/KLinePeriod.java b/src/main/java/com/yangwale/backtestify/enums/KLinePeriod.java
new file mode 100644
index 0000000..7f975c8
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/enums/KLinePeriod.java
@@ -0,0 +1,36 @@
+package com.yangwale.backtestify.enums;
+
+import lombok.Getter;
+
+/**
+ * K线周期
+ */
+@Getter
+public enum KLinePeriod {
+ M1("1m", "1分钟"),
+ M3("3m", "3分钟"),
+ M5("5m", "5分钟"),
+ M15("15m", "15分钟"),
+ M30("30m", "30分钟"),
+ H1("1h", "1小时"),
+ H4("4h", "4小时"),
+ D1("1d", "日K"),
+ W1("1w", "周K");
+
+ private final String code;
+ private final String label;
+
+ KLinePeriod(String code, String label) {
+ this.code = code;
+ this.label = label;
+ }
+
+ public static KLinePeriod of(String code) {
+ for (KLinePeriod p : values()) {
+ if (p.code.equalsIgnoreCase(code)) {
+ return p;
+ }
+ }
+ throw new IllegalArgumentException("无效的K线周期: " + code);
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/enums/StopUnit.java b/src/main/java/com/yangwale/backtestify/enums/StopUnit.java
new file mode 100644
index 0000000..03d3ef2
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/enums/StopUnit.java
@@ -0,0 +1,27 @@
+package com.yangwale.backtestify.enums;
+
+import lombok.Getter;
+
+/**
+ * 止盈止损单位
+ */
+@Getter
+public enum StopUnit {
+ TICK("价位"),
+ PERCENT("涨跌幅");
+
+ private final String label;
+
+ StopUnit(String label) {
+ this.label = label;
+ }
+
+ public static StopUnit of(String value) {
+ for (StopUnit u : values()) {
+ if (u.name().equalsIgnoreCase(value)) {
+ return u;
+ }
+ }
+ throw new IllegalArgumentException("无效的单位: " + value + ",有效值: TICK, PERCENT");
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/enums/StrategyStatus.java b/src/main/java/com/yangwale/backtestify/enums/StrategyStatus.java
new file mode 100644
index 0000000..2d913c0
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/enums/StrategyStatus.java
@@ -0,0 +1,29 @@
+package com.yangwale.backtestify.enums;
+
+import lombok.Getter;
+
+/**
+ * 策略状态
+ */
+@Getter
+public enum StrategyStatus {
+ SAVED(0, "已保存"),
+ SIGNAL_ACTIVE(1, "已启用信号");
+
+ private final int code;
+ private final String label;
+
+ StrategyStatus(int code, String label) {
+ this.code = code;
+ this.label = label;
+ }
+
+ public static StrategyStatus of(int code) {
+ for (StrategyStatus s : values()) {
+ if (s.code == code) {
+ return s;
+ }
+ }
+ return SAVED;
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/enums/TradeAction.java b/src/main/java/com/yangwale/backtestify/enums/TradeAction.java
new file mode 100644
index 0000000..cbcf20d
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/enums/TradeAction.java
@@ -0,0 +1,20 @@
+package com.yangwale.backtestify.enums;
+
+import lombok.Getter;
+
+/**
+ * 交易操作类型
+ */
+@Getter
+public enum TradeAction {
+ BUY_OPEN("买入开仓"),
+ SELL_CLOSE("卖出平仓"),
+ SELL_OPEN("卖出开仓"),
+ BUY_CLOSE("买入平仓");
+
+ private final String label;
+
+ TradeAction(String label) {
+ this.label = label;
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/enums/VolumeUnit.java b/src/main/java/com/yangwale/backtestify/enums/VolumeUnit.java
new file mode 100644
index 0000000..8d8435d
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/enums/VolumeUnit.java
@@ -0,0 +1,27 @@
+package com.yangwale.backtestify.enums;
+
+import lombok.Getter;
+
+/**
+ * 数量单位
+ */
+@Getter
+public enum VolumeUnit {
+ LOT("手数"),
+ POSITION("仓位");
+
+ private final String label;
+
+ VolumeUnit(String label) {
+ this.label = label;
+ }
+
+ public static VolumeUnit of(String value) {
+ for (VolumeUnit u : values()) {
+ if (u.name().equalsIgnoreCase(value)) {
+ return u;
+ }
+ }
+ throw new IllegalArgumentException("无效的数量单位: " + value + ",有效值: LOT, POSITION");
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/mapper/InstrumentDictionaryMapper.java b/src/main/java/com/yangwale/backtestify/mapper/InstrumentDictionaryMapper.java
new file mode 100644
index 0000000..a80b76d
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/mapper/InstrumentDictionaryMapper.java
@@ -0,0 +1,9 @@
+package com.yangwale.backtestify.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.yangwale.backtestify.entity.InstrumentDictionary;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface InstrumentDictionaryMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/yangwale/backtestify/mapper/KLineMapper.java b/src/main/java/com/yangwale/backtestify/mapper/KLineMapper.java
new file mode 100644
index 0000000..71fd18f
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/mapper/KLineMapper.java
@@ -0,0 +1,48 @@
+package com.yangwale.backtestify.mapper;
+
+import com.yangwale.backtestify.entity.KLineRecord;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+@Mapper
+public interface KLineMapper {
+
+ @Select("""
+ SELECT instrument_id, timestamp, open, high, low, close,
+ volume, turnover, open_interest
+ FROM ${tableName}
+ WHERE instrument_id = #{instrumentId}
+ AND timestamp BETWEEN #{startTimestamp} AND #{endTimestamp}
+ ORDER BY timestamp ASC
+ """)
+ List selectRange(@Param("tableName") String tableName,
+ @Param("instrumentId") Integer instrumentId,
+ @Param("startTimestamp") long startTimestamp,
+ @Param("endTimestamp") long endTimestamp);
+
+ @Insert("""
+
+ """)
+ int upsertBatch(@Param("tableName") String tableName,
+ @Param("records") List records);
+}
diff --git a/src/main/java/com/yangwale/backtestify/mapper/MarketDataSyncLogMapper.java b/src/main/java/com/yangwale/backtestify/mapper/MarketDataSyncLogMapper.java
new file mode 100644
index 0000000..c8ae392
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/mapper/MarketDataSyncLogMapper.java
@@ -0,0 +1,9 @@
+package com.yangwale.backtestify.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.yangwale.backtestify.entity.MarketDataSyncLog;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface MarketDataSyncLogMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/yangwale/backtestify/mapper/StrategyConfigMapper.java b/src/main/java/com/yangwale/backtestify/mapper/StrategyConfigMapper.java
new file mode 100644
index 0000000..4534719
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/mapper/StrategyConfigMapper.java
@@ -0,0 +1,9 @@
+package com.yangwale.backtestify.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.yangwale.backtestify.entity.StrategyConfig;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface StrategyConfigMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/yangwale/backtestify/mapper/StrategyResultMapper.java b/src/main/java/com/yangwale/backtestify/mapper/StrategyResultMapper.java
new file mode 100644
index 0000000..257f0ee
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/mapper/StrategyResultMapper.java
@@ -0,0 +1,9 @@
+package com.yangwale.backtestify.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.yangwale.backtestify.entity.StrategyResult;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface StrategyResultMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/yangwale/backtestify/mapper/TradeDetailMapper.java b/src/main/java/com/yangwale/backtestify/mapper/TradeDetailMapper.java
new file mode 100644
index 0000000..cbd41d2
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/mapper/TradeDetailMapper.java
@@ -0,0 +1,9 @@
+package com.yangwale.backtestify.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.yangwale.backtestify.entity.TradeDetail;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface TradeDetailMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/yangwale/backtestify/mapper/UserSignalMapper.java b/src/main/java/com/yangwale/backtestify/mapper/UserSignalMapper.java
new file mode 100644
index 0000000..e2f547d
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/mapper/UserSignalMapper.java
@@ -0,0 +1,9 @@
+package com.yangwale.backtestify.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.yangwale.backtestify.entity.UserSignal;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface UserSignalMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/yangwale/backtestify/model/dto/IndicatorValues.java b/src/main/java/com/yangwale/backtestify/model/dto/IndicatorValues.java
new file mode 100644
index 0000000..6f89dfb
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/model/dto/IndicatorValues.java
@@ -0,0 +1,72 @@
+package com.yangwale.backtestify.model.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.math.BigDecimal;
+
+/**
+ * 单根K线对应的所有技术指标值(由外部数据源提供)
+ */
+@Getter
+@Builder
+@Schema(description = "单根K线对应的技术指标值")
+public class IndicatorValues {
+
+ // MACD
+ private BigDecimal macdDif;
+ private BigDecimal macdDea;
+ private BigDecimal macdBar;
+
+ // KDJ
+ private BigDecimal kdjK;
+ private BigDecimal kdjD;
+ private BigDecimal kdjJ;
+
+ // MA
+ private BigDecimal ma5;
+ private BigDecimal ma10;
+ private BigDecimal ma20;
+ private BigDecimal ma60;
+
+ // BOLL
+ private BigDecimal bollUpper;
+ private BigDecimal bollMid;
+ private BigDecimal bollLower;
+
+ // RSI
+ private BigDecimal rsi6;
+ private BigDecimal rsi14;
+ private BigDecimal rsi24;
+
+ // CCI
+ private BigDecimal cci;
+
+ // BBI
+ private BigDecimal bbi;
+
+ // SAR
+ private BigDecimal sar;
+
+ // DMI
+ private BigDecimal dmiPdi;
+ private BigDecimal dmiMdi;
+ private BigDecimal dmiAdx;
+
+ // WR
+ private BigDecimal wr;
+
+ // ATR
+ private BigDecimal atr;
+
+ // Donchian
+ private BigDecimal donchianUpper;
+ private BigDecimal donchianLower;
+
+ // CJL (成交量)
+ private BigDecimal cjlVolume;
+
+ // VOL_AMOUNT (成交额指标)
+ private BigDecimal volAmount;
+}
diff --git a/src/main/java/com/yangwale/backtestify/model/dto/KLineData.java b/src/main/java/com/yangwale/backtestify/model/dto/KLineData.java
new file mode 100644
index 0000000..0c7a12c
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/model/dto/KLineData.java
@@ -0,0 +1,41 @@
+package com.yangwale.backtestify.model.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * K线数据 - 包含外部提供的指标值
+ */
+@Getter
+@Builder
+@Schema(description = "K线数据,价格为未放大的原始值")
+public class KLineData {
+
+ @Schema(description = "K线时间", example = "2026-01-02T09:00:00")
+ private LocalDateTime time;
+
+ @Schema(description = "开盘价", example = "520.50")
+ private BigDecimal open;
+
+ @Schema(description = "最高价", example = "525.00")
+ private BigDecimal high;
+
+ @Schema(description = "最低价", example = "518.50")
+ private BigDecimal low;
+
+ @Schema(description = "收盘价", example = "522.00")
+ private BigDecimal close;
+
+ @Schema(description = "成交量", example = "10000")
+ private BigDecimal volume;
+
+ @Schema(description = "成交额", example = "5200000")
+ private BigDecimal amount;
+
+ @Schema(description = "技术指标值")
+ private IndicatorValues indicators;
+}
diff --git a/src/main/java/com/yangwale/backtestify/model/dto/TradeRecordDto.java b/src/main/java/com/yangwale/backtestify/model/dto/TradeRecordDto.java
new file mode 100644
index 0000000..3676f15
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/model/dto/TradeRecordDto.java
@@ -0,0 +1,31 @@
+package com.yangwale.backtestify.model.dto;
+
+import lombok.Builder;
+import lombok.Getter;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 交易记录 DTO
+ */
+@Getter
+@Builder
+public class TradeRecordDto {
+
+ private TradeAction action;
+ private BigDecimal price;
+ private int volume;
+ private BigDecimal turnover;
+ private LocalDateTime tradeTime;
+ private LocalDateTime klineTime;
+ private SignalType signalType;
+
+ public enum TradeAction {
+ BUY_OPEN, SELL_CLOSE, SELL_OPEN, BUY_CLOSE
+ }
+
+ public enum SignalType {
+ B, S
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/model/request/AdminStrategyPageRequest.java b/src/main/java/com/yangwale/backtestify/model/request/AdminStrategyPageRequest.java
new file mode 100644
index 0000000..857a179
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/model/request/AdminStrategyPageRequest.java
@@ -0,0 +1,24 @@
+package com.yangwale.backtestify.model.request;
+
+import com.yangwale.backtestify.common.PageRequest;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * 后台管理分页查询请求
+ */
+@Getter
+@Setter
+@Schema(description = "后台管理分页查询请求")
+public class AdminStrategyPageRequest extends PageRequest {
+
+ @Schema(description = "按合约代码筛选", example = "rb")
+ private String contractCode;
+
+ @Schema(description = "按用户ID筛选", example = "10001")
+ private Long userId;
+
+ @Schema(description = "按方向筛选", example = "LONG", allowableValues = {"LONG", "SHORT"})
+ private String direction;
+}
diff --git a/src/main/java/com/yangwale/backtestify/model/request/BacktestRequest.java b/src/main/java/com/yangwale/backtestify/model/request/BacktestRequest.java
new file mode 100644
index 0000000..62a3732
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/model/request/BacktestRequest.java
@@ -0,0 +1,66 @@
+package com.yangwale.backtestify.model.request;
+
+import com.yangwale.backtestify.common.BaseRequest;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.*;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 回测请求参数
+ */
+@Getter
+@Setter
+@Schema(description = "回测请求参数")
+public class BacktestRequest extends BaseRequest {
+
+ @Schema(description = "合约代码", example = "rb2601")
+ @NotBlank(message = "合约代码不能为空")
+ private String contractCode;
+
+ @Schema(description = "合约名称", example = "螺纹钢2601")
+ @NotBlank(message = "合约名称不能为空")
+ private String contractName;
+
+ @Schema(description = "交易方向", example = "LONG", allowableValues = {"LONG", "SHORT"})
+ @NotBlank(message = "交易方向不能为空")
+ private String direction;
+
+ @Schema(description = "K线周期", example = "1d", allowableValues = {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d", "1w"})
+ @NotBlank(message = "K线周期不能为空")
+ private String klinePeriod;
+
+ @Schema(description = "技术指标列表,最多3个", example = "[\"MACD\",\"KDJ\"]")
+ @NotEmpty(message = "至少选择1个技术指标")
+ @Size(max = 3, message = "最多选择3个技术指标")
+ private List indicators;
+
+ @Schema(description = "开仓数量", example = "1")
+ @NotNull(message = "开仓数量不能为空")
+ @Min(value = 1, message = "开仓数量至少为1")
+ private Integer openVolume;
+
+ @Schema(description = "数量单位", example = "LOT", allowableValues = {"LOT", "POSITION"})
+ private String volumeUnit = "LOT";
+
+ @Schema(description = "止损值", example = "2.5")
+ @DecimalMin(value = "0.001", message = "止损值必须大于0")
+ private BigDecimal stopLossValue;
+
+ @Schema(description = "止损单位", example = "PERCENT", allowableValues = {"TICK", "PERCENT"})
+ private String stopLossUnit;
+
+ @Schema(description = "止盈值", example = "5")
+ @DecimalMin(value = "0.001", message = "止盈值必须大于0")
+ private BigDecimal takeProfitValue;
+
+ @Schema(description = "止盈单位", example = "PERCENT", allowableValues = {"TICK", "PERCENT"})
+ private String takeProfitUnit;
+
+ @Schema(description = "回测区间", example = "6m", allowableValues = {"1m", "3m", "6m", "1y"})
+ @NotBlank(message = "回测区间不能为空")
+ private String backtestPeriod;
+}
diff --git a/src/main/java/com/yangwale/backtestify/model/request/SignalQueryRequest.java b/src/main/java/com/yangwale/backtestify/model/request/SignalQueryRequest.java
new file mode 100644
index 0000000..a3eec01
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/model/request/SignalQueryRequest.java
@@ -0,0 +1,32 @@
+package com.yangwale.backtestify.model.request;
+
+import com.yangwale.backtestify.common.BaseRequest;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * 信号标记查询请求
+ */
+@Getter
+@Setter
+@Schema(description = "信号标记查询请求")
+public class SignalQueryRequest extends BaseRequest {
+
+ @Schema(description = "合约代码", example = "rb2601")
+ @NotBlank(message = "合约代码不能为空")
+ private String contractCode;
+
+ @Schema(description = "K线周期", example = "1d", allowableValues = {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d", "1w"})
+ @NotBlank(message = "K线周期不能为空")
+ private String period;
+
+ @Schema(description = "起始时间", example = "2026-01-01 00:00:00")
+ @NotBlank(message = "起始时间不能为空")
+ private String startTime;
+
+ @Schema(description = "结束时间", example = "2026-07-01 00:00:00")
+ @NotBlank(message = "结束时间不能为空")
+ private String endTime;
+}
diff --git a/src/main/java/com/yangwale/backtestify/model/response/BacktestResponse.java b/src/main/java/com/yangwale/backtestify/model/response/BacktestResponse.java
new file mode 100644
index 0000000..17c2c34
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/model/response/BacktestResponse.java
@@ -0,0 +1,86 @@
+package com.yangwale.backtestify.model.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+/**
+ * 回测响应
+ */
+@Getter
+@Builder
+@Schema(description = "回测响应")
+public class BacktestResponse {
+
+ /** 策略ID */
+ private Long strategyId;
+
+ // 策略参数摘要
+ private String contractCode;
+ private String contractName;
+ private String direction;
+ private String klinePeriod;
+ private List indicators;
+ private Integer openVolume;
+ private String volumeUnit;
+ private BigDecimal stopLossValue;
+ private String stopLossUnit;
+ private BigDecimal takeProfitValue;
+ private String takeProfitUnit;
+ private String backtestPeriod;
+
+ // 回测结果指标
+ private BigDecimal initialCapital;
+ private BigDecimal finalCapital;
+ private BigDecimal maxEquity;
+ private BigDecimal minEquity;
+ private BigDecimal totalYield;
+ private BigDecimal profitAmount;
+ private BigDecimal annualizedYield;
+ private Integer tradeCount;
+ private BigDecimal maxDrawdown;
+ private BigDecimal sharpeRatio;
+ private BigDecimal winRate;
+ private LocalDate startDate;
+ private LocalDate endDate;
+
+ // 每日净值曲线 [{date, equity, yield}]
+ private List dailyEquityCurve;
+
+ // 交易明细
+ private List tradeDetails;
+
+ // 信号标记点
+ private List signalPoints;
+
+ @Getter
+ @Builder
+ public static class DailyEquityPoint {
+ private LocalDate date;
+ private BigDecimal equity;
+ private BigDecimal yield;
+ }
+
+ @Getter
+ @Builder
+ public static class TradeDetailItem {
+ private String action;
+ private BigDecimal price;
+ private Integer volume;
+ private BigDecimal turnover;
+ private String tradeTime;
+ private String signalType;
+ }
+
+ @Getter
+ @Builder
+ public static class SignalPointItem {
+ private String time;
+ private String type;
+ private BigDecimal price;
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/model/response/SignalPointResponse.java b/src/main/java/com/yangwale/backtestify/model/response/SignalPointResponse.java
new file mode 100644
index 0000000..cb560ff
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/model/response/SignalPointResponse.java
@@ -0,0 +1,28 @@
+package com.yangwale.backtestify.model.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.math.BigDecimal;
+
+/**
+ * 信号标记点
+ */
+@Getter
+@Builder
+@Schema(description = "K线图信号标记点")
+public class SignalPointResponse {
+
+ /** 信号时间 */
+ private String time;
+
+ /** 信号类型 B/S */
+ private String type;
+
+ /** 信号价格 */
+ private BigDecimal price;
+
+ /** 策略ID */
+ private Long strategyId;
+}
diff --git a/src/main/java/com/yangwale/backtestify/model/response/StrategyDetailResponse.java b/src/main/java/com/yangwale/backtestify/model/response/StrategyDetailResponse.java
new file mode 100644
index 0000000..6ec8b1b
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/model/response/StrategyDetailResponse.java
@@ -0,0 +1,55 @@
+package com.yangwale.backtestify.model.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 策略详情响应
+ */
+@Getter
+@Builder
+@Schema(description = "策略详情响应")
+public class StrategyDetailResponse {
+
+ private Long strategyId;
+ private Long userId;
+ private String contractCode;
+ private String contractName;
+ private String direction;
+ private String klinePeriod;
+ private List indicators;
+ private Integer openVolume;
+ private String volumeUnit;
+ private BigDecimal stopLossValue;
+ private String stopLossUnit;
+ private BigDecimal takeProfitValue;
+ private String takeProfitUnit;
+ private String backtestPeriod;
+ private Integer status;
+ private LocalDateTime createTime;
+
+ // 回测结果
+ private BigDecimal initialCapital;
+ private BigDecimal finalCapital;
+ private BigDecimal maxEquity;
+ private BigDecimal minEquity;
+ private BigDecimal totalYield;
+ private BigDecimal profitAmount;
+ private BigDecimal annualizedYield;
+ private Integer tradeCount;
+ private BigDecimal maxDrawdown;
+ private BigDecimal sharpeRatio;
+ private BigDecimal winRate;
+ private LocalDate startDate;
+ private LocalDate endDate;
+ private List dailyEquityCurve;
+
+ // 交易明细
+ private List tradeDetails;
+}
diff --git a/src/main/java/com/yangwale/backtestify/model/response/StrategyListItemResponse.java b/src/main/java/com/yangwale/backtestify/model/response/StrategyListItemResponse.java
new file mode 100644
index 0000000..b5b5ecf
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/model/response/StrategyListItemResponse.java
@@ -0,0 +1,32 @@
+package com.yangwale.backtestify.model.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 策略列表项响应
+ */
+@Getter
+@Builder
+@Schema(description = "策略列表项响应")
+public class StrategyListItemResponse {
+
+ private Long id;
+ private Long userId;
+ private String contractCode;
+ private String contractName;
+ private String direction;
+ private String klinePeriod;
+ private String indicators;
+ private Integer status;
+ private BigDecimal totalYield;
+ private BigDecimal profitAmount;
+ private Integer tradeCount;
+ private BigDecimal winRate;
+ private BigDecimal maxDrawdown;
+ private LocalDateTime createTime;
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/BacktestEngine.java b/src/main/java/com/yangwale/backtestify/service/BacktestEngine.java
new file mode 100644
index 0000000..31b4239
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/BacktestEngine.java
@@ -0,0 +1,15 @@
+package com.yangwale.backtestify.service;
+
+import com.yangwale.backtestify.model.request.BacktestRequest;
+import com.yangwale.backtestify.model.response.BacktestResponse;
+
+/**
+ * 回测引擎接口
+ */
+public interface BacktestEngine {
+
+ /**
+ * 执行回测并保存结果
+ */
+ BacktestResponse execute(BacktestRequest request);
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/MarketDataService.java b/src/main/java/com/yangwale/backtestify/service/MarketDataService.java
new file mode 100644
index 0000000..22e6124
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/MarketDataService.java
@@ -0,0 +1,28 @@
+package com.yangwale.backtestify.service;
+
+import com.yangwale.backtestify.model.dto.KLineData;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 行情数据服务接口
+ */
+public interface MarketDataService {
+
+ /**
+ * 获取历史K线数据
+ */
+ List getKLineData(String contractCode, String period,
+ LocalDateTime startTime, LocalDateTime endTime);
+
+ /**
+ * 获取可用合约列表
+ */
+ List getAvailableContracts();
+
+ /**
+ * 验证合约是否存在
+ */
+ void validateContract(String contractCode);
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/StrategyService.java b/src/main/java/com/yangwale/backtestify/service/StrategyService.java
new file mode 100644
index 0000000..062c424
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/StrategyService.java
@@ -0,0 +1,58 @@
+package com.yangwale.backtestify.service;
+
+import com.yangwale.backtestify.common.PageResult;
+import com.yangwale.backtestify.model.request.AdminStrategyPageRequest;
+import com.yangwale.backtestify.model.request.BacktestRequest;
+import com.yangwale.backtestify.model.request.SignalQueryRequest;
+import com.yangwale.backtestify.model.response.BacktestResponse;
+import com.yangwale.backtestify.model.response.SignalPointResponse;
+import com.yangwale.backtestify.model.response.StrategyDetailResponse;
+import com.yangwale.backtestify.model.response.StrategyListItemResponse;
+
+import java.util.List;
+
+/**
+ * 策略服务接口
+ */
+public interface StrategyService {
+
+ /**
+ * 执行回测(含保存)
+ */
+ BacktestResponse backtest(BacktestRequest request);
+
+ /**
+ * 我的策略列表(分页)
+ */
+ PageResult myList(Long userId, int pageNum, int pageSize);
+
+ /**
+ * 策略详情
+ */
+ StrategyDetailResponse detail(Long strategyId);
+
+ /**
+ * 逻辑删除策略
+ */
+ void delete(Long strategyId, Long userId);
+
+ /**
+ * 切换信号启用/停用
+ */
+ void toggleSignal(Long strategyId, Long userId);
+
+ /**
+ * 查询K线图信号标记点
+ */
+ List getSignals(SignalQueryRequest request);
+
+ /**
+ * 获取可用指标列表
+ */
+ List getAvailableIndicators();
+
+ /**
+ * 后台分页查询全部策略
+ */
+ PageResult adminList(AdminStrategyPageRequest request);
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/impl/BacktestEngineImpl.java b/src/main/java/com/yangwale/backtestify/service/impl/BacktestEngineImpl.java
new file mode 100644
index 0000000..08f5b92
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/impl/BacktestEngineImpl.java
@@ -0,0 +1,529 @@
+package com.yangwale.backtestify.service.impl;
+
+import com.alibaba.fastjson2.JSON;
+import com.yangwale.backtestify.common.BusinessException;
+import com.yangwale.backtestify.common.ErrorCode;
+import com.yangwale.backtestify.config.WebMvcConfig;
+import com.yangwale.backtestify.entity.StrategyConfig;
+import com.yangwale.backtestify.entity.StrategyResult;
+import com.yangwale.backtestify.entity.TradeDetail;
+import com.yangwale.backtestify.enums.Direction;
+import com.yangwale.backtestify.enums.KLinePeriod;
+import com.yangwale.backtestify.enums.TradeAction;
+import com.yangwale.backtestify.mapper.StrategyConfigMapper;
+import com.yangwale.backtestify.mapper.StrategyResultMapper;
+import com.yangwale.backtestify.mapper.TradeDetailMapper;
+import com.yangwale.backtestify.model.dto.KLineData;
+import com.yangwale.backtestify.model.request.BacktestRequest;
+import com.yangwale.backtestify.model.response.BacktestResponse;
+import com.yangwale.backtestify.service.BacktestEngine;
+import com.yangwale.backtestify.service.MarketDataService;
+import com.yangwale.backtestify.service.signal.SignalStrategy;
+import com.yangwale.backtestify.service.signal.SignalStrategyFactory;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 回测引擎核心实现
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class BacktestEngineImpl implements BacktestEngine {
+
+ private final MarketDataService marketDataService;
+ private final SignalStrategyFactory signalStrategyFactory;
+ private final StrategyConfigMapper strategyConfigMapper;
+ private final StrategyResultMapper strategyResultMapper;
+ private final TradeDetailMapper tradeDetailMapper;
+
+ @Value("${backtest.initial-capital:1000000}")
+ private BigDecimal initialCapital;
+
+ @Value("${backtest.margin-ratio:0.10}")
+ private BigDecimal marginRatio;
+
+ @Value("${backtest.fee-rate:0.00005}")
+ private BigDecimal feeRate;
+
+ @Value("${backtest.risk-free-rate:0.025}")
+ private BigDecimal riskFreeRate;
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public BacktestResponse execute(BacktestRequest request) {
+ // 1. 参数解析
+ Direction direction = Direction.of(request.getDirection());
+ KLinePeriod period = KLinePeriod.of(request.getKlinePeriod());
+ List strategies = signalStrategyFactory.get(request.getIndicators());
+
+ // 2. 计算回测时间范围
+ LocalDateTime now = LocalDateTime.now();
+ LocalDateTime startTime = calcStartTime(now, request.getBacktestPeriod());
+
+ // 3. 获取历史K线数据
+ List kLines = marketDataService.getKLineData(
+ request.getContractCode(), period.getCode(), startTime, now);
+ if (kLines.isEmpty()) {
+ throw new BusinessException(ErrorCode.MARKET_DATA_UNAVAILABLE, "回测区间内无可用K线数据");
+ }
+ log.info("回测区间: {} ~ {}, K线数量: {}", kLines.getFirst().getTime(), kLines.getLast().getTime(), kLines.size());
+
+ // 4. 创建回测上下文
+ BacktestContext ctx = new BacktestContext(initialCapital, marginRatio, feeRate);
+
+ // 5. 逐根K线遍历
+ for (int i = 0; i < kLines.size(); i++) {
+ KLineData kline = kLines.get(i);
+ ctx.updateEquity();
+
+ // 5a. 检查止盈/止损
+ if (ctx.hasPosition() && checkStopCondition(ctx, kline, request)) {
+ closePosition(ctx, kline, direction);
+ continue;
+ }
+
+ // 5b. 检查信号策略
+ if (!ctx.hasPosition()) {
+ boolean allBuy = strategies.stream().allMatch(s -> s.isBuySignal(kline, kLines));
+ boolean allSell = strategies.stream().allMatch(s -> s.isSellSignal(kline, kLines));
+
+ if (direction == Direction.LONG && allBuy) {
+ openPosition(ctx, kline, direction);
+ } else if (direction == Direction.SHORT && allSell) {
+ openPosition(ctx, kline, direction);
+ }
+ }
+ }
+
+ // 6. 遍历结束,强制平仓
+ if (ctx.hasPosition()) {
+ KLineData lastKline = kLines.getLast();
+ closePosition(ctx, lastKline, direction);
+ }
+
+ // 7. 计算指标
+ LocalDate startDate = kLines.getFirst().getTime().toLocalDate();
+ LocalDate endDate = kLines.getLast().getTime().toLocalDate();
+ long totalDays = ChronoUnit.DAYS.between(startDate, endDate) + 1;
+
+ BigDecimal finalCapital = ctx.getTotalEquity();
+ BigDecimal totalYield = calcTotalYield(finalCapital);
+ BigDecimal profitAmount = finalCapital.subtract(initialCapital);
+ BigDecimal annualizedYield = calcAnnualizedYield(totalYield, totalDays);
+ BigDecimal maxDrawdown = calcMaxDrawdown(ctx);
+ BigDecimal sharpeRatio = calcSharpeRatio(ctx.dailyYields);
+ BigDecimal winRate = calcWinRate(ctx.tradeRecords);
+
+ // 8. 持久化
+ StrategyConfig config = saveStrategyConfig(request);
+ saveStrategyResult(config.getId(), initialCapital, finalCapital,
+ ctx.maxEquity, ctx.minEquity, totalYield, profitAmount, annualizedYield,
+ ctx.tradeRecords.size(), maxDrawdown, sharpeRatio, winRate,
+ startDate, endDate, ctx.dailyEquityCurve);
+ List tradeDetails = saveTradeDetails(config.getId(), ctx.tradeRecords);
+
+ // 9. 构建响应
+ return buildResponse(request, config.getId(), initialCapital, finalCapital,
+ ctx.maxEquity, ctx.minEquity, totalYield, profitAmount, annualizedYield,
+ ctx.tradeRecords.size(), maxDrawdown, sharpeRatio, winRate,
+ startDate, endDate, ctx, tradeDetails);
+ }
+
+ // ==================== 交易操作 ====================
+
+ private void openPosition(BacktestContext ctx, KLineData kline, Direction direction) {
+ BigDecimal price = kline.getClose();
+ int volume = 1; // 简化:每次开仓1手
+ BigDecimal turnover = price.multiply(BigDecimal.valueOf(volume));
+ BigDecimal margin = turnover.multiply(marginRatio);
+ BigDecimal fee = turnover.multiply(feeRate);
+
+ if (ctx.availableCapital.compareTo(margin.add(fee)) < 0) {
+ log.debug("保证金不足,跳过开仓。可用: {}, 需要: {}", ctx.availableCapital, margin.add(fee));
+ return;
+ }
+
+ ctx.availableCapital = ctx.availableCapital.subtract(margin).subtract(fee);
+ ctx.position = volume;
+ ctx.avgCostPrice = price;
+ ctx.marginLocked = margin;
+
+ TradeAction action = direction == Direction.LONG ? TradeAction.BUY_OPEN : TradeAction.SELL_OPEN;
+ String signal = direction == Direction.LONG ? "B" : "S";
+ ctx.addRecord(action, price, volume, turnover, kline.getTime(), signal);
+ log.debug("开仓: {} {}手 @ {}, 保证金={}, 手续费={}", action.getLabel(), volume, price, margin, fee);
+ }
+
+ private void closePosition(BacktestContext ctx, KLineData kline, Direction direction) {
+ BigDecimal price = kline.getClose();
+ int volume = ctx.position;
+ BigDecimal turnover = price.multiply(BigDecimal.valueOf(volume));
+ BigDecimal fee = turnover.multiply(feeRate);
+
+ // 释放保证金,计算盈亏,更新可用资金
+ BigDecimal profit = turnover.subtract(ctx.avgCostPrice.multiply(BigDecimal.valueOf(volume)));
+ if (direction == Direction.SHORT) {
+ profit = BigDecimal.ZERO.subtract(profit); // 做空盈亏反向
+ }
+ ctx.availableCapital = ctx.availableCapital.add(ctx.marginLocked).add(profit).subtract(fee);
+ ctx.position = 0;
+ ctx.marginLocked = BigDecimal.ZERO;
+ ctx.avgCostPrice = BigDecimal.ZERO;
+
+ TradeAction action = direction == Direction.LONG ? TradeAction.SELL_CLOSE : TradeAction.BUY_CLOSE;
+ String signal = direction == Direction.LONG ? "S" : "B";
+ ctx.addRecord(action, price, volume, turnover, kline.getTime(), signal);
+ log.debug("平仓: {} {}手 @ {}, 盈亏={}, 手续费={}", action.getLabel(), volume, price, profit, fee);
+ }
+
+ // ==================== 止盈止损检查 ====================
+
+ private boolean checkStopCondition(BacktestContext ctx, KLineData kline, BacktestRequest request) {
+ BigDecimal price = kline.getClose();
+ BigDecimal cost = ctx.avgCostPrice;
+
+ if (request.getStopLossValue() != null) {
+ if ("PERCENT".equalsIgnoreCase(request.getStopLossUnit())) {
+ BigDecimal stopPct = request.getStopLossValue().divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP);
+ BigDecimal lossRatio = BigDecimal.ONE.subtract(stopPct);
+ // 做多:止损价 = 成本价 × (1 - 止损%)
+ // 做空:止损价 = 成本价 × (1 + 止损%)
+ BigDecimal stopPrice = cost.multiply(lossRatio);
+ if (price.compareTo(stopPrice) <= 0) {
+ log.info("触发止损: 价格={}, 止损价={}", price, stopPrice);
+ return true;
+ }
+ }
+ }
+
+ if (request.getTakeProfitValue() != null) {
+ if ("PERCENT".equalsIgnoreCase(request.getTakeProfitUnit())) {
+ BigDecimal tpPct = request.getTakeProfitValue().divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP);
+ BigDecimal gainRatio = BigDecimal.ONE.add(tpPct);
+ BigDecimal tpPrice = cost.multiply(gainRatio);
+ if (price.compareTo(tpPrice) >= 0) {
+ log.info("触发止盈: 价格={}, 止盈价={}", price, tpPrice);
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ // ==================== 指标计算 ====================
+
+ private BigDecimal calcTotalYield(BigDecimal finalCapital) {
+ return finalCapital.subtract(initialCapital)
+ .divide(initialCapital, 8, RoundingMode.HALF_UP)
+ .multiply(BigDecimal.valueOf(100))
+ .setScale(4, RoundingMode.HALF_UP);
+ }
+
+ private BigDecimal calcAnnualizedYield(BigDecimal totalYield, long totalDays) {
+ if (totalDays <= 0) return BigDecimal.ZERO;
+ return totalYield.divide(BigDecimal.valueOf(totalDays), 8, RoundingMode.HALF_UP)
+ .multiply(BigDecimal.valueOf(365))
+ .setScale(4, RoundingMode.HALF_UP);
+ }
+
+ private BigDecimal calcMaxDrawdown(BacktestContext ctx) {
+ if (ctx.maxEquity.compareTo(BigDecimal.ZERO) == 0) return BigDecimal.ZERO;
+ BigDecimal minAfterMax = ctx.minEquity;
+ return ctx.maxEquity.subtract(minAfterMax)
+ .divide(ctx.maxEquity, 8, RoundingMode.HALF_UP)
+ .multiply(BigDecimal.valueOf(100))
+ .abs()
+ .setScale(4, RoundingMode.HALF_UP);
+ }
+
+ private BigDecimal calcSharpeRatio(List dailyYields) {
+ if (dailyYields == null || dailyYields.size() < 5) return BigDecimal.ZERO;
+ // 日收益率平均值
+ BigDecimal sum = dailyYields.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
+ BigDecimal avgDaily = sum.divide(BigDecimal.valueOf(dailyYields.size()), 8, RoundingMode.HALF_UP);
+ // 日收益率标准差
+ BigDecimal variance = dailyYields.stream()
+ .map(y -> y.subtract(avgDaily).pow(2))
+ .reduce(BigDecimal.ZERO, BigDecimal::add)
+ .divide(BigDecimal.valueOf(dailyYields.size()), 8, RoundingMode.HALF_UP);
+ BigDecimal stdDaily = BigDecimal.valueOf(Math.sqrt(variance.doubleValue()));
+ if (stdDaily.compareTo(BigDecimal.ZERO) == 0) return BigDecimal.ZERO;
+ // 年化夏普
+ BigDecimal annualizedReturn = avgDaily.multiply(BigDecimal.valueOf(252)); // 252交易日
+ BigDecimal annualizedStd = stdDaily.multiply(BigDecimal.valueOf(Math.sqrt(252)));
+ return annualizedReturn.subtract(riskFreeRate)
+ .divide(annualizedStd, 8, RoundingMode.HALF_UP)
+ .setScale(4, RoundingMode.HALF_UP);
+ }
+
+ private BigDecimal calcWinRate(List records) {
+ if (records.isEmpty()) return BigDecimal.ZERO;
+ long winCount = 0;
+ long totalClose = 0;
+ for (int i = 0; i < records.size(); i++) {
+ BacktestContext.TradeRecord r = records.get(i);
+ if (r.action == TradeAction.SELL_CLOSE || r.action == TradeAction.BUY_CLOSE) {
+ totalClose++;
+ // 寻找对应的开仓记录计算盈亏
+ for (int j = i - 1; j >= 0; j--) {
+ BacktestContext.TradeRecord open = records.get(j);
+ if ((r.action == TradeAction.SELL_CLOSE && open.action == TradeAction.BUY_OPEN)
+ || (r.action == TradeAction.BUY_CLOSE && open.action == TradeAction.SELL_OPEN)) {
+ if (r.turnover.compareTo(open.turnover) > 0) winCount++;
+ break;
+ }
+ }
+ }
+ }
+ return totalClose == 0 ? BigDecimal.ZERO :
+ BigDecimal.valueOf(winCount).divide(BigDecimal.valueOf(totalClose), 8, RoundingMode.HALF_UP)
+ .multiply(BigDecimal.valueOf(100)).setScale(4, RoundingMode.HALF_UP);
+ }
+
+ // ==================== 时间范围计算 ====================
+
+ private LocalDateTime calcStartTime(LocalDateTime now, String backtestPeriod) {
+ return switch (backtestPeriod) {
+ case "1m" -> now.minusMonths(1);
+ case "6m" -> now.minusMonths(6);
+ case "1y" -> now.minusYears(1);
+ default -> now.minusMonths(3);
+ };
+ }
+
+ // ==================== 持久化 ====================
+
+ private StrategyConfig saveStrategyConfig(BacktestRequest request) {
+ StrategyConfig config = new StrategyConfig();
+ config.setUserId(WebMvcConfig.RequestContextHolder.getUserId());
+ config.setContractCode(request.getContractCode());
+ config.setContractName(request.getContractName());
+ config.setDirection(request.getDirection().toUpperCase());
+ config.setKlinePeriod(request.getKlinePeriod());
+ config.setIndicators(JSON.toJSONString(request.getIndicators()));
+ config.setOpenVolume(request.getOpenVolume());
+ config.setVolumeUnit(request.getVolumeUnit());
+ config.setStopLossValue(request.getStopLossValue());
+ config.setStopLossUnit(request.getStopLossUnit());
+ config.setTakeProfitValue(request.getTakeProfitValue());
+ config.setTakeProfitUnit(request.getTakeProfitUnit());
+ config.setBacktestPeriod(request.getBacktestPeriod());
+ config.setStatus(0); // 已保存
+ strategyConfigMapper.insert(config);
+ return config;
+ }
+
+ private void saveStrategyResult(Long strategyId, BigDecimal initialCapital,
+ BigDecimal finalCapital, BigDecimal maxEquity,
+ BigDecimal minEquity, BigDecimal totalYield,
+ BigDecimal profitAmount, BigDecimal annualizedYield,
+ int tradeCount, BigDecimal maxDrawdown,
+ BigDecimal sharpeRatio, BigDecimal winRate,
+ LocalDate startDate, LocalDate endDate,
+ List dailyCurve) {
+ StrategyResult result = new StrategyResult();
+ result.setStrategyId(strategyId);
+ result.setInitialCapital(initialCapital);
+ result.setFinalCapital(finalCapital);
+ result.setMaxEquity(maxEquity);
+ result.setMinEquity(minEquity);
+ result.setTotalYield(totalYield);
+ result.setProfitAmount(profitAmount);
+ result.setAnnualizedYield(annualizedYield);
+ result.setTradeCount(tradeCount);
+ result.setMaxDrawdown(maxDrawdown);
+ result.setSharpeRatio(sharpeRatio);
+ result.setWinRate(winRate);
+ result.setStartDate(startDate);
+ result.setEndDate(endDate);
+ result.setDailyEquityCurve(JSON.toJSONString(dailyCurve));
+ strategyResultMapper.insert(result);
+ }
+
+ private List saveTradeDetails(Long strategyId,
+ List records) {
+ List details = new ArrayList<>();
+ for (BacktestContext.TradeRecord r : records) {
+ TradeDetail detail = new TradeDetail();
+ detail.setStrategyId(strategyId);
+ detail.setAction(r.action.name());
+ detail.setPrice(r.price);
+ detail.setVolume(r.volume);
+ detail.setTurnover(r.turnover);
+ detail.setTradeTime(r.tradeTime);
+ detail.setKlineTime(r.klineTime);
+ detail.setSignalType(r.signal);
+ details.add(detail);
+ }
+ if (!details.isEmpty()) {
+ tradeDetailMapper.insert(details);
+ }
+ return details;
+ }
+
+ // ==================== 构建响应 ====================
+
+ private BacktestResponse buildResponse(BacktestRequest request, Long strategyId,
+ BigDecimal initialCapital, BigDecimal finalCapital,
+ BigDecimal maxEquity, BigDecimal minEquity,
+ BigDecimal totalYield, BigDecimal profitAmount,
+ BigDecimal annualizedYield, int tradeCount,
+ BigDecimal maxDrawdown, BigDecimal sharpeRatio,
+ BigDecimal winRate,
+ LocalDate startDate, LocalDate endDate,
+ BacktestContext ctx,
+ List tradeDetails) {
+ List curve = ctx.dailyEquityCurve.stream()
+ .map(d -> BacktestResponse.DailyEquityPoint.builder()
+ .date(d.date)
+ .equity(d.equity)
+ .yield(d.yield)
+ .build())
+ .toList();
+
+ List trades = tradeDetails.stream()
+ .map(t -> BacktestResponse.TradeDetailItem.builder()
+ .action(t.getAction())
+ .price(t.getPrice())
+ .volume(t.getVolume())
+ .turnover(t.getTurnover())
+ .tradeTime(t.getTradeTime() != null ? t.getTradeTime().toString() : null)
+ .signalType(t.getSignalType())
+ .build())
+ .toList();
+
+ List signals = ctx.tradeRecords.stream()
+ .map(r -> BacktestResponse.SignalPointItem.builder()
+ .time(r.klineTime.toString())
+ .type(r.signal)
+ .price(r.price)
+ .build())
+ .toList();
+
+ return BacktestResponse.builder()
+ .strategyId(strategyId)
+ .contractCode(request.getContractCode())
+ .contractName(request.getContractName())
+ .direction(request.getDirection())
+ .klinePeriod(request.getKlinePeriod())
+ .indicators(request.getIndicators())
+ .openVolume(request.getOpenVolume())
+ .volumeUnit(request.getVolumeUnit())
+ .stopLossValue(request.getStopLossValue())
+ .stopLossUnit(request.getStopLossUnit())
+ .takeProfitValue(request.getTakeProfitValue())
+ .takeProfitUnit(request.getTakeProfitUnit())
+ .backtestPeriod(request.getBacktestPeriod())
+ .initialCapital(initialCapital)
+ .finalCapital(finalCapital)
+ .maxEquity(maxEquity)
+ .minEquity(minEquity)
+ .totalYield(totalYield)
+ .profitAmount(profitAmount)
+ .annualizedYield(annualizedYield)
+ .tradeCount(tradeCount)
+ .maxDrawdown(maxDrawdown)
+ .sharpeRatio(sharpeRatio)
+ .winRate(winRate)
+ .startDate(startDate)
+ .endDate(endDate)
+ .dailyEquityCurve(curve)
+ .tradeDetails(trades)
+ .signalPoints(signals)
+ .build();
+ }
+
+ // ==================== 回测上下文(内部类) ====================
+
+ static class BacktestContext {
+ BigDecimal availableCapital;
+ BigDecimal marginRatio;
+ BigDecimal feeRate;
+ int position = 0;
+ BigDecimal avgCostPrice = BigDecimal.ZERO;
+ BigDecimal marginLocked = BigDecimal.ZERO;
+ BigDecimal maxEquity;
+ BigDecimal minEquity;
+ List tradeRecords = new ArrayList<>();
+ List dailyEquityCurve = new ArrayList<>();
+ List dailyYields = new ArrayList<>();
+ BigDecimal dayStartEquity;
+
+ BacktestContext(BigDecimal capital, BigDecimal marginRatio, BigDecimal feeRate) {
+ this.availableCapital = capital;
+ this.marginRatio = marginRatio;
+ this.feeRate = feeRate;
+ this.maxEquity = capital;
+ this.minEquity = capital;
+ this.dayStartEquity = capital;
+ }
+
+ boolean hasPosition() {
+ return position > 0;
+ }
+
+ BigDecimal getTotalEquity() {
+ return availableCapital.add(marginLocked);
+ }
+
+ void updateEquity() {
+ BigDecimal equity = getTotalEquity();
+ if (equity.compareTo(maxEquity) > 0) maxEquity = equity;
+ if (equity.compareTo(minEquity) < 0) minEquity = equity;
+ }
+
+ void addRecord(TradeAction action, BigDecimal price, int volume, BigDecimal turnover,
+ LocalDateTime klineTime, String signal) {
+ tradeRecords.add(new TradeRecord(action, price, volume, turnover, LocalDateTime.now(), klineTime, signal));
+ }
+
+ static class TradeRecord {
+ TradeAction action;
+ BigDecimal price;
+ int volume;
+ BigDecimal turnover;
+ LocalDateTime tradeTime;
+ LocalDateTime klineTime;
+ String signal;
+
+ TradeRecord(TradeAction action, BigDecimal price, int volume, BigDecimal turnover,
+ LocalDateTime tradeTime, LocalDateTime klineTime, String signal) {
+ this.action = action;
+ this.price = price;
+ this.volume = volume;
+ this.turnover = turnover;
+ this.tradeTime = tradeTime;
+ this.klineTime = klineTime;
+ this.signal = signal;
+ }
+ }
+
+ @Getter
+ static class DailyEquity {
+ LocalDate date;
+ BigDecimal equity;
+ BigDecimal yield;
+
+ DailyEquity(LocalDate date, BigDecimal equity, BigDecimal yield) {
+ this.date = date;
+ this.equity = equity;
+ this.yield = yield;
+ }
+
+ }
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/impl/MarketDataServiceImpl.java b/src/main/java/com/yangwale/backtestify/service/impl/MarketDataServiceImpl.java
new file mode 100644
index 0000000..5c1f218
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/impl/MarketDataServiceImpl.java
@@ -0,0 +1,85 @@
+package com.yangwale.backtestify.service.impl;
+
+import com.yangwale.backtestify.common.BusinessException;
+import com.yangwale.backtestify.common.ErrorCode;
+import com.yangwale.backtestify.model.dto.KLineData;
+import com.yangwale.backtestify.service.MarketDataService;
+import com.yangwale.backtestify.service.market.MarketDataProvider;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.List;
+
+/**
+ * 行情数据服务实现
+ * 先从 Redis 缓存获取(如果Redis可用),缓存未命中则从 Provider 获取
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class MarketDataServiceImpl implements MarketDataService {
+
+ private final MarketDataProvider marketDataProvider;
+
+ @Autowired(required = false)
+ private RedisTemplate redisTemplate;
+
+ private static final String CACHE_KEY_PREFIX = "kline:";
+ private static final Duration CACHE_TTL = Duration.ofHours(1);
+ private static final ZoneId ZONE_ID = ZoneId.of("Asia/Shanghai");
+
+ @Override
+ public List getKLineData(String contractCode, String period,
+ LocalDateTime startTime, LocalDateTime endTime) {
+ validateContract(contractCode);
+
+ // 尝试从Redis缓存获取
+ if (redisTemplate != null) {
+ String cacheKey = buildCacheKey(contractCode, period, startTime, endTime);
+ @SuppressWarnings("unchecked")
+ List cached = (List) redisTemplate.opsForValue().get(cacheKey);
+ if (cached != null && !cached.isEmpty()) {
+ log.debug("命中K线缓存: {}", cacheKey);
+ return cached.stream()
+ .filter(k -> !k.getTime().isBefore(startTime) && !k.getTime().isAfter(endTime))
+ .toList();
+ }
+ }
+
+ log.info("从数据源获取K线数据: {} {}", contractCode, period);
+ List data = marketDataProvider.getKLineData(contractCode, period, startTime, endTime);
+
+ // 写入缓存
+ if (data != null && !data.isEmpty() && redisTemplate != null) {
+ String cacheKey = buildCacheKey(contractCode, period, startTime, endTime);
+ redisTemplate.opsForValue().set(cacheKey, data, CACHE_TTL);
+ }
+ return data != null ? data : List.of();
+ }
+
+ @Override
+ public List getAvailableContracts() {
+ return marketDataProvider.getAvailableContracts();
+ }
+
+ @Override
+ public void validateContract(String contractCode) {
+ if (!marketDataProvider.supportsContract(contractCode)) {
+ throw new BusinessException(ErrorCode.CONTRACT_NOT_FOUND,
+ "合约不存在: " + contractCode);
+ }
+ }
+
+ private String buildCacheKey(String contractCode, String period,
+ LocalDateTime startTime, LocalDateTime endTime) {
+ long startEpoch = startTime.atZone(ZONE_ID).toEpochSecond();
+ long endEpoch = endTime.atZone(ZONE_ID).toEpochSecond();
+ return CACHE_KEY_PREFIX + contractCode + ":" + period + ":" + startEpoch + ":" + endEpoch;
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/impl/StrategyServiceImpl.java b/src/main/java/com/yangwale/backtestify/service/impl/StrategyServiceImpl.java
new file mode 100644
index 0000000..803f086
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/impl/StrategyServiceImpl.java
@@ -0,0 +1,329 @@
+package com.yangwale.backtestify.service.impl;
+
+import com.alibaba.fastjson2.JSON;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.yangwale.backtestify.common.BusinessException;
+import com.yangwale.backtestify.common.ErrorCode;
+import com.yangwale.backtestify.common.PageResult;
+import com.yangwale.backtestify.entity.StrategyConfig;
+import com.yangwale.backtestify.entity.StrategyResult;
+import com.yangwale.backtestify.entity.TradeDetail;
+import com.yangwale.backtestify.entity.UserSignal;
+import com.yangwale.backtestify.enums.StrategyStatus;
+import com.yangwale.backtestify.mapper.StrategyConfigMapper;
+import com.yangwale.backtestify.mapper.StrategyResultMapper;
+import com.yangwale.backtestify.mapper.TradeDetailMapper;
+import com.yangwale.backtestify.mapper.UserSignalMapper;
+import com.yangwale.backtestify.model.request.AdminStrategyPageRequest;
+import com.yangwale.backtestify.model.request.BacktestRequest;
+import com.yangwale.backtestify.model.request.SignalQueryRequest;
+import com.yangwale.backtestify.model.response.BacktestResponse;
+import com.yangwale.backtestify.model.response.SignalPointResponse;
+import com.yangwale.backtestify.model.response.StrategyDetailResponse;
+import com.yangwale.backtestify.model.response.StrategyListItemResponse;
+import com.yangwale.backtestify.service.BacktestEngine;
+import com.yangwale.backtestify.service.StrategyService;
+import com.yangwale.backtestify.service.signal.SignalStrategyFactory;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 策略服务实现
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class StrategyServiceImpl implements StrategyService {
+
+ private final BacktestEngine backtestEngine;
+ private final SignalStrategyFactory signalStrategyFactory;
+ private final StrategyConfigMapper strategyConfigMapper;
+ private final StrategyResultMapper strategyResultMapper;
+ private final TradeDetailMapper tradeDetailMapper;
+ private final UserSignalMapper userSignalMapper;
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public BacktestResponse backtest(BacktestRequest request) {
+ return backtestEngine.execute(request);
+ }
+
+ @Override
+ public PageResult myList(Long userId, int pageNum, int pageSize) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(StrategyConfig::getUserId, userId)
+ .orderByDesc(StrategyConfig::getCreateTime);
+
+ IPage page = strategyConfigMapper.selectPage(
+ new Page<>(pageNum, pageSize), wrapper);
+
+ List list = page.getRecords().stream()
+ .map(this::toListItem)
+ .collect(Collectors.toList());
+
+ return PageResult.of(page.getTotal(), pageNum, pageSize, list);
+ }
+
+ @Override
+ public StrategyDetailResponse detail(Long strategyId) {
+ StrategyConfig config = strategyConfigMapper.selectById(strategyId);
+ if (config == null) {
+ throw new BusinessException(ErrorCode.STRATEGY_NOT_FOUND);
+ }
+
+ StrategyResult result = strategyResultMapper.selectOne(
+ new LambdaQueryWrapper()
+ .eq(StrategyResult::getStrategyId, strategyId));
+
+ List trades = tradeDetailMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(TradeDetail::getStrategyId, strategyId)
+ .orderByAsc(TradeDetail::getTradeTime));
+
+ List indicators = parseJsonArray(config.getIndicators());
+
+ List curve = Collections.emptyList();
+ if (result != null && result.getDailyEquityCurve() != null) {
+ curve = JSON.parseArray(result.getDailyEquityCurve(), BacktestResponse.DailyEquityPoint.class);
+ }
+
+ List tradeItems = trades.stream()
+ .map(t -> BacktestResponse.TradeDetailItem.builder()
+ .action(t.getAction())
+ .price(t.getPrice())
+ .volume(t.getVolume())
+ .turnover(t.getTurnover())
+ .tradeTime(t.getTradeTime() != null ? t.getTradeTime().toString() : null)
+ .signalType(t.getSignalType())
+ .build())
+ .collect(Collectors.toList());
+
+ StrategyDetailResponse.StrategyDetailResponseBuilder builder = StrategyDetailResponse.builder()
+ .strategyId(config.getId())
+ .userId(config.getUserId())
+ .contractCode(config.getContractCode())
+ .contractName(config.getContractName())
+ .direction(config.getDirection())
+ .klinePeriod(config.getKlinePeriod())
+ .indicators(indicators)
+ .openVolume(config.getOpenVolume())
+ .volumeUnit(config.getVolumeUnit())
+ .stopLossValue(config.getStopLossValue())
+ .stopLossUnit(config.getStopLossUnit())
+ .takeProfitValue(config.getTakeProfitValue())
+ .takeProfitUnit(config.getTakeProfitUnit())
+ .backtestPeriod(config.getBacktestPeriod())
+ .status(config.getStatus())
+ .createTime(config.getCreateTime())
+ .dailyEquityCurve(curve)
+ .tradeDetails(tradeItems);
+
+ if (result != null) {
+ builder.initialCapital(result.getInitialCapital())
+ .finalCapital(result.getFinalCapital())
+ .maxEquity(result.getMaxEquity())
+ .minEquity(result.getMinEquity())
+ .totalYield(result.getTotalYield())
+ .profitAmount(result.getProfitAmount())
+ .annualizedYield(result.getAnnualizedYield())
+ .tradeCount(result.getTradeCount())
+ .maxDrawdown(result.getMaxDrawdown())
+ .sharpeRatio(result.getSharpeRatio())
+ .winRate(result.getWinRate())
+ .startDate(result.getStartDate())
+ .endDate(result.getEndDate());
+ }
+
+ return builder.build();
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void delete(Long strategyId, Long userId) {
+ StrategyConfig config = strategyConfigMapper.selectById(strategyId);
+ if (config == null) {
+ throw new BusinessException(ErrorCode.STRATEGY_NOT_FOUND);
+ }
+ if (!config.getUserId().equals(userId)) {
+ throw new BusinessException(ErrorCode.FORBIDDEN);
+ }
+ // MyBatis-Plus 逻辑删除
+ strategyConfigMapper.deleteById(strategyId);
+
+ // 同时停用信号
+ UserSignal signal = userSignalMapper.selectOne(
+ new LambdaQueryWrapper()
+ .eq(UserSignal::getStrategyId, strategyId));
+ if (signal != null) {
+ signal.setIsActive(0);
+ userSignalMapper.updateById(signal);
+ }
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void toggleSignal(Long strategyId, Long userId) {
+ StrategyConfig config = strategyConfigMapper.selectById(strategyId);
+ if (config == null) {
+ throw new BusinessException(ErrorCode.STRATEGY_NOT_FOUND);
+ }
+ if (!config.getUserId().equals(userId)) {
+ throw new BusinessException(ErrorCode.FORBIDDEN);
+ }
+
+ // 查找已有的信号记录
+ UserSignal signal = userSignalMapper.selectOne(
+ new LambdaQueryWrapper()
+ .eq(UserSignal::getStrategyId, strategyId));
+
+ if (signal == null) {
+ // 新建信号记录
+ signal = new UserSignal();
+ signal.setUserId(userId);
+ signal.setStrategyId(strategyId);
+ signal.setContractCode(config.getContractCode());
+ signal.setKlinePeriod(config.getKlinePeriod());
+ signal.setIsActive(1);
+ userSignalMapper.insert(signal);
+ config.setStatus(StrategyStatus.SIGNAL_ACTIVE.getCode());
+ } else {
+ // 切换状态
+ int newActive = signal.getIsActive() == 1 ? 0 : 1;
+ signal.setIsActive(newActive);
+ userSignalMapper.updateById(signal);
+ config.setStatus(newActive == 1 ? StrategyStatus.SIGNAL_ACTIVE.getCode() : StrategyStatus.SAVED.getCode());
+ }
+ strategyConfigMapper.updateById(config);
+ }
+
+ @Override
+ public List getSignals(SignalQueryRequest request) {
+ Long userId = request.getUserId();
+ // 找到该用户在该合约+周期下的所有启用信号
+ List activeSignals = userSignalMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(UserSignal::getUserId, userId)
+ .eq(UserSignal::getContractCode, request.getContractCode())
+ .eq(UserSignal::getKlinePeriod, request.getPeriod())
+ .eq(UserSignal::getIsActive, 1));
+
+ if (activeSignals.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ // 获取所有关联策略的交易明细作为信号点
+ List signals = new java.util.ArrayList<>();
+ DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+ LocalDateTime startTime = LocalDateTime.parse(request.getStartTime(), fmt);
+ LocalDateTime endTime = LocalDateTime.parse(request.getEndTime(), fmt);
+
+ for (UserSignal us : activeSignals) {
+ List trades = tradeDetailMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(TradeDetail::getStrategyId, us.getStrategyId())
+ .between(TradeDetail::getTradeTime, startTime, endTime)
+ .orderByAsc(TradeDetail::getTradeTime));
+
+ for (TradeDetail t : trades) {
+ signals.add(SignalPointResponse.builder()
+ .time(t.getTradeTime() != null ? t.getTradeTime().format(fmt) : null)
+ .type(t.getSignalType())
+ .price(t.getPrice())
+ .strategyId(us.getStrategyId())
+ .build());
+ }
+ }
+
+ // 冲突处理:同一时间点只保留最新策略的信号
+ return resolveSignalConflicts(signals);
+ }
+
+ @Override
+ public List getAvailableIndicators() {
+ return signalStrategyFactory.getAllIndicatorNames();
+ }
+
+ @Override
+ public PageResult adminList(AdminStrategyPageRequest request) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ if (request.getContractCode() != null && !request.getContractCode().isBlank()) {
+ wrapper.like(StrategyConfig::getContractCode, request.getContractCode());
+ }
+ if (request.getUserId() != null) {
+ wrapper.eq(StrategyConfig::getUserId, request.getUserId());
+ }
+ if (request.getDirection() != null && !request.getDirection().isBlank()) {
+ wrapper.eq(StrategyConfig::getDirection, request.getDirection().toUpperCase());
+ }
+ wrapper.orderByDesc(StrategyConfig::getCreateTime);
+
+ IPage page = strategyConfigMapper.selectPage(
+ new Page<>(request.getPageNum(), request.getPageSize()), wrapper);
+
+ List list = page.getRecords().stream()
+ .map(this::toListItem)
+ .collect(Collectors.toList());
+
+ return PageResult.of(page.getTotal(), request.getPageNum(), request.getPageSize(), list);
+ }
+
+ // ==================== 私有方法 ====================
+
+ private StrategyListItemResponse toListItem(StrategyConfig config) {
+ StrategyResult result = strategyResultMapper.selectOne(
+ new LambdaQueryWrapper()
+ .eq(StrategyResult::getStrategyId, config.getId()));
+
+ return StrategyListItemResponse.builder()
+ .id(config.getId())
+ .userId(config.getUserId())
+ .contractCode(config.getContractCode())
+ .contractName(config.getContractName())
+ .direction(config.getDirection())
+ .klinePeriod(config.getKlinePeriod())
+ .indicators(config.getIndicators())
+ .status(config.getStatus())
+ .totalYield(result != null ? result.getTotalYield() : null)
+ .profitAmount(result != null ? result.getProfitAmount() : null)
+ .tradeCount(result != null ? result.getTradeCount() : null)
+ .winRate(result != null ? result.getWinRate() : null)
+ .maxDrawdown(result != null ? result.getMaxDrawdown() : null)
+ .createTime(config.getCreateTime())
+ .build();
+ }
+
+ @SuppressWarnings("unchecked")
+ private List parseJsonArray(String json) {
+ if (json == null || json.isBlank()) return Collections.emptyList();
+ try {
+ return JSON.parseArray(json, String.class);
+ } catch (Exception e) {
+ log.warn("解析JSON数组失败: {}", json, e);
+ return Collections.emptyList();
+ }
+ }
+
+ /**
+ * 信号冲突处理:同一K线时间只保留最新策略(按策略ID最大)的信号
+ */
+ private List resolveSignalConflicts(List signals) {
+ return signals.stream()
+ .collect(Collectors.toMap(
+ SignalPointResponse::getTime,
+ s -> s,
+ (s1, s2) -> s1.getStrategyId() > s2.getStrategyId() ? s1 : s2))
+ .values().stream()
+ .sorted((a, b) -> a.getTime().compareTo(b.getTime()))
+ .collect(Collectors.toList());
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/market/FakeMarketDataProvider.java b/src/main/java/com/yangwale/backtestify/service/market/FakeMarketDataProvider.java
new file mode 100644
index 0000000..2b9febb
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/market/FakeMarketDataProvider.java
@@ -0,0 +1,396 @@
+package com.yangwale.backtestify.service.market;
+
+import com.yangwale.backtestify.model.dto.IndicatorValues;
+import com.yangwale.backtestify.model.dto.KLineData;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.*;
+
+/**
+ * 假数据行情提供方
+ * 生成一套固定的、可重复的历史K线数据,附带完整的技术指标值。
+ * 所有测试用例都基于此套数据进行计算和断言。
+ *
+ * 数据特征:
+ * - 合约 PVC2605,日K,近3月约65个交易日
+ * - 价格区间 4800~5300
+ * - 包含上涨趋势、下跌趋势、震荡区间
+ * - 多次MACD金叉/死叉
+ * - KDJ超买超卖区间
+ */
+@Component
+@ConditionalOnProperty(prefix = "market-data", name = "provider", havingValue = "fake", matchIfMissing = true)
+public class FakeMarketDataProvider implements MarketDataProvider {
+
+ private static final String CONTRACT = "PVC2605";
+ private static final Set PERIODS = Set.of("1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d", "1w");
+
+ /** 预生成的日K数据(按时间升序) */
+ private final List dailyData;
+
+ public FakeMarketDataProvider() {
+ this.dailyData = generateDailyData();
+ }
+
+ @Override
+ public List getKLineData(String contractCode, String period,
+ LocalDateTime startTime, LocalDateTime endTime) {
+ if (!CONTRACT.equals(contractCode)) {
+ return Collections.emptyList();
+ }
+ // 所有周期返回基于日K的数据(简化处理:3m/1h 通过日K插值模拟)
+ // 不按时间过滤,直接返回全部假数据,确保测试可重复
+ return generateDataForPeriod(period);
+ }
+
+ @Override
+ public List getAvailableContracts() {
+ return List.of(CONTRACT);
+ }
+
+ @Override
+ public boolean supportsContract(String contractCode) {
+ return CONTRACT.equals(contractCode);
+ }
+
+ // ==================== 数据生成 ====================
+
+ /**
+ * 根据周期返回对应数据
+ */
+ private List generateDataForPeriod(String period) {
+ return switch (period) {
+ case "1d" -> dailyData;
+ case "1h" -> generateHourlyFromDaily();
+ case "3m" -> generate3MinFromDaily();
+ case "1w" -> generateWeeklyFromDaily();
+ default -> dailyData; // 其他周期默认返回日K
+ };
+ }
+
+ /**
+ * 生成约65个交易日的日K数据
+ * 包含完整的技术指标值
+ */
+ private List generateDailyData() {
+ List list = new ArrayList<>();
+
+ // 起始日期:2024-09-02(周一),约65个交易日到 2024-12-02
+ LocalDate date = LocalDate.of(2024, 9, 2);
+ LocalDate endDate = LocalDate.of(2024, 12, 2);
+
+ // 价格序列参数 — 制造趋势和震荡
+ // Phase 1: 横盘震荡 (days 0-15) 价格 ~5000-5100
+ // Phase 2: 上升趋势 (days 16-35) 价格 5100→5300
+ // Phase 3: 下跌趋势 (days 36-50) 价格 5300→4850
+ // Phase 4: 反弹恢复 (days 51-65) 价格 4850→5150
+
+ double[] closes = new double[65];
+ double[] opens = new double[65];
+ double[] highs = new double[65];
+ double[] lows = new double[65];
+ double[] volumes = new double[65];
+
+ // 生成收盘价序列
+ Random rng = new Random(42); // 固定种子,确保可重复
+ double price = 5050.0;
+
+ for (int i = 0; i < 65; i++) {
+ double trend;
+ if (i < 16) {
+ trend = (rng.nextDouble() - 0.5) * 30; // 震荡 ±15
+ } else if (i < 36) {
+ trend = 10 + rng.nextDouble() * 15; // 上升 +10~25
+ } else if (i < 51) {
+ trend = -15 + rng.nextDouble() * -10; // 下跌 -15~-25
+ } else {
+ trend = 15 + rng.nextDouble() * 10; // 反弹 +15~25
+ }
+ price += trend;
+ price = Math.max(4800, Math.min(5350, price));
+ closes[i] = price;
+ }
+
+ // 生成开高低量
+ for (int i = 0; i < 65; i++) {
+ double c = closes[i];
+ double prevC = i > 0 ? closes[i - 1] : 5000.0;
+ double dailyRange = 15 + rng.nextDouble() * 25;
+ double o = prevC + (rng.nextDouble() - 0.5) * 10;
+ double h = Math.max(o, c) + rng.nextDouble() * dailyRange * 0.4;
+ double l = Math.min(o, c) - rng.nextDouble() * dailyRange * 0.6;
+ opens[i] = o;
+ highs[i] = Math.max(h, Math.max(o, c));
+ lows[i] = Math.min(l, Math.min(o, c));
+ volumes[i] = 5000 + rng.nextDouble() * 15000;
+ }
+
+ // 计算各指标序列
+ double[] ma5 = calcMA(closes, 5);
+ double[] ma10 = calcMA(closes, 10);
+ double[] ma20 = calcMA(closes, 20);
+ double[] ma60 = calcMA(closes, 60);
+
+ // MACD
+ double[] ema12 = calcEMA(closes, 12);
+ double[] ema26 = calcEMA(closes, 26);
+ double[] dif = new double[65];
+ double[] dea = new double[65];
+ double[] macdBar = new double[65];
+ for (int i = 0; i < 65; i++) {
+ dif[i] = ema12[i] - ema26[i];
+ dea[i] = i == 0 ? dif[i] : dea[i - 1] * 0.8 + dif[i] * 0.2;
+ macdBar[i] = 2 * (dif[i] - dea[i]);
+ }
+
+ // KDJ (9,3,3)
+ double[] k = new double[65];
+ double[] d = new double[65];
+ double[] j = new double[65];
+ for (int i = 0; i < 65; i++) {
+ if (i < 8) {
+ k[i] = 50; d[i] = 50; j[i] = 50;
+ } else {
+ double highestHigh = maxOfRange(highs, i - 8, i);
+ double lowestLow = minOfRange(lows, i - 8, i);
+ double rsv = (closes[i] - lowestLow) / (highestHigh - lowestLow + 0.0001) * 100;
+ k[i] = (i == 0 ? 50 : k[i - 1]) * 2.0 / 3.0 + rsv / 3.0;
+ d[i] = (i == 0 ? 50 : d[i - 1]) * 2.0 / 3.0 + k[i] / 3.0;
+ j[i] = 3 * k[i] - 2 * d[i];
+ }
+ }
+
+ // BOLL (20,2)
+ double[] bollMid = ma20.clone();
+ double[] bollUpper = new double[65];
+ double[] bollLower = new double[65];
+ for (int i = 0; i < 65; i++) {
+ double std = calcStd(closes, i, 20);
+ bollUpper[i] = bollMid[i] + 2 * std;
+ bollLower[i] = bollMid[i] - 2 * std;
+ }
+
+ // RSI (6,14,24)
+ double[] rsi6 = calcRSI(closes, 6);
+ double[] rsi14 = calcRSI(closes, 14);
+ double[] rsi24 = calcRSI(closes, 24);
+
+ // BBI = (MA3+MA6+MA12+MA24)/4
+ double[] ma3_arr = calcMA(closes, 3);
+ double[] ma6_arr = calcMA(closes, 6);
+ double[] ma12_arr = calcMA(closes, 12);
+ double[] ma24_arr = calcMA(closes, 24);
+ double[] bbi = new double[65];
+ for (int i = 0; i < 65; i++) {
+ bbi[i] = (ma3_arr[i] + ma6_arr[i] + ma12_arr[i] + ma24_arr[i]) / 4.0;
+ }
+
+ // CCI (14)
+ double[] cciArr = new double[65];
+ for (int i = 0; i < 65; i++) {
+ double tp = (highs[i] + lows[i] + closes[i]) / 3.0;
+ if (i < 13) { cciArr[i] = 0; continue; }
+ double sumTp = 0;
+ for (int j2 = i - 13; j2 <= i; j2++) {
+ sumTp += (highs[j2] + lows[j2] + closes[j2]) / 3.0;
+ }
+ double maTp = sumTp / 14.0;
+ double sumMd = 0;
+ for (int j2 = i - 13; j2 <= i; j2++) {
+ sumMd += Math.abs((highs[j2] + lows[j2] + closes[j2]) / 3.0 - maTp);
+ }
+ double md = sumMd / 14.0;
+ cciArr[i] = md == 0 ? 0 : (tp - maTp) / (0.015 * md);
+ }
+
+ // ATR (14)
+ double[] atrArr = new double[65];
+ for (int i = 0; i < 65; i++) {
+ double tr = highs[i] - lows[i];
+ if (i > 0) {
+ tr = Math.max(tr, Math.abs(highs[i] - closes[i - 1]));
+ tr = Math.max(tr, Math.abs(lows[i] - closes[i - 1]));
+ }
+ atrArr[i] = i == 0 ? tr : (atrArr[i - 1] * 13 + tr) / 14.0;
+ }
+
+ // WR (14)
+ double[] wrArr = new double[65];
+ for (int i = 0; i < 65; i++) {
+ if (i < 13) { wrArr[i] = 50; continue; }
+ double hh = maxOfRange(highs, i - 13, i);
+ double ll = minOfRange(lows, i - 13, i);
+ wrArr[i] = (hh - closes[i]) / (hh - ll + 0.0001) * 100;
+ }
+
+ // 组装数据
+ LocalDate cursor = date;
+ for (int i = 0; i < 65; i++) {
+ // 跳过周末
+ while (cursor.getDayOfWeek().getValue() >= 6) {
+ cursor = cursor.plusDays(1);
+ }
+
+ IndicatorValues indicators = IndicatorValues.builder()
+ .macdDif(bd(dif[i])).macdDea(bd(dea[i])).macdBar(bd(macdBar[i]))
+ .kdjK(bd(k[i])).kdjD(bd(d[i])).kdjJ(bd(j[i]))
+ .ma5(bd(ma5[i])).ma10(bd(ma10[i])).ma20(bd(ma20[i])).ma60(bd(ma60[i]))
+ .bollUpper(bd(bollUpper[i])).bollMid(bd(bollMid[i])).bollLower(bd(bollLower[i]))
+ .rsi6(bd(rsi6[i])).rsi14(bd(rsi14[i])).rsi24(bd(rsi24[i]))
+ .cci(bd(cciArr[i]))
+ .bbi(bd(bbi[i]))
+ .sar(bd(closes[i] * (i < 35 ? 0.99 : 1.01))) // 简化的SAR
+ .dmiPdi(bd(20 + (i - 32) * 0.3))
+ .dmiMdi(bd(20 - (i - 32) * 0.3))
+ .dmiAdx(bd(25))
+ .wr(bd(wrArr[i]))
+ .atr(bd(atrArr[i]))
+ .donchianUpper(bd(i >= 19 ? maxOfRange(highs, i - 19, i) : highs[i]))
+ .donchianLower(bd(i >= 19 ? minOfRange(lows, i - 19, i) : lows[i]))
+ .cjlVolume(bd(volumes[i]))
+ .volAmount(bd(volumes[i] * closes[i]))
+ .build();
+
+ KLineData kline = KLineData.builder()
+ .time(LocalDateTime.of(cursor, LocalTime.of(9, 0)))
+ .open(bd(opens[i])).high(bd(highs[i])).low(bd(lows[i])).close(bd(closes[i]))
+ .volume(bd(volumes[i])).amount(bd(volumes[i] * closes[i]))
+ .indicators(indicators)
+ .build();
+ list.add(kline);
+ cursor = cursor.plusDays(1);
+ }
+
+ return list;
+ }
+
+ // ==================== 不同周期转换 ====================
+
+ private List generateHourlyFromDaily() {
+ List result = new ArrayList<>();
+ for (KLineData day : dailyData) {
+ LocalDate d = day.getTime().toLocalDate();
+ for (int h = 9; h <= 15; h++) {
+ BigDecimal factor = BigDecimal.valueOf(0.9 + Math.random() * 0.2);
+ BigDecimal intradayClose = day.getClose().multiply(factor).setScale(2, RoundingMode.HALF_UP);
+ result.add(KLineData.builder()
+ .time(LocalDateTime.of(d, LocalTime.of(h, 0)))
+ .open(day.getOpen()).high(day.getHigh()).low(day.getLow())
+ .close(intradayClose)
+ .volume(day.getVolume().divide(BigDecimal.valueOf(7), 0, RoundingMode.HALF_UP))
+ .amount(day.getAmount().divide(BigDecimal.valueOf(7), 2, RoundingMode.HALF_UP))
+ .indicators(day.getIndicators())
+ .build());
+ }
+ }
+ return result;
+ }
+
+ private List generate3MinFromDaily() {
+ List result = new ArrayList<>();
+ for (KLineData day : dailyData) {
+ LocalDate d = day.getTime().toLocalDate();
+ int barsPerDay = 80; // 9:00-15:00 共6小时=360分钟, 360/3=120, 用80简化
+ for (int i = 0; i < barsPerDay; i++) {
+ int minute = 9 * 60 + i * 3;
+ int hour = minute / 60;
+ int min = minute % 60;
+ BigDecimal factor = BigDecimal.valueOf(0.95 + Math.random() * 0.1);
+ result.add(KLineData.builder()
+ .time(LocalDateTime.of(d, LocalTime.of(hour, min)))
+ .open(day.getOpen()).high(day.getHigh()).low(day.getLow())
+ .close(day.getClose().multiply(factor).setScale(2, RoundingMode.HALF_UP))
+ .volume(day.getVolume().divide(BigDecimal.valueOf(barsPerDay), 0, RoundingMode.HALF_UP))
+ .amount(day.getAmount().divide(BigDecimal.valueOf(barsPerDay), 2, RoundingMode.HALF_UP))
+ .indicators(day.getIndicators())
+ .build());
+ }
+ }
+ return result;
+ }
+
+ private List generateWeeklyFromDaily() {
+ List result = new ArrayList<>();
+ for (int i = 0; i < dailyData.size(); i += 5) {
+ KLineData day = dailyData.get(i);
+ result.add(day); // 简化:每5天取一根
+ }
+ return result;
+ }
+
+ // ==================== 指标计算工具方法 ====================
+
+ private double[] calcMA(double[] prices, int period) {
+ double[] result = new double[prices.length];
+ double sum = 0;
+ for (int i = 0; i < prices.length; i++) {
+ sum += prices[i];
+ if (i >= period) sum -= prices[i - period];
+ result[i] = sum / Math.min(i + 1, period);
+ }
+ return result;
+ }
+
+ private double[] calcEMA(double[] prices, int period) {
+ double[] result = new double[prices.length];
+ double multiplier = 2.0 / (period + 1);
+ result[0] = prices[0];
+ for (int i = 1; i < prices.length; i++) {
+ result[i] = (prices[i] - result[i - 1]) * multiplier + result[i - 1];
+ }
+ return result;
+ }
+
+ private double[] calcRSI(double[] closes, int period) {
+ double[] result = new double[closes.length];
+ double avgGain = 0, avgLoss = 0;
+ for (int i = 1; i < closes.length; i++) {
+ double change = closes[i] - closes[i - 1];
+ double gain = Math.max(change, 0);
+ double loss = Math.max(-change, 0);
+ if (i < period) {
+ avgGain = (avgGain * (i - 1) + gain) / i;
+ avgLoss = (avgLoss * (i - 1) + loss) / i;
+ } else {
+ avgGain = (avgGain * (period - 1) + gain) / period;
+ avgLoss = (avgLoss * (period - 1) + loss) / period;
+ }
+ result[i] = avgLoss == 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss);
+ }
+ result[0] = 50;
+ return result;
+ }
+
+ private double calcStd(double[] values, int idx, int period) {
+ int start = Math.max(0, idx - period + 1);
+ int count = idx - start + 1;
+ double sum = 0;
+ for (int i = start; i <= idx; i++) sum += values[i];
+ double mean = sum / count;
+ double sqSum = 0;
+ for (int i = start; i <= idx; i++) sqSum += Math.pow(values[i] - mean, 2);
+ return Math.sqrt(sqSum / count);
+ }
+
+ private double maxOfRange(double[] arr, int from, int to) {
+ double max = arr[from];
+ for (int i = from + 1; i <= to; i++) max = Math.max(max, arr[i]);
+ return max;
+ }
+
+ private double minOfRange(double[] arr, int from, int to) {
+ double min = arr[from];
+ for (int i = from + 1; i <= to; i++) min = Math.min(min, arr[i]);
+ return min;
+ }
+
+ private BigDecimal bd(double value) {
+ return BigDecimal.valueOf(Math.round(value * 10000) / 10000.0);
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/market/MarketDataProvider.java b/src/main/java/com/yangwale/backtestify/service/market/MarketDataProvider.java
new file mode 100644
index 0000000..df20b84
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/market/MarketDataProvider.java
@@ -0,0 +1,36 @@
+package com.yangwale.backtestify.service.market;
+
+import com.yangwale.backtestify.model.dto.KLineData;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 行情数据提供方接口
+ * 仅提供一套固定的假数据用于开发测试。
+ * 如需对接真实数据源,实现此接口即可。
+ */
+public interface MarketDataProvider {
+
+ /**
+ * 获取指定合约和周期的历史K线数据(包含外部提供的指标值)
+ *
+ * @param contractCode 合约代码
+ * @param period K线周期
+ * @param startTime 起始时间
+ * @param endTime 结束时间
+ * @return K线数据列表,按时间升序排列
+ */
+ List getKLineData(String contractCode, String period,
+ LocalDateTime startTime, LocalDateTime endTime);
+
+ /**
+ * 获取支持的合约列表
+ */
+ List getAvailableContracts();
+
+ /**
+ * 检查是否支持该合约
+ */
+ boolean supportsContract(String contractCode);
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/market/MysqlMarketDataProvider.java b/src/main/java/com/yangwale/backtestify/service/market/MysqlMarketDataProvider.java
new file mode 100644
index 0000000..31769b6
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/market/MysqlMarketDataProvider.java
@@ -0,0 +1,100 @@
+package com.yangwale.backtestify.service.market;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.yangwale.backtestify.entity.InstrumentDictionary;
+import com.yangwale.backtestify.entity.KLineRecord;
+import com.yangwale.backtestify.mapper.InstrumentDictionaryMapper;
+import com.yangwale.backtestify.mapper.KLineMapper;
+import com.yangwale.backtestify.model.dto.KLineData;
+import com.yangwale.backtestify.service.market.convert.PriceScaleConverter;
+import com.yangwale.backtestify.service.market.indicator.KLineIndicatorCalculator;
+import com.yangwale.backtestify.service.market.repository.KLineTableResolver;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.List;
+
+/**
+ * MySQL真实行情数据源
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+@ConditionalOnProperty(prefix = "market-data", name = "provider", havingValue = "mysql")
+public class MysqlMarketDataProvider implements MarketDataProvider {
+
+ private static final ZoneId ZONE_ID = ZoneId.of("Asia/Shanghai");
+ private static final long LOOKBACK_SECONDS = 120L * 24 * 60 * 60;
+
+ private final InstrumentDictionaryMapper instrumentDictionaryMapper;
+ private final KLineMapper kLineMapper;
+ private final KLineTableResolver tableResolver;
+ private final KLineIndicatorCalculator indicatorCalculator;
+
+ @Override
+ public List getKLineData(String contractCode, String period,
+ LocalDateTime startTime, LocalDateTime endTime) {
+ InstrumentDictionary instrument = findInstrument(contractCode);
+ if (instrument == null) {
+ return List.of();
+ }
+
+ String tableName = tableResolver.resolve(period);
+ long start = toEpochSecond(startTime);
+ long end = toEpochSecond(endTime);
+ long lookbackStart = Math.max(0, start - LOOKBACK_SECONDS);
+
+ List records = kLineMapper.selectRange(tableName, instrument.getId(), lookbackStart, end);
+ List withIndicators = indicatorCalculator.attachIndicators(records.stream()
+ .map(record -> toKLineData(record, instrument.getPriceScale()))
+ .toList());
+
+ return withIndicators.stream()
+ .filter(item -> !item.getTime().isBefore(startTime) && !item.getTime().isAfter(endTime))
+ .toList();
+ }
+
+ @Override
+ public List getAvailableContracts() {
+ return instrumentDictionaryMapper.selectList(new LambdaQueryWrapper()
+ .eq(InstrumentDictionary::getIsDeleted, 0)
+ .orderByAsc(InstrumentDictionary::getContractCode))
+ .stream()
+ .map(InstrumentDictionary::getContractCode)
+ .toList();
+ }
+
+ @Override
+ public boolean supportsContract(String contractCode) {
+ return findInstrument(contractCode) != null;
+ }
+
+ private InstrumentDictionary findInstrument(String contractCode) {
+ return instrumentDictionaryMapper.selectOne(new LambdaQueryWrapper()
+ .eq(InstrumentDictionary::getContractCode, contractCode)
+ .eq(InstrumentDictionary::getIsDeleted, 0)
+ .last("LIMIT 1"));
+ }
+
+ private KLineData toKLineData(KLineRecord record, int priceScale) {
+ return KLineData.builder()
+ .time(LocalDateTime.ofInstant(Instant.ofEpochSecond(record.getTimestamp()), ZONE_ID))
+ .open(PriceScaleConverter.toRaw(record.getOpen(), priceScale))
+ .high(PriceScaleConverter.toRaw(record.getHigh(), priceScale))
+ .low(PriceScaleConverter.toRaw(record.getLow(), priceScale))
+ .close(PriceScaleConverter.toRaw(record.getClose(), priceScale))
+ .volume(BigDecimal.valueOf(record.getVolume() == null ? 0 : record.getVolume()))
+ .amount(BigDecimal.valueOf(record.getTurnover() == null ? 0 : record.getTurnover()))
+ .build();
+ }
+
+ private long toEpochSecond(LocalDateTime time) {
+ return time.atZone(ZONE_ID).toEpochSecond();
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/market/client/CnQuotationClient.java b/src/main/java/com/yangwale/backtestify/service/market/client/CnQuotationClient.java
new file mode 100644
index 0000000..0ba78af
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/market/client/CnQuotationClient.java
@@ -0,0 +1,145 @@
+package com.yangwale.backtestify.service.market.client;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.TypeReference;
+import com.yangwale.backtestify.common.BusinessException;
+import com.yangwale.backtestify.common.ErrorCode;
+import com.yangwale.backtestify.config.MarketDataProperties;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.HttpUrl;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.List;
+
+/**
+ * CnQuotation 行情HTTP客户端
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CnQuotationClient {
+
+ private final MarketDataProperties properties;
+
+ private volatile OkHttpClient okHttpClient;
+
+ public List listMainContracts() {
+ String body = get(urlBuilder("goods/list")
+ .addQueryParameter("pageSize", "-1")
+ .build());
+ CnQuotationModels.ResultModel result = JSON.parseObject(body,
+ new TypeReference>() {
+ });
+ CnQuotationModels.GoodsPage data = unwrap(result);
+ return data == null || data.list() == null ? List.of() : data.list();
+ }
+
+ public List getKChart(String excode, String code, String period) {
+ Integer type = typeOf(period);
+ String body = get(urlBuilder("kChart")
+ .addQueryParameter("excode", excode)
+ .addQueryParameter("code", code)
+ .addQueryParameter("type", String.valueOf(type))
+ .build());
+ CnQuotationModels.ResultModel result = JSON.parseObject(body,
+ new TypeReference>() {
+ });
+ CnQuotationModels.KChartResult data = unwrap(result);
+ return data == null || data.chats() == null ? List.of() : data.chats();
+ }
+
+ public List getKChartByDate(String excode, String code, String period,
+ long date, String direction) {
+ Integer type = typeOf(period);
+ String body = get(urlBuilder("kChartByDate")
+ .addQueryParameter("excode", excode)
+ .addQueryParameter("code", code)
+ .addQueryParameter("type", String.valueOf(type))
+ .addQueryParameter("date", String.valueOf(date))
+ .addQueryParameter("direction", direction)
+ .build());
+ CnQuotationModels.ResultModel result = JSON.parseObject(body,
+ new TypeReference>() {
+ });
+ CnQuotationModels.KChartResult data = unwrap(result);
+ return data == null || data.chats() == null ? List.of() : data.chats();
+ }
+
+ public Integer typeOf(String period) {
+ return switch (period) {
+ case "1m" -> 10;
+ case "5m" -> 2;
+ case "15m" -> 3;
+ case "30m" -> 4;
+ case "1h" -> 5;
+ case "1d" -> 6;
+ case "1w" -> 7;
+ case "4h" -> 9;
+ default -> throw new BusinessException(ErrorCode.INVALID_KLINE_PERIOD,
+ "行情接口不支持K线周期: " + period);
+ };
+ }
+
+ private T unwrap(CnQuotationModels.ResultModel result) {
+ if (result == null) {
+ throw new BusinessException(ErrorCode.MARKET_DATA_UNAVAILABLE, "行情接口返回为空");
+ }
+ if (!Boolean.TRUE.equals(result.success())) {
+ throw new BusinessException(ErrorCode.MARKET_DATA_UNAVAILABLE,
+ result.errorInfo() != null ? result.errorInfo() : "行情接口调用失败: " + result.errorCode());
+ }
+ return result.data();
+ }
+
+ private HttpUrl.Builder urlBuilder(String path) {
+ HttpUrl baseUrl = HttpUrl.parse(properties.getQuotation().getBaseUrl());
+ if (baseUrl == null) {
+ throw new BusinessException(ErrorCode.MARKET_DATA_UNAVAILABLE, "行情接口base-url配置无效");
+ }
+ return baseUrl.newBuilder().addPathSegments(path);
+ }
+
+ private String get(HttpUrl url) {
+ Request request = new Request.Builder()
+ .url(url)
+ .header("User-Agent", "backtestify/1.0")
+ .get()
+ .build();
+ try (Response response = client().newCall(request).execute()) {
+ String body = response.body() == null ? "" : response.body().string();
+ if (!response.isSuccessful()) {
+ throw new BusinessException(ErrorCode.MARKET_DATA_UNAVAILABLE,
+ "行情接口HTTP错误: " + response.code());
+ }
+ return body;
+ } catch (IOException e) {
+ log.warn("行情接口调用异常: {}", url, e);
+ throw new BusinessException(ErrorCode.MARKET_DATA_UNAVAILABLE, "行情接口调用异常: " + e.getMessage());
+ }
+ }
+
+ private OkHttpClient client() {
+ OkHttpClient current = okHttpClient;
+ if (current == null) {
+ synchronized (this) {
+ current = okHttpClient;
+ if (current == null) {
+ Duration timeout = Duration.ofSeconds(properties.getQuotation().getTimeoutSeconds());
+ current = new OkHttpClient.Builder()
+ .connectTimeout(timeout)
+ .readTimeout(timeout)
+ .writeTimeout(timeout)
+ .build();
+ okHttpClient = current;
+ }
+ }
+ }
+ return current;
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/market/client/CnQuotationModels.java b/src/main/java/com/yangwale/backtestify/service/market/client/CnQuotationModels.java
new file mode 100644
index 0000000..1137819
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/market/client/CnQuotationModels.java
@@ -0,0 +1,48 @@
+package com.yangwale.backtestify.service.market.client;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 行情接口响应模型
+ */
+public class CnQuotationModels {
+
+ public record ResultModel(Boolean success, String errorCode, String errorInfo, T data) {
+ }
+
+ public record GoodsPage(List list) {
+ }
+
+ public record GoodsItem(String excode,
+ String goodsCode,
+ String productId,
+ String goodsName,
+ String mainContractCode,
+ Integer isPrincipal,
+ Integer decimalPrecision) {
+ }
+
+ public record KChartResult(List chats) {
+ }
+
+ public record KChartItem(String t,
+ String o,
+ String h,
+ String l,
+ String c,
+ String v,
+ String a,
+ Long u,
+ String i,
+ String s) {
+ }
+
+ @SuppressWarnings("unused")
+ public record MainContractBatchRequest(List symbols) {
+ }
+
+ @SuppressWarnings("unused")
+ public record MainContractBatchResult(Map data) {
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/market/convert/PriceScaleConverter.java b/src/main/java/com/yangwale/backtestify/service/market/convert/PriceScaleConverter.java
new file mode 100644
index 0000000..c0b19d1
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/market/convert/PriceScaleConverter.java
@@ -0,0 +1,57 @@
+package com.yangwale.backtestify.service.market.convert;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+/**
+ * 价格放大整数与原始价格转换工具
+ */
+public final class PriceScaleConverter {
+
+ private PriceScaleConverter() {
+ }
+
+ public static int toScaled(String rawPrice, int priceScale) {
+ if (rawPrice == null || rawPrice.isBlank() || "-".equals(rawPrice.trim())) {
+ throw new IllegalArgumentException("价格不能为空");
+ }
+ return new BigDecimal(rawPrice.trim())
+ .multiply(BigDecimal.valueOf(priceScale))
+ .setScale(0, RoundingMode.HALF_UP)
+ .intValueExact();
+ }
+
+ public static BigDecimal toRaw(Integer scaledPrice, int priceScale) {
+ if (scaledPrice == null) {
+ return null;
+ }
+ return BigDecimal.valueOf(scaledPrice)
+ .divide(BigDecimal.valueOf(priceScale), scaleDigits(priceScale), RoundingMode.UNNECESSARY);
+ }
+
+ public static long toLong(String value) {
+ if (value == null || value.isBlank() || "-".equals(value.trim())) {
+ return 0L;
+ }
+ return new BigDecimal(value.trim()).setScale(0, RoundingMode.HALF_UP).longValue();
+ }
+
+ public static int scaleFromPrecision(Integer decimalPrecision) {
+ int precision = decimalPrecision == null ? 0 : Math.max(decimalPrecision, 0);
+ int scale = 1;
+ for (int i = 0; i < precision; i++) {
+ scale *= 10;
+ }
+ return scale;
+ }
+
+ private static int scaleDigits(int priceScale) {
+ int digits = 0;
+ int scale = priceScale;
+ while (scale > 1 && scale % 10 == 0) {
+ digits++;
+ scale /= 10;
+ }
+ return digits;
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/market/indicator/KLineIndicatorCalculator.java b/src/main/java/com/yangwale/backtestify/service/market/indicator/KLineIndicatorCalculator.java
new file mode 100644
index 0000000..ccbace1
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/market/indicator/KLineIndicatorCalculator.java
@@ -0,0 +1,222 @@
+package com.yangwale.backtestify.service.market.indicator;
+
+import com.yangwale.backtestify.model.dto.IndicatorValues;
+import com.yangwale.backtestify.model.dto.KLineData;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 根据基础K线计算回测信号所需指标
+ */
+@Component
+public class KLineIndicatorCalculator {
+
+ public List attachIndicators(List source) {
+ if (source == null || source.isEmpty()) {
+ return List.of();
+ }
+
+ int size = source.size();
+ double[] closes = new double[size];
+ double[] highs = new double[size];
+ double[] lows = new double[size];
+ double[] volumes = new double[size];
+ double[] amounts = new double[size];
+
+ for (int i = 0; i < size; i++) {
+ KLineData item = source.get(i);
+ closes[i] = toDouble(item.getClose());
+ highs[i] = toDouble(item.getHigh());
+ lows[i] = toDouble(item.getLow());
+ volumes[i] = toDouble(item.getVolume());
+ amounts[i] = toDouble(item.getAmount());
+ }
+
+ double[] ma3 = calcMA(closes, 3);
+ double[] ma5 = calcMA(closes, 5);
+ double[] ma6 = calcMA(closes, 6);
+ double[] ma10 = calcMA(closes, 10);
+ double[] ma12 = calcMA(closes, 12);
+ double[] ma20 = calcMA(closes, 20);
+ double[] ma24 = calcMA(closes, 24);
+ double[] ma60 = calcMA(closes, 60);
+ double[] ema12 = calcEMA(closes, 12);
+ double[] ema26 = calcEMA(closes, 26);
+ double[] dif = new double[size];
+ double[] dea = new double[size];
+ double[] macdBar = new double[size];
+ double[] k = new double[size];
+ double[] d = new double[size];
+ double[] j = new double[size];
+ double[] rsi6 = calcRSI(closes, 6);
+ double[] rsi14 = calcRSI(closes, 14);
+ double[] rsi24 = calcRSI(closes, 24);
+ double[] atr = new double[size];
+ double[] wr = new double[size];
+ double[] cci = new double[size];
+
+ for (int i = 0; i < size; i++) {
+ dif[i] = ema12[i] - ema26[i];
+ dea[i] = i == 0 ? dif[i] : dea[i - 1] * 0.8 + dif[i] * 0.2;
+ macdBar[i] = 2 * (dif[i] - dea[i]);
+
+ if (i < 8) {
+ k[i] = 50;
+ d[i] = 50;
+ j[i] = 50;
+ } else {
+ double highestHigh = maxOfRange(highs, i - 8, i);
+ double lowestLow = minOfRange(lows, i - 8, i);
+ double rsv = (closes[i] - lowestLow) / (highestHigh - lowestLow + 0.0001) * 100;
+ k[i] = (i == 0 ? 50 : k[i - 1]) * 2.0 / 3.0 + rsv / 3.0;
+ d[i] = (i == 0 ? 50 : d[i - 1]) * 2.0 / 3.0 + k[i] / 3.0;
+ j[i] = 3 * k[i] - 2 * d[i];
+ }
+
+ double tr = highs[i] - lows[i];
+ if (i > 0) {
+ tr = Math.max(tr, Math.abs(highs[i] - closes[i - 1]));
+ tr = Math.max(tr, Math.abs(lows[i] - closes[i - 1]));
+ }
+ atr[i] = i == 0 ? tr : (atr[i - 1] * 13 + tr) / 14.0;
+
+ if (i < 13) {
+ wr[i] = 50;
+ cci[i] = 0;
+ } else {
+ double hh = maxOfRange(highs, i - 13, i);
+ double ll = minOfRange(lows, i - 13, i);
+ wr[i] = (hh - closes[i]) / (hh - ll + 0.0001) * 100;
+ cci[i] = calcCCI(highs, lows, closes, i, 14);
+ }
+ }
+
+ List result = new ArrayList<>(size);
+ for (int i = 0; i < size; i++) {
+ double bollStd = calcStd(closes, i, 20);
+ double bbi = (ma3[i] + ma6[i] + ma12[i] + ma24[i]) / 4.0;
+ IndicatorValues indicators = IndicatorValues.builder()
+ .macdDif(bd(dif[i])).macdDea(bd(dea[i])).macdBar(bd(macdBar[i]))
+ .kdjK(bd(k[i])).kdjD(bd(d[i])).kdjJ(bd(j[i]))
+ .ma5(bd(ma5[i])).ma10(bd(ma10[i])).ma20(bd(ma20[i])).ma60(bd(ma60[i]))
+ .bollUpper(bd(ma20[i] + 2 * bollStd)).bollMid(bd(ma20[i])).bollLower(bd(ma20[i] - 2 * bollStd))
+ .rsi6(bd(rsi6[i])).rsi14(bd(rsi14[i])).rsi24(bd(rsi24[i]))
+ .cci(bd(cci[i]))
+ .bbi(bd(bbi))
+ .sar(bd(closes[i] * (i < size / 2 ? 0.99 : 1.01)))
+ .dmiPdi(bd(20 + i * 0.1))
+ .dmiMdi(bd(20 - i * 0.05))
+ .dmiAdx(bd(25))
+ .wr(bd(wr[i]))
+ .atr(bd(atr[i]))
+ .donchianUpper(bd(i >= 19 ? maxOfRange(highs, i - 19, i) : highs[i]))
+ .donchianLower(bd(i >= 19 ? minOfRange(lows, i - 19, i) : lows[i]))
+ .cjlVolume(bd(volumes[i]))
+ .volAmount(bd(amounts[i]))
+ .build();
+ KLineData item = source.get(i);
+ result.add(KLineData.builder()
+ .time(item.getTime())
+ .open(item.getOpen()).high(item.getHigh()).low(item.getLow()).close(item.getClose())
+ .volume(item.getVolume()).amount(item.getAmount())
+ .indicators(indicators)
+ .build());
+ }
+ return result;
+ }
+
+ private double[] calcMA(double[] values, int period) {
+ double[] result = new double[values.length];
+ double sum = 0;
+ for (int i = 0; i < values.length; i++) {
+ sum += values[i];
+ if (i >= period) sum -= values[i - period];
+ result[i] = sum / Math.min(i + 1, period);
+ }
+ return result;
+ }
+
+ private double[] calcEMA(double[] values, int period) {
+ double[] result = new double[values.length];
+ if (values.length == 0) return result;
+ double multiplier = 2.0 / (period + 1);
+ result[0] = values[0];
+ for (int i = 1; i < values.length; i++) {
+ result[i] = (values[i] - result[i - 1]) * multiplier + result[i - 1];
+ }
+ return result;
+ }
+
+ private double[] calcRSI(double[] closes, int period) {
+ double[] result = new double[closes.length];
+ if (closes.length == 0) return result;
+ result[0] = 50;
+ double avgGain = 0;
+ double avgLoss = 0;
+ for (int i = 1; i < closes.length; i++) {
+ double change = closes[i] - closes[i - 1];
+ double gain = Math.max(change, 0);
+ double loss = Math.max(-change, 0);
+ if (i < period) {
+ avgGain = (avgGain * (i - 1) + gain) / i;
+ avgLoss = (avgLoss * (i - 1) + loss) / i;
+ } else {
+ avgGain = (avgGain * (period - 1) + gain) / period;
+ avgLoss = (avgLoss * (period - 1) + loss) / period;
+ }
+ result[i] = avgLoss == 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss);
+ }
+ return result;
+ }
+
+ private double calcStd(double[] values, int idx, int period) {
+ int start = Math.max(0, idx - period + 1);
+ int count = idx - start + 1;
+ double sum = 0;
+ for (int i = start; i <= idx; i++) sum += values[i];
+ double mean = sum / count;
+ double sqSum = 0;
+ for (int i = start; i <= idx; i++) sqSum += Math.pow(values[i] - mean, 2);
+ return Math.sqrt(sqSum / count);
+ }
+
+ private double calcCCI(double[] highs, double[] lows, double[] closes, int idx, int period) {
+ int start = idx - period + 1;
+ double tp = (highs[idx] + lows[idx] + closes[idx]) / 3.0;
+ double sumTp = 0;
+ for (int i = start; i <= idx; i++) {
+ sumTp += (highs[i] + lows[i] + closes[i]) / 3.0;
+ }
+ double maTp = sumTp / period;
+ double sumMd = 0;
+ for (int i = start; i <= idx; i++) {
+ sumMd += Math.abs((highs[i] + lows[i] + closes[i]) / 3.0 - maTp);
+ }
+ double md = sumMd / period;
+ return md == 0 ? 0 : (tp - maTp) / (0.015 * md);
+ }
+
+ private double maxOfRange(double[] arr, int from, int to) {
+ double max = arr[from];
+ for (int i = from + 1; i <= to; i++) max = Math.max(max, arr[i]);
+ return max;
+ }
+
+ private double minOfRange(double[] arr, int from, int to) {
+ double min = arr[from];
+ for (int i = from + 1; i <= to; i++) min = Math.min(min, arr[i]);
+ return min;
+ }
+
+ private double toDouble(BigDecimal value) {
+ return value == null ? 0.0 : value.doubleValue();
+ }
+
+ private BigDecimal bd(double value) {
+ return BigDecimal.valueOf(value).setScale(4, RoundingMode.HALF_UP);
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/market/repository/KLineTableResolver.java b/src/main/java/com/yangwale/backtestify/service/market/repository/KLineTableResolver.java
new file mode 100644
index 0000000..1c101d8
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/market/repository/KLineTableResolver.java
@@ -0,0 +1,39 @@
+package com.yangwale.backtestify.service.market.repository;
+
+import com.yangwale.backtestify.common.BusinessException;
+import com.yangwale.backtestify.common.ErrorCode;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * K线周期与物理表名白名单映射
+ */
+@Component
+public class KLineTableResolver {
+
+ private static final Map TABLES = Map.of(
+ "1m", "t_kline_1m",
+ "5m", "t_kline_5m",
+ "15m", "t_kline_15m",
+ "30m", "t_kline_30m",
+ "1h", "t_kline_1h",
+ "4h", "t_kline_4h",
+ "1d", "t_kline_1d",
+ "1w", "t_kline_1w"
+ );
+
+ public String resolve(String period) {
+ String tableName = TABLES.get(period);
+ if (tableName == null) {
+ throw new BusinessException(ErrorCode.INVALID_KLINE_PERIOD,
+ "真实行情数据源不支持K线周期: " + period);
+ }
+ return tableName;
+ }
+
+ public Set supportedPeriods() {
+ return TABLES.keySet();
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/market/sync/MarketDataSyncService.java b/src/main/java/com/yangwale/backtestify/service/market/sync/MarketDataSyncService.java
new file mode 100644
index 0000000..b5ce0e0
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/market/sync/MarketDataSyncService.java
@@ -0,0 +1,197 @@
+package com.yangwale.backtestify.service.market.sync;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.yangwale.backtestify.config.MarketDataProperties;
+import com.yangwale.backtestify.entity.InstrumentDictionary;
+import com.yangwale.backtestify.entity.KLineRecord;
+import com.yangwale.backtestify.entity.MarketDataSyncLog;
+import com.yangwale.backtestify.mapper.InstrumentDictionaryMapper;
+import com.yangwale.backtestify.mapper.KLineMapper;
+import com.yangwale.backtestify.mapper.MarketDataSyncLogMapper;
+import com.yangwale.backtestify.service.market.client.CnQuotationClient;
+import com.yangwale.backtestify.service.market.client.CnQuotationModels;
+import com.yangwale.backtestify.service.market.convert.PriceScaleConverter;
+import com.yangwale.backtestify.service.market.repository.KLineTableResolver;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 行情增量同步服务
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@ConditionalOnProperty(prefix = "market-data", name = "provider", havingValue = "mysql")
+public class MarketDataSyncService {
+
+ private final MarketDataProperties properties;
+ private final CnQuotationClient cnQuotationClient;
+ private final InstrumentDictionaryMapper instrumentDictionaryMapper;
+ private final KLineMapper kLineMapper;
+ private final MarketDataSyncLogMapper syncLogMapper;
+ private final KLineTableResolver tableResolver;
+
+ @Scheduled(cron = "${market-data.sync.cron:0 0 6 * * ?}", zone = "${market-data.sync.zone:Asia/Shanghai}")
+ public void syncYesterdayMainContracts() {
+ if (!properties.getSync().isEnabled()) {
+ log.debug("行情增量同步未启用");
+ return;
+ }
+ syncIncrementalForMainContracts();
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void syncIncrementalForMainContracts() {
+ refreshMainContracts();
+ ZoneId zoneId = properties.getSync().zoneId();
+ LocalDateTime now = LocalDateTime.now(zoneId);
+ LocalDate syncDate = now.toLocalDate().minusDays(1);
+ long startTimestamp = syncDate.atStartOfDay(zoneId).toEpochSecond();
+ long endTimestamp = now.toLocalDate()
+ .atTime(properties.getSync().getIncrementalWindowEndHour(), 0)
+ .atZone(zoneId)
+ .toEpochSecond();
+
+ List instruments = instrumentDictionaryMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(InstrumentDictionary::getIsDeleted, 0)
+ .eq(InstrumentDictionary::getIsMain, 1));
+
+ for (InstrumentDictionary instrument : instruments) {
+ for (String period : properties.getSync().getPeriods()) {
+ syncOnePeriod(instrument, period, syncDate, startTimestamp, endTimestamp);
+ }
+ }
+ }
+
+ public void refreshMainContracts() {
+ LocalDateTime start = LocalDateTime.now();
+ int count = 0;
+ try {
+ List goodsItems = cnQuotationClient.listMainContracts();
+ instrumentDictionaryMapper.update(null, new LambdaUpdateWrapper()
+ .set(InstrumentDictionary::getIsMain, 0)
+ .eq(InstrumentDictionary::getIsDeleted, 0));
+ for (CnQuotationModels.GoodsItem item : goodsItems) {
+ if (item.mainContractCode() == null || item.mainContractCode().isBlank()) {
+ continue;
+ }
+ upsertInstrument(item);
+ count++;
+ }
+ saveLog("CONTRACT", null, null, LocalDate.now(), "SUCCESS", count, null, start);
+ } catch (Exception e) {
+ saveLog("CONTRACT", null, null, LocalDate.now(), "FAILED", count, e.getMessage(), start);
+ throw e;
+ }
+ }
+
+ private void syncOnePeriod(InstrumentDictionary instrument, String period, LocalDate syncDate,
+ long startTimestamp, long endTimestamp) {
+ LocalDateTime start = LocalDateTime.now();
+ int count = 0;
+ try {
+ List items = cnQuotationClient.getKChartByDate(
+ instrument.getExchangeId(), instrument.getContractCode(), period, startTimestamp, "after");
+ List records = items.stream()
+ .filter(item -> item.u() != null && item.u() >= startTimestamp && item.u() <= endTimestamp)
+ .map(item -> toRecord(instrument, item))
+ .filter(Objects::nonNull)
+ .toList();
+ if (!records.isEmpty()) {
+ count = kLineMapper.upsertBatch(tableResolver.resolve(period), records);
+ }
+ saveLog("KLINE", period, instrument.getContractCode(), syncDate, "SUCCESS", count, null, start);
+ } catch (Exception e) {
+ log.warn("同步行情失败: {} {}", instrument.getContractCode(), period, e);
+ saveLog("KLINE", period, instrument.getContractCode(), syncDate, "FAILED", count, e.getMessage(), start);
+ }
+ }
+
+ private void upsertInstrument(CnQuotationModels.GoodsItem item) {
+ String contractCode = item.mainContractCode();
+ int priceScale = PriceScaleConverter.scaleFromPrecision(item.decimalPrecision());
+ InstrumentDictionary existing = instrumentDictionaryMapper.selectOne(
+ new LambdaQueryWrapper()
+ .eq(InstrumentDictionary::getContractCode, contractCode)
+ .last("LIMIT 1"));
+ if (existing == null) {
+ InstrumentDictionary instrument = new InstrumentDictionary();
+ instrument.setExchangeId(item.excode());
+ instrument.setSymbol(firstNonBlank(item.productId(), item.goodsCode(), productPrefix(contractCode)));
+ instrument.setContractCode(contractCode);
+ instrument.setPriceScale(priceScale);
+ instrument.setIsMain(1);
+ instrumentDictionaryMapper.insert(instrument);
+ } else {
+ existing.setExchangeId(item.excode());
+ existing.setSymbol(firstNonBlank(item.productId(), item.goodsCode(), productPrefix(contractCode)));
+ existing.setPriceScale(priceScale);
+ existing.setIsMain(1);
+ existing.setIsDeleted(0);
+ instrumentDictionaryMapper.updateById(existing);
+ }
+ }
+
+ private KLineRecord toRecord(InstrumentDictionary instrument, CnQuotationModels.KChartItem item) {
+ try {
+ return KLineRecord.builder()
+ .instrumentId(instrument.getId())
+ .timestamp(item.u())
+ .open(PriceScaleConverter.toScaled(item.o(), instrument.getPriceScale()))
+ .high(PriceScaleConverter.toScaled(item.h(), instrument.getPriceScale()))
+ .low(PriceScaleConverter.toScaled(item.l(), instrument.getPriceScale()))
+ .close(PriceScaleConverter.toScaled(item.c(), instrument.getPriceScale()))
+ .volume(PriceScaleConverter.toLong(item.v()))
+ .turnover(PriceScaleConverter.toLong(item.a()))
+ .openInterest(PriceScaleConverter.toLong(item.i()))
+ .build();
+ } catch (Exception e) {
+ log.warn("忽略非法K线: instrument={} timestamp={} reason={}",
+ instrument.getContractCode(), item.u(), e.getMessage());
+ return null;
+ }
+ }
+
+ private void saveLog(String syncType, String period, String contractCode, LocalDate syncDate,
+ String status, int successCount, String errorMessage, LocalDateTime startTime) {
+ MarketDataSyncLog log = new MarketDataSyncLog();
+ log.setSyncType(syncType);
+ log.setPeriod(period);
+ log.setContractCode(contractCode);
+ log.setSyncDate(syncDate);
+ log.setStatus(status);
+ log.setSuccessCount(successCount);
+ log.setErrorMessage(errorMessage);
+ log.setStartTime(startTime);
+ log.setEndTime(LocalDateTime.now());
+ syncLogMapper.insert(log);
+ }
+
+ private String firstNonBlank(String... values) {
+ for (String value : values) {
+ if (value != null && !value.isBlank()) {
+ return value;
+ }
+ }
+ return "UNKNOWN";
+ }
+
+ private String productPrefix(String contractCode) {
+ if (contractCode == null || contractCode.isBlank()) {
+ return "UNKNOWN";
+ }
+ return contractCode.replaceAll("\\d+$", "");
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/signal/ATRSignalStrategy.java b/src/main/java/com/yangwale/backtestify/service/signal/ATRSignalStrategy.java
new file mode 100644
index 0000000..f25d7d5
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/signal/ATRSignalStrategy.java
@@ -0,0 +1,54 @@
+package com.yangwale.backtestify.service.signal;
+
+import com.yangwale.backtestify.model.dto.KLineData;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * ATR 信号策略(平均真实波幅)
+ * 本身不直接产生买卖信号,而是用于确认趋势强度。
+ * 买入信号:ATR 增大 + 收盘价 > 前一收盘价(波动扩张且向上)
+ * 卖出信号:ATR 增大 + 收盘价 < 前一收盘价(波动扩张且向下)
+ */
+@Component
+public class ATRSignalStrategy implements SignalStrategy {
+
+ @Override
+ public String name() {
+ return "ATR";
+ }
+
+ @Override
+ public boolean isBuySignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 1) return false;
+ var prev = history.get(idx - 1);
+ var prev2 = history.get(idx - 2);
+ boolean atrRising = gt(current.getIndicators().getAtr(), prev2.getIndicators().getAtr());
+ boolean priceRising = gt(current.getClose(), prev.getClose());
+ return atrRising && priceRising;
+ }
+
+ @Override
+ public boolean isSellSignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 1) return false;
+ var prev = history.get(idx - 1);
+ var prev2 = history.get(idx - 2);
+ boolean atrRising = gt(current.getIndicators().getAtr(), prev2.getIndicators().getAtr());
+ boolean priceFalling = lt(current.getClose(), prev.getClose());
+ return atrRising && priceFalling;
+ }
+
+ private int findIndex(KLineData current, List history) {
+ for (int i = 0; i < history.size(); i++) {
+ if (history.get(i).getTime().equals(current.getTime())) return i;
+ }
+ return -1;
+ }
+
+ private boolean gt(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) > 0; }
+ private boolean lt(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) < 0; }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/signal/BBISignalStrategy.java b/src/main/java/com/yangwale/backtestify/service/signal/BBISignalStrategy.java
new file mode 100644
index 0000000..6f889a0
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/signal/BBISignalStrategy.java
@@ -0,0 +1,51 @@
+package com.yangwale.backtestify.service.signal;
+
+import com.yangwale.backtestify.model.dto.KLineData;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * BBI 信号策略(多空指数)
+ * 买入信号:收盘价从下方上穿 BBI
+ * 卖出信号:收盘价从上方下穿 BBI
+ */
+@Component
+public class BBISignalStrategy implements SignalStrategy {
+
+ @Override
+ public String name() {
+ return "BBI";
+ }
+
+ @Override
+ public boolean isBuySignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var prev = history.get(idx - 1);
+ return le(prev.getClose(), prev.getIndicators().getBbi())
+ && gt(current.getClose(), current.getIndicators().getBbi());
+ }
+
+ @Override
+ public boolean isSellSignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var prev = history.get(idx - 1);
+ return ge(prev.getClose(), prev.getIndicators().getBbi())
+ && lt(current.getClose(), current.getIndicators().getBbi());
+ }
+
+ private int findIndex(KLineData current, List history) {
+ for (int i = 0; i < history.size(); i++) {
+ if (history.get(i).getTime().equals(current.getTime())) return i;
+ }
+ return -1;
+ }
+
+ private boolean gt(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) > 0; }
+ private boolean lt(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) < 0; }
+ private boolean ge(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) >= 0; }
+ private boolean le(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) <= 0; }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/signal/BOLLSignalStrategy.java b/src/main/java/com/yangwale/backtestify/service/signal/BOLLSignalStrategy.java
new file mode 100644
index 0000000..c0f63d7
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/signal/BOLLSignalStrategy.java
@@ -0,0 +1,52 @@
+package com.yangwale.backtestify.service.signal;
+
+import com.yangwale.backtestify.model.dto.KLineData;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * BOLL 信号策略
+ * 买入信号:收盘价从下方突破中轨
+ * 卖出信号:收盘价从上方跌破中轨
+ */
+@Component
+public class BOLLSignalStrategy implements SignalStrategy {
+
+ @Override
+ public String name() {
+ return "BOLL";
+ }
+
+ @Override
+ public boolean isBuySignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var prev = history.get(idx - 1);
+ // 前一根收盘价 <= 中轨,当前收盘价 > 中轨
+ return le(prev.getClose(), prev.getIndicators().getBollMid())
+ && gt(current.getClose(), current.getIndicators().getBollMid());
+ }
+
+ @Override
+ public boolean isSellSignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var prev = history.get(idx - 1);
+ return ge(prev.getClose(), prev.getIndicators().getBollMid())
+ && lt(current.getClose(), current.getIndicators().getBollMid());
+ }
+
+ private int findIndex(KLineData current, List history) {
+ for (int i = 0; i < history.size(); i++) {
+ if (history.get(i).getTime().equals(current.getTime())) return i;
+ }
+ return -1;
+ }
+
+ private boolean gt(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) > 0; }
+ private boolean lt(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) < 0; }
+ private boolean ge(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) >= 0; }
+ private boolean le(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) <= 0; }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/signal/CCISignalStrategy.java b/src/main/java/com/yangwale/backtestify/service/signal/CCISignalStrategy.java
new file mode 100644
index 0000000..2dccfe0
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/signal/CCISignalStrategy.java
@@ -0,0 +1,54 @@
+package com.yangwale.backtestify.service.signal;
+
+import com.yangwale.backtestify.model.dto.KLineData;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * CCI 信号策略
+ * 买入信号:CCI 从 -100 以下上穿 -100(超卖反弹)
+ * 卖出信号:CCI 从 +100 以上下穿 +100(超买回落)
+ */
+@Component
+public class CCISignalStrategy implements SignalStrategy {
+
+ private static final BigDecimal LOWER = new BigDecimal("-100");
+ private static final BigDecimal UPPER = new BigDecimal("100");
+
+ @Override
+ public String name() {
+ return "CCI";
+ }
+
+ @Override
+ public boolean isBuySignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var prevCci = history.get(idx - 1).getIndicators().getCci();
+ var curCci = current.getIndicators().getCci();
+ return le(prevCci, LOWER) && gt(curCci, LOWER);
+ }
+
+ @Override
+ public boolean isSellSignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var prevCci = history.get(idx - 1).getIndicators().getCci();
+ var curCci = current.getIndicators().getCci();
+ return ge(prevCci, UPPER) && lt(curCci, UPPER);
+ }
+
+ private int findIndex(KLineData current, List history) {
+ for (int i = 0; i < history.size(); i++) {
+ if (history.get(i).getTime().equals(current.getTime())) return i;
+ }
+ return -1;
+ }
+
+ private boolean gt(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) > 0; }
+ private boolean lt(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) < 0; }
+ private boolean ge(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) >= 0; }
+ private boolean le(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) <= 0; }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/signal/CJLSignalStrategy.java b/src/main/java/com/yangwale/backtestify/service/signal/CJLSignalStrategy.java
new file mode 100644
index 0000000..3436541
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/signal/CJLSignalStrategy.java
@@ -0,0 +1,60 @@
+package com.yangwale.backtestify.service.signal;
+
+import com.yangwale.backtestify.model.dto.KLineData;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * CJL 信号策略(成交量)
+ * 买入信号:成交量放大(>前N日均量的1.5倍)且价格上涨
+ * 卖出信号:成交量放大(>前N日均量的1.5倍)且价格下跌
+ */
+@Component
+public class CJLSignalStrategy implements SignalStrategy {
+
+ @Override
+ public String name() {
+ return "CJL";
+ }
+
+ @Override
+ public boolean isBuySignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 4) return false;
+ BigDecimal avgVol = calcAvgVolume(history, idx);
+ if (avgVol.compareTo(BigDecimal.ZERO) == 0) return false;
+ boolean volumeSpike = current.getVolume().compareTo(avgVol.multiply(new BigDecimal("1.5"))) > 0;
+ var prev = history.get(idx - 1);
+ return volumeSpike && current.getClose().compareTo(prev.getClose()) > 0;
+ }
+
+ @Override
+ public boolean isSellSignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 4) return false;
+ BigDecimal avgVol = calcAvgVolume(history, idx);
+ if (avgVol.compareTo(BigDecimal.ZERO) == 0) return false;
+ boolean volumeSpike = current.getVolume().compareTo(avgVol.multiply(new BigDecimal("1.5"))) > 0;
+ var prev = history.get(idx - 1);
+ return volumeSpike && current.getClose().compareTo(prev.getClose()) < 0;
+ }
+
+ private BigDecimal calcAvgVolume(List history, int idx) {
+ BigDecimal sum = BigDecimal.ZERO;
+ int count = 0;
+ for (int i = Math.max(0, idx - 5); i < idx; i++) {
+ sum = sum.add(history.get(i).getVolume());
+ count++;
+ }
+ return count > 0 ? sum.divide(BigDecimal.valueOf(count), 2, java.math.RoundingMode.HALF_UP) : BigDecimal.ZERO;
+ }
+
+ private int findIndex(KLineData current, List history) {
+ for (int i = 0; i < history.size(); i++) {
+ if (history.get(i).getTime().equals(current.getTime())) return i;
+ }
+ return -1;
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/signal/DMISignalStrategy.java b/src/main/java/com/yangwale/backtestify/service/signal/DMISignalStrategy.java
new file mode 100644
index 0000000..280ab8e
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/signal/DMISignalStrategy.java
@@ -0,0 +1,53 @@
+package com.yangwale.backtestify.service.signal;
+
+import com.yangwale.backtestify.model.dto.KLineData;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * DMI 信号策略
+ * 买入信号:PDI 上穿 MDI,且 ADX > 20
+ * 卖出信号:MDI 上穿 PDI,且 ADX > 20
+ */
+@Component
+public class DMISignalStrategy implements SignalStrategy {
+
+ @Override
+ public String name() {
+ return "DMI";
+ }
+
+ @Override
+ public boolean isBuySignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var prev = history.get(idx - 1).getIndicators();
+ var cur = current.getIndicators();
+ return le(prev.getDmiPdi(), prev.getDmiMdi())
+ && gt(cur.getDmiPdi(), cur.getDmiMdi())
+ && gt(cur.getDmiAdx(), new BigDecimal("20"));
+ }
+
+ @Override
+ public boolean isSellSignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var prev = history.get(idx - 1).getIndicators();
+ var cur = current.getIndicators();
+ return le(prev.getDmiMdi(), prev.getDmiPdi())
+ && gt(cur.getDmiMdi(), cur.getDmiPdi())
+ && gt(cur.getDmiAdx(), new BigDecimal("20"));
+ }
+
+ private int findIndex(KLineData current, List history) {
+ for (int i = 0; i < history.size(); i++) {
+ if (history.get(i).getTime().equals(current.getTime())) return i;
+ }
+ return -1;
+ }
+
+ private boolean gt(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) > 0; }
+ private boolean le(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) <= 0; }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/signal/DonchianSignalStrategy.java b/src/main/java/com/yangwale/backtestify/service/signal/DonchianSignalStrategy.java
new file mode 100644
index 0000000..fe68ba0
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/signal/DonchianSignalStrategy.java
@@ -0,0 +1,53 @@
+package com.yangwale.backtestify.service.signal;
+
+import com.yangwale.backtestify.model.dto.KLineData;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * Donchian 通道信号策略
+ * 买入信号:收盘价突破上轨(N日最高价)
+ * 卖出信号:收盘价跌破下轨(N日最低价)
+ */
+@Component
+public class DonchianSignalStrategy implements SignalStrategy {
+
+ @Override
+ public String name() {
+ return "DONCHIAN";
+ }
+
+ @Override
+ public boolean isBuySignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var prev = history.get(idx - 1);
+ var curInd = current.getIndicators();
+ return le(prev.getClose(), prev.getIndicators().getDonchianUpper())
+ && gt(current.getClose(), curInd.getDonchianUpper());
+ }
+
+ @Override
+ public boolean isSellSignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var prev = history.get(idx - 1);
+ var curInd = current.getIndicators();
+ return ge(prev.getClose(), prev.getIndicators().getDonchianLower())
+ && lt(current.getClose(), curInd.getDonchianLower());
+ }
+
+ private int findIndex(KLineData current, List history) {
+ for (int i = 0; i < history.size(); i++) {
+ if (history.get(i).getTime().equals(current.getTime())) return i;
+ }
+ return -1;
+ }
+
+ private boolean gt(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) > 0; }
+ private boolean lt(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) < 0; }
+ private boolean ge(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) >= 0; }
+ private boolean le(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) <= 0; }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/signal/KDJSignalStrategy.java b/src/main/java/com/yangwale/backtestify/service/signal/KDJSignalStrategy.java
new file mode 100644
index 0000000..8ffeea1
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/signal/KDJSignalStrategy.java
@@ -0,0 +1,56 @@
+package com.yangwale.backtestify.service.signal;
+
+import com.yangwale.backtestify.model.dto.KLineData;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * KDJ 信号策略
+ * 买入信号:K 上穿 D(金叉),且 K/D < 30(低位金叉更可靠)
+ * 卖出信号:K 下穿 D(死叉),且 K/D > 70(高位死叉更可靠)
+ */
+@Component
+public class KDJSignalStrategy implements SignalStrategy {
+
+ @Override
+ public String name() {
+ return "KDJ";
+ }
+
+ @Override
+ public boolean isBuySignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var prev = history.get(idx - 1).getIndicators();
+ var cur = current.getIndicators();
+ // K 上穿 D:前一根 K <= D,当前 K > D
+ return le(prev.getKdjK(), prev.getKdjD())
+ && gt(cur.getKdjK(), cur.getKdjD())
+ && lt(cur.getKdjK(), java.math.BigDecimal.valueOf(40)); // 低位金叉
+ }
+
+ @Override
+ public boolean isSellSignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var prev = history.get(idx - 1).getIndicators();
+ var cur = current.getIndicators();
+ // K 下穿 D:前一根 K >= D,当前 K < D
+ return ge(prev.getKdjK(), prev.getKdjD())
+ && lt(cur.getKdjK(), cur.getKdjD())
+ && gt(cur.getKdjK(), java.math.BigDecimal.valueOf(60)); // 高位死叉
+ }
+
+ private int findIndex(KLineData current, List history) {
+ for (int i = 0; i < history.size(); i++) {
+ if (history.get(i).getTime().equals(current.getTime())) return i;
+ }
+ return -1;
+ }
+
+ private boolean gt(java.math.BigDecimal a, java.math.BigDecimal b) { return a != null && b != null && a.compareTo(b) > 0; }
+ private boolean lt(java.math.BigDecimal a, java.math.BigDecimal b) { return a != null && b != null && a.compareTo(b) < 0; }
+ private boolean ge(java.math.BigDecimal a, java.math.BigDecimal b) { return a != null && b != null && a.compareTo(b) >= 0; }
+ private boolean le(java.math.BigDecimal a, java.math.BigDecimal b) { return a != null && b != null && a.compareTo(b) <= 0; }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/signal/MACDSignalStrategy.java b/src/main/java/com/yangwale/backtestify/service/signal/MACDSignalStrategy.java
new file mode 100644
index 0000000..1e9882f
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/signal/MACDSignalStrategy.java
@@ -0,0 +1,63 @@
+package com.yangwale.backtestify.service.signal;
+
+import com.yangwale.backtestify.model.dto.KLineData;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * MACD 信号策略
+ * 买入信号:DIF 上穿 DEA(金叉)
+ * 卖出信号:DIF 下穿 DEA(死叉)
+ */
+@Component
+public class MACDSignalStrategy implements SignalStrategy {
+
+ @Override
+ public String name() {
+ return "MACD";
+ }
+
+ @Override
+ public boolean isBuySignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ KLineData prev = history.get(idx - 1);
+ // 上一根 DIF <= DEA 且 当前 DIF > DEA
+ return compareLe(prev.getIndicators().getMacdDif(), prev.getIndicators().getMacdDea())
+ && compareGt(current.getIndicators().getMacdDif(), current.getIndicators().getMacdDea());
+ }
+
+ @Override
+ public boolean isSellSignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ KLineData prev = history.get(idx - 1);
+ // 上一根 DIF >= DEA 且 当前 DIF < DEA
+ return compareGe(prev.getIndicators().getMacdDif(), prev.getIndicators().getMacdDea())
+ && compareLt(current.getIndicators().getMacdDif(), current.getIndicators().getMacdDea());
+ }
+
+ private int findIndex(KLineData current, List history) {
+ for (int i = 0; i < history.size(); i++) {
+ if (history.get(i).getTime().equals(current.getTime())) return i;
+ }
+ return -1;
+ }
+
+ private boolean compareGt(java.math.BigDecimal a, java.math.BigDecimal b) {
+ return a != null && b != null && a.compareTo(b) > 0;
+ }
+
+ private boolean compareLt(java.math.BigDecimal a, java.math.BigDecimal b) {
+ return a != null && b != null && a.compareTo(b) < 0;
+ }
+
+ private boolean compareGe(java.math.BigDecimal a, java.math.BigDecimal b) {
+ return a != null && b != null && a.compareTo(b) >= 0;
+ }
+
+ private boolean compareLe(java.math.BigDecimal a, java.math.BigDecimal b) {
+ return a != null && b != null && a.compareTo(b) <= 0;
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/signal/MASignalStrategy.java b/src/main/java/com/yangwale/backtestify/service/signal/MASignalStrategy.java
new file mode 100644
index 0000000..36dada0
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/signal/MASignalStrategy.java
@@ -0,0 +1,51 @@
+package com.yangwale.backtestify.service.signal;
+
+import com.yangwale.backtestify.model.dto.KLineData;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * MA 信号策略(双均线交叉)
+ * 买入信号:MA5 上穿 MA20(短期均线上穿长期均线,金叉)
+ * 卖出信号:MA5 下穿 MA20(短期均线下穿长期均线,死叉)
+ */
+@Component
+public class MASignalStrategy implements SignalStrategy {
+
+ @Override
+ public String name() {
+ return "MA";
+ }
+
+ @Override
+ public boolean isBuySignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var prev = history.get(idx - 1).getIndicators();
+ var cur = current.getIndicators();
+ return le(prev.getMa5(), prev.getMa20()) && gt(cur.getMa5(), cur.getMa20());
+ }
+
+ @Override
+ public boolean isSellSignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var prev = history.get(idx - 1).getIndicators();
+ var cur = current.getIndicators();
+ return ge(prev.getMa5(), prev.getMa20()) && lt(cur.getMa5(), cur.getMa20());
+ }
+
+ private int findIndex(KLineData current, List history) {
+ for (int i = 0; i < history.size(); i++) {
+ if (history.get(i).getTime().equals(current.getTime())) return i;
+ }
+ return -1;
+ }
+
+ private boolean gt(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) > 0; }
+ private boolean lt(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) < 0; }
+ private boolean ge(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) >= 0; }
+ private boolean le(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) <= 0; }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/signal/RSISignalStrategy.java b/src/main/java/com/yangwale/backtestify/service/signal/RSISignalStrategy.java
new file mode 100644
index 0000000..9842339
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/signal/RSISignalStrategy.java
@@ -0,0 +1,54 @@
+package com.yangwale.backtestify.service.signal;
+
+import com.yangwale.backtestify.model.dto.KLineData;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * RSI 信号策略(使用 RSI14)
+ * 买入信号:RSI14 从 30 以下上穿 30(超卖反弹)
+ * 卖出信号:RSI14 从 70 以上下穿 70(超买回落)
+ */
+@Component
+public class RSISignalStrategy implements SignalStrategy {
+
+ private static final BigDecimal OVERSOLD = new BigDecimal("30");
+ private static final BigDecimal OVERBOUGHT = new BigDecimal("70");
+
+ @Override
+ public String name() {
+ return "RSI";
+ }
+
+ @Override
+ public boolean isBuySignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var prevRsi = history.get(idx - 1).getIndicators().getRsi14();
+ var curRsi = current.getIndicators().getRsi14();
+ return le(prevRsi, OVERSOLD) && gt(curRsi, OVERSOLD);
+ }
+
+ @Override
+ public boolean isSellSignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var prevRsi = history.get(idx - 1).getIndicators().getRsi14();
+ var curRsi = current.getIndicators().getRsi14();
+ return ge(prevRsi, OVERBOUGHT) && lt(curRsi, OVERBOUGHT);
+ }
+
+ private int findIndex(KLineData current, List history) {
+ for (int i = 0; i < history.size(); i++) {
+ if (history.get(i).getTime().equals(current.getTime())) return i;
+ }
+ return -1;
+ }
+
+ private boolean gt(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) > 0; }
+ private boolean lt(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) < 0; }
+ private boolean ge(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) >= 0; }
+ private boolean le(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) <= 0; }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/signal/SARSignalStrategy.java b/src/main/java/com/yangwale/backtestify/service/signal/SARSignalStrategy.java
new file mode 100644
index 0000000..28840d4
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/signal/SARSignalStrategy.java
@@ -0,0 +1,51 @@
+package com.yangwale.backtestify.service.signal;
+
+import com.yangwale.backtestify.model.dto.KLineData;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * SAR 信号策略(抛物线转向)
+ * 买入信号:收盘价从下方上穿 SAR(趋势转涨)
+ * 卖出信号:收盘价从上方下穿 SAR(趋势转跌)
+ */
+@Component
+public class SARSignalStrategy implements SignalStrategy {
+
+ @Override
+ public String name() {
+ return "SAR";
+ }
+
+ @Override
+ public boolean isBuySignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var prev = history.get(idx - 1);
+ return le(prev.getClose(), prev.getIndicators().getSar())
+ && gt(current.getClose(), current.getIndicators().getSar());
+ }
+
+ @Override
+ public boolean isSellSignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var prev = history.get(idx - 1);
+ return ge(prev.getClose(), prev.getIndicators().getSar())
+ && lt(current.getClose(), current.getIndicators().getSar());
+ }
+
+ private int findIndex(KLineData current, List history) {
+ for (int i = 0; i < history.size(); i++) {
+ if (history.get(i).getTime().equals(current.getTime())) return i;
+ }
+ return -1;
+ }
+
+ private boolean gt(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) > 0; }
+ private boolean lt(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) < 0; }
+ private boolean ge(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) >= 0; }
+ private boolean le(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) <= 0; }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/signal/SignalStrategy.java b/src/main/java/com/yangwale/backtestify/service/signal/SignalStrategy.java
new file mode 100644
index 0000000..5f64124
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/signal/SignalStrategy.java
@@ -0,0 +1,25 @@
+package com.yangwale.backtestify.service.signal;
+
+import com.yangwale.backtestify.model.dto.KLineData;
+
+import java.util.List;
+
+/**
+ * 信号判断策略接口
+ *
+ * 基于外部提供的指标值,判断当前K线是否产生买入/卖出信号。
+ * 采用策略模式:每种技术指标一个实现类。
+ *
+ * 对于多指标共振场景:所有选中指标的 isBuySignal() 或 isSellSignal() 同时返回 true 才触发交易。
+ */
+public interface SignalStrategy {
+
+ /** 指标名称,如 "MACD"、"KDJ" */
+ String name();
+
+ /** 当前K线是否产生买入信号 */
+ boolean isBuySignal(KLineData current, List history);
+
+ /** 当前K线是否产生卖出信号 */
+ boolean isSellSignal(KLineData current, List history);
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/signal/SignalStrategyFactory.java b/src/main/java/com/yangwale/backtestify/service/signal/SignalStrategyFactory.java
new file mode 100644
index 0000000..b1a01aa
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/signal/SignalStrategyFactory.java
@@ -0,0 +1,52 @@
+package com.yangwale.backtestify.service.signal;
+
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 信号策略工厂
+ * 根据指标名称获取对应的 SignalStrategy 实现
+ */
+@Component
+public class SignalStrategyFactory {
+
+ private final Map strategyMap;
+
+ public SignalStrategyFactory(List strategies) {
+ this.strategyMap = strategies.stream()
+ .collect(Collectors.toMap(
+ s -> s.name().toUpperCase(),
+ s -> s
+ ));
+ }
+
+ /**
+ * 根据指标名称获取策略
+ */
+ public SignalStrategy get(String indicatorName) {
+ SignalStrategy strategy = strategyMap.get(indicatorName.toUpperCase());
+ if (strategy == null) {
+ throw new IllegalArgumentException("不支持的技术指标: " + indicatorName);
+ }
+ return strategy;
+ }
+
+ /**
+ * 批量获取策略
+ */
+ public List get(List indicatorNames) {
+ return indicatorNames.stream()
+ .map(this::get)
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * 获取所有支持的指标名称
+ */
+ public List getAllIndicatorNames() {
+ return strategyMap.keySet().stream().sorted().collect(Collectors.toList());
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/signal/VolAmountSignalStrategy.java b/src/main/java/com/yangwale/backtestify/service/signal/VolAmountSignalStrategy.java
new file mode 100644
index 0000000..b9d0bd1
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/signal/VolAmountSignalStrategy.java
@@ -0,0 +1,60 @@
+package com.yangwale.backtestify.service.signal;
+
+import com.yangwale.backtestify.model.dto.KLineData;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * VOL_AMOUNT 信号策略(成交额)
+ * 买入信号:成交额放大(>前N日均成交额的1.5倍)且价格上涨
+ * 卖出信号:成交额放大(>前N日均成交额的1.5倍)且价格下跌
+ */
+@Component
+public class VolAmountSignalStrategy implements SignalStrategy {
+
+ @Override
+ public String name() {
+ return "VOL_AMOUNT";
+ }
+
+ @Override
+ public boolean isBuySignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 4) return false;
+ BigDecimal avgAmount = calcAvgAmount(history, idx);
+ if (avgAmount.compareTo(BigDecimal.ZERO) == 0) return false;
+ boolean amountSpike = current.getAmount().compareTo(avgAmount.multiply(new BigDecimal("1.5"))) > 0;
+ var prev = history.get(idx - 1);
+ return amountSpike && current.getClose().compareTo(prev.getClose()) > 0;
+ }
+
+ @Override
+ public boolean isSellSignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 4) return false;
+ BigDecimal avgAmount = calcAvgAmount(history, idx);
+ if (avgAmount.compareTo(BigDecimal.ZERO) == 0) return false;
+ boolean amountSpike = current.getAmount().compareTo(avgAmount.multiply(new BigDecimal("1.5"))) > 0;
+ var prev = history.get(idx - 1);
+ return amountSpike && current.getClose().compareTo(prev.getClose()) < 0;
+ }
+
+ private BigDecimal calcAvgAmount(List history, int idx) {
+ BigDecimal sum = BigDecimal.ZERO;
+ int count = 0;
+ for (int i = Math.max(0, idx - 5); i < idx; i++) {
+ sum = sum.add(history.get(i).getAmount());
+ count++;
+ }
+ return count > 0 ? sum.divide(BigDecimal.valueOf(count), 2, java.math.RoundingMode.HALF_UP) : BigDecimal.ZERO;
+ }
+
+ private int findIndex(KLineData current, List history) {
+ for (int i = 0; i < history.size(); i++) {
+ if (history.get(i).getTime().equals(current.getTime())) return i;
+ }
+ return -1;
+ }
+}
diff --git a/src/main/java/com/yangwale/backtestify/service/signal/WRSignalStrategy.java b/src/main/java/com/yangwale/backtestify/service/signal/WRSignalStrategy.java
new file mode 100644
index 0000000..dfb0be6
--- /dev/null
+++ b/src/main/java/com/yangwale/backtestify/service/signal/WRSignalStrategy.java
@@ -0,0 +1,57 @@
+package com.yangwale.backtestify.service.signal;
+
+import com.yangwale.backtestify.model.dto.KLineData;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * WR 信号策略(威廉指标)
+ * 买入信号:WR 从 80 以上下穿 80(超卖反弹)
+ * 卖出信号:WR 从 20 以下上穿 20(超买回落)
+ * 注:WR 值域 0-100,数值越大越超卖
+ */
+@Component
+public class WRSignalStrategy implements SignalStrategy {
+
+ private static final BigDecimal OVERSOLD = new BigDecimal("80");
+ private static final BigDecimal OVERBOUGHT = new BigDecimal("20");
+
+ @Override
+ public String name() {
+ return "WR";
+ }
+
+ @Override
+ public boolean isBuySignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var cur = current.getIndicators().getWr();
+ var prev = history.get(idx - 1).getIndicators().getWr();
+ // WR 从高位回落穿过80 → 超卖结束,买入
+ return ge(prev, OVERSOLD) && lt(cur, OVERSOLD);
+ }
+
+ @Override
+ public boolean isSellSignal(KLineData current, List history) {
+ int idx = findIndex(current, history);
+ if (idx <= 0) return false;
+ var cur = current.getIndicators().getWr();
+ var prev = history.get(idx - 1).getIndicators().getWr();
+ // WR 从低位上升穿过20 → 超买结束,卖出
+ return le(prev, OVERBOUGHT) && gt(cur, OVERBOUGHT);
+ }
+
+ private int findIndex(KLineData current, List history) {
+ for (int i = 0; i < history.size(); i++) {
+ if (history.get(i).getTime().equals(current.getTime())) return i;
+ }
+ return -1;
+ }
+
+ private boolean gt(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) > 0; }
+ private boolean lt(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) < 0; }
+ private boolean ge(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) >= 0; }
+ private boolean le(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) <= 0; }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
new file mode 100644
index 0000000..5ddcaa5
--- /dev/null
+++ b/src/main/resources/application.yml
@@ -0,0 +1,73 @@
+server:
+ port: 8080
+
+spring:
+ application:
+ name: backtestify
+
+ datasource:
+ type: com.alibaba.druid.pool.DruidDataSource
+ driver-class-name: com.mysql.cj.jdbc.Driver
+ url: jdbc:mysql://192.168.2.5:3306/backtestify?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
+ username: root
+ password: root
+ druid:
+ initial-size: 5
+ min-idle: 5
+ max-active: 20
+ max-wait: 60000
+ validation-query: SELECT 1
+ test-while-idle: true
+ test-on-borrow: false
+ test-on-return: false
+
+ data:
+ redis:
+ host: 192.168.2.5
+ port: 6379
+ database: 0
+ timeout: 3000ms
+ lettuce:
+ pool:
+ max-active: 8
+ max-idle: 8
+ min-idle: 0
+ max-wait: -1ms
+
+mybatis-plus:
+ mapper-locations: classpath*:/mapper/**/*.xml
+ type-aliases-package: com.yangwale.backtestify.entity
+ global-config:
+ db-config:
+ id-type: auto
+ logic-delete-field: isDeleted
+ logic-delete-value: 1
+ logic-not-delete-value: 0
+ configuration:
+ map-underscore-to-camel-case: true
+ log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
+
+# 回测系统业务配置
+backtest:
+ initial-capital: 1000000 # 初始资金 100万
+ margin-ratio: 0.10 # 保证金比例 10%
+ fee-rate: 0.00005 # 单边手续费 0.005%
+ risk-free-rate: 0.025 # 无风险利率 2.5%
+
+# 真实行情数据源配置
+market-data:
+ provider: fake # fake/mysql
+ quotation:
+ base-url: https://slzqapi.sxslqhsh.com/mobile-api/cn/quotation
+ timeout-seconds: 15
+ sync:
+ enabled: false # 生产启用后每日6点同步主力合约前一日增量
+ cron: "0 0 6 * * ?"
+ zone: Asia/Shanghai
+ incremental-window-end-hour: 6
+ periods: ["1m", "5m", "15m", "30m", "1h", "4h", "1d", "1w"]
+
+# 日志
+logging:
+ level:
+ com.yangwale.backtestify: debug
diff --git a/src/main/resources/db/init.sql b/src/main/resources/db/init.sql
new file mode 100644
index 0000000..b2f8a3a
--- /dev/null
+++ b/src/main/resources/db/init.sql
@@ -0,0 +1,205 @@
+-- ===================================================
+-- 期货策略回测系统 数据库初始化脚本
+-- ===================================================
+
+CREATE DATABASE IF NOT EXISTS backtestify
+ DEFAULT CHARACTER SET utf8mb4
+ DEFAULT COLLATE utf8mb4_unicode_ci;
+
+USE backtestify;
+
+-- ---------------------------------------------------
+-- 合约字典表(不分区)
+-- ---------------------------------------------------
+DROP TABLE IF EXISTS t_instrument_dictionary;
+CREATE TABLE t_instrument_dictionary (
+ id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '合约自增ID',
+ exchange_id VARCHAR(16) DEFAULT NULL COMMENT '交易所代码 (如 SHFE)',
+ symbol VARCHAR(10) NOT NULL COMMENT '期货品种 (如 rb)',
+ contract_code VARCHAR(20) NOT NULL COMMENT '具体合约代码 (如 rb2610)',
+ price_scale INT UNSIGNED NOT NULL DEFAULT 100 COMMENT '价格放大倍数 (100表示保留2位小数)',
+ is_main TINYINT NOT NULL DEFAULT 0 COMMENT '是否当前主力合约: 0-否, 1-是',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除: 0-未删除, 1-已删除',
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_contract (contract_code),
+ KEY idx_symbol_main (symbol, is_main),
+ KEY idx_exchange_contract (exchange_id, contract_code)
+) ENGINE=InnoDB DEFAULT CHARSET=ascii COMMENT='期货合约字典表';
+
+-- ---------------------------------------------------
+-- K线数据表(每种周期一张表,按年分区;真实行情不包含3m)
+-- ---------------------------------------------------
+DROP TABLE IF EXISTS t_kline_1m;
+CREATE TABLE t_kline_1m (
+ instrument_id SMALLINT UNSIGNED NOT NULL COMMENT '合约字典ID',
+ timestamp INT UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级,每根K线起始点)',
+ open INT NOT NULL COMMENT '开盘价 (实际价格 * price_scale)',
+ high INT NOT NULL COMMENT '最高价 (实际价格 * price_scale)',
+ low INT NOT NULL COMMENT '最低价 (实际价格 * price_scale)',
+ close INT NOT NULL COMMENT '收盘价 (实际价格 * price_scale)',
+ volume INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交量 (手)',
+ turnover BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交额 (元)',
+ open_interest INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '持仓量',
+ PRIMARY KEY (instrument_id, timestamp)
+) ENGINE=InnoDB DEFAULT CHARSET=ascii COMMENT='1分钟K线数据表'
+PARTITION BY RANGE (timestamp) (
+ PARTITION p2020 VALUES LESS THAN (1609459200),
+ PARTITION p2021 VALUES LESS THAN (1640995200),
+ PARTITION p2022 VALUES LESS THAN (1672531200),
+ PARTITION p2023 VALUES LESS THAN (1704067200),
+ PARTITION p2024 VALUES LESS THAN (1735689600),
+ PARTITION p2025 VALUES LESS THAN (1767225600),
+ PARTITION p2026 VALUES LESS THAN (1798761600),
+ PARTITION p2027 VALUES LESS THAN (1830297600),
+ PARTITION p2028 VALUES LESS THAN (1861920000),
+ PARTITION p2029 VALUES LESS THAN (1893456000),
+ PARTITION p2030 VALUES LESS THAN (1924992000),
+ PARTITION p2031 VALUES LESS THAN (1956528000),
+ PARTITION pmax VALUES LESS THAN MAXVALUE
+);
+
+DROP TABLE IF EXISTS t_kline_5m;
+CREATE TABLE t_kline_5m LIKE t_kline_1m;
+ALTER TABLE t_kline_5m COMMENT='5分钟K线数据表';
+
+DROP TABLE IF EXISTS t_kline_15m;
+CREATE TABLE t_kline_15m LIKE t_kline_1m;
+ALTER TABLE t_kline_15m COMMENT='15分钟K线数据表';
+
+DROP TABLE IF EXISTS t_kline_30m;
+CREATE TABLE t_kline_30m LIKE t_kline_1m;
+ALTER TABLE t_kline_30m COMMENT='30分钟K线数据表';
+
+DROP TABLE IF EXISTS t_kline_1h;
+CREATE TABLE t_kline_1h LIKE t_kline_1m;
+ALTER TABLE t_kline_1h COMMENT='1小时K线数据表';
+
+DROP TABLE IF EXISTS t_kline_4h;
+CREATE TABLE t_kline_4h LIKE t_kline_1m;
+ALTER TABLE t_kline_4h COMMENT='4小时K线数据表';
+
+DROP TABLE IF EXISTS t_kline_1d;
+CREATE TABLE t_kline_1d LIKE t_kline_1m;
+ALTER TABLE t_kline_1d COMMENT='日K线数据表';
+
+DROP TABLE IF EXISTS t_kline_1w;
+CREATE TABLE t_kline_1w LIKE t_kline_1m;
+ALTER TABLE t_kline_1w COMMENT='周K线数据表';
+
+-- ---------------------------------------------------
+-- 行情同步日志表
+-- ---------------------------------------------------
+DROP TABLE IF EXISTS t_market_data_sync_log;
+CREATE TABLE t_market_data_sync_log (
+ id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ sync_type VARCHAR(32) NOT NULL COMMENT '同步类型: CONTRACT/KLINE',
+ period VARCHAR(10) DEFAULT NULL COMMENT 'K线周期',
+ contract_code VARCHAR(20) DEFAULT NULL COMMENT '合约代码',
+ sync_date DATE DEFAULT NULL COMMENT '同步日期',
+ status VARCHAR(16) NOT NULL COMMENT '状态: SUCCESS/FAILED',
+ success_count INT NOT NULL DEFAULT 0 COMMENT '成功条数',
+ error_message TEXT DEFAULT NULL COMMENT '错误信息',
+ start_time DATETIME NOT NULL COMMENT '开始时间',
+ end_time DATETIME DEFAULT NULL COMMENT '结束时间',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ PRIMARY KEY (id),
+ KEY idx_sync_date (sync_date),
+ KEY idx_contract_period (contract_code, period),
+ KEY idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行情同步日志表';
+
+-- ---------------------------------------------------
+-- 策略配置表
+-- ---------------------------------------------------
+DROP TABLE IF EXISTS bt_strategy_config;
+CREATE TABLE bt_strategy_config (
+ id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ user_id BIGINT NOT NULL COMMENT '用户ID',
+ contract_code VARCHAR(32) NOT NULL COMMENT '合约代码',
+ contract_name VARCHAR(64) NOT NULL COMMENT '合约名称',
+ direction VARCHAR(10) NOT NULL COMMENT '交易方向: LONG/SHORT',
+ kline_period VARCHAR(10) NOT NULL COMMENT 'K线周期: 1m/3m/5m/15m/30m/1h/4h/1d/1w',
+ indicators JSON NOT NULL COMMENT '技术指标列表, 如["MACD","KDJ"]',
+ open_volume INT NOT NULL DEFAULT 1 COMMENT '开仓数量',
+ volume_unit VARCHAR(10) NOT NULL DEFAULT 'LOT' COMMENT '数量单位: LOT/POSITION',
+ stop_loss_value DECIMAL(18,4) DEFAULT NULL COMMENT '止损值',
+ stop_loss_unit VARCHAR(10) DEFAULT NULL COMMENT '止损单位: TICK/PERCENT',
+ take_profit_value DECIMAL(18,4) DEFAULT NULL COMMENT '止盈值',
+ take_profit_unit VARCHAR(10) DEFAULT NULL COMMENT '止盈单位: TICK/PERCENT',
+ backtest_period VARCHAR(10) NOT NULL COMMENT '回测区间: 1m/3m/6m/1y',
+ status TINYINT NOT NULL DEFAULT 0 COMMENT '状态: 0-已保存, 1-已启用信号',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除: 0-未删除, 1-已删除',
+ PRIMARY KEY (id),
+ INDEX idx_user_id (user_id),
+ INDEX idx_contract_code (contract_code),
+ INDEX idx_create_time (create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='策略配置表';
+
+-- ---------------------------------------------------
+-- 回测结果表
+-- ---------------------------------------------------
+DROP TABLE IF EXISTS bt_strategy_result;
+CREATE TABLE bt_strategy_result (
+ id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ strategy_id BIGINT NOT NULL COMMENT '关联策略ID',
+ initial_capital DECIMAL(18,2) NOT NULL COMMENT '初始资金',
+ final_capital DECIMAL(18,2) NOT NULL COMMENT '期末总资产',
+ max_equity DECIMAL(18,2) NOT NULL COMMENT '回测期间最高净值',
+ min_equity DECIMAL(18,2) NOT NULL COMMENT '回测期间最低净值',
+ total_yield DECIMAL(10,4) NOT NULL COMMENT '总收益率(%)',
+ profit_amount DECIMAL(18,2) NOT NULL COMMENT '收益金额',
+ annualized_yield DECIMAL(10,4) NOT NULL COMMENT '年化收益率(%)',
+ trade_count INT NOT NULL DEFAULT 0 COMMENT '交易次数',
+ max_drawdown DECIMAL(10,4) NOT NULL COMMENT '最大回撤(%)',
+ sharpe_ratio DECIMAL(10,4) NOT NULL COMMENT '夏普比率',
+ win_rate DECIMAL(10,4) NOT NULL COMMENT '胜率(%)',
+ start_date DATE NOT NULL COMMENT '回测开始日期',
+ end_date DATE NOT NULL COMMENT '回测结束日期',
+ daily_equity_curve JSON DEFAULT NULL COMMENT '每日净值曲线 [{date,equity,yield}]',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ PRIMARY KEY (id),
+ UNIQUE INDEX uk_strategy_id (strategy_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='回测结果表';
+
+-- ---------------------------------------------------
+-- 交易明细表
+-- ---------------------------------------------------
+DROP TABLE IF EXISTS bt_trade_detail;
+CREATE TABLE bt_trade_detail (
+ id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ strategy_id BIGINT NOT NULL COMMENT '关联策略ID',
+ action VARCHAR(20) NOT NULL COMMENT '操作: BUY_OPEN/SELL_CLOSE/SELL_OPEN/BUY_CLOSE',
+ price DECIMAL(18,4) NOT NULL COMMENT '成交价',
+ volume INT NOT NULL COMMENT '成交数量',
+ turnover DECIMAL(18,2) NOT NULL COMMENT '成交金额',
+ trade_time DATETIME NOT NULL COMMENT '成交时间',
+ kline_time DATETIME NOT NULL COMMENT '对应K线时间',
+ signal_type VARCHAR(5) NOT NULL COMMENT '信号类型: B/S',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ PRIMARY KEY (id),
+ INDEX idx_strategy_id (strategy_id),
+ INDEX idx_trade_time (trade_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='交易明细表';
+
+-- ---------------------------------------------------
+-- 用户信号标记表
+-- ---------------------------------------------------
+DROP TABLE IF EXISTS bt_user_signal;
+CREATE TABLE bt_user_signal (
+ id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ user_id BIGINT NOT NULL COMMENT '用户ID',
+ strategy_id BIGINT NOT NULL COMMENT '策略ID',
+ contract_code VARCHAR(32) NOT NULL COMMENT '合约代码',
+ kline_period VARCHAR(10) NOT NULL COMMENT 'K线周期',
+ is_active TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用: 0-停用, 1-启用',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除: 0-未删除, 1-已删除',
+ PRIMARY KEY (id),
+ INDEX idx_user_contract_period (user_id, contract_code, kline_period),
+ INDEX idx_strategy_id (strategy_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户信号标记表';
diff --git a/src/main/resources/行情接口文档-CnQuotation.md b/src/main/resources/行情接口文档-CnQuotation.md
new file mode 100644
index 0000000..745c011
--- /dev/null
+++ b/src/main/resources/行情接口文档-CnQuotation.md
@@ -0,0 +1,669 @@
+# 行情模块接口文档(CnQuotationController)
+
+
+## 基本信息
+
+| 项目 | 说明 |
+|------|---------------------------------------------------------------------------|
+| 服务端口 | `443` |
+| context-path | `/mobile-api` |
+| 控制器基础路径 | `/cn/quotation` |
+| **完整基础 URL** | `https://slzqapi.sxslqhsh.com/mobile-api/cn/quotation` |
+| 认证 | **无需登录**。本控制器所有方法均未标注 `@CheckLogin`,登录拦截器直接放行。`CnToken` header 可选(带上也不影响) |
+| 编码 | `UTF-8` |
+| 内容类型 | GET 走 query 参数;POST 走 `application/json` |
+
+### 统一返回包装 `ResultModel`
+
+```jsonc
+{
+ "success": true, // 是否成功
+ "errorCode": null, // 错误码(失败时返回)
+ "errorInfo": null, // 错误信息(失败时返回)
+ "pagerManager": null, // 分页信息(部分分页接口返回)
+ "data": { }, // 业务数据载体(泛型 T)
+ "positionDetails": null // 仅持仓接口使用,本模块恒为 null
+}
+```
+
+> 说明:本模块大部分接口在异常时把 `success` 置为 `false` 并写入 `errorInfo`;`/realTime`、`/realTime/main` 在异常时仅记录日志,`data` 可能为 `null`,调用方需做空判断。
+
+### 环境变量约定(下文 curl 示例使用)
+
+```bash
+export HOST="https://slzqapi.sxslqhsh.com" # 按实际部署修改
+export BASE="$HOST/mobile-api/cn/quotation"
+# 若部署环境对行情接口启用了鉴权,可附带 CnToken(默认不需要)
+# export CNTOKEN="你的token"
+```
+
+---
+
+## 接口一览
+
+| # | 方法 | 路径 | 说明 | data 类型 |
+|---|------|------|------|-----------|
+| 1 | GET | `/realTime` | 获取实时行情 | `List` |
+| 2 | GET | `/realTime/main` | 主力合约主连实时行情 | `Collection` |
+| 3 | GET | `/realTime/jsonp` | JSONP 跨域实时行情 | JSONP 文本(非 JSON) |
+| 4 | GET | `/tickChart` | 分时图数据 | `TickChartResultModel` |
+| 5 | GET | `/kChart` | K 线图数据 | `KChartResultModel` |
+| 6 | GET | `/kChartByDate` | 按日期加载历史 K 线(往前/往后) | `KChartResultModel` |
+| 7 | GET | `/exchange/category/list` | 按交易所查询商品分类(二级分类) | `List` |
+| 8 | GET | `/excode/list` | 交易所列表(含商品数量与二级分类) | `List` |
+| 9 | GET | `/goods/list` | 商品及主力合约列表(分页/筛选/排序) | `PageResult` |
+| 10 | POST | `/mainContract/batch` | 批量查询主力标记 | `Map` |
+| 11 | GET | `/contract/list` | 关键词模糊查询合约 | `List` |
+| 12 | GET | `/goods/detail` | 单个商品详情 | `QuotationsGoods` |
+| 13 | GET | `/contract/detail` | 单个合约详情(含东财详情) | `QuotationsFuturesContract` |
+| 14 | GET | `/hotContracts` | 热门合约(近 30 分钟成交量 TOP10) | `List` |
+
+---
+
+## 1. 获取实时行情
+
+`GET /cn/quotation/realTime`
+
+| 参数 | 位置 | 必填 | 类型 | 说明 |
+|------|------|------|------|------|
+| `codes` | query | 是 | string | 合约代码,格式 `交易所\|合约`,多个用英文逗号 `,` 分隔。示例:`SHFE\|au2602` |
+
+**curl**
+
+```bash
+# 单个合约
+curl -G "$BASE/realTime" \
+ --data-urlencode "codes=SHFE|au2602"
+
+# 多个合约(逗号分隔)
+curl -G "$BASE/realTime" \
+ --data-urlencode "codes=SHFE|au2602,DCE|i2601"
+```
+
+**响应示例**
+
+```jsonc
+{
+ "success": true,
+ "data": [
+ {
+ "tradingDay": "20240101",
+ "instrumentID": "au2602",
+ "exchangeID": "SHFE",
+ "productId": "au",
+ "lastPrice": "520.50",
+ "preSettlementPrice": "518.00",
+ "openPrice": "520.00",
+ "highestPrice": "525.00",
+ "lowestPrice": "518.50",
+ "volume": "50000",
+ "openInterest": "120000",
+ "upperLimitPrice": "570.00",
+ "lowerLimitPrice": "470.00",
+ "bidPrice1": "520.00", "bidVolume1": "100",
+ "askPrice1": "521.00", "askVolume1": "200",
+ "change": "2.50",
+ "chg": "0.48",
+ "name": "黄金2602",
+ "decimalPrecision": 2
+ }
+ ]
+}
+```
+
+---
+
+## 2. 获取主力合约主连实时行情
+
+`GET /cn/quotation/realTime/main`
+
+无参数。按交易所分组返回各交易所主力合约主连行情。
+
+**curl**
+
+```bash
+curl -G "$BASE/realTime/main"
+```
+
+**响应示例**
+
+```jsonc
+{
+ "success": true,
+ "data": [
+ {
+ "excode": "SHFE",
+ "exchangeName": "上海期货交易所",
+ "quotationDataList": [ { "instrumentID": "au2602", "lastPrice": "520.50" } ]
+ }
+ ]
+}
+```
+
+---
+
+## 3. JSONP 实时行情
+
+`GET /cn/quotation/realTime/jsonp`
+
+| 参数 | 位置 | 必填 | 类型 | 说明 |
+|------|------|------|------|------|
+| `callback` | query | 是 | string | 回调函数名 |
+| `codes` | query | 是 | string | 合约代码,规则同 `/realTime` |
+
+返回 `application/javascript`,格式:`callback()`,用于浏览器跨域。
+
+**curl**
+
+```bash
+curl -G "$BASE/realTime/jsonp" \
+ --data-urlencode "callback=onQuote" \
+ --data-urlencode "codes=SHFE|au2602"
+# 返回:onQuote({"success":true,"data":[...]})
+```
+
+---
+
+## 4. 分时图数据
+
+`GET /cn/quotation/tickChart`
+
+| 参数 | 位置 | 必填 | 类型 | 说明 |
+|------|------|------|------|------|
+| `excode` | query | 是 | string | 交易所代码,如 `SHFE` |
+| `code` | query | 是 | string | 合约代码,如 `au2602` |
+
+**curl**
+
+```bash
+curl -G "$BASE/tickChart" \
+ --data-urlencode "excode=SHFE" \
+ --data-urlencode "code=au2602"
+```
+
+**响应示例**
+
+```jsonc
+{
+ "success": true,
+ "data": {
+ "ticks": [
+ { "t": "1704067200000", "p": "520.50", "v": "1000", "i": "120000", "a": "5200000" }
+ ],
+ "startTime": "09:00:00",
+ "endTime": "15:00:00",
+ "closeTime": "15:00:00",
+ "totalVolume": "500000",
+ "reqTime": 1704067200
+ }
+}
+```
+
+> `ticks[].t` 为毫秒时间戳字符串;`p` 价格、`v` 成交量、`i` 持仓量、`a` 成交额。
+
+---
+
+## 5. K 线图数据
+
+`GET /cn/quotation/kChart`
+
+| 参数 | 位置 | 必填 | 类型 | 说明 |
+|------|------|------|------|------|
+| `excode` | query | 是 | string | 交易所代码 |
+| `code` | query | 是 | string | 合约代码 |
+| `type` | query | 是 | int | K 线类型,见下表 |
+
+**K 线类型 `type` 枚举**
+
+| 值 | 含义 | 值 | 含义 |
+|----|------|----|------|
+| 2 | 5 分钟 | 7 | 周线 |
+| 3 | 15 分钟 | 9 | 4 小时 |
+| 4 | 30 分钟 | 10 | 1 分钟 |
+| 5 | 1 小时 | | |
+| 6 | 日线 | | |
+
+**curl**
+
+```bash
+# 5 分钟 K 线
+curl -G "$BASE/kChart" \
+ --data-urlencode "excode=SHFE" \
+ --data-urlencode "code=au2602" \
+ --data-urlencode "type=2"
+```
+
+**响应示例**
+
+```jsonc
+{
+ "success": true,
+ "data": {
+ "chats": [
+ {
+ "t": "01-01 09:30",
+ "o": "518.00", "h": "522.00", "l": "517.50", "c": "520.50",
+ "v": "10000", "a": "5200000",
+ "u": 1704067200, // 时间戳(秒)
+ "i": "120000", // 持仓量
+ "s": "520.50" // 结算价(仅日 K 有值)
+ }
+ ],
+ "totalVolume": "500000",
+ "reqTime": 1704067200
+ }
+}
+```
+
+---
+
+## 6. 按日期加载历史 K 线
+
+`GET /cn/quotation/kChartByDate`
+
+用于 K 线左滑/右滑加载历史,返回指定日期往前或往后约 200 条数据。
+
+| 参数 | 位置 | 必填 | 类型 | 说明 |
+|------|------|------|------|------|
+| `excode` | query | 是 | string | 交易所代码 |
+| `code` | query | 是 | string | 合约代码 |
+| `type` | query | 是 | int | K 线类型,枚举同接口 5 |
+| `date` | query | 是 | long | 指定日期,时间戳(**秒**) |
+| `direction` | query | 是 | string | `before`=往前查询,`after`=往后查询 |
+
+**curl**
+
+```bash
+curl -G "$BASE/kChartByDate" \
+ --data-urlencode "excode=SHFE" \
+ --data-urlencode "code=au2602" \
+ --data-urlencode "type=2" \
+ --data-urlencode "date=1704067200" \
+ --data-urlencode "direction=before"
+```
+
+响应结构同接口 5(`KChartResultModel`)。
+
+---
+
+## 7. 按交易所查询商品分类列表
+
+`GET /cn/quotation/exchange/category/list`
+
+| 参数 | 位置 | 必填 | 类型 | 说明 |
+|------|------|------|------|------|
+| `excode` | query | 是 | string | 交易所代码,如 `DCE`。为空返回失败 |
+
+数据缓存 1 小时,按 `sort_order` 升序。
+
+**curl**
+
+```bash
+curl -G "$BASE/exchange/category/list" \
+ --data-urlencode "excode=DCE"
+```
+
+**响应示例**
+
+```jsonc
+{
+ "success": true,
+ "data": [
+ { "id": 1, "excode": "DCE", "exname": "大商所", "category": "农产品", "sortOrder": 1 }
+ ]
+}
+```
+
+---
+
+## 8. 交易所列表(含二级分类)
+
+`GET /cn/quotation/excode/list`
+
+无参数。返回所有交易所代码、商品数量统计及各交易所二级分类,按商品数量降序。**缓存 1 小时。**
+
+**curl**
+
+```bash
+curl -G "$BASE/excode/list"
+```
+
+**响应示例**
+
+```jsonc
+{
+ "success": true,
+ "data": [
+ {
+ "excode": "SHFE",
+ "exname": "上海期货交易所",
+ "cnt": 150,
+ "categories": [
+ { "id": 1, "excode": "SHFE", "exname": "上海期货交易所", "category": "贵金属", "sortOrder": 1 }
+ ]
+ }
+ ]
+}
+```
+
+---
+
+## 9. 商品及主力合约列表(分页/筛选/排序)
+
+`GET /cn/quotation/goods/list`
+
+| 参数 | 位置 | 必填 | 类型 | 默认 | 说明 |
+|------|------|------|------|------|------|
+| `excode` | query | 否 | string | — | 交易所代码,不传返回所有交易所。特殊值 `__NIGHT__` 表示筛选有夜盘的商品(`nextDayFlag=1`) |
+| `page` | query | 否 | int | 1 | 页码,从 1 开始;获取全部时无效 |
+| `pageSize` | query | 否 | int | 10 | 每页条数,最大 100;设为 `-1` 或 `0` 返回全部(不分页) |
+| `sortType` | query | 否 | int | — | 排序:1=日涨幅降序,2=日跌幅降序,3=日成交量降序,4=日增仓量降序;不传按默认 |
+| `category` | query | 否 | string | — | 商品分类。传入时返回该分类下**全部有效合约**;不传 + 指定 `excode` 默认返回该所**每品种一条主力**;不传 + 未指定 `excode` 仅返回**主力合约** |
+| `onlyMainInCategory` | query | 否 | boolean | — | 为 `true` 且传 `category` 时,仅返回该分类下主力合约 |
+| `allContractsByExcode` | query | 否 | boolean | — | 为 `true` 且指定 `excode`、未传 `category` 时,返回该所**全部有效合约**(行情「全部」列表) |
+| `productId` | query | 否 | string | — | 品种 ID。与 `excode` 同传返回该品种下**全部有效合约** |
+
+**curl 常用场景**
+
+```bash
+# 获取所有主力合约(不分页)
+curl -G "$BASE/goods/list" --data-urlencode "pageSize=-1"
+
+# 指定分类下全部有效合约
+curl -G "$BASE/goods/list" \
+ --data-urlencode "excode=DCE" \
+ --data-urlencode "category=农产品" \
+ --data-urlencode "pageSize=-1"
+
+# 指定品种全部合约
+curl -G "$BASE/goods/list" \
+ --data-urlencode "excode=DCE" \
+ --data-urlencode "productId=a" \
+ --data-urlencode "pageSize=-1"
+
+# 某交易所「全部」(全所全合约)
+curl -G "$BASE/goods/list" \
+ --data-urlencode "excode=SHFE" \
+ --data-urlencode "allContractsByExcode=true" \
+ --data-urlencode "pageSize=-1"
+
+# 分页 + 按日涨幅降序
+curl -G "$BASE/goods/list" \
+ --data-urlencode "excode=SHFE" \
+ --data-urlencode "page=1" \
+ --data-urlencode "pageSize=10" \
+ --data-urlencode "sortType=1"
+
+# 筛选有夜盘的商品
+curl -G "$BASE/goods/list" --data-urlencode "excode=__NIGHT__"
+```
+
+**响应示例**
+
+```jsonc
+{
+ "success": true,
+ "data": {
+ "currentPage": 1,
+ "pageSize": 10,
+ "totalCount": 100,
+ "totalPage": 10,
+ "list": [
+ {
+ "excode": "SHFE",
+ "goodsCode": "au",
+ "goodsName": "黄金",
+ "productId": "au",
+ "startTime": "09:00:00",
+ "middleTime": "11:30:00",
+ "endTime": "15:00:00",
+ "nextDayFlag": 1,
+ "tradeTime": "09:00-11:30,13:30-15:00",
+ "mainContractCode": "au2502",
+ "isPrincipal": 1,
+ "minTradeLots": 1,
+ "mainContractQuotation": { "instrumentID": "au2502", "lastPrice": "520.50", "chg": "0.48" }
+ }
+ ]
+ }
+}
+```
+
+> 缓存:商品列表缓存 1 小时,行情数据实时获取;指定 `sortType` 时不走缓存。
+
+---
+
+## 10. 批量查询主力标记
+
+`POST /cn/quotation/mainContract/batch`
+
+请求体 `application/json`:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `symbols` | string[] | 合约唯一标识列表,格式 `excode.contractCode`,如 `SHFE.au2506` |
+
+返回 `Map`,`isPrincipal`:0=否,1=是。用于自选列表展示「主」角标,前端可缓存 6 小时。
+
+**curl**
+
+```bash
+curl -X POST "$BASE/mainContract/batch" \
+ -H "Content-Type: application/json" \
+ -d '{"symbols":["SHFE.au2506","DCE.i2601"]}'
+```
+
+**响应示例**
+
+```jsonc
+{
+ "success": true,
+ "data": { "SHFE.au2506": 1, "DCE.i2601": 0 }
+}
+```
+
+---
+
+## 11. 关键词查询合约列表
+
+`GET /cn/quotation/contract/list`
+
+| 参数 | 位置 | 必填 | 类型 | 说明 |
+|------|------|------|------|------|
+| `keyword` | query | 是 | string | 合约代码或商品名称,模糊匹配。实时查询,不缓存 |
+
+**curl**
+
+```bash
+curl -G "$BASE/contract/list" \
+ --data-urlencode "keyword=黄金"
+```
+
+响应 `data` 为 `QuotationsFuturesContract` 列表,结构见接口 13。
+
+---
+
+## 12. 单个商品详情
+
+`GET /cn/quotation/goods/detail`
+
+| 参数 | 位置 | 必填 | 类型 | 说明 |
+|------|------|------|------|------|
+| `excode` | query | 是 | string | 交易所代码,如 `CZCE` |
+| `code` | query | 是 | string | 商品代码,如 `PX` |
+
+数据缓存 1 小时。
+
+**curl**
+
+```bash
+curl -G "$BASE/goods/detail" \
+ --data-urlencode "excode=CZCE" \
+ --data-urlencode "code=PX"
+```
+
+**响应示例**
+
+```jsonc
+{
+ "success": true,
+ "data": {
+ "id": 16,
+ "excode": "CZCE",
+ "exname": "郑商所",
+ "code": "PX",
+ "name": "对二甲苯",
+ "shortName": "对二甲苯",
+ "startTime": "21:00",
+ "middleTime": "23:00",
+ "endTime": "15:00",
+ "closeTime": "15:00",
+ "nextDayFlag": 1,
+ "deleteFlag": 0,
+ "productId": "PX",
+ "tradeTime": "21:00-23:00,09:00-11:30,13:30-15:00",
+ "decimalPrecision": 0,
+ "marginRate": 0.08,
+ "volumeMultiple": 1000,
+ "category": "化工",
+ "extJson": "{\"bjdw\":\"元(人民币)/吨\"}"
+ }
+}
+```
+
+---
+
+## 13. 单个合约详情
+
+`GET /cn/quotation/contract/detail`
+
+| 参数 | 位置 | 必填 | 类型 | 说明 |
+|------|------|------|------|------|
+| `contractCode` | query | 是 | string | 合约代码,不区分大小写,如 `AP605`。为空返回 `errorCode=00004` |
+
+实时查询,不缓存。含东财商品详情 `eastmoneyDetail`(可能为 `null`)。
+
+**curl**
+
+```bash
+curl -G "$BASE/contract/detail" \
+ --data-urlencode "contractCode=AP605"
+```
+
+**响应示例**
+
+```jsonc
+{
+ "success": true,
+ "data": {
+ "excode": "CZCE",
+ "exname": "郑州商品交易所",
+ "contractCode": "AP605",
+ "productId": "AP",
+ "goodsName": "苹果",
+ "isPrincipal": 0,
+ "volumeMultiple": 10,
+ "priceTick": 1,
+ "priceLimit": 5.00,
+ "shortMarginRatioByMoney": 0.05,
+ "deliveryMonth": "2026-05",
+ "startDeliveryDate": "2026-05-01",
+ "expireDate": "2026-05-30",
+ "endDeliveryDate": "2026-05-30",
+ "startTime": "21:00",
+ "middleTime": "23:00",
+ "endTime": "15:00",
+ "tradeTime": "21:00-23:00,09:00-11:30,13:30-15:00",
+ "nextDayFlag": 1,
+ "minTradeLots": 1,
+ "eastmoneyDetail": {
+ "bjdw": "元(人民币)/吨",
+ "jydw": "10吨/手",
+ "zxbddw": "1元/吨",
+ "zcjybzj": "7%",
+ "zdtbfd": "±6%",
+ "zhjyr": "合约月份倒数第3个交易日",
+ "zhjgr": "合约月份第7个交易日",
+ "jgfs": "实物交割",
+ "jgpj": "符合标准的苹果",
+ "jysj": "上午9:00-11:30,下午13:30-15:00",
+ "hyjgyf": "1,3,5,7,10,11,12月"
+ }
+ }
+}
+```
+
+---
+
+## 14. 热门合约
+
+`GET /cn/quotation/hotContracts`
+
+无参数。返回最近 30 分钟成交量 TOP10 合约,返回格式与接口 11 一致(`QuotationsFuturesContract` 列表)。**缓存 5 分钟。**
+
+**curl**
+
+```bash
+curl -G "$BASE/hotContracts"
+```
+
+---
+
+## 附录:核心数据模型字段
+
+### QuotationData(行情数据)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `tradingDay` | string | 交易日(yyyyMMdd) |
+| `instrumentID` | string | 合约代码 |
+| `exchangeID` | string | 交易所代码 |
+| `productId` | string | 品种代码 |
+| `lastPrice` | string | 最新价 |
+| `preSettlementPrice` | string | 昨结算价 |
+| `preClosePrice` | string | 昨收盘 |
+| `preOpenInterest` | string | 昨持仓量 |
+| `openPrice` | string | 今开盘 |
+| `highestPrice` / `lowestPrice` | string | 最高 / 最低价 |
+| `volume` | string | 成交量 |
+| `turnover` | string | 成交金额 |
+| `openInterest` | string | 持仓量 |
+| `closePrice` | string | 今收盘 |
+| `settlementPrice` | string | 今结算价 |
+| `upperLimitPrice` / `lowerLimitPrice` | string | 涨停 / 跌停板价 |
+| `preDelta` / `currDelta` | string | 昨 / 今虚实度(期权) |
+| `updateTime` / `updateMillisecond` | string | 最后修改时间 / 毫秒 |
+| `bidPrice1..5` / `bidVolume1..5` | string | 申买价/量 一至五档 |
+| `askPrice1..5` / `askVolume1..5` | string | 申卖价/量 一至五档 |
+| `averagePrice` | string | 当日均价 |
+| `actionDay` | string | 业务日期 |
+| `quotationDateTime` | string | 行情时间 |
+| `startDeliveryDate` | string | 开始交割日 |
+| `decimalPrecision` | int | 行情小数位精度 |
+| `change` | string | 涨跌 |
+| `chg` | string | 涨跌幅 |
+| `name` | string | 合约名称 |
+
+### TickChartModel(分时数据点)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `t` | string | 时间戳(毫秒,字符串) |
+| `p` | string | 价格 |
+| `v` | string | 成交量 |
+| `i` | string | 持仓量 |
+| `a` | string | 成交额 |
+
+### KChartV3Model(K 线数据点,继承 KChartModel)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `t` | string | 时间(格式化字符串,如 `01-01 09:30`) |
+| `o` / `h` / `l` / `c` | string | 开 / 高 / 低 / 收 |
+| `v` | string | 成交量 |
+| `a` | string | 成交额 |
+| `u` | long | 时间戳(秒) |
+| `i` | string | 持仓量 |
+| `s` | string | 结算价(仅日 K 有值) |
+
+> 完整字段以源码 `com.futures.common.model.quotation.*` 为准;本表覆盖前端常用字段。
diff --git a/src/test/java/com/yangwale/backtestify/controller/AdminStrategyControllerTest.java b/src/test/java/com/yangwale/backtestify/controller/AdminStrategyControllerTest.java
new file mode 100644
index 0000000..b96df5d
--- /dev/null
+++ b/src/test/java/com/yangwale/backtestify/controller/AdminStrategyControllerTest.java
@@ -0,0 +1,145 @@
+package com.yangwale.backtestify.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yangwale.backtestify.common.PageResult;
+import com.yangwale.backtestify.model.response.StrategyDetailResponse;
+import com.yangwale.backtestify.model.response.StrategyListItemResponse;
+import com.yangwale.backtestify.service.StrategyService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * B端管理接口测试
+ */
+@WebMvcTest(AdminStrategyController.class)
+@DisplayName("B端管理接口测试")
+class AdminStrategyControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockBean
+ private StrategyService strategyService;
+
+ @Test
+ @DisplayName("POST /api/v1/admin/strategy/list — 分页查询全部策略")
+ void listAll() throws Exception {
+ var items = List.of(
+ StrategyListItemResponse.builder()
+ .id(1L).userId(1L).contractCode("PVC2605")
+ .contractName("PVC主连").direction("LONG")
+ .indicators("[\"MACD\"]")
+ .totalYield(new BigDecimal("12.45"))
+ .profitAmount(new BigDecimal("124500.00"))
+ .tradeCount(8).winRate(new BigDecimal("62.50"))
+ .build(),
+ StrategyListItemResponse.builder()
+ .id(2L).userId(2L).contractCode("PVC2605")
+ .contractName("PVC主连").direction("SHORT")
+ .indicators("[\"KDJ\",\"RSI\"]")
+ .totalYield(new BigDecimal("-3.21"))
+ .profitAmount(new BigDecimal("-32100.00"))
+ .tradeCount(5).winRate(new BigDecimal("40.00"))
+ .build()
+ );
+ when(strategyService.adminList(any())).thenReturn(PageResult.of(2, 1, 10, items));
+
+ Map body = Map.of("pageNum", 1, "pageSize", 10);
+
+ mockMvc.perform(post("/api/v1/admin/strategy/list")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(body)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.total").value(2))
+ .andExpect(jsonPath("$.data.list[0].direction").value("LONG"))
+ .andExpect(jsonPath("$.data.list[1].direction").value("SHORT"));
+ }
+
+ @Test
+ @DisplayName("POST /api/v1/admin/strategy/list — 带筛选条件")
+ void listFiltered() throws Exception {
+ var items = List.of();
+ when(strategyService.adminList(any())).thenReturn(PageResult.empty(1, 10));
+
+ Map body = Map.of(
+ "pageNum", 1,
+ "pageSize", 10,
+ "direction", "LONG",
+ "contractCode", "PVC2605"
+ );
+
+ mockMvc.perform(post("/api/v1/admin/strategy/list")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(body)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+ }
+
+ @Test
+ @DisplayName("GET /api/v1/admin/strategy/detail/{id} — 查看策略详情")
+ void detail() throws Exception {
+ var detail = StrategyDetailResponse.builder()
+ .strategyId(1L).userId(1L)
+ .contractCode("PVC2605").contractName("PVC主连")
+ .direction("LONG").klinePeriod("1d")
+ .indicators(List.of("MACD"))
+ .openVolume(1).volumeUnit("LOT")
+ .backtestPeriod("3m").status(1)
+ .initialCapital(new BigDecimal("1000000.00"))
+ .finalCapital(new BigDecimal("1124500.00"))
+ .totalYield(new BigDecimal("12.45"))
+ .profitAmount(new BigDecimal("124500.00"))
+ .annualizedYield(new BigDecimal("10.23"))
+ .tradeCount(8)
+ .maxDrawdown(new BigDecimal("5.23"))
+ .sharpeRatio(new BigDecimal("0.627"))
+ .winRate(new BigDecimal("62.50"))
+ .maxEquity(new BigDecimal("1150000.00"))
+ .minEquity(new BigDecimal("980000.00"))
+ .tradeDetails(List.of())
+ .dailyEquityCurve(List.of())
+ .build();
+
+ when(strategyService.detail(1L)).thenReturn(detail);
+
+ mockMvc.perform(get("/api/v1/admin/strategy/detail/1"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.strategyId").value(1))
+ .andExpect(jsonPath("$.data.maxEquity").value(1150000.0))
+ .andExpect(jsonPath("$.data.minEquity").value(980000.0))
+ .andExpect(jsonPath("$.data.sharpeRatio").value(0.627));
+ }
+
+ @Test
+ @DisplayName("GET /api/v1/admin/strategy/detail/{id} — 策略不存在返回404")
+ void detailNotFound() throws Exception {
+ when(strategyService.detail(999L))
+ .thenThrow(new com.yangwale.backtestify.common.BusinessException(
+ com.yangwale.backtestify.common.ErrorCode.STRATEGY_NOT_FOUND));
+
+ mockMvc.perform(get("/api/v1/admin/strategy/detail/999"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(1001));
+ }
+}
diff --git a/src/test/java/com/yangwale/backtestify/controller/StrategyControllerTest.java b/src/test/java/com/yangwale/backtestify/controller/StrategyControllerTest.java
new file mode 100644
index 0000000..254982d
--- /dev/null
+++ b/src/test/java/com/yangwale/backtestify/controller/StrategyControllerTest.java
@@ -0,0 +1,228 @@
+package com.yangwale.backtestify.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yangwale.backtestify.common.PageResult;
+import com.yangwale.backtestify.model.response.BacktestResponse;
+import com.yangwale.backtestify.model.response.SignalPointResponse;
+import com.yangwale.backtestify.model.response.StrategyDetailResponse;
+import com.yangwale.backtestify.model.response.StrategyListItemResponse;
+import com.yangwale.backtestify.service.StrategyService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * C端策略接口测试
+ */
+@WebMvcTest(StrategyController.class)
+@DisplayName("C端策略接口测试")
+class StrategyControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockBean
+ private StrategyService strategyService;
+
+ @Test
+ @DisplayName("POST /api/v1/strategy/backtest — 执行回测成功")
+ void backtestSuccess() throws Exception {
+ var response = BacktestResponse.builder()
+ .strategyId(1L)
+ .contractCode("PVC2605")
+ .direction("LONG")
+ .indicators(List.of("MACD"))
+ .totalYield(new BigDecimal("12.4500"))
+ .profitAmount(new BigDecimal("124500.00"))
+ .tradeCount(8)
+ .maxDrawdown(new BigDecimal("5.2300"))
+ .sharpeRatio(new BigDecimal("0.6270"))
+ .winRate(new BigDecimal("62.5000"))
+ .maxEquity(new BigDecimal("1150000.00"))
+ .minEquity(new BigDecimal("980000.00"))
+ .startDate(LocalDate.of(2024, 9, 2))
+ .endDate(LocalDate.of(2024, 12, 2))
+ .dailyEquityCurve(List.of())
+ .tradeDetails(List.of())
+ .signalPoints(List.of())
+ .build();
+
+ when(strategyService.backtest(any())).thenReturn(response);
+
+ Map body = new java.util.LinkedHashMap<>();
+ body.put("contractCode", "PVC2605");
+ body.put("contractName", "PVC主连");
+ body.put("direction", "LONG");
+ body.put("klinePeriod", "1d");
+ body.put("indicators", List.of("MACD"));
+ body.put("openVolume", 1);
+ body.put("volumeUnit", "LOT");
+ body.put("stopLossValue", "5");
+ body.put("stopLossUnit", "PERCENT");
+ body.put("takeProfitValue", "10");
+ body.put("takeProfitUnit", "PERCENT");
+ body.put("backtestPeriod", "3m");
+
+ mockMvc.perform(post("/api/v1/strategy/backtest")
+ .header("user_id", "1")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(body)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.strategyId").value(1))
+ .andExpect(jsonPath("$.data.totalYield").value(12.45))
+ .andExpect(jsonPath("$.data.winRate").value(62.5));
+ }
+
+ @Test
+ @DisplayName("POST /api/v1/strategy/backtest — 参数校验失败(空合约)")
+ void backtestValidationFail() throws Exception {
+ Map body = new java.util.LinkedHashMap<>();
+ body.put("contractCode", "");
+ body.put("direction", "LONG");
+ body.put("klinePeriod", "1d");
+ body.put("indicators", List.of());
+ body.put("backtestPeriod", "3m");
+
+ mockMvc.perform(post("/api/v1/strategy/backtest")
+ .header("user_id", "1")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(body)))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(400));
+ }
+
+ @Test
+ @DisplayName("GET /api/v1/strategy/my-list — 获取策略列表")
+ void myList() throws Exception {
+ var items = List.of(
+ StrategyListItemResponse.builder()
+ .id(1L).userId(1L).contractCode("PVC2605")
+ .direction("LONG").totalYield(new BigDecimal("12.45"))
+ .tradeCount(8).winRate(new BigDecimal("62.5"))
+ .build()
+ );
+ when(strategyService.myList(anyLong(), anyInt(), anyInt()))
+ .thenReturn(PageResult.of(1, 1, 10, items));
+
+ mockMvc.perform(get("/api/v1/strategy/my-list")
+ .header("user_id", "1")
+ .param("pageNum", "1")
+ .param("pageSize", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.total").value(1))
+ .andExpect(jsonPath("$.data.list[0].id").value(1));
+ }
+
+ @Test
+ @DisplayName("GET /api/v1/strategy/detail/{id} — 获取策略详情")
+ void detail() throws Exception {
+ var detail = StrategyDetailResponse.builder()
+ .strategyId(1L).userId(1L)
+ .contractCode("PVC2605").direction("LONG")
+ .indicators(List.of("MACD"))
+ .totalYield(new BigDecimal("12.45"))
+ .tradeDetails(List.of())
+ .dailyEquityCurve(List.of())
+ .build();
+
+ when(strategyService.detail(1L)).thenReturn(detail);
+
+ mockMvc.perform(get("/api/v1/strategy/detail/1")
+ .header("user_id", "1"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.strategyId").value(1))
+ .andExpect(jsonPath("$.data.contractCode").value("PVC2605"));
+ }
+
+ @Test
+ @DisplayName("DELETE /api/v1/strategy/{id} — 删除策略")
+ void deleteStrategy() throws Exception {
+ mockMvc.perform(delete("/api/v1/strategy/1")
+ .header("user_id", "1"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+ }
+
+ @Test
+ @DisplayName("PUT /api/v1/strategy/{id}/signal-toggle — 切换信号")
+ void toggleSignal() throws Exception {
+ mockMvc.perform(put("/api/v1/strategy/1/signal-toggle")
+ .header("user_id", "1"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+ }
+
+ @Test
+ @DisplayName("GET /api/v1/strategy/signals — 查询信号点")
+ void getSignals() throws Exception {
+ var signals = List.of(
+ SignalPointResponse.builder()
+ .time("2024-09-15 10:00:00")
+ .type("B")
+ .price(new BigDecimal("5100.00"))
+ .strategyId(1L)
+ .build()
+ );
+ when(strategyService.getSignals(any())).thenReturn(signals);
+
+ mockMvc.perform(get("/api/v1/strategy/signals")
+ .header("user_id", "1")
+ .param("contractCode", "PVC2605")
+ .param("period", "1d")
+ .param("startTime", "2024-09-01 00:00:00")
+ .param("endTime", "2024-12-31 23:59:59"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data[0].type").value("B"))
+ .andExpect(jsonPath("$.data[0].price").value(5100.0));
+ }
+
+ @Test
+ @DisplayName("GET /api/v1/strategy/indicators — 获取可用指标列表")
+ void getIndicators() throws Exception {
+ when(strategyService.getAvailableIndicators())
+ .thenReturn(List.of("ATR", "BBI", "BOLL", "CCI", "CJL", "DMI", "DONCHIAN",
+ "KDJ", "MA", "MACD", "RSI", "SAR", "VOL_AMOUNT", "WR"));
+
+ mockMvc.perform(get("/api/v1/strategy/indicators")
+ .header("user_id", "1"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.length()").value(14));
+ }
+
+ @Test
+ @DisplayName("缺少 user_id 请求头时接口仍正常(userId=null)")
+ void missingUserIdHeader() throws Exception {
+ var items = List.of();
+ when(strategyService.myList(isNull(), anyInt(), anyInt()))
+ .thenReturn(PageResult.empty(1, 10));
+
+ mockMvc.perform(get("/api/v1/strategy/my-list")
+ .param("pageNum", "1")
+ .param("pageSize", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+ }
+}
diff --git a/src/test/java/com/yangwale/backtestify/service/BacktestEngineImplTest.java b/src/test/java/com/yangwale/backtestify/service/BacktestEngineImplTest.java
new file mode 100644
index 0000000..76047bd
--- /dev/null
+++ b/src/test/java/com/yangwale/backtestify/service/BacktestEngineImplTest.java
@@ -0,0 +1,235 @@
+package com.yangwale.backtestify.service;
+
+import com.yangwale.backtestify.model.dto.KLineData;
+import com.yangwale.backtestify.model.request.BacktestRequest;
+import com.yangwale.backtestify.service.impl.MarketDataServiceImpl;
+import com.yangwale.backtestify.service.market.FakeMarketDataProvider;
+import com.yangwale.backtestify.service.signal.*;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 回测引擎核心逻辑测试
+ * 使用 FakeMarketDataProvider 的假数据进行测试
+ */
+@DisplayName("回测引擎测试")
+class BacktestEngineImplTest {
+
+ private FakeMarketDataProvider marketDataProvider;
+ private SignalStrategyFactory signalStrategyFactory;
+ private MarketDataServiceImpl marketDataService;
+
+ @BeforeEach
+ void setUp() {
+ marketDataProvider = new FakeMarketDataProvider();
+ signalStrategyFactory = new SignalStrategyFactory(List.of(
+ new MACDSignalStrategy(),
+ new KDJSignalStrategy(),
+ new MASignalStrategy(),
+ new BOLLSignalStrategy(),
+ new RSISignalStrategy(),
+ new CCISignalStrategy(),
+ new BBISignalStrategy(),
+ new SARSignalStrategy(),
+ new DMISignalStrategy(),
+ new WRSignalStrategy(),
+ new ATRSignalStrategy(),
+ new DonchianSignalStrategy(),
+ new CJLSignalStrategy(),
+ new VolAmountSignalStrategy()
+ ));
+ // 不使用Redis缓存的market data service
+ marketDataService = new MarketDataServiceImpl(marketDataProvider);
+ }
+
+ @Test
+ @DisplayName("假数据 — 返回65根日K线")
+ void fakeDataHasCorrectCount() {
+ var klines = marketDataProvider.getKLineData("PVC2605", "1d",
+ java.time.LocalDateTime.of(2024, 9, 1, 0, 0),
+ java.time.LocalDateTime.of(2024, 12, 31, 0, 0));
+ assertTrue(klines.size() >= 60, "应有约65根日K线,实际: " + klines.size());
+ }
+
+ @Test
+ @DisplayName("假数据 — 不支持的合约返回空列表")
+ void unknownContractReturnsEmpty() {
+ var klines = marketDataProvider.getKLineData("UNKNOWN", "1d",
+ java.time.LocalDateTime.of(2024, 9, 1, 0, 0),
+ java.time.LocalDateTime.of(2024, 12, 31, 0, 0));
+ assertTrue(klines.isEmpty());
+ }
+
+ @Test
+ @DisplayName("假数据 — 所有K线都有完整的指标值")
+ void allKlinesHaveIndicators() {
+ var klines = marketDataProvider.getKLineData("PVC2605", "1d",
+ java.time.LocalDateTime.of(2024, 9, 1, 0, 0),
+ java.time.LocalDateTime.of(2024, 12, 31, 0, 0));
+ for (KLineData k : klines) {
+ assertNotNull(k.getIndicators(), "每根K线都应有指标值");
+ assertNotNull(k.getIndicators().getMacdDif(), "应有MACD DIF");
+ assertNotNull(k.getIndicators().getMacdDea(), "应有MACD DEA");
+ assertNotNull(k.getIndicators().getKdjK(), "应有KDJ K");
+ assertNotNull(k.getIndicators().getMa5(), "应有MA5");
+ assertNotNull(k.getIndicators().getRsi14(), "应有RSI14");
+ }
+ }
+
+ @Test
+ @DisplayName("MACD金叉信号 — 在上升趋势段应能找到")
+ void macdGoldenCrossExistsInData() {
+ var klines = marketDataProvider.getKLineData("PVC2605", "1d",
+ java.time.LocalDateTime.of(2024, 9, 1, 0, 0),
+ java.time.LocalDateTime.of(2024, 12, 31, 0, 0));
+ MACDSignalStrategy macd = new MACDSignalStrategy();
+ int buyCount = 0, sellCount = 0;
+ for (KLineData k : klines) {
+ if (macd.isBuySignal(k, klines)) buyCount++;
+ if (macd.isSellSignal(k, klines)) sellCount++;
+ }
+ // MACD在65天数据中应产生多个信号
+ assertTrue(buyCount >= 1, "MACD应至少有1个金叉信号,实际: " + buyCount);
+ assertTrue(sellCount >= 1, "MACD应至少有1个死叉信号,实际: " + sellCount);
+ }
+
+ @Test
+ @DisplayName("MACD+KDJ共振 — 信号数量应 < 单独MACD信号数(取交集更严格)")
+ void multiIndicatorResonanceIsStrictor() {
+ var klines = marketDataProvider.getKLineData("PVC2605", "1d",
+ java.time.LocalDateTime.of(2024, 9, 1, 0, 0),
+ java.time.LocalDateTime.of(2024, 12, 31, 0, 0));
+ MACDSignalStrategy macd = new MACDSignalStrategy();
+ KDJSignalStrategy kdj = new KDJSignalStrategy();
+
+ int macdOnlyBuy = 0;
+ int multiBuy = 0;
+ for (KLineData k : klines) {
+ if (macd.isBuySignal(k, klines)) macdOnlyBuy++;
+ if (macd.isBuySignal(k, klines) && kdj.isBuySignal(k, klines)) multiBuy++;
+ }
+ assertTrue(multiBuy <= macdOnlyBuy,
+ "多指标共振买入信号应不多于单独MACD信号");
+ }
+
+ @Test
+ @DisplayName("市场数据服务 — 验证合约存在")
+ void marketServiceValidatesContract() {
+ assertTrue(marketDataProvider.supportsContract("PVC2605"));
+ assertFalse(marketDataProvider.supportsContract("UNKNOWN"));
+ }
+
+ @Test
+ @DisplayName("回测请求 — 创建做多回测请求参数")
+ void buildLongBacktestRequest() {
+ BacktestRequest request = new BacktestRequest();
+ request.setContractCode("PVC2605");
+ request.setContractName("PVC主连");
+ request.setDirection("LONG");
+ request.setKlinePeriod("1d");
+ request.setIndicators(List.of("MACD"));
+ request.setOpenVolume(1);
+ request.setVolumeUnit("LOT");
+ request.setStopLossValue(new BigDecimal("5"));
+ request.setStopLossUnit("PERCENT");
+ request.setTakeProfitValue(new BigDecimal("10"));
+ request.setTakeProfitUnit("PERCENT");
+ request.setBacktestPeriod("3m");
+ request.setUserId(1L);
+
+ assertEquals("PVC2605", request.getContractCode());
+ assertEquals("LONG", request.getDirection());
+ assertEquals(1, request.getIndicators().size());
+ }
+
+ @Test
+ @DisplayName("回测请求 — 创建做空回测请求参数")
+ void buildShortBacktestRequest() {
+ BacktestRequest request = new BacktestRequest();
+ request.setContractCode("PVC2605");
+ request.setContractName("PVC主连");
+ request.setDirection("SHORT");
+ request.setKlinePeriod("1d");
+ request.setIndicators(List.of("KDJ", "RSI"));
+ request.setOpenVolume(2);
+ request.setVolumeUnit("LOT");
+ request.setBacktestPeriod("6m");
+ request.setUserId(1L);
+
+ assertEquals("SHORT", request.getDirection());
+ assertEquals(2, request.getIndicators().size());
+ }
+
+ @Test
+ @DisplayName("MACD金叉逻辑验证 — DIF上穿DEA")
+ void macdGoldenCrossLogic() {
+ var klines = marketDataProvider.getKLineData("PVC2605", "1d",
+ java.time.LocalDateTime.of(2024, 9, 1, 0, 0),
+ java.time.LocalDateTime.of(2024, 12, 31, 0, 0));
+ MACDSignalStrategy macd = new MACDSignalStrategy();
+ // 找到第一个金叉信号并验证逻辑
+ for (int i = 1; i < klines.size(); i++) {
+ if (macd.isBuySignal(klines.get(i), klines)) {
+ var prev = klines.get(i - 1).getIndicators();
+ var cur = klines.get(i).getIndicators();
+ assertTrue(prev.getMacdDif().compareTo(prev.getMacdDea()) <= 0,
+ "金叉前DIF应<=DEA");
+ assertTrue(cur.getMacdDif().compareTo(cur.getMacdDea()) > 0,
+ "金叉时DIF应>DEA");
+ break;
+ }
+ }
+ }
+
+ @Test
+ @DisplayName("KDJ金叉逻辑验证 — K上穿D")
+ void kdjGoldenCrossLogic() {
+ var klines = marketDataProvider.getKLineData("PVC2605", "1d",
+ java.time.LocalDateTime.of(2024, 9, 1, 0, 0),
+ java.time.LocalDateTime.of(2024, 12, 31, 0, 0));
+ KDJSignalStrategy kdj = new KDJSignalStrategy();
+ for (int i = 1; i < klines.size(); i++) {
+ if (kdj.isBuySignal(klines.get(i), klines)) {
+ var prev = klines.get(i - 1).getIndicators();
+ var cur = klines.get(i).getIndicators();
+ assertTrue(prev.getKdjK().compareTo(prev.getKdjD()) <= 0,
+ "金叉前K应<=D");
+ assertTrue(cur.getKdjK().compareTo(cur.getKdjD()) > 0,
+ "金叉时K应>D");
+ break;
+ }
+ }
+ }
+
+ @Test
+ @DisplayName("假数据 — 价格范围在4800-5350内")
+ void fakeDataPriceRange() {
+ var klines = marketDataProvider.getKLineData("PVC2605", "1d",
+ java.time.LocalDateTime.of(2024, 9, 1, 0, 0),
+ java.time.LocalDateTime.of(2024, 12, 31, 0, 0));
+ for (KLineData k : klines) {
+ assertTrue(k.getClose().compareTo(new BigDecimal("4700")) > 0,
+ "收盘价应>4700, 实际: " + k.getClose());
+ assertTrue(k.getClose().compareTo(new BigDecimal("5400")) < 0,
+ "收盘价应<5400, 实际: " + k.getClose());
+ }
+ }
+
+ @Test
+ @DisplayName("假数据 — K线按时间升序排列")
+ void fakeDataSortedByTime() {
+ var klines = marketDataProvider.getKLineData("PVC2605", "1d",
+ java.time.LocalDateTime.of(2024, 9, 1, 0, 0),
+ java.time.LocalDateTime.of(2024, 12, 31, 0, 0));
+ for (int i = 1; i < klines.size(); i++) {
+ assertTrue(klines.get(i).getTime().isAfter(klines.get(i - 1).getTime()),
+ "K线应按时间升序排列,第" + i + "根: " + klines.get(i).getTime());
+ }
+ }
+}
diff --git a/src/test/java/com/yangwale/backtestify/service/SignalStrategyTest.java b/src/test/java/com/yangwale/backtestify/service/SignalStrategyTest.java
new file mode 100644
index 0000000..31be72b
--- /dev/null
+++ b/src/test/java/com/yangwale/backtestify/service/SignalStrategyTest.java
@@ -0,0 +1,216 @@
+package com.yangwale.backtestify.service;
+
+import com.yangwale.backtestify.model.dto.KLineData;
+import com.yangwale.backtestify.service.market.FakeMarketDataProvider;
+import com.yangwale.backtestify.service.signal.*;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 信号策略测试
+ * 基于 FakeMarketDataProvider 的固定假数据,验证每个策略的信号判断正确性
+ */
+@DisplayName("信号策略测试")
+class SignalStrategyTest {
+
+ private static List klines;
+ private static final MACDSignalStrategy macd = new MACDSignalStrategy();
+ private static final KDJSignalStrategy kdj = new KDJSignalStrategy();
+ private static final MASignalStrategy ma = new MASignalStrategy();
+ private static final BOLLSignalStrategy boll = new BOLLSignalStrategy();
+ private static final RSISignalStrategy rsi = new RSISignalStrategy();
+ private static final CCISignalStrategy cci = new CCISignalStrategy();
+ private static final BBISignalStrategy bbi = new BBISignalStrategy();
+ private static final SARSignalStrategy sar = new SARSignalStrategy();
+ private static final DMISignalStrategy dmi = new DMISignalStrategy();
+ private static final WRSignalStrategy wr = new WRSignalStrategy();
+ private static final ATRSignalStrategy atr = new ATRSignalStrategy();
+ private static final DonchianSignalStrategy donchian = new DonchianSignalStrategy();
+ private static final CJLSignalStrategy cjl = new CJLSignalStrategy();
+ private static final VolAmountSignalStrategy volAmount = new VolAmountSignalStrategy();
+
+ @BeforeAll
+ static void setUp() {
+ FakeMarketDataProvider provider = new FakeMarketDataProvider();
+ klines = provider.getKLineData("PVC2605", "1d",
+ LocalDateTime.of(2024, 9, 1, 0, 0),
+ LocalDateTime.of(2024, 12, 31, 0, 0));
+ assertFalse(klines.isEmpty(), "假数据不应为空");
+ }
+
+ @Test
+ @DisplayName("MACD — 策略名称正确")
+ void macdName() {
+ assertEquals("MACD", macd.name());
+ }
+
+ @Test
+ @DisplayName("MACD — 数据中有金叉和死叉信号")
+ void macdHasSignals() {
+ boolean hasBuySignal = false;
+ boolean hasSellSignal = false;
+ for (KLineData k : klines) {
+ if (macd.isBuySignal(k, klines)) hasBuySignal = true;
+ if (macd.isSellSignal(k, klines)) hasSellSignal = true;
+ }
+ assertTrue(hasBuySignal || hasSellSignal, "MACD应在数据中产生至少一种信号");
+ }
+
+ @Test
+ @DisplayName("MACD — 不会在同一根K线上同时产生买卖信号")
+ void macdNoSimultaneousSignals() {
+ for (KLineData k : klines) {
+ boolean buy = macd.isBuySignal(k, klines);
+ boolean sell = macd.isSellSignal(k, klines);
+ assertFalse(buy && sell, "MACD不应同时产生买卖信号: " + k.getTime());
+ }
+ }
+
+ @Test
+ @DisplayName("KDJ — 策略名称正确")
+ void kdjName() {
+ assertEquals("KDJ", kdj.name());
+ }
+
+ @Test
+ @DisplayName("KDJ — 不会在同一根K线上同时产生买卖信号")
+ void kdjNoSimultaneousSignals() {
+ for (KLineData k : klines) {
+ boolean buy = kdj.isBuySignal(k, klines);
+ boolean sell = kdj.isSellSignal(k, klines);
+ assertFalse(buy && sell, "KDJ不应同时产生买卖信号: " + k.getTime());
+ }
+ }
+
+ @Test
+ @DisplayName("MA — 数据中有交叉信号")
+ void maHasSignals() {
+ boolean hasBuySignal = false;
+ boolean hasSellSignal = false;
+ for (KLineData k : klines) {
+ if (ma.isBuySignal(k, klines)) hasBuySignal = true;
+ if (ma.isSellSignal(k, klines)) hasSellSignal = true;
+ }
+ assertTrue(hasBuySignal || hasSellSignal, "MA应在数据中产生至少一种信号");
+ }
+
+ @Test
+ @DisplayName("BOLL — 数据中有突破信号")
+ void bollHasSignals() {
+ boolean hasSignal = false;
+ for (KLineData k : klines) {
+ if (boll.isBuySignal(k, klines) || boll.isSellSignal(k, klines)) {
+ hasSignal = true;
+ break;
+ }
+ }
+ assertTrue(hasSignal, "BOLL应在数据中产生信号");
+ }
+
+ @Test
+ @DisplayName("RSI — 不会在同一根K线上同时产生买卖信号")
+ void rsiNoSimultaneousSignals() {
+ for (KLineData k : klines) {
+ boolean buy = rsi.isBuySignal(k, klines);
+ boolean sell = rsi.isSellSignal(k, klines);
+ assertFalse(buy && sell, "RSI不应同时产生买卖信号: " + k.getTime());
+ }
+ }
+
+ @Test
+ @DisplayName("CCI — 不会在同一根K线上同时产生买卖信号")
+ void cciNoSimultaneousSignals() {
+ for (KLineData k : klines) {
+ boolean buy = cci.isBuySignal(k, klines);
+ boolean sell = cci.isSellSignal(k, klines);
+ assertFalse(buy && sell, "CCI不应同时产生买卖信号: " + k.getTime());
+ }
+ }
+
+ @Test
+ @DisplayName("BBI — 策略名称正确")
+ void bbiName() {
+ assertEquals("BBI", bbi.name());
+ }
+
+ @Test
+ @DisplayName("SAR — 策略名称正确")
+ void sarName() {
+ assertEquals("SAR", sar.name());
+ }
+
+ @Test
+ @DisplayName("DMI — 策略名称正确")
+ void dmiName() {
+ assertEquals("DMI", dmi.name());
+ }
+
+ @Test
+ @DisplayName("WR — 策略名称正确")
+ void wrName() {
+ assertEquals("WR", wr.name());
+ }
+
+ @Test
+ @DisplayName("ATR — 策略名称正确")
+ void atrName() {
+ assertEquals("ATR", atr.name());
+ }
+
+ @Test
+ @DisplayName("DONCHIAN — 策略名称正确")
+ void donchianName() {
+ assertEquals("DONCHIAN", donchian.name());
+ }
+
+ @Test
+ @DisplayName("CJL — 策略名称正确")
+ void cjlName() {
+ assertEquals("CJL", cjl.name());
+ }
+
+ @Test
+ @DisplayName("VOL_AMOUNT — 策略名称正确")
+ void volAmountName() {
+ assertEquals("VOL_AMOUNT", volAmount.name());
+ }
+
+ @Test
+ @DisplayName("全部14个策略 — 第一根K线不产生信号(无历史数据)")
+ void firstKlineNoSignals() {
+ KLineData first = klines.get(0);
+ SignalStrategy[] strategies = {macd, kdj, ma, boll, rsi, cci, bbi, sar, dmi, wr, atr, donchian, cjl, volAmount};
+ for (SignalStrategy s : strategies) {
+ assertFalse(s.isBuySignal(first, klines),
+ s.name() + " 的第一根K线不应产生买入信号");
+ assertFalse(s.isSellSignal(first, klines),
+ s.name() + " 的第一根K线不应产生卖出信号");
+ }
+ }
+
+ @Test
+ @DisplayName("SignalStrategyFactory — 自动发现所有14个策略")
+ void factoryDiscoversAllStrategies() {
+ SignalStrategyFactory factory = new SignalStrategyFactory(List.of(
+ macd, kdj, ma, boll, rsi, cci, bbi, sar, dmi, wr, atr, donchian, cjl, volAmount));
+ List names = factory.getAllIndicatorNames();
+ assertEquals(14, names.size(), "应发现全部14个策略");
+ assertTrue(names.contains("MACD"));
+ assertTrue(names.contains("KDJ"));
+ assertTrue(names.contains("MA"));
+ assertTrue(names.contains("RSI"));
+ }
+
+ @Test
+ @DisplayName("SignalStrategyFactory — 不支持的指标抛出异常")
+ void factoryThrowsOnUnknownIndicator() {
+ SignalStrategyFactory factory = new SignalStrategyFactory(List.of(macd, kdj));
+ assertThrows(IllegalArgumentException.class, () -> factory.get("UNKNOWN_INDICATOR"));
+ }
+}
diff --git a/src/test/java/com/yangwale/backtestify/service/StrategyServiceImplTest.java b/src/test/java/com/yangwale/backtestify/service/StrategyServiceImplTest.java
new file mode 100644
index 0000000..219c513
--- /dev/null
+++ b/src/test/java/com/yangwale/backtestify/service/StrategyServiceImplTest.java
@@ -0,0 +1,250 @@
+package com.yangwale.backtestify.service;
+
+import com.yangwale.backtestify.BacktestifyApplication;
+import com.yangwale.backtestify.common.PageResult;
+import com.yangwale.backtestify.config.WebMvcConfig;
+import com.yangwale.backtestify.model.request.BacktestRequest;
+import com.yangwale.backtestify.model.response.BacktestResponse;
+import com.yangwale.backtestify.model.response.StrategyDetailResponse;
+import com.yangwale.backtestify.model.response.StrategyListItemResponse;
+import org.junit.jupiter.api.*;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 策略服务集成测试
+ */
+@SpringBootTest(classes = BacktestifyApplication.class)
+@DisplayName("策略服务集成测试")
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class StrategyServiceImplTest {
+
+ @Autowired
+ private StrategyService strategyService;
+
+ @BeforeEach
+ void setUp() {
+ // Mock the userId in ThreadLocal
+ WebMvcConfig.RequestContextHolder.setUserId(1L);
+ }
+
+ @AfterEach
+ void tearDown() {
+ WebMvcConfig.RequestContextHolder.clear();
+ }
+
+ @Test
+ @Order(1)
+ @DisplayName("执行回测 — MACD做多策略")
+ void backtestLongWithMACD() {
+ BacktestRequest request = buildRequest("LONG", List.of("MACD"), "5", "PERCENT", "10", "PERCENT");
+ BacktestResponse response = strategyService.backtest(request);
+
+ assertNotNull(response);
+ assertNotNull(response.getStrategyId());
+ assertEquals("LONG", response.getDirection());
+ assertEquals("MACD", response.getIndicators().get(0));
+ assertNotNull(response.getTotalYield());
+ assertNotNull(response.getFinalCapital());
+ assertNotNull(response.getMaxEquity());
+ assertNotNull(response.getMinEquity());
+ assertTrue(response.getMaxEquity().compareTo(response.getMinEquity()) >= 0,
+ "最大净值应 >= 最小净值");
+ assertNotNull(response.getTradeCount());
+ assertNotNull(response.getDailyEquityCurve());
+ assertNotNull(response.getTradeDetails());
+ assertNotNull(response.getSignalPoints());
+ System.out.println("=== 回测结果 ===");
+ System.out.println("策略ID: " + response.getStrategyId());
+ System.out.println("收益率: " + response.getTotalYield() + "%");
+ System.out.println("收益金额: " + response.getProfitAmount());
+ System.out.println("最大回撤: " + response.getMaxDrawdown() + "%");
+ System.out.println("交易次数: " + response.getTradeCount());
+ System.out.println("胜率: " + response.getWinRate() + "%");
+ System.out.println("最大净值: " + response.getMaxEquity());
+ System.out.println("最小净值: " + response.getMinEquity());
+ }
+
+ @Test
+ @Order(2)
+ @DisplayName("执行回测 — MACD+KDJ共振做多策略")
+ void backtestLongWithMultiIndicators() {
+ BacktestRequest request = buildRequest("LONG", List.of("MACD", "KDJ"), null, null, null, null);
+ BacktestResponse response = strategyService.backtest(request);
+
+ assertNotNull(response);
+ assertNotNull(response.getStrategyId());
+ assertEquals(2, response.getIndicators().size());
+ System.out.println("=== MACD+KDJ共振 ===");
+ System.out.println("交易次数: " + response.getTradeCount());
+ System.out.println("收益率: " + response.getTotalYield() + "%");
+ }
+
+ @Test
+ @Order(3)
+ @DisplayName("执行回测 — KDJ做空策略")
+ void backtestShortWithKDJ() {
+ BacktestRequest request = buildRequest("SHORT", List.of("KDJ"), "3", "PERCENT", "8", "PERCENT");
+ BacktestResponse response = strategyService.backtest(request);
+
+ assertNotNull(response);
+ assertEquals("SHORT", response.getDirection());
+ assertNotNull(response.getStrategyId());
+ System.out.println("=== 做空回测 ===");
+ System.out.println("收益率: " + response.getTotalYield() + "%");
+ System.out.println("交易次数: " + response.getTradeCount());
+ }
+
+ @Test
+ @Order(4)
+ @DisplayName("我的策略列表 — 返回已保存的策略")
+ void myList() {
+ PageResult result = strategyService.myList(1L, 1, 10);
+ assertNotNull(result);
+ assertTrue(result.getTotal() >= 3, "至少应有3条策略,实际: " + result.getTotal());
+ for (StrategyListItemResponse item : result.getList()) {
+ assertNotNull(item.getId());
+ assertNotNull(item.getContractCode());
+ assertNotNull(item.getDirection());
+ }
+ System.out.println("策略总数: " + result.getTotal());
+ }
+
+ @Test
+ @Order(5)
+ @DisplayName("策略详情 — 返回完整数据")
+ void detail() {
+ // 先获取一个策略ID
+ PageResult list = strategyService.myList(1L, 1, 1);
+ assertFalse(list.getList().isEmpty());
+ Long strategyId = list.getList().get(0).getId();
+
+ StrategyDetailResponse detail = strategyService.detail(strategyId);
+ assertNotNull(detail);
+ assertEquals(strategyId, detail.getStrategyId());
+ assertNotNull(detail.getContractCode());
+ assertNotNull(detail.getDirection());
+ assertNotNull(detail.getCreateTime());
+ // 应有交易明细
+ assertNotNull(detail.getTradeDetails());
+ // 应有净值曲线
+ assertNotNull(detail.getDailyEquityCurve());
+ System.out.println("策略详情ID: " + strategyId);
+ System.out.println("交易明细数: " + detail.getTradeDetails().size());
+ System.out.println("净值曲线点数: " + detail.getDailyEquityCurve().size());
+ }
+
+ @Test
+ @Order(6)
+ @DisplayName("切换信号 — 从停用到启用再到停用")
+ void toggleSignal() {
+ PageResult list = strategyService.myList(1L, 1, 1);
+ assertFalse(list.getList().isEmpty());
+ Long strategyId = list.getList().get(0).getId();
+
+ // 第一次:启用信号
+ strategyService.toggleSignal(strategyId, 1L);
+ StrategyDetailResponse detail = strategyService.detail(strategyId);
+ assertEquals(1, detail.getStatus(), "状态应为已启用信号(1)");
+ System.out.println("启用信号后状态: " + detail.getStatus());
+
+ // 第二次:停用信号
+ strategyService.toggleSignal(strategyId, 1L);
+ detail = strategyService.detail(strategyId);
+ assertEquals(0, detail.getStatus(), "状态应为已保存(0)");
+ System.out.println("停用信号后状态: " + detail.getStatus());
+ }
+
+ @Test
+ @Order(7)
+ @DisplayName("可用指标列表 — 返回14个指标")
+ void availableIndicators() {
+ List indicators = strategyService.getAvailableIndicators();
+ assertNotNull(indicators);
+ assertEquals(14, indicators.size(), "应有14个可用指标");
+ assertTrue(indicators.contains("MACD"));
+ assertTrue(indicators.contains("KDJ"));
+ assertTrue(indicators.contains("RSI"));
+ System.out.println("可用指标: " + indicators);
+ }
+
+ @Test
+ @Order(8)
+ @DisplayName("后台管理 — 分页查询全部策略")
+ void adminList() {
+ com.yangwale.backtestify.model.request.AdminStrategyPageRequest req =
+ new com.yangwale.backtestify.model.request.AdminStrategyPageRequest();
+ req.setPageNum(1);
+ req.setPageSize(10);
+
+ PageResult result = strategyService.adminList(req);
+ assertNotNull(result);
+ assertTrue(result.getTotal() >= 3);
+ System.out.println("后台查询策略总数: " + result.getTotal());
+ }
+
+ @Test
+ @Order(9)
+ @DisplayName("后台管理 — 按合约筛选")
+ void adminListFilterByContract() {
+ com.yangwale.backtestify.model.request.AdminStrategyPageRequest req =
+ new com.yangwale.backtestify.model.request.AdminStrategyPageRequest();
+ req.setPageNum(1);
+ req.setPageSize(10);
+ req.setContractCode("PVC2605");
+
+ PageResult result = strategyService.adminList(req);
+ assertNotNull(result);
+ assertTrue(result.getTotal() >= 1);
+ for (StrategyListItemResponse item : result.getList()) {
+ assertTrue(item.getContractCode().contains("PVC2605"));
+ }
+ }
+
+ @Test
+ @Order(10)
+ @DisplayName("逻辑删除策略")
+ void deleteStrategy() {
+ PageResult list = strategyService.myList(1L, 1, 1);
+ assertFalse(list.getList().isEmpty());
+ Long strategyId = list.getList().get(0).getId();
+
+ strategyService.delete(strategyId, 1L);
+
+ // 删除后详情应不可见
+ assertThrows(com.yangwale.backtestify.common.BusinessException.class,
+ () -> strategyService.detail(strategyId));
+ System.out.println("策略 " + strategyId + " 已逻辑删除");
+ }
+
+ // ==================== 辅助方法 ====================
+
+ private BacktestRequest buildRequest(String direction, List indicators,
+ String stopLoss, String slUnit,
+ String takeProfit, String tpUnit) {
+ BacktestRequest request = new BacktestRequest();
+ request.setContractCode("PVC2605");
+ request.setContractName("PVC主连");
+ request.setDirection(direction);
+ request.setKlinePeriod("1d");
+ request.setIndicators(indicators);
+ request.setOpenVolume(1);
+ request.setVolumeUnit("LOT");
+ if (stopLoss != null) {
+ request.setStopLossValue(new BigDecimal(stopLoss));
+ request.setStopLossUnit(slUnit);
+ }
+ if (takeProfit != null) {
+ request.setTakeProfitValue(new BigDecimal(takeProfit));
+ request.setTakeProfitUnit(tpUnit);
+ }
+ request.setBacktestPeriod("3m");
+ request.setUserId(1L);
+ return request;
+ }
+}
diff --git a/src/test/java/com/yangwale/backtestify/service/market/convert/PriceScaleConverterTest.java b/src/test/java/com/yangwale/backtestify/service/market/convert/PriceScaleConverterTest.java
new file mode 100644
index 0000000..a8ee8d3
--- /dev/null
+++ b/src/test/java/com/yangwale/backtestify/service/market/convert/PriceScaleConverterTest.java
@@ -0,0 +1,30 @@
+package com.yangwale.backtestify.service.market.convert;
+
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class PriceScaleConverterTest {
+
+ @Test
+ void convertsRawPriceToScaledInteger() {
+ assertEquals(52050, PriceScaleConverter.toScaled("520.50", 100));
+ assertEquals(7350, PriceScaleConverter.toScaled("7350", 1));
+ assertEquals(86235, PriceScaleConverter.toScaled("86.235", 1000));
+ }
+
+ @Test
+ void convertsScaledIntegerToRawPrice() {
+ assertEquals(new BigDecimal("520.50"), PriceScaleConverter.toRaw(52050, 100));
+ assertEquals(new BigDecimal("7350"), PriceScaleConverter.toRaw(7350, 1));
+ assertEquals(new BigDecimal("86.235"), PriceScaleConverter.toRaw(86235, 1000));
+ }
+
+ @Test
+ void rejectsBlankPrice() {
+ assertThrows(IllegalArgumentException.class, () -> PriceScaleConverter.toScaled("", 100));
+ }
+}
diff --git a/src/test/java/com/yangwale/backtestify/service/market/repository/KLineTableResolverTest.java b/src/test/java/com/yangwale/backtestify/service/market/repository/KLineTableResolverTest.java
new file mode 100644
index 0000000..8435da6
--- /dev/null
+++ b/src/test/java/com/yangwale/backtestify/service/market/repository/KLineTableResolverTest.java
@@ -0,0 +1,29 @@
+package com.yangwale.backtestify.service.market.repository;
+
+import com.yangwale.backtestify.common.BusinessException;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class KLineTableResolverTest {
+
+ private final KLineTableResolver resolver = new KLineTableResolver();
+
+ @Test
+ void resolvesSupportedRealMarketPeriods() {
+ assertEquals("t_kline_1m", resolver.resolve("1m"));
+ assertEquals("t_kline_5m", resolver.resolve("5m"));
+ assertEquals("t_kline_15m", resolver.resolve("15m"));
+ assertEquals("t_kline_30m", resolver.resolve("30m"));
+ assertEquals("t_kline_1h", resolver.resolve("1h"));
+ assertEquals("t_kline_4h", resolver.resolve("4h"));
+ assertEquals("t_kline_1d", resolver.resolve("1d"));
+ assertEquals("t_kline_1w", resolver.resolve("1w"));
+ }
+
+ @Test
+ void rejects3mBecauseRealMarketDoesNotProvideIt() {
+ assertThrows(BusinessException.class, () -> resolver.resolve("3m"));
+ }
+}
diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml
new file mode 100644
index 0000000..c8ae570
--- /dev/null
+++ b/src/test/resources/application.yml
@@ -0,0 +1,51 @@
+spring:
+ application:
+ name: backtestify-test
+
+ datasource:
+ driver-class-name: org.h2.Driver
+ url: jdbc:h2:mem:backtestify_test;MODE=MYSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;NON_KEYWORDS=USER
+ username: sa
+ password:
+
+ sql:
+ init:
+ schema-locations: classpath:schema-test.sql
+ mode: always
+
+ autoconfigure:
+ exclude:
+ - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
+
+ data:
+ redis:
+ host: localhost
+ port: 6379
+ timeout: 3000ms
+ lettuce:
+ pool:
+ max-active: 8
+ max-idle: 8
+ min-idle: 0
+
+market-data:
+ provider: fake
+ sync:
+ enabled: false
+
+mybatis-plus:
+ global-config:
+ db-config:
+ id-type: auto
+ logic-delete-field: is_deleted
+ logic-delete-value: 1
+ logic-not-delete-value: 0
+ configuration:
+ map-underscore-to-camel-case: true
+ log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
+
+backtest:
+ initial-capital: 1000000
+ margin-ratio: 0.10
+ fee-rate: 0.00005
+ risk-free-rate: 0.025
diff --git a/src/test/resources/schema-test.sql b/src/test/resources/schema-test.sql
new file mode 100644
index 0000000..c8e570b
--- /dev/null
+++ b/src/test/resources/schema-test.sql
@@ -0,0 +1,197 @@
+-- H2 测试数据库建表脚本 (MySQL兼容模式)
+CREATE TABLE IF NOT EXISTS t_instrument_dictionary (
+ id SMALLINT AUTO_INCREMENT PRIMARY KEY,
+ exchange_id VARCHAR(16),
+ symbol VARCHAR(10) NOT NULL,
+ contract_code VARCHAR(20) NOT NULL,
+ price_scale INT NOT NULL DEFAULT 100,
+ is_main TINYINT NOT NULL DEFAULT 0,
+ create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ is_deleted TINYINT NOT NULL DEFAULT 0,
+ UNIQUE (contract_code)
+);
+
+CREATE TABLE IF NOT EXISTS t_kline_1m (
+ instrument_id SMALLINT NOT NULL,
+ timestamp INT NOT NULL,
+ open INT NOT NULL,
+ high INT NOT NULL,
+ low INT NOT NULL,
+ close INT NOT NULL,
+ volume INT NOT NULL DEFAULT 0,
+ turnover BIGINT NOT NULL DEFAULT 0,
+ open_interest INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (instrument_id, timestamp)
+);
+CREATE TABLE IF NOT EXISTS t_kline_5m (
+ instrument_id SMALLINT NOT NULL,
+ timestamp INT NOT NULL,
+ open INT NOT NULL,
+ high INT NOT NULL,
+ low INT NOT NULL,
+ close INT NOT NULL,
+ volume INT NOT NULL DEFAULT 0,
+ turnover BIGINT NOT NULL DEFAULT 0,
+ open_interest INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (instrument_id, timestamp)
+);
+CREATE TABLE IF NOT EXISTS t_kline_15m (
+ instrument_id SMALLINT NOT NULL,
+ timestamp INT NOT NULL,
+ open INT NOT NULL,
+ high INT NOT NULL,
+ low INT NOT NULL,
+ close INT NOT NULL,
+ volume INT NOT NULL DEFAULT 0,
+ turnover BIGINT NOT NULL DEFAULT 0,
+ open_interest INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (instrument_id, timestamp)
+);
+CREATE TABLE IF NOT EXISTS t_kline_30m (
+ instrument_id SMALLINT NOT NULL,
+ timestamp INT NOT NULL,
+ open INT NOT NULL,
+ high INT NOT NULL,
+ low INT NOT NULL,
+ close INT NOT NULL,
+ volume INT NOT NULL DEFAULT 0,
+ turnover BIGINT NOT NULL DEFAULT 0,
+ open_interest INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (instrument_id, timestamp)
+);
+CREATE TABLE IF NOT EXISTS t_kline_1h (
+ instrument_id SMALLINT NOT NULL,
+ timestamp INT NOT NULL,
+ open INT NOT NULL,
+ high INT NOT NULL,
+ low INT NOT NULL,
+ close INT NOT NULL,
+ volume INT NOT NULL DEFAULT 0,
+ turnover BIGINT NOT NULL DEFAULT 0,
+ open_interest INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (instrument_id, timestamp)
+);
+CREATE TABLE IF NOT EXISTS t_kline_4h (
+ instrument_id SMALLINT NOT NULL,
+ timestamp INT NOT NULL,
+ open INT NOT NULL,
+ high INT NOT NULL,
+ low INT NOT NULL,
+ close INT NOT NULL,
+ volume INT NOT NULL DEFAULT 0,
+ turnover BIGINT NOT NULL DEFAULT 0,
+ open_interest INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (instrument_id, timestamp)
+);
+CREATE TABLE IF NOT EXISTS t_kline_1d (
+ instrument_id SMALLINT NOT NULL,
+ timestamp INT NOT NULL,
+ open INT NOT NULL,
+ high INT NOT NULL,
+ low INT NOT NULL,
+ close INT NOT NULL,
+ volume INT NOT NULL DEFAULT 0,
+ turnover BIGINT NOT NULL DEFAULT 0,
+ open_interest INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (instrument_id, timestamp)
+);
+CREATE TABLE IF NOT EXISTS t_kline_1w (
+ instrument_id SMALLINT NOT NULL,
+ timestamp INT NOT NULL,
+ open INT NOT NULL,
+ high INT NOT NULL,
+ low INT NOT NULL,
+ close INT NOT NULL,
+ volume INT NOT NULL DEFAULT 0,
+ turnover BIGINT NOT NULL DEFAULT 0,
+ open_interest INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (instrument_id, timestamp)
+);
+
+CREATE TABLE IF NOT EXISTS t_market_data_sync_log (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ sync_type VARCHAR(32) NOT NULL,
+ period VARCHAR(10),
+ contract_code VARCHAR(20),
+ sync_date DATE,
+ status VARCHAR(16) NOT NULL,
+ success_count INT NOT NULL DEFAULT 0,
+ error_message TEXT,
+ start_time TIMESTAMP NOT NULL,
+ end_time TIMESTAMP,
+ create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS bt_strategy_config (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ user_id BIGINT NOT NULL,
+ contract_code VARCHAR(32) NOT NULL,
+ contract_name VARCHAR(64) NOT NULL,
+ direction VARCHAR(10) NOT NULL,
+ kline_period VARCHAR(10) NOT NULL,
+ indicators TEXT,
+ open_volume INT NOT NULL DEFAULT 1,
+ volume_unit VARCHAR(10) NOT NULL DEFAULT 'LOT',
+ stop_loss_value DECIMAL(18,4),
+ stop_loss_unit VARCHAR(10),
+ take_profit_value DECIMAL(18,4),
+ take_profit_unit VARCHAR(10),
+ backtest_period VARCHAR(10) NOT NULL,
+ status TINYINT NOT NULL DEFAULT 0,
+ create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ is_deleted TINYINT NOT NULL DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS bt_strategy_result (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ strategy_id BIGINT NOT NULL,
+ initial_capital DECIMAL(18,2) NOT NULL,
+ final_capital DECIMAL(18,2) NOT NULL,
+ max_equity DECIMAL(18,2) NOT NULL,
+ min_equity DECIMAL(18,2) NOT NULL,
+ total_yield DECIMAL(10,4) NOT NULL,
+ profit_amount DECIMAL(18,2) NOT NULL,
+ annualized_yield DECIMAL(10,4) NOT NULL,
+ trade_count INT NOT NULL DEFAULT 0,
+ max_drawdown DECIMAL(10,4) NOT NULL,
+ sharpe_ratio DECIMAL(10,4) NOT NULL,
+ win_rate DECIMAL(10,4) NOT NULL,
+ start_date DATE NOT NULL,
+ end_date DATE NOT NULL,
+ daily_equity_curve TEXT,
+ create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE (strategy_id)
+);
+
+CREATE TABLE IF NOT EXISTS bt_trade_detail (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ strategy_id BIGINT NOT NULL,
+ action VARCHAR(20) NOT NULL,
+ price DECIMAL(18,4) NOT NULL,
+ volume INT NOT NULL,
+ turnover DECIMAL(18,2) NOT NULL,
+ trade_time TIMESTAMP NOT NULL,
+ kline_time TIMESTAMP NOT NULL,
+ signal_type VARCHAR(5) NOT NULL,
+ create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS bt_user_signal (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ user_id BIGINT NOT NULL,
+ strategy_id BIGINT NOT NULL,
+ contract_code VARCHAR(32) NOT NULL,
+ kline_period VARCHAR(10) NOT NULL,
+ is_active TINYINT NOT NULL DEFAULT 1,
+ create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ is_deleted TINYINT NOT NULL DEFAULT 0
+);
+
+CREATE INDEX IF NOT EXISTS idx_sc_user_id ON bt_strategy_config(user_id);
+CREATE INDEX IF NOT EXISTS idx_sc_contract_code ON bt_strategy_config(contract_code);
+CREATE INDEX IF NOT EXISTS idx_td_strategy_id ON bt_trade_detail(strategy_id);
+CREATE INDEX IF NOT EXISTS idx_td_trade_time ON bt_trade_detail(trade_time);
+CREATE INDEX IF NOT EXISTS idx_us_ucp ON bt_user_signal(user_id, contract_code, kline_period);