├── .air.toml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── deploy.yml │ └── sync.yml ├── .gitignore ├── .goreleaser.yml ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── README.md ├── build.sh ├── cmds ├── checker.go ├── checker_cmd.go ├── cmds_test.go ├── exportor.go ├── exportor_cmd.go ├── exportor_csv.go ├── exportor_excel.go ├── exportor_excel_test.go ├── exportor_json.go ├── exportor_pic.go ├── exportor_pic_test.go ├── index.go ├── index_cmd.go ├── json_dump_cmd.go └── webserver_cmd.go ├── config.toml ├── core ├── checker.go ├── checker_test.go ├── core.go ├── core_test.go ├── searcher.go ├── searcher_test.go ├── selector.go └── selector_test.go ├── cron ├── bond.go ├── cron.go ├── fund.go ├── fund_managers.go ├── fund_test.go ├── stock.go └── stock_test.go ├── datacenter ├── chinabond │ ├── chinabond.go │ ├── chinabond_test.go │ ├── query_tree.go │ ├── query_tree_test.go │ ├── query_xy_fxsyl.go │ └── query_xy_fxsyl_test.go ├── datacenter.go ├── eastmoney │ ├── README.md │ ├── company_profile.go │ ├── company_profile_test.go │ ├── eastmoney.go │ ├── eastmoney_test.go │ ├── fina_cashflow.go │ ├── fina_cashflow_test.go │ ├── fina_income.go │ ├── fina_income_test.go │ ├── fina_main.go │ ├── fina_main_test.go │ ├── free_holders.go │ ├── free_holders_test.go │ ├── fund_info.go │ ├── fund_info_test.go │ ├── fund_manager_base_list.go │ ├── fund_manager_base_list_test.go │ ├── fund_managers.go │ ├── fund_managers_test.go │ ├── fund_net_list.go │ ├── fund_net_list_test.go │ ├── fund_search.go │ ├── fund_search_test.go │ ├── historical_pe_list.go │ ├── historical_pe_list_test.go │ ├── hs300.go │ ├── hs300_test.go │ ├── index.go │ ├── index_test.go │ ├── industry_list.go │ ├── industry_list_test.go │ ├── jiazhipinggu.go │ ├── jiazhipinggu_test.go │ ├── org_rating.go │ ├── org_rating_test.go │ ├── profit_predict.go │ ├── profit_predict_test.go │ ├── query_fund_by_stock.go │ ├── query_fund_by_stock_test.go │ ├── select_stocks.go │ ├── select_stocks_test.go │ ├── valuation_status.go │ ├── valuation_status_test.go │ ├── zonghepingjia.go │ ├── zonghepingjia_test.go │ ├── zscfg.go │ ├── zscfg_test.go │ ├── zz500.go │ └── zz500_test.go ├── eniu │ ├── README.md │ ├── eniu.go │ ├── eniu_test.go │ ├── historical_price.go │ └── historical_price_test.go ├── qq │ ├── README.md │ ├── keyword_search.go │ ├── keyword_search_test.go │ ├── qq.go │ └── qq_test.go ├── sina │ ├── keyword_search.go │ ├── keyword_search_test.go │ ├── sina.go │ └── sina_test.go └── zszx │ ├── net_inflows.go │ ├── net_inflows_test.go │ ├── zszx.go │ └── zszx_test.go ├── go.mod ├── go.sum ├── main.go ├── misc ├── .gitignore ├── README.md ├── configs │ ├── README.md │ ├── nginx.conf │ └── supervisor.conf ├── data │ └── materials.json ├── docs │ ├── checker.png │ ├── checker2.png │ └── 历史波动率分析使用简介.pdf ├── pics │ ├── gin_arch.pdf │ ├── gin_arch.png │ ├── gin_arch.svg │ └── logo.png ├── scripts │ ├── README.md │ ├── app.min.js.sh │ ├── bumpversion.sh │ ├── dist.sh │ ├── docker_run.sh │ ├── gen_apidocs.sh │ ├── new_project.sh │ └── pre-push.githook └── sqls │ ├── README.md │ └── export_struct.sh ├── models ├── exportor_data.go ├── fund.go ├── fund_test.go ├── global.go ├── models.go ├── models_test.go └── stock.go ├── release.sh ├── routes ├── about.go ├── comment.go ├── docs │ ├── docs.go │ ├── swagger.json │ └── swagger.yaml ├── fund.go ├── materials.go ├── ping.go ├── ping_test.go ├── query_fund_by_stock.go ├── register.go ├── register_test.go ├── response │ ├── README.md │ ├── errcode.go │ ├── errcode_test.go │ ├── response.go │ └── response_test.go ├── routes.go └── stock.go ├── statics ├── README.md ├── ads.txt ├── css │ ├── README.md │ ├── app.css │ └── materialize.min.css ├── favicon.ico ├── font │ └── exportor.ttf ├── html │ ├── README.md │ ├── about.html │ ├── base.html │ ├── checker_form.html │ ├── comment.html │ ├── fund_filter.html │ ├── fund_index.html │ ├── fund_managers.html │ ├── fund_similarity.html │ ├── fund_table.html │ ├── hold_stock_fund.html │ ├── materials.html │ ├── modal.html │ └── stock_index.html ├── img │ ├── README.md │ ├── alipay.jpg │ ├── sidenav_bg.png │ ├── sidenav_icon.png │ └── wxpay.jpg ├── js │ ├── README.md │ ├── app.js │ ├── clipboard.min.js │ ├── jquery-3.6.0.min.js │ ├── materialize.min.js │ └── tableExport.js ├── materials ├── robots.txt └── statics.go ├── version └── version.go └── webserver ├── README.md ├── gin.go ├── gin_middlewares.go ├── gin_templ_func_map.go ├── gin_test.go ├── prom.go ├── webserver.go └── webserver_test.go /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | tmp_dir = "tmp" 3 | 4 | [build] 5 | bin = "./tmp/main webserver -c config.prod.toml" 6 | cmd = "swag init --dir ./ --generalInfo routes/routes.go --propertyStrategy snakecase --output ./routes/docs && go build -o ./tmp/main ." 7 | delay = 1000 8 | exclude_dir = ["assets", "tmp", "vendor", "routes/docs"] 9 | exclude_file = [] 10 | exclude_regex = [] 11 | exclude_unchanged = false 12 | follow_symlink = false 13 | full_bin = "" 14 | include_dir = [] 15 | include_ext = ["go", "tpl", "tmpl", "html"] 16 | kill_delay = "0s" 17 | log = "build-errors.log" 18 | send_interrupt = false 19 | stop_on_error = true 20 | 21 | [color] 22 | app = "" 23 | build = "yellow" 24 | main = "magenta" 25 | runner = "green" 26 | watcher = "cyan" 27 | 28 | [log] 29 | time = false 30 | 31 | [misc] 32 | clean_on_exit = false 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | 10 | build: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 15 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: "1.20" # https://github.com/actions/setup-go/issues/326 20 | 21 | # - name: Test 22 | # run: go test -race ./... 23 | 24 | - name: Build 25 | run: CGO_ENABLED=0 GOOS=linux go build -ldflags "-X github.com/axiaoxin-com/investool/version.Version=`TZ=Asia/Shanghai date +'%y%m%d%H%M'`" -o app 26 | 27 | - name: Tar 28 | run: sed -i "s/env = \"localhost\"/env = \"prod\"/g" config.toml && tar czvf app.tar.gz app config.toml 29 | 30 | - name: SCP Files 31 | uses: appleboy/scp-action@master 32 | with: 33 | host: ${{ secrets.REMOTE_HOST }} 34 | username: ${{ secrets.REMOTE_USER }} 35 | port: ${{ secrets.REMOTE_PORT }} 36 | key: ${{ secrets.SERVER_SSH_KEY }} 37 | source: 'app.tar.gz' 38 | target: ${{ secrets.REMOTE_TARGET }} 39 | 40 | - name: SSH Remote Commands 41 | uses: appleboy/ssh-action@master 42 | with: 43 | host: ${{ secrets.REMOTE_HOST }} 44 | username: ${{ secrets.REMOTE_USER }} 45 | port: ${{ secrets.REMOTE_PORT }} 46 | key: ${{ secrets.SERVER_SSH_KEY }} 47 | script: ${{ secrets.AFTER_COMMAND }} 48 | -------------------------------------------------------------------------------- /.github/workflows/sync.yml: -------------------------------------------------------------------------------- 1 | name: sync 2 | 3 | on: 4 | schedule: 5 | - cron: '30 20 * * 1-5' 6 | 7 | jobs: 8 | sync: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 10 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | - name: Set up Go 15 | uses: actions/setup-go@v2 16 | with: 17 | go-version: "1.20" 18 | 19 | - name: Build 20 | run: CGO_ENABLED=0 go build -o app 21 | 22 | - name: Dump JSON Files 23 | run: ./app json -d 24 | 25 | - name: Tar JSON Files 26 | run: tar czvf jsons.tar.gz ./*.json 27 | 28 | - name: Scp JSON Files 29 | uses: appleboy/scp-action@master 30 | with: 31 | host: ${{ secrets.REMOTE_HOST }} 32 | username: ${{ secrets.REMOTE_USER }} 33 | port: ${{ secrets.REMOTE_PORT }} 34 | key: ${{ secrets.SERVER_SSH_KEY }} 35 | source: 'jsons.tar.gz' 36 | target: ${{ secrets.REMOTE_TARGET }} 37 | 38 | - name: SSH Remote Commands 39 | uses: appleboy/ssh-action@master 40 | with: 41 | host: ${{ secrets.REMOTE_HOST }} 42 | username: ${{ secrets.REMOTE_USER }} 43 | port: ${{ secrets.REMOTE_PORT }} 44 | key: ${{ secrets.SERVER_SSH_KEY }} 45 | script: cd ${{ secrets.REMOTE_TARGET }} && tar xzvf jsons.tar.gz 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | 15 | # custom 16 | .DS_Store 17 | dist/ 18 | *.log 19 | config.prod.toml 20 | tmp/ 21 | 22 | x-stock 23 | investool 24 | app 25 | *.tar.gz 26 | 27 | statics/js/app.min.*.js 28 | *.json 29 | cron/*.json 30 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sane defaults. 2 | # Make sure to check the documentation at http://goreleaser.com 3 | builds: 4 | - env: 5 | - CGO_ENABLED=0 6 | goos: 7 | - linux 8 | - windows 9 | - darwin 10 | flags: 11 | - -v 12 | 13 | archives: 14 | - replacements: 15 | darwin: Darwin 16 | linux: Linux 17 | windows: Windows 18 | 386: i386 19 | amd64: x86_64 20 | files: 21 | - config.toml 22 | checksum: 23 | name_template: 'checksums.txt' 24 | snapshot: 25 | name_template: "{{ .Tag }}-next" 26 | changelog: 27 | sort: asc 28 | filters: 29 | exclude: 30 | - '^docs:' 31 | - '^test:' 32 | env_files: 33 | github_token: ~/.config/goreleaser/github_token 34 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - "1.16.x" 5 | - "master" 6 | 7 | os: 8 | - linux 9 | 10 | go_import_path: x-stock 11 | 12 | env: 13 | - GO111MODULE=on 14 | 15 | services: 16 | - mysql 17 | - postgresql 18 | - redis 19 | 20 | before_install: 21 | - cd src 22 | 23 | install: 24 | - go mod tidy 25 | 26 | script: 27 | - go test -race -coverprofile=coverage.txt -covermode=atomic -v ./... 28 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at 254606826@qq.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | 3 | WORKDIR /srv/investool 4 | 5 | ADD ./dist/investool.tar.gz /srv/ 6 | 7 | EXPOSE 4869 4870 8 | ENTRYPOINT ["./investool", "-c", "./config.toml"] 9 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | CGO_ENABLED=0 go build -ldflags "-X github.com/axiaoxin-com/investool/version.Version=`TZ=Asia/Shanghai date +%y%m%d%H%M`" 2 | -------------------------------------------------------------------------------- /cmds/checker.go: -------------------------------------------------------------------------------- 1 | // 对给定股票名/股票代码进行检测 2 | 3 | package cmds 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "os" 9 | "strings" 10 | 11 | "github.com/axiaoxin-com/investool/core" 12 | "github.com/axiaoxin-com/logging" 13 | "github.com/olekukonko/tablewriter" 14 | ) 15 | 16 | // Check 对给定名称或代码进行检测,输出检测结果 17 | func Check(ctx context.Context, keywords []string, opts core.CheckerOptions) (results map[string]core.CheckResult, err error) { 18 | results = make(map[string]core.CheckResult) 19 | searcher := core.NewSearcher(ctx) 20 | stocks, err := searcher.SearchStocks(ctx, keywords) 21 | if err != nil { 22 | logging.Fatal(ctx, err.Error()) 23 | } 24 | 25 | for _, stock := range stocks { 26 | checker := core.NewChecker(ctx, opts) 27 | checkResult, ok := checker.CheckFundamentals(ctx, stock) 28 | k := fmt.Sprintf("%s-%s", stock.BaseInfo.SecurityNameAbbr, stock.BaseInfo.Secucode) 29 | results[k] = checkResult 30 | table := newTable() 31 | if !ok { 32 | renderTable(table, checkResult, []string{k, "FAILED"}) 33 | } else { 34 | renderTable(table, checkResult, []string{k, "OK"}) 35 | } 36 | } 37 | return results, nil 38 | } 39 | 40 | func newTable() *tablewriter.Table { 41 | table := tablewriter.NewWriter(os.Stdout) 42 | table.SetAlignment(tablewriter.ALIGN_LEFT) 43 | table.SetRowLine(true) 44 | headers := []string{"检测指标", "检测结果"} 45 | table.SetHeader(headers) 46 | table.SetHeaderColor( 47 | tablewriter.Colors{tablewriter.Bold, tablewriter.BgBlackColor}, 48 | tablewriter.Colors{tablewriter.Bold, tablewriter.BgBlackColor}, 49 | ) 50 | return table 51 | } 52 | 53 | func renderTable(table *tablewriter.Table, checkResult core.CheckResult, footers []string) { 54 | footerValColor := tablewriter.FgRedColor 55 | if footers[1] == "OK" { 56 | footerValColor = tablewriter.FgGreenColor 57 | } 58 | table.SetFooter(footers) 59 | table.SetFooterColor( 60 | tablewriter.Colors{tablewriter.Bold, footerValColor}, 61 | tablewriter.Colors{tablewriter.Bold, footerValColor}, 62 | ) 63 | for k, m := range checkResult { 64 | row := []string{k, strings.ReplaceAll(m["desc"], "
", "\n")} 65 | 66 | if m["ok"] == "false" { 67 | table.Rich( 68 | row, 69 | []tablewriter.Colors{{tablewriter.Bold, tablewriter.BgRedColor}, {tablewriter.Bold, tablewriter.BgRedColor}}, 70 | ) 71 | } else { 72 | table.Append(row) 73 | } 74 | } 75 | table.Render() 76 | } 77 | -------------------------------------------------------------------------------- /cmds/cmds_test.go: -------------------------------------------------------------------------------- 1 | package cmds 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | var ( 8 | _ctx = context.TODO() 9 | ) 10 | -------------------------------------------------------------------------------- /cmds/exportor.go: -------------------------------------------------------------------------------- 1 | // 导出各类型的数据结果 2 | 3 | package cmds 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "os" 9 | "path" 10 | "strings" 11 | "time" 12 | 13 | "github.com/axiaoxin-com/investool/core" 14 | "github.com/axiaoxin-com/investool/models" 15 | "github.com/axiaoxin-com/logging" 16 | ) 17 | 18 | // Exportor exportor 实例 19 | type Exportor struct { 20 | Stocks models.ExportorDataList 21 | Selector core.Selector 22 | } 23 | 24 | // New 创建要导出的数据列表 25 | func New(ctx context.Context, stocks models.StockList, selector core.Selector) Exportor { 26 | dlist := models.ExportorDataList{} 27 | for _, s := range stocks { 28 | dlist = append(dlist, models.NewExportorData(ctx, s)) 29 | } 30 | 31 | return Exportor{ 32 | Stocks: dlist, 33 | Selector: selector, 34 | } 35 | } 36 | 37 | // Export 导出数据 38 | func Export(ctx context.Context, exportFilename string, selector core.Selector) { 39 | beginTime := time.Now() 40 | filedir := path.Dir(exportFilename) 41 | fileext := strings.ToLower(path.Ext(exportFilename)) 42 | exportType := "excel" 43 | switch fileext { 44 | case ".json": 45 | exportType = "json" 46 | case ".csv", ".txt": 47 | exportType = "csv" 48 | case ".xlsx", ".xls": 49 | exportType = "excel" 50 | case ".png", ".jpg", ".jpeg", ".pic": 51 | exportType = "pic" 52 | case ".all": 53 | exportType = "all" 54 | } 55 | if _, err := os.Stat(filedir); os.IsNotExist(err) { 56 | os.Mkdir(filedir, 0755) 57 | } 58 | 59 | logging.Infof(ctx, "investool exportor start export selected stocks to %s", exportFilename) 60 | var err error 61 | // 自动筛选股票 62 | stocks, err := selector.AutoFilterStocks(ctx) 63 | if err != nil { 64 | logging.Fatal(ctx, err.Error()) 65 | } 66 | e := New(ctx, stocks, selector) 67 | 68 | switch exportType { 69 | case "json": 70 | _, err = e.ExportJSON(ctx, exportFilename) 71 | case "csv": 72 | _, err = e.ExportCSV(ctx, exportFilename) 73 | case "excel": 74 | _, err = e.ExportExcel(ctx, exportFilename) 75 | case "pic": 76 | _, err = e.ExportPic(ctx, exportFilename) 77 | case "all": 78 | jsonFilename := strings.ReplaceAll(exportFilename, ".all", ".json") 79 | _, err = e.ExportJSON(ctx, jsonFilename) 80 | csvFilename := strings.ReplaceAll(exportFilename, ".all", ".csv") 81 | _, err = e.ExportCSV(ctx, csvFilename) 82 | xlsxFilename := strings.ReplaceAll(exportFilename, ".all", ".xlsx") 83 | _, err = e.ExportExcel(ctx, xlsxFilename) 84 | pngFilename := strings.ReplaceAll(exportFilename, ".all", ".png") 85 | _, err = e.ExportPic(ctx, pngFilename) 86 | } 87 | if err != nil { 88 | logging.Fatal(ctx, err.Error()) 89 | } 90 | 91 | fmt.Printf( 92 | "\ninvestool exportor export %s succuss, total:%d latency:%#vs\n", 93 | exportType, 94 | len(stocks), 95 | time.Now().Sub(beginTime).Seconds(), 96 | ) 97 | } 98 | -------------------------------------------------------------------------------- /cmds/exportor_csv.go: -------------------------------------------------------------------------------- 1 | // 导出 csv 2 | 3 | package cmds 4 | 5 | import ( 6 | "context" 7 | "io/ioutil" 8 | 9 | "github.com/gocarina/gocsv" 10 | ) 11 | 12 | // ExportCSV 数据导出为 CSV 13 | // 不传文件名则返回 []bytes,传文件名则保存到文件 14 | func (e Exportor) ExportCSV(ctx context.Context, filename string) (result []byte, err error) { 15 | result, err = gocsv.MarshalBytes(&e.Stocks) 16 | 17 | if filename != "" { 18 | err = ioutil.WriteFile(filename, result, 0666) 19 | } 20 | return 21 | } 22 | -------------------------------------------------------------------------------- /cmds/exportor_excel_test.go: -------------------------------------------------------------------------------- 1 | package cmds 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/axiaoxin-com/investool/models" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestExportExcel(t *testing.T) { 11 | e := Exportor{ 12 | Stocks: []models.ExportorData{ 13 | { 14 | Name: "中文名称", 15 | Code: "1234code", 16 | }, { 17 | Name: "中文名称1", 18 | Code: "code12345", 19 | }, 20 | }, 21 | } 22 | 23 | _, err := e.ExportExcel(_ctx, "/tmp/test.xlsx") 24 | require.Nil(t, err) 25 | } 26 | -------------------------------------------------------------------------------- /cmds/exportor_json.go: -------------------------------------------------------------------------------- 1 | // 导出 json 文件 2 | 3 | package cmds 4 | 5 | import ( 6 | "context" 7 | "encoding/json" 8 | "io/ioutil" 9 | ) 10 | 11 | // ExportJSON 数据导出为 JSON 文件 12 | // 不传文件名则返回 []bytes,传文件名则保存到文件 13 | func (e Exportor) ExportJSON(ctx context.Context, filename string) (result []byte, err error) { 14 | result, err = json.MarshalIndent(e.Stocks, "", " ") 15 | if filename != "" { 16 | err = ioutil.WriteFile(filename, result, 0666) 17 | } 18 | return 19 | } 20 | -------------------------------------------------------------------------------- /cmds/exportor_pic.go: -------------------------------------------------------------------------------- 1 | // 导出股票名称+代码图片 2 | 3 | package cmds 4 | 5 | import ( 6 | "bytes" 7 | "context" 8 | "fmt" 9 | "image" 10 | "image/draw" 11 | "image/png" 12 | "io/ioutil" 13 | "path/filepath" 14 | "strings" 15 | 16 | "github.com/axiaoxin-com/investool/statics" 17 | "github.com/axiaoxin-com/logging" 18 | "github.com/golang/freetype" 19 | "github.com/pkg/errors" 20 | "golang.org/x/image/font" 21 | ) 22 | 23 | // PicChuckSize 每张图片最多展示股票数 24 | var PicChuckSize = 15 25 | 26 | // ExportPic 导出股票名称+代码图片,一张图片最多 PicChuckSize 个,超过则导出多张图片 27 | func (e Exportor) ExportPic(ctx context.Context, filename string) (result []byte, err error) { 28 | bgColor, fgColor := image.White, image.Black 29 | // set font 30 | fontBytes, err := statics.Files.ReadFile("font/exportor.ttf") 31 | if err != nil { 32 | err = errors.Wrap(err, "read embed file error") 33 | return 34 | } 35 | ffont, err := freetype.ParseFont(fontBytes) 36 | if err != nil { 37 | err = errors.Wrap(err, "parse font error") 38 | return 39 | } 40 | fontSize := float64(15) 41 | fc := freetype.NewContext() 42 | fc.SetFont(ffont) 43 | fc.SetFontSize(fontSize) 44 | fc.SetSrc(fgColor) 45 | fc.SetDPI(300) 46 | fc.SetHinting(font.HintingNone) 47 | 48 | // 按分组写入股票名称+代码到不同图片 49 | num := 1 50 | for i, stocks := range e.Stocks.ChunkedBySize(PicChuckSize) { 51 | // 设置图片大小 52 | height := 64*len(stocks) + 64 53 | width := 600 54 | 55 | leftTop := image.Point{0, 0} 56 | rightBottom := image.Point{width, height} 57 | 58 | img := image.NewRGBA(image.Rectangle{leftTop, rightBottom}) 59 | draw.Draw(img, img.Bounds(), bgColor, image.ZP, draw.Src) 60 | fc.SetDst(img) 61 | fc.SetClip(img.Bounds()) 62 | 63 | // 写入图片 64 | for j, stock := range stocks { 65 | pt := freetype.Pt(40, (j+1)*int(fc.PointToFixed(fontSize)>>6)+40) 66 | line := fmt.Sprintf("%s %s", strings.Split(stock.Code, ".")[0], stock.Name) 67 | _, err = fc.DrawString(line, pt) 68 | if err != nil { 69 | logging.Errorf(ctx, "draw %s error: %s", line, err.Error()) 70 | continue 71 | } 72 | num++ 73 | } 74 | 75 | // 生成图片 76 | picbuff := new(bytes.Buffer) 77 | err = png.Encode(picbuff, img) 78 | if err != nil { 79 | err = errors.Wrap(err, "png encode error") 80 | return 81 | } 82 | result = picbuff.Bytes() 83 | oriFilename := filename 84 | if i > 0 { 85 | dir, base := filepath.Split(filename) 86 | ext := filepath.Ext(base) 87 | fn := strings.TrimSuffix(base, ext) 88 | 89 | base = fmt.Sprint(fn, "_", i, ext) 90 | filename = filepath.Join(dir, base) 91 | } 92 | err = ioutil.WriteFile(filename, result, 0666) 93 | err = errors.Wrap(err, "write file error") 94 | filename = oriFilename 95 | } 96 | return 97 | } 98 | -------------------------------------------------------------------------------- /cmds/exportor_pic_test.go: -------------------------------------------------------------------------------- 1 | package cmds 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/axiaoxin-com/investool/models" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestExportPic(t *testing.T) { 11 | e := Exportor{ 12 | Stocks: []models.ExportorData{ 13 | { 14 | Name: "中文名称", 15 | Code: "1234code", 16 | }, { 17 | Name: "中文名称1", 18 | Code: "code12345", 19 | }, 20 | }, 21 | } 22 | 23 | PicChuckSize = 1 24 | _, err := e.ExportPic(_ctx, "/tmp/test.png") 25 | require.Nil(t, err) 26 | } 27 | -------------------------------------------------------------------------------- /cmds/index.go: -------------------------------------------------------------------------------- 1 | package cmds 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/axiaoxin-com/investool/datacenter/eastmoney" 9 | "github.com/axiaoxin-com/logging" 10 | "github.com/olekukonko/tablewriter" 11 | ) 12 | 13 | func showIndexData(data *eastmoney.IndexData) { 14 | table := tablewriter.NewWriter(os.Stdout) 15 | table.SetAlignment(tablewriter.ALIGN_LEFT) 16 | table.SetRowSeparator("") 17 | table.SetBorder(false) 18 | table.SetNoWhiteSpace(true) 19 | headers := []string{} 20 | table.SetHeader(headers) 21 | table.SetCaption(true, data.IndexCode+"指数信息") 22 | rows := [][]string{ 23 | {"指数名称", data.FullIndexName}, 24 | {"指数说明", data.Reaprofile}, 25 | {"编制方", data.MakerName}, 26 | {"估值", data.IndexValueCN()}, 27 | {"估值PE值", data.Petim}, 28 | {"估值PE百分位", data.Pep100}, 29 | {"指数代码", data.IndexCode}, 30 | {"板块名称", data.BKName}, 31 | {"当前点数", data.NewPrice}, 32 | {"最新涨幅", data.NewCHG}, 33 | {"最近一周涨幅", data.W}, 34 | {"最近一月涨幅", data.M}, 35 | {"最近三月涨幅", data.Q}, 36 | {"最近六月涨幅", data.Hy}, 37 | {"最近一年涨幅", data.Y}, 38 | {"最近两年涨幅", data.Twy}, 39 | {"最近三年涨幅", data.Try}, 40 | {"最近五年涨幅", data.Fy}, 41 | {"今年来涨幅", data.Sy}, 42 | } 43 | table.AppendBulk(rows) 44 | 45 | table.Render() 46 | } 47 | 48 | func showIndexStocks(stocks []eastmoney.ZSCFGItem) { 49 | table := tablewriter.NewWriter(os.Stdout) 50 | table.SetAlignment(tablewriter.ALIGN_LEFT) 51 | table.SetRowLine(true) 52 | headers := []string{"股票名称", "股票代码", "持仓占比"} 53 | table.SetHeader(headers) 54 | 55 | sum := 0.0 56 | for _, stock := range stocks { 57 | row := []string{stock.StockName, stock.StockCode, stock.Marketcappct} 58 | table.Append(row) 59 | if stock.Marketcappct != "" && stock.Marketcappct != "--" { 60 | v, err := strconv.ParseFloat(stock.Marketcappct, 64) 61 | if err != nil { 62 | logging.Error(nil, err.Error()) 63 | continue 64 | } 65 | sum += v 66 | } 67 | } 68 | 69 | if len(stocks) > 0 { 70 | table.SetCaption(true, stocks[0].IndexName+"成分股") 71 | } 72 | footers := []string{fmt.Sprintf("总数:%d", len(stocks)), "--", fmt.Sprintf("占比求和:%.2f", sum)} 73 | table.SetFooter(footers) 74 | table.Render() 75 | } 76 | 77 | func showIntersecStocks(stocks1, stocks2 []eastmoney.ZSCFGItem) { 78 | table := tablewriter.NewWriter(os.Stdout) 79 | table.SetAlignment(tablewriter.ALIGN_LEFT) 80 | table.SetRowLine(true) 81 | headers := []string{"股票名称-代码"} 82 | table.SetHeader(headers) 83 | 84 | counter := map[string]int{} 85 | for _, stock := range stocks1 { 86 | key := fmt.Sprintf("%v-%v", stock.StockName, stock.StockCode) 87 | counter[key]++ 88 | } 89 | 90 | for _, stock := range stocks2 { 91 | key := fmt.Sprintf("%v-%v", stock.StockName, stock.StockCode) 92 | counter[key]++ 93 | } 94 | 95 | intersecCount := 0 96 | for k, v := range counter { 97 | if v == 2 { 98 | table.Append([]string{k}) 99 | intersecCount++ 100 | } 101 | } 102 | 103 | if intersecCount > 0 { 104 | table.SetCaption(true, fmt.Sprintf("%s∩%s 成分股交集", stocks1[0].IndexName, stocks2[0].IndexName)) 105 | } 106 | footers := []string{fmt.Sprintf("交集总数:%d", intersecCount)} 107 | table.SetFooter(footers) 108 | table.Render() 109 | } 110 | -------------------------------------------------------------------------------- /cmds/index_cmd.go: -------------------------------------------------------------------------------- 1 | // 指数数据 2 | 3 | package cmds 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | 9 | "github.com/axiaoxin-com/investool/datacenter" 10 | "github.com/urfave/cli/v2" 11 | ) 12 | 13 | const ( 14 | // ProcessorIndex 指数数据处理 15 | ProcessorIndex = "index" 16 | ) 17 | 18 | // FlagsIndex cli flags 19 | func FlagsIndex() []cli.Flag { 20 | return []cli.Flag{ 21 | &cli.StringFlag{ 22 | Name: "code", 23 | Aliases: []string{"c"}, 24 | Value: "", 25 | Usage: "指定指数代码", 26 | Required: true, 27 | }, 28 | &cli.BoolFlag{ 29 | Name: "desc", 30 | Aliases: []string{"d"}, 31 | Value: false, 32 | Usage: "返回指数信息", 33 | Required: false, 34 | }, 35 | &cli.BoolFlag{ 36 | Name: "stocks", 37 | Aliases: []string{"s"}, 38 | Value: false, 39 | Usage: "返回指数成分股", 40 | Required: false, 41 | }, 42 | &cli.StringFlag{ 43 | Name: "intersec", 44 | Aliases: []string{"i"}, 45 | Value: "", 46 | Usage: "返回成分股交集", 47 | Required: false, 48 | }, 49 | } 50 | } 51 | 52 | // ActionIndex cli action 53 | func ActionIndex() func(c *cli.Context) error { 54 | return func(c *cli.Context) error { 55 | ctx := context.Background() 56 | indexCode := c.String("code") 57 | 58 | showDesc := c.Bool("desc") 59 | if showDesc { 60 | indexData, err := datacenter.EastMoney.Index(ctx, indexCode) 61 | if err != nil { 62 | fmt.Println(err) 63 | } 64 | showIndexData(indexData) 65 | } 66 | 67 | showStocks := c.Bool("stocks") 68 | if showStocks { 69 | stocks, err := datacenter.EastMoney.ZSCFG(ctx, indexCode) 70 | if err != nil { 71 | return err 72 | } 73 | showIndexStocks(stocks) 74 | } 75 | 76 | intersecIndexCode := c.String("intersec") 77 | if intersecIndexCode != "" { 78 | stocks1, err := datacenter.EastMoney.ZSCFG(ctx, indexCode) 79 | if err != nil { 80 | return err 81 | } 82 | stocks2, err := datacenter.EastMoney.ZSCFG(ctx, intersecIndexCode) 83 | if err != nil { 84 | return err 85 | } 86 | showIntersecStocks(stocks1, stocks2) 87 | } 88 | return nil 89 | } 90 | } 91 | 92 | // CommandIndex 指数成分股 cli command 93 | func CommandIndex() *cli.Command { 94 | flags := FlagsIndex() 95 | cmd := &cli.Command{ 96 | Name: ProcessorIndex, 97 | Usage: "指数数据", 98 | Flags: flags, 99 | Action: ActionIndex(), 100 | } 101 | return cmd 102 | } 103 | -------------------------------------------------------------------------------- /cmds/json_dump_cmd.go: -------------------------------------------------------------------------------- 1 | package cmds 2 | 3 | import ( 4 | "github.com/axiaoxin-com/investool/cron" 5 | "github.com/axiaoxin-com/logging" 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | const ( 10 | // ProcessorJSON 导出json数据文件 11 | ProcessorJSON = "json" 12 | ) 13 | 14 | // FlagsJSON cli flags 15 | func FlagsJSON() []cli.Flag { 16 | return []cli.Flag{ 17 | &cli.BoolFlag{ 18 | Name: "dump", 19 | Aliases: []string{"d"}, 20 | Usage: "导出json数据文件", 21 | }, 22 | } 23 | } 24 | 25 | // ActionJSON dump json files 26 | func ActionJSON() func(c *cli.Context) error { 27 | return func(c *cli.Context) error { 28 | loglevel := c.String("loglevel") 29 | logging.SetLevel(loglevel) 30 | 31 | if c.Bool("d") { 32 | cron.SyncFund() 33 | cron.SyncFundManagers() 34 | cron.SyncIndustryList() 35 | return nil 36 | } 37 | return nil 38 | } 39 | } 40 | 41 | // CommandJSON dump json files cmd 42 | func CommandJSON() *cli.Command { 43 | flags := FlagsJSON() 44 | cmd := &cli.Command{ 45 | Name: ProcessorJSON, 46 | Usage: "JSON数据", 47 | Flags: flags, 48 | Action: ActionJSON(), 49 | } 50 | return cmd 51 | } 52 | -------------------------------------------------------------------------------- /cmds/webserver_cmd.go: -------------------------------------------------------------------------------- 1 | // web 服务 2 | 3 | package cmds 4 | 5 | import ( 6 | "github.com/axiaoxin-com/investool/cron" 7 | "github.com/axiaoxin-com/investool/routes" 8 | "github.com/axiaoxin-com/investool/routes/response" 9 | "github.com/axiaoxin-com/investool/webserver" 10 | "github.com/gin-gonic/gin" 11 | "github.com/spf13/viper" 12 | "github.com/urfave/cli/v2" 13 | ) 14 | 15 | const ( 16 | // ProcessorWebserver web 服务 17 | ProcessorWebserver = "webserver" 18 | ) 19 | 20 | // FlagsWebserver cli flags 21 | func FlagsWebserver() []cli.Flag { 22 | return []cli.Flag{ 23 | &cli.StringFlag{ 24 | Name: "config", 25 | Aliases: []string{"c"}, 26 | Value: "./config.toml", 27 | Usage: "配置文件", 28 | Required: false, 29 | }, 30 | } 31 | } 32 | 33 | // DefaultGinMiddlewares 默认的 gin server 使用的中间件列表 34 | func DefaultGinMiddlewares() []gin.HandlerFunc { 35 | m := []gin.HandlerFunc{ 36 | // 记录请求处理日志,最顶层执行 37 | webserver.GinLogMiddleware(), 38 | // 捕获 panic 保存到 context 中由 GinLogger 统一打印, panic 时返回 500 JSON 39 | webserver.GinRecovery(response.Respond), 40 | } 41 | 42 | // 配置开启请求限频则添加限频中间件 43 | if viper.GetBool("ratelimiter.enable") { 44 | m = append(m, webserver.GinRatelimitMiddleware()) 45 | } 46 | return m 47 | } 48 | 49 | // ActionWebserver cli action 50 | func ActionWebserver() func(c *cli.Context) error { 51 | return func(c *cli.Context) error { 52 | configFile := c.String("config") 53 | webserver.InitWithConfigFile(configFile) 54 | 55 | // 启动定时任务 56 | cron.RunCronJobs(true) 57 | // 创建 gin app 58 | middlewares := DefaultGinMiddlewares() 59 | server := webserver.NewGinEngine(middlewares...) 60 | // 注册路由 61 | routes.Register(server) 62 | // 运行服务 63 | webserver.Run(server) 64 | return nil 65 | } 66 | } 67 | 68 | // CommandWebserver 检测器 cli command 69 | func CommandWebserver() *cli.Command { 70 | flags := FlagsWebserver() 71 | cmd := &cli.Command{ 72 | Name: ProcessorWebserver, 73 | Usage: "web服务器", 74 | Flags: flags, 75 | Action: ActionWebserver(), 76 | } 77 | return cmd 78 | } 79 | -------------------------------------------------------------------------------- /config.toml: -------------------------------------------------------------------------------- 1 | ############################# 2 | # # 3 | # viper web server 配置文件 # 4 | # # 5 | ############################# 6 | 7 | 8 | 9 | ########## 部署环境标志 10 | # 该值关联影响其他配置,如数据库, redis 等涉及不同环境的配置 11 | # 如 配置为 localhost , 使用 goutils 获取 mysql 相关实例时将使用 [mysql.localhost] 的配置 12 | env = "localhost" 13 | 14 | 15 | ########## 业务相关配置 16 | [app] 17 | # 并发拉取数据时channel的大小 18 | chan_size = 1 19 | 20 | [app.cronexp] 21 | # sync_fund = "0 6 * * 1-5" 22 | # sync_fund_managers = "0 5 * * 1-5" 23 | # sync_industry_list = "0 4 * * 1-5" 24 | sync_global_vars = "0 6 * * 1-5" 25 | 26 | 27 | ########## server 相关配置 28 | [server] 29 | # server 运行地址,支持 HTTP 端口 ":port" 或 UNIX Socket "unix:/file" 30 | addr = ":4869" 31 | # gin mode ,可选值: debug 、 test 、 release 32 | mode = "debug" 33 | # 开启 pprof 34 | pprof = true 35 | # 开启 prometheus metrics 36 | metrics = true 37 | host_url = "" 38 | 39 | 40 | 41 | ########## 静态文件相关配置 42 | [statics] 43 | # 网页模板路径 44 | tmpl_path = "html/*" 45 | # 静态文件 URL 路径,网页中通过 /statics/js/xx.js 访问资源, 46 | url = "/statics" 47 | 48 | 49 | 50 | ########## token bucket 请求频率限制配置 51 | [ratelimiter] 52 | # 是否开启 ratelimiter 请求频率限制 53 | enable = true 54 | # 限频方式: mem->进程内存; redis.->使用用配置文件中对应的 redis 配置,如 redis.localhost 55 | type = "mem" 56 | 57 | 58 | 59 | ########## 日志相关配置 60 | [logging] 61 | # 日志级别,可选值: debug info warn error dpanic panic fatal 62 | level = "info" 63 | # 日志格式,可选值: json console 64 | format = "json" 65 | # 日志输出路径: stdout, stderr, logrotate:///path/to/logfile 66 | output_paths = ["stdout"] 67 | # 是否关闭打印 caller 字段 68 | disable_caller = false 69 | # 是否关闭打印 stacktrace 字段 70 | disable_stacktrace = true 71 | 72 | ## 动态修改日志级别 http 服务配置 73 | [logging.atomic_level_server] 74 | # http 服务端口 75 | addr = ":4870" 76 | # 接口 url path 77 | path = "/" 78 | 79 | ## 访问日志相关配置 80 | [logging.access_logger] 81 | # 打印更多访问信息字段 82 | enable_details = false 83 | # 打印 context 中的 keys 信息,慎用,推荐仅开发调试使用 84 | enable_context_keys = false 85 | # 打印请求 Header ,慎用,推荐仅开发调试使用 86 | enable_request_header = false 87 | # 打印请求的表单信息,慎用,推荐仅开发调试使用 88 | enable_request_form = false 89 | # 打印请求 body ,慎用,严重影响性能 90 | enable_request_body = false 91 | # 打印响应 body ,慎用,严重影响性能 92 | enable_response_body = false 93 | # 精确指定不打印日志的 path 94 | skip_paths = [] 95 | # 正则表达式指定不打印日志的 path 96 | skip_path_regexps = [ 97 | "/x/apidocs/.+\\.json", 98 | "/x/apidocs/.+\\.js", 99 | "/x/apidocs/.+\\.css", 100 | ".+\\.js", 101 | ".+\\.css", 102 | ".+\\.png", 103 | ] 104 | # 慢请求阈值(毫秒)请求处理时间大于该值使用 WARN 级别打印请求日志 105 | slow_threshold = 200 106 | 107 | ## 日志输出到文件时的 rotate 配置 108 | # 示例: 若 logging.output_paths 配置为: logrotate:///tmp/x.log 109 | # 日志会输出到文件 /tmp/x.log 并按以下策略进行 rotate 110 | [logging.logrotate] 111 | # 备份文件最大保存天数 112 | max_age = 30 113 | # 最大保存的备份文件数 114 | max_backups = 10 115 | # 最大日志文件大小 单位: M 116 | max_size = 100 117 | # 是否压缩备份文件 118 | compress = true 119 | # 压缩文件名是否使用 localtime 120 | localtime = true 121 | 122 | 123 | 124 | ########## apidocs 相关配置 125 | [apidocs] 126 | # 文档标题 127 | title = "investool swagger apidocs" 128 | # 文档描述 129 | desc = "Using investool to develop gin app on fly." 130 | # 请求地址,端口要和 server.addr 一致,浏览器访问时需要区分 127.0.0.1 和 localhost 131 | host = "localhost:4869" 132 | # 请求地址的 basepath 133 | basepath = "/" 134 | # 支持的请求 schemes 135 | schemes = ["http"] 136 | 137 | 138 | 139 | ########## basic auth 相关配置 140 | [basic_auth] 141 | # 登录用户名 142 | username = "admin" 143 | # 登录密码 144 | password = "admin" 145 | -------------------------------------------------------------------------------- /core/checker_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/axiaoxin-com/investool/models" 9 | "github.com/axiaoxin-com/logging" 10 | "github.com/spf13/viper" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestCheckFundamentals(t *testing.T) { 15 | stock := models.Stock{} 16 | logging.SetLevel("error") 17 | c := NewChecker(_ctx, DefaultCheckerOptions) 18 | result, ok := c.CheckFundamentals(_ctx, stock) 19 | t.Log(ok, result) 20 | } 21 | 22 | func _TestGetFundStocksSimilarity(t *testing.T) { 23 | viper.SetDefault("app.chan_size", 500) 24 | c := NewChecker(_ctx, DefaultCheckerOptions) 25 | codes := []string{ 26 | // 4433 27 | "270028", 28 | "377530", 29 | "550009", 30 | "210003", 31 | "002160", 32 | "519644", 33 | "166301", 34 | "001365", 35 | "519133", 36 | "519642", 37 | "000073", 38 | "001808", 39 | "001279", 40 | "001397", 41 | "000592", 42 | "001975", 43 | "163807", 44 | "001869", 45 | // manager 46 | "001938", 47 | "008314", 48 | "001679", 49 | "163406", 50 | "162605", 51 | } 52 | 53 | sims, err := c.GetFundStocksSimilarity(_ctx, codes) 54 | require.Nil(t, err) 55 | 56 | for _, s := range sims { 57 | if len(s.SameStocks) < 4 { 58 | continue 59 | } 60 | sim, _ := json.MarshalIndent(s, "", " ") 61 | fmt.Println(string(sim)) 62 | fmt.Println("---------------") 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /core/core.go: -------------------------------------------------------------------------------- 1 | // Package core 核心逻辑实现 2 | package core 3 | -------------------------------------------------------------------------------- /core/core_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import "context" 4 | 5 | var ( 6 | _ctx = context.TODO() 7 | ) 8 | -------------------------------------------------------------------------------- /core/searcher_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/axiaoxin-com/logging" 7 | "github.com/spf13/viper" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestSearchStocks(t *testing.T) { 12 | logging.SetLevel("info") 13 | s := NewSearcher(_ctx) 14 | k := []string{"招商银行", "贵州茅台", "600038"} 15 | results, err := s.SearchStocks(_ctx, k) 16 | require.Nil(t, err) 17 | require.Len(t, results, 3) 18 | } 19 | 20 | func TestSearchFunds(t *testing.T) { 21 | viper.SetDefault("app.chan_size", 500) 22 | s := NewSearcher(_ctx) 23 | data, err := s.SearchFunds(_ctx, []string{"007135", "000209"}) 24 | require.Nil(t, err) 25 | require.NotEmpty(t, data) 26 | t.Log("data:", data["000209"]) 27 | } 28 | 29 | func TestSearchFundByStock(t *testing.T) { 30 | viper.SetDefault("app.chan_size", 500) 31 | s := NewSearcher(_ctx) 32 | result, err := s.SearchFundByStock(_ctx, "金域医学") 33 | require.Nil(t, err) 34 | require.NotEmpty(t, result) 35 | t.Log("result:", result) 36 | } 37 | -------------------------------------------------------------------------------- /core/selector.go: -------------------------------------------------------------------------------- 1 | // selector 选股器,自动按条件筛选优质公司。(好公司,但不代表当前股价在涨) 2 | // 筛选规则: 3 | // 行业要分散 4 | // 最新 ROE 高于 8% 5 | // ROE 平均值小于 20 时,至少 3 年内逐年递增 6 | // EPS 至少 3 年内逐年递增 7 | // 营业总收入至少 3 年内逐年递增 8 | // 净利润至少 3 年内逐年递增 9 | // 估值较低或中等 10 | // 股价低于合理价格 11 | // 负债率低于 60% 12 | 13 | package core 14 | 15 | import ( 16 | "context" 17 | "fmt" 18 | "math" 19 | "sync" 20 | 21 | "github.com/axiaoxin-com/investool/datacenter" 22 | "github.com/axiaoxin-com/investool/datacenter/eastmoney" 23 | "github.com/axiaoxin-com/investool/models" 24 | "github.com/axiaoxin-com/logging" 25 | "github.com/spf13/viper" 26 | "go.uber.org/zap" 27 | ) 28 | 29 | // Selector 选股器 30 | type Selector struct { 31 | Filter eastmoney.Filter 32 | Checker *Checker 33 | } 34 | 35 | // NewSelector 创建选股器 36 | func NewSelector(ctx context.Context, filter eastmoney.Filter, checker *Checker) Selector { 37 | return Selector{ 38 | Filter: filter, 39 | Checker: checker, 40 | } 41 | } 42 | 43 | // AutoFilterStocks 按默认设置自动筛选股票 44 | func (s Selector) AutoFilterStocks(ctx context.Context) (result models.StockList, err error) { 45 | stocks, err := datacenter.EastMoney.QuerySelectedStocksWithFilter(ctx, s.Filter) 46 | if err != nil { 47 | return 48 | } 49 | logging.Infof(ctx, "AutoFilterStocks will filter from %d stocks by %s", len(stocks), s.Filter.String()) 50 | if len(stocks) == 0 { 51 | return 52 | } 53 | 54 | // 并发执行筛选任务 55 | workerCount := int(math.Min(float64(len(stocks)), float64(viper.GetFloat64("app.chan_size")))) 56 | jobChan := make(chan struct{}, workerCount) 57 | wg := sync.WaitGroup{} 58 | var mu sync.Mutex 59 | 60 | for _, baseInfo := range stocks { 61 | wg.Add(1) 62 | jobChan <- struct{}{} 63 | 64 | go func(ctx context.Context, baseInfo eastmoney.StockInfo) { 65 | defer func() { 66 | wg.Done() 67 | <-jobChan 68 | if r := recover(); r != nil { 69 | logging.Errorf(ctx, "recover from:%v", r) 70 | } 71 | }() 72 | 73 | stock, err := models.NewStock(ctx, baseInfo) 74 | if err != nil { 75 | logging.Error(ctx, "NewStock error:"+err.Error()) 76 | return 77 | } 78 | if s.Checker == nil { 79 | mu.Lock() 80 | result = append(result, stock) 81 | mu.Unlock() 82 | } else { 83 | // 检测是否为优质股票 84 | if details, ok := s.Checker.CheckFundamentals(ctx, stock); ok { 85 | mu.Lock() 86 | result = append(result, stock) 87 | mu.Unlock() 88 | } else { 89 | logging.Debug(ctx, fmt.Sprintf("%s %s has some defects", stock.BaseInfo.SecurityNameAbbr, stock.BaseInfo.Secucode), zap.Any("details", details)) 90 | } 91 | } 92 | }(ctx, baseInfo) 93 | } 94 | wg.Wait() 95 | logging.Infof(ctx, "AutoFilterStocks selected %d stocks", len(result)) 96 | result.SortByROE() 97 | return 98 | } 99 | -------------------------------------------------------------------------------- /core/selector_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/axiaoxin-com/investool/datacenter/eastmoney" 7 | "github.com/axiaoxin-com/logging" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestAutoFilterStocks(t *testing.T) { 12 | logging.SetLevel("error") 13 | checker := NewChecker(_ctx, DefaultCheckerOptions) 14 | s := NewSelector(_ctx, eastmoney.DefaultFilter, checker) 15 | _, err := s.AutoFilterStocks(_ctx) 16 | require.Nil(t, err) 17 | // t.Log(result) 18 | } 19 | -------------------------------------------------------------------------------- /cron/bond.go: -------------------------------------------------------------------------------- 1 | // Package cron 定时任务 2 | package cron 3 | 4 | import ( 5 | "context" 6 | 7 | "github.com/axiaoxin-com/goutils" 8 | "github.com/axiaoxin-com/investool/datacenter" 9 | "github.com/axiaoxin-com/investool/models" 10 | ) 11 | 12 | // SyncBond 同步债券 13 | func SyncBond() { 14 | if !goutils.IsTradingDay() { 15 | return 16 | } 17 | ctx := context.Background() 18 | syl := datacenter.ChinaBond.QueryAAACompanyBondSyl(ctx) 19 | if syl != 0 { 20 | models.AAACompanyBondSyl = syl 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /cron/cron.go: -------------------------------------------------------------------------------- 1 | // Package cron 定时任务 2 | package cron 3 | 4 | import ( 5 | "time" 6 | 7 | "github.com/axiaoxin-com/investool/models" 8 | "github.com/axiaoxin-com/logging" 9 | "github.com/go-co-op/gocron" 10 | "github.com/prometheus/client_golang/prometheus" 11 | "github.com/prometheus/client_golang/prometheus/promauto" 12 | "github.com/spf13/viper" 13 | ) 14 | 15 | var ( 16 | promSyncLabels = []string{ 17 | "jobname", 18 | } 19 | promSyncError = promauto.NewCounterVec( 20 | prometheus.CounterOpts{ 21 | Namespace: "cron", 22 | Name: "sync_error", 23 | Help: "cron sync job error", 24 | }, promSyncLabels, 25 | ) 26 | ) 27 | 28 | // RunCronJobs 启动定时任务 29 | func RunCronJobs(async bool) { 30 | timezone, err := time.LoadLocation("Asia/Shanghai") 31 | if err != nil { 32 | logging.Errorf(nil, "RunCronJobs time LoadLocation error:%v, using Local timezone as default", err.Error()) 33 | timezone, _ = time.LoadLocation("Local") 34 | } 35 | logging.Debugf(nil, "cron timezone:%v", timezone) 36 | sched := gocron.NewScheduler(timezone) 37 | 38 | // 同步基金净值列表和4433列表 39 | // sched.Cron(viper.GetString("app.cronexp.sync_fund")).Do(SyncFund) 40 | // 同步东方财富行业列表 41 | // sched.Cron(viper.GetString("app.cronexp.sync_industry_list")).Do(SyncIndustryList) 42 | // 同步基金经理列表 43 | // sched.Cron(viper.GetString("app.cronexp.sync_fund_managers")).Do(SyncFundManagers) 44 | 45 | // ---------------------- 46 | // 以上的定时任务注释掉不再执行是因为部署的机器内存不够,执行时会oom 47 | // 改为定时读取本地的JSON数据更新到全局变量,json数据由外部同步到机器上 48 | sched.Cron(viper.GetString("app.cronexp.sync_global_vars")).Do(models.InitGlobalVars) 49 | 50 | if async { 51 | sched.StartAsync() 52 | } else { 53 | sched.StartBlocking() 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /cron/fund.go: -------------------------------------------------------------------------------- 1 | // Package cron 定时任务 2 | package cron 3 | 4 | import ( 5 | "context" 6 | "encoding/json" 7 | "io/ioutil" 8 | "time" 9 | 10 | "github.com/axiaoxin-com/goutils" 11 | "github.com/axiaoxin-com/investool/core" 12 | "github.com/axiaoxin-com/investool/datacenter" 13 | "github.com/axiaoxin-com/investool/datacenter/eastmoney" 14 | "github.com/axiaoxin-com/investool/models" 15 | "github.com/axiaoxin-com/logging" 16 | ) 17 | 18 | // SyncFund 同步基金数据 19 | func SyncFund() { 20 | if !goutils.IsTradingDay() { 21 | return 22 | } 23 | ctx := context.Background() 24 | logging.Info(ctx, "SyncFund request start...") 25 | 26 | // 获取全量列表 27 | efundlist, err := datacenter.EastMoney.QueryAllFundList(ctx, eastmoney.FundTypeALL) 28 | if err != nil { 29 | logging.Error(ctx, "SyncFund QueryAllFundList error:"+err.Error()) 30 | promSyncError.WithLabelValues("SyncFund").Inc() 31 | return 32 | } 33 | 34 | fundCodes := []string{} 35 | for _, efund := range efundlist { 36 | fundCodes = append(fundCodes, efund.Fcode) 37 | } 38 | s := core.NewSearcher(ctx) 39 | data, err := s.SearchFunds(ctx, fundCodes) 40 | fundlist := models.FundList{} 41 | typeMap := map[string]struct{}{} 42 | for _, fund := range data { 43 | fundlist = append(fundlist, fund) 44 | typeMap[fund.Type] = struct{}{} 45 | } 46 | 47 | // 更新 services 变量 48 | models.FundAllList = fundlist 49 | fundtypes := []string{} 50 | for k := range typeMap { 51 | fundtypes = append(fundtypes, k) 52 | } 53 | models.FundTypeList = fundtypes 54 | 55 | // 更新同步时间 56 | models.SyncFundTime = time.Now() 57 | 58 | // 更新4433列表 59 | Update4433() 60 | 61 | // 更新文件 62 | b, err := json.Marshal(efundlist) 63 | if err != nil { 64 | logging.Errorf(ctx, "SyncFund json marshal efundlist error:%v", err) 65 | promSyncError.WithLabelValues("SyncFund").Inc() 66 | } else if err := ioutil.WriteFile(models.RawFundAllListFilename, b, 0666); err != nil { 67 | logging.Errorf(ctx, "SyncFund WriteFile efundlist error:%v", err) 68 | promSyncError.WithLabelValues("SyncFund").Inc() 69 | } 70 | b, err = json.Marshal(fundlist) 71 | if err != nil { 72 | logging.Errorf(ctx, "SyncFund json marshal fundlist error:%v", err) 73 | promSyncError.WithLabelValues("SyncFund").Inc() 74 | } else if err := ioutil.WriteFile(models.FundAllListFilename, b, 0666); err != nil { 75 | logging.Errorf(ctx, "SyncFund WriteFile fundlist error:%v", err) 76 | promSyncError.WithLabelValues("SyncFund").Inc() 77 | } 78 | b, err = json.Marshal(models.FundTypeList) 79 | if err != nil { 80 | logging.Errorf(ctx, "SyncFund json marshal fundtypelist error:%v", err) 81 | promSyncError.WithLabelValues("SyncFund").Inc() 82 | } else if err := ioutil.WriteFile(models.FundTypeListFilename, b, 0666); err != nil { 83 | logging.Errorf(ctx, "SyncFund WriteFile fundtypelist error:%v", err) 84 | promSyncError.WithLabelValues("SyncFund").Inc() 85 | } 86 | } 87 | 88 | // Update4433 更新4433检测结果 89 | func Update4433() { 90 | ctx := context.Background() 91 | fundlist := models.FundList{} 92 | for _, fund := range models.FundAllList { 93 | if fund.Is4433(ctx) { 94 | fundlist = append(fundlist, fund) 95 | } 96 | } 97 | // 更新 models 变量 98 | fundlist.Sort(models.FundSortTypeWeek) 99 | models.Fund4433List = fundlist 100 | 101 | // 更新文件 102 | b, err := json.Marshal(fundlist) 103 | if err != nil { 104 | logging.Errorf(ctx, "Update4433 json marshal error:", err) 105 | promSyncError.WithLabelValues("Update4433").Inc() 106 | return 107 | } else if err := ioutil.WriteFile(models.Fund4433ListFilename, b, 0666); err != nil { 108 | logging.Errorf(ctx, "Update4433 WriteFile error:", err) 109 | promSyncError.WithLabelValues("Update4433").Inc() 110 | return 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /cron/fund_managers.go: -------------------------------------------------------------------------------- 1 | // Package cron 定时任务 2 | package cron 3 | 4 | import ( 5 | "context" 6 | "encoding/json" 7 | "io/ioutil" 8 | 9 | "github.com/axiaoxin-com/goutils" 10 | "github.com/axiaoxin-com/investool/datacenter" 11 | "github.com/axiaoxin-com/investool/models" 12 | "github.com/axiaoxin-com/logging" 13 | ) 14 | 15 | // SyncFundManagers 同步基金经理 16 | func SyncFundManagers() { 17 | if !goutils.IsTradingDay() { 18 | return 19 | } 20 | ctx := context.Background() 21 | managers, err := datacenter.EastMoney.FundMangers(ctx, "all", "penavgrowth", "desc") 22 | if err != nil { 23 | logging.Error(ctx, "SyncFundManagers error:"+err.Error()) 24 | } 25 | managers.SortByYieldse() 26 | if len(managers) != 0 { 27 | models.FundManagers = managers 28 | } 29 | 30 | // 更新文件 31 | b, err := json.MarshalIndent(managers, "", " ") 32 | if err != nil { 33 | logging.Errorf(ctx, "SyncFundManagers json marshal error:", err) 34 | promSyncError.WithLabelValues("SyncFundManagers").Inc() 35 | return 36 | } 37 | if err := ioutil.WriteFile(models.FundManagersFilename, b, 0666); err != nil { 38 | logging.Errorf(ctx, "SyncFundManagers WriteFile error:", err) 39 | promSyncError.WithLabelValues("SyncFundManagers").Inc() 40 | return 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /cron/fund_test.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/axiaoxin-com/logging" 7 | "github.com/spf13/viper" 8 | ) 9 | 10 | func _TestSyncFund(t *testing.T) { 11 | logging.SetLevel("warn") 12 | viper.SetDefault("app.chan_size", 500) 13 | SyncFund() 14 | } 15 | 16 | func _TestSyncFundManagers(t *testing.T) { 17 | logging.SetLevel("warn") 18 | viper.SetDefault("app.chan_size", 500) 19 | SyncFundManagers() 20 | } 21 | -------------------------------------------------------------------------------- /cron/stock.go: -------------------------------------------------------------------------------- 1 | // Package cron 定时任务 2 | package cron 3 | 4 | import ( 5 | "context" 6 | "encoding/json" 7 | "io/ioutil" 8 | 9 | "github.com/axiaoxin-com/goutils" 10 | "github.com/axiaoxin-com/investool/datacenter" 11 | "github.com/axiaoxin-com/investool/models" 12 | "github.com/axiaoxin-com/logging" 13 | ) 14 | 15 | // SyncIndustryList 同步行业列表 16 | func SyncIndustryList() { 17 | if !goutils.IsTradingDay() { 18 | return 19 | } 20 | ctx := context.Background() 21 | indlist, err := datacenter.EastMoney.QueryIndustryList(ctx) 22 | if err != nil { 23 | logging.Errorf(ctx, "SyncIndustryList QueryIndustryList error:", err) 24 | promSyncError.WithLabelValues("SyncIndustryList").Inc() 25 | return 26 | } 27 | if len(indlist) != 0 { 28 | models.StockIndustryList = indlist 29 | } 30 | 31 | // 更新文件 32 | b, err := json.Marshal(indlist) 33 | if err != nil { 34 | logging.Errorf(ctx, "SyncIndustryList json marshal error:", err) 35 | promSyncError.WithLabelValues("SyncIndustryList").Inc() 36 | return 37 | } 38 | if err := ioutil.WriteFile(models.IndustryListFilename, b, 0666); err != nil { 39 | logging.Errorf(ctx, "SyncIndustryList WriteFile error:", err) 40 | promSyncError.WithLabelValues("SyncIndustryList").Inc() 41 | return 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /cron/stock_test.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import "testing" 4 | 5 | func TestSyncIndustryList(t *testing.T) { 6 | SyncIndustryList() 7 | } 8 | -------------------------------------------------------------------------------- /datacenter/chinabond/chinabond.go: -------------------------------------------------------------------------------- 1 | // Package chinabond 接口封装 2 | // https://yield.chinabond.com.cn/cbweb-mn/yield_main?locale=zh_CN 3 | package chinabond 4 | 5 | import ( 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | // ChinaBond 中国债券信息网 11 | type ChinaBond struct { 12 | // http 客户端 13 | HTTPClient *http.Client 14 | } 15 | 16 | // NewChinaBond 创建 ChinaBond 实例 17 | func NewChinaBond() ChinaBond { 18 | hc := &http.Client{ 19 | Timeout: time.Second * 60 * 5, 20 | } 21 | return ChinaBond{ 22 | HTTPClient: hc, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /datacenter/chinabond/chinabond_test.go: -------------------------------------------------------------------------------- 1 | package chinabond 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | var ( 8 | _c = NewChinaBond() 9 | _ctx = context.TODO() 10 | ) 11 | -------------------------------------------------------------------------------- /datacenter/chinabond/query_tree.go: -------------------------------------------------------------------------------- 1 | package chinabond 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/axiaoxin-com/goutils" 8 | "github.com/axiaoxin-com/logging" 9 | "github.com/corpix/uarand" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | // TreeItem 债券曲线树节点 14 | type TreeItem struct { 15 | ID string `json:"id"` 16 | PID string `json:"pId"` 17 | Name string `json:"name"` 18 | IsParent string `json:"isParent"` 19 | Open string `json:"open"` 20 | Checked bool `json:"checked"` 21 | Font interface{} `json:"font"` 22 | } 23 | 24 | // RespQueryTree 债券曲线树 https://yield.chinabond.com.cn/cbweb-mn/yc/queryTree?locale=zh_CN 接口返回结果 25 | type RespQueryTree []TreeItem 26 | 27 | // QueryTree 查询债券曲线树数据,返回key为债券曲线名称,value为曲线名称对应的随机id 28 | func (c ChinaBond) QueryTree(ctx context.Context) (map[string]string, error) { 29 | apiurl := "https://yield.chinabond.com.cn/cbweb-mn/yc/queryTree?locale=zh_CN" 30 | logging.Debug(ctx, "ChinaBond QueryTree "+apiurl+" begin") 31 | beginTime := time.Now() 32 | resp := RespQueryTree{} 33 | header := map[string]string{ 34 | "User-Agent": uarand.GetRandom(), 35 | } 36 | err := goutils.HTTPGET(ctx, c.HTTPClient, apiurl, header, &resp) 37 | latency := time.Now().Sub(beginTime).Milliseconds() 38 | logging.Debug( 39 | ctx, 40 | "ChinaBond QueryTree "+apiurl+" end", 41 | zap.Int64("latency(ms)", latency), 42 | zap.Any("resp", resp), 43 | ) 44 | if err != nil { 45 | return nil, err 46 | } 47 | result := map[string]string{} 48 | for _, i := range resp { 49 | result[i.Name] = i.ID 50 | } 51 | return result, nil 52 | } 53 | -------------------------------------------------------------------------------- /datacenter/chinabond/query_tree_test.go: -------------------------------------------------------------------------------- 1 | package chinabond 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestQueryTree(t *testing.T) { 10 | results, err := _c.QueryTree(_ctx) 11 | require.Nil(t, err) 12 | require.NotEqual(t, len(results), 0) 13 | id := results["中债证券公司债收益率曲线(AAA)"] 14 | require.Equal(t, "5781a1ff7651967e0176978d957b7346", id) 15 | } 16 | -------------------------------------------------------------------------------- /datacenter/chinabond/query_xy_fxsyl.go: -------------------------------------------------------------------------------- 1 | package chinabond 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/axiaoxin-com/goutils" 10 | "github.com/axiaoxin-com/logging" 11 | "github.com/corpix/uarand" 12 | "github.com/pkg/errors" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | // RespQueryFxsyl 债券收益率接口返回结果 17 | // SeriesData: [ [期限年数, 收益率], ... ] 18 | type RespQueryFxsyl struct { 19 | YcChartDataList []struct { 20 | YcDefID string `json:"ycDefId"` 21 | YcDefName string `json:"ycDefName"` 22 | YcYWName interface{} `json:"ycYWName"` 23 | Worktime string `json:"worktime"` 24 | SeriesData [][]float64 `json:"seriesData"` 25 | IsPoint bool `json:"isPoint"` 26 | HyCurve bool `json:"hyCurve"` 27 | Point bool `json:"point"` 28 | } `json:"ycChartDataList"` 29 | ChartDataList interface{} `json:"chartDataList"` 30 | UpThrow int `json:"upThrow"` 31 | DownThrow int `json:"downThrow"` 32 | UpOffset int `json:"upOffset"` 33 | DownOffset int `json:"downOffset"` 34 | } 35 | 36 | // QueryFxsyl 查询指定债券在指定日期的收益率 37 | // treeItemID 为QueryTree中对应债券的id 38 | // date为string格式的指定日期:YYYY-mm-dd 39 | func (c ChinaBond) QueryFxsyl(ctx context.Context, treeItemID, date string) ([][]float64, error) { 40 | apiurl := fmt.Sprintf( 41 | "https://yield.chinabond.com.cn/cbweb-mn/yc/searchXyFxsyl?xyzSelect=txy&&workTimes=%s&&dxbj=4&&qxll=1,&&yqqxN=N&&yqqxK=K&&ycDefIds=%s,&&locale=zh_CN", 42 | date, 43 | treeItemID, 44 | ) 45 | logging.Debug(ctx, "ChinaBond QueryFxsyl "+apiurl+" begin") 46 | beginTime := time.Now() 47 | resp := RespQueryFxsyl{} 48 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiurl, nil) 49 | if err != nil { 50 | return nil, errors.Wrap(err, "QueryFxsyl NewRequestWithContext") 51 | } 52 | req.Header.Set("Content-Type", "application/json") 53 | req.Header.Set("User-Agent", uarand.GetRandom()) 54 | err = goutils.HTTPPOST(ctx, c.HTTPClient, req, &resp) 55 | latency := time.Now().Sub(beginTime).Milliseconds() 56 | logging.Debug( 57 | ctx, 58 | "ChinaBond QueryFxsyl "+apiurl+" end", 59 | zap.Int64("latency(ms)", latency), 60 | zap.Any("resp", resp), 61 | ) 62 | if err != nil { 63 | return nil, err 64 | } 65 | if len(resp.YcChartDataList) == 0 { 66 | return nil, nil 67 | } 68 | 69 | return resp.YcChartDataList[0].SeriesData, nil 70 | } 71 | 72 | // QueryCurrentSyl 返回债券当期收益率 73 | // 债券名称:https://yield.chinabond.com.cn/cbweb-mn/yield_main?locale=zh_CN 74 | func (c ChinaBond) QueryCurrentSyl(ctx context.Context, bondName string) (float64, error) { 75 | bonds, err := c.QueryTree(ctx) 76 | if err != nil { 77 | return 0, err 78 | } 79 | id := bonds[bondName] 80 | if id == "" { 81 | return 0, fmt.Errorf("债券名称不存在:%v", bondName) 82 | } 83 | date := goutils.GetLatestTradingDay() 84 | data, err := c.QueryFxsyl(ctx, id, date) 85 | if err != nil { 86 | return 0, err 87 | } 88 | if len(data) == 0 { 89 | return 0, errors.New("收益率数据为空") 90 | } 91 | syl := data[0] 92 | if len(syl) != 2 { 93 | return 0, fmt.Errorf("收益率数据异常:%v", syl) 94 | } 95 | return syl[1], nil 96 | } 97 | 98 | // QueryAAACompanyBondSyl AAA公司债当期收益率 99 | func (c ChinaBond) QueryAAACompanyBondSyl(ctx context.Context) float64 { 100 | syl, err := c.QueryCurrentSyl(ctx, "中债证券公司债收益率曲线(AAA)") 101 | if err != nil { 102 | logging.Error(ctx, "QueryCurrentSyl error:"+err.Error()) 103 | } 104 | return syl 105 | } 106 | -------------------------------------------------------------------------------- /datacenter/chinabond/query_xy_fxsyl_test.go: -------------------------------------------------------------------------------- 1 | package chinabond 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func _TestQueryFxsyl(t *testing.T) { 10 | results, err := _c.QueryFxsyl(_ctx, "5781a1ff7651967e0176978d957b7346", "2021-11-19") 11 | require.Nil(t, err) 12 | require.NotEqual(t, len(results), 0) 13 | require.NotZero(t, results[0][1]) 14 | } 15 | func _TestQueryCurrentSyl(t *testing.T) { 16 | result, err := _c.QueryCurrentSyl(_ctx, "中债证券公司债收益率曲线(AAA)") 17 | require.Nil(t, err) 18 | require.NotZero(t, result) 19 | t.Log(result) 20 | } 21 | -------------------------------------------------------------------------------- /datacenter/datacenter.go: -------------------------------------------------------------------------------- 1 | // Package datacenter 数据来源 2 | package datacenter 3 | 4 | import ( 5 | "github.com/axiaoxin-com/investool/datacenter/chinabond" 6 | "github.com/axiaoxin-com/investool/datacenter/eastmoney" 7 | "github.com/axiaoxin-com/investool/datacenter/eniu" 8 | "github.com/axiaoxin-com/investool/datacenter/sina" 9 | "github.com/axiaoxin-com/investool/datacenter/zszx" 10 | ) 11 | 12 | var ( 13 | // EastMoney 东方财富 14 | EastMoney eastmoney.EastMoney 15 | // Eniu 亿牛网 16 | Eniu eniu.Eniu 17 | // Sina 新浪财经 18 | Sina sina.Sina 19 | // Zszx 招商证券 20 | Zszx zszx.Zszx 21 | // ChinaBond 中国债券信息网 22 | ChinaBond chinabond.ChinaBond 23 | ) 24 | 25 | func init() { 26 | EastMoney = eastmoney.NewEastMoney() 27 | Eniu = eniu.NewEniu() 28 | Sina = sina.NewSina() 29 | Zszx = zszx.NewZszx() 30 | ChinaBond = chinabond.NewChinaBond() 31 | } 32 | -------------------------------------------------------------------------------- /datacenter/eastmoney/README.md: -------------------------------------------------------------------------------- 1 | # eastmoney 2 | 3 | 东方财富接口封装 4 | 5 | ## 实现功能 6 | 7 | ### 选股器 8 | 9 | - 按我的默认条件初筛股票 10 | - 自定义我的默认条件参考值筛选股票 11 | - 对筛选结果进行 ROE 排序 12 | - 获取行业列表 13 | 14 | ### 公司信息 15 | 16 | - 所属行业 17 | - 所属概念 18 | - 简介 19 | - 主营业务 20 | - 题材关键字 21 | - 主营构成 22 | 23 | ### 财务数据 24 | 25 | - 获取历史财报信息 26 | - 获取最新财报预约披露时间 27 | - 按类型过滤历史财报:历史一季报,历史中报,历史三季报,历史年报 28 | - 按年份过滤历史财报: xxxx 年-一季报、中报、三季报、年报 29 | - 判断 ROE/EPS/REVENUE/PROFIT 是否逐年递增 30 | - 计算 ROE/EPS 中位数 31 | - 获取今年一季报营收增长比 32 | - 获取历史 PE 33 | - 计算历史 PE 中位数 34 | 35 | ### 估值 36 | 37 | - 获取当前估值状态 38 | - 获取机构评级统计 39 | - 获取盈利预测信息 40 | -------------------------------------------------------------------------------- /datacenter/eastmoney/company_profile_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestQueryCompanyProfile(t *testing.T) { 10 | data, err := _em.QueryCompanyProfile(_ctx, "002459.sz") 11 | require.Nil(t, err) 12 | require.NotEmpty(t, data.Keywords) 13 | require.NotEmpty(t, data.MainForms) 14 | require.NotEmpty(t, data.Secucode) 15 | require.NotEmpty(t, data.Name) 16 | require.NotEmpty(t, data.Industry) 17 | require.NotEmpty(t, data.Concept) 18 | require.NotEmpty(t, data.Profile) 19 | require.NotEmpty(t, data.MainBusiness) 20 | } 21 | -------------------------------------------------------------------------------- /datacenter/eastmoney/eastmoney.go: -------------------------------------------------------------------------------- 1 | // 东方财富数据源封装 2 | 3 | package eastmoney 4 | 5 | import ( 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | // EastMoney 东方财富数据源 11 | type EastMoney struct { 12 | // http 客户端 13 | HTTPClient *http.Client 14 | } 15 | 16 | // NewEastMoney 创建 EastMoney 实例 17 | func NewEastMoney() EastMoney { 18 | hc := &http.Client{ 19 | Timeout: time.Second * 60 * 5, 20 | } 21 | return EastMoney{ 22 | HTTPClient: hc, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /datacenter/eastmoney/eastmoney_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | var ( 8 | _em = NewEastMoney() 9 | _ctx = context.TODO() 10 | ) 11 | -------------------------------------------------------------------------------- /datacenter/eastmoney/fina_cashflow_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestQueryFinaCashflowData(t *testing.T) { 10 | data, err := _em.QueryFinaCashflowData(_ctx, "000958.SZ") 11 | require.Nil(t, err) 12 | require.NotEmpty(t, data) 13 | t.Log(data[0].ReportType) 14 | } 15 | -------------------------------------------------------------------------------- /datacenter/eastmoney/fina_income_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestQueryFinaGincomeData(t *testing.T) { 10 | data, err := _em.QueryFinaGincomeData(_ctx, "002671.sz") 11 | require.Nil(t, err) 12 | require.NotEmpty(t, data) 13 | t.Log(data[0].ReportType) 14 | } 15 | -------------------------------------------------------------------------------- /datacenter/eastmoney/fina_main_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestQueryHistoricalFinaMainData(t *testing.T) { 11 | data, err := _em.QueryHistoricalFinaMainData(_ctx, "600188.SH") 12 | require.Nil(t, err) 13 | require.NotEmpty(t, data) 14 | data1 := data.FilterByReportType(_ctx, FinaReportTypeYear) 15 | require.NotEmpty(t, data1) 16 | year := time.Now().Year() - 1 17 | data2 := data.FilterByReportYear(_ctx, year) 18 | require.Equal(t, 4, len(data2)) 19 | ratio := data.GetAvgRevenueIncreasingRatioByYear(_ctx, year) 20 | t.Log("ratio:", ratio) 21 | em, err := data.MidValue(_ctx, "EPS", 10, FinaReportTypeYear) 22 | require.Nil(t, err) 23 | rm, err := data.MidValue(_ctx, "ROE", 0, FinaReportTypeYear) 24 | require.Nil(t, err) 25 | t.Log("eps mid:", em, " roe mid:", rm) 26 | } 27 | 28 | func TestQueryFinaPublishDateList(t *testing.T) { 29 | date, err := _em.QueryFinaPublishDateList(_ctx, "000026") 30 | require.Nil(t, err) 31 | t.Log("pubdate:", date) 32 | } 33 | -------------------------------------------------------------------------------- /datacenter/eastmoney/free_holders.go: -------------------------------------------------------------------------------- 1 | // 获取十大流通股东 2 | 3 | package eastmoney 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "strings" 9 | "time" 10 | 11 | "github.com/axiaoxin-com/goutils" 12 | "github.com/axiaoxin-com/logging" 13 | 14 | "go.uber.org/zap" 15 | ) 16 | 17 | // FreeHolder 流通股东 18 | type FreeHolder struct { 19 | // 日期 20 | EndDate string `json:"END_DATE"` 21 | // 股东名称 22 | HolderName string `json:"HOLDER_NAME"` 23 | // 股东代码 24 | HolderCode string `json:"HOLDER_CODE"` 25 | // 持股数 26 | HoldNum int `json:"HOLD_NUM"` 27 | // 占流通股比 28 | FreeHoldnumRatio float64 `json:"FREE_HOLDNUM_RATIO"` 29 | // 环比增减 30 | FreeRatioQoq string `json:"FREE_RATIO_QOQ"` 31 | // 1:机构 0:个人 32 | IsHoldorg string `json:"IS_HOLDORG"` 33 | // 持股数排名 34 | HolderRank int `json:"HOLDER_RANK"` 35 | } 36 | 37 | // FreeHolderList ... 38 | type FreeHolderList []FreeHolder 39 | 40 | func (f FreeHolderList) String() string { 41 | s := []string{} 42 | for i, h := range f { 43 | fs := fmt.Sprintf("%d.%s|%.2f%%|%v", i+1, h.HolderName, h.FreeHoldnumRatio, h.FreeRatioQoq) 44 | s = append(s, fs) 45 | } 46 | return strings.Join(s, "
") 47 | } 48 | 49 | // RespFreeHolders QueryFreeHolders 返回json结构 50 | type RespFreeHolders struct { 51 | Version string `json:"version"` 52 | Result struct { 53 | Pages int `json:"pages"` 54 | Data FreeHolderList `json:"data"` 55 | Count int `json:"count"` 56 | } `json:"result"` 57 | Success bool `json:"success"` 58 | Message string `json:"message"` 59 | Code int `json:"code"` 60 | } 61 | 62 | // QueryFreeHolders 获取前十大流通股东信息 63 | func (e EastMoney) QueryFreeHolders(ctx context.Context, secuCode string) (FreeHolderList, error) { 64 | apiurl := "https://datacenter.eastmoney.com/securities/api/data/v1/get" 65 | params := map[string]string{ 66 | "reportName": "RPT_F10_EH_FREEHOLDERS", 67 | "columns": "END_DATE,HOLDER_NAME,HOLDER_CODE,HOLD_NUM,FREE_HOLDNUM_RATIO,FREE_RATIO_QOQ,IS_HOLDORG,HOLDER_RANK", 68 | "filter": fmt.Sprintf(`(SECUCODE="%s")`, strings.ToUpper(secuCode)), 69 | "pageSize": "10", 70 | } 71 | logging.Debug(ctx, "EastMoney QueryFreeHolders "+apiurl+" begin", zap.Any("params", params)) 72 | beginTime := time.Now() 73 | apiurl, err := goutils.NewHTTPGetURLWithQueryString(ctx, apiurl, params) 74 | if err != nil { 75 | return nil, err 76 | } 77 | resp := RespFreeHolders{} 78 | err = goutils.HTTPGET(ctx, e.HTTPClient, apiurl, nil, &resp) 79 | latency := time.Now().Sub(beginTime).Milliseconds() 80 | logging.Debug( 81 | ctx, 82 | "EastMoney QueryFreeHolders "+apiurl+" end", 83 | zap.Int64("latency(ms)", latency), 84 | // zap.Any("resp", resp), 85 | ) 86 | if err != nil { 87 | return nil, err 88 | } 89 | if resp.Code != 0 { 90 | return nil, fmt.Errorf("%s %#v", secuCode, resp) 91 | } 92 | return resp.Result.Data, nil 93 | } 94 | -------------------------------------------------------------------------------- /datacenter/eastmoney/free_holders_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestQueryFreeHolders(t *testing.T) { 10 | data, err := _em.QueryFreeHolders(_ctx, "600031.sh") 11 | t.Log(data) 12 | require.Nil(t, err) 13 | require.Len(t, data, 10) 14 | } 15 | -------------------------------------------------------------------------------- /datacenter/eastmoney/fund_info_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestQueryFundInfo(t *testing.T) { 11 | data, err := _em.QueryFundInfo(_ctx, "013781") 12 | require.Nil(t, err) 13 | require.NotEmpty(t, data) 14 | t.Logf("data:%+v", data) 15 | _, err = json.Marshal(data) 16 | require.Nil(t, err) 17 | } 18 | -------------------------------------------------------------------------------- /datacenter/eastmoney/fund_manager_base_list_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestFundManagerBaseList(t *testing.T) { 11 | data, err := _em.FundMangerBaseList(_ctx, "", "YIELDSE") 12 | require.Nil(t, err) 13 | require.NotEmpty(t, data) 14 | t.Logf("data:%+v", data) 15 | _, err = json.Marshal(data) 16 | require.Nil(t, err) 17 | } 18 | 19 | func TestFundMsnManagerInfo(t *testing.T) { 20 | data, err := _em.QueryFundMsnMangerInfo(_ctx, "30040544") 21 | require.Nil(t, err) 22 | require.NotEmpty(t, data) 23 | t.Logf("data:%+v", data) 24 | _, err = json.Marshal(data) 25 | require.Nil(t, err) 26 | } 27 | -------------------------------------------------------------------------------- /datacenter/eastmoney/fund_managers_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func _TestFundManagers(t *testing.T) { 11 | data, err := _em.FundMangers(_ctx, "all", "penavgrowth", "desc") 12 | require.Nil(t, err) 13 | require.NotEmpty(t, data) 14 | // t.Logf("data:%+v\n", data) 15 | _, err = json.Marshal(data) 16 | require.Nil(t, err) 17 | fd := data.Filter(_ctx, ParamFundManagerFilter{ 18 | MinWorkingYears: 8, 19 | MinYieldse: 15.0, 20 | MaxCurrentFundCount: 10, 21 | }) 22 | for _, d := range fd { 23 | t.Logf("d:%+v\n", *d) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /datacenter/eastmoney/fund_net_list_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spf13/viper" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func _TestQueryAllFundNetList(t *testing.T) { 11 | viper.SetDefault("app.chan_size", 500) 12 | data, err := _em.QueryAllFundList(_ctx, FundTypeALL) 13 | require.Nil(t, err) 14 | require.NotEmpty(t, data) 15 | t.Log("data len:", len(data)) 16 | } 17 | 18 | func TestQueryFundListByPage(t *testing.T) { 19 | result, err := _em.QueryFundListByPage(_ctx, FundTypeALL, 1) 20 | require.Nil(t, err) 21 | t.Logf("%#v\n", result) 22 | } 23 | -------------------------------------------------------------------------------- /datacenter/eastmoney/fund_search.go: -------------------------------------------------------------------------------- 1 | // 关键词搜索 2 | 3 | package eastmoney 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "regexp" 9 | "time" 10 | 11 | "github.com/axiaoxin-com/goutils" 12 | "github.com/axiaoxin-com/logging" 13 | "github.com/corpix/uarand" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | // SearchFundInfo 关键词搜索基金结构 18 | type SearchFundInfo struct { 19 | Code string 20 | Name string 21 | Type string 22 | } 23 | 24 | // SearchFund 关键词搜索, 股票、代码、拼音 25 | func (e EastMoney) SearchFund(ctx context.Context, kw string) (results []SearchFundInfo, err error) { 26 | count := 10 27 | apiurl := fmt.Sprintf("https://fundsuggest.eastmoney.com/FundCodeNew.aspx?input=%s&count=%d&cb=x", kw, count) 28 | logging.Debug(ctx, "EastMoney SearchFund "+apiurl+" begin") 29 | beginTime := time.Now() 30 | header := map[string]string{ 31 | "user-agent": uarand.GetRandom(), 32 | } 33 | resp, err := goutils.HTTPGETRaw(ctx, e.HTTPClient, apiurl, header) 34 | strresp := string(resp) 35 | latency := time.Now().Sub(beginTime).Milliseconds() 36 | logging.Debug(ctx, "EastMoney SearchFund "+apiurl+" end", 37 | zap.Int64("latency(ms)", latency), 38 | // zap.Any("resp", strresp), 39 | ) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | if len(strresp) < 6 { 45 | logging.Warnf(ctx, "SearchFund invalid resp: %s", strresp) 46 | return nil, fmt.Errorf("无法找到相关基金") 47 | } 48 | reg, err := regexp.Compile(`"(?P\d{6}),.+?,(?P.+?),(?P.+?),"`) 49 | if err != nil { 50 | logging.Error(ctx, "regexp error:"+err.Error()) 51 | return nil, err 52 | } 53 | matched := reg.FindAllStringSubmatch(strresp, -1) 54 | for _, m := range matched { 55 | results = append(results, SearchFundInfo{ 56 | Code: m[1], 57 | Name: m[2], 58 | Type: m[3], 59 | }) 60 | } 61 | return 62 | } 63 | -------------------------------------------------------------------------------- /datacenter/eastmoney/fund_search_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestSearchFund(t *testing.T) { 10 | results, err := _em.SearchFund(_ctx, "半导体") 11 | require.Nil(t, err) 12 | t.Log(results) 13 | } 14 | -------------------------------------------------------------------------------- /datacenter/eastmoney/historical_pe_list.go: -------------------------------------------------------------------------------- 1 | // 获取历史市盈率 2 | 3 | package eastmoney 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/axiaoxin-com/goutils" 12 | "github.com/axiaoxin-com/logging" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | // RespHistoricalPE 历史市盈率接口返回结构 17 | type RespHistoricalPE struct { 18 | Data [][]struct { 19 | Securitycode string `json:"SECURITYCODE"` 20 | Datetype string `json:"DATETYPE"` 21 | Sl string `json:"SL"` 22 | Endate string `json:"ENDATE"` 23 | Value string `json:"VALUE"` 24 | } `json:"data"` 25 | Pe [][]struct { 26 | Securitycode string `json:"SECURITYCODE"` 27 | Pe30 string `json:"PE30"` 28 | Pe50 string `json:"PE50"` 29 | Pe70 string `json:"PE70"` 30 | Total string `json:"TOTAL"` 31 | Rn1 string `json:"RN1"` 32 | Rn2 string `json:"RN2"` 33 | Rn3 string `json:"RN3"` 34 | } `json:"pe"` 35 | } 36 | 37 | // HistoricalPE 历史 pe 38 | type HistoricalPE struct { 39 | Value float64 40 | Date string 41 | } 42 | 43 | // HistoricalPEList 历史 pe 列表 44 | type HistoricalPEList []HistoricalPE 45 | 46 | // GetMidValue 获取历史 pe 中位数 47 | func (h HistoricalPEList) GetMidValue(ctx context.Context) (float64, error) { 48 | values := []float64{} 49 | for _, i := range h { 50 | values = append(values, i.Value) 51 | } 52 | return goutils.MidValueFloat64(values) 53 | } 54 | 55 | // QueryHistoricalPEList 获取历史市盈率 56 | func (e EastMoney) QueryHistoricalPEList(ctx context.Context, secuCode string) (HistoricalPEList, error) { 57 | apiurl := "https://emfront.eastmoney.com/APP_HSF10/CPBD/GZFX" 58 | params := map[string]string{ 59 | "code": e.GetFC(secuCode), 60 | "year": "4", // 10 年 61 | "type": "1", // 市盈率 62 | } 63 | logging.Debug(ctx, "EastMoney QueryHistoricalPEList "+apiurl+" begin", zap.Any("params", params)) 64 | beginTime := time.Now() 65 | apiurl, err := goutils.NewHTTPGetURLWithQueryString(ctx, apiurl, params) 66 | if err != nil { 67 | return nil, err 68 | } 69 | resp := RespHistoricalPE{} 70 | err = goutils.HTTPGET(ctx, e.HTTPClient, apiurl, nil, &resp) 71 | latency := time.Now().Sub(beginTime).Milliseconds() 72 | logging.Debug( 73 | ctx, 74 | "EastMoney QueryHistoricalPEList "+apiurl+" end", 75 | zap.Int64("latency(ms)", latency), 76 | // zap.Any("resp", resp), 77 | ) 78 | if err != nil { 79 | return nil, err 80 | } 81 | result := HistoricalPEList{} 82 | if len(resp.Data) == 0 { 83 | return nil, errors.New("no historical pe data") 84 | } 85 | for _, i := range resp.Data[0] { 86 | value, err := strconv.ParseFloat(i.Value, 64) 87 | if err != nil { 88 | logging.Error(ctx, "QueryHistoricalPEList ParseFloat error:"+err.Error()) 89 | continue 90 | } 91 | pe := HistoricalPE{ 92 | Date: i.Endate, 93 | Value: value, 94 | } 95 | result = append(result, pe) 96 | } 97 | return result, nil 98 | } 99 | -------------------------------------------------------------------------------- /datacenter/eastmoney/historical_pe_list_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestPEGetMidValue(t *testing.T) { 10 | d := HistoricalPEList{ 11 | HistoricalPE{Date: "1", Value: 6.0}, 12 | HistoricalPE{Date: "1", Value: 1.0}, 13 | HistoricalPE{Date: "1", Value: 5.0}, 14 | HistoricalPE{Date: "1", Value: 2.0}, 15 | HistoricalPE{Date: "1", Value: 4.0}, 16 | HistoricalPE{Date: "1", Value: 3.0}, 17 | } 18 | m, err := d.GetMidValue(_ctx) 19 | require.Nil(t, err) 20 | require.Equal(t, 3.5, m) 21 | } 22 | 23 | func TestQueryHistoricalPEList(t *testing.T) { 24 | d, err := _em.QueryHistoricalPEList(_ctx, "600149.sh") 25 | require.Nil(t, err) 26 | t.Log(d) 27 | } 28 | -------------------------------------------------------------------------------- /datacenter/eastmoney/hs300.go: -------------------------------------------------------------------------------- 1 | // 沪深300成分股 2 | 3 | package eastmoney 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | 9 | "github.com/axiaoxin-com/goutils" 10 | "github.com/corpix/uarand" 11 | ) 12 | 13 | // HS300Item 沪深300成分股信息 14 | type HS300Item struct { 15 | Secucode string `json:"SECUCODE"` // 股票代码.XX 16 | SecurityCode string `json:"SECURITY_CODE"` // 股票代码 17 | SecurityNameAbbr string `json:"SECURITY_NAME_ABBR"` // 股票简称 18 | ClosePrice float64 `json:"CLOSE_PRICE"` // 最新价格 19 | Industry string `json:"INDUSTRY"` // 主营行业 20 | Region string `json:"REGION"` // 地区 21 | Weight float64 `json:"WEIGHT"` // 持仓比例(%) 22 | Eps float64 `json:"EPS"` // 每股收益 23 | Bps float64 `json:"BPS"` // 每股净资产 24 | Roe float64 `json:"ROE"` // 净资产收益率 25 | TotalShares float64 `json:"TOTAL_SHARES"` // 总股本(亿股) 26 | FreeShares float64 `json:"FREE_SHARES"` // 流通股本(亿股) 27 | FreeCap float64 `json:"FREE_CAP"` // 流通市值(亿元) 28 | Type string `json:"TYPE"` 29 | F2 interface{} `json:"f2"` 30 | F3 interface{} `json:"f3"` 31 | } 32 | 33 | // RspHS300 HS300接口返回结构 34 | type RspHS300 struct { 35 | Version string `json:"version"` 36 | Result struct { 37 | Pages int `json:"pages"` 38 | Data []HS300Item `json:"data"` 39 | Count int `json:"count"` 40 | } `json:"result"` 41 | Success bool `json:"success"` 42 | Message string `json:"message"` 43 | Code int `json:"code"` 44 | } 45 | 46 | // HS300 返回沪深300成分股列表 47 | func (e EastMoney) HS300(ctx context.Context) (results []HS300Item, err error) { 48 | apiurl := "https://datacenter-web.eastmoney.com/api/data/v1/get?sortColumns=ROE&sortTypes=-1&pageSize=300&pageNumber=1&reportName=RPT_INDEX_TS_COMPONENT&columns=SECUCODE%2CSECURITY_CODE%2CTYPE%2CSECURITY_NAME_ABBR%2CCLOSE_PRICE%2CINDUSTRY%2CREGION%2CWEIGHT%2CEPS%2CBPS%2CROE%2CTOTAL_SHARES%2CFREE_SHARES%2CFREE_CAP"eColumns=f2%2Cf3"eType=0&source=WEB&client=WEB&filter=(TYPE%3D%221%22)" 49 | header := map[string]string{ 50 | "user-agent": uarand.GetRandom(), 51 | } 52 | rsp := RspHS300{} 53 | if err := goutils.HTTPGET(ctx, e.HTTPClient, apiurl, header, &rsp); err != nil { 54 | return nil, err 55 | } 56 | if rsp.Code != 0 { 57 | return nil, fmt.Errorf("HS300 rsp code error, rsp:%+v", rsp) 58 | } 59 | if len(rsp.Result.Data) != 300 { 60 | return nil, fmt.Errorf("HS300 rsp data len != 300, len=%d", len(rsp.Result.Data)) 61 | } 62 | return rsp.Result.Data, nil 63 | } 64 | -------------------------------------------------------------------------------- /datacenter/eastmoney/hs300_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestHS300(t *testing.T) { 10 | results, err := _em.HS300(_ctx) 11 | require.Nil(t, err) 12 | t.Log(results) 13 | } 14 | -------------------------------------------------------------------------------- /datacenter/eastmoney/index.go: -------------------------------------------------------------------------------- 1 | // 指数信息 2 | 3 | package eastmoney 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | 9 | "github.com/axiaoxin-com/goutils" 10 | "github.com/corpix/uarand" 11 | ) 12 | 13 | // IndexData 指数信息 14 | type IndexData struct { 15 | IndexCode string `json:"IndexCode"` 16 | IndexName string `json:"IndexName"` 17 | Newindextexch string `json:"NEWINDEXTEXCH"` 18 | FullIndexName string `json:"FullIndexName"` 19 | NewPrice string `json:"NewPrice"` // 指数点数 20 | NewPriceDate string `json:"NewPriceDate"` 21 | NewCHG string `json:"NewCHG"` // 最新涨幅 22 | Reaprofile string `json:"reaprofile"` // 指数说明 23 | MakerName string `json:"MakerName"` // 指数编制方 24 | Bkid string `json:"BKID"` 25 | BKName string `json:"BKName"` // 板块名称 26 | IsGuess bool `json:"IsGuess"` 27 | IndexvaluaCN string `json:"IndexvaluaCN"` // 估值:低估=-2,较为低估=-1,适中=0,较为高估=1,高估=2 28 | Petim string `json:"Petim"` // 估值PE值 29 | Pep100 string `json:"PEP100"` // 估值PE百分位 30 | Pb string `json:"PB"` 31 | Pbp100 string `json:"PBP100"` 32 | W string `json:"W"` // 近一周涨幅 33 | M string `json:"M"` // 近一月涨幅 34 | Q string `json:"Q"` // 近三月涨幅 35 | Hy string `json:"HY"` // 近六月涨幅 36 | Y string `json:"Y"` // 近一年涨幅 37 | Twy string `json:"TWY"` // 近两年涨幅 38 | Try string `json:"TRY"` // 近三年涨幅 39 | Fy string `json:"FY"` // 近五年涨幅 40 | Sy string `json:"SY"` // 今年来涨幅 41 | StddevW string `json:"STDDEV_W"` 42 | StddevM string `json:"STDDEV_M"` 43 | StddevQ string `json:"STDDEV_Q"` 44 | StddevHy string `json:"STDDEV_HY"` 45 | StddevY string `json:"STDDEV_Y"` 46 | StddevTwy string `json:"STDDEV_TWY"` 47 | PDate string `json:"PDate"` 48 | TopicJJBID interface{} `json:"TopicJJBId"` 49 | Isstatic string `json:"ISSTATIC"` 50 | } 51 | 52 | // IndexValueCN 指数估值 53 | func (i *IndexData) IndexValueCN() string { 54 | switch i.IndexvaluaCN { 55 | case "-2": 56 | return "低估" 57 | case "-1": 58 | return "较为低估" 59 | case "0": 60 | return "适中" 61 | case "1": 62 | return "较为高估" 63 | case "2": 64 | return "高估" 65 | } 66 | return "--" 67 | } 68 | 69 | // RspIndex Index接口返回结构 70 | type RspIndex struct { 71 | Datas IndexData `json:"Datas"` 72 | ErrCode int `json:"ErrCode"` 73 | Success bool `json:"Success"` 74 | ErrMsg interface{} `json:"ErrMsg"` 75 | Message interface{} `json:"Message"` 76 | ErrorCode string `json:"ErrorCode"` 77 | ErrorMessage interface{} `json:"ErrorMessage"` 78 | ErrorMsgLst interface{} `json:"ErrorMsgLst"` 79 | TotalCount int `json:"TotalCount"` 80 | Expansion interface{} `json:"Expansion"` 81 | } 82 | 83 | // Index 返回指数信息 84 | func (e EastMoney) Index(ctx context.Context, indexCode string) (data *IndexData, err error) { 85 | apiurl := fmt.Sprintf( 86 | "https://fundztapi.eastmoney.com/FundSpecialApiNew/FundSpecialZSB30ZSIndex?IndexCode=%s&Version=6.5.5&deviceid=-&pageIndex=1&pageSize=10000&plat=Iphone&product=EFund", 87 | indexCode, 88 | ) 89 | header := map[string]string{ 90 | "user-agent": uarand.GetRandom(), 91 | } 92 | rsp := RspIndex{} 93 | if err := goutils.HTTPGET(ctx, e.HTTPClient, apiurl, header, &rsp); err != nil { 94 | return nil, err 95 | } 96 | if rsp.ErrCode != 0 { 97 | return nil, fmt.Errorf("Index rsp code error, rsp:%+v", rsp) 98 | } 99 | return &rsp.Datas, nil 100 | } 101 | -------------------------------------------------------------------------------- /datacenter/eastmoney/index_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestIndex(t *testing.T) { 10 | data, err := _em.Index(_ctx, "000905") 11 | require.Nil(t, err) 12 | t.Log(data) 13 | } 14 | -------------------------------------------------------------------------------- /datacenter/eastmoney/industry_list.go: -------------------------------------------------------------------------------- 1 | // 获取选股器中的行业列表数据 2 | 3 | package eastmoney 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "time" 9 | 10 | "github.com/axiaoxin-com/goutils" 11 | "github.com/axiaoxin-com/logging" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | // RespIndustryList 接口返回的 json 结构 16 | type RespIndustryList struct { 17 | Result struct { 18 | Count int `json:"count"` 19 | Pages int `json:"pages"` 20 | Data []struct { 21 | Industry string `json:"INDUSTRY"` 22 | FirstLetter string `json:"FIRST_LETTER"` 23 | } `json:"data"` 24 | } `json:"result"` 25 | Success bool `json:"success"` 26 | Message string `json:"message"` 27 | Code int `json:"code"` 28 | } 29 | 30 | // QueryIndustryList 获取行业列表 31 | func (e EastMoney) QueryIndustryList(ctx context.Context) ([]string, error) { 32 | apiurl := "https://datacenter.eastmoney.com/stock/selection/api/data/get/" 33 | reqData := map[string]string{ 34 | "source": "SELECT_SECURITIES", 35 | "client": "APP", 36 | "type": "RPTA_APP_INDUSTRY", 37 | "sty": "ALL", 38 | } 39 | logging.Debug(ctx, "EastMoney IndustryList "+apiurl+" begin", zap.Any("reqData", reqData)) 40 | beginTime := time.Now() 41 | req, err := goutils.NewHTTPMultipartReq(ctx, apiurl, reqData) 42 | if err != nil { 43 | return nil, err 44 | } 45 | resp := RespIndustryList{} 46 | err = goutils.HTTPPOST(ctx, e.HTTPClient, req, &resp) 47 | latency := time.Now().Sub(beginTime).Milliseconds() 48 | logging.Debug(ctx, "EastMoney IndustryList "+apiurl+" end", 49 | zap.Int64("latency(ms)", latency), 50 | // zap.Any("resp", resp), 51 | ) 52 | if err != nil { 53 | return nil, err 54 | } 55 | if resp.Code != 0 { 56 | return nil, fmt.Errorf("%#v", resp) 57 | } 58 | result := []string{} 59 | for _, i := range resp.Result.Data { 60 | result = append(result, i.Industry) 61 | } 62 | return result, nil 63 | } 64 | -------------------------------------------------------------------------------- /datacenter/eastmoney/industry_list_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestIndustryList(t *testing.T) { 10 | data, err := _em.QueryIndustryList(_ctx) 11 | require.Nil(t, err) 12 | require.Len(t, data, 105) 13 | } 14 | -------------------------------------------------------------------------------- /datacenter/eastmoney/jiazhipinggu.go: -------------------------------------------------------------------------------- 1 | // 获取智能诊股中的价值评估 2 | 3 | package eastmoney 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "strings" 9 | "time" 10 | 11 | "github.com/axiaoxin-com/goutils" 12 | "github.com/axiaoxin-com/logging" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | // JZPG 价值评估 17 | type JZPG struct { 18 | // 股票名 19 | Secname string `json:"SecName"` 20 | // 行业名 21 | Industryname string `json:"IndustryName"` 22 | Type string `json:"Type"` 23 | // 当前排名 24 | Valueranking string `json:"ValueRanking"` 25 | // 排名总数 26 | Total string `json:"Total"` 27 | // 整体质地 28 | Valuetotalscore string `json:"ValueTotalScore"` 29 | Reportdate string `json:"ReportDate"` 30 | Reporttype string `json:"ReportType"` 31 | // 盈利能力 32 | Profitabilityscore string `json:"ProfitabilityScore"` 33 | // 成长能力 34 | Growupscore string `json:"GrowUpScore"` 35 | // 营运偿债能力 36 | Operationscore string `json:"OperationScore"` 37 | // 现金流 38 | Cashflowscore string `json:"CashFlowScore"` 39 | // 估值 40 | Valuationscore string `json:"ValuationScore"` 41 | } 42 | 43 | // GetValueRanking 当前排名 44 | func (j JZPG) GetValueRanking() string { 45 | return strings.Split(j.Valueranking, "|")[0] 46 | } 47 | 48 | // GetProfitabilityScore 盈利能力 49 | func (j JZPG) GetProfitabilityScore() string { 50 | return strings.Split(j.Profitabilityscore, "|")[0] 51 | } 52 | 53 | // GetGrowUpScore 成长能力 54 | func (j JZPG) GetGrowUpScore() string { 55 | return strings.Split(j.Growupscore, "|")[0] 56 | } 57 | 58 | // GetOperationScore 营运偿债能力 59 | func (j JZPG) GetOperationScore() string { 60 | return strings.Split(j.Operationscore, "|")[0] 61 | } 62 | 63 | // GetCashFlowScore 现金流能力 64 | func (j JZPG) GetCashFlowScore() string { 65 | return strings.Split(j.Cashflowscore, "|")[0] 66 | } 67 | 68 | // GetValuationScore 估值能力 69 | func (j JZPG) GetValuationScore() string { 70 | return strings.Split(j.Valuationscore, "|")[0] 71 | } 72 | 73 | // GetValueTotalScore 整体质地 74 | func (j JZPG) GetValueTotalScore() string { 75 | return strings.Split(j.Valuetotalscore, "|")[0] 76 | } 77 | 78 | func (j JZPG) String() string { 79 | return fmt.Sprintf( 80 | "%s属于%s行业,排名%s/%s。\n盈利能力%s,成长能力%s,营运偿债能力%s,现金流%s,估值%s,整体质地%s。", 81 | j.Secname, 82 | j.Industryname, 83 | j.GetValueRanking(), 84 | j.Total, 85 | j.GetProfitabilityScore(), 86 | j.GetGrowUpScore(), 87 | j.GetOperationScore(), 88 | j.GetCashFlowScore(), 89 | j.GetValuationScore(), 90 | j.GetValueTotalScore(), 91 | ) 92 | } 93 | 94 | // RespJiaZhiPingGu 综合评价接口返回结构 95 | type RespJiaZhiPingGu struct { 96 | Result struct { 97 | JiazhipingguGaiyao JZPG `json:"JiaZhiPingGu_GaiYao"` 98 | JiazhipingguWuweitulist []struct { 99 | Reportdate string `json:"ReportDate"` 100 | Reporttype string `json:"ReportType"` 101 | Profitabilityscore string `json:"ProfitabilityScore"` 102 | Growupscore string `json:"GrowUpScore"` 103 | Operationscore string `json:"OperationScore"` 104 | Cashflowscore string `json:"CashFlowScore"` 105 | Valuationscore string `json:"ValuationScore"` 106 | } `json:"JiaZhiPingGu_WuWeiTuList"` 107 | } `json:"Result"` 108 | Status int `json:"Status"` 109 | Message string `json:"Message"` 110 | Otherinfo struct { 111 | } `json:"OtherInfo"` 112 | } 113 | 114 | // QueryJiaZhiPingGu 返回智能诊股中的价值评估 115 | func (e EastMoney) QueryJiaZhiPingGu(ctx context.Context, secuCode string) (JZPG, error) { 116 | fc := e.GetFC(secuCode) 117 | apiurl := "https://emstockdiag.eastmoney.com/api/ZhenGuShouYe/GetJiaZhiPingGu" 118 | reqData := map[string]interface{}{ 119 | "fc": fc, 120 | } 121 | logging.Debug(ctx, "EastMoney QueryJiaZhiPingGu "+apiurl+" begin", zap.Any("reqData", reqData)) 122 | beginTime := time.Now() 123 | req, err := goutils.NewHTTPJSONReq(ctx, apiurl, reqData) 124 | if err != nil { 125 | return JZPG{}, err 126 | } 127 | resp := RespJiaZhiPingGu{} 128 | err = goutils.HTTPPOST(ctx, e.HTTPClient, req, &resp) 129 | latency := time.Now().Sub(beginTime).Milliseconds() 130 | logging.Debug( 131 | ctx, 132 | "EastMoney QueryJiaZhiPingGu "+apiurl+" end", 133 | zap.Int64("latency(ms)", latency), 134 | // zap.Any("resp", resp), 135 | ) 136 | if err != nil { 137 | return JZPG{}, err 138 | } 139 | if resp.Status != 0 { 140 | return JZPG{}, fmt.Errorf("%s %#v", secuCode, resp.Message) 141 | } 142 | return resp.Result.JiazhipingguGaiyao, nil 143 | } 144 | -------------------------------------------------------------------------------- /datacenter/eastmoney/jiazhipinggu_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestQueryJiaZhiPingGu(t *testing.T) { 10 | data, err := _em.QueryJiaZhiPingGu(_ctx, "002291.sz") 11 | require.Nil(t, err) 12 | t.Logf("%+v", data) 13 | } 14 | -------------------------------------------------------------------------------- /datacenter/eastmoney/org_rating.go: -------------------------------------------------------------------------------- 1 | // 获取机构评级统计 2 | 3 | package eastmoney 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "strings" 9 | "time" 10 | 11 | "github.com/axiaoxin-com/goutils" 12 | "github.com/axiaoxin-com/logging" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | // RespOrgRating 统计评级接口返回结构 17 | type RespOrgRating struct { 18 | Version string `json:"version"` 19 | Result struct { 20 | Pages int `json:"pages"` 21 | Data []OrgRating `json:"data"` 22 | Count int `json:"count"` 23 | } `json:"result"` 24 | Success bool `json:"success"` 25 | Message string `json:"message"` 26 | Code int `json:"code"` 27 | } 28 | 29 | // OrgRating 机构评级统计 30 | type OrgRating struct { 31 | // 时间段 32 | DateType string `json:"DATE_TYPE"` 33 | // 综合评级 34 | CompreRating string `json:"COMPRE_RATING"` 35 | } 36 | 37 | // OrgRatingList 评级列表 38 | type OrgRatingList []OrgRating 39 | 40 | // String 字符串输出 41 | func (o OrgRatingList) String() string { 42 | s := []string{} 43 | for _, i := range o { 44 | s = append(s, fmt.Sprintf("%s:%s", i.DateType, i.CompreRating)) 45 | } 46 | return strings.Join(s, "
") 47 | } 48 | 49 | // QueryOrgRating 获取评级统计 50 | func (e EastMoney) QueryOrgRating(ctx context.Context, secuCode string) (OrgRatingList, error) { 51 | apiurl := "https://datacenter.eastmoney.com/securities/api/data/get" 52 | params := map[string]string{ 53 | "source": "SECURITIES", 54 | "client": "APP", 55 | "type": "RPT_RES_ORGRATING", 56 | "sty": "DATE_TYPE,COMPRE_RATING", 57 | "filter": fmt.Sprintf(`(SECUCODE="%s")`, strings.ToUpper(secuCode)), 58 | "sr": "1", 59 | "st": "DATE_TYPE_CODE", 60 | } 61 | logging.Debug(ctx, "EastMoney QueryOrgRating "+apiurl+" begin", zap.Any("params", params)) 62 | beginTime := time.Now() 63 | apiurl, err := goutils.NewHTTPGetURLWithQueryString(ctx, apiurl, params) 64 | if err != nil { 65 | return nil, err 66 | } 67 | resp := RespOrgRating{} 68 | err = goutils.HTTPGET(ctx, e.HTTPClient, apiurl, nil, &resp) 69 | latency := time.Now().Sub(beginTime).Milliseconds() 70 | logging.Debug(ctx, "EastMoney QueryOrgRating "+apiurl+" end", 71 | zap.Int64("latency(ms)", latency), 72 | // zap.Any("resp", resp), 73 | ) 74 | if err != nil { 75 | return nil, err 76 | } 77 | if resp.Code != 0 { 78 | return nil, fmt.Errorf("%s %#v", secuCode, resp) 79 | } 80 | return resp.Result.Data, nil 81 | } 82 | -------------------------------------------------------------------------------- /datacenter/eastmoney/org_rating_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestQueryOrgRating(t *testing.T) { 10 | data, err := _em.QueryOrgRating(_ctx, "002459.sz") 11 | require.Nil(t, err) 12 | require.Len(t, data, 3) 13 | } 14 | -------------------------------------------------------------------------------- /datacenter/eastmoney/profit_predict.go: -------------------------------------------------------------------------------- 1 | // 获取盈利预测 2 | 3 | package eastmoney 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "strings" 9 | "time" 10 | 11 | "github.com/axiaoxin-com/goutils" 12 | "github.com/axiaoxin-com/logging" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | // RespProfitPredict 盈利预测接口返回结构 17 | type RespProfitPredict struct { 18 | Version string `json:"version"` 19 | Result struct { 20 | Pages int `json:"pages"` 21 | Data []ProfitPredict `json:"data"` 22 | Count int `json:"count"` 23 | } `json:"result"` 24 | Success bool `json:"success"` 25 | Message string `json:"message"` 26 | Code int `json:"code"` 27 | } 28 | 29 | // ProfitPredict 盈利预测 30 | type ProfitPredict struct { 31 | // 年份 32 | PredictYear int `json:"PREDICT_YEAR"` 33 | // 预测每股收益 34 | Eps float64 `json:"EPS"` 35 | // 预测市盈率 36 | Pe float64 `json:"PE"` 37 | } 38 | 39 | // ProfitPredictList 预测列表 40 | type ProfitPredictList []ProfitPredict 41 | 42 | func (p ProfitPredictList) String() string { 43 | s := []string{} 44 | for _, i := range p { 45 | s = append(s, fmt.Sprintf("%d | 预测每股收益:%f 预测市盈率:%f", i.PredictYear, i.Eps, i.Pe)) 46 | } 47 | return strings.Join(s, "
") 48 | } 49 | 50 | // QueryProfitPredict 获取盈利预测 51 | func (e EastMoney) QueryProfitPredict(ctx context.Context, secuCode string) (ProfitPredictList, error) { 52 | apiurl := "https://datacenter.eastmoney.com/securities/api/data/get" 53 | params := map[string]string{ 54 | "source": "SECURITIES", 55 | "client": "APP", 56 | "type": "RPT_RES_PROFITPREDICT", 57 | "sty": "PREDICT_YEAR,EPS,PE", 58 | "filter": fmt.Sprintf(`(SECUCODE="%s")`, strings.ToUpper(secuCode)), 59 | "sr": "1", 60 | "st": "PREDICT_YEAR", 61 | } 62 | logging.Debug(ctx, "EastMoney QueryProfitPredict "+apiurl+" begin", zap.Any("params", params)) 63 | beginTime := time.Now() 64 | apiurl, err := goutils.NewHTTPGetURLWithQueryString(ctx, apiurl, params) 65 | if err != nil { 66 | return nil, err 67 | } 68 | resp := RespProfitPredict{} 69 | err = goutils.HTTPGET(ctx, e.HTTPClient, apiurl, nil, &resp) 70 | latency := time.Now().Sub(beginTime).Milliseconds() 71 | logging.Debug(ctx, "EastMoney QueryProfitPredict "+apiurl+" end", 72 | zap.Int64("latency(ms)", latency), 73 | // zap.Any("resp", resp), 74 | ) 75 | if err != nil { 76 | return nil, err 77 | } 78 | if resp.Code != 0 { 79 | return nil, fmt.Errorf("%s %#v", secuCode, resp) 80 | } 81 | return resp.Result.Data, nil 82 | } 83 | -------------------------------------------------------------------------------- /datacenter/eastmoney/profit_predict_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestQueryProfitPredict(t *testing.T) { 10 | data, err := _em.QueryProfitPredict(_ctx, "002459.sz") 11 | require.Nil(t, err) 12 | require.Len(t, data, 3) 13 | } 14 | -------------------------------------------------------------------------------- /datacenter/eastmoney/query_fund_by_stock.go: -------------------------------------------------------------------------------- 1 | // 天天基金根据股票查询基金 2 | 3 | package eastmoney 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "time" 9 | 10 | "github.com/axiaoxin-com/goutils" 11 | "github.com/axiaoxin-com/logging" 12 | "github.com/corpix/uarand" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | // HoldStockFund 持有指定股票的基金 17 | type HoldStockFund struct { 18 | Fcode string `json:"FCODE"` 19 | Shortname string `json:"SHORTNAME"` 20 | Holdstock string `json:"HOLDSTOCK"` 21 | Stockname string `json:"STOCKNAME"` 22 | Zjzbl float64 `json:"ZJZBL"` 23 | Tsrq string `json:"TSRQ"` 24 | Chgtype string `json:"CHGTYPE"` 25 | Chgnum float64 `json:"CHGNUM"` 26 | SylY float64 `json:"SYL_Y"` 27 | Syl6Y float64 `json:"SYL_6Y"` 28 | Isbuy string `json:"ISBUY"` 29 | Stocktexch string `json:"STOCKTEXCH"` 30 | Newtexch string `json:"NEWTEXCH"` 31 | Zjzblchg float64 `json:"ZJZBLCHG"` 32 | Zjzblchgtype string `json:"ZJZBLCHGTYPE"` 33 | } 34 | 35 | // RespQueryFundByStock QueryFundByStock 原始api返回的结构 36 | type RespQueryFundByStock struct { 37 | Datas struct { 38 | Datas []HoldStockFund `json:"Datas"` 39 | Stocktexch string `json:"STOCKTEXCH"` 40 | Newtexch string `json:"NEWTEXCH"` 41 | } `json:"Datas"` 42 | ErrCode int `json:"ErrCode"` 43 | Success bool `json:"Success"` 44 | ErrMsg interface{} `json:"ErrMsg"` 45 | Message interface{} `json:"Message"` 46 | ErrorCode string `json:"ErrorCode"` 47 | ErrorMessage interface{} `json:"ErrorMessage"` 48 | ErrorMsgLst interface{} `json:"ErrorMsgLst"` 49 | TotalCount int `json:"TotalCount"` 50 | Expansion interface{} `json:"Expansion"` 51 | } 52 | 53 | // QueryFundByStock 根据股票查询基金 54 | func (e EastMoney) QueryFundByStock(ctx context.Context, stockName, stockCode string) ([]HoldStockFund, error) { 55 | apiurl := fmt.Sprintf( 56 | "https://fundztapi.eastmoney.com/FundSpecialApiNew/FundSpecialApiGpGetFunds?pageIndex=1&pageSize=10000&isBuy=1&sortName=ZJZBL&sortType=DESC&deviceid=1&version=6.9.9&product=EFund&plat=Iphone&name=%s&code=%s", 57 | stockName, 58 | stockCode, 59 | ) 60 | logging.Debug(ctx, "EastMoney QueryFundByStock "+apiurl+" begin") 61 | beginTime := time.Now() 62 | resp := RespQueryFundByStock{} 63 | header := map[string]string{ 64 | "user-agent": uarand.GetRandom(), 65 | } 66 | err := goutils.HTTPGET(ctx, e.HTTPClient, apiurl, header, &resp) 67 | latency := time.Now().Sub(beginTime).Milliseconds() 68 | logging.Debug( 69 | ctx, 70 | "EastMoney QueryFundByStock "+apiurl+" end", 71 | zap.Int64("latency(ms)", latency), 72 | // zap.Any("resp", resp), 73 | ) 74 | if err != nil { 75 | return nil, err 76 | } 77 | return resp.Datas.Datas, nil 78 | } 79 | -------------------------------------------------------------------------------- /datacenter/eastmoney/query_fund_by_stock_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestQueryFundByStock(t *testing.T) { 10 | data, err := _em.QueryFundByStock(_ctx, "金域医学", "603882") 11 | require.Nil(t, err) 12 | require.NotEmpty(t, data) 13 | t.Log("data:", data) 14 | } 15 | -------------------------------------------------------------------------------- /datacenter/eastmoney/select_stocks_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestQuerySelectedStocks(t *testing.T) { 11 | data, err := _em.QuerySelectedStocks(_ctx) 12 | require.Nil(t, err) 13 | require.NotEmpty(t, data) 14 | } 15 | 16 | func TestQuerySelectedStocksWithFilter(t *testing.T) { 17 | filter := DefaultFilter 18 | filter.SpecialSecurityCodeList = []string{"002312"} 19 | data, err := _em.QuerySelectedStocksWithFilter(_ctx, filter) 20 | require.Nil(t, err) 21 | b, _ := json.Marshal(data) 22 | t.Log(string(b)) 23 | } 24 | -------------------------------------------------------------------------------- /datacenter/eastmoney/valuation_status.go: -------------------------------------------------------------------------------- 1 | // 获取估值状态 2 | 3 | package eastmoney 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "strings" 9 | "time" 10 | 11 | "github.com/axiaoxin-com/goutils" 12 | "github.com/axiaoxin-com/logging" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | // RespValuation 估值状态接口返回结构 17 | type RespValuation struct { 18 | Version string `json:"version"` 19 | Result struct { 20 | Pages int `json:"pages"` 21 | Data []struct { 22 | ValationStatus string `json:"VALATION_STATUS"` 23 | } `json:"data"` 24 | Count int `json:"count"` 25 | } `json:"result"` 26 | Success bool `json:"success"` 27 | Message string `json:"message"` 28 | Code int `json:"code"` 29 | } 30 | 31 | // QueryValuationStatus 获取估值状态 32 | func (e EastMoney) QueryValuationStatus(ctx context.Context, secuCode string) (map[string]string, error) { 33 | valuations := map[string]string{} 34 | secuCode = strings.ToUpper(secuCode) 35 | apiurl := "https://datacenter.eastmoney.com/securities/api/data/get" 36 | // 市盈率估值 37 | params := map[string]string{ 38 | "type": "RPT_VALUATIONSTATUS", 39 | "sty": "VALATION_STATUS", 40 | "p": "1", 41 | "ps": "1", 42 | "var": "source=DataCenter", 43 | "client": "APP", 44 | "filter": fmt.Sprintf(`(SECUCODE="%s")(INDICATOR_TYPE="1")`, secuCode), 45 | } 46 | beginTime := time.Now() 47 | apiurl1, err := goutils.NewHTTPGetURLWithQueryString(ctx, apiurl, params) 48 | logging.Debug(ctx, "EastMoney QueryValuationStatus "+apiurl1+" begin", zap.Any("params", params)) 49 | if err != nil { 50 | return nil, err 51 | } 52 | resp := RespValuation{} 53 | if err := goutils.HTTPGET(ctx, e.HTTPClient, apiurl1, nil, &resp); err != nil { 54 | return nil, err 55 | } 56 | latency := time.Now().Sub(beginTime).Milliseconds() 57 | logging.Debug( 58 | ctx, 59 | "EastMoney QueryValuationStatus "+apiurl1+" end", 60 | zap.Int64("latency(ms)", latency), 61 | // zap.Any("resp", resp), 62 | ) 63 | if resp.Code != 0 { 64 | return nil, fmt.Errorf("%s %#v", secuCode, resp) 65 | } 66 | if len(resp.Result.Data) > 0 { 67 | valuations["市盈率"] = resp.Result.Data[0].ValationStatus 68 | } 69 | 70 | // 市净率估值 71 | params["filter"] = fmt.Sprintf(`(SECUCODE="%s")(INDICATOR_TYPE="2")`, secuCode) 72 | beginTime = time.Now() 73 | apiurl2, err := goutils.NewHTTPGetURLWithQueryString(ctx, apiurl, params) 74 | logging.Debug(ctx, "EastMoney QueryValuationStatus "+apiurl2+" begin", zap.Any("params", params)) 75 | if err != nil { 76 | return nil, err 77 | } 78 | if err := goutils.HTTPGET(ctx, e.HTTPClient, apiurl2, nil, &resp); err != nil { 79 | return nil, err 80 | } 81 | latency = time.Now().Sub(beginTime).Milliseconds() 82 | logging.Debug( 83 | ctx, 84 | "EastMoney QueryValuationStatus "+apiurl2+" end", 85 | zap.Int64("latency(ms)", latency), 86 | // zap.Any("resp", resp), 87 | ) 88 | if resp.Code != 0 { 89 | return nil, fmt.Errorf("%s %#v", secuCode, resp) 90 | } 91 | if len(resp.Result.Data) > 0 { 92 | valuations["市净率"] = resp.Result.Data[0].ValationStatus 93 | } 94 | 95 | // 市销率估值 96 | params["filter"] = fmt.Sprintf(`(SECUCODE="%s")(INDICATOR_TYPE="3")`, secuCode) 97 | beginTime = time.Now() 98 | apiurl3, err := goutils.NewHTTPGetURLWithQueryString(ctx, apiurl, params) 99 | logging.Debug(ctx, "EastMoney QueryValuationStatus "+apiurl3+" begin", zap.Any("params", params)) 100 | if err != nil { 101 | return nil, err 102 | } 103 | if err := goutils.HTTPGET(ctx, e.HTTPClient, apiurl3, nil, &resp); err != nil { 104 | return nil, err 105 | } 106 | latency = time.Now().Sub(beginTime).Milliseconds() 107 | logging.Debug( 108 | ctx, 109 | "EastMoney QueryValuationStatus "+apiurl3+" end", 110 | zap.Int64("latency(ms)", latency), 111 | // zap.Any("resp", resp), 112 | ) 113 | if resp.Code != 0 { 114 | return nil, fmt.Errorf("%s %#v", secuCode, resp) 115 | } 116 | if len(resp.Result.Data) > 0 { 117 | valuations["市销率"] = resp.Result.Data[0].ValationStatus 118 | } 119 | 120 | // 市现率估值 121 | params["filter"] = fmt.Sprintf(`(SECUCODE="%s")(INDICATOR_TYPE="4")`, secuCode) 122 | beginTime = time.Now() 123 | apiurl4, err := goutils.NewHTTPGetURLWithQueryString(ctx, apiurl, params) 124 | logging.Debug(ctx, "EastMoney QueryValuationStatus "+apiurl4+" begin", zap.Any("params", params)) 125 | if err != nil { 126 | return nil, err 127 | } 128 | err = goutils.HTTPGET(ctx, e.HTTPClient, apiurl4, nil, &resp) 129 | latency = time.Now().Sub(beginTime).Milliseconds() 130 | logging.Debug( 131 | ctx, 132 | "EastMoney QueryValuationStatus "+apiurl4+" end", 133 | zap.Int64("latency(ms)", latency), 134 | // zap.Any("resp", resp), 135 | ) 136 | if err != nil { 137 | return nil, err 138 | } 139 | if resp.Code != 0 { 140 | return nil, fmt.Errorf("%s %#v", secuCode, resp) 141 | } 142 | if len(resp.Result.Data) > 0 { 143 | valuations["市现率"] = resp.Result.Data[0].ValationStatus 144 | } 145 | 146 | return valuations, nil 147 | } 148 | -------------------------------------------------------------------------------- /datacenter/eastmoney/valuation_status_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestQueryValuationStatus(t *testing.T) { 10 | data, err := _em.QueryValuationStatus(_ctx, "603043.SH") 11 | require.Nil(t, err) 12 | require.Len(t, data, 4) 13 | t.Log("data:", data) 14 | } 15 | -------------------------------------------------------------------------------- /datacenter/eastmoney/zonghepingjia.go: -------------------------------------------------------------------------------- 1 | // 获取智能诊股中的综合评价 2 | 3 | package eastmoney 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "time" 9 | 10 | "github.com/axiaoxin-com/goutils" 11 | "github.com/axiaoxin-com/logging" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | // ZHPJ 综合评价 16 | type ZHPJ struct { 17 | Securitycode string `json:"SecurityCode"` 18 | Updatetime string `json:"UpdateTime"` 19 | Totalscore string `json:"TotalScore"` 20 | Totalscorechg string `json:"TotalScoreCHG"` 21 | Leadpre interface{} `json:"LeadPre"` 22 | Risepro interface{} `json:"RisePro"` 23 | // 消息面 24 | Msgcount string `json:"MsgCount"` 25 | // 主力资金 26 | Capitalscore string `json:"CapitalScore"` 27 | // 短期呈现 28 | D1 string `json:"D1"` 29 | // 公司质地 30 | Valuescore string `json:"ValueScore"` 31 | // 市场关注意愿 32 | Marketscorechg string `json:"MarketScoreCHG"` 33 | Status string `json:"Status"` 34 | // 评分 35 | Pingfennum string `json:"PingFenNum"` 36 | // 打败 xxx 的股票 37 | Dabaishichangnum string `json:"DaBaiShiChangNum"` 38 | // 次日上涨概率 39 | Shangzhanggailvnum string `json:"ShangZhangGaiLvNum"` 40 | Checkzhengustatus bool `json:"CheckZhenGuStatus"` 41 | } 42 | 43 | // RespZongHePingJia 综合评价接口返回结构 44 | type RespZongHePingJia struct { 45 | Result struct { 46 | Zonghepingjia ZHPJ `json:"ZongHePingJia"` 47 | } `json:"Result"` 48 | Status int `json:"Status"` 49 | Message string `json:"Message"` 50 | Otherinfo struct { 51 | } `json:"OtherInfo"` 52 | } 53 | 54 | // QueryZongHePingJia 返回智能诊股中的综合评价 55 | func (e EastMoney) QueryZongHePingJia(ctx context.Context, secuCode string) (ZHPJ, error) { 56 | fc := e.GetFC(secuCode) 57 | apiurl := "https://emstockdiag.eastmoney.com/api//ZhenGuShouYe/GetZongHePingJia" 58 | reqData := map[string]interface{}{ 59 | "fc": fc, 60 | } 61 | logging.Debug(ctx, "EastMoney QueryZongHePingJia "+apiurl+" begin", zap.Any("reqData", reqData)) 62 | beginTime := time.Now() 63 | req, err := goutils.NewHTTPJSONReq(ctx, apiurl, reqData) 64 | if err != nil { 65 | return ZHPJ{}, err 66 | } 67 | resp := RespZongHePingJia{} 68 | err = goutils.HTTPPOST(ctx, e.HTTPClient, req, &resp) 69 | latency := time.Now().Sub(beginTime).Milliseconds() 70 | logging.Debug( 71 | ctx, 72 | "EastMoney QueryZongHePingJia "+apiurl+" end", 73 | zap.Int64("latency(ms)", latency), 74 | // zap.Any("resp", resp), 75 | ) 76 | if err != nil { 77 | return ZHPJ{}, err 78 | } 79 | if resp.Status != 0 { 80 | return ZHPJ{}, fmt.Errorf("%s %#v", secuCode, resp.Message) 81 | } 82 | return resp.Result.Zonghepingjia, nil 83 | } 84 | -------------------------------------------------------------------------------- /datacenter/eastmoney/zonghepingjia_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestQueryZongHePingJia(t *testing.T) { 10 | data, err := _em.QueryZongHePingJia(_ctx, "600809.sh") 11 | require.Nil(t, err) 12 | t.Logf("%+v", data) 13 | } 14 | -------------------------------------------------------------------------------- /datacenter/eastmoney/zscfg.go: -------------------------------------------------------------------------------- 1 | // 指数成分股 2 | 3 | package eastmoney 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | 9 | "github.com/axiaoxin-com/goutils" 10 | "github.com/corpix/uarand" 11 | ) 12 | 13 | // ZSCFGItem 指数成分股信息 14 | type ZSCFGItem struct { 15 | IndexCode string `json:"IndexCode"` // 指数代码 16 | IndexName string `json:"IndexName"` // 指数名称 17 | StockCode string `json:"StockCode"` // 股票代码 18 | StockName string `json:"StockName"` // 股票名称 19 | Snewprice string `json:"SNEWPRICE"` // 最新价格 20 | Snewchg string `json:"SNEWCHG"` // 最新涨幅 21 | Marketcappct string `json:"MARKETCAPPCT"` // 持仓比例(%) 22 | StockTEXCH string `json:"StockTEXCH"` 23 | Dctexch string `json:"DCTEXCH"` 24 | } 25 | 26 | // RspZSCFG ZSCFG接口返回结构 27 | type RspZSCFG struct { 28 | Datas []ZSCFGItem `json:"Datas"` 29 | ErrCode int `json:"ErrCode"` 30 | Success bool `json:"Success"` 31 | ErrMsg interface{} `json:"ErrMsg"` 32 | Message interface{} `json:"Message"` 33 | ErrorCode string `json:"ErrorCode"` 34 | ErrorMessage interface{} `json:"ErrorMessage"` 35 | ErrorMsgLst interface{} `json:"ErrorMsgLst"` 36 | TotalCount int `json:"TotalCount"` 37 | Expansion interface{} `json:"Expansion"` 38 | } 39 | 40 | // ZSCFG 返回指数成分股列表 41 | func (e EastMoney) ZSCFG(ctx context.Context, indexCode string) (results []ZSCFGItem, err error) { 42 | apiurl := fmt.Sprintf( 43 | "https://fundztapi.eastmoney.com/FundSpecialApiNew/FundSpecialZSB30ZSCFG?IndexCode=%s&Version=6.5.5&deviceid=-&pageIndex=1&pageSize=10000&plat=Iphone&product=EFund", 44 | indexCode, 45 | ) 46 | header := map[string]string{ 47 | "user-agent": uarand.GetRandom(), 48 | } 49 | rsp := RspZSCFG{} 50 | if err := goutils.HTTPGET(ctx, e.HTTPClient, apiurl, header, &rsp); err != nil { 51 | return nil, err 52 | } 53 | if rsp.ErrCode != 0 { 54 | return nil, fmt.Errorf("ZSCFG rsp code error, rsp:%+v", rsp) 55 | } 56 | if len(rsp.Datas) != rsp.TotalCount { 57 | return nil, fmt.Errorf("ZSCFG rsp data len:%d != TotalCount:%d", len(rsp.Datas), rsp.TotalCount) 58 | } 59 | return rsp.Datas, nil 60 | } 61 | -------------------------------------------------------------------------------- /datacenter/eastmoney/zscfg_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestZSCFG(t *testing.T) { 10 | results, err := _em.ZSCFG(_ctx, "000905") 11 | require.Nil(t, err) 12 | t.Log(results) 13 | } 14 | -------------------------------------------------------------------------------- /datacenter/eastmoney/zz500.go: -------------------------------------------------------------------------------- 1 | // 中证500成分股 2 | 3 | package eastmoney 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | 9 | "github.com/axiaoxin-com/goutils" 10 | "github.com/corpix/uarand" 11 | ) 12 | 13 | // ZZ500Item 中证500成分股信息 14 | type ZZ500Item struct { 15 | Secucode string `json:"SECUCODE"` // 股票代码.XX 16 | SecurityCode string `json:"SECURITY_CODE"` // 股票代码 17 | SecurityNameAbbr string `json:"SECURITY_NAME_ABBR"` // 股票简称 18 | ClosePrice float64 `json:"CLOSE_PRICE"` // 最新价格 19 | Industry string `json:"INDUSTRY"` // 主营行业 20 | Region string `json:"REGION"` // 地区 21 | Weight float64 `json:"WEIGHT"` // 持仓比例(%) 22 | Eps float64 `json:"EPS"` // 每股收益 23 | Bps float64 `json:"BPS"` // 每股净资产 24 | Roe float64 `json:"ROE"` // 净资产收益率 25 | TotalShares float64 `json:"TOTAL_SHARES"` // 总股本(亿股) 26 | FreeShares float64 `json:"FREE_SHARES"` // 流通股本(亿股) 27 | FreeCap float64 `json:"FREE_CAP"` // 流通市值(亿元) 28 | Type string `json:"TYPE"` 29 | F2 interface{} `json:"f2"` 30 | F3 interface{} `json:"f3"` 31 | } 32 | 33 | // RspZZ500 ZZ500接口返回结构 34 | type RspZZ500 struct { 35 | Version string `json:"version"` 36 | Result struct { 37 | Pages int `json:"pages"` 38 | Data []ZZ500Item `json:"data"` 39 | Count int `json:"count"` 40 | } `json:"result"` 41 | Success bool `json:"success"` 42 | Message string `json:"message"` 43 | Code int `json:"code"` 44 | } 45 | 46 | // ZZ500 返回中证500成分股列表 47 | func (e EastMoney) ZZ500(ctx context.Context) (results []ZZ500Item, err error) { 48 | apiurl := "https://datacenter-web.eastmoney.com/api/data/v1/get?sortColumns=ROE&sortTypes=-1&pageSize=500&pageNumber=1&reportName=RPT_INDEX_TS_COMPONENT&columns=SECUCODE%2CSECURITY_CODE%2CTYPE%2CSECURITY_NAME_ABBR%2CCLOSE_PRICE%2CINDUSTRY%2CREGION%2CWEIGHT%2CEPS%2CBPS%2CROE%2CTOTAL_SHARES%2CFREE_SHARES%2CFREE_CAP"eColumns=f2%2Cf3"eType=0&source=WEB&client=WEB&filter=(TYPE%3D%223%22)" 49 | header := map[string]string{ 50 | "user-agent": uarand.GetRandom(), 51 | } 52 | rsp := RspZZ500{} 53 | if err := goutils.HTTPGET(ctx, e.HTTPClient, apiurl, header, &rsp); err != nil { 54 | return nil, err 55 | } 56 | if rsp.Code != 0 { 57 | return nil, fmt.Errorf("ZZ500 rsp code error, rsp:%+v", rsp) 58 | } 59 | if len(rsp.Result.Data) != 500 { 60 | return nil, fmt.Errorf("ZZ500 rsp data len != 500, len=%d", len(rsp.Result.Data)) 61 | } 62 | return rsp.Result.Data, nil 63 | } 64 | -------------------------------------------------------------------------------- /datacenter/eastmoney/zz500_test.go: -------------------------------------------------------------------------------- 1 | package eastmoney 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestZZ500(t *testing.T) { 11 | results, err := _em.ZZ500(_ctx) 12 | fmt.Println(err) 13 | require.Nil(t, err) 14 | t.Log(results) 15 | } 16 | -------------------------------------------------------------------------------- /datacenter/eniu/README.md: -------------------------------------------------------------------------------- 1 | # eniu 2 | 3 | 亿牛网接口封装 4 | 5 | ## 实现功能 6 | 7 | - 获取历史股价信息 8 | - 计算股价历史波动率 9 | -------------------------------------------------------------------------------- /datacenter/eniu/eniu.go: -------------------------------------------------------------------------------- 1 | // 亿牛网数据源封装 2 | 3 | package eniu 4 | 5 | import ( 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | // Eniu 亿牛网数据源 11 | type Eniu struct { 12 | // http 客户端 13 | HTTPClient *http.Client 14 | } 15 | 16 | // NewEniu 创建 Eniu 实例 17 | func NewEniu() Eniu { 18 | hc := &http.Client{ 19 | Timeout: time.Second * 60 * 5, 20 | } 21 | return Eniu{ 22 | HTTPClient: hc, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /datacenter/eniu/eniu_test.go: -------------------------------------------------------------------------------- 1 | package eniu 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | var ( 8 | _e = NewEniu() 9 | _ctx = context.TODO() 10 | ) 11 | -------------------------------------------------------------------------------- /datacenter/eniu/historical_price.go: -------------------------------------------------------------------------------- 1 | // 获取历史股价 2 | 3 | package eniu 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "fmt" 9 | "math" 10 | "strings" 11 | "time" 12 | 13 | "github.com/axiaoxin-com/goutils" 14 | "github.com/axiaoxin-com/logging" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | // RespHistoricalStockPrice 历史股价接口返回结构 19 | type RespHistoricalStockPrice struct { 20 | Date []string `json:"date"` 21 | Price []float64 `json:"price"` 22 | } 23 | 24 | // LastYearFinalPrice 获取去年12月份最后一个交易日的股价 25 | func (p RespHistoricalStockPrice) LastYearFinalPrice() float64 { 26 | if len(p.Date) == 0 { 27 | return 0 28 | } 29 | for i := len(p.Date) - 1; i > 0; i-- { 30 | prefix := fmt.Sprintf("%d-12-", time.Now().Year()-1) 31 | date := p.Date[i] 32 | if strings.Contains(date, prefix) { 33 | price := p.Price[i] 34 | logging.Debugf(nil, "date:%s price:%f", date, price) 35 | return price 36 | } 37 | } 38 | return 0 39 | } 40 | 41 | // HistoricalVolatility 计算历史波动率 42 | // 历史波动率计算方法:https://goodcalculators.com/historical-volatility-calculator/ 43 | // 1、从市场上获得标的股票在固定时间间隔(如每天DAY、每周WEEK或每月MONTH等)上的价格。 44 | // 2、对于每个时间段,求出该时间段末的股价与该时段初的股价之比的自然对数。 45 | // 3、求出这些对数值的标准差即为历史波动率的估计值 46 | // 4、若将日、周等标准差转化为年标准差,需要再乘以一年中包含的时段数量的平方根(如,选取时间间隔为每天,则若扣除闭市,每年中有250个交易日,应乘以根号250) 47 | func (p RespHistoricalStockPrice) HistoricalVolatility(ctx context.Context, period string) (float64, error) { 48 | priceLen := len(p.Price) 49 | if priceLen == 0 { 50 | return -1.0, errors.New("no historical price data") 51 | } 52 | // 求末初股价比自然对数 53 | logs := []float64{} 54 | for i := priceLen - 1; i >= 1; i-- { 55 | endPrice := p.Price[i] 56 | startPrice := p.Price[i-1] 57 | log := math.Log(endPrice / startPrice) 58 | logs = append(logs, log) 59 | } 60 | // 标准差 61 | stdev, err := goutils.StdDeviationFloat64(logs) 62 | if err != nil { 63 | return -1.0, err 64 | } 65 | logging.Debugs(ctx, "stdev:", stdev) 66 | 67 | periodValue := float64(250) 68 | period = strings.ToUpper(period) 69 | switch period { 70 | case "DAY": 71 | periodValue = 1 72 | case "WEEK": 73 | periodValue = 5 74 | case "MONTH": 75 | periodValue = 21.75 76 | case "YEAR": 77 | periodValue = 250 78 | } 79 | volatility := stdev * math.Sqrt(periodValue) 80 | // 数据异常时全部股价为 0 导致返回 NaN 81 | if math.IsNaN(volatility) { 82 | return -1, errors.New("volatility is NaN") 83 | } 84 | return volatility, nil 85 | } 86 | 87 | // QueryHistoricalStockPrice 获取历史股价,最新数据在最后,有一天的延迟 88 | func (e Eniu) QueryHistoricalStockPrice(ctx context.Context, secuCode string) (RespHistoricalStockPrice, error) { 89 | apiurl := fmt.Sprintf("https://eniu.com/chart/pricea/%s/t/all", e.GetPathCode(ctx, secuCode)) 90 | logging.Debug(ctx, "EastMoney QueryOrgRating "+apiurl+" begin") 91 | beginTime := time.Now() 92 | resp := RespHistoricalStockPrice{} 93 | err := goutils.HTTPGET(ctx, e.HTTPClient, apiurl, nil, &resp) 94 | latency := time.Now().Sub(beginTime).Milliseconds() 95 | logging.Debug(ctx, "EastMoney QueryOrgRating "+apiurl+" end", zap.Int64("latency(ms)", latency), zap.Any("resp", resp)) 96 | return resp, err 97 | } 98 | 99 | // GetPathCode 返回接口 url path 中的股票代码 100 | func (e Eniu) GetPathCode(ctx context.Context, secuCode string) string { 101 | s := strings.Split(secuCode, ".") 102 | if len(s) != 2 { 103 | return "" 104 | } 105 | return strings.ToLower(s[1]) + s[0] 106 | } 107 | -------------------------------------------------------------------------------- /datacenter/eniu/historical_price_test.go: -------------------------------------------------------------------------------- 1 | package eniu 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestGetPathCode(t *testing.T) { 10 | code := _e.GetPathCode(_ctx, "002459.SZ") 11 | require.Equal(t, "sz002459", code) 12 | } 13 | 14 | func TestQueryHistoricalStockPrice(t *testing.T) { 15 | data, err := _e.QueryHistoricalStockPrice(_ctx, "002312.SZ") 16 | require.Nil(t, err) 17 | require.NotEmpty(t, data.Date) 18 | v, _ := data.HistoricalVolatility(_ctx, "YEAR") 19 | t.Log("volatility:", v) 20 | } 21 | 22 | func TestHistoricalVolatility(t *testing.T) { 23 | data := RespHistoricalStockPrice{ 24 | Price: []float64{ 25 | 8.47, 26 | 8.54, 27 | 8.3, 28 | 7.57, 29 | 7.77, 30 | 7.4, 31 | 8.23, 32 | 7.83, 33 | 7.43, 34 | 7.02, 35 | 6.75, 36 | 6.73, 37 | 6.7, 38 | 6.5, 39 | 7.45, 40 | 7.4, 41 | 7.25, 42 | 7.15, 43 | 7.25, 44 | 7.0, 45 | }, 46 | } 47 | d, err := data.HistoricalVolatility(_ctx, "DAY") 48 | require.Nil(t, err) 49 | w, err := data.HistoricalVolatility(_ctx, "WEEK") 50 | require.Nil(t, err) 51 | m, err := data.HistoricalVolatility(_ctx, "MONTH") 52 | require.Nil(t, err) 53 | y, err := data.HistoricalVolatility(_ctx, "YEAR") 54 | require.Nil(t, err) 55 | t.Log("day volatility:", d, " week volatility:", w, " month volatility:", m, " year volatility:", y) 56 | } 57 | -------------------------------------------------------------------------------- /datacenter/qq/README.md: -------------------------------------------------------------------------------- 1 | # qq 2 | 3 | 腾讯证券接口封装 4 | 5 | https://stockapp.finance.qq.com/mstats/# 6 | 7 | ## 实现功能 8 | 9 | - 关键词搜索 10 | -------------------------------------------------------------------------------- /datacenter/qq/keyword_search.go: -------------------------------------------------------------------------------- 1 | // 关键词搜索 2 | 3 | package qq 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/axiaoxin-com/goutils" 13 | "github.com/axiaoxin-com/logging" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | // SearchResult 搜索结果 18 | type SearchResult struct { 19 | // 数字代码 20 | SecurityCode string 21 | // 带后缀的代码 22 | Secucode string 23 | // 股票名称 24 | Name string 25 | } 26 | 27 | // KeywordSearch 关键词搜索, 股票、代码、拼音 28 | func (q QQ) KeywordSearch(ctx context.Context, kw string) (results []SearchResult, err error) { 29 | apiurl := fmt.Sprintf("https://smartbox.gtimg.cn/s3/?v=2&q=%s&t=all&c=1", kw) 30 | logging.Debug(ctx, "QQ KeywordSearch "+apiurl+" begin") 31 | beginTime := time.Now() 32 | resp, err := goutils.HTTPGETRaw(ctx, q.HTTPClient, apiurl, nil) 33 | latency := time.Now().Sub(beginTime).Milliseconds() 34 | logging.Debug(ctx, "QQ KeywordSearch "+apiurl+" end", zap.Int64("latency(ms)", latency), zap.Any("resp", string(resp))) 35 | if err != nil { 36 | return nil, err 37 | } 38 | respMap := map[string]string{} 39 | for _, line := range strings.Split(string(resp), ";") { 40 | lineitems := strings.Split(line, "=") 41 | if len(lineitems) != 2 { 42 | continue 43 | } 44 | k := strings.TrimSpace(lineitems[0]) 45 | v := strings.TrimSpace(lineitems[1]) 46 | respMap[k] = strings.Trim(v, `"`) 47 | } 48 | logging.Debugf(ctx, "respMap: %#v", respMap) 49 | resultsStrs := strings.Split(respMap["v_hint"], "^") 50 | logging.Debugs(ctx, "resultsStrs:", resultsStrs) 51 | for _, rs := range resultsStrs { 52 | matchedSlice := strings.Split(rs, "~") 53 | if len(matchedSlice) < 3 { 54 | logging.Debugf(ctx, "invalid matchedSlice:%v", matchedSlice) 55 | continue 56 | } 57 | market, securityCode, name := matchedSlice[0], matchedSlice[1], matchedSlice[2] 58 | // unicode -> cn 59 | name, err = strconv.Unquote(strings.Replace(strconv.Quote(string(name)), `\\u`, `\u`, -1)) 60 | if err != nil { 61 | return nil, err 62 | } 63 | result := SearchResult{ 64 | Secucode: securityCode + "." + market, 65 | SecurityCode: securityCode, 66 | Name: name, 67 | } 68 | results = append(results, result) 69 | } 70 | return 71 | } 72 | -------------------------------------------------------------------------------- /datacenter/qq/keyword_search_test.go: -------------------------------------------------------------------------------- 1 | package qq 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestKeywordSearch(t *testing.T) { 10 | results, err := _q.KeywordSearch(_ctx, "招商银行") 11 | require.Nil(t, err) 12 | t.Log(results) 13 | } 14 | -------------------------------------------------------------------------------- /datacenter/qq/qq.go: -------------------------------------------------------------------------------- 1 | // Package qq 腾讯证券接口封装 2 | package qq 3 | 4 | import ( 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | // QQ 新浪财经数据源 10 | type QQ struct { 11 | // http 客户端 12 | HTTPClient *http.Client 13 | } 14 | 15 | // NewQQ 创建 QQ 实例 16 | func NewQQ() QQ { 17 | hc := &http.Client{ 18 | Timeout: time.Second * 60 * 5, 19 | } 20 | return QQ{ 21 | HTTPClient: hc, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /datacenter/qq/qq_test.go: -------------------------------------------------------------------------------- 1 | package qq 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | var ( 8 | _q = NewQQ() 9 | _ctx = context.TODO() 10 | ) 11 | -------------------------------------------------------------------------------- /datacenter/sina/keyword_search.go: -------------------------------------------------------------------------------- 1 | // 关键词搜索 2 | 3 | package sina 4 | 5 | import ( 6 | "bytes" 7 | "context" 8 | "fmt" 9 | "io/ioutil" 10 | "sort" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/axiaoxin-com/goutils" 16 | "github.com/axiaoxin-com/logging" 17 | "github.com/pkg/errors" 18 | "go.uber.org/zap" 19 | "golang.org/x/text/encoding/simplifiedchinese" 20 | "golang.org/x/text/transform" 21 | ) 22 | 23 | // SearchResult 搜索结果 24 | type SearchResult struct { 25 | // 数字代码 26 | SecurityCode string 27 | // 带后缀的代码 28 | Secucode string 29 | // 股票名称 30 | Name string 31 | // 股市类型: 11=A股 31=港股 41=美股 103=英股 32 | Market int 33 | } 34 | 35 | // KeywordSearch 关键词搜索, 股票、代码、拼音 36 | func (s Sina) KeywordSearch(ctx context.Context, kw string) (results []SearchResult, err error) { 37 | apiurl := fmt.Sprintf("https://suggest3.sinajs.cn/suggest/key=%s", kw) 38 | logging.Debug(ctx, "Sina KeywordSearch "+apiurl+" begin") 39 | beginTime := time.Now() 40 | resp, err := goutils.HTTPGETRaw(ctx, s.HTTPClient, apiurl, nil) 41 | latency := time.Now().Sub(beginTime).Milliseconds() 42 | logging.Debug(ctx, "Sina KeywordSearch "+apiurl+" end", zap.Int64("latency(ms)", latency), zap.Any("resp", string(resp))) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | trans := transform.NewReader(bytes.NewReader(resp), simplifiedchinese.GBK.NewDecoder()) 48 | utf8resp, err := ioutil.ReadAll(trans) 49 | if err != nil { 50 | logging.Error(ctx, "transform ReadAll error:"+err.Error()) 51 | } 52 | ds := strings.Split(string(utf8resp), "=") 53 | if len(ds) != 2 { 54 | return nil, errors.New("search resp invalid:" + string(utf8resp)) 55 | } 56 | data := strings.Trim(ds[1], `"`) 57 | for _, line := range strings.Split(data, ";") { 58 | lineitems := strings.Split(line, ",") 59 | if len(lineitems) < 9 { 60 | continue 61 | } 62 | market, err := strconv.Atoi(lineitems[1]) 63 | if err != nil { 64 | logging.Errorf(ctx, "market:%s atoi error:%v", lineitems[1], err) 65 | } 66 | secucode := lineitems[3][2:] + "." + lineitems[3][:2] 67 | result := SearchResult{ 68 | SecurityCode: lineitems[2], 69 | Secucode: secucode, 70 | Name: lineitems[6], 71 | Market: market, 72 | } 73 | results = append(results, result) 74 | } 75 | // 按股市编号排序确保A股在前面 76 | sort.Slice(results, func(i, j int) bool { 77 | return results[i].Market < results[j].Market 78 | }) 79 | return 80 | } 81 | -------------------------------------------------------------------------------- /datacenter/sina/keyword_search_test.go: -------------------------------------------------------------------------------- 1 | package sina 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestKeywordSearch(t *testing.T) { 10 | results, err := _s.KeywordSearch(_ctx, "比亚迪") 11 | require.Nil(t, err) 12 | t.Log(results) 13 | } 14 | -------------------------------------------------------------------------------- /datacenter/sina/sina.go: -------------------------------------------------------------------------------- 1 | // Package sina 新浪财经接口封装 2 | package sina 3 | 4 | import ( 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | // Sina 新浪财经数据源 10 | type Sina struct { 11 | // http 客户端 12 | HTTPClient *http.Client 13 | } 14 | 15 | // NewSina 创建 Sina 实例 16 | func NewSina() Sina { 17 | hc := &http.Client{ 18 | Timeout: time.Second * 60 * 5, 19 | } 20 | return Sina{ 21 | HTTPClient: hc, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /datacenter/sina/sina_test.go: -------------------------------------------------------------------------------- 1 | package sina 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | var ( 8 | _s = NewSina() 9 | _ctx = context.TODO() 10 | ) 11 | -------------------------------------------------------------------------------- /datacenter/zszx/net_inflows.go: -------------------------------------------------------------------------------- 1 | // 获取个股指定时间段内资金净流入数据 2 | 3 | package zszx 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "fmt" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/axiaoxin-com/goutils" 14 | "github.com/axiaoxin-com/logging" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | // NetInflow 资金净流入详情 19 | type NetInflow struct { 20 | // 交易日期 21 | TrdDt string `json:"TrdDt"` 22 | // 当日股价 23 | ClsPrc string `json:"ClsPrc"` 24 | // 主力净流入(万元) 25 | MainMnyNetIn string `json:"MainMnyNetIn"` 26 | // 超大单净流入(万元) 27 | HugeNetIn string `json:"HugeNetIn"` 28 | // 大单净流入(万元) 29 | BigNetIn string `json:"BigNetIn"` 30 | // 中单净流入(万元) 31 | MidNetIn string `json:"MidNetIn"` 32 | // 小单净流入(万元) 33 | SmallNetIn string `json:"SmallNetIn"` 34 | TTLMnyNetIn string `json:"TtlMnyNetIn"` 35 | } 36 | 37 | // NetInflowList 净流入详情列表 38 | type NetInflowList []NetInflow 39 | 40 | func (n NetInflowList) String() string { 41 | ctx := context.Background() 42 | netInflowsLen := len(n) 43 | netInflow3days := "--" 44 | if netInflowsLen >= 3 { 45 | netInflow3days = fmt.Sprintf("近3日主力资金净流入:%.2f万元", n[:3].SumMainNetIn(ctx)) 46 | } 47 | netInflow5days := "--" 48 | if netInflowsLen >= 5 { 49 | netInflow5days = fmt.Sprintf("近5日主力资金净流入:%.2f万元", n[:5].SumMainNetIn(ctx)) 50 | } 51 | netInflow10days := "--" 52 | if netInflowsLen >= 10 { 53 | netInflow10days = fmt.Sprintf("近10日主力资金净流入:%.2f万元", n[:10].SumMainNetIn(ctx)) 54 | } 55 | netInflow20days := "--" 56 | if netInflowsLen >= 20 { 57 | netInflow20days = fmt.Sprintf("近20日主力资金净流入:%.2f万元", n[:20].SumMainNetIn(ctx)) 58 | } 59 | netInflow30days := "--" 60 | if netInflowsLen >= 30 { 61 | netInflow30days = fmt.Sprintf("近30日主力资金净流入:%.2f万元", n[:30].SumMainNetIn(ctx)) 62 | } 63 | netInflow40days := "--" 64 | if netInflowsLen >= 40 { 65 | netInflow40days = fmt.Sprintf("近40日主力资金净流入:%.2f万元", n[:40].SumMainNetIn(ctx)) 66 | } 67 | return fmt.Sprintf( 68 | "%s
%s
%s
%s
%s
%s", 69 | netInflow3days, 70 | netInflow5days, 71 | netInflow10days, 72 | netInflow20days, 73 | netInflow30days, 74 | netInflow40days, 75 | ) 76 | } 77 | 78 | // SumMainNetIn 主力净流入列表求和 79 | func (n NetInflowList) SumMainNetIn(ctx context.Context) float64 { 80 | var netFlowin float64 = 0.0 81 | for _, i := range n { 82 | mainNetIn, err := strconv.ParseFloat(i.MainMnyNetIn, 64) 83 | if err != nil { 84 | logging.Errorf(ctx, "Parse MainMnyNetIn:%v to Float error:%v", i.MainMnyNetIn, err) 85 | } 86 | netFlowin += mainNetIn 87 | } 88 | return netFlowin 89 | } 90 | 91 | // RespMainMoneyNetInflows QueryMainMoneyNetInflows 返回json结构 92 | type RespMainMoneyNetInflows struct { 93 | Success bool `json:"success"` 94 | Message string `json:"message"` 95 | Code int `json:"code"` 96 | Data NetInflowList `json:"data"` 97 | } 98 | 99 | // QueryMainMoneyNetInflows 查询主力资金净流入数据 100 | func (z Zszx) QueryMainMoneyNetInflows(ctx context.Context, secuCode, startDate, endDate string) (NetInflowList, error) { 101 | apiurl := "https://zszx.cmschina.com/pcnews/f10/stkcnmnyflow" 102 | stockCodeAndMarket := strings.Split(secuCode, ".") 103 | if len(stockCodeAndMarket) != 2 { 104 | return nil, errors.New("invalid secuCode:" + secuCode) 105 | } 106 | stockCode := stockCodeAndMarket[0] 107 | market := stockCodeAndMarket[1] 108 | marketCode := "0" 109 | if strings.ToUpper(market) == "SH" { 110 | marketCode = "1" 111 | } 112 | params := map[string]string{ 113 | "dateStart": startDate, 114 | "dateEnd": endDate, 115 | "ecode": marketCode, 116 | "scode": stockCode, 117 | } 118 | logging.Debug(ctx, "Zszx QueryMainMoneyNetInflows "+apiurl+" begin", zap.Any("params", params)) 119 | beginTime := time.Now() 120 | apiurl, err := goutils.NewHTTPGetURLWithQueryString(ctx, apiurl, params) 121 | if err != nil { 122 | return nil, err 123 | } 124 | resp := RespMainMoneyNetInflows{} 125 | err = goutils.HTTPGET(ctx, z.HTTPClient, apiurl, nil, &resp) 126 | latency := time.Now().Sub(beginTime).Milliseconds() 127 | logging.Debug( 128 | ctx, 129 | "Zszx QueryMainMoneyNetInflows "+apiurl+" end", 130 | zap.Int64("latency(ms)", latency), 131 | zap.Any("resp", resp), 132 | ) 133 | if err != nil { 134 | return nil, err 135 | } 136 | if resp.Code != 0 { 137 | return nil, fmt.Errorf("%s %#v", secuCode, resp) 138 | } 139 | return resp.Data, nil 140 | } 141 | -------------------------------------------------------------------------------- /datacenter/zszx/net_inflows_test.go: -------------------------------------------------------------------------------- 1 | package zszx 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestQueryMainMoneyNetInflows(t *testing.T) { 11 | now := time.Now() 12 | end := now.Format("2006-01-02") 13 | d, _ := time.ParseDuration("-720h") 14 | start := now.Add(d).Format("2006-01-02") 15 | results, err := _z.QueryMainMoneyNetInflows(_ctx, "002028.sz", start, end) 16 | require.Nil(t, err) 17 | require.NotEqual(t, len(results), 0) 18 | last3days := results[:3] 19 | t.Logf("last3days:%#v, sum:%f", last3days, last3days.SumMainNetIn(_ctx)) 20 | last5days := results[:5] 21 | t.Logf("last5days:%#v, sum:%f", last5days, last5days.SumMainNetIn(_ctx)) 22 | last10days := results[:10] 23 | t.Logf("last10days:%#v, sum:%f", last10days, last10days.SumMainNetIn(_ctx)) 24 | } 25 | -------------------------------------------------------------------------------- /datacenter/zszx/zszx.go: -------------------------------------------------------------------------------- 1 | // Package zszx 招商证券接口封装 2 | // https://zszx.cmschina.com/ 3 | package zszx 4 | 5 | import ( 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | // Zszx 招商证券接口 11 | type Zszx struct { 12 | // http 客户端 13 | HTTPClient *http.Client 14 | } 15 | 16 | // NewZszx 创建 Zszx 实例 17 | func NewZszx() Zszx { 18 | hc := &http.Client{ 19 | Timeout: time.Second * 60 * 5, 20 | } 21 | return Zszx{ 22 | HTTPClient: hc, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /datacenter/zszx/zszx_test.go: -------------------------------------------------------------------------------- 1 | package zszx 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | var ( 8 | _z = NewZszx() 9 | _ctx = context.TODO() 10 | ) 11 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/axiaoxin-com/investool 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/360EntSecGroup-Skylar/excelize/v2 v2.4.0 7 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 8 | github.com/avast/retry-go v3.0.0+incompatible 9 | github.com/axiaoxin-com/goutils v1.1.0 10 | github.com/axiaoxin-com/logging v1.2.11-0.20210710005236-5a960a1422ba 11 | github.com/axiaoxin-com/ratelimiter v1.0.3 12 | github.com/corpix/uarand v0.1.1 13 | github.com/deckarep/golang-set v1.8.0 14 | github.com/fsnotify/fsnotify v1.7.0 15 | github.com/gin-contrib/pprof v1.3.0 16 | github.com/gin-gonic/gin v1.9.1 17 | github.com/go-co-op/gocron v1.6.2 18 | github.com/gocarina/gocsv v0.0.0-20210516172204-ca9e8a8ddea8 19 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 20 | github.com/json-iterator/go v1.1.12 21 | github.com/olekukonko/tablewriter v0.0.5 22 | github.com/pkg/errors v0.9.1 23 | github.com/prometheus/client_golang v1.11.0 24 | github.com/spf13/viper v1.17.0 25 | github.com/stretchr/testify v1.8.4 26 | github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14 27 | github.com/swaggo/gin-swagger v1.2.0 28 | github.com/swaggo/swag v1.7.1 29 | github.com/urfave/cli/v2 v2.3.0 30 | go.uber.org/zap v1.21.0 31 | golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb 32 | golang.org/x/text v0.13.0 33 | ) 34 | 35 | require ( 36 | github.com/KyleBanks/depth v1.2.1 // indirect 37 | github.com/PuerkitoBio/purell v1.1.1 // indirect 38 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect 39 | github.com/antlabs/strsim v0.0.2 // indirect 40 | github.com/beorn7/perks v1.0.1 // indirect 41 | github.com/bytedance/sonic v1.9.1 // indirect 42 | github.com/cespare/xxhash/v2 v2.1.2 // indirect 43 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect 44 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect 45 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 46 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 47 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 48 | github.com/getsentry/sentry-go v0.6.0 // indirect 49 | github.com/gin-contrib/sse v0.1.0 // indirect 50 | github.com/go-openapi/jsonpointer v0.19.5 // indirect 51 | github.com/go-openapi/jsonreference v0.19.5 // indirect 52 | github.com/go-openapi/spec v0.20.3 // indirect 53 | github.com/go-openapi/swag v0.19.14 // indirect 54 | github.com/go-playground/locales v0.14.1 // indirect 55 | github.com/go-playground/universal-translator v0.18.1 // indirect 56 | github.com/go-playground/validator/v10 v10.14.0 // indirect 57 | github.com/go-redis/redis/v8 v8.11.5 // indirect 58 | github.com/goccy/go-json v0.10.2 // indirect 59 | github.com/golang/protobuf v1.5.3 // indirect 60 | github.com/hashicorp/hcl v1.0.0 // indirect 61 | github.com/josharian/intern v1.0.0 // indirect 62 | github.com/klauspost/cpuid/v2 v2.2.4 // indirect 63 | github.com/leodido/go-urn v1.2.4 // indirect 64 | github.com/magiconair/properties v1.8.7 // indirect 65 | github.com/mailru/easyjson v0.7.6 // indirect 66 | github.com/mattn/go-isatty v0.0.19 // indirect 67 | github.com/mattn/go-runewidth v0.0.9 // indirect 68 | github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect 69 | github.com/mitchellh/mapstructure v1.5.0 // indirect 70 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 71 | github.com/modern-go/reflect2 v1.0.2 // indirect 72 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect 73 | github.com/natefinch/lumberjack v2.0.0+incompatible // indirect 74 | github.com/patrickmn/go-cache v2.1.0+incompatible // indirect 75 | github.com/pelletier/go-toml/v2 v2.1.0 // indirect 76 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 77 | github.com/prometheus/client_model v0.2.0 // indirect 78 | github.com/prometheus/common v0.26.0 // indirect 79 | github.com/prometheus/procfs v0.6.0 // indirect 80 | github.com/richardlehane/mscfb v1.0.3 // indirect 81 | github.com/richardlehane/msoleps v1.0.1 // indirect 82 | github.com/robfig/cron/v3 v3.0.1 // indirect 83 | github.com/rs/xid v1.2.1 // indirect 84 | github.com/russross/blackfriday/v2 v2.0.1 // indirect 85 | github.com/sagikazarmark/locafero v0.3.0 // indirect 86 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 87 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 88 | github.com/sourcegraph/conc v0.3.0 // indirect 89 | github.com/speps/go-hashids v2.0.0+incompatible // indirect 90 | github.com/spf13/afero v1.10.0 // indirect 91 | github.com/spf13/cast v1.5.1 // indirect 92 | github.com/spf13/pflag v1.0.5 // indirect 93 | github.com/subosito/gotenv v1.6.0 // indirect 94 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 95 | github.com/ugorji/go/codec v1.2.11 // indirect 96 | github.com/xuri/efp v0.0.0-20210322160811-ab561f5b45e3 // indirect 97 | go.uber.org/atomic v1.9.0 // indirect 98 | go.uber.org/multierr v1.9.0 // indirect 99 | golang.org/x/arch v0.3.0 // indirect 100 | golang.org/x/crypto v0.13.0 // indirect 101 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect 102 | golang.org/x/net v0.15.0 // indirect 103 | golang.org/x/sync v0.3.0 // indirect 104 | golang.org/x/sys v0.12.0 // indirect 105 | golang.org/x/time v0.3.0 // indirect 106 | golang.org/x/tools v0.13.0 // indirect 107 | google.golang.org/protobuf v1.31.0 // indirect 108 | gopkg.in/ini.v1 v1.67.0 // indirect 109 | gopkg.in/yaml.v2 v2.4.0 // indirect 110 | gopkg.in/yaml.v3 v3.0.1 // indirect 111 | gorm.io/gorm v1.23.4 // indirect 112 | ) 113 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | //go:generate swag init --dir ./ --generalInfo routes/routes.go --propertyStrategy snakecase --output ./routes/docs 2 | 3 | // Package main investool is my stock bot 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "time" 10 | 11 | "github.com/axiaoxin-com/investool/cmds" 12 | "github.com/axiaoxin-com/investool/models" 13 | "github.com/axiaoxin-com/investool/version" 14 | "github.com/spf13/viper" 15 | "github.com/urfave/cli/v2" 16 | ) 17 | 18 | var ( 19 | // DefaultLoglevel 日志级别默认值 20 | DefaultLoglevel = "info" 21 | // ProcessorOptions 要启动运行的进程可选项 22 | ProcessorOptions = []string{cmds.ProcessorChecker, cmds.ProcessorExportor, cmds.ProcessorWebserver, cmds.ProcessorIndex, cmds.ProcessorJSON} 23 | ) 24 | 25 | func init() { 26 | viper.SetDefault("app.chan_size", 1) 27 | models.InitGlobalVars() 28 | } 29 | 30 | func main() { 31 | app := cli.NewApp() 32 | app.EnableBashCompletion = true 33 | app.Name = "investool" 34 | app.Usage = "axiaoxin 的股票工具程序" 35 | app.UsageText = `该程序不构成任何投资建议,程序只是个人辅助工具,具体分析仍然需要自己判断。 36 | 37 | 官网地址: http://investool.axiaoxin.com` 38 | app.Version = version.Version 39 | app.Compiled = time.Now() 40 | app.Authors = []*cli.Author{ 41 | { 42 | Name: "axiaoxin", 43 | Email: "254606826@qq.com", 44 | }, 45 | } 46 | app.Copyright = "(c) 2021 axiaoxin" 47 | 48 | cli.VersionFlag = &cli.BoolFlag{ 49 | Name: "version", 50 | Aliases: []string{"v"}, 51 | Usage: "show the version", 52 | } 53 | 54 | app.Flags = []cli.Flag{ 55 | &cli.StringFlag{ 56 | Name: "loglevel", 57 | Aliases: []string{"l"}, 58 | Value: DefaultLoglevel, 59 | Usage: "cmd 日志级别 [debug|info|warn|error]", 60 | EnvVars: []string{"INVESTOOL_CMD_LOGLEVEL"}, 61 | DefaultText: DefaultLoglevel, 62 | }, 63 | } 64 | app.BashComplete = func(c *cli.Context) { 65 | if c.NArg() > 0 { 66 | return 67 | } 68 | for _, i := range ProcessorOptions { 69 | fmt.Println(i) 70 | } 71 | } 72 | 73 | app.Commands = append(app.Commands, cmds.CommandExportor()) 74 | app.Commands = append(app.Commands, cmds.CommandChecker()) 75 | app.Commands = append(app.Commands, cmds.CommandWebserver()) 76 | app.Commands = append(app.Commands, cmds.CommandIndex()) 77 | app.Commands = append(app.Commands, cmds.CommandJSON()) 78 | 79 | if err := app.Run(os.Args); err != nil { 80 | fmt.Println(err.Error()) 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /misc/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiaoxin-com/investool/2323bfdd2ba785cee851f74bb8a3b4a255ddc7d0/misc/.gitignore -------------------------------------------------------------------------------- /misc/README.md: -------------------------------------------------------------------------------- 1 | 开发部署相关的工具及脚本或其他物料存放目录 2 | -------------------------------------------------------------------------------- /misc/configs/README.md: -------------------------------------------------------------------------------- 1 | 其他配置文件存放目录 2 | -------------------------------------------------------------------------------- /misc/configs/nginx.conf: -------------------------------------------------------------------------------- 1 | upstream pink_lady_api { 2 | server 0.0.0.0:4869; 3 | keepalive 600; 4 | } 5 | 6 | server { 7 | listen 80; 8 | server_name example.com; 9 | 10 | 11 | access_log /path_to_project/logs/nginx.access.log; 12 | error_log /path_to_project/logs/nginx.error.log; 13 | 14 | location / { 15 | proxy_pass http://pink_lady_api; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /misc/configs/supervisor.conf: -------------------------------------------------------------------------------- 1 | [program:investool] 2 | directory=/srv/investool 3 | command=/srv/investool/investool webserver -c ./config.prod.toml 4 | process_name=%(program_name)s 5 | redirect_stderr=false 6 | stdout_logfile=/srv/investool/logs/%(program_name)s.stdout.log 7 | stderr_logfile=/srv/investool/logs/%(program_name)s.stderr.log 8 | autorestart=true 9 | stdout_logfile_maxbytes=10MB ; max # logfile bytes b4 rotation (default 50MB) 10 | stdout_logfile_backups=10 ; # of stdout logfile backups (default 10) 11 | stdout_capture_maxbytes=10MB ; number of bytes in 'capturemode' (default 0) 12 | stdout_events_enabled=true ; emit events on stdout writes (default false) 13 | stderr_logfile_maxbytes=10MB ; max # logfile bytes b4 rotation (default 50MB) 14 | stderr_logfile_backups=10 ; # of stderr logfile backups (default 10) 15 | stderr_capture_maxbytes=10MB ; number of bytes in 'capturemode' (default 0) 16 | stderr_events_enabled=true ; emit events on stderr writes (default false) 17 | -------------------------------------------------------------------------------- /misc/docs/checker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiaoxin-com/investool/2323bfdd2ba785cee851f74bb8a3b4a255ddc7d0/misc/docs/checker.png -------------------------------------------------------------------------------- /misc/docs/checker2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiaoxin-com/investool/2323bfdd2ba785cee851f74bb8a3b4a255ddc7d0/misc/docs/checker2.png -------------------------------------------------------------------------------- /misc/docs/历史波动率分析使用简介.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiaoxin-com/investool/2323bfdd2ba785cee851f74bb8a3b4a255ddc7d0/misc/docs/历史波动率分析使用简介.pdf -------------------------------------------------------------------------------- /misc/pics/gin_arch.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiaoxin-com/investool/2323bfdd2ba785cee851f74bb8a3b4a255ddc7d0/misc/pics/gin_arch.pdf -------------------------------------------------------------------------------- /misc/pics/gin_arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiaoxin-com/investool/2323bfdd2ba785cee851f74bb8a3b4a255ddc7d0/misc/pics/gin_arch.png -------------------------------------------------------------------------------- /misc/pics/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiaoxin-com/investool/2323bfdd2ba785cee851f74bb8a3b4a255ddc7d0/misc/pics/logo.png -------------------------------------------------------------------------------- /misc/scripts/README.md: -------------------------------------------------------------------------------- 1 | 工具类的脚本存放目录 2 | -------------------------------------------------------------------------------- /misc/scripts/app.min.js.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | # 编译替换app.js 3 | 4 | realpath() { 5 | [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}" 6 | } 7 | 8 | # PATHS 9 | PROJECT_PATH=$(dirname $(dirname $(dirname $(realpath $0)))) 10 | SRC_PATH=${PROJECT_PATH}/statics/js 11 | NOW=$(date "+%Y%m%d-%H%M%S") 12 | NEW_NAME=app.min.${NOW}.js 13 | 14 | echo "压缩js..." 15 | uglifyjs --compress --mangle --output ${SRC_PATH}/${NEW_NAME} -- ${SRC_PATH}/app.js && \ 16 | 17 | echo "替换html..." 18 | sed -i '' -e "s|investool/js/app.*.js\"{{ else }}|investool/js/${NEW_NAME}\"{{ else }}|g" ${PROJECT_PATH}/statics/html/base.html 19 | -------------------------------------------------------------------------------- /misc/scripts/bumpversion.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # PATHS 4 | PROJECT_PATH=$(dirname $(dirname $(dirname $(realpath $0)))) 5 | SRC_PATH=${PROJECT_PATH}/version 6 | 7 | VERSION=`git describe --abbrev=0 --tags` 8 | 9 | #replace . with space so can split into an array 10 | VERSION_BITS=(${VERSION//./ }) 11 | 12 | #get number parts and increase last one by 1 13 | VNUM1=${VERSION_BITS[0]} 14 | VNUM2=${VERSION_BITS[1]} 15 | VNUM3=${VERSION_BITS[2]} 16 | VNUM3=$((VNUM3+1)) 17 | 18 | #create new tag 19 | DEFAULT_TAG="$VNUM1.$VNUM2.$VNUM3" 20 | 21 | echo -ne "Updating $VERSION to new tag[${DEFAULT_TAG}]: " 22 | read NEW_TAG 23 | if [ "$NEW_TAG" == "" ]; then 24 | NEW_TAG=${DEFAULT_TAG} 25 | fi 26 | 27 | #get current hash and see if it already has a tag 28 | GIT_COMMIT=`git rev-parse HEAD` 29 | NEEDS_TAG=`git describe --contains $GIT_COMMIT 2>/dev/null` 30 | 31 | #only tag if no tag already 32 | if [ -z "$NEEDS_TAG" ]; then 33 | # https://github.com/x-motemen/gobump 34 | # 使用 git tag 更新 main.go 中的 VERSION ,去掉前缀 v 35 | gobump set ${NEW_TAG/#v} -w ${SRC_PATH} && \ 36 | bash ${PROJECT_PATH}/misc/scripts/gen_apidocs.sh && \ 37 | git commit -am "bump verision to $NEW_TAG" && \ 38 | git tag $NEW_TAG && \ 39 | echo "Tagged with $NEW_TAG" 40 | else 41 | echo "Already a tag on this commit" 42 | fi 43 | -------------------------------------------------------------------------------- /misc/scripts/dist.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | # 编译打包二进制文件 3 | 4 | realpath() { 5 | [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}" 6 | } 7 | 8 | # PATHS 9 | PROJECT_PATH=$(dirname $(dirname $(dirname $(realpath $0)))) 10 | SRC_PATH=${PROJECT_PATH} 11 | DIST_PATH=${PROJECT_PATH}/dist 12 | NOW=$(date "+%Y%m%d-%H%M%S") 13 | 14 | 15 | # ERROR CODE 16 | TESTING_FAILED=-1 17 | BUILDING_FAILED=-2 18 | 19 | # vars 20 | BINARY_NAME=apiserver 21 | CONFIGFILE=config.default.toml 22 | TARNAME=investool 23 | 24 | # clean dist dir 25 | clean() { 26 | rm -rf ${DIST_PATH} 27 | } 28 | 29 | # running go test 30 | tests() { 31 | # Running tests 32 | echo -e "Running tests" 33 | cd ${SRC_PATH} 34 | if !(go test -race ./...) 35 | then 36 | echo -e "Tests failed." 37 | cd - 38 | exit ${TESTING_FAILED} 39 | fi 40 | cd - 41 | } 42 | 43 | # gen apidocs and go build 44 | build() { 45 | echo -ne "Enter release binary name [${BINARY_NAME}]: " 46 | read binary_name 47 | if [ "${binary_name}" != "" ]; then 48 | BINARY_NAME=${binary_name} 49 | fi 50 | 51 | echo -e "Will build binary name to be ${BINARY_NAME}" 52 | 53 | # Update docs 54 | echo "Updating swag docs" 55 | # check swag 56 | if !(swag > /dev/null 2>&1); then 57 | echo -e "Need swag to generate API docs. Installing swag..." 58 | go get -u github.com/swaggo/swag/cmd/swag 59 | fi 60 | echo -e "Generating API docs..." 61 | bash ${PROJECT_PATH}/misc/scripts/gen_apidocs.sh 62 | 63 | # Building 64 | echo -e "Building..." 65 | if [ ! -d ${DIST_PATH} ]; then 66 | mkdir ${DIST_PATH} 67 | fi 68 | cd ${SRC_PATH} 69 | GOOS=linux GOARCH=amd64 go build -o ${DIST_PATH}/${BINARY_NAME} ${SRC_PATH} 70 | if [ $? -ne 0 ] 71 | then 72 | echo -e "Build failed." 73 | exit ${BUILDING_FAILED} 74 | fi 75 | cd - 76 | } 77 | 78 | # tar bin and config file 79 | tarball() { 80 | echo -ne "Enter your configfile[${CONFIGFILE}]: " 81 | read cf 82 | if [ "${cf}" != "" ]; then 83 | CONFIGFILE=${cf} 84 | fi 85 | 86 | echo -e "tar binary file and config file..." 87 | tardir=${DIST_PATH}/${TARNAME} 88 | mkdir ${tardir} 89 | mv ${DIST_PATH}/${BINARY_NAME} ${tardir} 90 | cp ${SRC_PATH}/${CONFIGFILE} ${tardir} 91 | tar czvf ${tardir}.tar.gz -C ${DIST_PATH} ${TARNAME} && rm -rf ${tardir} 92 | } 93 | 94 | main() { 95 | echo -e "This tool will help you to release your app.\nIt will run tests then update apidocs and build the binary file and tar it with configfile as tar.gz file." 96 | clean 97 | tests 98 | build 99 | tarball 100 | } 101 | 102 | main 103 | -------------------------------------------------------------------------------- /misc/scripts/docker_run.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | # 编译并docker中运行 3 | 4 | realpath() { 5 | [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}" 6 | } 7 | 8 | PROJECT_PATH=$(dirname $(dirname $(dirname $(realpath $0)))) 9 | 10 | source ${PROJECT_PATH}/misc/scripts/dist.sh 11 | 12 | docker build -t investool ${PROJECT_PATH} 13 | docker run -p 4869:4869 -p 4870:4870 investool 14 | -------------------------------------------------------------------------------- /misc/scripts/gen_apidocs.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | # 生成swag api文档 3 | 4 | realpath() { 5 | [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}" 6 | } 7 | 8 | PROJECT_PATH=$(dirname $(dirname $(dirname $(realpath $0)))) 9 | SRC_PATH=${PROJECT_PATH} 10 | 11 | # swag init必须在main.go所在的目录下执行,否则必须用--dir参数指定main.go的路径 12 | # go get -u github.com/swaggo/swag/cmd/swag 13 | swag init --dir ${SRC_PATH}/ --generalInfo routes/routes.go --propertyStrategy snakecase --output ${SRC_PATH}/routes/docs 14 | -------------------------------------------------------------------------------- /misc/scripts/new_project.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | main() { 4 | gopath=`go env GOPATH` 5 | if [ $? = 127 ]; then 6 | echo "GOPATH not exists" 7 | exit -1 8 | fi 9 | echo -e "New project will be create in ${gopath}/src/" 10 | echo -ne "Enter your new project full name: " 11 | read projname 12 | 13 | # get template project 14 | echo -e "Downloading the template..." 15 | # git clone https://github.com/axiaoxin-com/pink-lady.git ${gopath}/src/${projname} 16 | rm - 17 | if !(curl https://codeload.github.com/axiaoxin-com/pink-lady/zip/master -o /tmp/pink-lady.zip && unzip /tmp/pink-lady.zip -d /tmp) 18 | then 19 | echo "Downloading failed." 20 | exit -2 21 | fi 22 | 23 | echo -e "Generating the project..." 24 | mv /tmp/pink-lady-master ${gopath}/src/${projname} && cd ${gopath}/src/${projname} 25 | 26 | if [ `uname` = 'Darwin' ]; then 27 | sed -i '' -e "s|github.com/axiaoxin-com/pink-lady|${projname}|g" `grep "pink-lady" --include "swagger.*" --include ".travis.yml" --include "*.go" --include "go.*" -rl .` 28 | else 29 | sed -i "s|github.com/axiaoxin-com/pink-lady|${projname}|g" `grep "pink-lady" --include "swagger.*" --include ".travis.yml" --include "*.go" --include "go.*" -rl .` 30 | fi 31 | 32 | if [ $? -ne 0 ] 33 | then 34 | echo -e "set project name failed." 35 | exit -3 36 | fi 37 | 38 | echo -e "Create project ${projname} in ${gopath}/src succeed." 39 | 40 | # init a git repo 41 | echo -ne "Do you want to init a git repo[N/y]: " 42 | read initgit 43 | if [ "${initgit}" == "y" ] || [ "${rmdemo}" == "Y" ]; then 44 | cd ${gopath}/src/${projname} && git init && git add . && git commit -m "init project with pink-lady" 45 | cp ${gopath}/src/${projname}/misc/scripts/pre-push.githook ${gopath}/src/${projname}/.git/hooks/pre-push 46 | chmod +x ${gopath}/src/${projname}/.git/hooks/pre-push 47 | fi 48 | } 49 | main 50 | -------------------------------------------------------------------------------- /misc/scripts/pre-push.githook: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to verify what is about to be committed. 4 | # Called by "git commit" with no arguments. The hook should 5 | # exit with non-zero status after issuing an appropriate message if 6 | # it wants to stop the commit. 7 | # 8 | # To enable this hook, rename this file to "pre-commit". 9 | realpath() { 10 | [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}" 11 | } 12 | 13 | hooks_path=$(dirname "`realpath $0`") 14 | git_path=$(dirname "$hooks_path") 15 | project_path=$(dirname "$git_path") 16 | 17 | cd $project_path 18 | go fmt ./... && go vet ./... && go test -race ./... 19 | -------------------------------------------------------------------------------- /misc/sqls/README.md: -------------------------------------------------------------------------------- 1 | sql文件存放目录 2 | -------------------------------------------------------------------------------- /misc/sqls/export_struct.sh: -------------------------------------------------------------------------------- 1 | if [ $# != 1 ]; then 2 | echo "需要指定表名" 3 | exit -1 4 | fi 5 | table2struct --tag_gorm --db_host localhost --db_port 3306 --db_user root --db_pwd roooooot --db_name test $1 6 | -------------------------------------------------------------------------------- /models/fund_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "testing" 7 | 8 | "github.com/axiaoxin-com/investool/datacenter/eastmoney" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestNewFund(t *testing.T) { 13 | ctx := context.TODO() 14 | efund, err := eastmoney.NewEastMoney().QueryFundInfo(ctx, "260104") 15 | require.Nil(t, err) 16 | fund := NewFund(ctx, efund) 17 | b, err := json.Marshal(fund) 18 | require.Nil(t, err) 19 | t.Log(string(b)) 20 | } 21 | -------------------------------------------------------------------------------- /models/global.go: -------------------------------------------------------------------------------- 1 | // Package models 2 | // 全局变量 3 | 4 | package models 5 | 6 | import ( 7 | "encoding/json" 8 | "io/ioutil" 9 | "time" 10 | 11 | "github.com/axiaoxin-com/investool/datacenter/eastmoney" 12 | "github.com/axiaoxin-com/logging" 13 | ) 14 | 15 | var ( 16 | // StockIndustryList 东方财富股票行业列表 17 | StockIndustryList []string 18 | // FundTypeList 基金类型列表 19 | FundTypeList []string 20 | // Fund4433TypeList 4433基金类型列表 21 | Fund4433TypeList []string 22 | // FundAllList 全量基金列表 23 | FundAllList FundList 24 | // Fund4433List 满足4433法则的基金列表 25 | Fund4433List FundList 26 | // FundManagers 基金经理列表 27 | FundManagers eastmoney.FundManagerInfoList 28 | // SyncFundTime 基金数据同步时间 29 | SyncFundTime = time.Now() 30 | // RawFundAllListFilename api返回的原始结果 31 | RawFundAllListFilename = "./eastmoney_funds_list.json" 32 | // FundAllListFilename 基金列表数据文件 33 | FundAllListFilename = "./fund_all_list.json" 34 | // Fund4433ListFilename 4433基金列表数据文件 35 | Fund4433ListFilename = "./fund_4433_list.json" 36 | // IndustryListFilename 行业列表数据文件 37 | IndustryListFilename = "./industry_list.json" 38 | // FundTypeListFilename 基金类型数据文件 39 | FundTypeListFilename = "./fund_type_list.json" 40 | // FundManagersFilename 基金经理数据文件 41 | FundManagersFilename = "./fund_managers.json" 42 | // AAACompanyBondSyl AAA公司债当期收益率 43 | AAACompanyBondSyl = -1.0 // datacenter.ChinaBond.QueryAAACompanyBondSyl(context.Background()) 44 | ) 45 | 46 | // InitGlobalVars 初始化全局变量 47 | func InitGlobalVars() { 48 | if err := InitIndustryList(); err != nil { 49 | logging.Error(nil, "init models global vars error:"+err.Error()) 50 | } 51 | if err := InitFundAllList(); err != nil { 52 | logging.Error(nil, "init models global vars error:"+err.Error()) 53 | } 54 | if err := InitFund4433List(); err != nil { 55 | logging.Error(nil, "init models global vars error:"+err.Error()) 56 | } 57 | if err := InitFundTypeList(); err != nil { 58 | logging.Error(nil, "init models global vars error:"+err.Error()) 59 | } 60 | if err := InitFundManagers(); err != nil { 61 | logging.Error(nil, "init models global vars error:"+err.Error()) 62 | } 63 | // 更新同步时间 64 | SyncFundTime = time.Now() 65 | } 66 | 67 | // InitIndustryList 初始化行业列表 68 | func InitIndustryList() error { 69 | indlist, err := ioutil.ReadFile(IndustryListFilename) 70 | if err != nil { 71 | return err 72 | } 73 | return json.Unmarshal(indlist, &StockIndustryList) 74 | } 75 | 76 | // InitFundAllList 从json文件加载基金列表 77 | func InitFundAllList() error { 78 | fundlist, err := ioutil.ReadFile(FundAllListFilename) 79 | if err != nil { 80 | return err 81 | } 82 | return json.Unmarshal(fundlist, &FundAllList) 83 | } 84 | 85 | // InitFund4433List 从json文件加载基金列表 86 | func InitFund4433List() error { 87 | fundlist, err := ioutil.ReadFile(Fund4433ListFilename) 88 | if err != nil { 89 | return err 90 | } 91 | if err := json.Unmarshal(fundlist, &Fund4433List); err != nil { 92 | return err 93 | } 94 | Fund4433List.Sort(FundSortTypeWeek) 95 | Fund4433TypeList = Fund4433List.Types() 96 | return nil 97 | } 98 | 99 | // InitFundTypeList 从json文件加载基金类型 100 | func InitFundTypeList() error { 101 | types, err := ioutil.ReadFile(FundTypeListFilename) 102 | if err != nil { 103 | return err 104 | } 105 | return json.Unmarshal(types, &FundTypeList) 106 | } 107 | 108 | // InitFundManagers 初始化基金经理列表 109 | func InitFundManagers() error { 110 | m, err := ioutil.ReadFile(FundManagersFilename) 111 | if err != nil { 112 | return err 113 | } 114 | return json.Unmarshal(m, &FundManagers) 115 | } 116 | -------------------------------------------------------------------------------- /models/models.go: -------------------------------------------------------------------------------- 1 | // Package models 定义数据库 model 2 | package models 3 | -------------------------------------------------------------------------------- /models/models_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # git tag first 3 | echo "release..." 4 | goreleaser release --rm-dist 5 | -------------------------------------------------------------------------------- /routes/about.go: -------------------------------------------------------------------------------- 1 | // 关于 2 | 3 | package routes 4 | 5 | import ( 6 | "net/http" 7 | 8 | "github.com/axiaoxin-com/investool/version" 9 | "github.com/gin-gonic/gin" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | // About godoc 14 | func About(c *gin.Context) { 15 | data := gin.H{ 16 | "Env": viper.GetString("env"), 17 | "Version": version.Version, 18 | "PageTitle": "InvesTool | 关于", 19 | "HostURL": viper.GetString("server.host_url"), 20 | } 21 | c.HTML(http.StatusOK, "about.html", data) 22 | return 23 | } 24 | -------------------------------------------------------------------------------- /routes/comment.go: -------------------------------------------------------------------------------- 1 | // 评论留言 2 | 3 | package routes 4 | 5 | import ( 6 | "net/http" 7 | 8 | "github.com/axiaoxin-com/investool/version" 9 | "github.com/gin-gonic/gin" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | // Comment godoc 14 | func Comment(c *gin.Context) { 15 | data := gin.H{ 16 | "Env": viper.GetString("env"), 17 | "Version": version.Version, 18 | "PageTitle": "InvesTool | 留言", 19 | "HostURL": viper.GetString("server.host_url"), 20 | } 21 | c.HTML(http.StatusOK, "comment.html", data) 22 | return 23 | } 24 | -------------------------------------------------------------------------------- /routes/docs/docs.go: -------------------------------------------------------------------------------- 1 | // GENERATED BY THE COMMAND ABOVE; DO NOT EDIT 2 | // This file was generated by swaggo/swag 3 | 4 | package docs 5 | 6 | import ( 7 | "bytes" 8 | "encoding/json" 9 | "strings" 10 | 11 | "github.com/alecthomas/template" 12 | "github.com/swaggo/swag" 13 | ) 14 | 15 | var doc = `{ 16 | "schemes": {{ marshal .Schemes }}, 17 | "swagger": "2.0", 18 | "info": { 19 | "description": "{{.Description}}", 20 | "title": "{{.Title}}", 21 | "contact": {}, 22 | "version": "{{.Version}}" 23 | }, 24 | "host": "{{.Host}}", 25 | "basePath": "{{.BasePath}}", 26 | "paths": { 27 | "/x/ping": { 28 | "get": { 29 | "security": [ 30 | { 31 | "ApiKeyAuth": [] 32 | }, 33 | { 34 | "BasicAuth": [] 35 | } 36 | ], 37 | "description": "返回 server 相关信息,可以用于健康检查", 38 | "consumes": [ 39 | "application/json" 40 | ], 41 | "produces": [ 42 | "application/json" 43 | ], 44 | "tags": [ 45 | "x" 46 | ], 47 | "summary": "默认的 Ping 接口", 48 | "parameters": [ 49 | { 50 | "type": "string", 51 | "description": "you can set custom trace id in header", 52 | "name": "trace_id", 53 | "in": "header" 54 | } 55 | ], 56 | "responses": { 57 | "200": { 58 | "description": "OK", 59 | "schema": { 60 | "$ref": "#/definitions/response.Response" 61 | } 62 | } 63 | } 64 | } 65 | } 66 | }, 67 | "definitions": { 68 | "response.Response": { 69 | "type": "object", 70 | "properties": { 71 | "code": { 72 | "type": "object" 73 | }, 74 | "data": { 75 | "type": "object" 76 | }, 77 | "msg": { 78 | "type": "string" 79 | } 80 | } 81 | } 82 | } 83 | }` 84 | 85 | type swaggerInfo struct { 86 | Version string 87 | Host string 88 | BasePath string 89 | Schemes []string 90 | Title string 91 | Description string 92 | } 93 | 94 | // SwaggerInfo holds exported Swagger Info so clients can modify it 95 | var SwaggerInfo = swaggerInfo{ 96 | Version: "", 97 | Host: "", 98 | BasePath: "", 99 | Schemes: []string{}, 100 | Title: "", 101 | Description: "", 102 | } 103 | 104 | type s struct{} 105 | 106 | func (s *s) ReadDoc() string { 107 | sInfo := SwaggerInfo 108 | sInfo.Description = strings.Replace(sInfo.Description, "\n", "\\n", -1) 109 | 110 | t, err := template.New("swagger_info").Funcs(template.FuncMap{ 111 | "marshal": func(v interface{}) string { 112 | a, _ := json.Marshal(v) 113 | return string(a) 114 | }, 115 | }).Parse(doc) 116 | if err != nil { 117 | return doc 118 | } 119 | 120 | var tpl bytes.Buffer 121 | if err := t.Execute(&tpl, sInfo); err != nil { 122 | return doc 123 | } 124 | 125 | return tpl.String() 126 | } 127 | 128 | func init() { 129 | swag.Register(swag.Name, &s{}) 130 | } 131 | -------------------------------------------------------------------------------- /routes/docs/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "contact": {} 5 | }, 6 | "paths": { 7 | "/x/ping": { 8 | "get": { 9 | "security": [ 10 | { 11 | "ApiKeyAuth": [] 12 | }, 13 | { 14 | "BasicAuth": [] 15 | } 16 | ], 17 | "description": "返回 server 相关信息,可以用于健康检查", 18 | "consumes": [ 19 | "application/json" 20 | ], 21 | "produces": [ 22 | "application/json" 23 | ], 24 | "tags": [ 25 | "x" 26 | ], 27 | "summary": "默认的 Ping 接口", 28 | "parameters": [ 29 | { 30 | "type": "string", 31 | "description": "you can set custom trace id in header", 32 | "name": "trace_id", 33 | "in": "header" 34 | } 35 | ], 36 | "responses": { 37 | "200": { 38 | "description": "OK", 39 | "schema": { 40 | "$ref": "#/definitions/response.Response" 41 | } 42 | } 43 | } 44 | } 45 | } 46 | }, 47 | "definitions": { 48 | "response.Response": { 49 | "type": "object", 50 | "properties": { 51 | "code": { 52 | "type": "object" 53 | }, 54 | "data": { 55 | "type": "object" 56 | }, 57 | "msg": { 58 | "type": "string" 59 | } 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /routes/docs/swagger.yaml: -------------------------------------------------------------------------------- 1 | definitions: 2 | response.Response: 3 | properties: 4 | code: 5 | type: object 6 | data: 7 | type: object 8 | msg: 9 | type: string 10 | type: object 11 | info: 12 | contact: {} 13 | paths: 14 | /x/ping: 15 | get: 16 | consumes: 17 | - application/json 18 | description: 返回 server 相关信息,可以用于健康检查 19 | parameters: 20 | - description: you can set custom trace id in header 21 | in: header 22 | name: trace_id 23 | type: string 24 | produces: 25 | - application/json 26 | responses: 27 | "200": 28 | description: OK 29 | schema: 30 | $ref: '#/definitions/response.Response' 31 | security: 32 | - ApiKeyAuth: [] 33 | - BasicAuth: [] 34 | summary: 默认的 Ping 接口 35 | tags: 36 | - x 37 | swagger: "2.0" 38 | -------------------------------------------------------------------------------- /routes/materials.go: -------------------------------------------------------------------------------- 1 | // 学习资料页面 2 | 3 | package routes 4 | 5 | import ( 6 | "encoding/json" 7 | "net/http" 8 | 9 | "github.com/axiaoxin-com/investool/statics" 10 | "github.com/axiaoxin-com/investool/version" 11 | "github.com/axiaoxin-com/logging" 12 | "github.com/gin-gonic/gin" 13 | "github.com/spf13/viper" 14 | ) 15 | 16 | // MaterialItem 学习资料具体信息 17 | type MaterialItem struct { 18 | Name string `json:"name"` 19 | DownloadURL string `json:"download_url"` 20 | Desc string `json:"desc"` 21 | } 22 | 23 | // MaterialSeries 某一个系列的资料 24 | // 25 | // { 26 | // "飙股在线等": [ 27 | // MaterialItem, ... 28 | // ] 29 | // } 30 | type MaterialSeries map[string][]MaterialItem 31 | 32 | // TypedMaterialSeries 对MaterialSeries进行分类,如:视频、电子书等 33 | // 34 | // { 35 | // "videos": [ 36 | // MaterialSeries, ... 37 | // ], 38 | // "ebooks": [ 39 | // MaterialSeries, ... 40 | // ] 41 | // } 42 | type TypedMaterialSeries map[string][]MaterialSeries 43 | 44 | // AllMaterialsList 包含全部资料信息的大JSON列表 45 | // [ 46 | // 47 | // TypedMaterialSeries, ... 48 | // 49 | // ] 50 | type AllMaterialsList []TypedMaterialSeries 51 | 52 | // MaterialsFilename 资料JSON文件路径 53 | var MaterialsFilename = "materials" 54 | 55 | // Materials godoc 56 | func Materials(c *gin.Context) { 57 | data := gin.H{ 58 | "Env": viper.GetString("env"), 59 | "HostURL": viper.GetString("server.host_url"), 60 | "Version": version.Version, 61 | "PageTitle": "InvesTool | 资料", 62 | } 63 | f, err := statics.Files.ReadFile(MaterialsFilename) 64 | if err != nil { 65 | logging.Errorf(c, "Read MaterialsFilename:%v err:%v", MaterialsFilename, err) 66 | data["Error"] = err 67 | c.HTML(http.StatusOK, "materials.html", data) 68 | return 69 | } 70 | var mlist AllMaterialsList 71 | if err := json.Unmarshal(f, &mlist); err != nil { 72 | logging.Errorf(c, "json Unmarshal AllMaterialsList err:%v", err) 73 | data["Error"] = err 74 | c.HTML(http.StatusOK, "materials.html", data) 75 | return 76 | } 77 | data["AllMaterialsList"] = mlist 78 | c.HTML(http.StatusOK, "materials.html", data) 79 | return 80 | } 81 | -------------------------------------------------------------------------------- /routes/ping.go: -------------------------------------------------------------------------------- 1 | // 默认实现的 ping api 2 | 3 | package routes 4 | 5 | import ( 6 | "github.com/axiaoxin-com/investool/routes/response" 7 | "github.com/axiaoxin-com/investool/version" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | // Ping godoc 13 | // @Summary 默认的 Ping 接口 14 | // @Description 返回 server 相关信息,可以用于健康检查 15 | // @Tags x 16 | // @Accept json 17 | // @Produce json 18 | // @Success 200 {object} response.Response 19 | // @Security ApiKeyAuth 20 | // @Security BasicAuth 21 | // @Param trace_id header string false "you can set custom trace id in header" 22 | // @Router /x/ping [get] 23 | func Ping(c *gin.Context) { 24 | data := gin.H{ 25 | "version": version.Version, 26 | } 27 | response.JSON(c, data) 28 | return 29 | } 30 | -------------------------------------------------------------------------------- /routes/ping_test.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/axiaoxin-com/goutils" 7 | "github.com/gin-gonic/gin" 8 | "github.com/spf13/viper" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestPing(t *testing.T) { 13 | gin.SetMode(gin.ReleaseMode) 14 | r := gin.New() 15 | viper.Set("basic_auth.username", "admin") 16 | viper.Set("basic_auth.password", "admin") 17 | defer viper.Reset() 18 | Register(r) 19 | recorder, err := goutils.RequestHTTPHandler( 20 | r, 21 | "GET", 22 | "/x/ping", 23 | nil, 24 | map[string]string{"Authorization": "Basic YWRtaW46YWRtaW4="}, 25 | ) 26 | assert.Nil(t, err) 27 | assert.Equal(t, recorder.Code, 200) 28 | } 29 | -------------------------------------------------------------------------------- /routes/query_fund_by_stock.go: -------------------------------------------------------------------------------- 1 | // 根据股票查基金 2 | 3 | package routes 4 | 5 | import ( 6 | "net/http" 7 | 8 | "github.com/axiaoxin-com/goutils" 9 | "github.com/axiaoxin-com/investool/core" 10 | "github.com/axiaoxin-com/investool/version" 11 | "github.com/gin-gonic/gin" 12 | "github.com/spf13/viper" 13 | ) 14 | 15 | // ParamQueryFundByStock QueryFundByStock 请求参数 16 | type ParamQueryFundByStock struct { 17 | Keywords string `form:"keywords" binding:"required"` 18 | } 19 | 20 | // QueryFundByStock 股票选基 21 | func QueryFundByStock(c *gin.Context) { 22 | data := gin.H{ 23 | "Env": viper.GetString("env"), 24 | "HostURL": viper.GetString("server.host_url"), 25 | "Version": version.Version, 26 | "PageTitle": "InvesTool | 基金 | 股票选基", 27 | "Error": "", 28 | } 29 | 30 | param := ParamQueryFundByStock{} 31 | if err := c.ShouldBind(¶m); err != nil { 32 | data["Error"] = err.Error() 33 | c.JSON(http.StatusOK, data) 34 | return 35 | } 36 | keywords := goutils.SplitStringFields(param.Keywords) 37 | searcher := core.NewSearcher(c) 38 | dlist, err := searcher.SearchFundByStock(c, keywords...) 39 | if err != nil { 40 | data["Error"] = err.Error() 41 | c.JSON(http.StatusOK, data) 42 | return 43 | } 44 | data["Funds"] = dlist 45 | c.HTML(http.StatusOK, "hold_stock_fund.html", data) 46 | return 47 | } 48 | -------------------------------------------------------------------------------- /routes/register.go: -------------------------------------------------------------------------------- 1 | // @contact.name API Support 2 | // @contact.url http://github.com/axiaoxin-com/investool 3 | // @contact.email 254606826@qq.com 4 | 5 | // @license.name Apache 2.0 6 | // @license.url http://www.apache.org/licenses/LICENSE-2.0.html 7 | 8 | // @securityDefinitions.basic BasicAuth 9 | 10 | // @securityDefinitions.apikey ApiKeyAuth 11 | // @in header 12 | // @name apikey 13 | 14 | package routes 15 | 16 | import ( 17 | "net/http" 18 | 19 | "github.com/axiaoxin-com/investool/routes/docs" 20 | "github.com/axiaoxin-com/investool/statics" 21 | "github.com/axiaoxin-com/investool/version" 22 | "github.com/axiaoxin-com/investool/webserver" 23 | "github.com/axiaoxin-com/logging" 24 | "github.com/gin-contrib/pprof" 25 | 26 | // docs is generated by Swag CLI, you have to import it. 27 | _ "github.com/axiaoxin-com/investool/routes/docs" 28 | "github.com/spf13/viper" 29 | 30 | "github.com/gin-gonic/gin" 31 | swaggerFiles "github.com/swaggo/files" 32 | ginSwagger "github.com/swaggo/gin-swagger" 33 | ) 34 | 35 | const ( 36 | // DisableGinSwaggerEnvkey 设置该环境变量时关闭 swagger 文档 37 | DisableGinSwaggerEnvkey = "DISABLE_GIN_SWAGGER" 38 | ) 39 | 40 | // Register 在 gin engine 上注册 url 对应的 HandlerFunc 41 | func Register(httpHandler http.Handler) { 42 | app, ok := httpHandler.(*gin.Engine) 43 | if !ok { 44 | panic("HTTP handler must be *gin.Engine") 45 | } 46 | 47 | // api 文档变量设置,注意这里依赖 viper 读配置,需要保证在 main 中已预先加载这些配置项 48 | docs.SwaggerInfo.Title = viper.GetString("apidocs.title") 49 | docs.SwaggerInfo.Description = viper.GetString("apidocs.desc") 50 | docs.SwaggerInfo.Version = version.Version 51 | docs.SwaggerInfo.Host = viper.GetString("apidocs.host") 52 | docs.SwaggerInfo.BasePath = viper.GetString("apidocs.basepath") 53 | docs.SwaggerInfo.Schemes = viper.GetStringSlice("apidocs.schemes") 54 | 55 | // Group x 默认 url 路由 56 | x := app.Group("/x", webserver.GinBasicAuth()) 57 | { 58 | if viper.GetBool("server.pprof") { 59 | pprof.RouteRegister(x, "/pprof") 60 | } 61 | if viper.GetBool("server.metrics") { 62 | x.GET("/metrics", webserver.PromExporterHandler()) 63 | } 64 | // ginSwagger 生成的在线 API 文档路由 65 | x.GET("/apidocs/*any", ginSwagger.DisablingWrapHandler(swaggerFiles.Handler, DisableGinSwaggerEnvkey)) 66 | // 默认的 ping 方法,返回 server 相关信息 67 | x.Any("/ping", Ping) 68 | } 69 | 70 | // 注册 favicon.ico 和 robots.txt 71 | app.GET("/favicon.ico", func(c *gin.Context) { 72 | file, err := statics.Files.ReadFile("favicon.ico") 73 | if err != nil { 74 | logging.Error(c, "read favicon file error:"+err.Error()) 75 | } 76 | c.Data(http.StatusOK, "image/x-icon", file) 77 | return 78 | }) 79 | app.GET("/robots.txt", func(c *gin.Context) { 80 | file, err := statics.Files.ReadFile("robots.txt") 81 | if err != nil { 82 | logging.Error(c, "read robots file error:"+err.Error()) 83 | } 84 | c.Data(http.StatusOK, "text/plain", file) 85 | return 86 | }) 87 | app.GET("/ads.txt", func(c *gin.Context) { 88 | file, err := statics.Files.ReadFile("ads.txt") 89 | if err != nil { 90 | logging.Error(c, "read ads file error:"+err.Error()) 91 | } 92 | c.Data(http.StatusOK, "text/plain", file) 93 | return 94 | }) 95 | app.GET("/apple-touch-icon-120x120-precomposed.png", func(c *gin.Context) { 96 | file, err := statics.Files.ReadFile("img/sidenav_icon.png") 97 | if err != nil { 98 | logging.Error(c, "read favicon file error:"+err.Error()) 99 | } 100 | c.Data(http.StatusOK, "image/x-icon", file) 101 | return 102 | }) 103 | app.GET("/apple-touch-icon-120x120.png", func(c *gin.Context) { 104 | file, err := statics.Files.ReadFile("img/sidenav_icon.png") 105 | if err != nil { 106 | logging.Error(c, "read favicon file error:"+err.Error()) 107 | } 108 | c.Data(http.StatusOK, "image/x-icon", file) 109 | return 110 | }) 111 | app.GET("/apple-touch-icon-precomposed.png", func(c *gin.Context) { 112 | file, err := statics.Files.ReadFile("img/sidenav_icon.png") 113 | if err != nil { 114 | logging.Error(c, "read favicon file error:"+err.Error()) 115 | } 116 | c.Data(http.StatusOK, "image/x-icon", file) 117 | return 118 | }) 119 | app.GET("/apple-touch-icon.png", func(c *gin.Context) { 120 | file, err := statics.Files.ReadFile("img/sidenav_icon.png") 121 | if err != nil { 122 | logging.Error(c, "read favicon file error:"+err.Error()) 123 | } 124 | c.Data(http.StatusOK, "image/x-icon", file) 125 | return 126 | }) 127 | 128 | // 注册其他 gin HandlerFunc 129 | Routes(app) 130 | } 131 | -------------------------------------------------------------------------------- /routes/register_test.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/axiaoxin-com/goutils" 7 | "github.com/gin-gonic/gin" 8 | "github.com/spf13/viper" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestRegisterRoutes(t *testing.T) { 13 | gin.SetMode(gin.ReleaseMode) 14 | r := gin.New() 15 | // Register 中的 basic auth 依赖 viper 配置 16 | viper.Set("basic_auth.username", "admin") 17 | viper.Set("basic_auth.password", "admin") 18 | viper.Set("env", "localhost") 19 | defer viper.Reset() 20 | 21 | Register(r) 22 | recorder, err := goutils.RequestHTTPHandler( 23 | r, 24 | "GET", 25 | "/x/ping", 26 | nil, 27 | map[string]string{"Authorization": "Basic YWRtaW46YWRtaW4="}, 28 | ) 29 | assert.Nil(t, err) 30 | assert.Equal(t, recorder.Code, 200) 31 | } 32 | -------------------------------------------------------------------------------- /routes/response/README.md: -------------------------------------------------------------------------------- 1 | # response 2 | 3 | ## 功能 4 | 5 | 6 | ## 用法 7 | -------------------------------------------------------------------------------- /routes/response/errcode.go: -------------------------------------------------------------------------------- 1 | // 业务错误码定义 2 | 3 | package response 4 | 5 | import ( 6 | "strings" 7 | 8 | "github.com/axiaoxin-com/goutils" 9 | ) 10 | 11 | // 错误码中的 code 定义 12 | const ( 13 | failure = iota - 1 14 | success 15 | invalidParam 16 | notFound 17 | unknownError 18 | ) 19 | 20 | // 错误码对象定义 21 | var ( 22 | CodeSuccess = goutils.NewErrCode(success, "Success") 23 | CodeFailure = goutils.NewErrCode(failure, "Failure") 24 | CodeInvalidParam = goutils.NewErrCode(invalidParam, "Invalid Param") 25 | CodeNotFound = goutils.NewErrCode(notFound, "Not Fount") 26 | CodeInternalError = goutils.NewErrCode(unknownError, "Unknown Error") 27 | ) 28 | 29 | // IsInvalidParamError 判断错误信息中是否包含:参数错误 30 | func IsInvalidParamError(err error) bool { 31 | return strings.Contains(err.Error(), "Invalid Param") 32 | } 33 | -------------------------------------------------------------------------------- /routes/response/errcode_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestErrCode(t *testing.T) { 10 | assert.Equal(t, CodeSuccess.Code(), success) 11 | } 12 | -------------------------------------------------------------------------------- /routes/response/response.go: -------------------------------------------------------------------------------- 1 | // Package response 提供统一的 JSON 返回结构,可以通过配置设置具体返回的 code 字段为 int 或者 string 2 | package response 3 | 4 | import ( 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/axiaoxin-com/goutils" 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | // Response 统一的返回结构定义 13 | type Response struct { 14 | Code interface{} `json:"code"` 15 | Msg string `json:"msg"` 16 | Data interface{} `json:"data"` 17 | } 18 | 19 | // JSON 返回 HTTP 状态码为 200 的统一成功结构 20 | func JSON(c *gin.Context, data interface{}) { 21 | Respond(c, http.StatusOK, data, CodeSuccess) 22 | } 23 | 24 | // ErrJSON 返回 HTTP 状态码为 200 的统一失败结构 25 | func ErrJSON(c *gin.Context, err error, extraMsgs ...interface{}) { 26 | Respond(c, http.StatusOK, nil, err, extraMsgs...) 27 | } 28 | 29 | // Respond encapsulates c.JSON 30 | // debug mode respond indented json 31 | func Respond(c *gin.Context, status int, data interface{}, errcode error, extraMsgs ...interface{}) { 32 | // 初始化 code 、 msg 为失败 33 | code, msg, _ := CodeFailure.Decode() 34 | 35 | if ec, ok := errcode.(*goutils.ErrCode); ok { 36 | // 如果是返回码,正常处理 37 | code, msg, _ = ec.Decode() 38 | // 存在 errs 则将 errs 信息添加的 msg 39 | if len(ec.Errs()) > 0 { 40 | msg = fmt.Sprint(msg, " ", ec.Error()) 41 | } 42 | } else { 43 | // 支持 errcode 参数直接传 error ,如果是 error ,则将 error 信息添加到 msg 44 | msg = fmt.Sprint(msg, " ", errcode.Error()) 45 | } 46 | 47 | // 将 extraMsgs 添加到 msg 48 | if len(extraMsgs) > 0 { 49 | msg = fmt.Sprint(msg, "; ", extraMsgs) 50 | } 51 | 52 | resp := Response{ 53 | Code: code, 54 | Msg: msg, 55 | Data: data, 56 | } 57 | c.Header("x-response-code", fmt.Sprint(code)) 58 | if gin.Mode() == gin.ReleaseMode { 59 | c.JSON(status, resp) 60 | } else { 61 | c.IndentedJSON(status, resp) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /routes/response/response_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestJSON(t *testing.T) { 15 | gin.SetMode(gin.ReleaseMode) 16 | responseWriter := httptest.NewRecorder() 17 | c, _ := gin.CreateTestContext(responseWriter) 18 | JSON(c, gin.H{"k": "v"}) 19 | if c.Writer.Status() != 200 { 20 | t.Fatal("http status code error") 21 | } 22 | require.Equal(t, c.Writer.Status(), 200) 23 | 24 | j := responseWriter.Body.Bytes() 25 | r := Response{} 26 | err := json.Unmarshal(j, &r) 27 | require.Nil(t, err) 28 | require.Equal(t, r.Data.(map[string]interface{})["k"].(string), "v") 29 | } 30 | 31 | func TestErrJSON(t *testing.T) { 32 | gin.SetMode(gin.ReleaseMode) 33 | responseWriter := httptest.NewRecorder() 34 | c, _ := gin.CreateTestContext(responseWriter) 35 | ErrJSON(c, CodeInvalidParam) 36 | require.Equal(t, c.Writer.Status(), 200) 37 | j := responseWriter.Body.Bytes() 38 | r := Response{} 39 | err := json.Unmarshal(j, &r) 40 | require.Nil(t, err) 41 | } 42 | 43 | func TestRespond(t *testing.T) { 44 | gin.SetMode(gin.ReleaseMode) 45 | responseWriter := httptest.NewRecorder() 46 | c, _ := gin.CreateTestContext(responseWriter) 47 | Respond(c, 200, gin.H{"k": "v"}, CodeSuccess) 48 | require.Equal(t, c.Writer.Status(), 200) 49 | j := responseWriter.Body.Bytes() 50 | r := Response{} 51 | err := json.Unmarshal(j, &r) 52 | require.Nil(t, err) 53 | require.Equal(t, r.Data.(map[string]interface{})["k"].(string), "v") 54 | } 55 | 56 | func TestRespondWithExtraMsg(t *testing.T) { 57 | gin.SetMode(gin.ReleaseMode) 58 | responseWriter := httptest.NewRecorder() 59 | c, _ := gin.CreateTestContext(responseWriter) 60 | Respond(c, 200, gin.H{"k": "v"}, CodeSuccess, "xxx") 61 | require.Equal(t, c.Writer.Status(), 200) 62 | j := responseWriter.Body.Bytes() 63 | r := Response{} 64 | err := json.Unmarshal(j, &r) 65 | require.Nil(t, err) 66 | require.Equal(t, r.Data.(map[string]interface{})["k"].(string), "v") 67 | require.Equal(t, strings.Contains(r.Msg, "xxx"), true) 68 | } 69 | 70 | func TestRespondWithError(t *testing.T) { 71 | gin.SetMode(gin.ReleaseMode) 72 | responseWriter := httptest.NewRecorder() 73 | c, _ := gin.CreateTestContext(responseWriter) 74 | Respond(c, 200, gin.H{"k": "v"}, errors.New("errxx"), "xxx") 75 | require.Equal(t, c.Writer.Status(), 200) 76 | j := responseWriter.Body.Bytes() 77 | r := Response{} 78 | err := json.Unmarshal(j, &r) 79 | require.Nil(t, err) 80 | require.Equal(t, r.Data.(map[string]interface{})["k"].(string), "v") 81 | require.Equal(t, strings.Contains(r.Msg, "errxx"), true) 82 | } 83 | -------------------------------------------------------------------------------- /routes/routes.go: -------------------------------------------------------------------------------- 1 | // 在这个文件中注册 URL handler 2 | 3 | package routes 4 | 5 | import "github.com/gin-gonic/gin" 6 | 7 | // Routes 注册 API URL 路由 8 | func Routes(app *gin.Engine) { 9 | app.GET("/", FundIndex) 10 | app.GET("/stock", StockIndex) 11 | app.POST("/selector", StockSelector) 12 | app.POST("/checker", StockChecker) 13 | app.GET("/fund", FundIndex) 14 | app.GET("/fund/filter", FundFilter) 15 | app.POST("/fund/check", FundCheck) 16 | app.GET("/about", About) 17 | app.GET("/comment", Comment) 18 | app.GET("/fund/similarity", FundSimilarity) 19 | app.GET("/materials", Materials) 20 | app.POST("/fund/query_by_stock", QueryFundByStock) 21 | app.GET("/fund/managers", FundManagers) 22 | } 23 | -------------------------------------------------------------------------------- /statics/README.md: -------------------------------------------------------------------------------- 1 | 静态资源文件目录 2 | -------------------------------------------------------------------------------- /statics/ads.txt: -------------------------------------------------------------------------------- 1 | google.com, pub-3022214826355647, DIRECT, f08c47fec0942fa0 2 | -------------------------------------------------------------------------------- /statics/css/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiaoxin-com/investool/2323bfdd2ba785cee851f74bb8a3b4a255ddc7d0/statics/css/README.md -------------------------------------------------------------------------------- /statics/css/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | display: flex; 3 | min-height: 100vh; 4 | flex-direction: column; 5 | } 6 | 7 | main { 8 | flex: 1 0 auto; 9 | } 10 | 11 | 12 | 13 | h1 { 14 | font-weight: bold; 15 | font-size: 32px; 16 | } 17 | 18 | h2 { 19 | font-weight: bold; 20 | font-size: 26px; 21 | } 22 | 23 | h3 { 24 | font-weight: bold; 25 | font-size: 22px; 26 | } 27 | 28 | h4 { 29 | font-weight: bold; 30 | font-size: 20px; 31 | } 32 | 33 | ins.adsbygoogle[data-ad-status="unfilled"] { 34 | display:none !important 35 | } 36 | -------------------------------------------------------------------------------- /statics/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiaoxin-com/investool/2323bfdd2ba785cee851f74bb8a3b4a255ddc7d0/statics/favicon.ico -------------------------------------------------------------------------------- /statics/font/exportor.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiaoxin-com/investool/2323bfdd2ba785cee851f74bb8a3b4a255ddc7d0/statics/font/exportor.ttf -------------------------------------------------------------------------------- /statics/html/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiaoxin-com/investool/2323bfdd2ba785cee851f74bb8a3b4a255ddc7d0/statics/html/README.md -------------------------------------------------------------------------------- /statics/html/checker_form.html: -------------------------------------------------------------------------------- 1 | {{ define "checker_form_content" }} 2 |
3 |
4 | 5 | 6 |
7 |
8 | 9 | 10 |
11 |
12 |
13 |
14 | 15 | 16 |
17 |
18 | 19 | 20 |
21 |
22 |
23 |
24 | 25 | 26 |
27 |
28 | 29 | 30 |
31 |
32 |
33 |
34 | 35 | 36 |
37 |
38 | 39 | 40 |
41 |
42 | 43 | 44 |
45 |
46 | 47 | 48 |
49 |
50 | 51 |
52 |
53 | 54 | 55 | 仅针对银行股检测生效 56 |
57 |
58 | 59 | 60 | 仅针对银行股检测生效 61 |
62 |
63 | 64 | 65 | 仅针对银行股检测生效 66 |
67 |
68 | 69 | 70 | 仅针对银行股检测生效 71 |
72 |
73 | 74 |
75 | 79 | 83 | 87 | 91 | 95 | 99 | 103 | 107 | 111 |
112 | {{ end }} 113 | -------------------------------------------------------------------------------- /statics/html/comment.html: -------------------------------------------------------------------------------- 1 | {{ template "header" . }} 2 |
3 |

留言评论

4 |
5 |
6 |
愿您如K线记录股价般凡走过必留下痕迹,欢迎留下您的建议与评论
7 |
8 | 9 |
10 | 12 | 18 | 21 |
22 | 23 |
24 |
25 | 43 |
44 | 45 |
46 |
47 | 48 |
49 | 50 |
51 | 58 |
59 | {{ template "footer" . }} 60 | -------------------------------------------------------------------------------- /statics/html/fund_filter.html: -------------------------------------------------------------------------------- 1 | {{ template "header" . }} 2 |
3 |

4433基金筛选结果

4 |

以下所有数据与信息仅供参考,不构成投资建议

5 |
6 | 12 | 15 |
16 |
17 | {{ template "fundtable" . }} 18 |
19 | {{ template "footer" . }} 20 | -------------------------------------------------------------------------------- /statics/html/fund_similarity.html: -------------------------------------------------------------------------------- 1 | {{ template "header" . }} 2 |
3 |

基金持仓相似度help_outline

4 |

以下所有数据与信息仅供参考,不构成投资建议

5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {{ range .Result }} 16 | 17 | 20 | 25 | 28 | 29 | {{ end }} 30 | 31 |
基金名称重复持仓持仓相似度
18 | {{ .Fund.Name }}({{ .Fund.Code }}) 19 | 21 | {{ range $i, $s := .SameStocks }} 22 | {{ $i }}.{{ $s }}
23 | {{end}} 24 |
26 | {{ .SimilarityValue }} 27 |
32 |
33 |
34 |
35 |
36 |

持仓相似度

37 | 采用Jaccard相似系数计算基金持仓相似度,
38 | 相似度越接近1表示重复持仓的股票越多。
39 | 0 表示持仓股票完全不同,
40 | 1 表示持仓股票完全相同。 41 |
42 |
43 |
44 | {{ template "footer" . }} 45 | -------------------------------------------------------------------------------- /statics/html/hold_stock_fund.html: -------------------------------------------------------------------------------- 1 | {{ template "header" . }} 2 |
3 |

股票选基查询结果

4 |

以下所有数据与信息仅供参考,不构成投资建议

5 |
6 | 7 | 8 | {{ range .Funds }} 9 | 10 | 16 | 17 | {{ else }} 18 | 19 | {{ end }} 20 | 21 |
11 | 12 | {{ .Shortname }}({{ .Fcode }}) 13 | content_copy 14 | 15 |
没有找到同时持仓指定股票的基金
22 |
23 | {{ template "footer" . }} 24 | -------------------------------------------------------------------------------- /statics/html/materials.html: -------------------------------------------------------------------------------- 1 | {{ template "header" . }} 2 |

投资学习资料

3 | 4 |
5 | 12 | 13 | {{ range $TypedMaterialSeries := .AllMaterialsList }} 14 |
15 | {{ range $TypeName, $MaterialSeriesList := $TypedMaterialSeries }} 16 |

{{ $TypeName }}

17 |
18 | 24 | 27 |
28 |
    29 | {{ range $i, $MaterialSeries := $MaterialSeriesList }} 30 |
  • 31 | {{ range $SeriesName, $MaterialItemList := $MaterialSeries }} 32 |

    {{ $SeriesName }}

    33 |
    34 | {{ range $MaterialItem := $MaterialItemList }} 35 |
    36 |

    {{ $MaterialItem.Name }}

    37 |

    {{ $MaterialItem.Desc }}

    38 |
    39 | {{ end }} 40 |
    41 | {{ end }} 42 |
  • 43 | {{ end }} 44 |
45 |
46 | 53 | {{ end }} 54 |
55 | {{ end }} 56 | {{ template "footer" . }} 57 | -------------------------------------------------------------------------------- /statics/html/modal.html: -------------------------------------------------------------------------------- 1 | {{ define "modal" }} 2 | 10 | 11 | 20 | 21 | 30 | {{ end }} 31 | -------------------------------------------------------------------------------- /statics/img/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiaoxin-com/investool/2323bfdd2ba785cee851f74bb8a3b4a255ddc7d0/statics/img/README.md -------------------------------------------------------------------------------- /statics/img/alipay.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiaoxin-com/investool/2323bfdd2ba785cee851f74bb8a3b4a255ddc7d0/statics/img/alipay.jpg -------------------------------------------------------------------------------- /statics/img/sidenav_bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiaoxin-com/investool/2323bfdd2ba785cee851f74bb8a3b4a255ddc7d0/statics/img/sidenav_bg.png -------------------------------------------------------------------------------- /statics/img/sidenav_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiaoxin-com/investool/2323bfdd2ba785cee851f74bb8a3b4a255ddc7d0/statics/img/sidenav_icon.png -------------------------------------------------------------------------------- /statics/img/wxpay.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiaoxin-com/investool/2323bfdd2ba785cee851f74bb8a3b4a255ddc7d0/statics/img/wxpay.jpg -------------------------------------------------------------------------------- /statics/js/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiaoxin-com/investool/2323bfdd2ba785cee851f74bb8a3b4a255ddc7d0/statics/js/README.md -------------------------------------------------------------------------------- /statics/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow:/ 3 | -------------------------------------------------------------------------------- /statics/statics.go: -------------------------------------------------------------------------------- 1 | // Package statics embed 静态文件 2 | package statics 3 | 4 | import "embed" 5 | 6 | // Files 静态文件资源 7 | //go:embed favicon.ico robots.txt ads.txt materials 8 | //go:embed css/* font/* html/* img/* js/* 9 | var Files embed.FS 10 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | // Version 版本号 4 | var Version = "1.3.4" 5 | -------------------------------------------------------------------------------- /webserver/README.md: -------------------------------------------------------------------------------- 1 | # webserver 包的使用方法 2 | 3 | 大致流程如下,具体参考 [main.go](./main.go) 4 | 5 | 1. 加载配置文件,根据配置信息个性化 web server 6 | 7 | ``` 8 | webserver.InitWithConfigFile(path/to/configfile) 9 | ``` 10 | 11 | 2. 创建 app 路由 12 | 13 | ``` 14 | app := webserver.NewGinEngine(nil) 15 | ``` 16 | 17 | 3. 启动 server 18 | 19 | ``` 20 | webserver.Run(app) 21 | ``` 22 | -------------------------------------------------------------------------------- /webserver/gin.go: -------------------------------------------------------------------------------- 1 | package webserver 2 | 3 | import ( 4 | "html/template" 5 | "net/http" 6 | 7 | "github.com/axiaoxin-com/goutils" 8 | "github.com/axiaoxin-com/investool/statics" 9 | "github.com/gin-gonic/gin" 10 | "github.com/gin-gonic/gin/binding" 11 | "github.com/json-iterator/go/extra" 12 | "github.com/spf13/viper" 13 | ) 14 | 15 | func init() { 16 | // 替换 gin 默认的 validator,更加友好的错误信息 17 | binding.Validator = &goutils.GinStructValidator{} 18 | // causes the json binding Decoder to unmarshal a number into an interface{} as a Number instead of as a float64. 19 | binding.EnableDecoderUseNumber = true 20 | 21 | // jsoniter 启动模糊模式来支持 PHP 传递过来的 JSON。容忍字符串和数字互转 22 | extra.RegisterFuzzyDecoders() 23 | // jsoniter 设置支持 private 的 field 24 | extra.SupportPrivateFields() 25 | } 26 | 27 | // NewGinEngine 根据参数创建 gin 的 router engine 28 | // middlewares 需要使用到的中间件列表,默认不为 engine 添加任何中间件 29 | func NewGinEngine(middlewares ...gin.HandlerFunc) *gin.Engine { 30 | // set gin mode 31 | gin.SetMode(viper.GetString("server.mode")) 32 | 33 | engine := gin.New() 34 | // ///a///b -> /a/b 35 | engine.RemoveExtraSlash = true 36 | 37 | // use middlewares 38 | for _, middleware := range middlewares { 39 | engine.Use(middleware) 40 | } 41 | 42 | // load html template 43 | tmplPath := viper.GetString("statics.tmpl_path") 44 | if tmplPath != "" { 45 | // add temp func for template parse 46 | // template func usage: {{ funcname xx }} 47 | t := template.Must(template.New("").Funcs(TemplFuncs).ParseFS(&statics.Files, tmplPath)) 48 | engine.SetHTMLTemplate(t) 49 | } 50 | // register statics 51 | staticsURL := viper.GetString("statics.url") 52 | if staticsURL != "" { 53 | engine.StaticFS(staticsURL, http.FS(&statics.Files)) 54 | } 55 | 56 | return engine 57 | } 58 | -------------------------------------------------------------------------------- /webserver/gin_middlewares.go: -------------------------------------------------------------------------------- 1 | package webserver 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "os" 9 | "runtime/debug" 10 | "strings" 11 | "time" 12 | 13 | "github.com/axiaoxin-com/goutils" 14 | "github.com/axiaoxin-com/logging" 15 | "github.com/axiaoxin-com/ratelimiter" 16 | "github.com/gin-gonic/gin" 17 | "github.com/spf13/viper" 18 | ) 19 | 20 | // GinBasicAuth gin 的基础认证中间件 21 | // 加到 gin app 的路由中可以对该路由添加 basic auth 登录验证 22 | // 传入 username 和 password 对可以替换默认的 username 和 password 23 | func GinBasicAuth(args ...string) gin.HandlerFunc { 24 | username := viper.GetString("basic_auth.username") 25 | password := viper.GetString("basic_auth.password") 26 | if len(args) == 2 { 27 | username = args[0] 28 | password = args[1] 29 | } 30 | logging.Debug(nil, "Basic auth username:"+username+" password:"+password) 31 | return gin.BasicAuth(gin.Accounts{ 32 | username: password, 33 | }) 34 | } 35 | 36 | // GinRecovery gin recovery 中间件 37 | // save err in context and abort with recoveryHandler 38 | func GinRecovery( 39 | recoveryHandler ...func(c *gin.Context, status int, data interface{}, err error, extraMsgs ...interface{}), 40 | ) gin.HandlerFunc { 41 | return func(c *gin.Context) { 42 | defer func() { 43 | status := c.Writer.Status() 44 | if err := recover(); err != nil { 45 | // Check for a broken connection, as it is not really a 46 | // condition that warrants a panic stack trace. 47 | status = http.StatusInternalServerError 48 | var brokenPipe bool 49 | if ne, ok := err.(*net.OpError); ok { 50 | if se, ok := ne.Err.(*os.SyscallError); ok { 51 | if strings.Contains(strings.ToLower(se.Error()), "broken pipe") || 52 | strings.Contains(strings.ToLower(se.Error()), "connection reset by peer") { 53 | brokenPipe = true 54 | } 55 | } 56 | } 57 | if brokenPipe { 58 | // save err in context 59 | c.Error(fmt.Errorf("Broken pipe: %v\n%s", err, string(debug.Stack()))) 60 | if len(recoveryHandler) > 0 { 61 | c.Abort() 62 | recoveryHandler[0](c, status, nil, errors.New(http.StatusText(status))) 63 | return 64 | } 65 | c.AbortWithStatus(status) 66 | return 67 | } 68 | 69 | // save err in context when panic 70 | c.Error(fmt.Errorf("Recovery from panic: %v\n%s", err, string(debug.Stack()))) 71 | } 72 | 73 | // 状态码大于 400 的以 status handler 进行响应 74 | if status >= 400 { 75 | // 有 handler 时使用 handler 返回 76 | if len(recoveryHandler) > 0 { 77 | c.Abort() 78 | recoveryHandler[0](c, status, nil, errors.New(http.StatusText(status))) 79 | return 80 | } 81 | // 否则只返回状态码 82 | c.AbortWithStatus(status) 83 | return 84 | } 85 | }() 86 | c.Next() 87 | } 88 | } 89 | 90 | // GinLogMiddleware 日志中间件 91 | // 可根据实际需求自行修改定制 92 | func GinLogMiddleware() gin.HandlerFunc { 93 | logging.CtxLoggerName = logging.Ctxkey("ctx_logger") 94 | logging.TraceIDKeyname = logging.Ctxkey("trace_id") 95 | logging.TraceIDPrefix = "logging_" 96 | loggerMiddleware := logging.GinLoggerWithConfig(logging.GinLoggerConfig{ 97 | SkipPaths: viper.GetStringSlice("logging.access_logger.skip_paths"), 98 | SkipPathRegexps: viper.GetStringSlice("logging.access_logger.skip_path_regexps"), 99 | EnableDetails: viper.GetBool("logging.access_logger.enable_details"), 100 | EnableContextKeys: viper.GetBool("logging.access_logger.enable_context_keys"), 101 | EnableRequestHeader: viper.GetBool("logging.access_logger.enable_request_header"), 102 | EnableRequestForm: viper.GetBool("logging.access_logger.enable_request_form"), 103 | EnableRequestBody: viper.GetBool("logging.access_logger.enable_request_body"), 104 | EnableResponseBody: viper.GetBool("logging.access_logger.enable_response_body"), 105 | SlowThreshold: viper.GetDuration("logging.access_logger.slow_threshold") * time.Millisecond, 106 | TraceIDFunc: nil, 107 | InitFieldsFunc: nil, 108 | Formatter: nil, 109 | }) 110 | return loggerMiddleware 111 | } 112 | 113 | // GinRatelimitMiddleware 限频中间件 114 | // 需先实现对应的 TODO ,可根据实际需求自行修改定制 115 | func GinRatelimitMiddleware() gin.HandlerFunc { 116 | limiterConf := ratelimiter.GinRatelimiterConfig{ 117 | // TODO: you should implement this function by yourself 118 | LimitKey: ratelimiter.DefaultGinLimitKey, 119 | // TODO: you should implement this function by yourself 120 | LimitedHandler: ratelimiter.DefaultGinLimitedHandler, 121 | // TODO: you should implement this function by yourself 122 | TokenBucketConfig: func(*gin.Context) (time.Duration, int) { 123 | return time.Second * 1, 20 124 | }, 125 | } 126 | 127 | // 根据 viper 中的配置信息选择限流类型 128 | var limiterMiddleware gin.HandlerFunc 129 | limiterType := strings.ToLower(viper.GetString("ratelimiter.type")) 130 | logging.Info(nil, "enable ratelimiter with type: "+limiterType) 131 | if strings.HasPrefix(limiterType, "redis.") { 132 | which := strings.TrimPrefix(limiterType, "redis.") 133 | if rdb, err := goutils.RedisClient(which); err != nil { 134 | panic("redis ratelimiter does not work. get redis client error:" + err.Error()) 135 | } else { 136 | limiterMiddleware = ratelimiter.GinRedisRatelimiter(rdb, limiterConf) 137 | } 138 | } else { 139 | limiterMiddleware = ratelimiter.GinMemRatelimiter(limiterConf) 140 | } 141 | return limiterMiddleware 142 | } 143 | -------------------------------------------------------------------------------- /webserver/gin_templ_func_map.go: -------------------------------------------------------------------------------- 1 | // Functions from Go's strings package usable as template actions 2 | 3 | package webserver 4 | 5 | import ( 6 | "html/template" 7 | "strings" 8 | "unicode" 9 | 10 | "github.com/axiaoxin-com/goutils" 11 | ) 12 | 13 | // TemplFuncs is a template.FuncMap with functions that can be used as template actions. 14 | var TemplFuncs = template.FuncMap{ 15 | "StrContains": func(s, substr string) bool { return strings.Contains(s, substr) }, 16 | "StrContainsAny": func(s, chars string) bool { return strings.ContainsAny(s, chars) }, 17 | "StrContainsRune": func(s string, r rune) bool { return strings.ContainsRune(s, r) }, 18 | "StrCount": func(s, sep string) int { return strings.Count(s, sep) }, 19 | "StrEqualFold": func(s, t string) bool { return strings.EqualFold(s, t) }, 20 | "StrFields": func(s string) []string { return strings.Fields(s) }, 21 | "StrFieldsFunc": func(s string, f func(rune) bool) []string { return strings.FieldsFunc(s, f) }, 22 | "StrHasPrefix": func(s, prefix string) bool { return strings.HasPrefix(s, prefix) }, 23 | "StrHasSuffix": func(s, suffix string) bool { return strings.HasSuffix(s, suffix) }, 24 | "StrIndex": func(s, sep string) int { return strings.Index(s, sep) }, 25 | "StrIndexAny": func(s, chars string) int { return strings.IndexAny(s, chars) }, 26 | "StrIndexByte": func(s string, c byte) int { return strings.IndexByte(s, c) }, 27 | "StrIndexFunc": func(s string, f func(rune) bool) int { return strings.IndexFunc(s, f) }, 28 | "StrIndexRune": func(s string, r rune) int { return strings.IndexRune(s, r) }, 29 | "StrJoin": func(a []string, sep string) string { return strings.Join(a, sep) }, 30 | "StrLastIndex": func(s, sep string) int { return strings.LastIndex(s, sep) }, 31 | "StrLastIndexAny": func(s, chars string) int { return strings.LastIndexAny(s, chars) }, 32 | "StrLastIndexFunc": func(s string, f func(rune) bool) int { return strings.LastIndexFunc(s, f) }, 33 | "StrMap": func(mapping func(rune) rune, s string) string { return strings.Map(mapping, s) }, 34 | "StrRepeat": func(s string, count int) string { return strings.Repeat(s, count) }, 35 | "StrReplace": func(s, old, new string, n int) string { return strings.Replace(s, old, new, n) }, 36 | "StrSplit": func(s, sep string) []string { return strings.Split(s, sep) }, 37 | "StrSplitAfter": func(s, sep string) []string { return strings.SplitAfter(s, sep) }, 38 | "StrSplitAfterN": func(s, sep string, n int) []string { return strings.SplitAfterN(s, sep, n) }, 39 | "StrSplitN": func(s, sep string, n int) []string { return strings.SplitN(s, sep, n) }, 40 | "StrTitle": func(s string) string { return strings.Title(s) }, 41 | "StrToLower": func(s string) string { return strings.ToLower(s) }, 42 | "StrToLowerSpecial": func(_case unicode.SpecialCase, s string) string { return strings.ToLowerSpecial(_case, s) }, 43 | "StrToTitle": func(s string) string { return strings.ToTitle(s) }, 44 | "StrToTitleSpecial": func(_case unicode.SpecialCase, s string) string { return strings.ToTitleSpecial(_case, s) }, 45 | "StrToUpper": func(s string) string { return strings.ToUpper(s) }, 46 | "StrToUpperSpecial": func(_case unicode.SpecialCase, s string) string { return strings.ToUpperSpecial(_case, s) }, 47 | "StrTrim": func(s string, cutset string) string { return strings.Trim(s, cutset) }, 48 | "StrTrimFunc": func(s string, f func(rune) bool) string { return strings.TrimFunc(s, f) }, 49 | "StrTrimLeft": func(s string, cutset string) string { return strings.TrimLeft(s, cutset) }, 50 | "StrTrimLeftFunc": func(s string, f func(rune) bool) string { return strings.TrimLeftFunc(s, f) }, 51 | "StrTrimPrefix": func(s, prefix string) string { return strings.TrimPrefix(s, prefix) }, 52 | "StrTrimRight": func(s string, cutset string) string { return strings.TrimRight(s, cutset) }, 53 | "StrTrimRightFunc": func(s string, f func(rune) bool) string { return strings.TrimRightFunc(s, f) }, 54 | "StrTrimSpace": func(s string) string { return strings.TrimSpace(s) }, 55 | "StrTrimSuffix": func(s, suffix string) string { return strings.TrimSuffix(s, suffix) }, 56 | "IsStrInSlice": goutils.IsStrInSlice, 57 | "YiWanString": goutils.YiWanString, 58 | "mod": func(i, j int) bool { return i%j == 0 }, 59 | } 60 | -------------------------------------------------------------------------------- /webserver/gin_test.go: -------------------------------------------------------------------------------- 1 | package webserver 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/spf13/viper" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestNewGinEngine(t *testing.T) { 14 | viper.SetDefault("server.mode", "release") 15 | defer viper.Reset() 16 | app := NewGinEngine(nil) 17 | assert.NotNil(t, app) 18 | } 19 | 20 | func TestGinBasicAuth(t *testing.T) { 21 | viper.Set("basic_auth.username", "axiaoxin") 22 | viper.Set("basic_auth.password", "axiaoxin") 23 | defer viper.Reset() 24 | gin.SetMode(gin.ReleaseMode) 25 | c, _ := gin.CreateTestContext(httptest.NewRecorder()) 26 | c.Request, _ = http.NewRequest("GET", "/get", nil) 27 | auth := GinBasicAuth() 28 | auth(c) 29 | assert.Equal(t, c.Writer.Status(), http.StatusUnauthorized, "request without basic auth should return StatusUnauthorized") 30 | } 31 | -------------------------------------------------------------------------------- /webserver/prom.go: -------------------------------------------------------------------------------- 1 | // promethues 2 | 3 | package webserver 4 | 5 | import ( 6 | "time" 7 | 8 | "github.com/axiaoxin-com/logging" 9 | "github.com/gin-gonic/gin" 10 | "github.com/prometheus/client_golang/prometheus" 11 | "github.com/prometheus/client_golang/prometheus/promauto" 12 | "github.com/prometheus/client_golang/prometheus/promhttp" 13 | ) 14 | 15 | var ( 16 | // prometheus namespace 17 | promNamespace = "webserver" 18 | promUptime = promauto.NewCounterVec( 19 | prometheus.CounterOpts{ 20 | Namespace: promNamespace, 21 | Name: "server_uptime", 22 | Help: "gin server uptime in seconds", 23 | }, nil, 24 | ) 25 | ) 26 | 27 | // PromExporterHandler return a handler as the prometheus metrics exporter 28 | func PromExporterHandler(collectors ...prometheus.Collector) gin.HandlerFunc { 29 | for _, collector := range collectors { 30 | if err := prometheus.Register(collector); err != nil { 31 | logging.Error(nil, "Register collector error:"+err.Error()) 32 | } 33 | } 34 | 35 | // uptime 36 | go func() { 37 | for range time.Tick(time.Second) { 38 | promUptime.WithLabelValues().Inc() 39 | } 40 | }() 41 | return gin.WrapH(promhttp.Handler()) 42 | } 43 | -------------------------------------------------------------------------------- /webserver/webserver.go: -------------------------------------------------------------------------------- 1 | package webserver 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "path" 10 | "strings" 11 | "syscall" 12 | "time" 13 | 14 | "github.com/axiaoxin-com/goutils" 15 | "github.com/axiaoxin-com/logging" 16 | "github.com/fsnotify/fsnotify" 17 | "github.com/gin-gonic/gin" 18 | "github.com/spf13/viper" 19 | ) 20 | 21 | // InitWithConfigFile 根据 webserver 配置文件初始化 webserver 22 | func InitWithConfigFile(configFile string) { 23 | // 加载配置文件内容到 viper 中以便使用 24 | configPath, file := path.Split(configFile) 25 | if configPath == "" { 26 | configPath = "./" 27 | } 28 | ext := path.Ext(file) 29 | configType := strings.Trim(ext, ".") 30 | configName := strings.TrimSuffix(file, ext) 31 | logging.Infof(nil, "load %s type config file %s from %s", configType, configName, configPath) 32 | 33 | if err := goutils.InitViper(configFile, func(e fsnotify.Event) { 34 | logging.Warn(nil, "Config file changed:"+e.Name) 35 | logging.SetLevel(viper.GetString("logging.level")) 36 | }); err != nil { 37 | // 文件不存在时 1 使用默认配置,其他 err 直接 panic 38 | if _, ok := err.(viper.ConfigFileNotFoundError); ok { 39 | panic(err) 40 | } 41 | logging.Error(nil, "Init viper error:"+err.Error()) 42 | } 43 | 44 | // 设置 viper 中 webserver 配置项默认值 45 | viper.SetDefault("env", "localhost") 46 | 47 | viper.SetDefault("server.addr", ":4869") 48 | viper.SetDefault("server.mode", gin.ReleaseMode) 49 | viper.SetDefault("server.pprof", true) 50 | 51 | viper.SetDefault("apidocs.title", "pink-lady swagger apidocs") 52 | viper.SetDefault("apidocs.desc", "Using pink-lady to develop gin app on fly.") 53 | viper.SetDefault("apidocs.host", "localhost:4869") 54 | viper.SetDefault("apidocs.basepath", "/") 55 | viper.SetDefault("apidocs.schemes", []string{"http"}) 56 | 57 | viper.SetDefault("basic_auth.username", "admin") 58 | viper.SetDefault("basic_auth.password", "admin") 59 | 60 | // 打印viper配置 61 | logging.Infof(nil, "viper load all settings:%v", viper.AllSettings()) 62 | 63 | // 初始化 sentry 并创建 sentry 客户端 64 | sentryDSN := viper.GetString("sentry.dsn") 65 | if sentryDSN == "" { 66 | sentryDSN = os.Getenv(logging.SentryDSNEnvKey) 67 | } 68 | sentryDebug := viper.GetBool("sentry.debug") 69 | if viper.GetString("server.mode") == "release" { 70 | sentryDebug = false 71 | } 72 | logging.Debug(nil, "Sentry use dns: "+sentryDSN) 73 | sentry, err := logging.NewSentryClient(sentryDSN, sentryDebug) 74 | if err != nil { 75 | logging.Error(nil, "Sentry client create error:"+err.Error()) 76 | } 77 | 78 | // 根据配置创建 logging 的 logger 并将 logging 的默认 logger 替换为当前创建的 logger 79 | outputs := viper.GetStringSlice("logging.output_paths") 80 | var lumberjackSink *logging.LumberjackSink 81 | for _, output := range outputs { 82 | if strings.HasPrefix(output, "logrotate://") { 83 | filename := strings.Split(output, "://")[1] 84 | maxAge := viper.GetInt("logging.logrotate.max_age") 85 | maxBackups := viper.GetInt("logging.logrotate.max_backups") 86 | maxSize := viper.GetInt("logging.logrotate.max_size") 87 | compress := viper.GetBool("logging.logrotate.compress") 88 | localtime := viper.GetBool("logging.logrotate.localtime") 89 | lumberjackSink = logging.NewLumberjackSink("logrotate", filename, maxAge, maxBackups, maxSize, compress, localtime) 90 | } 91 | } 92 | logger, err := logging.NewLogger(logging.Options{ 93 | Level: viper.GetString("logging.level"), 94 | Format: viper.GetString("logging.format"), 95 | OutputPaths: outputs, 96 | DisableCaller: viper.GetBool("logging.disable_caller"), 97 | DisableStacktrace: viper.GetBool("logging.disable_stacktrace"), 98 | AtomicLevelServer: logging.AtomicLevelServerOption{ 99 | Addr: viper.GetString("logging.atomic_level_server.addr"), 100 | Path: viper.GetString("logging.atomic_level_server.path"), 101 | Username: viper.GetString("basic_auth.username"), 102 | Password: viper.GetString("basic_auth.password"), 103 | }, 104 | SentryClient: sentry, 105 | LumberjackSink: lumberjackSink, 106 | }) 107 | if err != nil { 108 | logging.Error(nil, "Logger create error:"+err.Error()) 109 | } else { 110 | logging.ReplaceLogger(logger) 111 | } 112 | } 113 | 114 | // Run 以 viper 加载的 app 配置启动运行 http.Handler 的 app 115 | // 注意:这里依赖 viper ,必须在外部先对 viper 配置进行加载 116 | func Run(app http.Handler) { 117 | // 判断是否加载 viper 配置 118 | if !goutils.IsInitedViper() { 119 | panic("Running server must init viper by config file first!") 120 | } 121 | 122 | // 创建 server 123 | addr := viper.GetString("server.addr") 124 | srv := &http.Server{ 125 | Addr: addr, 126 | Handler: app, 127 | ReadTimeout: 5 * time.Minute, 128 | WriteTimeout: 10 * time.Minute, 129 | } 130 | // Shutdown 时需要调用的方法 131 | srv.RegisterOnShutdown(func() { 132 | // TODO 133 | }) 134 | 135 | // 启动 http server 136 | go func() { 137 | var ln net.Listener 138 | var err error 139 | if strings.ToLower(strings.Split(addr, ":")[0]) == "unix" { 140 | ln, err = net.Listen("unix", strings.Split(addr, ":")[1]) 141 | if err != nil { 142 | panic(err) 143 | } 144 | } else { 145 | ln, err = net.Listen("tcp", addr) 146 | if err != nil { 147 | panic(err) 148 | } 149 | } 150 | if err := srv.Serve(ln); err != nil { 151 | logging.Error(nil, err.Error()) 152 | } 153 | }() 154 | logging.Infof(nil, "Server is running on %s", srv.Addr) 155 | 156 | // 监听中断信号, WriteTimeout 时间后优雅关闭服务 157 | // syscall.SIGTERM 不带参数的 kill 命令 158 | // syscall.SIGINT ctrl-c kill -2 159 | // syscall.SIGKILL 是 kill -9 无法捕获这个信号 160 | quit := make(chan os.Signal, 1) 161 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 162 | <-quit 163 | logging.Infof(nil, "Server is shutting down.") 164 | 165 | // 创建一个 context 用于通知 server 3 秒后结束当前正在处理的请求 166 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 167 | defer cancel() 168 | if err := srv.Shutdown(ctx); err != nil { 169 | logging.Error(nil, "Server shutdown with error: "+err.Error()) 170 | } 171 | logging.Info(nil, "Server exit.") 172 | } 173 | -------------------------------------------------------------------------------- /webserver/webserver_test.go: -------------------------------------------------------------------------------- 1 | package webserver 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/axiaoxin-com/goutils" 7 | "github.com/spf13/viper" 8 | ) 9 | 10 | func TestViperConfig(t *testing.T) { 11 | InitWithConfigFile("../config.toml") 12 | defer viper.Reset() 13 | if !goutils.IsInitedViper() { 14 | t.Error("init viper failed") 15 | } 16 | } 17 | --------------------------------------------------------------------------------