This commit is contained in:
Lee 2026-07-13 19:28:43 +08:00
commit 61dd0073d5
93 changed files with 7477 additions and 0 deletions

186
pom.xml Normal file
View File

@ -0,0 +1,186 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
</parent>
<groupId>com.yangwale</groupId>
<artifactId>backtestify</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>backtestify</name>
<description>期货策略回测系统</description>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<mybatis-plus.version>3.5.12</mybatis-plus.version>
<mysql.version>8.0.28</mysql.version>
<druid.version>1.2.25</druid.version>
<fastjson2.version>2.0.54</fastjson2.version>
<okhttp.version>5.0.0-alpha.14</okhttp.version>
<springdoc.version>2.6.0</springdoc.version>
<lombok.version>1.18.42</lombok.version>
<commons-pool2.version>2.13.1</commons-pool2.version>
</properties>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MySQL -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
<scope>runtime</scope>
</dependency>
<!-- Druid -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-3-starter</artifactId>
<version>${druid.version}</version>
</dependency>
<!-- Fastjson2 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
<!-- OkHttp -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>${okhttp.version}</version>
</dependency>
<!-- SpringDoc OpenAPI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<!-- Commons Pool2 (Redis连接池) -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>${commons-pool2.version}</version>
</dependency>
<!-- H2 for testing -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<!-- <version>4.0.56.Final</version>-->
</dependency><!-- 简化protobuf的使用 -->
<dependency>
<groupId>com.baidu</groupId>
<artifactId>jprotobuf</artifactId>
<version>2.4.21</version>
<exclusions>
<exclusion>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>3.21.7</version>
</dependency>
<dependency>
<groupId>com.baidu</groupId>
<artifactId>jprotobuf-precompile-plugin</artifactId>
<version>2.2.8</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
</project>

View File

@ -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);
}
}

View File

@ -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<T> {
@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 <T> ApiResult<T> success(T data) {
return new ApiResult<>(ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), data);
}
public static <T> ApiResult<T> success() {
return new ApiResult<>(ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), null);
}
public static <T> ApiResult<T> error(ErrorCode errorCode) {
return new ApiResult<>(errorCode.getCode(), errorCode.getMessage(), null);
}
public static <T> ApiResult<T> error(ErrorCode errorCode, String message) {
return new ApiResult<>(errorCode.getCode(), message, null);
}
public static <T> ApiResult<T> error(int code, String message) {
return new ApiResult<>(code, message, null);
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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<Void> 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<Void> 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<Void> 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<Void> handleException(Exception e) {
log.error("系统异常", e);
return ApiResult.error(ErrorCode.INTERNAL_ERROR);
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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<T> {
@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<T> list;
private PageResult(long total, long pages, int pageNum, int pageSize, List<T> list) {
this.total = total;
this.pages = pages;
this.pageNum = pageNum;
this.pageSize = pageSize;
this.list = list;
}
public static <T> PageResult<T> of(long total, int pageNum, int pageSize, List<T> list) {
long pages = (total + pageSize - 1) / pageSize;
return new PageResult<>(total, pages, pageNum, pageSize, list != null ? list : Collections.emptyList());
}
public static <T> PageResult<T> empty(int pageNum, int pageSize) {
return new PageResult<>(0, 0, pageNum, pageSize, Collections.emptyList());
}
}

View File

@ -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;
}
}

View File

@ -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<String> periods = List.of("1m", "5m", "15m", "30m", "1h", "4h", "1d", "1w");
public ZoneId zoneId() {
return ZoneId.of(zone);
}
}
}

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> 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;
}
}

View File

@ -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 {
}

View File

@ -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<Long> 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();
}
}
}

View File

@ -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<PageResult<StrategyListItemResponse>> list(@Valid @RequestBody AdminStrategyPageRequest request) {
PageResult<StrategyListItemResponse> result = strategyService.adminList(request);
return ApiResult.success(result);
}
@Operation(summary = "策略详情")
@GetMapping("/detail/{id}")
public ApiResult<StrategyDetailResponse> detail(@PathVariable Long id) {
StrategyDetailResponse result = strategyService.detail(id);
return ApiResult.success(result);
}
}

View File

@ -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<BacktestResponse> backtest(@Valid @RequestBody BacktestRequest request) {
injectUserId(request);
BacktestResponse response = strategyService.backtest(request);
return ApiResult.success(response);
}
@Operation(summary = "我的策略列表")
@GetMapping("/my-list")
public ApiResult<PageResult<StrategyListItemResponse>> myList(
@RequestParam(defaultValue = "1") @Min(1) int pageNum,
@RequestParam(defaultValue = "10") @Min(1) int pageSize) {
Long userId = WebMvcConfig.RequestContextHolder.getUserId();
PageResult<StrategyListItemResponse> result = strategyService.myList(userId, pageNum, pageSize);
return ApiResult.success(result);
}
@Operation(summary = "策略详情")
@GetMapping("/detail/{id}")
public ApiResult<StrategyDetailResponse> detail(@PathVariable Long id) {
StrategyDetailResponse result = strategyService.detail(id);
return ApiResult.success(result);
}
@Operation(summary = "删除策略")
@DeleteMapping("/{id}")
public ApiResult<Void> delete(@PathVariable Long id) {
Long userId = WebMvcConfig.RequestContextHolder.getUserId();
strategyService.delete(id, userId);
return ApiResult.success();
}
@Operation(summary = "切换信号启用/停用")
@PutMapping("/{id}/signal-toggle")
public ApiResult<Void> toggleSignal(@PathVariable Long id) {
Long userId = WebMvcConfig.RequestContextHolder.getUserId();
strategyService.toggleSignal(id, userId);
return ApiResult.success();
}
@Operation(summary = "查询K线图信号标记点")
@GetMapping("/signals")
public ApiResult<List<SignalPointResponse>> getSignals(@Valid SignalQueryRequest request) {
injectUserId(request);
List<SignalPointResponse> result = strategyService.getSignals(request);
return ApiResult.success(result);
}
@Operation(summary = "获取可用指标列表")
@GetMapping("/indicators")
public ApiResult<List<String>> getIndicators() {
List<String> 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);
}
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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");
}
}

View File

@ -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);
}
}

View File

@ -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");
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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");
}
}

View File

@ -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<InstrumentDictionary> {
}

View File

@ -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<KLineRecord> selectRange(@Param("tableName") String tableName,
@Param("instrumentId") Integer instrumentId,
@Param("startTimestamp") long startTimestamp,
@Param("endTimestamp") long endTimestamp);
@Insert("""
<script>
INSERT INTO ${tableName}
(instrument_id, timestamp, open, high, low, close, volume, turnover, open_interest)
VALUES
<foreach collection="records" item="item" separator=",">
(#{item.instrumentId}, #{item.timestamp}, #{item.open}, #{item.high}, #{item.low}, #{item.close},
#{item.volume}, #{item.turnover}, #{item.openInterest})
</foreach>
ON DUPLICATE KEY UPDATE
open = VALUES(open),
high = VALUES(high),
low = VALUES(low),
close = VALUES(close),
volume = VALUES(volume),
turnover = VALUES(turnover),
open_interest = VALUES(open_interest)
</script>
""")
int upsertBatch(@Param("tableName") String tableName,
@Param("records") List<KLineRecord> records);
}

View File

@ -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<MarketDataSyncLog> {
}

View File

@ -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<StrategyConfig> {
}

View File

@ -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<StrategyResult> {
}

View File

@ -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<TradeDetail> {
}

View File

@ -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<UserSignal> {
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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
}
}

View File

@ -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;
}

View File

@ -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<String> 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;
}

View File

@ -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;
}

View File

@ -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<String> 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<DailyEquityPoint> dailyEquityCurve;
// 交易明细
private List<TradeDetailItem> tradeDetails;
// 信号标记点
private List<SignalPointItem> 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;
}
}

View File

@ -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;
}

View File

@ -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<String> 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<BacktestResponse.DailyEquityPoint> dailyEquityCurve;
// 交易明细
private List<BacktestResponse.TradeDetailItem> tradeDetails;
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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<KLineData> getKLineData(String contractCode, String period,
LocalDateTime startTime, LocalDateTime endTime);
/**
* 获取可用合约列表
*/
List<String> getAvailableContracts();
/**
* 验证合约是否存在
*/
void validateContract(String contractCode);
}

View File

@ -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<StrategyListItemResponse> 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<SignalPointResponse> getSignals(SignalQueryRequest request);
/**
* 获取可用指标列表
*/
List<String> getAvailableIndicators();
/**
* 后台分页查询全部策略
*/
PageResult<StrategyListItemResponse> adminList(AdminStrategyPageRequest request);
}

View File

@ -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<SignalStrategy> strategies = signalStrategyFactory.get(request.getIndicators());
// 2. 计算回测时间范围
LocalDateTime now = LocalDateTime.now();
LocalDateTime startTime = calcStartTime(now, request.getBacktestPeriod());
// 3. 获取历史K线数据
List<KLineData> 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<TradeDetail> 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<BigDecimal> 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<BacktestContext.TradeRecord> 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<BacktestContext.DailyEquity> 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<TradeDetail> saveTradeDetails(Long strategyId,
List<BacktestContext.TradeRecord> records) {
List<TradeDetail> 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<TradeDetail> tradeDetails) {
List<BacktestResponse.DailyEquityPoint> curve = ctx.dailyEquityCurve.stream()
.map(d -> BacktestResponse.DailyEquityPoint.builder()
.date(d.date)
.equity(d.equity)
.yield(d.yield)
.build())
.toList();
List<BacktestResponse.TradeDetailItem> 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<BacktestResponse.SignalPointItem> 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<TradeRecord> tradeRecords = new ArrayList<>();
List<DailyEquity> dailyEquityCurve = new ArrayList<>();
List<BigDecimal> 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;
}
}
}
}

View File

@ -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<String, Object> 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<KLineData> 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<KLineData> cached = (List<KLineData>) 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<KLineData> 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<String> 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;
}
}

View File

@ -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<StrategyListItemResponse> myList(Long userId, int pageNum, int pageSize) {
LambdaQueryWrapper<StrategyConfig> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(StrategyConfig::getUserId, userId)
.orderByDesc(StrategyConfig::getCreateTime);
IPage<StrategyConfig> page = strategyConfigMapper.selectPage(
new Page<>(pageNum, pageSize), wrapper);
List<StrategyListItemResponse> 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<StrategyResult>()
.eq(StrategyResult::getStrategyId, strategyId));
List<TradeDetail> trades = tradeDetailMapper.selectList(
new LambdaQueryWrapper<TradeDetail>()
.eq(TradeDetail::getStrategyId, strategyId)
.orderByAsc(TradeDetail::getTradeTime));
List<String> indicators = parseJsonArray(config.getIndicators());
List<BacktestResponse.DailyEquityPoint> curve = Collections.emptyList();
if (result != null && result.getDailyEquityCurve() != null) {
curve = JSON.parseArray(result.getDailyEquityCurve(), BacktestResponse.DailyEquityPoint.class);
}
List<BacktestResponse.TradeDetailItem> 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<UserSignal>()
.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<UserSignal>()
.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<SignalPointResponse> getSignals(SignalQueryRequest request) {
Long userId = request.getUserId();
// 找到该用户在该合约+周期下的所有启用信号
List<UserSignal> activeSignals = userSignalMapper.selectList(
new LambdaQueryWrapper<UserSignal>()
.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<SignalPointResponse> 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<TradeDetail> trades = tradeDetailMapper.selectList(
new LambdaQueryWrapper<TradeDetail>()
.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<String> getAvailableIndicators() {
return signalStrategyFactory.getAllIndicatorNames();
}
@Override
public PageResult<StrategyListItemResponse> adminList(AdminStrategyPageRequest request) {
LambdaQueryWrapper<StrategyConfig> 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<StrategyConfig> page = strategyConfigMapper.selectPage(
new Page<>(request.getPageNum(), request.getPageSize()), wrapper);
List<StrategyListItemResponse> 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<StrategyResult>()
.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<String> 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<SignalPointResponse> resolveSignalConflicts(List<SignalPointResponse> 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());
}
}

View File

@ -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<String> PERIODS = Set.of("1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d", "1w");
/** 预生成的日K数据按时间升序 */
private final List<KLineData> dailyData;
public FakeMarketDataProvider() {
this.dailyData = generateDailyData();
}
@Override
public List<KLineData> 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<String> getAvailableContracts() {
return List.of(CONTRACT);
}
@Override
public boolean supportsContract(String contractCode) {
return CONTRACT.equals(contractCode);
}
// ==================== 数据生成 ====================
/**
* 根据周期返回对应数据
*/
private List<KLineData> 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<KLineData> generateDailyData() {
List<KLineData> 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) 价格 51005300
// Phase 3: 下跌趋势 (days 36-50) 价格 53004850
// Phase 4: 反弹恢复 (days 51-65) 价格 48505150
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<KLineData> generateHourlyFromDaily() {
List<KLineData> 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<KLineData> generate3MinFromDaily() {
List<KLineData> 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<KLineData> generateWeeklyFromDaily() {
List<KLineData> 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);
}
}

View File

@ -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<KLineData> getKLineData(String contractCode, String period,
LocalDateTime startTime, LocalDateTime endTime);
/**
* 获取支持的合约列表
*/
List<String> getAvailableContracts();
/**
* 检查是否支持该合约
*/
boolean supportsContract(String contractCode);
}

View File

@ -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<KLineData> 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<KLineRecord> records = kLineMapper.selectRange(tableName, instrument.getId(), lookbackStart, end);
List<KLineData> 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<String> getAvailableContracts() {
return instrumentDictionaryMapper.selectList(new LambdaQueryWrapper<InstrumentDictionary>()
.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<InstrumentDictionary>()
.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();
}
}

View File

@ -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<CnQuotationModels.GoodsItem> listMainContracts() {
String body = get(urlBuilder("goods/list")
.addQueryParameter("pageSize", "-1")
.build());
CnQuotationModels.ResultModel<CnQuotationModels.GoodsPage> result = JSON.parseObject(body,
new TypeReference<CnQuotationModels.ResultModel<CnQuotationModels.GoodsPage>>() {
});
CnQuotationModels.GoodsPage data = unwrap(result);
return data == null || data.list() == null ? List.of() : data.list();
}
public List<CnQuotationModels.KChartItem> 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<CnQuotationModels.KChartResult> result = JSON.parseObject(body,
new TypeReference<CnQuotationModels.ResultModel<CnQuotationModels.KChartResult>>() {
});
CnQuotationModels.KChartResult data = unwrap(result);
return data == null || data.chats() == null ? List.of() : data.chats();
}
public List<CnQuotationModels.KChartItem> 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<CnQuotationModels.KChartResult> result = JSON.parseObject(body,
new TypeReference<CnQuotationModels.ResultModel<CnQuotationModels.KChartResult>>() {
});
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> T unwrap(CnQuotationModels.ResultModel<T> 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;
}
}

View File

@ -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<T>(Boolean success, String errorCode, String errorInfo, T data) {
}
public record GoodsPage(List<GoodsItem> list) {
}
public record GoodsItem(String excode,
String goodsCode,
String productId,
String goodsName,
String mainContractCode,
Integer isPrincipal,
Integer decimalPrecision) {
}
public record KChartResult(List<KChartItem> 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<String> symbols) {
}
@SuppressWarnings("unused")
public record MainContractBatchResult(Map<String, Integer> data) {
}
}

View File

@ -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;
}
}

View File

@ -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<KLineData> attachIndicators(List<KLineData> 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<KLineData> 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);
}
}

View File

@ -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<String, String> 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<String> supportedPeriods() {
return TABLES.keySet();
}
}

View File

@ -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<InstrumentDictionary> instruments = instrumentDictionaryMapper.selectList(
new LambdaQueryWrapper<InstrumentDictionary>()
.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<CnQuotationModels.GoodsItem> goodsItems = cnQuotationClient.listMainContracts();
instrumentDictionaryMapper.update(null, new LambdaUpdateWrapper<InstrumentDictionary>()
.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<CnQuotationModels.KChartItem> items = cnQuotationClient.getKChartByDate(
instrument.getExchangeId(), instrument.getContractCode(), period, startTimestamp, "after");
List<KLineRecord> 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<InstrumentDictionary>()
.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+$", "");
}
}

View File

@ -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<KLineData> 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<KLineData> 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<KLineData> 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; }
}

View File

@ -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<KLineData> 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<KLineData> 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<KLineData> 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; }
}

View File

@ -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<KLineData> 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<KLineData> 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<KLineData> 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; }
}

View File

@ -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<KLineData> 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<KLineData> 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<KLineData> 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; }
}

View File

@ -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<KLineData> 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<KLineData> 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<KLineData> 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<KLineData> history) {
for (int i = 0; i < history.size(); i++) {
if (history.get(i).getTime().equals(current.getTime())) return i;
}
return -1;
}
}

View File

@ -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<KLineData> 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<KLineData> 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<KLineData> 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; }
}

View File

@ -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<KLineData> 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<KLineData> 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<KLineData> 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; }
}

View File

@ -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<KLineData> 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<KLineData> 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<KLineData> 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; }
}

View File

@ -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<KLineData> 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<KLineData> 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<KLineData> 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;
}
}

View File

@ -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<KLineData> 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<KLineData> 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<KLineData> 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; }
}

View File

@ -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<KLineData> 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<KLineData> 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<KLineData> 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; }
}

View File

@ -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<KLineData> 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<KLineData> 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<KLineData> 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; }
}

View File

@ -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<KLineData> history);
/** 当前K线是否产生卖出信号 */
boolean isSellSignal(KLineData current, List<KLineData> history);
}

View File

@ -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<String, SignalStrategy> strategyMap;
public SignalStrategyFactory(List<SignalStrategy> 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<SignalStrategy> get(List<String> indicatorNames) {
return indicatorNames.stream()
.map(this::get)
.collect(Collectors.toList());
}
/**
* 获取所有支持的指标名称
*/
public List<String> getAllIndicatorNames() {
return strategyMap.keySet().stream().sorted().collect(Collectors.toList());
}
}

View File

@ -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<KLineData> 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<KLineData> 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<KLineData> 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<KLineData> history) {
for (int i = 0; i < history.size(); i++) {
if (history.get(i).getTime().equals(current.getTime())) return i;
}
return -1;
}
}

View File

@ -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<KLineData> 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<KLineData> 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<KLineData> 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; }
}

View File

@ -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

View File

@ -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='用户信号标记表';

View File

@ -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<T>`
```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<QuotationData>` |
| 2 | GET | `/realTime/main` | 主力合约主连实时行情 | `Collection<GroupByQuotationModel>` |
| 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<ExchangeCategory>` |
| 8 | GET | `/excode/list` | 交易所列表(含商品数量与二级分类) | `List<ExcodeCountModel>` |
| 9 | GET | `/goods/list` | 商品及主力合约列表(分页/筛选/排序) | `PageResult<GoodsWithMainContractVO>` |
| 10 | POST | `/mainContract/batch` | 批量查询主力标记 | `Map<String,Integer>` |
| 11 | GET | `/contract/list` | 关键词模糊查询合约 | `List<QuotationsFuturesContract>` |
| 12 | GET | `/goods/detail` | 单个商品详情 | `QuotationsGoods` |
| 13 | GET | `/contract/detail` | 单个合约详情(含东财详情) | `QuotationsFuturesContract` |
| 14 | GET | `/hotContracts` | 热门合约(近 30 分钟成交量 TOP10 | `List<QuotationsFuturesContract>` |
---
## 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(<ResultModel JSON>)`,用于浏览器跨域。
**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<symbol, isPrincipal>``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 | 成交额 |
### KChartV3ModelK 线数据点,继承 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.*` 为准;本表覆盖前端常用字段。

View File

@ -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<String, Object> 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.<StrategyListItemResponse>of();
when(strategyService.adminList(any())).thenReturn(PageResult.empty(1, 10));
Map<String, Object> 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));
}
}

View File

@ -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<String, Object> 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<String, Object> 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.<StrategyListItemResponse>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));
}
}

View File

@ -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());
}
}
}

View File

@ -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<KLineData> 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<String> 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"));
}
}

View File

@ -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<StrategyListItemResponse> 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<StrategyListItemResponse> 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<StrategyListItemResponse> 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<String> 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<StrategyListItemResponse> 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<StrategyListItemResponse> 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<StrategyListItemResponse> 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<String> 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;
}
}

View File

@ -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));
}
}

View File

@ -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"));
}
}

View File

@ -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

View File

@ -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);