First commit

This commit is contained in:
2025-10-14 15:12:24 +08:00
commit 4bf21639c1
370 changed files with 93952 additions and 0 deletions

36
.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
HELP.md
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### Eclipse JDK Log ##
*pid*.log

BIN
.mvn/wrapper/maven-wrapper.jar vendored Normal file

Binary file not shown.

2
.mvn/wrapper/maven-wrapper.properties vendored Normal file
View File

@@ -0,0 +1,2 @@
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.4/apache-maven-3.9.4-bin.zip
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# Tushare Data Service
更新 Tushare 数据的中间件

1
logs/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/**.log

308
mvnw vendored Normal file
View File

@@ -0,0 +1,308 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.2.0
#
# Required ENV vars:
# ------------------
# JAVA_HOME - location of a JDK home dir
#
# Optional ENV vars
# -----------------
# MAVEN_OPTS - parameters passed to the Java VM when running Maven
# e.g. to debug Maven itself, use
# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
# MAVEN_SKIP_RC - flag to disable loading of mavenrc files
# ----------------------------------------------------------------------------
if [ -z "$MAVEN_SKIP_RC" ] ; then
if [ -f /usr/local/etc/mavenrc ] ; then
. /usr/local/etc/mavenrc
fi
if [ -f /etc/mavenrc ] ; then
. /etc/mavenrc
fi
if [ -f "$HOME/.mavenrc" ] ; then
. "$HOME/.mavenrc"
fi
fi
# OS specific support. $var _must_ be set to either true or false.
cygwin=false;
darwin=false;
mingw=false
case "$(uname)" in
CYGWIN*) cygwin=true ;;
MINGW*) mingw=true;;
Darwin*) darwin=true
# Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home
# See https://developer.apple.com/library/mac/qa/qa1170/_index.html
if [ -z "$JAVA_HOME" ]; then
if [ -x "/usr/libexec/java_home" ]; then
JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME
else
JAVA_HOME="/Library/Java/Home"; export JAVA_HOME
fi
fi
;;
esac
if [ -z "$JAVA_HOME" ] ; then
if [ -r /etc/gentoo-release ] ; then
JAVA_HOME=$(java-config --jre-home)
fi
fi
# For Cygwin, ensure paths are in UNIX format before anything is touched
if $cygwin ; then
[ -n "$JAVA_HOME" ] &&
JAVA_HOME=$(cygpath --unix "$JAVA_HOME")
[ -n "$CLASSPATH" ] &&
CLASSPATH=$(cygpath --path --unix "$CLASSPATH")
fi
# For Mingw, ensure paths are in UNIX format before anything is touched
if $mingw ; then
[ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] &&
JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)"
fi
if [ -z "$JAVA_HOME" ]; then
javaExecutable="$(which javac)"
if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then
# readlink(1) is not available as standard on Solaris 10.
readLink=$(which readlink)
if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then
if $darwin ; then
javaHome="$(dirname "\"$javaExecutable\"")"
javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac"
else
javaExecutable="$(readlink -f "\"$javaExecutable\"")"
fi
javaHome="$(dirname "\"$javaExecutable\"")"
javaHome=$(expr "$javaHome" : '\(.*\)/bin')
JAVA_HOME="$javaHome"
export JAVA_HOME
fi
fi
fi
if [ -z "$JAVACMD" ] ; then
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
else
JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)"
fi
fi
if [ ! -x "$JAVACMD" ] ; then
echo "Error: JAVA_HOME is not defined correctly." >&2
echo " We cannot execute $JAVACMD" >&2
exit 1
fi
if [ -z "$JAVA_HOME" ] ; then
echo "Warning: JAVA_HOME environment variable is not set."
fi
# traverses directory structure from process work directory to filesystem root
# first directory with .mvn subdirectory is considered project base directory
find_maven_basedir() {
if [ -z "$1" ]
then
echo "Path not specified to find_maven_basedir"
return 1
fi
basedir="$1"
wdir="$1"
while [ "$wdir" != '/' ] ; do
if [ -d "$wdir"/.mvn ] ; then
basedir=$wdir
break
fi
# workaround for JBEAP-8937 (on Solaris 10/Sparc)
if [ -d "${wdir}" ]; then
wdir=$(cd "$wdir/.." || exit 1; pwd)
fi
# end of workaround
done
printf '%s' "$(cd "$basedir" || exit 1; pwd)"
}
# concatenates all lines of a file
concat_lines() {
if [ -f "$1" ]; then
# Remove \r in case we run on Windows within Git Bash
# and check out the repository with auto CRLF management
# enabled. Otherwise, we may read lines that are delimited with
# \r\n and produce $'-Xarg\r' rather than -Xarg due to word
# splitting rules.
tr -s '\r\n' ' ' < "$1"
fi
}
log() {
if [ "$MVNW_VERBOSE" = true ]; then
printf '%s\n' "$1"
fi
}
BASE_DIR=$(find_maven_basedir "$(dirname "$0")")
if [ -z "$BASE_DIR" ]; then
exit 1;
fi
MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR
log "$MAVEN_PROJECTBASEDIR"
##########################################################################################
# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
# This allows using the maven wrapper in projects that prohibit checking in binary data.
##########################################################################################
wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar"
if [ -r "$wrapperJarPath" ]; then
log "Found $wrapperJarPath"
else
log "Couldn't find $wrapperJarPath, downloading it ..."
if [ -n "$MVNW_REPOURL" ]; then
wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
else
wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
fi
while IFS="=" read -r key value; do
# Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' )
safeValue=$(echo "$value" | tr -d '\r')
case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;;
esac
done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties"
log "Downloading from: $wrapperUrl"
if $cygwin; then
wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath")
fi
if command -v wget > /dev/null; then
log "Found wget ... using wget"
[ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet"
if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
else
wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
fi
elif command -v curl > /dev/null; then
log "Found curl ... using curl"
[ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent"
if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath"
else
curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath"
fi
else
log "Falling back to using Java to download"
javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java"
javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class"
# For Cygwin, switch paths to Windows format before running javac
if $cygwin; then
javaSource=$(cygpath --path --windows "$javaSource")
javaClass=$(cygpath --path --windows "$javaClass")
fi
if [ -e "$javaSource" ]; then
if [ ! -e "$javaClass" ]; then
log " - Compiling MavenWrapperDownloader.java ..."
("$JAVA_HOME/bin/javac" "$javaSource")
fi
if [ -e "$javaClass" ]; then
log " - Running MavenWrapperDownloader.java ..."
("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath"
fi
fi
fi
fi
##########################################################################################
# End of extension
##########################################################################################
# If specified, validate the SHA-256 sum of the Maven wrapper jar file
wrapperSha256Sum=""
while IFS="=" read -r key value; do
case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;;
esac
done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties"
if [ -n "$wrapperSha256Sum" ]; then
wrapperSha256Result=false
if command -v sha256sum > /dev/null; then
if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then
wrapperSha256Result=true
fi
elif command -v shasum > /dev/null; then
if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then
wrapperSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available."
echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties."
exit 1
fi
if [ $wrapperSha256Result = false ]; then
echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2
echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2
echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2
exit 1
fi
fi
MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
# For Cygwin, switch paths to Windows format before running java
if $cygwin; then
[ -n "$JAVA_HOME" ] &&
JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME")
[ -n "$CLASSPATH" ] &&
CLASSPATH=$(cygpath --path --windows "$CLASSPATH")
[ -n "$MAVEN_PROJECTBASEDIR" ] &&
MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR")
fi
# Provide a "standardized" way to retrieve the CLI args that will
# work with both Windows and non-Windows executions.
MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*"
export MAVEN_CMD_LINE_ARGS
WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
# shellcheck disable=SC2086 # safe args
exec "$JAVACMD" \
$MAVEN_OPTS \
$MAVEN_DEBUG_OPTS \
-classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
"-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"

205
mvnw.cmd vendored Normal file
View File

@@ -0,0 +1,205 @@
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM https://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.2.0
@REM
@REM Required ENV vars:
@REM JAVA_HOME - location of a JDK home dir
@REM
@REM Optional ENV vars
@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending
@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
@REM e.g. to debug Maven itself, use
@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
@REM ----------------------------------------------------------------------------
@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
@echo off
@REM set title of command window
title %0
@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO%
@REM set %HOME% to equivalent of $HOME
if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
@REM Execute a user defined script before this one
if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
@REM check for pre script, once with legacy .bat ending and once with .cmd ending
if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %*
if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %*
:skipRcPre
@setlocal
set ERROR_CODE=0
@REM To isolate internal variables from possible post scripts, we use another setlocal
@setlocal
@REM ==== START VALIDATION ====
if not "%JAVA_HOME%" == "" goto OkJHome
echo.
echo Error: JAVA_HOME not found in your environment. >&2
echo Please set the JAVA_HOME variable in your environment to match the >&2
echo location of your Java installation. >&2
echo.
goto error
:OkJHome
if exist "%JAVA_HOME%\bin\java.exe" goto init
echo.
echo Error: JAVA_HOME is set to an invalid directory. >&2
echo JAVA_HOME = "%JAVA_HOME%" >&2
echo Please set the JAVA_HOME variable in your environment to match the >&2
echo location of your Java installation. >&2
echo.
goto error
@REM ==== END VALIDATION ====
:init
@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
@REM Fallback to current working directory if not found.
set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
set EXEC_DIR=%CD%
set WDIR=%EXEC_DIR%
:findBaseDir
IF EXIST "%WDIR%"\.mvn goto baseDirFound
cd ..
IF "%WDIR%"=="%CD%" goto baseDirNotFound
set WDIR=%CD%
goto findBaseDir
:baseDirFound
set MAVEN_PROJECTBASEDIR=%WDIR%
cd "%EXEC_DIR%"
goto endDetectBaseDir
:baseDirNotFound
set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
cd "%EXEC_DIR%"
:endDetectBaseDir
IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
@setlocal EnableExtensions EnableDelayedExpansion
for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
:endReadAdditionalConfig
SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B
)
@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
@REM This allows using the maven wrapper in projects that prohibit checking in binary data.
if exist %WRAPPER_JAR% (
if "%MVNW_VERBOSE%" == "true" (
echo Found %WRAPPER_JAR%
)
) else (
if not "%MVNW_REPOURL%" == "" (
SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
)
if "%MVNW_VERBOSE%" == "true" (
echo Couldn't find %WRAPPER_JAR%, downloading it ...
echo Downloading from: %WRAPPER_URL%
)
powershell -Command "&{"^
"$webclient = new-object System.Net.WebClient;"^
"if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^
"$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^
"}"^
"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^
"}"
if "%MVNW_VERBOSE%" == "true" (
echo Finished downloading %WRAPPER_JAR%
)
)
@REM End of extension
@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file
SET WRAPPER_SHA_256_SUM=""
FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B
)
IF NOT %WRAPPER_SHA_256_SUM%=="" (
powershell -Command "&{"^
"$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^
"If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^
" Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^
" Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^
" Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^
" exit 1;"^
"}"^
"}"
if ERRORLEVEL 1 goto error
)
@REM Provide a "standardized" way to retrieve the CLI args that will
@REM work with both Windows and non-Windows executions.
set MAVEN_CMD_LINE_ARGS=%*
%MAVEN_JAVA_EXE% ^
%JVM_CONFIG_MAVEN_PROPS% ^
%MAVEN_OPTS% ^
%MAVEN_DEBUG_OPTS% ^
-classpath %WRAPPER_JAR% ^
"-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^
%WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*
if ERRORLEVEL 1 goto error
goto end
:error
set ERROR_CODE=1
:end
@endlocal & set ERROR_CODE=%ERROR_CODE%
if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost
@REM check for post script, once with legacy .bat ending and once with .cmd ending
if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat"
if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd"
:skipRcPost
@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
if "%MAVEN_BATCH_PAUSE%"=="on" pause
if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE%
cmd /C exit /B %ERROR_CODE%

277
pom.xml Normal file
View File

@@ -0,0 +1,277 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="https://maven.apache.org/POM/4.0.0"
xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://maven.apache.org/POM/4.0.0 https://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>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<groupId>link.at17.mid</groupId>
<artifactId>tushare-data-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>Tushare Data Service</name>
<description>Tushare 数据服务,负责更新和提供接口</description>
<properties>
<java.version>18</java.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- 固定 mybatis-spring 版本,解决 Invalid value type for attribute
'factoryBeanObjectType':
java.lang.String 问题 -->
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>3.0.3</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
</dependency>
<!--
https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-cache -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- 热重载 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
<!-- 合法性校验 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/org.redisson/redisson -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.26.0</version>
</dependency>
<!--
https://mvnrepository.com/artifact/org.redisson/redisson-spring-boot-starter -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.26.0</version>
</dependency>
<!-- jedis依赖的JAR配置start -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/com.alibaba.fastjson2/fastjson2 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.42</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.postgresql/postgresql -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.4</version><!--$NO-MVN-MAN-VER$-->
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
<!--
https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-boot-starter -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.5</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.0</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus</artifactId>
<version>3.5.5</version>
</dependency>
<!-- Excel 处理组件 -->
<dependency>
<groupId>com.hynnet</groupId>
<artifactId>jxl</artifactId>
<version>2.6.12.1</version>
</dependency>
<!-- Markdown 组件 -->
<dependency>
<groupId>com.vladsch.flexmark</groupId>
<artifactId>flexmark-all</artifactId>
<version>0.62.2</version>
</dependency>
<!-- quartz -->
<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz</artifactId>
<exclusions>
<exclusion>
<artifactId>slf4j-api</artifactId>
<groupId>org.slf4j</groupId>
</exclusion>
</exclusions>
</dependency>
<!-- https://mvnrepository.com/artifact/com.google.code.findbugs/jsr305 -->
<dependency>
<groupId>com.google.code.findbugs</groupId>
<artifactId>jsr305</artifactId>
<version>3.0.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<!-- https://mvnrepository.com/artifact/org.bouncycastle/bcprov-jdk15to18 -->
<!-- 加密套件 -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15to18</artifactId>
<version>1.77</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.reflections/reflections -->
<dependency>
<groupId>org.reflections</groupId>
<artifactId>reflections</artifactId>
<version>0.10.2</version>
</dependency>
<!-- 验证码 -->
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.4.8-jre</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.37</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</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-surefire-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,27 @@
package link.at17.mid.tushare;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import link.at17.mid.tushare.task.scheduler.DailyUpdateDataScheduler;
@EnableAsync
@EnableScheduling
@SpringBootApplication
@MapperScan(basePackages = {"link.at17.mid.tushare.dao", "link.at17.mid.tushare.web.mapper"})
@EnableCaching(proxyTargetClass=true)
public class TushareDataServiceApplication {
@Autowired
DailyUpdateDataScheduler dailyUpdateDataScheduler;
public static void main(String[] args) {
SpringApplication.run(TushareDataServiceApplication.class, args);
}
}

View File

@@ -0,0 +1,15 @@
package link.at17.mid.tushare.annotation;
import java.lang.annotation.*;
/**
* 将该注解注解在 mapper.java 或 dao.java 的批量插入 list 的方法上,以解除数据库对批量插入的限制
* <p>例:
* <code>
* @BatchInsert int insertOrUpdate(@BatchList list)
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface BatchInsert {
int maxParams() default 65535; // 换库可改
}

View File

@@ -0,0 +1,11 @@
package link.at17.mid.tushare.annotation;
import java.lang.annotation.*;
/**
* 配合 {@code BatchInsert} 使用
* @see BatchInsert
*/
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface BatchList {} // 标记那个是批量的集合参数

View File

@@ -0,0 +1,59 @@
package link.at17.mid.tushare.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
/**
* 将该注解用在配置上,给后台 ConfigController 自动渲染配置页用
*
* @author Doghole
*
*/
@Bean
@Lazy(false)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ConfigInfo {
/**
* @return 配置 field 标识,用以自动注入、持久化配置文件名
*/
String field() default "";
/**
* @return 名称
*/
String name() default "配置";
/**
* <p>
* 为 true 时,最终会调用其无参构造方法,若需要填充默认值的,请在无参构造器中填充。
* 当为 true 且无法载入配置文件而触发初始化时,若存在 /conf/system/{field}.fallback.json 文件时,从 fallback
* 文件中初始化。fallback 仅参与初始化,不参与持久化
* </p>
*
* @return 是否初始化默认值
*/
boolean initDefault() default false;
/**
* @return 是否提供后台控制
*/
boolean managed() default true;
/**
* 是否持久化
*/
boolean save() default true;
/**
* 用于排序场景,如枚举所有配置类列表
* @return
*/
int order() default 0;
}

View File

@@ -0,0 +1,53 @@
package link.at17.mid.tushare.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 注入前端的注解
* <p>
* <ol>
* <li>
* 注解在类上,以便在前端使用类的静态方法或常量值
* <hr>
* <code>
* {@code @StaticAttribute}<br>
* <b>public class</b> EncryptUtils {<br>
* &nbsp;&nbsp;<b>public static</b> String <i>DEFAULT_ENC</i> = "md5";<br>
* &nbsp;&nbsp;<b>public static</b> String md5(String plain);<br>
* }
* </code>
* <hr>
* 即可在 thymeleaf html 中使用该类的静态方法:
* <br>
* &nbsp;<br>
* <code>
* &lt;input th:value="${EncryptUtils.DEFAULT_ENC + EncryptUtils.md5(password)}"/>
* </code><br>
* &nbsp;<br>
* </li>
* <li>
* 又如:某类内成员字段是不可修改的枚举<p>
* <hr>
* <code>
* {@code @Data}<br>
* {@code @Component}<br>
* <b>public class</b> Network {<br>
* &nbsp;&nbsp;&nbsp;&nbsp;{@code @StaticAttribute("ProxyTypeEnum")}<br>
* &nbsp;&nbsp;&nbsp;&nbsp;<b>private</b> Proxy.Type <i>proxyType</i> = Proxy.Type.<i><b>DIRECT</b></i>;<br>
* }
* </code>
* <hr>
* 即可在 thymeleaf html 中使用该枚举:<br>
* &nbsp;<br>
* <code>
&lt;option value="DIRECT" th:selected="${@network.proxyType == ProxyTypeEnum.DIRECT}">无&lt;/option><br>
</code>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE, ElementType.FIELD})
public @interface StaticAttribute {
String value() default "";
}

View File

@@ -0,0 +1,35 @@
package link.at17.mid.tushare.api.common;
import java.time.LocalDateTime;
import org.apache.ibatis.annotations.Param;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import link.at17.mid.tushare.dao.StockCalendarDao;
import link.at17.mid.tushare.enums.StockMarket;
import link.at17.mid.tushare.system.util.LocalDateTimeUtils;
import lombok.extern.slf4j.Slf4j;
@RestController
@RequestMapping("/api/v1/common/calendar")
@Slf4j
public class StockCalendarController {
@Autowired
StockCalendarDao stockCalendarDao;
@GetMapping("todayIsOpen")
private String todayIsOpen(@Param("stockMarket") StockMarket stockMarket) {
return String.valueOf(stockCalendarDao.isOpen(LocalDateTime.now(), stockMarket));
}
@GetMapping("isOpen")
private String isOpen(@Param("stockMarket") StockMarket stockMarket, @Param("date") String date) {
LocalDateTime dateTime = LocalDateTimeUtils.parseDate(date);
return String.valueOf(stockCalendarDao.isOpen(dateTime, stockMarket));
}
}

View File

@@ -0,0 +1,119 @@
package link.at17.mid.tushare.cache;
public class CacheConstants {
public static class Statistics {
/** 每日更新、更新 Post 和 Comment 时更新 */
public static final String DAILY_POST_AND_COMMENT_COUNTS
= "StatisticsService::getPostAndCommentCountsWithDate";
/** 每日更新 */
public static final String DAY_SINCE
= "StatisticsService::daySince";
/** 更新 Post 时更新 */
public static final String TOTAL_RELEASE_POSTS_COUNT
= "StatisticsService::totalReleasedPostsCount";
/** 更新 Post 和 Comment 时更新 */
public static final String TOTAL_COMMENTS_COUNT
= "StatisticsService::totalCommentsCount";
}
public static class Posts {
static final String ID = "Posts::";
// Markdown
/** 更新 Post 时更新(指定 PostID) */
public static final String MARKDOWN_CONTENT_RENDERING
= ID + "Markdown::renderPostContent";
/** 更新 Post 时更新(指定 PostID) */
public static final String MARKDOWN_PREFACE_RENDERING
= ID + "Markdown::renderPostPreface";
// Statistic
public static final String POST_STATISTIC
= ID + "PostStatistic::getPostStatistic";
// CondictionPage
/** 更新 Post/Meta 时更新 */
public static final String CONDITION_PAGE
= ID + "ConditionPage::conditionPage";
// PostService
/// 与 ID 或 Name 有关
/** 更新 Post 时更新(指定 PostID) */
public static final String GET_BY_ID
= ID + "PostService::getById";
/** 更新 Post 时更新(指定 PostID) */
public static final String GET_BY_ID_LOGINED
= ID + "PostService::getByIdLogined";
/** 更新 Post 时更新(指定 PostID) */
public static final String GET_BY_ID_UNLOGINED
= ID + "PostService::getByIdUnlogined";
/** 更新 Post 时更新(指定 PostPageName) */
public static final String GET_BY_NAME_LOGINED
= ID + "PostService::getByNameLogined";
/** 更新 Post 时更新(指定 PostPageName) */
public static final String GET_BY_NAME_UNLOGINED
= ID + "PostService::getByNameUnlogined";
/** 更新 Post 时更新(指定 PostID 的 Next 的 PostID) */
public static final String GET_PREVIOUS_LOGINED
= ID + "PostService::getPreviousLogined";
/** 更新 Post 时更新(指定 PostID 的 Next 的 PostID) */
public static final String GET_PREVIOUS_UNLOGINED
= ID + "PostService::getPreviousUnlogined";
/** 更新 Post 时更新(指定 PostID 的 Previous 的 PostID) */
public static final String GET_NEXT_LOGINED
= ID + "PostService::getNextLogined";
/** 更新 Post 时更新(指定 PostID 的 Previous 的 PostID) */
public static final String GET_NEXT_UNLOGINED
= ID + "PostService::getNextUnlogined";
/// 与 ID 或 Name 无关(批量)
/** 更新 Post 时更新 */
public static final String GET_LATEST_POSTS_LOGINED
= ID + "PostService::getLatestPostsLogined";
/** 更新 Post 时更新 */
public static final String GET_LATEST_POSTS_UNLOGINED
= ID + "PostService::getLatestPostsUnlogined";
/** 更新 Post 和 Comment 时更新 */
public static final String GET_POPULAR_POSTS_LOGINED
= ID + "PostService::getPopularPostsLogined";
/** 更新 Post 和 Comment 时更新 */
public static final String GET_POPULAR_POSTS_UNLOGINED
= ID + "PostService::getPopularPostsUnlogined";
}
public static class Comments {
static final String ID = "Comments::";
// Markdown
public static final String MARKDOWN_CONTENT_RENDERING
= ID + "Markdown::renderCommentContent";
// CommentService
public static final String GET_POST_COMMENT_PAGE
= ID + "CommentService::getPostCommentPage";
public static final String GET_FULL_RELATIVE_COMMENT
= ID + "CommentService::getFullRelativeComment";
public static final String GET_LATEST_COMMENTS
= ID + "CommentService::getLatestComments";
public static final String GET_LATEST_COMMENTS_WITH_LEVEL
= ID + "CommentService::getLatestCommentsWithLevel";
public static final String LIST_FROM_CHILD_TO_PARENT
= ID + "CommentService::listFromChildToParent";
public static final String GET_JUMP_TO_URL
= ID + "CommentService::getJumpToUrl";
}
public static class Metas {
static final String ID = "Metas::";
// MetaService
/** 更新 Post 和 Meta 时更新(批量) */
public static final String LIST_META_COUNT
= ID + "MetaService::listMetaCount";
}
}

View File

@@ -0,0 +1,91 @@
package link.at17.mid.tushare.cache;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.Set;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.redisson.api.RedissonClient;
import org.reflections.Reflections;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.cache.CacheProperties;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.redis.cache.CacheKeyPrefix;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import link.at17.mid.tushare.task.TaskConstants;
import lombok.extern.slf4j.Slf4j;
/**
* 定时清除缓存任务
* @author Doghole
* @since 2024-01-30
*/
@Component
@Slf4j
public class CacheEvictionJob implements Job {
@Autowired
RedissonClient redis;
@Autowired
CacheProperties cacheProperties;
@Autowired
Reflections reflections;
static CacheKeyPrefix prefix;
static final CacheEvictionTask DAILY_ANNO = new CacheEvictionTask() {
@Override
public Class<? extends Annotation> annotationType() {
return CacheEvictionTask.class;
}
@Override
public CacheEvictionSpan value() {
return CacheEvictionSpan.DAILY;
}
};
@PostConstruct
void onPostConstruct() {
prefix = CacheKeyPrefix.prefixed(
cacheProperties.getRedis().getKeyPrefix());
}
public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
String triggerName = jobExecutionContext.getTrigger().getKey().getName();
Set<Method> methods = null;
if (TaskConstants.CACHE_DAILY_EVICTION_TRIGGER.equals(triggerName)) {
methods = reflections.getMethodsAnnotatedWith(DAILY_ANNO);
}
if (methods != null) {
for (Method method : methods) {
Cacheable cacheable = method.getAnnotation(Cacheable.class);
if (cacheable == null) {
continue;
}
String[] values = cacheable.value();
if (values.length == 0) {
values = cacheable.cacheNames();
}
for (String cacheName : values) {
String redisCacheName = prefix.compute(cacheName) + "*";
redis.getKeys().getKeysByPattern(redisCacheName).forEach(name -> {
redis.getBucket(name).deleteAsync();
log.debug("Scheduler clear cacheName {}", name);
});
}
}
}
}
}

View File

@@ -0,0 +1,7 @@
package link.at17.mid.tushare.cache;
public enum CacheEvictionSpan {
HOURLY, DAILY, WEEKLY, MONTHLY, SEASONLY, YEARLY
}

View File

@@ -0,0 +1,15 @@
package link.at17.mid.tushare.cache;
import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(CacheEvictionTasks.class)
public @interface CacheEvictionTask {
CacheEvictionSpan value() default CacheEvictionSpan.DAILY;
}

View File

@@ -0,0 +1,13 @@
package link.at17.mid.tushare.cache;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface CacheEvictionTasks {
CacheEvictionTask[] value();
}

View File

@@ -0,0 +1,18 @@
package link.at17.mid.tushare.cache;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 注解用于更新数据后
* @author Barry
*
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface EvictAfterUpdate {
String value() default "";
}

View File

@@ -0,0 +1,124 @@
package link.at17.mid.tushare.component;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.reflection.ParamNameResolver;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import link.at17.mid.tushare.annotation.BatchInsert;
import link.at17.mid.tushare.annotation.BatchList;
import lombok.RequiredArgsConstructor;
import lombok.Value;
/**
* 批量插入切片器
*/
@Aspect
@Component
@RequiredArgsConstructor
public class BatchInsertAspect {
private final SqlSessionFactory sqlSessionFactory;
private final ConcurrentMap<Method, Meta> cache = new ConcurrentHashMap<>();
@Around("@annotation(batchInsert)")
public Object around(ProceedingJoinPoint pjp, BatchInsert batchInsert) throws Throwable {
Method method = ((MethodSignature) pjp.getSignature()).getMethod();
Object[] args = pjp.getArgs();
// 定位批量参数索引
int listIdx = findBatchListIndex(method);
List<?> list = (List<?>) args[listIdx];
if (list == null || list.isEmpty()) {
return pjp.proceed(); // 无数据,原样调用一次
}
// 计算并缓存批大小
Meta meta = cache.computeIfAbsent(method, m -> computeMeta(m, args, listIdx, batchInsert.maxParams()));
int batchSize = Math.max(1, meta.batchSize);
// 分批执行
if (method.getReturnType() == Void.TYPE) {
for (int i = 0; i < list.size(); i += batchSize) {
Object[] cloned = args.clone();
cloned[listIdx] = list.subList(i, Math.min(i + batchSize, list.size()));
pjp.proceed(cloned);
}
return null;
}
if (method.getReturnType() == int.class || method.getReturnType() == Integer.class) {
int total = 0;
for (int i = 0; i < list.size(); i += batchSize) {
Object[] cloned = args.clone();
cloned[listIdx] = list.subList(i, Math.min(i + batchSize, list.size()));
total += (int) pjp.proceed(cloned);
}
return total;
}
throw new IllegalStateException("不支持的返回类型:" + method.getReturnType());
}
private int findBatchListIndex(Method method) {
Annotation[][] pa = method.getParameterAnnotations();
for (int i = 0; i < pa.length; i++) {
for (Annotation a : pa[i]) {
if (a.annotationType() == BatchList.class)
return i;
}
}
throw new IllegalArgumentException(method + " 缺少 @BatchList 标注的集合参数");
}
private Meta computeMeta(Method method, Object[] args, int listIdx, int maxParams) {
String statementId = method.getDeclaringClass().getName() + "." + method.getName();
try (SqlSession session = sqlSessionFactory.openSession()) {
MappedStatement ms = session.getConfiguration().getMappedStatement(statementId);
ParamNameResolver resolver = new ParamNameResolver(ms.getConfiguration(), method);
// 用真实参数计算 base/perRow一次放1条一次放2条两者相减即为单条参数数量
Object[] a1 = args.clone();
Object sample = ((List<?>) args[listIdx]).get(0);
a1[listIdx] = Collections.singletonList(sample);
Object p1 = resolver.getNamedParams(a1);
BoundSql bs1 = ms.getBoundSql(p1);
int s1 = bs1.getParameterMappings().size();
Object[] a2 = args.clone();
a2[listIdx] = Arrays.asList(sample, sample);
Object p2 = resolver.getNamedParams(a2);
BoundSql bs2 = ms.getBoundSql(p2);
int s2 = bs2.getParameterMappings().size();
int perRow = s2 - s1;
if (perRow <= 0)
throw new IllegalStateException("无法识别每行占位符数量");
int base = s1 - perRow;
int batch = Math.max(1, (maxParams - base) / perRow);
return new Meta(perRow, base, batch);
}
}
@Value
static class Meta {
int perRow;
int base;
int batchSize;
}
}

View File

@@ -0,0 +1,74 @@
package link.at17.mid.tushare.component;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.context.annotation.DependsOn;
import org.springframework.core.type.filter.AnnotationTypeFilter;
import org.springframework.core.type.filter.AssignableTypeFilter;
import link.at17.mid.tushare.annotation.ConfigInfo;
import link.at17.mid.tushare.interfaces.IConfig;
import lombok.extern.slf4j.Slf4j;
/**
* 实现自动化注册 Config
*/
@Slf4j
@DependsOn("configService")
public class ConfigAutoRegistrar implements BeanDefinitionRegistryPostProcessor {
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
ClassPathScanningCandidateComponentProvider scanner = new ClassPathScanningCandidateComponentProvider(false);
scanner.addIncludeFilter(new AssignableTypeFilter(IConfig.class));
scanner.addIncludeFilter(new AnnotationTypeFilter(ConfigInfo.class));
scanner.findCandidateComponents("link.at17.mid.tushare.system.config").forEach(beanDefinition -> {
String className = beanDefinition.getBeanClassName();
try {
// 确保其 field 规则与 configService 内 field 生成规则一致,即:
// 如果 @ConfigInfo 指定了 field 的,使用该 field + "Config"
// 作为 beanName否则使用首字母小写的 simpleClassName 作为
// beanName且 simpleClassName 无论如何必须以 Config 作为结尾。
Class<?> clazz = Class.forName(className);
String beanName = clazz.getSimpleName().substring(0, 1).toLowerCase()
+ clazz.getSimpleName().substring(1);
if (!IConfig.class.isAssignableFrom(clazz)) {
log.warn("Config {} does not implement IConfig, ignore", beanName);
return;
}
if (!beanName.endsWith("Config")) {
log.warn("Config {}'s simple class name does not end with \"Config\", ignore", beanName);
return;
}
ConfigInfo info = clazz.getAnnotation(ConfigInfo.class);
if (info == null) {
log.warn("Config {} does not have @ConfigInfo annotation, ignore", clazz.getName());
return;
}
if (StringUtils.isNotBlank(info.field())) {
beanName = info.field() + "Config";
}
BeanDefinitionBuilder factoryBean = BeanDefinitionBuilder
.genericBeanDefinition(ConfigServiceFactoryBean.class)
.addConstructorArgValue(clazz);
// 注意此处注册 factoryBean 不意味着 FactoryBean.getObject() 方法立即被执行,
// Spring 管理的 Bean 默认在其被使用时才创建,所以如果 getObject() 调用一些方
// 法,这些方法会在初次使用 Bean 时才被创建。如果这些方法对于启动过程很重要,
// 需要在对应 Config(Bean) 上加上 @Bean 和 @Lazy(false) 注解,确保一旦准备好
// 相应的 Bean 就会被创建。
registry.registerBeanDefinition(beanName, factoryBean.getBeanDefinition());
log.info("Add config {} to bean register", beanName);
} catch (ClassNotFoundException e) {
throw new RuntimeException("Failed to load class: " + className, e);
}
});
}
}

View File

@@ -0,0 +1,72 @@
package link.at17.mid.tushare.component;
import org.springframework.beans.factory.BeanNameAware;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
import link.at17.mid.tushare.config.ConstructionGuard;
import link.at17.mid.tushare.interfaces.IConfig;
import link.at17.mid.tushare.service.ConfigService;
import lombok.extern.slf4j.Slf4j;
/**
* 实现配置项自动载入
* @param <T>
*/
@Slf4j
public class ConfigServiceFactoryBean<T extends IConfig<T>> implements FactoryBean<T>, BeanNameAware {
private final Class<T> targetClass;
private String beanName;
@Autowired
private AutowireCapableBeanFactory beanFactory;
@Autowired
private ConfigService configService;
public ConfigServiceFactoryBean(Class<T> targetClass) {
this.targetClass = targetClass;
}
@Override
public void setBeanName(String name) {
this.beanName = name;
}
@Override
public T getObject() throws Exception {
ConstructionGuard.enter(targetClass);
boolean success = true;
try {
T bean = configService.getConfig(targetClass);
beanFactory.autowireBean(bean);
beanFactory.initializeBean(bean, beanName);
configService.saveOrUpdate(bean);
return bean;
}
catch (Exception e) {
log.error("Fail to load config: " + targetClass.getName(), e);
success = false;
throw e;
}
finally {
ConstructionGuard.exit(targetClass);
if (success) {
log.debug("getObject() for {} success", targetClass.toString());
}
}
}
@Override
public Class<?> getObjectType() {
return targetClass;
}
@Override
public boolean isSingleton() {
return true;
}
}

View File

@@ -0,0 +1,159 @@
package link.at17.mid.tushare.component;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.reflections.Reflections;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import link.at17.mid.tushare.annotation.StaticAttribute;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.Map.Entry;
/**
* 注入拦截器
*/
@Component
@Slf4j
public class PlatformInterceptor implements HandlerInterceptor {
@Autowired
Reflections reflections;
/**
* 静态注入的类缓存
*/
Map<String, Class<?>> staticAttributeClassCache;
/**
* 静态注入的枚举字段缓存
*/
Map<String, Map<String, Enum<?>>> options = null;
Map<String, String> optionNameMap = new HashMap<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
injectNecessary(request, handler);
request.getSession(true);
return true;
}
/**
* 为前端模板注入变量
* @param request
*/
public void injectNecessary(HttpServletRequest request, Object handler) {
// 只在 ControllerHandlerMethod里才做注入
if (!(handler instanceof HandlerMethod)) {
// 静态资源、图片、css、js 都会被 ResourceHttpRequestHandler 处理,
// 这里一律跳过
return;
}
// 排除 @ResponseBody/json 接口
HandlerMethod hm = (HandlerMethod) handler;
if (hm.hasMethodAnnotation(ResponseBody.class)
|| hm.getBeanType().isAnnotationPresent(RestController.class)) {
return;
}
request.setAttribute("request", request); // 把 request 本身放到 attribute 里去,供前端部分位置用
// 查找添加了 @StaticAttribute 注解的类,将其中的枚举注入前端供使用
if (staticAttributeClassCache == null) {
log.debug("init static attribute classes.");
staticAttributeClassCache = new HashMap<>();
Set<Class<?>> staticClasses = reflections.getTypesAnnotatedWith(StaticAttribute.class);
for (Class<?> staticClass : staticClasses) {
StaticAttribute sa = staticClass.getAnnotation(StaticAttribute.class);
String name = staticClass.getSimpleName();
if (StringUtils.isNotBlank(sa.value())) {
name = sa.value();
}
if (staticAttributeClassCache.containsKey(name)) {
// 缓存中已经存在了这个名字,直接忽略
log.warn("StaticAttribute annotation name {} for class {} has been taken, ignore.",
name, staticClass.getName());
}
staticAttributeClassCache.put(name, staticClass);
log.debug("{} injected as name {}", staticClass, name);
}
}
else if (staticAttributeClassCache.isEmpty()) {
// 跑完一次后,缓存仍然为空,可能不正常,提示一下
log.warn("StaticAttributes' staticAttributeClassCache is empty, it's unusual, if you didn't exclude it manually, please check.");
}
Iterator<Map.Entry<String, Class<?>>> iterator = staticAttributeClassCache.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Class<?>> entry = iterator.next();
request.setAttribute(entry.getKey(), entry.getValue());
}
if (options == null) {
options = new HashMap<>();
Set<Field> enumOptionFields = reflections.getFieldsAnnotatedWith(StaticAttribute.class);
for (Field f : enumOptionFields) {
if (!f.isAnnotationPresent(StaticAttribute.class)) continue;
if (!Enum.class.isAssignableFrom(f.getType())) continue;
// 拿到注解和名前缀
StaticAttribute anno = f.getAnnotation(StaticAttribute.class);
String prefix = anno.value().isBlank() ?
f.getName() + "Enum" :
anno.value();
prefix = prefix.toUpperCase().charAt(0) + prefix.substring(1);
if (optionNameMap.containsKey(prefix)) {
log.warn("EnumOption name {}:{} has already been taken by {}, please check",
prefix, f.getType().getName(), optionNameMap.get(prefix));
continue;
}
optionNameMap.put(prefix, f.getType().getName());
// enum 值列表
@SuppressWarnings("unchecked")
Class<? extends Enum<?>> enumType = (Class<? extends Enum<?>>) f.getType();
Enum<?>[] constants = enumType.getEnumConstants();
// 构造 Map<name, constant>
Map<String, Enum<?>> optionsMap = new LinkedHashMap<>();
for (Enum<?> c : constants) {
optionsMap.put(c.name(), c);
}
// 放到 Model 里
options.put(prefix, optionsMap);
}
}
for (Entry<String, Map<String, Enum<?>>> entry : options.entrySet()) {
request.setAttribute(entry.getKey(), entry.getValue());
log.debug("Inject enums {}: {} to request {}",
entry.getKey(), optionNameMap.get(entry.getKey()),
request.getRequestURI());
}
}
}

View File

@@ -0,0 +1,36 @@
package link.at17.mid.tushare.config;
import java.util.HashSet;
import java.util.Set;
import org.springframework.beans.factory.BeanCreationException;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ConstructionGuard {
private static final ThreadLocal<Set<Class<?>>> constructing = ThreadLocal.withInitial(HashSet::new);
public static boolean isConstructing(Class<?> clazz) {
return constructing.get().contains(clazz);
}
public static void enter(Class<?> clazz) {
log.debug("Enter construction for {}", clazz.toString());
if (isConstructing(clazz)) {
StringBuilder sb = new StringBuilder();
sb.append("Class ")
.append(clazz.getName())
.append(" is being constructed but is seems like circular involving.");
String msg = sb.toString();
log.error(msg);
throw new BeanCreationException(msg);
}
constructing.get().add(clazz);
}
public static void exit(Class<?> clazz) {
constructing.get().remove(clazz);
log.debug("Exit construction for {}", clazz.toString());
}
}

View File

@@ -0,0 +1,31 @@
package link.at17.mid.tushare.config;
import java.util.Properties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
@Configuration
public class KaptchaConfig {
@Bean(name = "kaptchaProperties")
@ConfigurationProperties
Properties getKaptchaProperties() {
return new Properties();
}
@Bean
@DependsOn("kaptchaProperties")
DefaultKaptcha getDefaultKaptcha(@Autowired Properties kaptchaProperties) {
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
Config config = new Config(kaptchaProperties);
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}

View File

@@ -0,0 +1,21 @@
package link.at17.mid.tushare.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
@Configuration
public class MybatisPlusConfig {
/**
* mybatis-plus分页插件
*/
@Bean
MybatisPlusInterceptor paginationInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
}

View File

@@ -0,0 +1,30 @@
package link.at17.mid.tushare.config;
import org.springframework.context.annotation.Configuration;
import org.quartz.Scheduler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;
import link.at17.mid.tushare.task.TaskSchedulerFactory;
@Configuration
public class QuartzConfig {
@Autowired
private TaskSchedulerFactory taskSchedulerFactory;
@Bean
public SchedulerFactoryBean schedulerFactoryBean() {
SchedulerFactoryBean schedulerFactoryBean = new SchedulerFactoryBean();
schedulerFactoryBean.setJobFactory(taskSchedulerFactory);
return schedulerFactoryBean;
}
@Bean
@Qualifier("scheduler")
public Scheduler scheduler() {
return schedulerFactoryBean().getScheduler();
}
}

View File

@@ -0,0 +1,28 @@
package link.at17.mid.tushare.config;
import org.reflections.Reflections;
import org.reflections.scanners.Scanners;
import org.reflections.util.ConfigurationBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Reflections
* @author Doghole
*
*/
@Configuration
public class ReflectionsConfig {
@Bean("reflections")
Reflections reflections() {
return new Reflections(new ConfigurationBuilder()
.addScanners(
Scanners.MethodsAnnotated,
Scanners.SubTypes,
Scanners.TypesAnnotated,
Scanners.FieldsAnnotated)
.forPackages("link.at17.mid.tushare"));
}
}

View File

@@ -0,0 +1,69 @@
package link.at17.mid.tushare.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import link.at17.mid.tushare.web.service.AuthService;
import lombok.RequiredArgsConstructor;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final AuthService userDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers
.cacheControl(cache -> cache.disable())
.frameOptions(frame -> frame.sameOrigin()))
.csrf(CsrfConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/res/**").permitAll()
.requestMatchers("/favicon.ico").permitAll()
.requestMatchers("/captcha/**").permitAll()
.requestMatchers("/api/**").permitAll()
.requestMatchers("/admin/login").permitAll()
// 静态资源
.requestMatchers("/js/**").permitAll().requestMatchers("/css/**").permitAll()
.requestMatchers("/images/**").permitAll().anyRequest().authenticated())
.formLogin(form -> form // 开启表单登录,并指定登录页
.loginPage("/admin/login") // 指定登录页
.loginProcessingUrl("/admin/doLogin") // 处理登录请求的 URL
.defaultSuccessUrl("/admin/manage", false) // 登录成功后默认跳转
.permitAll())
.logout(logout -> logout.logoutUrl("/admin/logout").logoutSuccessUrl("/admin/login")
.invalidateHttpSession(true).permitAll());
;
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(@Autowired PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(provider);
}
@SuppressWarnings("deprecation")
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}

View File

@@ -0,0 +1,9 @@
package link.at17.mid.tushare.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
//@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800) //session过期时间 如果部署多机环境,需要打开注释
@ConditionalOnProperty(prefix = "littlesweetdog", name = "spring-session-open", havingValue = "true")
public class SpringSessionConfig {
}

View File

@@ -0,0 +1,24 @@
package link.at17.mid.tushare.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import link.at17.mid.tushare.component.ConfigAutoRegistrar;
import link.at17.mid.tushare.component.PlatformInterceptor;
@Configuration
@Import(ConfigAutoRegistrar.class)
public class VerichConfig implements WebMvcConfigurer {
@Autowired
PlatformInterceptor platformInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(platformInterceptor)
.addPathPatterns("/**");
}
}

View File

@@ -0,0 +1,19 @@
package link.at17.mid.tushare.dao;
import com.alibaba.fastjson2.JSONObject;
import link.at17.mid.tushare.annotation.BatchInsert;
import link.at17.mid.tushare.annotation.BatchList;
import link.at17.mid.tushare.data.models.interfaces.ITsTradeDate;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
@Mapper
public interface StockAdjustDao extends ITsTradeDate {
@BatchInsert
int insertOrUpdateListTushare(@BatchList List<JSONObject> list);
}

View File

@@ -0,0 +1,181 @@
package link.at17.mid.tushare.dao;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import link.at17.mid.tushare.annotation.BatchInsert;
import link.at17.mid.tushare.annotation.BatchList;
import link.at17.mid.tushare.data.models.StockCalendar;
import link.at17.mid.tushare.enums.StockMarket;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.Temporal;
import java.util.List;
@Component
@Mapper
public interface StockCalendarDao extends BaseMapper<StockCalendar> {
/**
* 更新股票日历
* @param list
*/
@BatchInsert
void insertOrUpdateList(@BatchList List<JSONObject> list);
/**
* 获取数据库内指定证交所的最近一个交易日历
* @param stockMarket 指定证交所,若为 {@code null} 则取最近一条(市场不确定)
* @return
*/
StockCalendar getLatest(@Nullable StockMarket stockMarket);
/**
* 获取数据库内指定证交所的最早一个交易日历的日期
* <p>仅为日期,并未指定是否是开市日
* @see #getGreatest
* @param stockMarket 指定证交所,若为 {@code null} 则取最近一条(市场不确定)
* @return {@code LocalDate} 或 {@code null}
*/
default LocalDate getLatestLocalDate(@Nullable StockMarket stockMarket) {
StockCalendar stockCalendar = getLatest(stockMarket);
if (stockCalendar != null) {
return stockCalendar.getDate().atStartOfDay().toLocalDate();
}
return null;
}
/**
* 获取数据库内指定证交所的最早一个交易日历的日期时间
* <p>仅为日期时间,并未指定是否是开市日
* @see #getGreatest
* @param stockMarket 指定证交所,若为 {@code null} 则取最近一条(市场不确定)
* @return {@code LocalDateTime} 或 {@code null}
*/
default LocalDateTime getLatestLocalDateTime(@Nullable StockMarket stockMarket) {
StockCalendar stockCalendar = getLatest(stockMarket);
if (stockCalendar != null) {
return stockCalendar.getDate().atStartOfDay();
}
return null;
}
/**
* 获取数据库内指定证交所的最早一个交易日历
* <p>仅为日期,并未指定是否是开市日
* @param stockMarket 指定证交所,若为 {@code null} 则取最早一条(市场不确定)
* @return
*/
StockCalendar getGreatest(@Nullable StockMarket stockMarket);
/**
* 获取数据库内指定证交所的最早一个交易日历的日期
* <p>仅为日期,并未指定是否是开市日
* @see #getGreatest
* @param stockMarket 指定证交所,若为 {@code null} 则取最早一条(市场不确定)
* @return {@code LocalDate} 或 {@code null}
*/
default LocalDate getGreatestLocalDate(@Nullable StockMarket stockMarket) {
StockCalendar stockCalendar = getGreatest(stockMarket);
if (stockCalendar != null) {
return stockCalendar.getDate().atStartOfDay().toLocalDate();
}
return null;
}
/**
* 获取数据库内指定证交所的最早一个交易日历的日期时间
* <p>仅为日期,并未指定是否是开市日
* @see #getGreatest
* @param stockMarket 指定证交所,若为 {@code null} 则取最早一条(市场不确定)
* @return {@code LocalDateTime} 或 {@code null}
*/
default LocalDateTime getGreatestLocalDateTime(@Nullable StockMarket stockMarket) {
StockCalendar stockCalendar = getGreatest(stockMarket);
if (stockCalendar != null) {
return stockCalendar.getDate().atStartOfDay();
}
return null;
}
/**
* 获取指定证交所两个日期(含)之间的交易日个数<br>>
* 存储在数据库内的类型为无时分秒的 timestamp 类型,可视作 {@code java.time.LocalDate} <br>
* 若传入的是 java.time.LocalDateTime 类型,会忽略时分秒
* <p>例:
* <ul><li>
* <code>startDate : LocalDateTime 2021-08-16 01:20:30</code><br>
* <code>endDate : LocalDateTime 2021-08-20 19:20:30</code><br>
* 返回 5
* <li><code>countOpenDaysBetween(StockMarket.<b><i>SH</i></b>, startDate, endDate)</code> 其中<br>
* <code>startDate : LocalDateTime 2021-08-16 01:20:30</code><br>
* <code>endDate : LocalDateTime 2021-08-16 12:34:56</code><br>
* 返回 1
* <li><code>countOpenDaysBetween(StockMarket.<b><i>SH</i></b>, startDate, endDate)</code> 其中<br>
* <code>startDate : LocalDateTime 2021-08-15 01:20:30</code><br>
* <code>endDate : LocalDateTime 2021-08-16 12:34:56</code><br>
* 返回 1
* </ul>
* </p>
* @param exchange 股市类型枚举,若提供的枚举不为 SZ 或 SH则采用 SZ + SH 的所有交易日并去重计算
* @param startDate 开始日期
* @param endDate 结束日期
* @return
*/
long countOpenDaysBetween(@Param("exchange") StockMarket exchange, @Param("startDate") Temporal startDate, @Param("endDate") Temporal endDate);
/**
* 获取指定证交所两个日期(含)之间的所有交易日<br>
* 存储在数据库内的类型为无时分秒的 timestamp 类型,可视作 {@code java.time.LocalDate} <br>
* 若传入的是 java.time.LocalDateTime 类型,会忽略时分秒
* <p>例:
* <ul><li><code>countOpenDaysBetween(StockMarket.<b><i>SH</i></b>, startDate, endDate)</code> 其中<br>
* <code>startDate : LocalDateTime 2021-08-16 01:20:30</code><br>
* <code>endDate : LocalDateTime 2021-08-20 19:20:30</code><br>
* 返回2021-08-16 2021-08-17 2021-08-18 2021-08-19 2021-08-20
* </ul>
* </p>
* @param exchange 股市类型枚举,若提供的枚举不为 SZ 或 SH则采用 SZ + SH 的所有交易日并去重获得结果
* @param startDate 开始日期(含)
* @param endDate 结束日期(含)
* @return
*/
List<LocalDateTime> getAllOpenDatesBetween(@Param("exchange") StockMarket exchange, @Param("startDate") Temporal startDate, @Param("endDate") Temporal endDate);
/**
* 获取指定证交所指定日期偏移的指定交易日
* @param exchange
* @param date
* @param offset
* @return
*/
LocalDateTime getOpenDateOffset(@Param("exchange") StockMarket exchange, @Param("date") Temporal date, @Param("offset") long offset);
/**
* 获取指定证交所指定日期(含)以后的所有交易日
* @param exchange
* @param after 留空则查询所有
* @return
*/
List<LocalDateTime> getAllOpenDate(@Param("exchange") @Nullable StockMarket exchange, @Param("after") @Nullable Temporal after);
/**
* 判断指定日是否为指定交易所的交易日
* @param date 会截断时分秒,只取年月日
* @param exchange 目前只支持 SSE深交所和 SHSE上交所
* @return
* @see StockCalendarDao#isOpen(Temporal, StockMarket)
*/
boolean isOpen(@Param("date") @NonNull Temporal date, @Param("exchange") @Nullable StockMarket exchange);
}

View File

@@ -0,0 +1,23 @@
package link.at17.mid.tushare.dao;
import com.alibaba.fastjson2.JSONObject;
import link.at17.mid.tushare.annotation.BatchInsert;
import link.at17.mid.tushare.annotation.BatchList;
import link.at17.mid.tushare.data.models.interfaces.ITsTradeDate;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
@Mapper
public interface StockDailyBasicDao extends ITsTradeDate {
/**
* 批量插入/更新日基本数据
* @param list
*/
@BatchInsert
void insertOrUpdateList(@BatchList List<JSONObject> list);
}

View File

@@ -0,0 +1,77 @@
package link.at17.mid.tushare.dao;
import com.alibaba.fastjson2.JSONObject;
import link.at17.mid.tushare.annotation.BatchInsert;
import link.at17.mid.tushare.annotation.BatchList;
import link.at17.mid.tushare.data.models.StockValue;
import link.at17.mid.tushare.data.models.StockValueEx;
import link.at17.mid.tushare.data.models.interfaces.ITsTradeDate;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
@Component
@Mapper
public interface StockDailyDao extends ITsTradeDate {
/**
* 批量插入/更新日线数据
* @param list
*/
@BatchInsert
void insertOrUpdateList(@BatchList List<JSONObject> list);
/**
* 获取除权日线数据 + 基本行情数据
* @param stockCode 股票代码,不允许为空
* @param endDate 结束日期(包含),留空则为最新一个交易日
* @param before 多少个交易日以前,留空则查询上市以来所有数据
* @return
*/
List<StockValueEx> getExDailyBeforeTushare(@Param("stockCode") @NotNull String stockCode, @Param("endDate") LocalDateTime endDate, @Param("before") Long before);
/**
* 获取前复权日线数据
* @param stockCode 股票代码,不允许为空
* @param startDate 开始日期,留空则为股票上市日起
* @param endDate 结束日期null 则为最新一个交易日
* @return
*/
List<StockValue> getQfqDailyTushare(@Param("stockCode") @NotNull String stockCode, @Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
/**
* 获取前复权日线数据
* @param stockCode 股票代码,不允许为空
* @param endDate 结束日期,留空则为最新一个交易日
* @param before 多少个交易日以前null 则查询上市以来所有数据
* @return
*/
List<StockValue> getQfqDailyBeforeTushare(@Param("stockCode") @NotNull String stockCode, @Param("endDate") LocalDateTime endDate, @Param("before") Long before);
/**
* 获取前复权日线数据 + 基本行情数据
* @param stockCode 股票代码,不允许为空
* @param startDate 开始日期(包含),留空则为上市第一日
* @param after 多少个交易日以前null 则查询 startDate 以来所有数据
* @return
*/
List<StockValueEx> getExQfqDailyAfterTushare(@Param("stockCode") @NotNull String stockCode, @Param("startDate") LocalDateTime startDate, @Param("after") Long after);
/**
* 获取前复权日线数据 + 基本行情数据
* @param stockCode 股票代码,不允许为空
* @param endDate 结束日期,留空则为最新一个交易日
* @param before 多少个交易日以前null 则查询上市以来所有数据
* @return
*/
List<StockValueEx> getExQfqDailyBeforeTushare(@Param("stockCode") @NotNull String stockCode, @Param("endDate") LocalDateTime endDate, @Param("before") Long before);
/**
* 获取前复权日线数据
* @param stockCode 股票代码,不允许为空
* @param startDate 开始日期(包含),留空则为上市第一日
* @param after 多少个交易日以前null 则查询 startDate 以来所有数据
* @return
*/
List<StockValue> getQfqDailyAfterTushare(@Param("stockCode") @NotNull String stockCode, @Param("startDate") LocalDateTime startDate, @Param("after") Long after);
}

View File

@@ -0,0 +1,28 @@
package link.at17.mid.tushare.dao;
import com.alibaba.fastjson2.JSONObject;
import link.at17.mid.tushare.annotation.BatchInsert;
import link.at17.mid.tushare.annotation.BatchList;
import link.at17.mid.tushare.cache.EvictAfterUpdate;
import link.at17.mid.tushare.data.models.StockHolder;
import link.at17.mid.tushare.data.models.interfaces.ITsTradeDate;
import link.at17.mid.tushare.enums.StockHolderType;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
@Mapper
public interface StockHolderDao extends ITsTradeDate {
@BatchInsert
int insertOrUpdateListTushare(@BatchList List<JSONObject> list, @NonNull StockHolderType holderType);
@EvictAfterUpdate("tushare")
@Cacheable("stockHolderDao.getAllByStockCode")
List<StockHolder> getAllByStockCode(@NonNull String stockCode, @NonNull StockHolderType holderType);
}

View File

@@ -0,0 +1,24 @@
package link.at17.mid.tushare.dao;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import link.at17.mid.tushare.annotation.BatchInsert;
import link.at17.mid.tushare.annotation.BatchList;
import link.at17.mid.tushare.data.models.StockInfo;
import link.at17.mid.tushare.enums.ListStatus;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
@Mapper
public interface StockInfoDao extends BaseMapper<StockInfo> {
@BatchInsert
void insertOrUpdateList(@BatchList List<JSONObject> list);
StockInfo getStockInfoByStockCode(@Param("stockCode") String stockCode);
List<StockInfo> getStockListByListStatus(ListStatus listStatus);
}

View File

@@ -0,0 +1,21 @@
package link.at17.mid.tushare.dao;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import link.at17.mid.tushare.annotation.BatchInsert;
import link.at17.mid.tushare.annotation.BatchList;
import link.at17.mid.tushare.data.models.StockLimit;
import link.at17.mid.tushare.data.models.interfaces.ITsTradeDate;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
@Mapper
public interface StockLimitDao extends BaseMapper<StockLimit>, ITsTradeDate {
@BatchInsert
int insertOrUpdateListTushare(@BatchList List<JSONObject> list);
}

View File

@@ -0,0 +1,77 @@
package link.at17.mid.tushare.dao;
import com.alibaba.fastjson2.JSONObject;
import link.at17.mid.tushare.annotation.BatchInsert;
import link.at17.mid.tushare.annotation.BatchList;
import link.at17.mid.tushare.data.models.StockValue;
import link.at17.mid.tushare.data.models.StockValueEx;
import link.at17.mid.tushare.data.models.interfaces.ITsStockInfo;
import link.at17.mid.tushare.enums.StockSpan;
import org.apache.ibatis.annotations.Mapper;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
@Component
@Mapper
public interface StockMinuteDao {
/**
* 批量插入/更新日线数据
* @param list
*/
@BatchInsert
void insertOrUpdateList(@NotNull StockSpan stockSpan, @BatchList List<JSONObject> list);
/**
* 获取最新交易日<br/>
* 获取到股票的最新交易日
* @param stockSpan 分钟线频率
* @param stockInfo
* @return
*/
LocalDateTime getLatestTradeDate(@NotNull StockSpan stockSpan, ITsStockInfo stockInfo);
/**
* 获取指定 freq 下的所有交易日
* @param stockSpan 分钟线频率
* @param stockInfo
* @return
*/
List<LocalDateTime> getAllTradeDates(@NotNull StockSpan stockSpan, ITsStockInfo stockInfo);
/**
* 获取指定 freq 下的数据缺失日,包括分钟数据不全日<br/>
* 如60 分钟频率下,一日内 K 线数应为 240/60 + 1 = 5 条,则小于 5 条的日期都将被列为缺失日期
* @param stockSpan 分钟线频率
* @param stockInfo
* @return
*/
List<LocalDateTime> getAllMissingDates(@NotNull StockSpan stockSpan, ITsStockInfo stockInfo);
/**
* 获取前复权日线数据
* @param stockCode 股票代码,不允许为空
* @param stockSpan 分钟线频率
* @param startDate 开始日期,留空则为股票上市日起
* @param endDate 结束日期,留空则为最新一个交易日
* @return
*/
List<StockValue> getQfqMinuteTushare(@NotNull String stockCode, @NotNull StockSpan stockSpan, LocalDateTime startDate, LocalDateTime endDate);
/**
* 获取前复权日线数据
* @param stockCode 股票代码,不允许为空
* @param endDate 结束日期,留空则为最新一个交易日
* @param before 多少个交易日以前,留空则查询上市以来所有数据
* @return
*/
List<StockValue> getQfqMinuteBeforeTushare(@NotNull String stockCode, @NotNull StockSpan stockSpan, LocalDateTime endDate, Long before);
/**
* 获取前复权日线 Ex 数据
* @param stockCode 股票代码,不允许为空
* @param endDate 结束日期,留空则为最新一个交易日
* @param before 多少个交易日以前,留空则查询上市以来所有数据
* @return
*/
List<StockValueEx> getExQfqMinuteBeforeTushare(@NotNull String stockCode, @NotNull StockSpan stockSpan, LocalDateTime endDate, Long before);
}

View File

@@ -0,0 +1,35 @@
package link.at17.mid.tushare.dao;
import com.alibaba.fastjson2.JSONObject;
import link.at17.mid.tushare.annotation.BatchInsert;
import link.at17.mid.tushare.annotation.BatchList;
import link.at17.mid.tushare.data.models.StockValue;
import link.at17.mid.tushare.data.models.interfaces.ITsTradeDate;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
@Component
@Mapper
public interface StockThsDailyDao extends ITsTradeDate {
/**
* 批量插入/更新日线数据
* @param list
*/
@BatchInsert
void insertOrUpdateList(@BatchList List<JSONObject> list);
/**
* 获取板块日线数据
* @param stockCode 板块代码,不允许为空
* @param endDate 结束日期,留空则为最新一个交易日
* @param before 多少个交易日以前,留空则查询上市以来所有数据
* @return
*/
List<StockValue> getDailyBeforeTushare(@Param("stockCode") @NonNull String stockCode, @Param("endDate") LocalDateTime endDate, @Param("before") Long before);
}

View File

@@ -0,0 +1,25 @@
package link.at17.mid.tushare.dao;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import link.at17.mid.tushare.annotation.BatchInsert;
import link.at17.mid.tushare.annotation.BatchList;
import link.at17.mid.tushare.data.models.ThsStockInfo;
import link.at17.mid.tushare.enums.ThsStockMarket;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
@Mapper
public interface StockThsListDao extends BaseMapper<ThsStockInfo> {
@BatchInsert
void insertOrUpdateList(@BatchList List<JSONObject> list);
default List<ThsStockInfo> listByExchange(ThsStockMarket exchange){
return selectList(new LambdaQueryWrapper<ThsStockInfo>().eq(exchange != null, ThsStockInfo::getExchange, exchange));
}
}

View File

@@ -0,0 +1,26 @@
package link.at17.mid.tushare.dao;
import com.alibaba.fastjson2.JSONObject;
import link.at17.mid.tushare.annotation.BatchInsert;
import link.at17.mid.tushare.annotation.BatchList;
import link.at17.mid.tushare.data.models.StockInfo;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
@Mapper
public interface StockThsMemberDao {
@BatchInsert
void insertOrUpdateList(@BatchList List<JSONObject> list);
List<StockInfo> getThsMembers(String tsCode);
/**
* 获取同花顺所属概念
* @param tsCode
* @return
*/
String getBelongings(String tsCode);
}

View File

@@ -0,0 +1,30 @@
package link.at17.mid.tushare.data.crawler;
/**
* 查询方式
* @author Barry
*
*/
public enum QueryWay {
/**
* 以日期更新,适用于已有从上市开始的部分数据的更新
*/
ByDateUpdate,
/**
* 以日期扫描所有,适用于初始化
*/
ByDateAll,
/**
* 交叉检查个股和交易日历,而后按照数据缺失日期更新
*/
ByDateCrossCheck,
/**
* 以个股最新日数据为起点更新至今,更适用于初始化
*/
ByStock,
/**
* 交叉检查个股和交易日历,而后按照数据缺失个股和日期更新
*/
ByStockCrossCheck,
}

View File

@@ -0,0 +1,99 @@
package link.at17.mid.tushare.data.crawler;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* 线程安全的重试与延迟控制器
*/
@Accessors(chain = true)
@Slf4j
public class RetryAndDelay {
@Getter
@Setter
private int retryTime = 5;
private volatile long delayBase = 500L;
// 用 AtomicReference<Double> 存储 delayFactor
private final AtomicReference<Double> delayFactor = new AtomicReference<>(1d);
@Getter
@Setter
private int threadNum = 1;
private final AtomicInteger triggerFactorAdd = new AtomicInteger(0);
// 最大因子限制,避免无限增长
@Getter
@Setter
private double maxDelayFactor = 5.0;
/**
* 计算当前延迟
*/
public long getDelay() {
double factor = delayFactor.get();
long delay = (long) Math.floor(delayBase * factor);
// 衰减回 1
if (factor > 1) {
double newFactor = Math.max(1d, factor - 0.0005);
delayFactor.compareAndSet(factor, newFactor);
} else if (factor < 1) {
delayFactor.set(1d);
}
return delay;
}
public RetryAndDelay setDelay(long delay) {
this.delayBase = delay;
this.delayFactor.set(1d);
return this;
}
/**
* 按每分钟请求限制更新
*/
public RetryAndDelay setDelayByMinLimit(int minLimit) {
return setDelayBySpanAndLimit(60000, minLimit);
}
/**
* 按每小时请求限制更新
*/
public RetryAndDelay setDelayByHourLimit(int hourLimit) {
return setDelayBySpanAndLimit(3600000, hourLimit);
}
private RetryAndDelay setDelayBySpanAndLimit(int spanMs, int limit) {
if (threadNum <= 0) {
threadNum = 1;
}
long newDelay = (long) Math.floor(1.0d * spanMs / limit * threadNum);
log.debug("Trigger crawler limit = {}/span, delay = {}ms/{} threads", limit, newDelay, threadNum);
if (delayBase == newDelay) {
int count = triggerFactorAdd.incrementAndGet();
if (count >= threadNum) {
// 增加因子,但不超过最大值
delayFactor.updateAndGet(f -> Math.min(maxDelayFactor, f + 0.05));
triggerFactorAdd.set(0);
}
log.debug("Delay is still too short, increase delayFactor = {}, new delay = {}", delayFactor.get(), getDelay());
} else {
delayBase = newDelay;
triggerFactorAdd.set(0);
}
return this;
}
}

View File

@@ -0,0 +1,106 @@
package link.at17.mid.tushare.data.crawler.tushare;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONException;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import okhttp3.Call;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
@Slf4j
public class TushareClient {
private static final String TUSHARE_URL = "http://api.tushare.pro";
// private static String token = "83a82fadb0bbb803f008b31ce09479e5107f4aba3f28d5df2174c642";
// private static String token = "c473f86ae2f5703f58eecf9864fa9ec91d67edbc01e3294f6a4f9c32";
// private static String token = "ec3d7415bf3dfebf0f27b1e5a9805f809b083fb1f8590c8c2bdc633f";
private static String token = "6f284d9246bad80c3eff946f3ecae8442072b1e60652785f66007509";
/**
* 返回原始查询结果
* @param tr
* @return
*/
public static JSONObject query(TushareRequestBody tr) throws IOException, JSONException {
OkHttpClient okHttpClient = new OkHttpClient();
tr.setToken(token);
Request request = new Request.Builder()
.url(TUSHARE_URL)
.post(
RequestBody.create(
tr.toJSONString(),
MediaType.parse("application/json; charset=utf-8")))
.build();
final Call call = okHttpClient.newCall(request);
Response response = call.execute();
JSONObject result = JSONObject.parseObject(response.body().string());
if (result.get("data") == null) {
response.close();
throw new TushareException(result.getString("msg"));
}
response.close();
return result.getJSONObject("data");
}
/**
* 返回封装查询结果
* @param tr
* @return null 说明请求出错EmptyList 说明请求成功但结果列表为空
* @throws IOException
* @throws JSONException
*/
public static List<JSONObject> queryList(TushareRequestBody tr) throws JSONException, IOException {
JSONObject jo = query(tr);
if (jo == null) {
return null;
}
List<JSONObject> result = new ArrayList<JSONObject>();
JSONArray fields = jo.getJSONArray("fields");
JSONArray items = jo.getJSONArray("items");
int fieldsCount = fields.size();
for (Object o : items) {
JSONArray ja = (JSONArray)o;
JSONObject item = new JSONObject();
for (int i = 0; i < fieldsCount; i++) {
item.put(fields.getString(i), ja.get(i));
}
result.add(item);
}
return result;
}
public static void main(String[] args) {
/*
* 测试获取股票列表
TushareRequestBody request =
new TushareRequestBody("stock_basic")
.addParam("list_status", "L")
.addFields("ts_code,name,area,industry,market,list_date,delist_date");
System.out.println(query(request).toJSONString());
*/
TushareRequestBody request =
new TushareRequestBody("daily")
.addParam("start_date", "19910101")
.addParam("end_date", "20180101")
.addFields("ts_code");
Queue<TushareRequestBody> requestQueue = new LinkedList<>();
requestQueue.add(request);
requestQueue.add(request.clone().addParam("start_date", "fuck").addField("fuckyou"));
// 测试 clone 是否成功
requestQueue.forEach(System.out::println);
}
}

View File

@@ -0,0 +1,827 @@
package link.at17.mid.tushare.data.crawler.tushare;
import java.io.IOException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeSet;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.function.Function;
import java.util.stream.Stream;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import com.alibaba.fastjson2.JSONException;
import com.alibaba.fastjson2.JSONObject;
import link.at17.mid.tushare.dao.StockAdjustDao;
import link.at17.mid.tushare.dao.StockCalendarDao;
import link.at17.mid.tushare.dao.StockDailyBasicDao;
import link.at17.mid.tushare.dao.StockDailyDao;
import link.at17.mid.tushare.dao.StockHolderDao;
import link.at17.mid.tushare.dao.StockInfoDao;
import link.at17.mid.tushare.dao.StockLimitDao;
import link.at17.mid.tushare.dao.StockMinuteDao;
import link.at17.mid.tushare.dao.StockThsDailyDao;
import link.at17.mid.tushare.dao.StockThsListDao;
import link.at17.mid.tushare.dao.StockThsMemberDao;
import link.at17.mid.tushare.data.crawler.QueryWay;
import link.at17.mid.tushare.data.crawler.RetryAndDelay;
import link.at17.mid.tushare.data.models.StockInfo;
import link.at17.mid.tushare.data.models.ThsStockInfo;
import link.at17.mid.tushare.data.models.interfaces.ITsStockInfo;
import link.at17.mid.tushare.data.models.interfaces.ITsTradeDate;
import link.at17.mid.tushare.enums.StockHolderType;
import link.at17.mid.tushare.enums.StockMarket;
import link.at17.mid.tushare.enums.StockSpan;
import link.at17.mid.tushare.enums.ThsStockMarket;
import link.at17.mid.tushare.system.util.LocalDateTimeUtils;
import lombok.extern.slf4j.Slf4j;
/**
* Tushare 爬虫
* @author Barry
*
*/
@Component
@Slf4j
public class TushareCrawler {
@Autowired
private StockInfoDao stockInfoDao;
@Autowired
private StockDailyDao stockDailyDao;
@Autowired
private StockMinuteDao stockMinuteDao;
@Autowired
private StockDailyBasicDao stockDailyBasicDao;
@Autowired
private StockCalendarDao stockCalendarDao;
@Autowired
private StockAdjustDao stockAdjustDao;
@Autowired
private StockThsListDao stockThsListDao;
@Autowired
private StockThsMemberDao stockThsMemberDao;
@Autowired
private StockThsDailyDao stockThsDailyDao;
@Autowired
private StockHolderDao stockHolderDao;
@Autowired
private StockLimitDao stockLimitDao;
@Autowired
private RedissonClient redis;
private static final LocalDateTime THS_DAILY_BEGIN_LOCALDATE = LocalDateTime.of(2007, 8, 1, 0, 0, 0);
private static final int MAX_THREADS = 5;
/**
* 根据日期滚动查询日内所有股票相关信息<br>
* 该查询的起始日是指交易日历内的最新日,故在调用该方法之前,请确保交易日历已最新,且所有股票已有的数据都是最新日数据<br>
*
* 该接口使用 trade_date 进行轮询
* @param baseRequest
* @param ITsTradeDate 实现了 ITsTradeDate 接口的实体
* @param processDataFunc 获取 List 以后的处理方法
* @param QueryWay 查询方法
* @param start 开始日期,留空则默认根据 ITsTradeDate、stockCalendarDao 或个股 listDate 查询
* @param useTradeDate 是否使用 trade_date 字段来获取数据,否则使用 start_date 和 end_date 来获取(如分钟数据)
* @return
* @see TushareCrawler#rollingQueryByStock
*/
private List<Future<TushareCrawlerResult>> rollingQueryByDate(TushareRequestBody baseRequest, ITsTradeDate iTradeDate,
Function<List<JSONObject>, Boolean> processDataFunc, QueryWay queryWay, LocalDateTime start){
ExecutorService es = Executors.newFixedThreadPool(MAX_THREADS);
RetryAndDelay retryAndDelay = new RetryAndDelay().setThreadNum(MAX_THREADS);
List<Future<TushareCrawlerResult>> executeResult = new ArrayList<>();
// 生成指纹
baseRequest.generateFingerPrint();
if (queryWay != QueryWay.ByDateCrossCheck) {
// 查询最新日到当日
boolean all = queryWay.equals(QueryWay.ByDateAll) || iTradeDate == null;
if (start == null) {
if (!all && queryWay != QueryWay.ByDateUpdate) {
queryWay = QueryWay.ByDateCrossCheck;
return rollingQueryByDate(baseRequest, iTradeDate, processDataFunc, queryWay, start);
}
start = stockCalendarDao.getGreatestLocalDateTime(null);
}
if (queryWay == QueryWay.ByDateUpdate && iTradeDate != null) {
LocalDateTime iTradeDateLatest = iTradeDate.getLatestTradeDate(null);
if (iTradeDateLatest != null &&
iTradeDateLatest.toLocalDate().compareTo(start.toLocalDate()) > 0) {
// 数据库内最新数据存在,且大于开市日期,说明有一定数据,从数据库内最新日期开始更新
start = iTradeDateLatest;
}
}
start = start.toLocalDate().atStartOfDay();
LocalDateTime end = LocalDateTime.now().toLocalDate().atStartOfDay();
while(!start.isAfter(end)) {
if (all || stockCalendarDao.isOpen(start, (StockMarket)null)) {
TushareRequestBody rq = baseRequest.clone()
.addDateParam("trade_date", start);
executeResult.add(es.submit(new TushareResponseCallable(rq, processDataFunc)
.setRetryAndDelay(retryAndDelay)));
}
start = start.plusDays(1);
}
}
else {
if (iTradeDate == null) {
throw new RuntimeException("For queryWay = QueryWay.ByDateCrossCheck, iTradeDate must not be null!");
}
// 交叉检查
final LocalDateTime currentDate = LocalDateTime.now();
final List<LocalDateTime> szStockCalendars = stockCalendarDao.getAllOpenDatesBetween(StockMarket.SZ, null, currentDate);
final List<LocalDateTime> shStockCalendars = stockCalendarDao.getAllOpenDatesBetween(StockMarket.SH, null, currentDate);
final List<LocalDateTime> allStockCalendars = stockCalendarDao.getAllOpenDatesBetween((StockMarket)null, null, currentDate);
final List<StockInfo> stockInfos = stockInfoDao.getStockListByListStatus(null);
TreeSet<LocalDateTime> needUpdates = new TreeSet<>();
stockInfos.forEach(stockInfo -> {
List<LocalDateTime> stockTradeDates = iTradeDate.getAllTradeDates(stockInfo);
StockMarket exchange = stockInfo.getExchange();
List<LocalDateTime> crossToList = exchange == StockMarket.SZ ? szStockCalendars :
exchange == StockMarket.SH ? shStockCalendars : allStockCalendars;
Stream<LocalDateTime> crossTo;
LocalDateTime listDate = stockInfo.getListDate(), delistDate = stockInfo.getDelistDate();
if (delistDate != null) {
crossTo = crossToList.stream().filter(date -> (date.isAfter(listDate) || date.equals(listDate)) && date.isBefore(delistDate));
}
else {
crossTo = crossToList.stream().filter(date -> date.isAfter(listDate) || date.equals(listDate));
}
crossTo.filter(date -> !stockTradeDates.contains(date)).forEach(needUpdates::add);
});
needUpdates.descendingSet().forEach(date -> {
TushareRequestBody rq = baseRequest.clone()
.addDateParam("trade_date", date);
executeResult.add(es.submit(new TushareResponseCallable(rq, processDataFunc)
.setRetryAndDelay(retryAndDelay)));
});
}
es.shutdown();
while (!es.isTerminated()) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {}
}
return executeResult;
}
/**
* 根据股票滚动查询,用于获取从上市以来/已有最新记录日期到结束日期的股票的信息轮询<br>
* 除最初初始化时以外,不建议采用该方法进行轮询,请采用 rollingQueryByDate 方法进行轮询<br>
*
* 存储在数据库内相关联的最新日期的字段名必须为 trade_date<br>
* 必须是采用 ts_code, start_date 和 end_date 进行查询的接口才可以用
* @param baseRequest 基本请求,给定接口名和 fields 就行了
* @param stockList
* @param iTradeDate 对应的根据 ts_code 获取数据库内最新一条记录的接口实现,可以检查 list_date 和 delist_date当为空时只根据 ts_code 轮询
* @param afterRespFunc 获取结果后处理的方法,返回是否成功
* @param singleMax 单次最大请求条数
* @return 每条请求的执行结果
* @see TushareCrawler#rollingQueryByDate
*/
private List<Future<TushareCrawlerResult>> rollingQueryByStock(
TushareRequestBody baseRequest,
List<? extends ITsStockInfo> stockList,
ITsTradeDate iTradeDate,
Function<List<JSONObject>, Boolean> afterRespFunc,
long singleMax,
QueryWay queryWay,
StockSpan stockSpan) {
ExecutorService es = Executors.newFixedThreadPool(MAX_THREADS);
RetryAndDelay retryAndDelay = new RetryAndDelay().setThreadNum(MAX_THREADS);
List<Future<TushareCrawlerResult>> executeResult = new ArrayList<>();
// 生成指纹
baseRequest.generateFingerPrint();
if (stockSpan == null) {
stockSpan = StockSpan.Daily;
}
if (queryWay != QueryWay.ByStockCrossCheck) {
Assert.isTrue(singleMax > 0, "单次最大请求条数 singleMax 必须大于 0");;
boolean needCheckDate = iTradeDate != null;
for (ITsStockInfo stockInfo : stockList) {
String stockCode = stockInfo.getTsCode();
if (needCheckDate) {
LocalDateTime start = iTradeDate.getLatestTradeDate(stockInfo);
LocalDateTime end = stockInfo.getDelistDate();
// 是否退市
if (null == end) {
// 未退市或没有 delist_date 字段
end = LocalDateTime.now().toLocalDate().atStartOfDay();
}
if (null == start) {
// 最新日K为空则从头开始查询该股所有日K
start = stockInfo.getListDate();
}
else {
// 最新日K存在则查询从最新日开始至今的所有日K并更新
start = start.toLocalDate().atStartOfDay();
if (start.compareTo(end) == 0) {
// 已经是最新了
continue;
}
start = start.plusDays(1);
LocalDateTime today = LocalDateTime.now();
long todayPassHours = ChronoUnit.HOURS.between(today.toLocalDate().atStartOfDay(), today);
if (start.compareTo(end) == 0 && todayPassHours < 15) {
// 请求当日数据但未到当日收盘时间
continue;
}
}
// 各宽限一天,保证数据补缺、分钟数据
start = start.minusDays(1);
end = end.plusDays(1);
TushareRequestBody rq = baseRequest.clone()
.addDateParam("start_date", start)
.addParam("ts_code", stockCode);
// TODO: 如果是 BJ
StockMarket exchange = stockCode.endsWith("SZ") ? StockMarket.SZ : StockMarket.SH ; //stockCode.endsWith("SZ") ? "SZSE" : stockCode.endsWith("SH") ? "SSE" : null;
// 计算 start 和 end 之间一共有多少个交易日
long dataBetween = stockCalendarDao.countOpenDaysBetween(exchange, start, end);
long dataPerDay = 1;
if (stockSpan.compareTo(StockSpan.Daily) == -1 && stockSpan.compareTo(StockSpan.Minute) >= 0) {
// 分钟数,重新算下 dataBetween
dataPerDay = 240L / stockSpan.getMin() + 1;
}
dataBetween *= dataPerDay;
long daySpan = singleMax / dataPerDay;
LocalDateTime tmpEndDate = stockCalendarDao.getOpenDateOffset(exchange, start, daySpan);
while (dataBetween > singleMax) {
rq.addDateParam("end_date",tmpEndDate);
executeResult.add(es.submit(new TushareResponseCallable(rq.clone(), afterRespFunc)
.setRetryAndDelay(retryAndDelay)));
start = tmpEndDate;
tmpEndDate = stockCalendarDao.getOpenDateOffset(exchange, start, daySpan);
rq.addDateParam("start_date", start);
dataBetween -= daySpan * dataPerDay;
}
rq.addDateParam("end_date", end);
executeResult.add(es.submit(new TushareResponseCallable(rq.clone(), afterRespFunc)
.setRetryAndDelay(retryAndDelay)));
}
else {
TushareRequestBody rq = baseRequest.clone()
.addParam("ts_code", stockCode);
executeResult.add(es.submit(new TushareResponseCallable(rq.clone(), afterRespFunc)
.setRetryAndDelay(retryAndDelay)));
}
}
}
else {
// 交叉检查
final List<StockInfo> stockInfos = stockInfoDao.getStockListByListStatus(null);
for(StockInfo stockInfo : stockInfos) {
StockMarket exchange = stockInfo.getExchange();
List<LocalDateTime> missingDates = iTradeDate.getAllMissingDates(stockInfo);
if (missingDates.size() > 0) {
Collections.sort(missingDates);
int i = 0, maxI = missingDates.size() - 1;
LocalDateTime start = missingDates.get(0), end = missingDates.get(maxI);
end = end.plusDays(1); // end 宽限 1 天,对应分钟数据的情况
TushareRequestBody rq = baseRequest.clone()
.addDateParam("start_date", start)
.addParam("ts_code", stockInfo.getTsCode());
// 需要注意的是:交叉检查后的 start 和 end 和非交叉检查的不一样
// 不应成为一个范围。比如如果只缺失了 start = 2021-01-09 和 end
// = 2022-12-06 两天,成为范围以后将会变成更新 start 和 end 之间
// 的所有数据,这样将会大大增加不必要的更新请求。
long dataBetween = stockCalendarDao.countOpenDaysBetween(exchange, start, end);
long dataPerDay = 1;
if (stockSpan.compareTo(StockSpan.Daily) == -1 && stockSpan.compareTo(StockSpan.Minute) >= 0) {
// 分钟数,重新算下 dataBetween
dataPerDay = 240L / stockSpan.getMin() + 1;
}
dataBetween *= dataPerDay;
long daySpan = singleMax / dataPerDay;
LocalDateTime tmpEndDate = stockCalendarDao.getOpenDateOffset(exchange, start, daySpan);
while (dataBetween > singleMax) {
rq.addDateParam("end_date", tmpEndDate);
executeResult.add(es.submit(new TushareResponseCallable(rq.clone(), afterRespFunc)
.setRetryAndDelay(retryAndDelay)));
start = tmpEndDate;
while (i < maxI && start.isBefore(missingDates.get(i + 1))) {
start = missingDates.get(++i);
}
tmpEndDate = stockCalendarDao.getOpenDateOffset(exchange, start, daySpan);
rq.addDateParam("start_date", start);
dataBetween -= daySpan * dataPerDay;
}
rq.addDateParam("end_date", end);
executeResult.add(es.submit(new TushareResponseCallable(rq.clone(), afterRespFunc)
.setRetryAndDelay(retryAndDelay)));
}
};
}
es.shutdown();
while (!es.isTerminated()) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {}
}
return executeResult;
}
/**
* <a href="https://tushare.pro/document/2?doc_id=25"><b>股票列表</b></a><br>
* <b>更新股票列表</b><br/>
* 接口stock_basic可以通过数据工具调试和查看数据<br>
* 描述:获取基础信息数据,包括股票代码、名称、上市日期、退市日期等<br>
* 积分2000积分起<br>
* 包括上市、退市和暂停上市,无权限(积分不足)状态下每小时最多访问该接口 1 次
*/
public boolean updateStockList() {
try {
List<JSONObject> stockInfos = new ArrayList<>();
// Tushare 经常改请求参数的规则2024/11/13 更新:默认不提供 list_status 时,默认值是 L
TushareRequestBody requestBody = new TushareRequestBody("stock_basic")
.addFields("ts_code,name,area,industry,market,list_status,list_date,delist_date");
requestBody.addParam("list_status", "L");
stockInfos.addAll(TushareClient.queryList(requestBody));
requestBody.addParam("list_status", "D");
stockInfos.addAll(TushareClient.queryList(requestBody));
requestBody.addParam("list_status", "P");
stockInfos.addAll(TushareClient.queryList(requestBody));
stockInfoDao.insertOrUpdateList(stockInfos);
log.info("更新股票列表完成");
return true;
} catch (JSONException | IOException e) {
log.error("更新股票列表时发生错误", e);
return false;
}
}
/**
* <a href="https://tushare.pro/document/2?doc_id=259"><b>同花顺概念和行业指数</b></a><br>
* 接口ths_index<br>
* 描述获取同花顺板块指数。注数据版权归属同花顺如做商业用途请主动联系同花顺如需帮助请联系微信migedata 。<br>
* 限量本接口需获得600积分单次最大5000一次可提取全部数据请勿循环提取。
* @return
*/
public boolean updateThsList() {
try {
List<JSONObject> thsIndexes = TushareClient.queryList(new TushareRequestBody("ths_index"));
stockThsListDao.insertOrUpdateList(thsIndexes);
log.info("更新同花顺板块列表完成");
return true;
} catch (Exception e) {
log.error("更新同花顺板块列表时发生错误", e);
return false;
}
}
/**
* <a href="https://tushare.pro/document/2?doc_id=261"><b>同花顺概念板块成分</b></a><br>
* 接口ths_member<br>
* 描述:获取同花顺概念板块成分列表注:数据版权归属同花顺,如做商业用途,请主动联系同花顺。<br>
* 限量用户积累5000积分可调取可按概念板块代码循环提取所有成分<br>
*/
public void updateThsMember() {
TushareRequestBody baseRequest = new TushareRequestBody("ths_member").addFields("ts_code,con_code,con_name,weight,in_date,out_date,is_new");
Function<List<JSONObject>, Boolean> function = (t) -> {
stockThsMemberDao.insertOrUpdateList(t);
return true;
};
List<ThsStockInfo> stockInfoList = stockThsListDao.listByExchange(null);
while (true) {
List<Future<TushareCrawlerResult>> executeResult = rollingQueryByStock(baseRequest, stockInfoList, null, function, 5000L, QueryWay.ByStock, null);
stockInfoList.clear();
for (Future<TushareCrawlerResult> f : executeResult) {
try {
TushareCrawlerResult result = f.get();
JSONObject request = result.getRequest();
if (result.isFatal()) {
log.error("同花顺概念板块成分数据未获取,发生致命错误,将不会重试:{}", request.toJSONString());
}
else if (!result.isSuccess()) {
log.warn("同花顺概念板块成分数据未获取:{}", request.toJSONString());
stockInfoList.add(request.getJSONObject("params").to(ThsStockInfo.class));
}
} catch (InterruptedException | ExecutionException e) {
log.error("同花顺概念板块成分数据执行结果时发生错误", e);
}
}
if (stockInfoList.size() == 0) {
log.info("同花顺概念板块成分更新完成");
break;
}
log.info("重新获取未获取成功的同花顺概念板块成分");
}
if (baseRequest.hasFingerprint()) {
redis.getBucket(baseRequest.getFingerprint()).delete();
}
}
/**
* <a href="https://tushare.pro/document/2?doc_id=260"><b>同花顺板块指数行情</b></a><br>
* 接口ths_daily<br>
* 描述获取同花顺板块指数行情。注数据版权归属同花顺如做商业用途请主动联系同花顺如需帮助请联系微信migedata 。<br>
* 限量单次最大3000行数据可根据指数代码、日期参数循环提取。<br>
* @param queryWay
*/
public void updateThsDaily(QueryWay queryWay) {
TushareRequestBody baseRequest = new TushareRequestBody("ths_daily").addFields("ts_code,trade_date,close,open,high,low,pre_close,avg_price,change,pct_change,vol,turnover_rate,total_mv,float_mv");
Function<List<JSONObject>, Boolean> function = (t) -> {
stockThsDailyDao.insertOrUpdateList(t);
return true;
};
List<Future<TushareCrawlerResult>> executeResult;
if (queryWay.compareTo(QueryWay.ByStock) >= 0) {
executeResult = rollingQueryByStock(baseRequest, stockThsListDao.listByExchange(ThsStockMarket.A), stockThsDailyDao, function, 3000L, queryWay, null);
}
else {
executeResult = rollingQueryByDate(baseRequest, stockThsDailyDao, function, queryWay, THS_DAILY_BEGIN_LOCALDATE);
}
log.info("同花顺板块指数行情更新完成");
for (Future<TushareCrawlerResult> f : executeResult) {
try {
TushareCrawlerResult result = f.get();
JSONObject request = result.getRequest();
if (result.isFatal()) {
log.error("同花顺板块指数行情数据未获取,发生致命错误,将不会重试:{}", request.toJSONString());
}
else if (!result.isSuccess()) {
log.warn("同花顺板块指数行情数据未获取:{}", request.toJSONString());
}
} catch (InterruptedException | ExecutionException e) {
log.error("同花顺板块指数行情数据执行结果时发生错误", e);
}
}
if (baseRequest.hasFingerprint()) {
redis.getBucket(baseRequest.getFingerprint()).delete();
}
}
/**
* <b><a href="https://waditu.com/document/2?doc_id=27">更新日K数据</a></b><br/>
* 基础积分每分钟内最多调取500次每次5000条数据相当于23年历史用户获得超过5000积分正常调取无频次限制。
* <br>
* <font color="red">请先更新交易日历和股票列表后再调用该方法,否则可能造成股票日 K 数据缺失</font>
* @param queryWay 滚动查询方式
* @see TushareCrawler#rollingQueryByDate
* @see TushareCrawler#rollingQueryByStock
*/
public void updateStockDaily(QueryWay queryWay) {
TushareRequestBody baseRequest = new TushareRequestBody("daily")
.addFields("ts_code,trade_date,open,high,low,close,pre_close,change,pct_chg,vol,amount");
Function<List<JSONObject>, Boolean> function = (t) -> {
stockDailyDao.insertOrUpdateList(t);
return true;
};
List<Future<TushareCrawlerResult>> executeResult;
if (queryWay.compareTo(QueryWay.ByStock) >= 0) {
executeResult = rollingQueryByStock(baseRequest, stockInfoDao.getStockListByListStatus(null), stockDailyDao, function, 5000L, queryWay, null);
}
else {
executeResult = rollingQueryByDate(baseRequest, stockDailyDao, function, queryWay, null);
}
log.info("日 K 数据更新完成");
for (Future<TushareCrawlerResult> f : executeResult) {
try {
TushareCrawlerResult result = f.get();
JSONObject request = f.get().getRequest();
if (result.isFatal()) {
log.error("日 K 数据未获取,发生致命错误,将不会重试:{}", request.toJSONString());
}
else if (!result.isSuccess()) {
log.warn("日 K 数据未获取:{}", request.toJSONString());
}
} catch (InterruptedException | ExecutionException e) {
log.error("检查日 K 数据执行结果时发生错误", e);
}
}
if (baseRequest.hasFingerprint()) {
redis.getBucket(baseRequest.getFingerprint()).delete();
}
}
/**
* <b><a href="https://tushare.pro/document/1?doc_id=290">更新分钟K数据</a></b><br/>
* <p>有权限时每分钟500次每次8000行数据总量不限制</p>
* <font color="red">请先更新交易日历和股票列表后再调用该方法,否则可能造成股票分钟 K 数据缺失</font>
* @param queryWay 滚动查询方式,仅支持 StockSpan.ByStock... 系列
* @param stockSpan 股票粒度,仅支持 StockSpan.Minute - StockSpan.SixtyMinute
* @see TushareCrawler#rollingQueryByDate
* @see TushareCrawler#rollingQueryByStock
* @see link.at17.mid.tushare.enums.StockSpan
*/
public void updateStockMinValue(QueryWay queryWay, StockSpan stockSpan) {
Assert.isTrue(Objects.nonNull(stockSpan), "stockSpan 不允许为空");
Assert.isTrue(Objects.nonNull(stockSpan.getMin()), "不支持的 StockSpan" + stockSpan + ", 仅支持分钟数据类型");
Assert.isTrue(queryWay.compareTo(QueryWay.ByStock) >= 0, "不支持的 QueryWay");
String freq = stockSpan.getMin() + "min";
TushareRequestBody baseRequest = new TushareRequestBody("stk_mins")
.addFields("ts_code,trade_time,open,close,high,low,vol,amount")
.addParam("freq", freq);
Function<List<JSONObject>, Boolean> function = (t) -> {
stockMinuteDao.insertOrUpdateList(stockSpan, t);
return true;
};
// 特殊重写:获取最新日期和所有交易日,该重写仅对更新数据生效
ITsTradeDate minTradeDate = new ITsTradeDate() {
@Override
public LocalDateTime getLatestTradeDate(ITsStockInfo stockInfo) {
return stockMinuteDao.getLatestTradeDate(stockSpan, stockInfo);
}
@Override
public List<LocalDateTime> getAllTradeDates(ITsStockInfo stockInfo) {
return stockMinuteDao.getAllTradeDates(stockSpan, stockInfo);
}
@Override
public List<LocalDateTime> getAllMissingDates(ITsStockInfo stockInfo) {
return stockMinuteDao.getAllMissingDates(stockSpan, stockInfo);
}
};
List<Future<TushareCrawlerResult>> executeResult = rollingQueryByStock(baseRequest, stockInfoDao.getStockListByListStatus(null), minTradeDate, function, 8000L, queryWay, stockSpan);
log.info("{} 分钟 K 数据更新完成", stockSpan.getMin());
for (Future<TushareCrawlerResult> f : executeResult) {
try {
TushareCrawlerResult result = f.get();
JSONObject request = f.get().getRequest();
if (result.isFatal()) {
log.error("{} 分钟 K 数据未获取:{},发生致命错误,将不会重试:{}", stockSpan.getMin(), request.toJSONString());
}
else if (!result.isSuccess()) {
log.warn("{} 分钟 K 数据未获取:{}", stockSpan.getMin(), request.toJSONString());
}
} catch (InterruptedException | ExecutionException e) {
log.error("检查 {} 分钟 K 数据执行结果时发生错误\r\n{}", stockSpan.getMin(), e);
}
}
if (baseRequest.hasFingerprint()) {
redis.getBucket(baseRequest.getFingerprint()).delete();
}
}
/**
* 获取个股每日指标
* <blockquote>
* 接口daily_basic<br>
* 更新时间交易日每日15点17点之间<br>
* 描述:获取全部股票每日重要的基本面指标,可用于选股分析、报表展示等。<br>
* 积分用户需要至少600积分才可以调取具体请参阅
* <a href="https://tushare.pro/document/1?doc_id=13">积分获取办法</a><br>
* </blockquote>
*/
public void updateDailyBasic(QueryWay queryWay) {
TushareRequestBody baseRequest = new TushareRequestBody("daily_basic");
baseRequest.addFields("ts_code,trade_date,close,turnover_rate,turnover_rate_f,volume_ratio,pe,pe_ttm,pb,ps,ps_ttm,dv_ratio,dv_ttm,total_share,float_share,free_share,total_mv,circ_mv");
Function<List<JSONObject>, Boolean> function = (t) -> {
stockDailyBasicDao.insertOrUpdateList(t);
return true;
};
List<Future<TushareCrawlerResult>> executeResult;
if (queryWay.compareTo(QueryWay.ByStock) >= 0) {
executeResult = rollingQueryByStock(baseRequest, stockInfoDao.getStockListByListStatus(null), stockDailyBasicDao, function, 5000L, queryWay, null);
}
else {
executeResult = rollingQueryByDate(baseRequest, stockDailyBasicDao, function, queryWay, null);
}
log.info("每日指标数据更新完成");
for (Future<TushareCrawlerResult> f : executeResult) {
try {
TushareCrawlerResult result = f.get();
JSONObject request = f.get().getRequest();
if (result.isFatal()) {
log.error("每日指标数据未获取,发生致命错误,将不会重试:{}", request.toJSONString());
}
else if (!result.isSuccess()) {
log.warn("每日指标数据未获取:{}", request.toJSONString());
}
} catch (InterruptedException | ExecutionException e) {
log.error("检查每日指标数据执行结果时发生错误", e);
}
}
if (baseRequest.hasFingerprint()) {
redis.getBucket(baseRequest.getFingerprint()).delete();
}
}
/**
* <b><a href="https://waditu.com/document/2?doc_id=28">Tushare 复权因子</a></b><br>
* <blockquote>
* 接口adj_factor<br>
* 更新时间早上9点30分<br>
* 描述:获取股票复权因子,可提取单只股票全部历史复权因子,也可以提取单日全部股票的复权因子。<br>
* </blockquote>
* 虽然文档没说,但当这个接口请求单支股的所有数据时,也是 5000 条限制<br>
* <font color="red">请先更新交易日历和股票列表后再调用该方法,否则可能造成股票复权因子缺失</font>
*/
public void updateStockAdjustTushare(QueryWay queryWay) {
TushareRequestBody baseRequest = new TushareRequestBody("adj_factor");
Function<List<JSONObject>, Boolean> function = (t) -> {
stockAdjustDao.insertOrUpdateListTushare(t);
return true;
};
List<Future<TushareCrawlerResult>> executeResult;
if (queryWay.compareTo(QueryWay.ByStock) >= 0) {
executeResult = rollingQueryByStock(baseRequest, stockInfoDao.getStockListByListStatus(null), stockAdjustDao, function, 5000L, queryWay, null);
}
else {
executeResult = rollingQueryByDate(baseRequest, stockAdjustDao, function, queryWay, null);
}
log.info("复权数据更新完成");
for (Future<TushareCrawlerResult> f : executeResult) {
try {
TushareCrawlerResult result = f.get();
JSONObject request = f.get().getRequest();
if (result.isFatal()) {
log.error("复权数据未获取,发生致命错误,将不会重试:{}", request.toJSONString());
}
else if (!result.isSuccess()) {
log.info("复权数据未获取:{}", request.toJSONString());
}
} catch (InterruptedException | ExecutionException e) {
log.error("检查复权数据执行结果时发生错误", e);
}
}
if (baseRequest.hasFingerprint()) {
redis.getBucket(baseRequest.getFingerprint()).delete();
}
}
/**
* <b><a href="https://waditu.com/document/2?doc_id=298">Tushare 涨跌停列表</a></b><br>
* <ul>
* <li>接口limit_list_d<br>
* <li>描述获取沪深A股每日涨跌停、炸板数据情况数据从2020年开始<br>
* <li>限量单次最大可以获取500条数据可通过日期或者股票循环提取<br>
* <li>积分120积分可查看数据5000积分每分钟可以请求200次8000积分以上每分钟500次具体请参阅积分获取办法<br>
* </ul>
*/
public void updateStockLimit(QueryWay queryWay) {
TushareRequestBody baseRequest = new TushareRequestBody("limit_list_d");
Function<List<JSONObject>, Boolean> function = (t) -> {
stockLimitDao.insertOrUpdateListTushare(t);
return true;
};
List<Future<TushareCrawlerResult>> executeResult;
if (queryWay.compareTo(QueryWay.ByStock) >= 0) {
executeResult = rollingQueryByStock(baseRequest, stockInfoDao.getStockListByListStatus(null), stockLimitDao, function, 500L, queryWay, null);
}
else {
executeResult = rollingQueryByDate(baseRequest, stockLimitDao, function, queryWay, LocalDateTime.of(2019, 11, 28, 0, 0, 0));
}
log.info("涨跌停数据更新完成");
for (Future<TushareCrawlerResult> f : executeResult) {
try {
TushareCrawlerResult result = f.get();
JSONObject request = f.get().getRequest();
if (result.isFatal()) {
log.error("涨跌停数据未获取,发生致命错误,将不会重试:{}", request.toJSONString());
}
else if (!result.isSuccess()) {
log.info("涨跌停数据未获取:{}", request.toJSONString());
}
} catch (InterruptedException | ExecutionException e) {
log.error("检查涨跌停数据执行结果时发生错误", e);
}
}
if (baseRequest.hasFingerprint()) {
redis.getBucket(baseRequest.getFingerprint()).delete();
}
}
/**
* <b><a href="https://tushare.pro/document/2?doc_id=61">前十大股东</a>/<a href="https://tushare.pro/document/2?doc_id=62">前十大流通股东</a></b><br>
* <blockquote>
* 接口top10_holders/top10_floatholders<br>
* 更新时间:不定时,报告日<br>
* 描述:获取上市公司前十大股东数据,包括持有数量和比例等信息/获取上市公司前十大流通股东数据,不包括比例信息<br>
* </blockquote>
* 文档说单次 100 条限制,但实际测试有单次 5000 条<br>
* <font color="red">请先更新交易日历和股票列表后再调用该方法</font><br>
* <font color="red">如果是 A+H 或者 A+B 股,可能存在同股票存在多个同名股东,且持股数据不一致。需要仔细研究如何清洗和使用</font>
*/
public void updateStockHolder(StockHolderType holderType) {
boolean isFloat = holderType.getIsFloat() == 1;
TushareRequestBody baseRequest = new TushareRequestBody(isFloat ? "top10_floatholders" : "top10_holders");
Function<List<JSONObject>, Boolean> function = (t) -> {
Map<String, JSONObject> map = new HashMap<>();
for (JSONObject jo : t) {
String holderName = jo.getString("holder_name");
String[] temp = {jo.getString("ts_code"), jo.getString("end_date"), holderName, holderType.name()};
String key = String.join("_", temp);
Integer holderOffset = 1;
while (map.containsKey(key)) {
log.warn("存在重复的 key {},开始重命名", key);
temp[2] = holderName + '^' + holderOffset++;
jo.put("holder_name", temp[2]);
key = String.join("_", temp);
}
map.put(key, jo);
}
stockHolderDao.insertOrUpdateListTushare(t, holderType);
return true;
};
List<Future<TushareCrawlerResult>> executeResult;
executeResult = rollingQueryByStock(baseRequest, stockInfoDao.getStockListByListStatus(null), stockHolderDao, function, 5000L, QueryWay.ByStock, null);
String banner = "十大" + (isFloat ? "流通股东" : "股东");
log.info("{}数据更新完成", banner);
for (Future<TushareCrawlerResult> f : executeResult) {
try {
TushareCrawlerResult result = f.get();
JSONObject request = f.get().getRequest();
if (result.isFatal()) {
log.error("{}数据未获取,发生致命错误,将不会重试:{}", banner, request.toJSONString());
}
else if (!result.isSuccess()) {
log.info("{}数据未获取:{}", banner, request.toJSONString());
}
} catch (InterruptedException | ExecutionException e) {
log.error("检查" + banner + "数据执行结果时发生错误", e);
}
}
if (baseRequest.hasFingerprint()) {
redis.getBucket(baseRequest.getFingerprint()).delete();
}
}
/**
* <b>更新交易日历</b>
* <p>
* 更新当前交易日历到本年度的最后一天
* </p>
* @param stockMarkets 市场类型,若未提供则默认更新上交所和深交所
*/
public boolean updateStockCalendar(StockMarket...stockMarkets) {
LocalDate lastDayOfThisYear = LocalDateTimeUtils.getLastDayOfThisYear();
if (stockMarkets == null || stockMarkets.length == 0) {
stockMarkets = new StockMarket[] {StockMarket.SZ, StockMarket.SH};
}
for (StockMarket stockMarket : stockMarkets) {
log.debug("正在更新 {} 交易所的交易日历", stockMarket);
LocalDate latest = stockCalendarDao.getLatestLocalDate(stockMarket);
if (latest != null && latest.equals(lastDayOfThisYear)){
log.debug("{} 交易所的日历已更新到本年度最后一日,跳过", stockMarket);
continue;
}
TushareRequestBody rq = new TushareRequestBody("trade_cal")
.addParam("exchange", stockMarket.getExchangeCode())
.addDateParam("end_date", lastDayOfThisYear)
.addFields("exchange,cal_date,is_open,pretrade_date");
if (latest != null) {
rq.addDateParam("start_date", latest);
}
try {
List<JSONObject> list = TushareClient.queryList(rq);
stockCalendarDao.insertOrUpdateList(list);
} catch (JSONException e) {
log.error("JSONException", e);
return false;
} catch (IOException e) {
log.error("IOException", e);
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,22 @@
package link.at17.mid.tushare.data.crawler.tushare;
import lombok.Builder;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 放置原始请求和爬虫是否成功的类
* @author Barry
*
*/
@Data
@Builder
@Accessors(chain=true)
public class TushareCrawlerResult {
private TushareRequestBody request;
@Builder.Default
private boolean isFatal = false;
@Builder.Default
private boolean isSuccess = false;
private String msg;
}

View File

@@ -0,0 +1,20 @@
package link.at17.mid.tushare.data.crawler.tushare;
import lombok.Getter;
@SuppressWarnings("serial")
public class TushareException extends RuntimeException {
public static final String NO_PERMISSION = "抱歉,您没有访问该接口的权限,权限的具体详情访问";
@Getter
private boolean isFatal;
public TushareException(String message) {
super(message);
if (message != null && message.contains(NO_PERMISSION)) {
isFatal = true;
}
}
}

View File

@@ -0,0 +1,175 @@
package link.at17.mid.tushare.data.crawler.tushare;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAccessor;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* Tushare 请求主体封装
* @author Barry
*
*/
@Data
@EqualsAndHashCode(callSuper=false)
public class TushareRequestBody extends JSONObject implements Cloneable {
private static final long serialVersionUID = 1L;
private List<String> fields = new ArrayList<>();
private static final String PARAMS = "params";
private static final String API_NAME = "api_name";
private static final String FIELDS = "fields";
private static final String TOKEN = "token";
private static final DateTimeFormatter DEFAULT_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
private static final DateTimeFormatter DEFAULT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private String fingerprint = null;
/**
* 使用 apiName 实例化
* @param apiName
*/
public TushareRequestBody(String apiName) {
put(API_NAME, apiName);
put(PARAMS, new JSONObject());
put(FIELDS, null);
put(TOKEN, null);
}
@SuppressWarnings("unchecked")
@Override
public TushareRequestBody clone() {
TushareRequestBody copy = new TushareRequestBody(getString(API_NAME));
copy.putAll(JSONObject.parseObject(this.toJSONString()));
copy.fields = (List<String>)JSONObject.parseObject(JSONObject.toJSONString(fields), ArrayList.class);
if (hasFingerprint()) {
copy.fingerprint = fingerprint;
}
return copy;
}
/**
* 添加多个查询字段,字段间以逗号分隔
* @param fields
* @return
*/
public TushareRequestBody addFields(String fields) {
Collections.addAll(this.fields, fields.split(","));
this.fields = this.fields.stream().distinct().collect(Collectors.toList());
put(FIELDS, this.fields);
return this;
}
/**
* 添加多个查询字段
* @param fields
* @return
*/
public TushareRequestBody addFields(String... fields) {
Collections.addAll(this.fields, fields);
this.fields = this.fields.stream().distinct().collect(Collectors.toList());
put(FIELDS, this.fields);
return this;
}
/**
* 添加单个查询字段
* @param field
* @return
*/
public TushareRequestBody addField(String field) {
fields.add(field);
fields = fields.stream().distinct().collect(Collectors.toList());
put(FIELDS, fields);
return this;
}
/**
* 添加查询参数
* @param name
* @param value
* @return
*/
public TushareRequestBody addParam(String name, String value) {
((JSONObject)get(PARAMS)).put(name, value);
return this;
}
/**
* 添加日期型查询参数会截断到日期格式yyyyMMdd
* @param name 参数名称
* @param date 日期
* @return
*/
public TushareRequestBody addDateParam(String name, TemporalAccessor date) {
addParam(name, DEFAULT_DATE_FORMATTER.format(date));
return this;
}
/**
* 添加日期时间查询参数格式yyyy-MM-dd HH:mm:ss
* @param name 参数名称
* @param dateTime 时间
* @return
*/
public TushareRequestBody addDateTimeParam(String name, TemporalAccessor dateTime) {
addParam(name, DEFAULT_TIME_FORMATTER.format(dateTime));
return this;
}
/**
* 添加查询参数
* @param params
* @return
*/
public TushareRequestBody addParams(Map<String, String> params) {
((JSONObject)get(PARAMS)).putAll(params);
return this;
}
public TushareRequestBody setToken(String token) {
put(TOKEN, token);
return this;
}
/**
* 生成当前请求参数的 fingerprint
* @return
*/
public TushareRequestBody generateFingerPrint() {
if (!hasFingerprint()) {
StringBuilder sb = new StringBuilder()
.append(API_NAME)
.append(':')
.append(get(API_NAME))
.append('|')
.append(PARAMS)
.append(':')
.append(JSON.toJSONString(get(PARAMS)));
fingerprint = sb.toString();
}
return this;
}
/**
* 是否有 fingerprint用于判断请求是否为同一类型
* @return
*/
public Boolean hasFingerprint () {
return StringUtils.isNotEmpty(fingerprint);
}
}

View File

@@ -0,0 +1,116 @@
package link.at17.mid.tushare.data.crawler.tushare;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.Callable;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.redisson.api.RBucket;
import org.redisson.api.RedissonClient;
import com.alibaba.fastjson2.JSONObject;
import link.at17.mid.tushare.data.crawler.RetryAndDelay;
import link.at17.mid.tushare.system.util.SpringContextHolder;
import lombok.Data;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
/**
* 用来处理请求、请求返回结果,并返回是否执行成功
* @author Barry
*
*/
@Slf4j
@Data
@Accessors(chain=true)
public class TushareResponseCallable implements Callable<TushareCrawlerResult> {
private TushareRequestBody request;
private RetryAndDelay retryAndDelay = new RetryAndDelay();
private Function<List<JSONObject>, Boolean> function;
private static final Pattern MIN_LIMIT_PATTERN = Pattern.compile("分钟.*?(\\d+)次");
private static final Pattern HOUR_LIMIT_PATTERN = Pattern.compile("小时.*?(\\d+)次");
/**
* 使用 TushareRequestBody 对象和对应 Function 初始化
* @param request
* @param consumer
*/
public TushareResponseCallable(TushareRequestBody request, Function<List<JSONObject>, Boolean> function) {
this.request = request;
this.function = function;
}
@Override
public TushareCrawlerResult call() throws Exception {
if (!request.hasFingerprint()) {
return TushareCrawlerResult.builder()
.request(request)
.isSuccess(false)
.isFatal(true)
.msg("请求指纹缺失,请检查")
.build();
}
RedissonClient client = SpringContextHolder.getBean(RedissonClient.class);
RBucket<Object> bucket = client.getBucket(request.getFingerprint());
int retryTime = retryAndDelay.getRetryTime();
while (retryTime-- > 0) {
if (bucket.isExists()) {
return TushareCrawlerResult.builder()
.request(request)
.isSuccess(false)
.isFatal(true)
.msg("Request cancelled for existing fingerprint bucket.")
.build();
}
try {
List<JSONObject> list = TushareClient.queryList(request);
if (Objects.isNull(list)) {
break;
}
Thread.sleep(retryAndDelay.getDelay());
return TushareCrawlerResult.builder()
.request(request)
.isSuccess(list.size() > 0 ? function.apply(list) : true)
.build();
}
catch (Exception ex) {
log.error("Request failed: {}, error={}", request.toJSONString(), ex.getMessage());
try {
Thread.sleep(5000L);
if (ex instanceof TushareException te) {
if (te.isFatal()) {
// 设置 Redis 缓存,设置 Fatal 指纹
bucket.set(1);
return TushareCrawlerResult.builder()
.request(request)
.isSuccess(false)
.isFatal(true)
.msg(ex.getMessage())
.build();
}
Matcher m = MIN_LIMIT_PATTERN.matcher(ex.getMessage());
if (m.find()) {
Integer minLimit = Integer.parseInt(m.group(1));
retryAndDelay.setDelayByMinLimit(minLimit);
continue;
}
m = HOUR_LIMIT_PATTERN.matcher(ex.getMessage());
if (m.find()) {
Integer hourLimit = Integer.parseInt(m.group(1));
retryAndDelay.setDelayByHourLimit(hourLimit);
continue;
}
}
} catch (InterruptedException e) {
log.error(e.getMessage());
}
}
}
return TushareCrawlerResult.builder().request(request).isSuccess(false).build();
}
}

View File

@@ -0,0 +1,32 @@
package link.at17.mid.tushare.data.models;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 成本分布(筹码)
* @author Barry
*
*/
@Data
@Accessors(chain=true)
public class CostDistribution {
private Double[] x;
private Double[] y;
private LocalDateTime date;
private Double benifitPart;
private Double avgCost;
private PercentChips precentChips90;
private PercentChips precentChips70;
@Data
@Accessors(chain=true)
public static class PercentChips {
private Double priceUpper;
private Double priceLower;
private Double concentration;
}
}

View File

@@ -0,0 +1,27 @@
package link.at17.mid.tushare.data.models;
import java.time.LocalDateTime;
import link.at17.mid.tushare.enums.BuyOrSell;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 交易点
* @author Barry
*
*/
@Data
@Accessors(chain=true)
public class ExchangePoint<T> implements Comparable<ExchangePoint<?>> {
private BuyOrSell buyOrSell;
private LocalDateTime exchangeDate;
private Boolean used = false;
@Override
public int compareTo(ExchangePoint<?> o) {
return exchangeDate.compareTo(o.getExchangeDate());
}
}

View File

@@ -0,0 +1,29 @@
package link.at17.mid.tushare.data.models;
import java.time.LocalDateTime;
import link.at17.mid.tushare.system.util.LocalDateTimeUtils;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 个股持仓
* @author Barry
*
*/
@Data
@Accessors(chain=true)
public class Hold {
private int holds = 0;
private int canSell = 0;
private LocalDateTime date;
private StockInfo stockInfo;
private Double cost;
public int getCanSell(LocalDateTime currentDate) {
if (!LocalDateTimeUtils.isSameDay(currentDate, date) && currentDate.compareTo(date) == 1) {
return holds;
}
else return canSell;
}
}

View File

@@ -0,0 +1,21 @@
package link.at17.mid.tushare.data.models;
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain=true)
public class IndexValue<I extends Comparable<I>, T> implements Comparable<IndexValue<I, T>> {
private T value;
private I index;
@Override
public int compareTo(IndexValue<I, T> o) {
return index.compareTo(o.index);
}
public IndexValue(I index, T value) {
this.index = index;
this.value = value;
}
}

View File

@@ -0,0 +1,74 @@
package link.at17.mid.tushare.data.models;
import link.at17.mid.tushare.enums.PivotPointType;
import lombok.Getter;
/**
* 中心点、压力位和支撑位
* @author Barry
*
*/
@Getter
public class PivotPoint {
private Double resistance4;
private Double resistance3;
private Double resistance2;
private Double resistance1;
private Double pivotPoint;
private Double support1;
private Double support2;
private Double support3;
private Double support4;
private PivotPointType pivotPointType;
public PivotPoint(StockValue stockValue, PivotPointType pivotPointType) {
Double high = stockValue.getHigh(), // c
low = stockValue.getLow(), // f
open = stockValue.getOpen(), // e
close = stockValue.getClose(); // h
if (pivotPointType.equals(PivotPointType.Classic)) {
Double k = (high + low + close) / 3, g = 2 * k - high, bres1 = 2 * k - low;
resistance3 = high + 2 * (k - low);
resistance2 = k + (bres1 - g);
resistance1 = bres1;
pivotPoint = k;
support1 = g;
support2 = k - (bres1 - g);
support3 = low - 2 * (high - k);
}
else if (pivotPointType.equals(PivotPointType.Woodies)) {
Double d = (high + low + 2 * close) / 4 ;
resistance1 = 2 * d - low;
resistance2 = d + high - low;
pivotPoint = d;
support1 = 2 * d - high;
support2 = d - high + low;
}
else if (pivotPointType.equals(PivotPointType.Camarilla)) {
resistance1 = close + ((high - low) * (1.1 / 12));
resistance2 = close + ((high - low) * (1.1 / 6));
resistance3 = close + ((high - low) * (1.1 / 4));
resistance4 = close + ((high - low) * (1.1 / 2));
support1 = close - ((high - low) * (1.1 / 12));
support2 = close - ((high - low) * (1.1 / 6));
support3 = close - ((high - low) * (1.1 / 4));
support4 = close - ((high - low) * (1.1 / 2));
}
else {
Double b;
if (close < open) {
b = high + low * 2 + close;
}
else if (close > open) {
b = high * 2 + low + close;
}
else {
b = high + low + close * 2;
}
resistance1 = b / 2 - low;
support1 = b / 2 - high;
pivotPoint = b / 4;
}
this.pivotPointType = pivotPointType;
}
}

View File

@@ -0,0 +1,13 @@
package link.at17.mid.tushare.data.models;
import lombok.Data;
@Data
public class Sector {
String symbol;
String name;
Double lastPx;
Double pxChangeRate;
String market;
String typeCode;
}

View File

@@ -0,0 +1,25 @@
package link.at17.mid.tushare.data.models;
import java.time.LocalDate;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import link.at17.mid.tushare.enums.StockMarket;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import lombok.experimental.Accessors;
@SuppressWarnings("serial")
@Data
@Accessors(chain=true)
@EqualsAndHashCode(callSuper=false)
@ToString
@TableName("stock_calendar")
public class StockCalendar extends Model<StockCalendar> {
LocalDate date;
StockMarket exchange;
LocalDate pretradeDate;
Boolean isOpen;
}

View File

@@ -0,0 +1,23 @@
package link.at17.mid.tushare.data.models;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
@SuppressWarnings("serial")
@Data
@Accessors(chain=true)
@EqualsAndHashCode(callSuper=false)
@TableName("stock_holder")
public class StockHolder extends Model<StockHolder> {
String tsCode;
LocalDateTime annDate;
LocalDateTime endDate;
String holderName;
Double holdAmount;
Double holdRatio;
}

View File

@@ -0,0 +1,102 @@
package link.at17.mid.tushare.data.models;
import java.time.LocalDateTime;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import com.baomidou.mybatisplus.annotation.TableField;
import link.at17.mid.tushare.data.models.interfaces.ITsStockInfo;
import link.at17.mid.tushare.enums.ListStatus;
import link.at17.mid.tushare.enums.StockMarket;
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain=true)
public class StockInfo implements ITsStockInfo {
private static final Pattern EM_CODE_PATTERN = Pattern.compile("^(0|1|90)\\.(.*?)$");
private static final Pattern STANDARD_CODE_PATTERN = Pattern.compile("^(\\d{6})\\.(SZ|SH|BJ)$");
private String name;
@TableField(exist=false)
private String nudeCode;
private ListStatus listStatus;
private String tsCode;
private LocalDateTime listDate;
private LocalDateTime delistDate;
@TableField(exist=false)
private StockMarket exchange;
@TableField(exist=false)
private String thsBelongings;
public String getEMCode() {
return exchange.getEMCode() + "." + nudeCode;
}
public StockInfo setTsCode(String tsCode) {
this.tsCode = tsCode;
setStockCode(tsCode);
return this;
}
public String getStockCode() {
return nudeCode + "." + exchange.getStandardCode();
}
public StockInfo setStockCode(String stockCode) {
if (StringUtils.isEmpty(stockCode)) {
return this;
}
Matcher m = EM_CODE_PATTERN.matcher(stockCode);
if (m.matches()) {
String sec = m.group(1);
nudeCode = m.group(2);
if ("1".equals(sec)) {
exchange = StockMarket.SH;
}
else if ("0".equals(sec)) {
exchange = StockMarket.SZ;
}
else if ("BJ".equals(sec)) {
exchange = StockMarket.BJ;
}
else {
exchange = StockMarket.BK;
}
return this;
}
m = STANDARD_CODE_PATTERN.matcher(stockCode);
if (m.matches()) {
String sec = m.group(2);
nudeCode = m.group(1);
switch (sec) {
case "SH":
exchange = StockMarket.SH;
break;
case "SZ":
exchange = StockMarket.SZ;
break;
case "BJ":
default:
exchange = StockMarket.BJ;
break;
}
return this;
}
return this;
}
/**
* 是否主板
* @return
*/
public boolean isMainBoard() {
return exchange != null && (exchange == StockMarket.SH || exchange == StockMarket.SZ);
}
}

View File

@@ -0,0 +1,38 @@
package link.at17.mid.tushare.data.models;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import link.at17.mid.tushare.enums.LimitType;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
@SuppressWarnings("serial")
@Data
@Accessors(chain=true)
@EqualsAndHashCode(callSuper=false)
@TableName("stock_limit")
public class StockLimit extends Model<StockLimit> {
String tsCode;
LocalDateTime tradeDate;
String industry;
String name;
Double close;
Double pctChg;
Double amount; // 成交额
Double limitAmount; // 板上成交金额,跌停无此数据
Double floatMv;
Double totalMv;
Double turnoverRatio;
Double fdAmount;
LocalDateTime firstTime;
LocalDateTime lastTime;
Integer openTimes;
String upStat;
Integer limitTimes;
@TableField("\"limit\"")
LimitType limit;
}

View File

@@ -0,0 +1,73 @@
package link.at17.mid.tushare.data.models;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import link.at17.mid.tushare.enums.StockSpan;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
/**
* 开/收/高/低/成交量/日期/股票间隔类型
* @author Barry
*
*/
@Data
@Accessors(chain=true)
@NoArgsConstructor
@AllArgsConstructor
public class StockValue {
protected StockInfo stockInfo;
protected Double open = 0d;
protected Double close = 0d;
protected Double high = 0d;
protected Double low = 0d;
protected Double vol = 0d;
protected LocalDateTime date;
protected StockSpan stockSpan = StockSpan.Daily;
protected Double pctChg = null;
protected Double pctChange = null;
protected Double change = 0d;
protected Double amount = 0d;
public Double getPctChg() {
if (pctChg == null && pctChange != null) {
return pctChange;
}
return pctChg;
}
public Double getPctChange() {
if (pctChange == null && pctChg != null) {
return pctChg;
}
return pctChange;
}
public String getDateStr(String format) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(format);
return formatter.format(getDate());
}
public String getDateStr() {
return getDateStr("yyyy-MM-dd");
}
protected void setFreq(int freq) {
switch (freq) {
case 1:
stockSpan = StockSpan.Minute;
case 5:
stockSpan = StockSpan.Minute5;
break;
case 15:
stockSpan = StockSpan.Minute15;
case 30:
stockSpan = StockSpan.Minute30;
case 60:
stockSpan = StockSpan.Minute60;
}
}
}

View File

@@ -0,0 +1,80 @@
package link.at17.mid.tushare.data.models;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* StockValue 的扩展,增加换手率、流通股换手率、量比等数据
* @author Barry
*
*/
@Data
@EqualsAndHashCode(callSuper=false)
@NoArgsConstructor
@AllArgsConstructor
public class StockValueEx extends StockValue {
private Double turnoverRate;
private Double turnoverRateF;
private Double volumeRatio;
private Double pe;
private Double peTtm;
private Double pb;
private Double ps;
private Double psTtm;
private Double dvRatio;
private Double dvTtm;
private Double totalShare;
private Double floatShare;
private Double freeShare;
private Double totalMv;
private Double circMv;
/**
* 获取换手率
* @return
*/
public Double getTurnoverRate() {
if (turnoverRate == null) {
if (totalShare == null || vol == null) return null;
turnoverRate = vol * 100 / totalShare;
}
return turnoverRate;
}
/**
* 获取换手率(流通盘)
* @return
*/
public Double getTurnoverRateF() {
if (turnoverRateF == null) {
if (floatShare == null || vol == null) return null;
turnoverRateF = vol * 100 / floatShare;
}
return turnoverRateF;
}
@Setter(AccessLevel.NONE)
@Getter(AccessLevel.NONE)
@JsonAnyGetter
private Map<String, Object> ext = new HashMap<>();
public Object get(String key) {
return ext.get(key);
}
public StockValueEx put(String key, Object object) {
ext.put(key, object);
return this;
}
}

View File

@@ -0,0 +1,26 @@
package link.at17.mid.tushare.data.models;
import java.time.LocalDateTime;
import com.alibaba.fastjson2.annotation.JSONField;
import com.baomidou.mybatisplus.annotation.TableName;
import link.at17.mid.tushare.data.models.interfaces.ITsStockInfo;
import link.at17.mid.tushare.enums.ThsStockMarket;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
@Data
@EqualsAndHashCode(callSuper=false)
@Accessors(chain=true)
@TableName("stock_ths_list")
public class ThsStockInfo implements ITsStockInfo {
@JSONField(name="ts_code")
private String tsCode;
private String name;
private Integer count;
@JSONField(name="list_date")
private LocalDateTime listDate;
@JSONField(name="exchange")
private ThsStockMarket exchange;
}

View File

@@ -0,0 +1,33 @@
package link.at17.mid.tushare.data.models.interfaces;
import java.time.LocalDateTime;
/**
* 实现基本股票/板块指数查询信息的接口
* @see link.at17.mid.tushare.data.models.StockInfo
* @see link.at17.mid.tushare.data.models.ThsStockInfo
*/
public interface ITsStockInfo {
/**
* 获取 Tushare 代码
* @return
*/
String getTsCode();
/**
* 获取名称
* @return
*/
String getName();
/**
* 获取上市(成立)日期
* @return
*/
LocalDateTime getListDate();
/**
* 获取退市(终止)日期
* @return
*/
default LocalDateTime getDelistDate() {
return null;
}
}

View File

@@ -0,0 +1,34 @@
package link.at17.mid.tushare.data.models.interfaces;
import java.time.LocalDateTime;
import java.util.List;
import org.apache.ibatis.annotations.Param;
/**
* 根据 {@code ITsStockInfo} 获取与交易日相关数据的接口,主要用于数据更新轮询时查询给定股票/板块指数在本地数据表记录的交易日情况
* @see ITsStockInfo
*
*/
public interface ITsTradeDate {
/**
* 获取指定股票/板块指数在本地存在的最新一交易日
* @param stockInfo
* @return
*/
LocalDateTime getLatestTradeDate(@Param("stockInfo") ITsStockInfo stockInfo);
/**
* 获取指定股票/板块指数在本地存在的所有交易日
* @param stockInfo
* @return
*/
List<LocalDateTime> getAllTradeDates(@Param("stockInfo") ITsStockInfo stockInfo);
/**
* 交叉对比 stock_calendar 表,获取个股数据缺失的日期<p>
* 需要确保 stock_calendar 表所有数据完整无缺失
* @param stockInfo
* @return
*/
List<LocalDateTime> getAllMissingDates(@Param("stockInfo") ITsStockInfo stockInfo);
}

View File

@@ -0,0 +1,59 @@
package link.at17.mid.tushare.data.typehandler;
import com.alibaba.fastjson2.JSONObject;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.TypeException;
import java.sql.*;
public class PostgreSQLJsonbTypeHandler extends BaseTypeHandler<JSONObject> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, JSONObject parameter, JdbcType jdbcType) throws SQLException {
// 将 JSONObject 转为 String 并存入 jsonb 类型字段
ps.setObject(i, parameter.toJSONString(), Types.OTHER);
}
@Override
public void setParameter(PreparedStatement ps, int i, JSONObject parameter, JdbcType jdbcType) throws SQLException {
if (parameter == null) {
if (jdbcType == null) {
throw new TypeException("JDBC requires that the JdbcType must be specified for all nullable parameters.");
}
try {
ps.setObject(i, "{}", jdbcType.TYPE_CODE);
} catch (SQLException e) {
throw new TypeException("Error setting null for parameter #" + i + " with JdbcType " + jdbcType + " . "
+ "Try setting a different JdbcType for this parameter or a different jdbcTypeForNull configuration property. "
+ "Cause: " + e, e);
}
} else {
try {
setNonNullParameter(ps, i, parameter, jdbcType);
} catch (Exception e) {
throw new TypeException("Error setting non null for parameter #" + i + " with JdbcType " + jdbcType + " . "
+ "Try setting a different JdbcType for this parameter or a different configuration property. "
+ "Cause: " + e, e);
}
}
}
@Override
public JSONObject getNullableResult(ResultSet rs, String columnName) throws SQLException {
// 从 jsonb 字段读取并解析为 JSONObject
String json = rs.getString(columnName);
return json == null ? new JSONObject() : JSONObject.parseObject(json);
}
@Override
public JSONObject getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
String json = rs.getString(columnIndex);
return json == null ? new JSONObject() : JSONObject.parseObject(json);
}
@Override
public JSONObject getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
String json = cs.getString(columnIndex);
return json == null ? new JSONObject() : JSONObject.parseObject(json);
}
}

View File

@@ -0,0 +1,21 @@
package link.at17.mid.tushare.data.util;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import org.apache.commons.codec.binary.Hex;
public class CryptoUtil {
public static String getSHA256Str(String str) {
MessageDigest messageDigest;
String encdeStr = "";
try {
messageDigest = MessageDigest.getInstance("SHA-256");
byte[] hash = messageDigest.digest(str.getBytes(StandardCharsets.UTF_8));
encdeStr = Hex.encodeHexString(hash);
} catch (NoSuchAlgorithmException e) {}
return encdeStr;
}
}

View File

@@ -0,0 +1,595 @@
package link.at17.mid.tushare.data.util;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import org.apache.commons.lang3.ArrayUtils;
import org.springframework.util.Assert;
import link.at17.mid.tushare.data.models.CostDistribution;
import link.at17.mid.tushare.data.models.PivotPoint;
import link.at17.mid.tushare.data.models.StockValue;
import link.at17.mid.tushare.data.models.StockValueEx;
import link.at17.mid.tushare.data.models.CostDistribution.PercentChips;
import link.at17.mid.tushare.enums.PivotPointType;
/**
* 指标库
* @author Barry
*
*/
public final class Indicators {
/**
* 计算移动平均线
* @param values
* @param count
* @return
*/
public static <T extends StockValue> List<Double> getMA(List<T> values, int count) {
return getMA(values, v -> v.getClose(), count);
}
/**
* 计算移动平均线
* @param values
* @param count
* @return
*/
public static <T> List<Double> getMA(List<T> values, Function<T, Double> valueFunc, int count) {
if (Objects.isNull(values) || values.size() < count) {
return null;
}
int size = values.size();
List<Double> result = new ArrayList<>(size);
double[] tmp = new double[size - count + 1];
for (int i = 0; i < size; i++) {
if (i > count - 1) {
// sum 直接等于之前的 sum - 头天 + 当天
double sum =
tmp[i - count]
- valueFunc.apply(values.get(i - count))
+ valueFunc.apply(values.get(i));
tmp[i - count + 1] = sum;
result.add(sum / count);
continue;
}
if (i < count - 1) {
result.add(null);
continue;
}
if (i == count - 1) {
double sum = 0;
for (int j = 0; j < count; j++) {
sum += valueFunc.apply(values.get(i - j));
}
tmp[i - count + 1] = sum;
result.add(sum / count);
}
}
return result;
}
/**
* 计算 CCI 专用 TP 移动平均线
* @param values
* @param count
* @return
*/
public static <T extends StockValue> List<Double> getTPMA(List<T> values, int count) {
return getMA(values, v -> (v.getHigh() + v.getLow() + v.getClose()) / 3, count);
}
/**
* 使用 StockValue 列表获取 CCI
* @param values
* @param count
* @return
*/
public static <T extends StockValue> List<Double> getCCI(List<T> values, int count) {
if (Objects.isNull(values) || values.size() < count) {
return null;
}
int size = values.size();
List<Double> cci = new ArrayList<>(size);
List<Double> sma = getTPMA(values, count);
for (int i = 0; i < size; i++) {
if (i < count - 1) {
cci.add(null);
continue;
}
StockValue sv = values.get(i);
double tp = getTP(sv), md = 0;
for (int j = i - count + 1; j <= i; j++) {
md += Math.abs(getTP(values.get(j)) - sma.get(i));
}
md /= count;
cci.add((tp - sma.get(i)) / md / 0.015);
}
return cci;
}
/** 计算 StockValue 的 高低收 平均值 **/
private static double getTP(StockValue value) {
return (value.getHigh() + value.getLow() + value.getClose()) / 3;
}
/**
* 使用 StockValue 列表获取 CYW
* @param values
* @param count
* @return
*/
public static <T extends StockValue> List<Double> getCYW(List<T> values, int count) {
if (Objects.isNull(values) || values.size() < count) {
return null;
}
int size = values.size();
List<Double> cyw = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
if (i < count - 1) {
cyw.add(null);
continue;
}
StockValue sv = values.get(i);
double sum = 0;
for (int j = i - count + 1; j <= i; j++) {
sum += sv.getHigh() > sv.getLow() ? (
((sv.getClose() - sv.getLow()) / (sv.getHigh() - sv.getLow()) +
(sv.getClose() - sv.getHigh()) / (sv.getHigh() - sv.getLow()))
* sv.getVol() / 10000
) : 0;
}
cyw.add(sum);
}
return cyw;
}
/**
* 收盘价计算 EXPMA
* @param values
* @param period
* @param multiplier
* @return
*/
public static <T extends StockValue> List<Double> getEXPMA(List<T> values, int period) {
return getEXPMA(values, v -> v.getClose(), period);
}
/**
* 计算 EXPMA(EMA),提供非主流算法,只需提供好对应获取计算值的方法即可
* @param <T>
* @param values 值列表
* @param valueFunc 获取值的 Function
* @param period 周期
* @return
* 例:获取常规版 EXPMA
* <br><code>getEXPMA(values, v -> v.getClose(), period) where values is List&ltStockValue>
*/
public static <T> List<Double> getEXPMA(List<T> values, Function<T, Double> valueFunc, int period) {
if (Objects.isNull(values)) {
return null;
}
int size = values.size();
List<Double> ema = new ArrayList<>(size);
Double k = 2 / (period + 1.0);
ema.add(valueFunc.apply(values.get(0)));
for (int i = 1; i < size; i++) {
ema.add(valueFunc.apply(values.get(i)) * k + ema.get(i - 1) * (1 - k));
}
return ema;
}
/**
* 收盘价计算 SMA
* @param values
* @param period
* @param multiplier
* @return
*/
public static <T extends StockValue> List<Double> getSMA(List<T> values, int period, int m) {
return getSMA(values, v -> v.getClose(), period, m);
}
/**
* 简单移动平均线 SMA
* @param <T>
* @param values
* @param valueFunc
* @param period 周期
* @param m 平滑系数
* @return
*/
public static <T> List<Double> getSMA(List<T> values, Function<T, Double> valueFunc, int period, int m) {
Assert.isTrue(period > m, "周期必须大于平滑系数!");
if (Objects.isNull(values)) {
return null;
}
int size = values.size();
List<Double> sma = new ArrayList<>(size);
Double k = m * 1.0 / period;
sma.add(valueFunc.apply(values.get(0)));
for (int i = 1; i < size; i++) {
sma.add(valueFunc.apply(values.get(i)) * k + sma.get(i - 1) * (1 - k));
}
return sma;
}
/**
* 针对 StockValue 及其子类的动态异动平均线 DMA
* @param <T>
* @param values StockValue 或其子类的列表
* @param k 权重系数
* @return
*/
public static <T extends StockValue> List<Double> getDMA(List<T> values, double k) {
return getDMA(values, v -> v.getClose(), k);
}
/**
* 动态移动平均线 DMA
* @param <T>
* @param values
* @param valueFunc
* @param k 权重系数
* @return
*/
public static <T> List<Double> getDMA(List<T> values, Function<T, Double> valueFunc, double k) {
Assert.isTrue(k >= 0 && k <= 1, "权重系数须介于 [0, 1] 之间!");
if (Objects.isNull(values) || values.size() == 0) {
return null;
}
int size = values.size();
List<Double> ema = new ArrayList<>(size);
ema.add(valueFunc.apply(values.get(0)));
for (int i = 1; i < size; i++) {
ema.add(valueFunc.apply(values.get(i)) * k + ema.get(i - 1) * (1 - k));
}
return ema;
}
/**
* 通达信 MCST 指标拟合版
* @param <T>
* @param values
* @return
*/
public static <T extends StockValueEx> List<Double> getMCST(List<T> values) {
if (Objects.isNull(values) || values.size() == 0) {
return null;
}
int size = values.size();
List<Double> mcst = new ArrayList<>(size);
T first = values.get(0);
mcst.add((first.getHigh() + first.getLow() + first.getClose())/3);
for (int i = 1; i < size; i++) {
T curr = values.get(i);
double k = curr.getVol() / curr.getFloatShare() / 100;
// 成交额单位是千元成交量单位是手1000/100 = 10所以 * 10
mcst.add(curr.getAmount() * 10 / curr.getVol() * k + mcst.get(i - 1) * (1 - k));
}
return mcst;
}
/**
* 成交量加权平均线 (VWMA),默认采用 func = CLOSE * VOL 进行计算
* @param <T>
* @param values
* @param period
* @return
* @see Indicators#getVWMA(List, Function, int)
*/
public static <T extends StockValue> List<Double> getVWMA(List<T> values, int period) {
return getVWMA(values, v -> v.getVol() * v.getClose(), period);
}
/**
* 成交量加权平均线 (VWMA),可指定 func 进行计算
* @param <T>
* @param values
* @param func
* @param period
* @return
*/
public static <T extends StockValue> List<Double> getVWMA(List<T> values, Function<T, Double> func, int period) {
if (Objects.isNull(values)) {
return null;
}
int size = values.size();
List<Double> vwma = new ArrayList<>(size);
List<Double> closeMulVol = values.stream().map(func).toList();
List<Double> vol = values.stream().map(v -> v.getVol()).toList();
for (int i = 0; i < size; i++) {
vwma.add(closeMulVol.subList(Math.max(0, i - period + 1), i + 1).stream().mapToDouble(v -> v).sum()/
vol.subList(Math.max(0, i - period + 1), i + 1).stream().mapToDouble(v -> v).sum());
}
return vwma;
}
/**
* 成交量价格确认指标 VPCI
* @param <T>
* @param values
* @param m
* @param n
* @return
*/
public static <T extends StockValue> List<Double> getVPCI(List<T> values, int n, int m) {
Assert.isTrue(m < n, "m 值必须小于 n 值!");
if (Objects.isNull(values) || values.size() == 0) {
return null;
}
int size = values.size();
/**
VWMA:=SUM(C*V,N)/SUM(V,N);
VPC:=VWMA - MA(C,N);
VPR:=(SUM(C*V,M)/SUM(V,M))/MA(C,M);
VM:=MA(V,M)/MA(V,N);
VPCI:VPC*VPR*VM,COLORCYAN;
*/
List<Double> vwma = getVWMA(values, n);
List<Double> vpr = getVWMA(values, m);
List<Double> vpcma = getMA(values, n);
List<Double> vprma = getMA(values, m);
List<Double> vmFast = getMA(values, m);
List<Double> vmSlow = getMA(values, n);
List<Double> vpci = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
Double vwmai = vwma.get(i), vpcmai = vpcma.get(i),
vprmai = vprma.get(i), vmFastI = vmFast.get(i),
vmSlowI = vmSlow.get(i);
Double vpcI = null, vprI = null, vmI = null;
if (vwmai != null && vpcmai != null) {
vpcI = vwmai - vpcmai;
if (vprmai != null) {
vprI = vpr.get(i) / vprmai;
if (vmFastI != null && vmSlowI != null) {
vmI = vmFastI / vmSlowI;
if (vmI != null) {
vpci.add(vpcI * vprI * vmI * 100);
continue;
}
}
}
}
vpci.add(null);
}
return vpci;
}
/**
* TRIX 指标
* @param values
* @param n
* @return
*/
public static <T extends StockValue> List<Double> getTRIX(List<T> values, int n) {
List<Double> em3 = getEXPMA(getEXPMA(getEXPMA(values, v -> v.getClose(), n), v -> v, n), v -> v, n);
if (Objects.isNull(em3)) {
return null;
}
int size = values.size();
List<Double> trix = new ArrayList<>(size);
trix.add(0d);
for (int i = 1; i < em3.size(); i++) {
trix.add((em3.get(i) - em3.get(i - 1)) / em3.get(i - 1) * 100);
}
return trix;
}
/**
* TRIX 指标
* @param values
* @param func
* @param m
* @return
*/
public static <T extends StockValue> List<Double> getTRIX(List<T> values, Function<T, Double> func, int n) {
List<Double> em3 = getEXPMA(getEXPMA(getEXPMA(values, func, n), v -> v, n), v -> v, n);
if (Objects.isNull(em3)) {
return null;
}
int size = values.size();
List<Double> trix = new ArrayList<>(size);
trix.add(0d);
for (int i = 1; i < em3.size(); i++) {
trix.add((em3.get(i) - em3.get(i - 1)) / em3.get(i - 1) * 100);
}
return trix;
}
/**
* LON 指标,默认快线 10慢线 20
* @param values
* @return
*/
public static <T extends StockValue> List<Double> getLON(List<T> values) {
return getLON(values, 10, 20);
}
/**
* LON 指标
* @param values
* @return
*/
public static <T extends StockValue> List<Double> getLON(List<T> values, int fast, int slow) {
Assert.isTrue(fast < slow, "fast 周期必须小于 slow");
if (Objects.isNull(values)) {
return null;
}
int size = values.size();
List<Double> lon = new ArrayList<>(size);
lon.add(0d);
List<Double> rcList = new ArrayList<>();
List<Double> longSum = new ArrayList<>();
longSum.add(0d);
for (int i = 1; i < values.size(); i++) {
StockValue prev = values.get(i - 1), now = values.get(i);
Double vid = (prev.getVol() + now.getVol()) / ((Math.max(prev.getHigh(), now.getHigh()) - Math.min(prev.getLow(), now.getLow())) * 100);
rcList.add((now.getClose() - prev.getClose()) * vid);
longSum.add(rcList.stream().reduce(Double::sum).get());
Double dif = getSMA(longSum, v -> v, fast, 1).get(longSum.size() - 1);
Double dea = getSMA(longSum, v -> v, slow, 1).get(longSum.size() - 1);
lon.add(dif - dea);
}
return lon;
}
/**
* 非主流 LON 指标
* @param values
* @return
*/
public static <T extends StockValue> List<Double> getFZLLON(List<T> values, int fast, int slow) {
Assert.isTrue(fast < slow, "fast 周期必须小于 slow");
if (Objects.isNull(values)) {
return null;
}
int size = values.size();
List<Double> lon = new ArrayList<>(size);
lon.add(0d);
List<Double> rcList = new ArrayList<>();
List<Double> longSum = new ArrayList<>();
longSum.add(0d);
for (int i = 1; i < values.size(); i++) {
StockValue prev = values.get(i - 1), now = values.get(i);
PivotPoint prevPivot = new PivotPoint(prev, PivotPointType.DeMarks);
PivotPoint nowPivot = new PivotPoint(now, PivotPointType.DeMarks);
Double vid = (prev.getVol() + now.getVol()) / ((Math.max(prevPivot.getResistance1(), now.getHigh()) - Math.min(prevPivot.getSupport1(), now.getLow())) * 100);
rcList.add((nowPivot.getPivotPoint() - prevPivot.getPivotPoint()) * vid);
longSum.add(rcList.stream().reduce(Double::sum).get());
Double dif = getSMA(longSum, v -> v, fast, 1).get(longSum.size() - 1);
Double dea = getSMA(longSum, v -> v, slow, 1).get(longSum.size() - 1);
lon.add(dif - dea);
}
return lon;
}
/**
* 根据给定的股价列表,计算指定 index 处的成本分布
* @param values 股价列表
* @param index 指定的 index
* @param factor 系数,指成本分布的最大数量,一般取 150
* @param range 取多少个历史交易日进行计算(包括当日),一般取 120
* @return
*/
public static CostDistribution getCostDistribution(List<StockValueEx> values, int index, int factor, int range) {
CostDistribution cost = new CostDistribution();
StockValueEx current = values.get(index);
List<StockValueEx> subValues = values.subList(Math.max(0, index - range + 1), Math.max(1, index + 1));
Double historyMax = subValues.stream().mapToDouble(StockValue::getHigh).max().getAsDouble();
Double historyMin = subValues.stream().mapToDouble(StockValue::getLow).min().getAsDouble();
double normFactor = Math.max(0.01, (historyMax - historyMin) / (factor - 1));
double[] distributionY = new double[factor];
for (int i = 0; i < factor; i++) {
distributionY[i] = historyMin + normFactor * i;
}
double[] distributionX = new double[factor];
for (int i = 0; i < subValues.size(); i++) {
StockValueEx stockValueEx = subValues.get(i);
double open = stockValueEx.getOpen();
double close = stockValueEx.getClose();
double high = stockValueEx.getHigh();
double low = stockValueEx.getLow();
double ohlcAvg = (open + close + high + low) / 4;
double turnoverRate = Math.min(1, stockValueEx.getTurnoverRate() / 100);
int currDistUpper = (int) Math.floor((high - historyMin) / normFactor);
int currDistLower = (int) Math.ceil((low - historyMin) / normFactor);
double v0 = high == low ? (factor - 1) : (2 / (high - low));
int currDistIndex = (int) Math.floor((ohlcAvg - historyMin) / normFactor);
for (int x = 0; x < factor; x++) {
distributionX[x] *= 1 - turnoverRate;
}
if (high == low) {
// 一字板
distributionX[currDistIndex] += v0 * turnoverRate / 2;
}
else {
for (int I = currDistLower; I <= currDistUpper; I++) {
double b = historyMin + normFactor * I;
if (b <= ohlcAvg) {
if (Math.abs(ohlcAvg - low) < 1e-8) {
distributionX[I] += v0 * turnoverRate;
}
else {
distributionX[I] += (b - low) / (ohlcAvg - low) * v0 * turnoverRate;
}
}
else {
if (Math.abs(high - ohlcAvg) < 1e-8) {
distributionX[I] += v0 * turnoverRate;
}
else {
distributionX[I] += (high - b) / (high - ohlcAvg) * v0 * turnoverRate;
}
}
}
}
}
double w = current.getClose();
double distributionXSum = 0;
for (int i = 0; i < factor; i++) {
distributionXSum += distributionX[i];
}
Function<Double, Double> R = (t) -> {
Double e = 0d, i = 0d;
for (int n = 0; n < factor; n++) {
if (t < i + distributionX[n]) {
e = historyMin + n * normFactor;
break;
}
i += distributionX[n];
}
return e;
};
final double finalSum = distributionXSum;
Function<Double, PercentChips> computePercentChips = (t1) -> {
double e0 = (1 - t1) / 2,
e1 = (1 + t1) / 2,
upper = R.apply(finalSum * e1),
lower = R.apply(finalSum * e0);
return new PercentChips()
.setPriceLower(lower)
.setPriceUpper(upper)
.setConcentration(Math.abs(lower + upper) < 1e-8 ? 0 : ((upper - lower)/(upper + lower)));
};
Function<Double, Double> getBenifitPart = (t) -> {
double e = 0;
for (int i = 0; i < factor; i++) {
if (historyMin + i * normFactor <= t) {
e += distributionX[i];
}
}
return 0 == finalSum ? 0 : (e / finalSum);
};
cost.setX(ArrayUtils.toObject(distributionX)).setY(ArrayUtils.toObject(distributionY))
.setDate(current.getDate())
.setBenifitPart(getBenifitPart.apply(w))
.setAvgCost(R.apply(.5 * distributionXSum))
.setPrecentChips90(computePercentChips.apply(.9))
.setPrecentChips70(computePercentChips.apply(.7));
return cost;
}
/**
* 根据给定的股价列表,计算指定范围的成本分布
* @param values 股价列表
* @param from 起始索引,包括
* @param to 结束索引,不包括
* @param factor 系数,指成本分布的最大数量,一般取 150
* @param range 取多少个历史交易日进行计算(包括当日),一般取 120
* @return
*/
public static List<CostDistribution> getCostDistributions(List<StockValueEx> values, int from, int to, int factor, int range) {
List<CostDistribution> cds = new ArrayList<>();
for (int i = from; i < to; i++) {
cds.add(getCostDistribution(values, i, factor, range));
}
return cds;
}
}

View File

@@ -0,0 +1,495 @@
package link.at17.mid.tushare.data.util;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import link.at17.mid.tushare.data.models.IndexValue;
import link.at17.mid.tushare.data.models.StockValue;
import link.at17.mid.tushare.enums.CrossType;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.TreeSet;
import java.util.stream.Collectors;
public class PointsUtil {
/**
* 获取 StockValue 极大值所在的位置索引通用方法默认迭代次数1
* @param list
* @return
*/
public static List<Integer> getStockValueHighestIndex(List<StockValue> list) {
return getStockValueHighestIndex(list, 1);
}
/**
* 获取 StockValue 极小值所在的位置索引通用方法默认迭代次数1
* @param list
* @return
*/
public static List<Integer> getStockLowestIndex(List<StockValue> list) {
return getValueLowestIndex(list, 1);
}
/**
* 获取 StockValue 极大值所在的位置索引通用方法
* @param list
* @param generation 迭代次数
* @return
*/
public static List<Integer> getStockValueHighestIndex(List<StockValue> list, int generation) {
if (CollectionUtils.isEmpty(list) || list.size() < 2) {
return new ArrayList<Integer>();
}
List<Integer> indexList = new ArrayList<>();
List<StockValue> subList = new ArrayList<>();
for (int i = 0; i < list.size(); i++) {
StockValue val = list.get(i);
if (i == 0) {
if (val.getHigh().compareTo(list.get(1).getHigh()) == 1) {
indexList.add(i);
if (generation > 0) {
subList.add(val);
}
}
continue;
}
if (i != list.size() - 1 &&
val.getHigh().compareTo(list.get(i - 1).getHigh()) == 1 &&
val.getHigh().compareTo(list.get(i + 1).getHigh()) == 1) {
indexList.add(i);
if (generation > 0) {
subList.add(val);
}
continue;
}
if (i == list.size() - 1 &&
val.getHigh().compareTo(list.get(i - 1).getHigh()) == 1) {
indexList.add(i);
if (generation > 0) {
subList.add(val);
}
}
}
if (generation-- > 0) {
List<Integer> subGeneration = getStockValueHighestIndex(subList, generation);
List<Integer> subResult = new ArrayList<Integer>();
for (Integer subLowest : subGeneration) {
subResult.add(indexList.get(subLowest));
}
indexList = subResult;
}
return indexList;
}
/**
* 获取 StockValue 极小值所在的位置索引通用方法
* @param list
* @param generation 迭代次数
* @param compareTo -1 或 1
* @return
*/
public static List<Integer> getValueLowestIndex(List<StockValue> list, int generation) {
if (CollectionUtils.isEmpty(list) || list.size() < 2) {
return new ArrayList<Integer>();
}
List<Integer> indexList = new ArrayList<>();
List<StockValue> subList = new ArrayList<>();
for (int i = 0; i < list.size(); i++) {
StockValue val = list.get(i);
if (i == 0) {
if (val.getLow().compareTo(list.get(1).getLow()) == -1) {
indexList.add(i);
if (generation > 0) {
subList.add(val);
}
}
continue;
}
if (i != list.size() - 1 &&
val.getLow().compareTo(list.get(i - 1).getLow()) == -1 &&
val.getLow().compareTo(list.get(i + 1).getLow()) == -1) {
indexList.add(i);
if (generation > 0) {
subList.add(val);
}
continue;
}
if (i == list.size() - 1 &&
val.getLow().compareTo(list.get(i - 1).getLow()) == -1) {
indexList.add(i);
if (generation > 0) {
subList.add(val);
}
}
}
if (generation-- > 0) {
List<Integer> subGeneration = getValueLowestIndex(subList, generation);
List<Integer> subResult = new ArrayList<Integer>();
for (Integer subLowest : subGeneration) {
subResult.add(indexList.get(subLowest));
}
indexList = subResult;
}
return indexList;
}
/**
* 获取极值所在的位置索引通用方法
* @param list
* @param generation 迭代次数
* @param compareTo -1 或 1
* @return
*/
public static List<Integer> getExtremeIndex(List<Double> list, int generation, int compareTo) {
if (CollectionUtils.isEmpty(list) || list.size() < 2) {
return new ArrayList<Integer>();
}
List<Integer> indexList = new ArrayList<>();
List<Double> subList = new ArrayList<>();
for (int i = 0, size = list.size(); i < size; i++) {
Double val = list.get(i);
if (Objects.isNull(val)) {
continue;
}
if (i == 0) {
if(val.compareTo(list.get(1)) == compareTo) {
indexList.add(i);
if (generation > 0) {
subList.add(val);
}
}
continue;
}
if (i != size - 1 &&
Objects.nonNull(list.get(i - 1)) &&
val.compareTo(list.get(i - 1)) == compareTo &&
val.compareTo(list.get(i + 1)) == compareTo) {
indexList.add(i);
if (generation > 0) {
subList.add(val);
}
continue;
}
if (i == size - 1 &&
Objects.nonNull(list.get(i - 1)) &&
val.compareTo(list.get(i - 1)) == compareTo) {
indexList.add(i);
if (generation > 0) {
subList.add(val);
}
}
}
if (generation-- > 0) {
List<Integer> subGeneration = getExtremeIndex(subList, generation, compareTo);
List<Integer> subResult = new ArrayList<Integer>();
for (Integer subLowest : subGeneration) {
subResult.add(indexList.get(subLowest));
}
indexList = subResult;
}
return indexList;
}
/**
* 获取极小值所在的位置索引通用方法默认迭代次数1
* @param list
* @return
*/
public static List<Integer> getLowestIndex(List<Double> list) {
return getLowestIndex(list, 1);
}
/**
* 获取极小值所在的位置索引通用方法
* @param list
* @param generation 迭代次数
* @return
*/
public static List<Integer> getLowestIndex(List<Double> list, int generation) {
return getExtremeIndex(list, generation, -1);
}
/**
* 获取极大值所在的位置索引通用方法默认迭代次数1
* @param list
* @return
*/
public static List<Integer> getHighestIndex(List<Double> list) {
return getHighestIndex(list, 1);
}
/**
* 获取极大值所在的位置索引通用方法
* @param list
* @param generation 迭代次数
* @return
*/
public static List<Integer> getHighestIndex(List<Double> list, int generation) {
return getExtremeIndex(list, generation, 1);
}
/**
* 获取金叉和死叉及其所在索引
* @param fast 快线
* @param slow 慢线
* @return
*/
public static List<IndexValue<Integer, CrossType>> getCross(List<Double> fast, List<Double> slow) {
Assert.isTrue(fast != null && slow != null && fast.size() == slow.size(), "快线和慢线不能为 null 且其长度必须相等!");
TreeSet<IndexValue<Integer, CrossType>> set = new TreeSet<>();
for (int i = 1; i < fast.size(); i++) {
Double slowPrev = slow.get(i - 1), slowNow = slow.get(i);
Double fastPrev = fast.get(i - 1), fastNow = fast.get(i);
if (slowPrev == null || fastPrev == null || slowNow == null || fastNow == null) {
continue;
}
if ((fastPrev < slowPrev && fastNow >= slowNow) || (fastPrev <= slowPrev && fastNow > slowNow)) {
set.add(new IndexValue<Integer, CrossType>(i, CrossType.Bullish));
}
else if ((fastPrev > slowPrev && fastNow <= slowNow) || (fastPrev >= slowPrev && fastNow < slowNow)) {
set.add(new IndexValue<Integer, CrossType>(i, CrossType.Bearish));
}
}
return set.stream().collect(Collectors.toList());
}
/**
* 获取多头排列的 Index<br>
* 一般用于寻找买点或上升趋势。寻找买点时建议 satisfiedCount 严格等于提供的 lists 的个数。
* @param risePeriod 要连续多少天出现多头才认为是多头,最小 2默认 2
* @param satisfiedCount lists 中有多少个 list 满足上涨和多头排列就认为市场是多头,最小 2默认lists 经过筛选后的 size
* @param lists 要对比多头的 list按照快慢排列其中任意 list 不能为空,且提供的 lists 的个数至少为 2 组
* @return
*/
@SafeVarargs
public static List<Integer> getBullishArrange(Integer risePeriod, Integer satisfiedCount, List<Double>... lists){
Assert.isTrue(lists != null && lists.length > 1, "lists 不能为空且至少要有 2 组 List<Double> 存在!");
if (risePeriod == null || risePeriod < 2) {
risePeriod = 2;
}
List<List<Double>> listList = new ArrayList<>(Arrays.asList(lists));
Integer length = null;
for (int i = 0; i < listList.size(); i++) {
List<Double> list = listList.get(i);
if (list == null) {
listList.remove(i--);
continue;
}
if (length == null) {
length = list.size();
}
else {
Assert.isTrue(length == list.size(), "提供的 lists 内的各 List 长度必须相等!");
}
}
Assert.isTrue(lists.length == listList.size(), "提供的 lists 不允许存在空 list");
if (satisfiedCount == null || satisfiedCount < 2 || satisfiedCount > listList.size()) {
satisfiedCount = listList.size();
}
TreeSet<Integer> set = new TreeSet<>();
if (listList.size() == 0) {
return set.stream().collect(Collectors.toList());
}
nextPeriod:
for (int i = risePeriod - 1; i < length; i++) {
final int index = i;
// listList 是最终的去除了全 null lists 的 lists
// 从中对每个 lists 取对应的 index 索引的值
// 得到的就是 index 日的所有线的值
List<Double> values = listList.stream().map(list -> list.get(index)).collect(Collectors.toList());
// 这里判断 null判断 list 的个数与 count 有关
if (values.subList(0, satisfiedCount).stream().anyMatch(d -> d == null)) {
continue;
}
// 当天及前 period 天的多线信息,注意最好往前推,或者往后推时要取最后那一个点,不然属于未来函数了
// 这里选择的是往前推 risePeriod days再从 index - risePeriod 到 index 日,依次放入 nearValuesList
List<List<Double>> nearValuesList = new ArrayList<>();
for (int j = risePeriod - 1; j > 0; j--) {
final int backwardIndex = j;
nearValuesList.add(listList.stream().map(list -> list.get(index - backwardIndex)).collect(Collectors.toList()));
}
nearValuesList.add(values);
/** 到这里,假设一开始给定的 List 是 MA5, MA10, MA20则 nearValuesList 的结构是这样的 **/
// List<List<Double>> nearValuesList
// Index of nearValuesList : 0 , 1 , ..., period - 1
// Structure of ... MA5[0], MA5[1], ..., MA5[period - 1]
// MA10[0], MA10[1], ..., MA10[period - 1]
// MA20[0], MA20[1], ..., MA20[period - 1]
// 统计有上涨趋势的线
List<Integer> upListIndexes = new ArrayList<>();
nextUpJudge:
for (int j = 0; j < values.size(); j++) {
for (int k = 0; k < risePeriod - 1; k++) {
List<Double> prevValues = nearValuesList.get(k);
if (prevValues.get(j) == null) {
continue;
}
List<Double> currValues = nearValuesList.get(k + 1);
if (currValues.get(j) == null) {
continue;
}
// 只要有一次大于,那这根线就不算上涨趋势了,去判断下一根线
if (prevValues.get(j) > currValues.get(j)) {
continue nextUpJudge;
}
}
// 一根线跑完一个 period 都没有触发 continue 那这根线认为是上涨趋势
// 把这根线的索引加到上涨趋势索引列表里
upListIndexes.add(j);
}
if (upListIndexes.size() < satisfiedCount) {
// 当前 risePeriod 内,满足上涨趋势的线的数量不够,直接去下一个 period
continue nextPeriod;
}
// 统计多头排列的线
// 假设现在有 MA5MA10MA20MA60 四根线
// count == 4 时MA5 ≥ MA10 ≥ MA20 ≥ MA60 才认为是多头
// count == 3 时MA5 ≥ MA10 ≥ MA20 或 MA5 ≥ MA10 ≥ MA60 或 MA5 ≥ MA20 ≥ MA60 或 MA10 ≥ MA20 ≥ MA60 就可认为是多头
// 以此类推
int bullCount = 0;
int minBullCount = (satisfiedCount - 1) * satisfiedCount / 2;
for (int j = 0; j < values.size() - 1; j++) {
slowLineJudge:
for (int k = j + 1; k < values.size(); k++) {
for (int m = 0; m < risePeriod; m++) {
List<Double> allLineOneDayValue = nearValuesList.get(m);
Double fastLineValue = allLineOneDayValue.get(j);
Double slowLineValue = allLineOneDayValue.get(k);
if (fastLineValue == null || slowLineValue == null) {
continue;
}
if (fastLineValue < slowLineValue) {
// 对比下一条被对比线
continue slowLineJudge;
}
}
// 这条被对比线对比完了,没出现跳到下一个 slowLineJudge 的情况,可认为这两条线满足多头
bullCount++;
}
}
if (bullCount < minBullCount) {
// 当前 period 内,满足多头排列的线的数量不够,直接去下一个 period
continue nextPeriod;
}
// 到这里都满足,加入
set.add(i);
}
return set.stream().collect(Collectors.toList());
}
/**
* 获取多头排列的 Index
* @param lists
* @return
*/
@SafeVarargs
public static List<Integer> getBullishArrange(Integer period, List<Double>... lists){
if (period == null || period < 1) {
period = 1;
}
List<List<Double>> listList = new ArrayList<>(Arrays.asList(lists));
Integer length = null;
for (int i = 0; i < listList.size(); i++) {
List<Double> list = listList.get(i);
if (list == null) {
listList.remove(i--);
continue;
}
if (length == null) {
length = list.size();
}
else {
Assert.isTrue(length == list.size(), "提供的 List 长度必须相等!");
}
}
TreeSet<Integer> set = new TreeSet<>();
if (listList.size() == 0) {
return set.stream().collect(Collectors.toList());
}
valuesLabel:
for (int i = period; i < length; i++) {
final int index = i;
List<Double> values = listList.stream().map(list -> list.get(index)).collect(Collectors.toList());
if (values.stream().anyMatch(d -> d == null)) {
continue;
}
// 当天及前 period 天的多线信息,注意必须往前推,不然属于未来函数了
List<List<Double>> prevValuesList = new ArrayList<>();
for (int j = period; j > 0; j--) {
final int jIndex = j;
prevValuesList.add(listList.stream().map(list -> list.get(index - jIndex)).collect(Collectors.toList()));
}
prevValuesList.add(values);
// 上涨趋势
for (int j = 0; j < values.size(); j++) {
for (int k = 0; k < period - 1; k++) {
List<Double> prevValues = prevValuesList.get(k);
if (prevValues.get(j) == null) {
continue;
}
List<Double> currValues = prevValuesList.get(k + 1);
if (currValues.get(j) == null) {
continue;
}
if (prevValues.get(j) > currValues.get(j)) {
continue valuesLabel;
}
}
}
// 多头排列
for (int j = 1; j < values.size(); j++) {
if (j != values.size() - 1) {
if (!((values.get(j - 1) > values.get(j) && values.get(j) >= values.get(j + 1)) || (values.get(j - 1) >= values.get(j) && values.get(j) > values.get(j + 1)))) {
continue valuesLabel;
}
}
else if (values.get(j - 1) < values.get(j)) {
continue valuesLabel;
}
}
set.add(i);
}
return set.stream().collect(Collectors.toList());
}
/**
* 判断 i 是否位于指定的金叉(死叉)~死叉(金叉)区间内
* @param i
* @param crossIndexList
* @param begin
* @return
*/
public static Boolean inCrossAreaBetween(int i, List<IndexValue<Integer, CrossType>> crossIndexList, CrossType beginCrossType) {
for (int k = 0; k < crossIndexList.size(); k++) {
IndexValue<Integer, CrossType> cross = crossIndexList.get(k), crossNext = k != crossIndexList.size() - 1 ? crossIndexList.get(k + 1) : null;
if (cross.getValue() == beginCrossType && cross.getIndex() >= i && (crossNext != null && crossNext.getIndex() <= k || crossNext == null)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,105 @@
package link.at17.mid.tushare.data.util;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
import link.at17.mid.tushare.data.models.StockInfo;
import link.at17.mid.tushare.data.models.StockValue;
public class PriceUtil {
public static final BigDecimal DOT_05 = new BigDecimal("0.05");
public static final BigDecimal DOT_1 = new BigDecimal("0.1");
public static final BigDecimal DOT_2 = new BigDecimal("0.2");
public static final BigDecimal TWO = new BigDecimal("2");
/**
* 根据股票信息获取涨跌幅限制<br/>
* 股票以 30 或 68 为开头,或同花顺概念为注册制次新股的,返回 0.2<br/>
* 不为以上情况,且为 ST 股票的,返回 0.05<br/>
* 不为以上情况的,返回 0.1
* @param stockInfo
* @return
*/
public static BigDecimal getLimitByStockInfo(StockInfo stockInfo) {
if (stockInfo.getNudeCode().startsWith("30")
|| stockInfo.getNudeCode().startsWith("68")
|| (stockInfo.getThsBelongings() != null && stockInfo.getThsBelongings().contains("注册制"))) {
return new BigDecimal("0.2");
}
else if (stockInfo.getName().contains("ST")) {
return new BigDecimal("0.05");
}
return new BigDecimal("0.1");
}
/**
* 根据前收、现收和股票信息,判断股票是否涨停
* @param preClose
* @param nowClose
* @param stockInfo
* @return
*/
public static boolean isLimit(double preClose, double nowClose, StockInfo stockInfo) {
return isLimit(preClose, nowClose, stockInfo, false);
}
/**
* 根据前收、现收和股票信息,判断股票是否涨/跌停
* @param preClose
* @param nowClose
* @param stockInfo
* @param negative 是否跌停
* @return
*/
public static boolean isLimit(double preClose, double nowClose, StockInfo stockInfo, boolean negative) {
BigDecimal preCloseDec = new BigDecimal(preClose).setScale(2, RoundingMode.HALF_UP);
BigDecimal nowCloseDec = new BigDecimal(nowClose).setScale(2, RoundingMode.HALF_UP);
BigDecimal limit = getLimitByStockInfo(stockInfo);
if (negative) {
limit = limit.negate();
}
BigDecimal limitUp = preCloseDec.multiply(
limit.add(BigDecimal.ONE)).setScale(2, RoundingMode.HALF_UP);
boolean isLimitUp = limitUp.compareTo(nowCloseDec) == 0;
if (limit.abs().compareTo(DOT_05) == 0) {
limitUp = preCloseDec.multiply(
limit.multiply(TWO).add(BigDecimal.ONE)).setScale(2, RoundingMode.HALF_UP);
isLimitUp = isLimitUp || limitUp.compareTo(nowCloseDec) == 0;
}
return isLimitUp;
}
/**
* 判断是否达到涨跌幅限制
* @param preClose 前收
* @param nowClose 现收
* @param upDownLimitPrecent 涨跌幅限制
* @return
*/
public static boolean isLimit(double preClose, double nowClose, BigDecimal upDownLimitPrecent) {
BigDecimal preCloseDec = new BigDecimal(preClose).setScale(2, RoundingMode.HALF_UP);
BigDecimal nowCloseDec = new BigDecimal(nowClose).setScale(2, RoundingMode.HALF_UP);
BigDecimal limitUp = preCloseDec.multiply(
upDownLimitPrecent.add(BigDecimal.ONE)).setScale(2, RoundingMode.HALF_UP);
return limitUp.compareTo(nowCloseDec) == 0;
}
/**
* 根据股价列表获取所有涨/跌停的 Indexes
* @param stockValues 股价列表
* @param upDownLimitPrecent 涨跌幅
* @return
*/
public static List<Integer> getLimitIndexes(List<? extends StockValue> stockValues, BigDecimal upDownLimitPrecent) {
List<Integer> list = new ArrayList<>();
for (int i = 0; i < stockValues.size() - 1; i++) {
if (isLimit(stockValues.get(i).getClose(), stockValues.get(i + 1).getClose(), upDownLimitPrecent)) {
list.add(i + 1);
}
}
return list;
}
}

View File

@@ -0,0 +1,41 @@
package link.at17.mid.tushare.data.util;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
public class StockCodeUtil {
private static final Pattern EM_CODE_PATTERN = Pattern.compile("^(\\d{1,2})\\.(.*?)$");
/**
* 转为标准形式的代码,最终仅支持 6 位数字 + .SZ 或 .SH 的形式,否则会抛出异常
* @param code
* @return
* @throws UnsupportedOperationException
*/
public static String toStandardCode(String code) throws UnsupportedOperationException {
if (StringUtils.isEmpty(code)) {
return code;
}
Matcher m = EM_CODE_PATTERN.matcher(code);
if (m.matches()) {
String sec = m.group(1);
String nudeCode = m.group(2);
if ("1".equals(sec)) {
return nudeCode + ".SH";
}
else if ("0".equals(sec)) {
return nudeCode + ".SZ";
}
else {
}
return code;
}
return null;
}
}

View File

@@ -0,0 +1,41 @@
package link.at17.mid.tushare.dto;
import java.io.Serializable;
import java.util.List;
import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 后台查询结果,适配 layui
* @author Doghole
**/
@Data
@Accessors(chain = true)
public class LayPageResp<T> implements Serializable {
/**
*
*/
private static final long serialVersionUID = 3706125181722544872L;
//状态码
private int code;
//提示消息
private String msg;
//总条数
private long count;
//表格数据
private List<T> data;
public LayPageResp() {}
public LayPageResp(IPage<T> page) {
this.data = page.getRecords();
this.count = page.getTotal();
}
}

View File

@@ -0,0 +1,222 @@
package link.at17.mid.tushare.dto;
import java.io.Serializable;
import org.springframework.http.HttpStatus;
import link.at17.mid.tushare.web.exception.RException;
import lombok.AccessLevel;
import lombok.Data;
import lombok.Getter;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
/**
* application/json 返回
* <p>
* 此处返回若非提前指定,虽然 resultCode 可以设置对应 HttpStatus 但 Response 的 HttpStatus 依然会是
* 200.<br>
* 需要同时指定 HttpStatus 的,见 {@code RException}
* </p>
*
* @see me.qwq.doghouse.exception.RException
*/
@Data
@Accessors(chain = true)
@Slf4j
public class R<T> implements Serializable {
private static final long serialVersionUID = -7658884488696622015L;
@Getter(AccessLevel.PRIVATE)
private int resultCode;
private String message;
private T data;
public R() {
}
public R(int resultCode, String message) {
this.resultCode = resultCode;
this.message = message;
}
/**
* 是否成功
*
* @return
*/
public boolean isOk() {
return HttpStatus.OK.value() == getResultCode();
}
/**
* 对当前 R&lt;T> 类型的 T 数据调用 toString(),并将其结果作为生成的 R&lt;String> 的 data 返回一个相应的
* R&lt;String> 对象
*
* @return
*/
public R<String> toStringifyR() {
return new R<String>().setResultCode(resultCode).setMessage(message).setData(data.toString());
}
/**
* 返回指定状态且 data 为空(类型为 String)的 R
*
* @param constants
* @return
*/
public static R<String> status(HttpStatus constants) {
return new R<String>().setResultCode(constants.value()).setMessage(constants.getReasonPhrase());
}
/**
* 返回指定状态和 data 的 R
*
* @param constants
* @return
*/
public static <T> R<T> status(HttpStatus constants, T data) {
return new R<T>().setResultCode(constants.value()).setMessage(constants.getReasonPhrase()).setData(data);
}
/**
* 返回 HttpStatus.OK 状态和指定的 Data
*
* @param <T>
* @param data
* @return
*/
public static <T> R<T> ok(T data) {
return status(HttpStatus.OK, data);
}
/**
* 返回 HttpStatus.OK
*
* @return
*/
public static R<?> ok() {
return new R<>().setResultCode(HttpStatus.OK.value());
}
/**
* 返回 HttpStatus.UNAUTHORIZED 状态和指定的 Data
*
* @param <T>
* @param data
* @return
*/
public static <T> R<T> unauthorized(T data) {
return status(HttpStatus.UNAUTHORIZED, data);
}
/**
* 返回 HttpStatus.UNAUTHORIZED
*
* @return
*/
public static R<?> unauthorized() {
return status(HttpStatus.UNAUTHORIZED);
}
/**
* 返回 HttpStatus.BAD_REQUEST 状态和指定的 Data
*
* @param <T>
* @param data
* @return
*/
public static <T> R<T> badRequest(T data) {
return status(HttpStatus.BAD_REQUEST, data);
}
/**
* 返回 HttpStatus.BAD_REQUEST
*
* @return
*/
public static R<?> badRequest() {
return status(HttpStatus.BAD_REQUEST);
}
/**
* 返回 HttpStatus.INTERNAL_SERVER_ERROR 状态和指定的 Data
*
* @param <T>
* @param data
* @return
*/
public static <T> R<T> internalServerError(T data) {
return status(HttpStatus.INTERNAL_SERVER_ERROR, data);
}
/**
* 返回 HttpStatus.INTERNAL_SERVER_ERROR
*
* @return
*/
public static R<?> internalServerError() {
return status(HttpStatus.INTERNAL_SERVER_ERROR);
}
/**
* 根据布尔值判断返回<br>
* true 返回 R.ok()<br>
* false throw RException.badRequest()
* @param condition
* @return
*/
public static R<?> judge(boolean condition) {
if (condition) return R.ok();
throw RException.badRequest();
}
public static R<?> judgeNonNull(Object obj) {
if (obj != null) return R.ok(obj);
throw RException.badRequest();
}
public static R<?> judge(ThrowingSupplier<?> supplier) {
try {
return R.ok(supplier.get());
}
catch (Exception e) {
throw RException.badRequest(e.getMessage());
}
}
public static R<?> judgeThrow(ThrowingSupplier<?> supplier) throws Exception {
return R.ok(supplier.get());
}
/**
* 根据布尔值判断返回<br>
* true 返回 R.ok(T okData)<br>
* false throw RException.badRequest(failedMessage)
* @param condition
* @return
*/
public static <T> R<?> judge(boolean condition, T okData, String failedMessage) {
if (condition) return R.ok(okData);
throw RException.badRequest(failedMessage);
}
/**
* 根据布尔值判断返回<br>
* true 返回 R.ok(null)<br>
* false throw RException.badRequest(failedMessage)
* @param condition
* @return
*/
public static R<?> judge(boolean condition, String failedMessage) {
return R.judge(condition, null, failedMessage);
}
@FunctionalInterface
public static interface ThrowingSupplier<T> {
T get() throws Exception;
}
}

View File

@@ -0,0 +1,13 @@
package link.at17.mid.tushare.enums;
public enum AdjustType {
None("0"),
Qfq("1"),
Hfq("2");
private String emCode;
public String getEmCode() { return emCode; }
private AdjustType(String emCode) {
this.emCode = emCode;
}
}

View File

@@ -0,0 +1,6 @@
package link.at17.mid.tushare.enums;
public enum BuyOrSell {
Buy,
Sell
}

View File

@@ -0,0 +1,6 @@
package link.at17.mid.tushare.enums;
public enum CrossType {
Bullish,
Bearish
}

View File

@@ -0,0 +1,17 @@
package link.at17.mid.tushare.enums;
/**
* 信息来源
* @author Barry
*
*/
public enum InfoSource {
/** 同花顺 **/
THS,
/** 东方财富 **/
EastMoney,
/** tushare **/
Tushare,
/** 新浪财经 **/
Sina
}

View File

@@ -0,0 +1,74 @@
package link.at17.mid.tushare.enums;
import org.springframework.util.Assert;
import lombok.Getter;
/**
* 开盘啦 特殊指标
* @author Barry
*
*/
public enum KaipanlaIndexType {
/**
* 对倒金额
*/
EXCHANGE_AMOUNT("GetDuiDaoKLine", "DDJE", "exchange_amount"),
/**
* 竞价成交量
*/
BID_VOL("GetBidVolKLine", "Vol", "bid_vol"),
/**
* 压单托单
*/
HOLD_PRESS_AMOUNT("GetTuoYaDanKLine", "TDJE", "YDJE", "hold_amount", "press_amount"),
/**
* 资金净额(大单净额,大于 30 万的成交单)
*/
BIG_AMOUNT("GetDaDanKLine2New", "DDJE", "big_amount");
/**
* 指标类型,数据库对应字段名
*/
@Getter
private String[] fieldsName;
/**
* 别名,请求返回字段名
*/
@Getter
private String[] alias;
/**
* 接口 action 名称
*/
@Getter String actionName;
/**
* 初始化开盘啦指标
* @param actionName 请求名称,即指标对应请求的名称
* @param args 参数填写格式为请求返回字段1 请求返回字段2 ... 请求返回字段n 数据库对应字段1 数据库对应字段2 ... 数据库对应字段n
*/
private KaipanlaIndexType(
String actionName,
String...args
) {
this.actionName = actionName;
Assert.isTrue(args != null && args.length % 2 == 0, "参数错误,参数数目必须为 2 的倍数");
int half = args.length / 2;
this.alias = new String[half];
this.fieldsName = new String[half];
for (int i = 0; i < half; i++) {
alias[i] = args[i];
fieldsName[i] = args[i + half];
}
}
private KaipanlaIndexType(
String actionName,
String[] alias,
String[] fieldsName
) {
this.actionName = actionName;
this.alias = alias;
this.fieldsName = fieldsName;
}
}

View File

@@ -0,0 +1,24 @@
package link.at17.mid.tushare.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import lombok.Getter;
/**
* 涨跌停类型
* @author Administrator
*
*/
public enum LimitType {
UpLimit("U"),
DownLimit("D"),
Zha("Z");
@Getter
@EnumValue
private String limitType;
private LimitType(String limitType) {
this.limitType = limitType;
}
}

View File

@@ -0,0 +1,19 @@
package link.at17.mid.tushare.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import lombok.Getter;
public enum ListStatus {
LIST("L"),
DELIST("D"),
PAUSE("P");
@Getter
@EnumValue
private String listStatus;
private ListStatus(String listStatus) {
this.listStatus = listStatus;
}
}

View File

@@ -0,0 +1,12 @@
package link.at17.mid.tushare.enums;
public enum PivotPointType {
/** 经典中心点,提供 Pivot、Resistance/Support 1-3 **/
Classic,
/** Woodie's 中心点,提供 Pivot、Resistance/Support 1-2 **/
Woodies,
/** Camarilla 中心点,提供 Resistance/Support 1-4 **/
Camarilla,
/** DeMark's 中心点,提供 Resistance/Support 1 **/
DeMarks
}

View File

@@ -0,0 +1,21 @@
package link.at17.mid.tushare.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import lombok.Getter;
public enum StockHolderType {
TOP10(0),
TOP10Float(1);
@EnumValue
@Getter
private int isFloat;
private StockHolderType(int isFloat) {
this.isFloat = isFloat;
}
}

View File

@@ -0,0 +1,29 @@
package link.at17.mid.tushare.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import lombok.Getter;
public enum StockMarket {
BK("BK", "90", null),
SZ("SZ", "0", "SZSE"),
SH("SH", "1", "SSE"),
BJ("BJ", "BJ", "BSE");
@Getter
private String eMCode;
@Getter
private String standardCode;
@EnumValue
@Getter
private String exchangeCode;
private StockMarket(String standardCode, String emCode, String exchangeCode) {
this.standardCode = standardCode;
this.eMCode = emCode;
this.exchangeCode = exchangeCode;
}
}

View File

@@ -0,0 +1,227 @@
package link.at17.mid.tushare.enums;
import lombok.Getter;
/**
* 股价数据粒度
* @author Administrator
*
*/
public enum StockSpan {
Tick(null),
Minute(1),
Minute5(5),
Minute15(15),
Minute30(30),
Minute60(60),
Minute120(120),
Daily(null),
Weekly(null),
Monthly(null),
Seasonly(null),
HalfYearly(null),
Yearly(null);
@Getter
private Integer min;
private StockSpan(Integer min) {
this.min = min;
}
/**
* 获取东财对应的时间粒度表示
* @return
*/
public String getEastMoneyCode() {
if (this.min != null) {
return String.format("%d", 0);
}
switch(this) {
case Daily:
return "101";
case Weekly:
return "102";
case Monthly:
return "103";
case Seasonly:
return "104";
case HalfYearly:
return "105";
case Yearly:
return "106";
default:
throw new RuntimeException("东财时间粒度不支持 " + this);
}
}
/**
* 从东财时间粒度转换为 StockSpan
* @param eastMoneyCode
* @return
*/
public static StockSpan fromEastMoneyCode(String eastMoneyCode) {
switch (eastMoneyCode) {
case "1":
return Minute;
case "5":
return Minute5;
case "15":
return Minute15;
case "30":
return Minute30;
case "60":
return Minute60;
case "120":
return Minute120;
case "101":
return Daily;
case "102":
return Weekly;
case "103":
return Monthly;
case "104":
return Seasonly;
case "105":
return HalfYearly;
case "106":
return Yearly;
default:
throw new RuntimeException("未知的东财时间粒度 " + eastMoneyCode);
}
}
/**
* 转换为益盟时间粒度
* @return
*/
public Integer getEmoneyCode() {
if (this.min != null) {
return this.min;
}
switch(this) {
case Daily:
return 10000;
case Weekly:
return 20000;
case Monthly:
return 30000;
case Seasonly:
return 40000;
case HalfYearly:
return 50000;
case Yearly:
return 60000;
default:
throw new RuntimeException("益盟时间粒度不支持:" + this);
}
}
/**
* 从益盟时间粒度转换
* @param emoneyDataPeriod
* @return
*/
public static StockSpan fromEmoneyCode(Integer emoneyDataPeriod) {
switch (emoneyDataPeriod) {
case 1:
return Minute;
case 5:
return Minute5;
case 15:
return Minute15;
case 30:
return Minute30;
case 60:
return Minute60;
case 120:
return Minute120;
case 10000:
return Daily;
case 20000:
return Weekly;
case 30000:
return Monthly;
case 40000:
return Seasonly;
case 50000:
return HalfYearly;
case 60000:
return Yearly;
default:
throw new RuntimeException("未知的益盟时间粒度 " + emoneyDataPeriod);
}
}
/**
* 转换成开盘啦数据粒度
* @return
*/
public String getKaipanlaType() {
String type;
if (this == StockSpan.Yearly) {
type = "y";
}
else if (this == StockSpan.Monthly) {
type = "m";
}
else if (this == StockSpan.Weekly) {
type = "w";
}
else if (this == StockSpan.Minute60) {
type = "60";
}
else if (this == StockSpan.Minute30) {
type = "30";
}
else if (this == StockSpan.Minute15) {
type = "15";
}
else if (this == StockSpan.Minute5) {
type = "5";
}
else if (this == StockSpan.Daily){
type = "d";
}
else {
throw new RuntimeException("开盘啦指标不支持该数据粒度 " + this);
}
return type;
}
/**
* 从开盘啦时间粒度转换
* @param kaipanlaType
* @return
*/
public static StockSpan fromKaipanlaType(String kaipanlaType) {
switch (kaipanlaType) {
case "5":
return Minute5;
case "15":
return Minute15;
case "30":
return Minute30;
case "60":
return Minute60;
case "d":
return Daily;
case "w":
return Weekly;
case "n":
return Monthly;
case "y":
return Yearly;
default:
throw new RuntimeException("未知的开盘啦时间粒度 " + kaipanlaType);
}
}
}

View File

@@ -0,0 +1,19 @@
package link.at17.mid.tushare.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import lombok.Getter;
public enum ThsStockMarket {
A("A"),
US("US"),
HK("HK");
@EnumValue
@Getter
private String code;
private ThsStockMarket(String code) {
this.code = code;
}
}

View File

@@ -0,0 +1,84 @@
package link.at17.mid.tushare.interfaces;
import java.util.Objects;
import org.springframework.stereotype.Component;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.bean.copier.CopyOptions;
import jakarta.annotation.PostConstruct;
import link.at17.mid.tushare.service.ConfigService;
import link.at17.mid.tushare.system.util.SpringContextHolder;
/**
* 配置项必须实现该接口
*
* @author Doghole
*
*/
@Component
public interface IConfig<T extends IConfig<T>> {
@SuppressWarnings("unchecked")
public default boolean saveOrUpdate() {
return SpringContextHolder.getBean(ConfigService.class).saveOrUpdate((T)this);
}
/**
* 保存前处理。无论 @ConfigInfo 是否设置 save = true 都会调用
*/
@SuppressWarnings("unchecked")
public default T afterSaving() {
return (T) this;
}
/**
* 保存后处理。无论 @ConfigInfo 是否设置 save = true 都会调用
*/
@SuppressWarnings("unchecked")
public default T beforeSaving() {
return (T) this;
}
/**
* 合并到 other且返回合并后的 other
* @return
*/
public default T mergeTo(T other) {
if (!Objects.equals(this, other)) {
BeanUtil.copyProperties(this, other,
CopyOptions.create().setIgnoreNullValue(true));
}
return other;
}
/**
* 合并其他,并返回本身
* @param other
* @return
*/
@SuppressWarnings("unchecked")
public default T mergeFrom(T other) {
if (!Objects.equals(this, other)) {
BeanUtil.copyProperties(other, this,
CopyOptions.create().setIgnoreNullValue(true));
}
return (T) this;
}
/**
* 初始化完成之后的方法,会在
* <code><i>beanFactory</i>.autowireBean(bean)</code>
* 和
* <code><i>beanFactory</i>.initializeBean(bean, beanName)</code>
* 后执行。<br>
* @see quant.rich.emoney.config.ConfigServiceFactoryBean
*/
@PostConstruct
public default void afterBeanInit() {}
public static class Views {
public static class Persistence {}
}
}

View File

@@ -0,0 +1,169 @@
package link.at17.mid.tushare.robot.feishu.ablilities;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.Serializable;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Objects;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletResponse;
import link.at17.mid.tushare.robot.models.FeishuAppEnv;
import link.at17.mid.tushare.system.util.SpringContextHolder;
import org.apache.commons.codec.binary.Hex;
import org.apache.commons.io.IOUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJacksonInputMessage;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdviceAdapter;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@ControllerAdvice("link.at17.mid.tushare.robot.feishu.controller")
public class FeishuAppControllerAdvice extends RequestBodyAdviceAdapter {
@Autowired
HttpServletResponse response;
private static MessageDigest sha256Digest = null;
@PostConstruct
private void init() throws NoSuchAlgorithmException {
sha256Digest = MessageDigest.getInstance("SHA-256");
}
private FeishuAppEnv getEnv(Class<? extends FeishuAppEnv> clazz) {
return SpringContextHolder.getBean(clazz);
}
/**
* 飞书 App 校验错误处理
* @param ex
* @return
*/
@ExceptionHandler(value = FeishuAppThrowable.class)
@ResponseBody
public Serializable feishuAppVerifyExceptionHandler(FeishuAppThrowable ex) {
JSONObject respJson = new JSONObject();
if (!ex.hasResponse()) {
response.setStatus(HttpStatus.BAD_REQUEST.value());
respJson.put("error_info", ex.getMessage());
return respJson;
}
else {
return ex.getResponse();
}
}
@Override
public boolean supports(MethodParameter methodParameter, Type targetType,
Class<? extends HttpMessageConverter<?>> converterType) {
// 是否支持注入
FeishuAppVerify feishuAppVerify =
methodParameter.getMethod()
.getAnnotation(FeishuAppVerify.class);
// 首先是验证注解不为空,而后获取环境配置的 encryptKey 不为空
return Objects.nonNull(feishuAppVerify);
}
/**
* 重写,用于验证和解密
*/
@SuppressWarnings("serial")
@Override
public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
HttpHeaders headers = inputMessage.getHeaders();
if (!headers.getContentType().toString().startsWith(MediaType.APPLICATION_JSON.toString())
) {
return inputMessage;
}
try {
FeishuAppVerify feishuAppVerify =
parameter.getMethod()
.getAnnotation(FeishuAppVerify.class);
FeishuAppEnv env = getEnv(feishuAppVerify.value());
InputStream inputStream = inputMessage.getBody();
String body = IOUtils.toString(inputStream, StandardCharsets.UTF_8);
JSONObject json = JSONObject.parseObject(body);
if (env.supportDecryption()) {
try {
json = JSONObject.parseObject(env.decrypt(JSONObject.parseObject(body).getString("encrypt")));
}
catch (Exception e) {
throw new FeishuAppThrowable("decrypt error");
}
}
if (Objects.isNull(json)) {
// 本是加密的配置,却传来明文,或者主体根本无法获取
throw new FeishuAppThrowable("bad request");
}
// if it's a challenge
if (json.containsKey("type") && "url_verification".equals(json.getString("type"))) {
final String challenge = json.getString("challenge");
throw new FeishuAppThrowable("challenge").setResponse(new JSONObject() {{ put("challenge", challenge);}});
}
// schema verify
if (!json.containsKey("schema")) {
throw new FeishuAppThrowable("request is not callback event(v2)");
}
// Token verify
if (!json.getJSONObject("header").getString("token").equals(env.getVerificationToken())) {
throw new FeishuAppThrowable("invalid token");
}
String timestampStr = headers.getFirst("X-Lark-Request-Timestamp");
// Timestamp expire verify
int expireIn = feishuAppVerify.timestampExpireIn();
if (expireIn > -1) {
int timestampInt = Integer.parseInt(timestampStr);
int currentTs = (int) (System.currentTimeMillis() / 1000);
if (currentTs - timestampInt > expireIn) {
throw new FeishuAppThrowable("timestamp expired");
}
}
String nonce = headers.getFirst("X-Lark-Request-Nonce");
String signature = headers.getFirst("X-Lark-Signature");
// Signature verify
String localSignature = Hex.encodeHexString(
sha256Digest.digest(
(timestampStr + nonce + env.getEncryptKey() + body).getBytes(StandardCharsets.UTF_8)));
if (!localSignature.equalsIgnoreCase(signature)) {
throw new FeishuAppThrowable("invalid signature in event");
}
return new MappingJacksonInputMessage(new ByteArrayInputStream(json.toJSONString().getBytes()), inputMessage.getHeaders());
}
catch (FeishuAppThrowable e) {
log.error(e.getMessage());
throw e;
}
catch (Exception e) {
log.error("Error occurred. ", e);
throw new FeishuAppThrowable("bad request");
}
}
}

View File

@@ -0,0 +1,17 @@
package link.at17.mid.tushare.robot.feishu.ablilities;
public enum FeishuAppEvents {
ImReceiveMessageV1("im.message.receive_v1");
private String value;
private FeishuAppEvents(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}

View File

@@ -0,0 +1,30 @@
package link.at17.mid.tushare.robot.feishu.ablilities;
import java.io.Serializable;
import java.util.Objects;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
@SuppressWarnings("serial")
public class FeishuAppThrowable extends RuntimeException {
@Getter
@Setter
@Accessors(chain=true)
private Serializable response = null;
public FeishuAppThrowable() {
super();
}
public FeishuAppThrowable(String message) {
super(message);
}
public boolean hasResponse() {
return Objects.nonNull(response);
}
}

View File

@@ -0,0 +1,27 @@
package link.at17.mid.tushare.robot.feishu.ablilities;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import link.at17.mid.tushare.robot.models.FeishuAppEnv;
import java.lang.annotation.ElementType;
import java.lang.annotation.RetentionPolicy;
/**
* 飞书请求头校验注解
* @author Barry
*
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface FeishuAppVerify {
Class<? extends FeishuAppEnv> value();
/**
* 飞书请求时间戳过期时间(秒),默认 -1即永不过期
* @return
*/
int timestampExpireIn() default -1;
}

View File

@@ -0,0 +1,13 @@
package link.at17.mid.tushare.robot.feishu.ablilities;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface FeishuRegisterEvent {
FeishuAppEvents value();
}

View File

@@ -0,0 +1,5 @@
package link.at17.mid.tushare.robot.feishu.controller;
public class BaseRobotV1 {
}

View File

@@ -0,0 +1,83 @@
package link.at17.mid.tushare.robot.feishu.controller;
import org.springframework.beans.factory.annotation.Autowired;
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.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import com.alibaba.fastjson2.JSONObject;
import link.at17.mid.tushare.robot.feishu.ablilities.FeishuAppEvents;
import link.at17.mid.tushare.robot.feishu.ablilities.FeishuAppThrowable;
import link.at17.mid.tushare.robot.feishu.ablilities.FeishuAppVerify;
import link.at17.mid.tushare.robot.models.SweetDogAppEnv;
import lombok.extern.slf4j.Slf4j;
import okhttp3.Call;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
@Slf4j
@RestController
@RequestMapping("/feishu/sweetDogRobotV1")
public class SweetDogRobotV1 {
@Autowired
private SweetDogAppEnv sweetDogAppEnv;
/*
* 该接口从飞书网络端获取消息,并且识别消息后回传给飞书端。相当于一个复读机
*/
@SuppressWarnings("serial")
@PostMapping("/request")
@ResponseBody
@FeishuAppVerify(SweetDogAppEnv.class)
private JSONObject request(@RequestBody JSONObject json) {
String eventType = json.getJSONObject("header").getString("event_type");
if (eventType.equals(FeishuAppEvents.ImReceiveMessageV1.getValue()) ) {
JSONObject message = json.getJSONObject("event").getJSONObject("message");
if (!message.getString("message_type").equals("text")) {
throw new FeishuAppThrowable("Other types of messages have not been processed yet");
}
sweetDogAppEnv.getTenantAccessToken().ifPresent((tenantAccessToken) -> {
OkHttpClient okHttpClient = new OkHttpClient();
JSONObject postJson = new JSONObject() {{
put("receive_id", json
.getJSONObject("event")
.getJSONObject("sender")
.getJSONObject("sender_id").getString("open_id"));
put("content", message.get("content"));
put("msg_type", "text");
}};
Request request = new Request.Builder()
.url(sweetDogAppEnv.getLarkHost() + "/open-apis/im/v1/messages?receive_id_type=open_id")
.addHeader("Authorization", "Bearer " + tenantAccessToken)
.post(
okhttp3.RequestBody.create(
postJson.toJSONString(),
MediaType.parse("application/json; charset=utf-8")))
.build();
log.info("tenantAccessToken {}, requestBody {}", tenantAccessToken, postJson);
final Call call = okHttpClient.newCall(request);
Response resp = null;
try {
resp = call.execute();
JSONObject sendRespJson = JSONObject.parseObject(resp.body().string());
if (sendRespJson.getIntValue("code") != 0) {
throw new Exception (sendRespJson.getJSONObject("error").toJSONString());
}
} catch (Exception e) {
log.error("send message error", e);
}
finally {
if (resp != null) resp.close();
}
});
}
return new JSONObject();
}
}

View File

@@ -0,0 +1,145 @@
package link.at17.mid.tushare.robot.models;
import java.nio.charset.StandardCharsets;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Optional;
import jakarta.annotation.PostConstruct;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpStatus;
import com.alibaba.fastjson2.JSONObject;
import lombok.AccessLevel;
import lombok.Data;
import lombok.Getter;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
import okhttp3.Call;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
@Data
@Accessors(chain=true)
@Slf4j
public abstract class FeishuAppEnv {
private String appId;
private String appSecret;
private String verificationToken;
private String encryptKey = "";
private String larkHost;
@Getter(AccessLevel.NONE)
private transient String tenantAccessToken = null;
@Getter(AccessLevel.NONE)
private transient int tenantExpire = 0;
@Getter(AccessLevel.NONE)
private transient byte[] keyBs;
@Getter(AccessLevel.NONE)
private transient Request accessTokenRequest;
private static MessageDigest sha256Digest = null;
private static Cipher aesCipher = null;
private static final String TENANT_ACCESS_TOKEN_URI = "/open-apis/auth/v3/tenant_access_token/internal";
@SuppressWarnings("serial")
@PostConstruct
private void init() throws NoSuchAlgorithmException, NoSuchPaddingException {
sha256Digest = MessageDigest.getInstance("SHA-256");
aesCipher = Cipher.getInstance("AES/CBC/NOPADDING");
if (!StringUtils.isEmpty(encryptKey)) {
keyBs = sha256Digest.digest(encryptKey.getBytes(StandardCharsets.UTF_8));
}
accessTokenRequest = new Request.Builder()
.url(getLarkHost() + TENANT_ACCESS_TOKEN_URI)
.post(
RequestBody.create(
new JSONObject() {{
put("app_id", getAppId());
put("app_secret", getAppSecret());
}}.toJSONString(),
MediaType.parse("application/json; charset=utf-8")))
.build();
}
public Optional<String> getTenantAccessToken() {
int currTs = (int) (System.currentTimeMillis() / 1000);
if (StringUtils.isEmpty(tenantAccessToken) || currTs + 1800 > tenantExpire) {
updateTenantAccessToken();
}
return Optional.ofNullable(tenantAccessToken);
}
private void updateTenantAccessToken() {
OkHttpClient okHttpClient = new OkHttpClient();
final Call call = okHttpClient.newCall(accessTokenRequest);
Response resp = null;
try {
int tenantExpireTemp = (int) (System.currentTimeMillis() / 1000);
resp = call.execute();
int status = resp.code();
if (status != HttpStatus.OK.value()) {
throw new Exception("status code " + status);
}
JSONObject respJson = JSONObject.parseObject(resp.body().string());
int code = respJson.getIntValue("code");
if (code != 0) {
throw new Exception("code = " + code + ", msg = " + respJson.getString("msg"));
}
tenantExpire = tenantExpireTemp + respJson.getIntValue("expire");
tenantAccessToken = respJson.getString("tenant_access_token");
} catch (Exception e) {
log.error("Cannot get tenant access token", e);
}
finally {
if (resp != null) resp.close();
}
}
public boolean supportDecryption() {
return StringUtils.isNotEmpty(encryptKey);
}
public String decrypt(String encryption) throws InvalidKeyException, InvalidAlgorithmParameterException, IllegalBlockSizeException, BadPaddingException {
if (!supportDecryption() || StringUtils.isBlank(encryption)) {
return encryption;
}
byte[] decode = Base64.getDecoder().decode(encryption);
byte[] iv = new byte[16];
System.arraycopy(decode, 0, iv, 0, 16);
byte[] data = new byte[decode.length - 16];
System.arraycopy(decode, 16, data, 0, data.length);
aesCipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBs, "AES"), new IvParameterSpec(iv));
byte[] r = aesCipher.doFinal(data);
if (r.length > 0) {
int p = r.length - 1;
for (; p >= 0 && r[p] <= 16; p--) {
}
if (p != r.length - 1) {
byte[] rr = new byte[p + 1];
System.arraycopy(r, 0, rr, 0, p + 1);
r = rr;
}
}
return new String(r, StandardCharsets.UTF_8);
}
}

View File

@@ -0,0 +1,5 @@
package link.at17.mid.tushare.robot.models;
public class FeishuRobotEvent {
}

View File

@@ -0,0 +1,10 @@
package link.at17.mid.tushare.robot.models;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "sweet-dog-app-env")
public class SweetDogAppEnv extends FeishuAppEnv {
}

View File

@@ -0,0 +1,8 @@
package link.at17.mid.tushare.robot.service;
import org.springframework.stereotype.Service;
@Service
public class FeishuRobotApiService {
}

View File

@@ -0,0 +1,425 @@
package link.at17.mid.tushare.service;
import lombok.extern.slf4j.Slf4j;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.BiMap;
import com.google.common.collect.HashBiMap;
import io.micrometer.core.instrument.util.IOUtils;
import jakarta.annotation.PostConstruct;
import link.at17.mid.tushare.TushareDataServiceApplication;
import link.at17.mid.tushare.annotation.ConfigInfo;
import link.at17.mid.tushare.interfaces.IConfig;
import link.at17.mid.tushare.system.util.SmartResourceResolver;
import link.at17.mid.tushare.system.util.SmartViewWriter;
import link.at17.mid.tushare.system.util.SpringContextHolder;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.reflections.Reflections;
import org.reflections.scanners.Scanners;
import org.reflections.util.ConfigurationBuilder;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jmx.export.annotation.ManagedAttribute;
import org.springframework.stereotype.Service;
/**
* 配置类服务
*
* @author Doghole
* @see quant.rich.emoney.config.ConfigAutoRegistrar
* @see quant.rich.emoney.config.ConfigServiceFactoryBean
*/
@Service
@Slf4j
public class ConfigService implements InitializingBean {
@Autowired
Reflections reflections;
static final boolean isJar = "jar".equals(TushareDataServiceApplication.class.getProtectionDomain().getCodeSource().getLocation().getProtocol());
static final ObjectMapper mapper = new ObjectMapper();
static {
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
/**
* 从
* <b><code> field </code></b>
* 到<b><code> ConfigClass </code></b>的<b>一对一</b>映射关系<br>
* 例: <code>"deviceInfo" &lt;--&gt; DeviceInfoConfig.class</code><br>
*/
@SuppressWarnings("rawtypes")
BiMap<String, Class<? extends IConfig>> fieldToClassCache = HashBiMap.create();
/**
* 从
* <b><code>ConfigClass</code></b>
* 类到对应实例化对象的映射关系。可以理解为对应类的实例运行时缓存。<p>
* <b>注意:当且仅当类被实例化后,该缓存才会被填充!</b>由于配置类是 Bean其生命周期交由
* Spring 框架管理,所以实例化的时机是不确定的。如果在这之前需要遍历类,请使用
* fieldToClassCache 或其他方式遍历。
*/
@SuppressWarnings("rawtypes")
private Map<Class<? extends IConfig>, Object> classToInstanceCache = new HashMap<>();
static final Pattern FIELD_PATTERN = Pattern.compile("^[a-zA-Z_$][a-zA-Z0-9_$]+$");
/**
* 初始化各项配置缓存,只有添加了 @ConfigInfo 的配置类才会被识别并缓存
*/
@SuppressWarnings("unchecked")
@PostConstruct
public void afterPropertiesSet() throws Exception {
if (reflections == null) {
reflections = new Reflections(new ConfigurationBuilder()
.addScanners(Scanners.MethodsAnnotated, Scanners.SubTypes, Scanners.TypesAnnotated)
.forPackages("link.at17.mid.tushare"));
}
Set<Class<?>> configClasses = reflections.getTypesAnnotatedWith(ConfigInfo.class);
for (Class<?> clazz : configClasses) {
ConfigInfo info = clazz.getAnnotation(ConfigInfo.class);
String field = info.field();
if (StringUtils.isEmpty(field)) {
// 如果 ConfigInfo 注解上 field() 为空,则默认从类名初始化该 field
// 规则是该配置类名必须以 Config 结尾,否则不合法
field = clazz.getSimpleName();
int lastIndexOfConfig = field.lastIndexOf("Config");
if (lastIndexOfConfig == -1) {
log.warn(
"Annotation @ConfigInfo doesn't set a field, "
+ "so try to init it from className \"{}\", but "
+ "it doesn't end with \"Config\", which is invalid, so ignore.", field);
continue;
}
field = field.substring(0, lastIndexOfConfig);
field = firstToLower(field);
}
else if (!FIELD_PATTERN.matcher(field).matches()) {
// field 必须是合法的 Java 变量名
log.warn("Invalid field name \"{}\", it cannot be a legal java variable name, so ignore.", field);
continue;
}
if (fieldToClassCache.containsKey(field)) {
Class<? extends IConfig<?>> existedClass =
(Class<? extends IConfig<?>>) fieldToClassCache.get(field);
log.warn("Found class {} with expected name {} which is already assigned to class {}, ignored.",
clazz.getName(), field, existedClass.getName());
continue;
}
Class<? extends IConfig<?>> configClass = (Class<? extends IConfig<?>>) clazz;
fieldToClassCache.put(field, configClass);
}
}
/**
* 根据 Config 的类名在缓存字典中取其值,这里偷懒了用了运行时缓存,您也可以根据需要换成 Redis 缓存
*
* @param <Config> 实现了接口 ConfigInterface 的网站配置类泛型
* @param clazz 实现了接口 ConfigInterface 的网站配置类
* @return <T>
*/
@SuppressWarnings("unchecked")
private <Config extends IConfig<Config>> Config getCache(Class<Config> clazz) {
return (Config) classToInstanceCache.get(clazz);
}
/**
* 将给定的 Config 的实体类缓存到缓存字典中, 这里偷懒了用了运行时缓存, 您也可以根据需要换成 Redis 缓存
*
* @param <Config> 实现了接口 ConfigInterface 的网站配置类泛型
* @param config 实现了接口 ConfigInterface 的网站配置类的实例化对象
* @return
*/
private <Config extends IConfig<Config>> void setCache(Config config) {
classToInstanceCache.put(config.getClass(), config);
}
/**
* 获取注释在继承了 ConfigInterface 上的接口
*
* @param <Config>
* @param config
* @return
*/
@SuppressWarnings("unchecked")
public static <Config extends IConfig<Config>> ConfigInfo getConfigInfo(Config config) {
return getConfigInfo(config.getClass());
}
/**
* 获取注释在继承了 ConfigInterface 上的接口
*
* @param <Config>
* @param clazz
* @return
*/
public static <Config extends IConfig<Config>> ConfigInfo getConfigInfo(Class<Config> clazz) {
ConfigInfo info = clazz.getAnnotation(ConfigInfo.class);
if (info == null) {
throw new RuntimeException("Cannot get config info of " + clazz.toString()
+ ", please check if @ConfigInfo annotation is set correctly");
}
return info;
}
/**
* 通过注解 @ConfigInfo 的 field 获取其本身
*
* @param field
* @return
*/
@SuppressWarnings("unchecked")
public ConfigInfo getConfigInfoByField(String field) {
return getConfigInfo(fieldToClassCache.get(field));
}
/**
* 通过注解 @ConfigInfo 的 field 从缓存中获取 Config 的类型
*
* @param field
* @return
*/
@SuppressWarnings("unchecked")
public <T extends IConfig<T>> Class<T> getConfigClassByField(String field) {
return (Class<T>) fieldToClassCache.get(field);
}
/**
* 获取 Config
* <p>
* 当缓存中有时从缓存取, 缓存没有时从数据库取并更新到缓存, 数据库也没有时, 如果指定的 Config 的 @ConfigInfo
* 注解开启了 initDefault = true 则尝试返回一个初始 Config否则返回 null
*
* @param <Config>
* @param clazz
* @return
*/
public <Config extends IConfig<Config>> Config getConfig(Class<Config> clazz) {
if (classToInstanceCache.containsKey(clazz)) {
try {
return getCache(clazz);
} catch (Exception e) {
log.warn("Cannot get config info of " + clazz.toString() + " from cache, try to read from database", e);
}
}
String field = fieldToClassCache.inverse().get(clazz);
// 和 doghouse 不同,配置不是从数据库拿的,而是从本地文件系统
Config config = getOrCreateConfig(field);
if (config == null) {
throw new RuntimeException("Cannot get or create config field named " + field);
}
setCache(config);
return config;
}
/**
* 持久化 Config同时设置缓存、更新注入
*
* @param <Config>
* @param config
* @return
*/
@ManagedAttribute
public <Config extends IConfig<Config>> boolean saveOrUpdate(Config config) {
config.beforeSaving();
String field = fieldToClassCache.inverse().get(config.getClass());
ConfigInfo info = getConfigInfoByField(field);
SmartViewWriter writer = new SmartViewWriter();
String configJoString = writer.writeWithSmartView(config, IConfig.Views.Persistence.class);
if (info.save()) {
try {
String filePath = getConfigFilePath(field, false);
SmartResourceResolver.saveText(filePath, configJoString);
//Path dirPath = Paths.get(filePath).getParent();
//if (Files.notExists(dirPath)) {
// Files.createDirectories(dirPath);
//}
//Files.writeString(Path.of(filePath), configJoString);
} catch (IOException e) {
log.error("Cannot write config to local file {}.json, error: {}", field, e.getMessage());
return false;
}
}
config.afterSaving();
setCache(config);
SpringContextHolder.updateBean(field + "Config", config);
return true;
}
/**
* 从指定路径获取配置文件并转换为实例对象
* @param <Config>
* @param path
* @param configClass
* @return
*/
private <Config extends IConfig<Config>> Config getFromFile(String path, Class<Config> configClass) {
String configString;
Config config = null;
try {
// 此处只是读取文件,并不关心该文件是否可写
configString = IOUtils.toString(SmartResourceResolver.loadResource(path), Charset.defaultCharset());
} catch (IOException e) {
String field = fieldToClassCache.inverse().get(configClass);
log.warn("Cannot read config {}.json: {}", field, e.getMessage());
return config;
}
try {
config = mapper.readValue(configString, configClass);
} catch (Exception e) {
String field = fieldToClassCache.inverse().get(configClass);
log.warn("Cannot parse configString of {} to Config", field);
e.printStackTrace();
}
return config;
}
/**
* 根据 config field 获取 Config将读取本地存储的配置若失败则试图初始化并存储
*
* @param configField
* @return
*/
@SuppressWarnings("unchecked")
public <Config extends IConfig<Config>> Config getOrCreateConfig(String field) {
Class<Config> configClass = (Class<Config>) fieldToClassCache.get(field);
if (configClass == null) {
log.warn("Cannot get class info from fieldClassCache, field name: {}", field);
return null;
}
ConfigInfo info = getConfigInfo(configClass);
Config config = null;
// 先从持久化路径拿
// fallback 流程:如果持久化文件不存在或者从中反序列化失败,则走 fallback 流程
// 如果 fallback 也失败,走初始化流程。但是初始化出的内容不参与 fallback 的持久化
// 也就是无论如何fallback 都不应由程序来写入
String filePath = getConfigFilePath(field, false);
config = getFromFile(filePath, configClass);
if (config == null) {
log.info("Cannot init config from local file of {}Config, try fallback", field);
// 走 fallback 流程
config = getFromFile(getConfigFilePath(field, true), configClass);
}
if (config == null) {
log.info("Cannot init config from fallback file of {}Config", field);
if (info.initDefault()) {
// 还为 null 且允许初始化默认值,则初始化一份
log.info("Init {} by default non-args constructor", field);
try {
config =
configClass.getDeclaredConstructor()
.newInstance();
} catch (InstantiationException | IllegalAccessException | IllegalArgumentException
| InvocationTargetException | NoSuchMethodException | SecurityException e) {
// 一般是初始化方法内出现未被捕获的错误
log.warn("Specific class " + configClass.toString()
+ " has @ConfigInfo annotation and enables initDefault, but init failed", e);
}
}
}
if (config == null) {
log.warn("Cannot read or init from default/fallback for config {}Config, it will be null", field);
}
else {
saveOrUpdate(config);
}
return config;
}
/**
* 根据 config field 生成配置文件路径
*
* @param configField
* @return
*/
private static String getConfigFilePath(String field, boolean isFallback) {
StringBuilder sb = new StringBuilder();
sb.append("./conf/system/");
sb.append(field);
sb.append(isFallback ? ".fallback.json" : ".json");
return sb.toString();
}
/**
* 按默认顺序列出所有已加载 config 的 ConfigInfo可以用在前端菜单枚举
*
* @param onlyManaged 是否只列举出 {@code @ConfigInfo(..., managed=true)} 的内容, {@code false} 则全部列出
* @return {@code List<ConfigInfo>}
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
public List<ConfigInfo> getConfigInfoList(boolean onlyManaged) {
List<ConfigInfo> configInfos = new ArrayList<>();
for (Class<? extends IConfig> configClazz : fieldToClassCache.values()) {
ConfigInfo info = getConfigInfo(configClazz);
if (onlyManaged && info.managed()) {
configInfos.add(info);
}
else if (!onlyManaged) {
configInfos.add(info);
}
}
Collections.sort(configInfos, CONFIG_INFO_SORTER);
return configInfos;
}
/**
* ConfigInfo 比较器
*/
private static class ConfigInfoSorter implements Comparator<ConfigInfo> {
@Override
public int compare(ConfigInfo o1, ConfigInfo o2) {
return Integer.compare(o1.order(), o2.order());
}
}
private static final ConfigInfoSorter CONFIG_INFO_SORTER = new ConfigInfoSorter();
/**
* 首字母小写
*
* @param s
* @return
*/
private static String firstToLower(String s) {
if (StringUtils.isNotEmpty(s)) {
char[] cs = s.toCharArray();
if (cs[0] >= 'A' && cs[0] <= 'Z') {
cs[0] += 32;
}
return String.valueOf(cs);
}
return s;
}
}

View File

@@ -0,0 +1,74 @@
package link.at17.mid.tushare.system.config;
import java.net.InetSocketAddress;
import java.net.Proxy;
import org.apache.commons.lang3.ObjectUtils;
import org.springframework.context.annotation.Lazy;
import com.fasterxml.jackson.annotation.JsonIgnore;
import link.at17.mid.tushare.annotation.ConfigInfo;
import link.at17.mid.tushare.annotation.StaticAttribute;
import link.at17.mid.tushare.interfaces.IConfig;
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain = true)
@ConfigInfo(field = "system", name = "平台设置", initDefault = true)
@Lazy(false)
public class SystemConfig implements IConfig<SystemConfig> {
private String tushareToken;
@StaticAttribute("ProxyTypeEnum")
private Proxy.Type proxyType = Proxy.Type.DIRECT;
private String proxyHost = "";
private Integer proxyPort = 1;
private Boolean ignoreHttpsVerification = false;
public SystemConfig() {
tushareToken = "";
}
/**
* 根据配置获取代理
* @return
*/
@JsonIgnore
public Proxy getProxy () {
if (getProxyType() != null && getProxyType() != Proxy.Type.DIRECT) {
return new Proxy(getProxyType(),
new InetSocketAddress(getProxyHost(), getProxyPort()));
}
return Proxy.NO_PROXY;
}
/**
* 根据配置获取代理 Url
* @return
*/
public String getProxyUrl() {
if (ObjectUtils.anyNull(getProxyType(), getProxyHost(), getProxyPort())) {
return null;
}
StringBuilder sb = new StringBuilder();
if (getProxyType() == Proxy.Type.SOCKS) {
sb.append("socks5://");
}
else if (getProxyType() == Proxy.Type.HTTP) {
sb.append("http://");
}
else {
return null;
}
sb.append(getProxyHost()).append(':').append(getProxyPort());
return sb.toString();
}
}

Some files were not shown because too many files have changed in this diff Show More