commit 61dd0073d53c19eae1502721fee47a0e5b7437d3 Author: Lee <1633292@qq.com> Date: Mon Jul 13 19:28:43 2026 +0800 init 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);