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