init
This commit is contained in:
commit
61dd0073d5
186
pom.xml
Normal file
186
pom.xml
Normal 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>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
53
src/main/java/com/yangwale/backtestify/common/ApiResult.java
Normal file
53
src/main/java/com/yangwale/backtestify/common/ApiResult.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
38
src/main/java/com/yangwale/backtestify/common/ErrorCode.java
Normal file
38
src/main/java/com/yangwale/backtestify/common/ErrorCode.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
27
src/main/java/com/yangwale/backtestify/enums/Direction.java
Normal file
27
src/main/java/com/yangwale/backtestify/enums/Direction.java
Normal 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");
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
27
src/main/java/com/yangwale/backtestify/enums/StopUnit.java
Normal file
27
src/main/java/com/yangwale/backtestify/enums/StopUnit.java
Normal 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");
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
27
src/main/java/com/yangwale/backtestify/enums/VolumeUnit.java
Normal file
27
src/main/java/com/yangwale/backtestify/enums/VolumeUnit.java
Normal 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");
|
||||
}
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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) 价格 5100→5300
|
||||
// Phase 3: 下跌趋势 (days 36-50) 价格 5300→4850
|
||||
// Phase 4: 反弹恢复 (days 51-65) 价格 4850→5150
|
||||
|
||||
double[] closes = new double[65];
|
||||
double[] opens = new double[65];
|
||||
double[] highs = new double[65];
|
||||
double[] lows = new double[65];
|
||||
double[] volumes = new double[65];
|
||||
|
||||
// 生成收盘价序列
|
||||
Random rng = new Random(42); // 固定种子,确保可重复
|
||||
double price = 5050.0;
|
||||
|
||||
for (int i = 0; i < 65; i++) {
|
||||
double trend;
|
||||
if (i < 16) {
|
||||
trend = (rng.nextDouble() - 0.5) * 30; // 震荡 ±15
|
||||
} else if (i < 36) {
|
||||
trend = 10 + rng.nextDouble() * 15; // 上升 +10~25
|
||||
} else if (i < 51) {
|
||||
trend = -15 + rng.nextDouble() * -10; // 下跌 -15~-25
|
||||
} else {
|
||||
trend = 15 + rng.nextDouble() * 10; // 反弹 +15~25
|
||||
}
|
||||
price += trend;
|
||||
price = Math.max(4800, Math.min(5350, price));
|
||||
closes[i] = price;
|
||||
}
|
||||
|
||||
// 生成开高低量
|
||||
for (int i = 0; i < 65; i++) {
|
||||
double c = closes[i];
|
||||
double prevC = i > 0 ? closes[i - 1] : 5000.0;
|
||||
double dailyRange = 15 + rng.nextDouble() * 25;
|
||||
double o = prevC + (rng.nextDouble() - 0.5) * 10;
|
||||
double h = Math.max(o, c) + rng.nextDouble() * dailyRange * 0.4;
|
||||
double l = Math.min(o, c) - rng.nextDouble() * dailyRange * 0.6;
|
||||
opens[i] = o;
|
||||
highs[i] = Math.max(h, Math.max(o, c));
|
||||
lows[i] = Math.min(l, Math.min(o, c));
|
||||
volumes[i] = 5000 + rng.nextDouble() * 15000;
|
||||
}
|
||||
|
||||
// 计算各指标序列
|
||||
double[] ma5 = calcMA(closes, 5);
|
||||
double[] ma10 = calcMA(closes, 10);
|
||||
double[] ma20 = calcMA(closes, 20);
|
||||
double[] ma60 = calcMA(closes, 60);
|
||||
|
||||
// MACD
|
||||
double[] ema12 = calcEMA(closes, 12);
|
||||
double[] ema26 = calcEMA(closes, 26);
|
||||
double[] dif = new double[65];
|
||||
double[] dea = new double[65];
|
||||
double[] macdBar = new double[65];
|
||||
for (int i = 0; i < 65; i++) {
|
||||
dif[i] = ema12[i] - ema26[i];
|
||||
dea[i] = i == 0 ? dif[i] : dea[i - 1] * 0.8 + dif[i] * 0.2;
|
||||
macdBar[i] = 2 * (dif[i] - dea[i]);
|
||||
}
|
||||
|
||||
// KDJ (9,3,3)
|
||||
double[] k = new double[65];
|
||||
double[] d = new double[65];
|
||||
double[] j = new double[65];
|
||||
for (int i = 0; i < 65; i++) {
|
||||
if (i < 8) {
|
||||
k[i] = 50; d[i] = 50; j[i] = 50;
|
||||
} else {
|
||||
double highestHigh = maxOfRange(highs, i - 8, i);
|
||||
double lowestLow = minOfRange(lows, i - 8, i);
|
||||
double rsv = (closes[i] - lowestLow) / (highestHigh - lowestLow + 0.0001) * 100;
|
||||
k[i] = (i == 0 ? 50 : k[i - 1]) * 2.0 / 3.0 + rsv / 3.0;
|
||||
d[i] = (i == 0 ? 50 : d[i - 1]) * 2.0 / 3.0 + k[i] / 3.0;
|
||||
j[i] = 3 * k[i] - 2 * d[i];
|
||||
}
|
||||
}
|
||||
|
||||
// BOLL (20,2)
|
||||
double[] bollMid = ma20.clone();
|
||||
double[] bollUpper = new double[65];
|
||||
double[] bollLower = new double[65];
|
||||
for (int i = 0; i < 65; i++) {
|
||||
double std = calcStd(closes, i, 20);
|
||||
bollUpper[i] = bollMid[i] + 2 * std;
|
||||
bollLower[i] = bollMid[i] - 2 * std;
|
||||
}
|
||||
|
||||
// RSI (6,14,24)
|
||||
double[] rsi6 = calcRSI(closes, 6);
|
||||
double[] rsi14 = calcRSI(closes, 14);
|
||||
double[] rsi24 = calcRSI(closes, 24);
|
||||
|
||||
// BBI = (MA3+MA6+MA12+MA24)/4
|
||||
double[] ma3_arr = calcMA(closes, 3);
|
||||
double[] ma6_arr = calcMA(closes, 6);
|
||||
double[] ma12_arr = calcMA(closes, 12);
|
||||
double[] ma24_arr = calcMA(closes, 24);
|
||||
double[] bbi = new double[65];
|
||||
for (int i = 0; i < 65; i++) {
|
||||
bbi[i] = (ma3_arr[i] + ma6_arr[i] + ma12_arr[i] + ma24_arr[i]) / 4.0;
|
||||
}
|
||||
|
||||
// CCI (14)
|
||||
double[] cciArr = new double[65];
|
||||
for (int i = 0; i < 65; i++) {
|
||||
double tp = (highs[i] + lows[i] + closes[i]) / 3.0;
|
||||
if (i < 13) { cciArr[i] = 0; continue; }
|
||||
double sumTp = 0;
|
||||
for (int j2 = i - 13; j2 <= i; j2++) {
|
||||
sumTp += (highs[j2] + lows[j2] + closes[j2]) / 3.0;
|
||||
}
|
||||
double maTp = sumTp / 14.0;
|
||||
double sumMd = 0;
|
||||
for (int j2 = i - 13; j2 <= i; j2++) {
|
||||
sumMd += Math.abs((highs[j2] + lows[j2] + closes[j2]) / 3.0 - maTp);
|
||||
}
|
||||
double md = sumMd / 14.0;
|
||||
cciArr[i] = md == 0 ? 0 : (tp - maTp) / (0.015 * md);
|
||||
}
|
||||
|
||||
// ATR (14)
|
||||
double[] atrArr = new double[65];
|
||||
for (int i = 0; i < 65; i++) {
|
||||
double tr = highs[i] - lows[i];
|
||||
if (i > 0) {
|
||||
tr = Math.max(tr, Math.abs(highs[i] - closes[i - 1]));
|
||||
tr = Math.max(tr, Math.abs(lows[i] - closes[i - 1]));
|
||||
}
|
||||
atrArr[i] = i == 0 ? tr : (atrArr[i - 1] * 13 + tr) / 14.0;
|
||||
}
|
||||
|
||||
// WR (14)
|
||||
double[] wrArr = new double[65];
|
||||
for (int i = 0; i < 65; i++) {
|
||||
if (i < 13) { wrArr[i] = 50; continue; }
|
||||
double hh = maxOfRange(highs, i - 13, i);
|
||||
double ll = minOfRange(lows, i - 13, i);
|
||||
wrArr[i] = (hh - closes[i]) / (hh - ll + 0.0001) * 100;
|
||||
}
|
||||
|
||||
// 组装数据
|
||||
LocalDate cursor = date;
|
||||
for (int i = 0; i < 65; i++) {
|
||||
// 跳过周末
|
||||
while (cursor.getDayOfWeek().getValue() >= 6) {
|
||||
cursor = cursor.plusDays(1);
|
||||
}
|
||||
|
||||
IndicatorValues indicators = IndicatorValues.builder()
|
||||
.macdDif(bd(dif[i])).macdDea(bd(dea[i])).macdBar(bd(macdBar[i]))
|
||||
.kdjK(bd(k[i])).kdjD(bd(d[i])).kdjJ(bd(j[i]))
|
||||
.ma5(bd(ma5[i])).ma10(bd(ma10[i])).ma20(bd(ma20[i])).ma60(bd(ma60[i]))
|
||||
.bollUpper(bd(bollUpper[i])).bollMid(bd(bollMid[i])).bollLower(bd(bollLower[i]))
|
||||
.rsi6(bd(rsi6[i])).rsi14(bd(rsi14[i])).rsi24(bd(rsi24[i]))
|
||||
.cci(bd(cciArr[i]))
|
||||
.bbi(bd(bbi[i]))
|
||||
.sar(bd(closes[i] * (i < 35 ? 0.99 : 1.01))) // 简化的SAR
|
||||
.dmiPdi(bd(20 + (i - 32) * 0.3))
|
||||
.dmiMdi(bd(20 - (i - 32) * 0.3))
|
||||
.dmiAdx(bd(25))
|
||||
.wr(bd(wrArr[i]))
|
||||
.atr(bd(atrArr[i]))
|
||||
.donchianUpper(bd(i >= 19 ? maxOfRange(highs, i - 19, i) : highs[i]))
|
||||
.donchianLower(bd(i >= 19 ? minOfRange(lows, i - 19, i) : lows[i]))
|
||||
.cjlVolume(bd(volumes[i]))
|
||||
.volAmount(bd(volumes[i] * closes[i]))
|
||||
.build();
|
||||
|
||||
KLineData kline = KLineData.builder()
|
||||
.time(LocalDateTime.of(cursor, LocalTime.of(9, 0)))
|
||||
.open(bd(opens[i])).high(bd(highs[i])).low(bd(lows[i])).close(bd(closes[i]))
|
||||
.volume(bd(volumes[i])).amount(bd(volumes[i] * closes[i]))
|
||||
.indicators(indicators)
|
||||
.build();
|
||||
list.add(kline);
|
||||
cursor = cursor.plusDays(1);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
// ==================== 不同周期转换 ====================
|
||||
|
||||
private List<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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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+$", "");
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
73
src/main/resources/application.yml
Normal file
73
src/main/resources/application.yml
Normal 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
|
||||
205
src/main/resources/db/init.sql
Normal file
205
src/main/resources/db/init.sql
Normal 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='用户信号标记表';
|
||||
669
src/main/resources/行情接口文档-CnQuotation.md
Normal file
669
src/main/resources/行情接口文档-CnQuotation.md
Normal 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 | 成交额 |
|
||||
|
||||
### KChartV3Model(K 线数据点,继承 KChartModel)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `t` | string | 时间(格式化字符串,如 `01-01 09:30`) |
|
||||
| `o` / `h` / `l` / `c` | string | 开 / 高 / 低 / 收 |
|
||||
| `v` | string | 成交量 |
|
||||
| `a` | string | 成交额 |
|
||||
| `u` | long | 时间戳(秒) |
|
||||
| `i` | string | 持仓量 |
|
||||
| `s` | string | 结算价(仅日 K 有值) |
|
||||
|
||||
> 完整字段以源码 `com.futures.common.model.quotation.*` 为准;本表覆盖前端常用字段。
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
51
src/test/resources/application.yml
Normal file
51
src/test/resources/application.yml
Normal 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
|
||||
197
src/test/resources/schema-test.sql
Normal file
197
src/test/resources/schema-test.sql
Normal 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);
|
||||
Loading…
Reference in New Issue
Block a user