计划任务编辑详情前后端适配

This commit is contained in:
2026-02-05 13:45:39 +08:00
parent b0093ccccb
commit 8f6c9af00f
37 changed files with 2145 additions and 517 deletions
@@ -29,7 +29,8 @@ import quant.rich.emoney.util.SpringContextHolder;
import okhttp3.OkHttpClient;
/**
* 益盟操盘手基本请求客户端,提供基本功能
* 益盟操盘手基本请求客户端,提供基本功能,如登录和请求。
* <p>本类一般只提供静态方法,故不能也不该实例化本类。具体请求内容需要自己封装。整个系统共用一套鉴权,所以不要复制本例
* <p><b>请求头顺序</b></p>
* <p>
* <ul>
@@ -54,12 +55,13 @@ import okhttp3.OkHttpClient;
@Data
@Slf4j
@Accessors(chain = true)
public class EmoneyClient implements Cloneable {
public class EmoneyClient {
private static final String MBS_URL = "https://mbs.emoney.cn/";
private static final String STRATEGY_URL = "https://mbs.emoney.cn/strategy/";
private static final String LOGIN_URL = "https://emapp.emoney.cn/user/auth/login";
private static final String RELOGIN_URL = "https://emapp.emoney.cn/user/auth/ReLogin";
private static final String STRATEGY_X_PROTOCOL_ID = "9400";
private static final String LOGIN_X_PROTOCOL_ID = "user%2Fauth%2Flogin";
private static final String RELOGIN_X_PROTOCOL_ID = "user%2Fauth%2FReLogin";
@@ -69,24 +71,27 @@ public class EmoneyClient implements Cloneable {
/**
* 根据 protocolId 返回 URL
* <p>益盟操盘手对于不同的 protocolId 有不同的 URL 负责。在进行某类请求之前,请先通过调试 APP 进行确认,否则可能无法获取到相应内容
*
* @param protocolId
* @return
* @return 对应的 URL,当所给 protocolId 为 null 或非 String/Integer 类型时,返回为 null,使用前需要检查
*/
private static String getUrlByProtocolId(Serializable protocolId) {
String strProtocolId;
if (protocolId instanceof Integer intProtocolId) {
switch (intProtocolId) {
case 9400: return STRATEGY_URL;
default: return MBS_URL;
}
strProtocolId = String.valueOf(intProtocolId);
}
else if (protocolId instanceof String strProtocolId) {
switch (strProtocolId) {
else if (protocolId instanceof String s) {
strProtocolId = s;
}
else {
return null;
}
switch (strProtocolId) {
case STRATEGY_X_PROTOCOL_ID: return STRATEGY_URL;
case LOGIN_X_PROTOCOL_ID: return LOGIN_URL;
case RELOGIN_X_PROTOCOL_ID: return RELOGIN_URL;
default: return null;
}
default: return MBS_URL;
}
return null;
}
/**
@@ -126,6 +131,9 @@ public class EmoneyClient implements Cloneable {
return requestResponseInspectService;
}
/**
* 不允许外部实例化对象
*/
private EmoneyClient() {}
/**
@@ -142,7 +150,6 @@ public class EmoneyClient implements Cloneable {
}
}
/**
* 使用系统管理的用户名密码登录
* <p>建议仅在调试时使用,其他情况请用 {@code loginWithManaged()}</p>
@@ -182,7 +189,7 @@ public class EmoneyClient implements Cloneable {
* 触发重登陆验证
* @return
*/
public static Boolean relogin() {
public static Boolean reloginCheck() {
RequestInfo requestInfo = getDefaultRequestInfo();
ObjectNode reloginObject = requestInfo.getReloginObject();
if (reloginObject == null) {
@@ -250,11 +257,9 @@ public class EmoneyClient implements Cloneable {
*/
private static Boolean login(ObjectNode formObject) {
try {
//OkHttpClient okHttpClient = new OkHttpClient();
RequestInfo requestInfo = getDefaultRequestInfo();
OkHttpClient okHttpClient = OkHttpClientProvider.getInstance();
MediaType type = MediaType.parse("application/json");
//type.charset(StandardCharsets.UTF_8);
byte[] content = formObject.toString().getBytes("utf-8");
RequestBody body = RequestBody.create(
content, type);
@@ -273,7 +278,7 @@ public class EmoneyClient implements Cloneable {
.header("Authorization", "")
.header("X-Android-Agent", requestInfo.getXAndroidAgent())
.header("Emapp-ViewMode", requestInfo.getEmappViewMode());
//.header("User-Agent", requestInfo.getOkHttpUserAgent())
//此处 User-Agent 由 ByteBuddy 拦截修改
Request request = requestBuilder.build();
@@ -312,8 +317,10 @@ public class EmoneyClient implements Cloneable {
/**
* 获取基本返回 Base_Response
*
* @param <T>
* @param nanoRequest
* @param <T> 请求类型泛型
* @param nanoRequest 请求
* @param xProtocolId 请求对应的 Protocol ID
* @param xRequestId 视具体请求而定。可能是 null,也可能是 String.valueOf(System.currentTimeMillis())
* @return
*/
protected static <T extends MessageNano> BaseResponse.Base_Response post(
@@ -355,8 +362,8 @@ public class EmoneyClient implements Cloneable {
.header("EM-Sign", EncryptUtils.getEMSign(content, "POST", xProtocolId.toString()))
.header("Authorization", requestInfo.getAuthorization())
.header("X-Android-Agent", requestInfo.getXAndroidAgent())
.header("Emapp-ViewMode", requestInfo.getEmappViewMode())
;
.header("Emapp-ViewMode", requestInfo.getEmappViewMode());
//此处 User-Agent 由 ByteBuddy 拦截修改
Request request = requestBuilder.build();
@@ -377,15 +384,20 @@ public class EmoneyClient implements Cloneable {
}
/**
* 根据指定 clazz 获取返回
*
* @param <T>
* @param <U>
* @param nanoRequest
* @param clazz
* 请求并返回
* @param <T> 请求类型泛型
* @param <U> 返回类型泛型
* @param nanoRequest 请求
* @param clazz 返回类型
* @param xProtocolId 请求对应的 Protocol ID
* @param xRequestId 视具体请求而定。可能是 null,也可能是 String.valueOf(System.currentTimeMillis())
* @return
*/
public static <T extends MessageNano, U extends MessageNano> U post(T nanoRequest, Class<U> clazz, Serializable xProtocolId, Serializable xRequestId) {
public static <T extends MessageNano, U extends MessageNano> U post(
T nanoRequest,
Class<U> clazz,
Serializable xProtocolId,
Serializable xRequestId) {
BaseResponse.Base_Response baseResponse;
try {
@@ -415,5 +427,5 @@ public class EmoneyClient implements Cloneable {
throw new EmoneyRequestException("执行 emoney " + nanoRequest.getClass().getSimpleName() + " 请求/返回时出现错误", e);
}
}
}
@@ -29,6 +29,8 @@ import quant.rich.emoney.util.SpringContextHolder;
/**
* OkHttpClient 提供器
* <p>
* 此处提供的 OkHttpClient 方便使用平台配置的代理,方便是否启用 HTTPS 证书认证等
* @see quant.rich.emoney.entity.config.ProxyConfig
* @see okhttp3.internal.http.BridgeInterceptor
*/
@@ -57,7 +57,7 @@ public class RequireAuthAndProxyAspect {
throw new RuntimeException("鉴权登录失败");
}
}
else if (!EmoneyClient.relogin()) {
else if (!EmoneyClient.reloginCheck()) {
throw new RuntimeException("检查重鉴权失败");
}
@@ -26,7 +26,10 @@ public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers.cacheControl(cache -> cache.disable()))
.headers(headers -> headers
.cacheControl(cache -> cache.disable())
.frameOptions(f -> f.sameOrigin())
)
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/favicon.ico").permitAll()
@@ -29,21 +29,8 @@ public class ErrorPageController implements ErrorController {
@GetMapping(value = ERROR_PATH)
@PostMapping(value = ERROR_PATH)
public String errorHtml(HttpServletRequest request) {
HttpStatus status = getStatus(request);
String prefix = "error/";
return prefix + "error_400";
// switch (status) {
// case BAD_REQUEST:
// return prefix + "error_400";
// case NOT_FOUND:
// return prefix + "error_404";
// case METHOD_NOT_ALLOWED:
// return prefix + "error_405";
// default:
// return prefix + "error_5xx";
// }
}
@GetMapping(value = ERROR_PATH, produces = "application/json")
@@ -13,6 +13,8 @@ import org.springframework.web.bind.annotation.RestController;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import quant.rich.emoney.service.AuthService;
import quant.rich.emoney.util.ArithmeticCaptchaGen;
import quant.rich.emoney.util.ArithmeticCaptchaGen.Captcha;
@RestController
@RequestMapping("/captcha")
@@ -20,13 +22,13 @@ public class KaptchaController extends BaseController {
@Autowired
DefaultKaptcha kaptcha;
@GetMapping(value = "/get", produces = MediaType.IMAGE_JPEG_VALUE)
public byte[] getCaptcha() throws Exception {
String createText = kaptcha.createText();
Captcha captcha = ArithmeticCaptchaGen.generate();
ByteArrayOutputStream os = new ByteArrayOutputStream();
session.setAttribute(AuthService.CAPTCHA, createText);
ImageIO.write(kaptcha.createImage(createText), "jpg", os);
session.setAttribute(AuthService.CAPTCHA, captcha.answer());
ImageIO.write(kaptcha.createImage(captcha.expr()), "jpg", os);
byte[] result = os.toByteArray();
os.close();
return result;
@@ -2,20 +2,29 @@ package quant.rich.emoney.controller.manage;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import quant.rich.emoney.controller.common.UpdateBoolServiceController;
import quant.rich.emoney.entity.sqlite.Plan;
import quant.rich.emoney.enums.PlanType;
import quant.rich.emoney.exception.PageNotFoundException;
import quant.rich.emoney.exception.RException;
import quant.rich.emoney.interfaces.IQueryableEnum;
import quant.rich.emoney.pojo.dto.LayPageReq;
@@ -31,6 +40,9 @@ public class PlanControllerV1 extends UpdateBoolServiceController<Plan> {
@Autowired
PlanService planService;
@Autowired
ObjectMapper objectMapper;
@GetMapping({"", "/", "/index"})
public String index() {
return "/admin/v1/manage/plan/index";
@@ -48,6 +60,60 @@ public class PlanControllerV1 extends UpdateBoolServiceController<Plan> {
return super.getOne(planId);
}
/**
* 通用编辑接口,跳转对应模式的编辑页
* @param planId
* @param ra
* @return
*/
@GetMapping("/edit")
public String edit(Integer planId, RedirectAttributes ra) {
Plan plan = null;
if (planId != null) {
plan = planService.getById(planId);
}
if (plan == null) {
plan = new Plan();
}
if (plan.getPlanId() != null) {
ra.addAttribute("planId", plan.getPlanId());
}
if (plan.getPlanType() == null || plan.getPlanType() == PlanType.SINGLE_INDEX) {
return "redirect:editSingleIndex";
}
else if (plan.getPlanType() == PlanType.MULTI_INDEX) {
return "redirect:editMultiIndex";
}
else if (plan.getPlanType() == PlanType.STOCK_STRATEGY) {
return "redirect:editStockStrategy";
}
throw new PageNotFoundException();
}
@GetMapping("/edit{planTypeString}")
public String editPlanType(@PathVariable String planTypeString, Integer planId) {
Optional<PlanType> optional = tryParseEnum(PlanType.class, planTypeString);
if (optional.isEmpty()) {
throw new PageNotFoundException();
}
Plan plan = null;
if (planId != null) {
plan = planService.getById(planId);
}
if (plan == null) {
plan = new Plan();
}
String planJsonString = "{}";
try {
planJsonString = objectMapper.writeValueAsString(plan);
}
catch (Exception e) {}
request.setAttribute("plan", plan);
request.setAttribute("planType", optional.get().toString());
request.setAttribute("planJsonString", planJsonString);
return "/admin/v1/manage/plan/edit" + planTypeString;
}
@PostMapping("/save")
@ResponseBody
public R<?> save(@RequestBody Plan plan) {
@@ -119,5 +185,63 @@ public class PlanControllerV1 extends UpdateBoolServiceController<Plan> {
return note;
}
}
/**
* 将大驼峰转换为大写+下划线分隔形式
* @param s
* @return
*/
public static String toUpperSnake(String s) {
if (s == null) return null;
s = s.trim();
if (s.isEmpty()) return s;
// 先把空格/连字符这类统一成下划线(可按需扩展)
s = s.replace('-', '_').replace(' ', '_');
// 1) fooBar / foo1Bar -> foo_Bar / foo1_Bar
s = s.replaceAll("([a-z0-9])([A-Z])", "$1_$2");
// 2) HTTPServer -> HTTP_Server(在 "P" 和 "S" 之间插)
s = s.replaceAll("([A-Z]+)([A-Z][a-z])", "$1_$2");
// 合并多余下划线
s = s.replaceAll("_+", "_");
// 去掉首尾下划线
s = s.replaceAll("^_+|_+$", "");
return s.toUpperCase(Locale.ROOT);
}
public static <E extends Enum<E>> Optional<E> tryParseEnum(Class<E> enumClass, String input) {
if (enumClass == null || input == null) return Optional.empty();
String raw = input.trim();
if (raw.isEmpty()) return Optional.empty();
// 1) 直接按原样尝试(允许用户本来就传了正确的常量名)
E e = tryValueOf(enumClass, raw);
if (e != null) return Optional.of(e);
// 2) 尝试 UPPER_SNAKE_CASE
String upperSnake = toUpperSnake(raw);
e = tryValueOf(enumClass, upperSnake);
if (e != null) return Optional.of(e);
// 3) 兜底:忽略大小写匹配(成本 O(n),但很稳)
for (E constant : enumClass.getEnumConstants()) {
if (constant.name().equalsIgnoreCase(raw) || constant.name().equalsIgnoreCase(upperSnake)) {
return Optional.of(constant);
}
}
return Optional.empty();
}
private static <E extends Enum<E>> E tryValueOf(Class<E> enumClass, String name) {
try {
return Enum.valueOf(enumClass, name);
} catch (IllegalArgumentException ex) {
return null;
}
}
}
@@ -0,0 +1,20 @@
package quant.rich.emoney.crawler;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 益盟爬虫爬取成功与否及对应的爬虫配置,方便重新爬取
* @author Barry
*
*/
@Data
@Accessors(chain=true)
public class EmoneyCrawlerResult<T> {
private T callable;
private boolean success;
private Exception exception;
}
@@ -0,0 +1,204 @@
package quant.rich.emoney.crawler;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.AccessLevel;
import lombok.Data;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import nano.CandleStickNewResponse.CandleStickNew_Response.CandleStick;
import nano.CandleStickNewWithIndexExResponse.CandleStickNewWithIndexEx_Response;
import nano.CandleStickNewWithIndexExResponse.CandleStickNewWithIndexEx_Response.IndLine;
import nano.CandleStickRequest.CandleStick_Request;
import nano.CandleStickWithIndexRequest.CandleStickWithIndex_Request;
import nano.CandleStickWithIndexRequest.CandleStickWithIndex_Request.IndexInfo;
import nano.IndexCalcNewExResponse.IndexCalcNewEx_Response.outputline;
import quant.rich.emoney.client.EmoneyClient;
import quant.rich.emoney.entity.postgre.EmoneyIndex;
import quant.rich.emoney.enums.StockSpan;
import quant.rich.emoney.exception.EmoneyRequestException;
import quant.rich.emoney.util.DateUtils;
import quant.rich.emoney.util.StockCodeUtils;
/**
* 执行爬虫时最终被调用的方法。该方法使用 2422(多指标、带 K 线)更新指标数据
*/
@Data
@Slf4j
public class EmoneyIndexCallable implements Callable<EmoneyCrawlerResult<EmoneyIndexCallable>> {
private static final int LIMIT_SIZE = 250;
private EmoneyCrawlerResult<EmoneyIndexCallable> result;
private String tsCode;
private StockSpan stockSpan;
private Long beginPosition;
private Long totalNeeds;
private Function<List<EmoneyIndex>, Boolean> func;
private IndexInfo[] indexInfos;
@Setter(AccessLevel.PRIVATE)
private String indexNames;
public EmoneyIndexCallable setIndexInfos(IndexInfo[] indexInfos) {
this.indexInfos = indexInfos;
indexNames = Arrays.stream(indexInfos)
.map(IndexInfo::getIndexName)
.collect(Collectors.joining(", "));
return this;
}
/**
* 初始化一个 Callable
* @param tsCode
* @param beginPosition 起始位置,一般为当日或当时,如 20230921L
* @param stockSpan 数据粒度
* @param totalNeeds 经过计算需要的总数据数
* @param func 消费 {@code List<EmoneyIndex>} 的方法,返回的布尔值辅助判断本次调用是否成功
* @param indexInfos 指标类型数组
*/
public EmoneyIndexCallable(
String tsCode,
Long beginPosition,
StockSpan stockSpan,
Long totalNeeds,
Function<List<EmoneyIndex>, Boolean> func,
IndexInfo...indexInfos) {
this.tsCode = tsCode;
this.beginPosition = beginPosition;
this.stockSpan = stockSpan;
setIndexInfos(indexInfos);
this.totalNeeds = totalNeeds;
this.func = func;
}
/**
* 从 EmoneyIndexCallable 恢复 Callable
* @param request
*/
public EmoneyIndexCallable(EmoneyIndexCallable other) {
this.tsCode = other.tsCode;
this.beginPosition = other.beginPosition;
this.stockSpan = other.stockSpan;
this.indexInfos = other.indexInfos;
this.indexNames = other.indexNames;
this.totalNeeds = other.totalNeeds;
this.func = other.func;
}
@Override
public EmoneyCrawlerResult<EmoneyIndexCallable> call() throws Exception {
List<EmoneyIndex> emoneyIndexList = new ArrayList<>();
while (totalNeeds > 0) {
// 组装请求
/*
* 2422 CANDLE_STICK_V3
* 该种方式除返回指标和对应时间外,还会返回对应时间粒度的 K 线(蜡烛图),数据量会比 2921 大。
* 使用 beginPosition, 如 20231230L 和 limitSize, 如 400 向前追溯
*/
CandleStickWithIndex_Request request = new CandleStickWithIndex_Request();
CandleStick_Request candleRequest = new CandleStick_Request();
candleRequest.setBeginPosition(beginPosition)
.setDataPeriod(stockSpan.getEmoneyCode())
.setExFlag(0)
.setGoodsId(StockCodeUtils.tsCodeToGoodsId(tsCode))
.setLastVolume(0)
.setLimitSize(Math.min(totalNeeds, LIMIT_SIZE))
.setReqFhsp(false)
.setReqHisShares(false);
request.candleRequest = candleRequest;
/*IndexInfo[] indexInfos = new IndexInfo[indexNames.length];
for (int i = 0; i < indexNames.length; i++) {
IndexInfo indexInfo = new IndexInfo().setIndexName(indexNames[i]);
indexInfos[i] = indexInfo;
}*/
request.indexRequest = indexInfos;
result = new EmoneyCrawlerResult<EmoneyIndexCallable>().setCallable(this).setSuccess(false);
CandleStickNewWithIndexEx_Response response;
try {
// 时间戳作为 xRequestId
response = EmoneyClient.post(
request,
CandleStickNewWithIndexEx_Response.class,
2422,
System.currentTimeMillis());
}
catch (EmoneyRequestException e) {
return result.setSuccess(false).setException(e);
}
log.info("Emoney index [{}] for {} called, totalNeeds left: {}, current received length/limitSize: {}/{}",
indexNames, tsCode, totalNeeds, response.kLines.length, LIMIT_SIZE);
for (int i = 0; i < response.kLines.length; i++) {
CandleStick candleStick = response.kLines[i];
long dateTime = candleStick.getDatetime();
LocalDateTime date = DateUtils.longDatetimeToLocalDateTime(dateTime);
for (IndexInfo indexInfo : indexInfos) {
IndLine indLine = response.indexDatas.get(indexInfo.getIndexName());
for (outputline lineValue : indLine.lineValue) {
EmoneyIndex emoneyIndex = new EmoneyIndex()
.setIndexName(indexInfo.getIndexName())
.setDate(date).setLineName(lineValue.getLineName())
.setLineShape(lineValue.getLineShape())
.setStockSpan(stockSpan)
.setTsCode(tsCode)
.setValue(lineValue.lineData[i]);
emoneyIndexList.add(emoneyIndex);
}
}
}
Thread.sleep(2500);
// 判断数据是否结束了
if (response.kLines.length < LIMIT_SIZE) {
break;
}
totalNeeds -= LIMIT_SIZE;
// 重设起始点为返回数据的最旧时间
beginPosition = response.kLines[0].getDatetime();
}
Boolean b = true;
if (!emoneyIndexList.isEmpty()) {
try {
b = func.apply(emoneyIndexList);
}
catch (Exception e) {
return result.setSuccess(false).setException(e);
}
}
return result.setSuccess(b);
}
}
@@ -1,6 +1,6 @@
package quant.rich.emoney.entity.postgre;
import java.util.Date;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Objects;
@@ -36,7 +36,7 @@ public class EmoneyIndex {
* 时间
*/
@TableField("trade_date")
private Date date;
private LocalDateTime date;
/**
* 指标值
*/
@@ -1,14 +1,5 @@
package quant.rich.emoney.entity.sqlite;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.springframework.util.CollectionUtils;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -17,7 +8,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import lombok.Data;
import lombok.experimental.Accessors;
import quant.rich.emoney.mybatis.typehandler.CommaListTypeHandler;
import quant.rich.emoney.enums.PlanType;
import quant.rich.emoney.mybatis.typehandler.JsonStringTypeHandler;
/**
@@ -27,6 +18,7 @@ import quant.rich.emoney.mybatis.typehandler.JsonStringTypeHandler;
* <li>个股策略(/strategy, id=9400)的抓取
* <li>选股策略的抓取
* </ul>
* 选股策略的抓取的筛选器自带股票过滤。其他抓取应该可以选股策略抓取的结果、tushare 的直接结果作为抓取的范围。
* 未来可能存在更多的计划任务, 所以需要考虑如何设计才能更好地兼容后续添加的任务类型
*/
@Data
@@ -38,7 +30,7 @@ public class Plan {
* 键 ID
*/
@TableId(type = IdType.AUTO)
private String planId;
private Integer planId;
/**
* 计划任务表达式
@@ -49,23 +41,17 @@ public class Plan {
* 计划名称
*/
private String planName;
/**
* 指标代码
*/
private String indexCode;
/**
* 需要抓取的指标周期
* 计划类型
*/
@TableField(typeHandler = CommaListTypeHandler.class)
private List<String> periods;
private PlanType planType;
/**
* 参数
*/
@TableField(typeHandler = JsonStringTypeHandler.class)
private JsonNode params;
private JsonNode detail;
/**
* 是否启用
@@ -77,34 +63,5 @@ public class Plan {
* 反之无论是否为交易日都执行
*/
private Boolean openDayCheck;
/**
* 设置抓取周期并去重
* @param periods
* @return
*/
public Plan setPeriods(List<String> periods) {
if (CollectionUtils.isEmpty(periods)) {
this.periods = Collections.emptyList();
return this;
}
Set<String> hashSet = new HashSet<>();
periods.forEach(s -> {
if (StringUtils.isNotBlank(s)) {
hashSet.add(s.trim());
}
});
this.periods = new ArrayList<>(hashSet);
return this;
}
/**
* 获取抓取周期。已去重
* @return
*/
public List<String> getPeriods() {
setPeriods(periods);
return periods;
}
}
@@ -0,0 +1,20 @@
package quant.rich.emoney.enums;
/**
* 计划任务类型枚举
*/
public enum PlanType {
/**
* 单指标
*/
SINGLE_INDEX,
/**
* 多指标
*/
MULTI_INDEX,
STOCK_STRATEGY,
STOCK_PICKING
}
@@ -1,6 +1,8 @@
package quant.rich.emoney.mapper.postgre;
import java.util.Collection;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Component;
import com.baomidou.dynamic.datasource.annotation.DS;
@@ -13,4 +15,7 @@ import quant.rich.emoney.entity.postgre.EmoneyIndex;
@DS("postgre")
public interface EmoneyIndexMapper extends BaseMapper<EmoneyIndex> {
int insertOrUpdateBatch(
@Param("list") Collection<EmoneyIndex> list,
@Param("batchSize") int batchSize);
}
@@ -0,0 +1,72 @@
package quant.rich.emoney.pojo;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.util.CollectionUtils;
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain=true)
public class MultiIndexPlanDetail {
/**
* 指标
*/
List<MultiIndexPlanPart> indexes;
/**
* 抓取的 K 线粒度
*/
List<Integer> periods;
/**
* 设置抓取周期并去重
* @param periods
* @return
*/
public MultiIndexPlanDetail setPeriods(List<Integer> periods) {
if (CollectionUtils.isEmpty(periods)) {
this.periods = Collections.emptyList();
return this;
}
Set<Integer> hashSet = new HashSet<>();
periods.forEach(period -> {
if (period != null) {
hashSet.add(period);
}
});
this.periods = new ArrayList<>(hashSet);
return this;
}
/**
* 获取抓取周期。已去重
* @return
*/
public List<Integer> getPeriods() {
setPeriods(periods);
return periods;
}
@Data
@Accessors(chain=true)
public static class MultiIndexPlanPart {
/**
* 单指标代码
*/
String indexCode;
/**
* 单指标参数
*/
Map<String, String> params;
}
}
@@ -0,0 +1,62 @@
package quant.rich.emoney.pojo;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.util.CollectionUtils;
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain=true)
public class SingleIndexPlanDetail {
/**
* 单指标代码
*/
String indexCode;
/**
* 单指标参数
*/
Map<String, String> params;
/**
* 抓取的 K 线粒度
*/
List<Integer> periods;
/**
* 设置抓取周期并去重
* @param periods
* @return
*/
public SingleIndexPlanDetail setPeriods(List<Integer> periods) {
if (CollectionUtils.isEmpty(periods)) {
this.periods = Collections.emptyList();
return this;
}
Set<Integer> hashSet = new HashSet<>();
periods.forEach(period -> {
if (period != null) {
hashSet.add(period);
}
});
this.periods = new ArrayList<>(hashSet);
return this;
}
/**
* 获取抓取周期。已去重
* @return
*/
public List<Integer> getPeriods() {
setPeriods(periods);
return periods;
}
}
@@ -0,0 +1,20 @@
package quant.rich.emoney.service.postgre;
import java.util.Collection;
import org.springframework.transaction.annotation.Transactional;
import com.baomidou.mybatisplus.extension.toolkit.SqlHelper;
import quant.rich.emoney.config.PostgreMybatisConfig;
import quant.rich.emoney.entity.postgre.EmoneyIndex;
import quant.rich.emoney.mapper.postgre.EmoneyIndexMapper;
public class EmoneyIndexService extends PostgreServiceImpl<EmoneyIndexMapper, EmoneyIndex> {
@Override
@Transactional(transactionManager = PostgreMybatisConfig.POSTGRE_TRANSACTION_MANAGER, rollbackFor = Exception.class)
public boolean saveOrUpdateBatch(Collection<EmoneyIndex> entityList, int batchSize) {
return SqlHelper.retBool(baseMapper.insertOrUpdateBatch(entityList, batchSize));
}
}
@@ -6,10 +6,16 @@ import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import feign.Headers;
/**
* 对接 TushareDataService 的 Feign 客户端
* <p>
* 服务器上配置了 fail2ban 对默认 User-Agent
* 拦截(Java/*),所以这里自定义 User-Agent
*/
@FeignClient(name="tushare-data-service-client", url="http://localhost:9999")
@Headers("User-Agent: At17DataService/1.0")
@FeignClient(name="tushare-data-service-client", url="https://tushare.database.at17.link")
public interface TushareDataServiceClient {
@GetMapping("/api/v1/common/stockInfo/list")
@@ -0,0 +1,164 @@
package quant.rich.emoney.util;
import java.security.SecureRandom;
public class ArithmeticCaptchaGen {
private static final SecureRandom RND = new SecureRandom();
private static final char[] OPS = new char[]{'+', '-', '×', '÷'};
public static class Captcha {
public final int a;
public final int b;
public final char op;
public Captcha(int a, int b, char op) {
this.a = a;
this.b = b;
this.op = op;
}
public String expr() {
return a + " " + op + " " + b;
}
public int answer() {
return switch (op) {
case '+' -> a + b;
case '-' -> a - b;
case '×' -> a * b;
case '÷' -> a / b; // 保证整除
default -> throw new IllegalStateException("Unexpected op: " + op);
};
}
@Override
public String toString() {
return expr() + " = ?";
}
}
/** 对外:随机生成一题 */
public static Captcha generate() {
char op = OPS[RND.nextInt(OPS.length)];
return generate(op);
}
/** 对外:指定运算符生成 */
public static Captcha generate(char op) {
return switch (op) {
case '+' -> genAddCarryAllowedButIfCarryUnitsZero();
case '-' -> genSubNoBorrowNonNegative();
case '×' -> genMulNoCarryOneDigit();
case '÷' -> genDivExact();
default -> throw new IllegalArgumentException("Unsupported op: " + op);
};
}
// ---------------- +:允许进位;若发生进位,则结果个位必须为 0;且和 <= 100 ----------------
private static Captcha genAddCarryAllowedButIfCarryUnitsZero() {
// 仍保留 100 的特殊题(不是必须,但能保证覆盖 100)
if (RND.nextInt(20) == 0) { // 5% 概率出 100
if (RND.nextBoolean()) return new Captcha(100, 0, '+');
return new Captcha(0, 100, '+');
}
while (true) {
int a = RND.nextInt(101); // 0..100
int b = RND.nextInt(101); // 0..100
int sum = a + b;
if (sum > 100) continue;
if (carryHappened(a, b)) {
// 发生进位:个位必须为 0
if (sum % 10 == 0) return new Captcha(a, b, '+');
} else {
// 未发生进位:不限制个位
return new Captcha(a, b, '+');
}
}
}
/** 判断十进制逐位相加是否发生过进位(任意一位) */
private static boolean carryHappened(int a, int b) {
int carry = 0;
while (a > 0 || b > 0) {
int da = a % 10;
int db = b % 10;
int s = da + db + carry;
if (s >= 10) return true;
carry = 0; // 因为 s<10 时 carry 必为 0
a /= 10;
b /= 10;
}
return false;
}
// ---------------- -:无退位,差 >= 0 ----------------
private static Captcha genSubNoBorrowNonNegative() {
while (true) {
int a = RND.nextInt(100); // 0..99
int b = RND.nextInt(100); // 0..99
int aT = a / 10, aU = a % 10;
int bT = b / 10, bU = b % 10;
// 无退位:每一位都要 a>=b;且整体差>=0
if (aU >= bU && aT >= bT) {
return new Captcha(a, b, '-'); // 此时 a>=b 自然成立
}
}
}
// ---------------- *:无进位;一元为 0..9;另一元每位*d < 10 ----------------
private static Captcha genMulNoCarryOneDigit() {
while (true) {
int d = RND.nextInt(10); // 0..9 其中一个因子
int x = RND.nextInt(100); // 另一元 0..99(你可自行放大范围)
// 如果 d=0,任何 x 都满足
if (d == 0) return orderMulOperands(d, x);
// 对 x 的每一位要求:digit*d < 10 才不会在该位产生进位
if (allDigitsMulLessThan10(x, d)) {
return orderMulOperands(d, x);
}
}
}
private static Captcha orderMulOperands(int d, int x) {
// 随机决定把 0..9 放左边还是右边
if (RND.nextBoolean()) return new Captcha(d, x, '×');
return new Captcha(x, d, '×');
}
private static boolean allDigitsMulLessThan10(int x, int d) {
// x 是非负
if (x == 0) return true;
int t = x;
while (t > 0) {
int digit = t % 10;
if (digit * d >= 10) return false;
t /= 10;
}
return true;
}
// ---------------- /:必须整除 ----------------
private static Captcha genDivExact() {
// 控制题目大小: dividend <= 100
while (true) {
int b = 1 + RND.nextInt(10); // 除数 1..10
int q = RND.nextInt(21); // 商 0..20
int a = b * q; // 被除数
if (a <= 100) return new Captcha(a, b, '÷');
}
}
// demo
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
Captcha c = generate();
System.out.println(c.expr() + " = " + c.answer());
}
}
}
@@ -0,0 +1,28 @@
package quant.rich.emoney.util;
import java.util.*;
public class ChunkRandomIter {
final static Random RANDOM = new Random();
public static <T> List<T> splitShuffleAndFlatten(List<T> list, int n) {
int size = list.size();
if (n <= 0) throw new IllegalArgumentException("n must be > 0");
n = Math.min(n, size == 0 ? 1 : size);
List<T> parts = new ArrayList<>(n);
int base = size / n; // 每份至少 base 个
int extra = size % n; // 前 extra 份多一个
int idx = 0;
for (int i = 0; i < n; i++) {
int partSize = base + (i < extra ? 1 : 0);
List<T> part = new ArrayList<>(list.subList(idx, idx + partSize));
Collections.shuffle(part, RANDOM);
parts.addAll(part);
idx += partSize;
}
return parts;
}
}
@@ -1,5 +1,6 @@
package quant.rich.emoney.util;
import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@@ -55,4 +56,40 @@ public final class DateUtils {
LocalDate date = LocalDate.parse(value, formatter);
return date;
}
/**
* 将数字类型日期转换成相应的 LocalDateTime
* <p>
* <li> 2309191130 → 2023-09-19 11:30:00
* <li> 230919 → 2023-09-19
*/
public static LocalDateTime longDatetimeToLocalDateTime(long dateTime) {
if (dateTime <= 0) {
throw new IllegalArgumentException("dateTime must be positive, got: " + dateTime);
}
try {
if (dateTime > 99_999_999L) {
// yyMMddHHmm 例如:2309191130 -> 2023-09-19 11:30
int yy = (int) (dateTime / 100_000_000L); // 23
int year = 2000 + yy; // 2023
int month = (int) ((dateTime % 100_000_000L) / 1_000_000L); // 09
int day = (int) ((dateTime % 1_000_000L) / 10_000L); // 19
int hour = (int) ((dateTime % 10_000L) / 100L); // 11
int minute = (int) (dateTime % 100L); // 30
return LocalDateTime.of(year, month, day, hour, minute, 0);
} else {
// yyyyMMdd 例如:20230919 -> 2023-09-19 00:00
int year = (int) (dateTime / 10_000L); // 2023
int month = (int) ((dateTime % 10_000L) / 100L); // 09
int day = (int) (dateTime % 100L); // 19
return LocalDateTime.of(year, month, day, 0, 0, 0);
}
} catch (DateTimeException ex) {
// 月/日/时/分越界会进这里(比如 month=13)
throw new IllegalArgumentException("Invalid dateTime value: " + dateTime, ex);
}
}
}