fiewolf1000 commited on
Commit
492836d
·
verified ·
1 Parent(s): c74fd90

Upload 65 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env-example +40 -0
  2. .gitattributes +3 -0
  3. .gitignore +91 -0
  4. .idea/.gitignore +3 -0
  5. .idea/StockAnal.iml +8 -0
  6. .idea/inspectionProfiles/Project_Default.xml +184 -0
  7. .idea/inspectionProfiles/profiles_settings.xml +6 -0
  8. .idea/misc.xml +10 -0
  9. .idea/modules.xml +8 -0
  10. Dockerfile +40 -0
  11. LICENSE +21 -0
  12. README.md +316 -12
  13. __pycache__/capital_flow_analyzer.cpython-311.pyc +0 -0
  14. __pycache__/database.cpython-311.pyc +0 -0
  15. __pycache__/fundamental_analyzer.cpython-311.pyc +0 -0
  16. __pycache__/index_industry_analyzer.cpython-311.pyc +0 -0
  17. __pycache__/industry_analyzer.cpython-311.pyc +0 -0
  18. __pycache__/risk_monitor.cpython-311.pyc +0 -0
  19. __pycache__/scenario_predictor.cpython-311.pyc +0 -0
  20. __pycache__/stock_analyzer.cpython-311.pyc +0 -0
  21. __pycache__/stock_qa.cpython-311.pyc +0 -0
  22. __pycache__/us_stock_service.cpython-311.pyc +0 -0
  23. app/analysis/capital_flow_analyzer.py +588 -0
  24. app/analysis/etf_analyzer.py +494 -0
  25. app/analysis/fundamental_analyzer.py +199 -0
  26. app/analysis/index_industry_analyzer.py +275 -0
  27. app/analysis/industry_analyzer.py +533 -0
  28. app/analysis/news_fetcher.py +282 -0
  29. app/analysis/risk_monitor.py +375 -0
  30. app/analysis/scenario_predictor.py +254 -0
  31. app/analysis/stock_analyzer.py +1693 -0
  32. app/analysis/stock_qa.py +484 -0
  33. app/analysis/us_stock_service.py +67 -0
  34. app/core/database.py +102 -0
  35. app/web/auth_middleware.py +79 -0
  36. app/web/industry_api_endpoints.py +9 -0
  37. app/web/static/css/theme.css +354 -0
  38. app/web/static/favicon.ico +3 -0
  39. app/web/static/swagger.json +573 -0
  40. app/web/templates/agent_analysis.html +703 -0
  41. app/web/templates/capital_flow.html +866 -0
  42. app/web/templates/dashboard.html +679 -0
  43. app/web/templates/error.html +30 -0
  44. app/web/templates/etf_analysis.html +321 -0
  45. app/web/templates/fundamental.html +405 -0
  46. app/web/templates/index.html +636 -0
  47. app/web/templates/industry_analysis.html +1135 -0
  48. app/web/templates/layout.html +1091 -0
  49. app/web/templates/market_scan.html +591 -0
  50. app/web/templates/portfolio.html +602 -0
.env-example ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API 提供商 (openai SDK)
2
+ API_PROVIDER=openai #使用openai格式的api即可,无需修改
3
+
4
+ # OpenAI API 配置 使用openai格式的api
5
+ OPENAI_API_URL=https://free.v36.cm/v1
6
+ OPENAI_API_KEY=sk-tLB1LCAGfBVMW1mt54F1A5026dD246E582809454Ea93E430
7
+ OPENAI_API_MODEL=gpt-4o-mini
8
+ NEWS_MODEL=your_open_news_model
9
+ EMBEDDING_MODEL=text-embedding-3-small
10
+
11
+
12
+ # 其他新闻获取接口 Serp 和 tavily(免费1000次/月) 二选一即可,如果都选,都会使用
13
+ SERP_API_KEY=e8956fd01e58d7a067b996a9af460bddf4ddd272 # Serp key申请地址:https://serper.dev/api-key
14
+ TAVILY_API_KEY=your_tavily_api_key # tavily key申请地址:https://app.tavily.com/playground
15
+ FINNHUB_API_KEY=your_finnhub_api_key # Finnhub key申请地址: https://finnhub.io/dashboard
16
+
17
+ FUNCTION_CALL_MODEL=your_function_call_model
18
+
19
+ # QA上下文数量
20
+ MAX_QA=10
21
+
22
+ # 安全配置
23
+ # API_KEY=sk-tLB1LCAGfBVMW1mt54F1A5026dD246E582809454Ea93E430
24
+ # HMAC_SECRET=your_hmac_secret_key_for_webhook_verification
25
+ # ALLOWED_ORIGINS=http://localhost:8888,https://your-domain.com
26
+
27
+ # Redis缓存设置(可选)
28
+ # REDIS_URL=redis://redis:6379 #docker配置
29
+ REDIS_URL=redis://localhost:6379
30
+ USE_REDIS_CACHE=False
31
+
32
+ # 数据库设置(可选)
33
+ # DATABASE_URL=sqlite:///app/data/stock_analyzer.db #docker配置
34
+ DATABASE_URL=sqlite:///data/stock_analyzer.db
35
+ USE_DATABASE=False
36
+
37
+ # 日志配置
38
+ # 可选的日志级别: DEBUG, INFO, WARNING, ERROR, CRITICAL
39
+ LOG_LEVEL=INFO
40
+ LOG_FILE=logs/stock_analyzer.log
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ app/web/static/favicon.ico filter=lfs diff=lfs merge=lfs -text
37
+ images/1.png filter=lfs diff=lfs merge=lfs -text
38
+ images/2.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ **/__pycache__/
4
+ */__pycache__/*
5
+ *.py[cod]
6
+ *$py.class
7
+ *.so
8
+ .Python
9
+ build/
10
+ develop-eggs/
11
+ dist/
12
+ downloads/
13
+ eggs/
14
+ .eggs/
15
+ lib/
16
+ lib64/
17
+ parts/
18
+ sdist/
19
+ var/
20
+ wheels/
21
+ *.egg-info/
22
+ .installed.cfg
23
+ *.egg
24
+ # Virtual Environment
25
+ venv/
26
+ env/
27
+ ENV/
28
+ myenv/
29
+ myenv2/
30
+ .env
31
+
32
+ # IDE
33
+ .idea/
34
+ .vscode/
35
+ *.swp
36
+ *.swo
37
+ .DS_Store
38
+
39
+ # Logs
40
+ *.log
41
+ logs/
42
+ log/
43
+
44
+ # Database
45
+ *.db
46
+ *.sqlite3
47
+ *.sqlite
48
+ data/
49
+
50
+ # Docker
51
+ .docker/
52
+
53
+ # Test
54
+ .coverage
55
+ htmlcov/
56
+ .pytest_cache/
57
+ .tox/
58
+
59
+ # Distribution
60
+ *.tar.gz
61
+ *.zip
62
+
63
+ # Cache
64
+ .cache/
65
+ .pytest_cache/
66
+
67
+ # Jupyter Notebook
68
+ .ipynb_checkpoints
69
+ *.ipynb
70
+
71
+ # Local development settings
72
+ local_settings.py
73
+
74
+ # Redis
75
+ dump.rdb
76
+
77
+ # Other
78
+ *.bak
79
+ *.tmp
80
+ *.temp
81
+ .env.local
82
+ .env.development.local
83
+ .env.test.local
84
+ .env.production.local
85
+
86
+ # md
87
+ CLAUDE.md
88
+ akshare.md
89
+ .server.pid
90
+
91
+ eval_results/
.idea/.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Default ignored files
2
+ /shelf/
3
+ /workspace.xml
.idea/StockAnal.iml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <module type="PYTHON_MODULE" version="4">
3
+ <component name="NewModuleRootManager">
4
+ <content url="file://$MODULE_DIR$" />
5
+ <orderEntry type="inheritedJdk" />
6
+ <orderEntry type="sourceFolder" forTests="false" />
7
+ </component>
8
+ </module>
.idea/inspectionProfiles/Project_Default.xml ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <component name="InspectionProjectProfileManager">
2
+ <profile version="1.0">
3
+ <option name="myName" value="Project Default" />
4
+ <inspection_tool class="HtmlUnknownAttribute" enabled="true" level="WARNING" enabled_by_default="true">
5
+ <option name="myValues">
6
+ <value>
7
+ <list size="1">
8
+ <item index="0" class="java.lang.String" itemvalue="market_type" />
9
+ </list>
10
+ </value>
11
+ </option>
12
+ <option name="myCustomValuesEnabled" value="true" />
13
+ </inspection_tool>
14
+ <inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
15
+ <option name="ignoredPackages">
16
+ <value>
17
+ <list size="147">
18
+ <item index="0" class="java.lang.String" itemvalue="chardet" />
19
+ <item index="1" class="java.lang.String" itemvalue="openai" />
20
+ <item index="2" class="java.lang.String" itemvalue="linkai" />
21
+ <item index="3" class="java.lang.String" itemvalue="qrcode" />
22
+ <item index="4" class="java.lang.String" itemvalue="pre-commit" />
23
+ <item index="5" class="java.lang.String" itemvalue="HTMLParser" />
24
+ <item index="6" class="java.lang.String" itemvalue="PyQRCode" />
25
+ <item index="7" class="java.lang.String" itemvalue="web.py" />
26
+ <item index="8" class="java.lang.String" itemvalue="requests" />
27
+ <item index="9" class="java.lang.String" itemvalue="Pillow" />
28
+ <item index="10" class="java.lang.String" itemvalue="zhipuai" />
29
+ <item index="11" class="java.lang.String" itemvalue="cprint" />
30
+ <item index="12" class="java.lang.String" itemvalue="pillow" />
31
+ <item index="13" class="java.lang.String" itemvalue="pyymal" />
32
+ <item index="14" class="java.lang.String" itemvalue="qianfan" />
33
+ <item index="15" class="java.lang.String" itemvalue="pydub" />
34
+ <item index="16" class="java.lang.String" itemvalue="flask" />
35
+ <item index="17" class="java.lang.String" itemvalue="pilk" />
36
+ <item index="18" class="java.lang.String" itemvalue="httpx" />
37
+ <item index="19" class="java.lang.String" itemvalue="yarg" />
38
+ <item index="20" class="java.lang.String" itemvalue="PyYAML" />
39
+ <item index="21" class="java.lang.String" itemvalue="pickleshare" />
40
+ <item index="22" class="java.lang.String" itemvalue="defusedxml" />
41
+ <item index="23" class="java.lang.String" itemvalue="executing" />
42
+ <item index="24" class="java.lang.String" itemvalue="Pygments" />
43
+ <item index="25" class="java.lang.String" itemvalue="bleach" />
44
+ <item index="26" class="java.lang.String" itemvalue="soupsieve" />
45
+ <item index="27" class="java.lang.String" itemvalue="comtypes" />
46
+ <item index="28" class="java.lang.String" itemvalue="jsonschema" />
47
+ <item index="29" class="java.lang.String" itemvalue="pywin32" />
48
+ <item index="30" class="java.lang.String" itemvalue="pydantic" />
49
+ <item index="31" class="java.lang.String" itemvalue="asgiref" />
50
+ <item index="32" class="java.lang.String" itemvalue="click" />
51
+ <item index="33" class="java.lang.String" itemvalue="nbconvert" />
52
+ <item index="34" class="java.lang.String" itemvalue="attrs" />
53
+ <item index="35" class="java.lang.String" itemvalue="jedi" />
54
+ <item index="36" class="java.lang.String" itemvalue="pure-eval" />
55
+ <item index="37" class="java.lang.String" itemvalue="regex" />
56
+ <item index="38" class="java.lang.String" itemvalue="jupyterlab_pygments" />
57
+ <item index="39" class="java.lang.String" itemvalue="pydantic_core" />
58
+ <item index="40" class="java.lang.String" itemvalue="asttokens" />
59
+ <item index="41" class="java.lang.String" itemvalue="google-ai-generativelanguage" />
60
+ <item index="42" class="java.lang.String" itemvalue="platformdirs" />
61
+ <item index="43" class="java.lang.String" itemvalue="gTTS" />
62
+ <item index="44" class="java.lang.String" itemvalue="httpcore" />
63
+ <item index="45" class="java.lang.String" itemvalue="idna" />
64
+ <item index="46" class="java.lang.String" itemvalue="referencing" />
65
+ <item index="47" class="java.lang.String" itemvalue="rsa" />
66
+ <item index="48" class="java.lang.String" itemvalue="decorator" />
67
+ <item index="49" class="java.lang.String" itemvalue="ephem" />
68
+ <item index="50" class="java.lang.String" itemvalue="pandocfilters" />
69
+ <item index="51" class="java.lang.String" itemvalue="pyttsx3" />
70
+ <item index="52" class="java.lang.String" itemvalue="pkgutil_resolve_name" />
71
+ <item index="53" class="java.lang.String" itemvalue="pyasn1" />
72
+ <item index="54" class="java.lang.String" itemvalue="sniffio" />
73
+ <item index="55" class="java.lang.String" itemvalue="websocket-client" />
74
+ <item index="56" class="java.lang.String" itemvalue="exceptiongroup" />
75
+ <item index="57" class="java.lang.String" itemvalue="stack-data" />
76
+ <item index="58" class="java.lang.String" itemvalue="zipp" />
77
+ <item index="59" class="java.lang.String" itemvalue="ntchat" />
78
+ <item index="60" class="java.lang.String" itemvalue="grpcio-status" />
79
+ <item index="61" class="java.lang.String" itemvalue="pyee" />
80
+ <item index="62" class="java.lang.String" itemvalue="annotated-types" />
81
+ <item index="63" class="java.lang.String" itemvalue="importlib_metadata" />
82
+ <item index="64" class="java.lang.String" itemvalue="tornado" />
83
+ <item index="65" class="java.lang.String" itemvalue="pyasn1_modules" />
84
+ <item index="66" class="java.lang.String" itemvalue="et-xmlfile" />
85
+ <item index="67" class="java.lang.String" itemvalue="mistune" />
86
+ <item index="68" class="java.lang.String" itemvalue="Django" />
87
+ <item index="69" class="java.lang.String" itemvalue="typing_extensions" />
88
+ <item index="70" class="java.lang.String" itemvalue="cachetools" />
89
+ <item index="71" class="java.lang.String" itemvalue="multidict" />
90
+ <item index="72" class="java.lang.String" itemvalue="yarl" />
91
+ <item index="73" class="java.lang.String" itemvalue="pytz" />
92
+ <item index="74" class="java.lang.String" itemvalue="webencodings" />
93
+ <item index="75" class="java.lang.String" itemvalue="tiktoken" />
94
+ <item index="76" class="java.lang.String" itemvalue="traitlets" />
95
+ <item index="77" class="java.lang.String" itemvalue="protobuf" />
96
+ <item index="78" class="java.lang.String" itemvalue="arrow" />
97
+ <item index="79" class="java.lang.String" itemvalue="googleapis-common-protos" />
98
+ <item index="80" class="java.lang.String" itemvalue="huggingface-hub" />
99
+ <item index="81" class="java.lang.String" itemvalue="jiter" />
100
+ <item index="82" class="java.lang.String" itemvalue="python-dateutil" />
101
+ <item index="83" class="java.lang.String" itemvalue="baidu-aip" />
102
+ <item index="84" class="java.lang.String" itemvalue="h11" />
103
+ <item index="85" class="java.lang.String" itemvalue="nbclient" />
104
+ <item index="86" class="java.lang.String" itemvalue="MarkupSafe" />
105
+ <item index="87" class="java.lang.String" itemvalue="tinycss2" />
106
+ <item index="88" class="java.lang.String" itemvalue="frozenlist" />
107
+ <item index="89" class="java.lang.String" itemvalue="fsspec" />
108
+ <item index="90" class="java.lang.String" itemvalue="docopt" />
109
+ <item index="91" class="java.lang.String" itemvalue="filelock" />
110
+ <item index="92" class="java.lang.String" itemvalue="backports.zoneinfo" />
111
+ <item index="93" class="java.lang.String" itemvalue="pyzmq" />
112
+ <item index="94" class="java.lang.String" itemvalue="certifi" />
113
+ <item index="95" class="java.lang.String" itemvalue="anthropic" />
114
+ <item index="96" class="java.lang.String" itemvalue="anyio" />
115
+ <item index="97" class="java.lang.String" itemvalue="google-api-core" />
116
+ <item index="98" class="java.lang.String" itemvalue="beautifulsoup4" />
117
+ <item index="99" class="java.lang.String" itemvalue="tokenizers" />
118
+ <item index="100" class="java.lang.String" itemvalue="image" />
119
+ <item index="101" class="java.lang.String" itemvalue="nodeenv" />
120
+ <item index="102" class="java.lang.String" itemvalue="jupyter_client" />
121
+ <item index="103" class="java.lang.String" itemvalue="backcall" />
122
+ <item index="104" class="java.lang.String" itemvalue="virtualenv" />
123
+ <item index="105" class="java.lang.String" itemvalue="charset-normalizer" />
124
+ <item index="106" class="java.lang.String" itemvalue="distlib" />
125
+ <item index="107" class="java.lang.String" itemvalue="pypiwin32" />
126
+ <item index="108" class="java.lang.String" itemvalue="cfgv" />
127
+ <item index="109" class="java.lang.String" itemvalue="distro" />
128
+ <item index="110" class="java.lang.String" itemvalue="matplotlib-inline" />
129
+ <item index="111" class="java.lang.String" itemvalue="async-timeout" />
130
+ <item index="112" class="java.lang.String" itemvalue="pysilk-mod" />
131
+ <item index="113" class="java.lang.String" itemvalue="wcwidth" />
132
+ <item index="114" class="java.lang.String" itemvalue="jupyter_core" />
133
+ <item index="115" class="java.lang.String" itemvalue="Jinja2" />
134
+ <item index="116" class="java.lang.String" itemvalue="types-python-dateutil" />
135
+ <item index="117" class="java.lang.String" itemvalue="sqlparse" />
136
+ <item index="118" class="java.lang.String" itemvalue="jsonschema-specifications" />
137
+ <item index="119" class="java.lang.String" itemvalue="google-generativeai" />
138
+ <item index="120" class="java.lang.String" itemvalue="rpds-py" />
139
+ <item index="121" class="java.lang.String" itemvalue="LunarCalendar" />
140
+ <item index="122" class="java.lang.String" itemvalue="urllib3" />
141
+ <item index="123" class="java.lang.String" itemvalue="croniter" />
142
+ <item index="124" class="java.lang.String" itemvalue="six" />
143
+ <item index="125" class="java.lang.String" itemvalue="identify" />
144
+ <item index="126" class="java.lang.String" itemvalue="importlib_resources" />
145
+ <item index="127" class="java.lang.String" itemvalue="prompt_toolkit" />
146
+ <item index="128" class="java.lang.String" itemvalue="parso" />
147
+ <item index="129" class="java.lang.String" itemvalue="nbformat" />
148
+ <item index="130" class="java.lang.String" itemvalue="tzdata" />
149
+ <item index="131" class="java.lang.String" itemvalue="ipython" />
150
+ <item index="132" class="java.lang.String" itemvalue="packaging" />
151
+ <item index="133" class="java.lang.String" itemvalue="pipreqs" />
152
+ <item index="134" class="java.lang.String" itemvalue="fastjsonschema" />
153
+ <item index="135" class="java.lang.String" itemvalue="tqdm" />
154
+ <item index="136" class="java.lang.String" itemvalue="colorama" />
155
+ <item index="137" class="java.lang.String" itemvalue="proto-plus" />
156
+ <item index="138" class="java.lang.String" itemvalue="aiohttp" />
157
+ <item index="139" class="java.lang.String" itemvalue="grpcio" />
158
+ <item index="140" class="java.lang.String" itemvalue="aiosignal" />
159
+ <item index="141" class="java.lang.String" itemvalue="google-auth" />
160
+ <item index="142" class="java.lang.String" itemvalue="openpyxl" />
161
+ <item index="143" class="java.lang.String" itemvalue="bs4" />
162
+ <item index="144" class="java.lang.String" itemvalue="lxml" />
163
+ <item index="145" class="java.lang.String" itemvalue="uvicorn" />
164
+ <item index="146" class="java.lang.String" itemvalue="gunicorn" />
165
+ </list>
166
+ </value>
167
+ </option>
168
+ </inspection_tool>
169
+ <inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
170
+ <option name="ignoredErrors">
171
+ <list>
172
+ <option value="N801" />
173
+ </list>
174
+ </option>
175
+ </inspection_tool>
176
+ <inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
177
+ <option name="ignoredIdentifiers">
178
+ <list>
179
+ <option value="time.error" />
180
+ </list>
181
+ </option>
182
+ </inspection_tool>
183
+ </profile>
184
+ </component>
.idea/inspectionProfiles/profiles_settings.xml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ <component name="InspectionProjectProfileManager">
2
+ <settings>
3
+ <option name="USE_PROJECT_PROFILE" value="false" />
4
+ <version value="1.0" />
5
+ </settings>
6
+ </component>
.idea/misc.xml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="Black">
4
+ <option name="sdkName" value="Python 3.11" />
5
+ </component>
6
+ <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.11" project-jdk-type="Python SDK" />
7
+ <component name="PyCharmProfessionalAdvertiser">
8
+ <option name="shown" value="true" />
9
+ </component>
10
+ </project>
.idea/modules.xml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/StockAnal.iml" filepath="$PROJECT_DIR$/.idea/StockAnal.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
Dockerfile ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 使用Python 3.11基础镜像(因为你的依赖包兼容性更好)
2
+ FROM python:3.11-slim
3
+
4
+ # 设置工作目录
5
+ WORKDIR /app
6
+
7
+ # 创建数据和日志目录
8
+ RUN mkdir -p /app/data /app/logs
9
+
10
+ # 设置环境变量
11
+ ENV PYTHONUNBUFFERED=1 \
12
+ PYTHONDONTWRITEBYTECODE=1
13
+
14
+ # 更新源列表并更换为阿里云源
15
+ RUN echo 'deb http://mirrors.aliyun.com/debian/ bookworm main' > /etc/apt/sources.list && \
16
+ echo 'deb-src http://mirrors.aliyun.com/debian/ bookworm main' >> /etc/apt/sources.list && \
17
+ echo 'deb http://mirrors.aliyun.com/debian/ bookworm-updates main' >> /etc/apt/sources.list && \
18
+ echo 'deb-src http://mirrors.aliyun.com/debian/ bookworm-updates main' >> /etc/apt/sources.list && \
19
+ echo 'deb http://mirrors.aliyun.com/debian-security bookworm-security main' >> /etc/apt/sources.list && \
20
+ echo 'deb-src http://mirrors.aliyun.com/debian-security bookworm-security main' >> /etc/apt/sources.list
21
+
22
+ # 安装系统依赖
23
+ RUN apt-get update && apt-get install -y --no-install-recommends \
24
+ build-essential \
25
+ && rm -rf /var/lib/apt/lists/*
26
+
27
+ # 复制requirements.txt
28
+ COPY requirements.txt .
29
+
30
+ # 安装Python依赖
31
+ RUN pip install --no-cache-dir -r requirements.txt
32
+
33
+ # 复制应用代码
34
+ COPY . .
35
+
36
+ # 暴露端口(假设Flask应用运行在5000端口)
37
+ EXPOSE 8888
38
+
39
+ # 使用gunicorn启动应用
40
+ CMD ["gunicorn", "--bind", "0.0.0.0:8888", "--workers", "4", "web_server:app"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Cookpro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,12 +1,316 @@
1
- ---
2
- title: Stock Any
3
- emoji: 📈
4
- colorFrom: indigo
5
- colorTo: purple
6
- sdk: docker
7
- pinned: false
8
- license: other
9
- short_description: stock_any
10
- ---
11
-
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 智能分析系统
2
+
3
+ ![版本](https://img.shields.io/badge/版本-2.1.0-blue.svg)
4
+ ![Python](https://img.shields.io/badge/Python-3.7+-green.svg)
5
+ ![Flask](https://img.shields.io/badge/Flask-2.0+-red.svg)
6
+ ![AKShare](https://img.shields.io/badge/AKShare-1.0.0+-orange.svg)
7
+ ![AI](https://img.shields.io/badge/AI_API-集成-blueviolet.svg)
8
+
9
+ ![系统首页截图](./images/1.png)
10
+
11
+ ## 📝 项目概述
12
+
13
+ 智能分析系统是一个基于Python和Flask的Web应用,整合了多维度股票分析能力和人工智能辅助决策功能。系统通过AKShare获取股票数据,结合技术分析、基本面分析和资金面分析,为投资者提供全方位的投资决策支持。
14
+
15
+ ## ✨ 核心功能
16
+
17
+ ### 多维度股票分析
18
+
19
+ - **技术面分析**:趋势识别、支撑压力位、技术指标(RSI、MACD、KDJ等)
20
+ - **基本面分析**:估值分析、财务健康、成长前景
21
+ - **资金面分析**:主力资金流向、北向资金、机构持仓
22
+ - **智能评分**:100分制综合评分,40-40-20权重分配
23
+
24
+ ### 智能化功能
25
+
26
+ - **AI增强分析**:通过AI API提供专业投资建议
27
+ - **支撑压力位自动识别**:智能识别关键价格区域
28
+ - **情景预测**:生成乐观、中性、悲观多种市场情景,优化预测精度和可视化效果
29
+ - **智能问答**:支持联网搜索实时信息和多轮对话,回答关于个股的专业问题
30
+
31
+ ### 市场分析工具
32
+
33
+ - **市场扫描**:筛选高评分股票,发现投资机会
34
+ - **投资组合分析**:评估投资组合表现,提供调整建议
35
+ - **风险监控**:多维度风险预警系统
36
+ - **指数和行业分析**:支持沪深300、中证500等指数和主要行业成分股分析
37
+
38
+ ### 可视化界面
39
+
40
+ - **交互式图表**:K线图、技术指标、多维度评分雷达图
41
+ - **直观数据展示**:支撑压力位、评分、投资建议等清晰呈现
42
+ - **响应式设计**:适配桌面和移动设备的界面
43
+ - **财经门户主页**:三栏式财经门户风格布局,左侧功能导航、中间实时财经要闻、右侧舆情热点,底部显示全球主要市场状态
44
+
45
+ ### 实时数据更新
46
+
47
+ - **实时财经要闻**:时间线形式展示最新财经新闻,自动高亮上涨/下跌相关内容
48
+ - **舆情热点监控**:自动识别和展示市场舆情热点,包括人工智能等前沿领域
49
+ - **全球市场状态**:实时显示亚太、欧非中东、美洲等全球主要证券市场的开闭市状态
50
+ - **自动刷新机制**:系统每10分钟自动刷新,确保数据实时性
51
+
52
+ ## 🔧 系统架构
53
+
54
+ ```
55
+ 智能分析系统/
56
+
57
+ ├── web_server.py # Web服务器和路由控制
58
+ ├── stock_analyzer.py # 股票分析核心引擎
59
+ ├── us_stock_service.py # 美股服务(可选)
60
+ ├── start.sh # 服务管理脚本
61
+ ├── news_fetcher.py # 新闻获取与缓存
62
+ ├── stock_qa.py # 智能问答功能,支持联网搜索
63
+
64
+ ├── templates/ # HTML模板
65
+ │ ├── layout.html # 基础布局模板
66
+ │ ├── index.html # 首页(财经门户风格)
67
+ │ ├── dashboard.html # 智能仪表盘
68
+ │ ├── stock_detail.html # 股票详情页
69
+ │ ├── market_scan.html # 市场扫描页面
70
+ │ ├── portfolio.html # 投资组合页面
71
+ │ └── error.html # 错误页面
72
+ │ └── ********* # 不一一列举了
73
+
74
+ ├── static/ # 静态资源
75
+ │ ├── favicon.ico # favicon.ico
76
+ │ └── swagger.json # API文档
77
+
78
+ ├── data/ # 数据存储目录
79
+ │ └── news/ # 新闻缓存目录
80
+
81
+ └── .env # 环境变量配置文件
82
+ ```
83
+
84
+ ### 技术栈
85
+
86
+ - **后端**:Python, Flask, AKShare, AI API
87
+ - **前端**:HTML5, CSS3, JavaScript, Bootstrap 5, ApexCharts
88
+ - **数据分析**:Pandas, NumPy
89
+ - **AI**:各种AI模型集成
90
+
91
+ ## 📦 安装指南
92
+
93
+ ### 环境要求
94
+
95
+ - Python 3.7+
96
+ - pip包管理器
97
+ - 网络连接(用于获取股票数据和访问AI API)
98
+
99
+ ### 安装步骤
100
+
101
+ 1. **克隆或下载代码库**
102
+
103
+ ```bash
104
+ git clone https://github.com/LargeCupPanda/StockAnal_Sys.git
105
+ cd StockAnal_Sys
106
+ ```
107
+
108
+ 2. **安装依赖**
109
+
110
+ ```bash
111
+ pip install -r requirements.txt
112
+ ```
113
+
114
+ 或手动安装主要依赖:
115
+
116
+ ```bash
117
+ pip install flask pandas numpy akshare requests matplotlib python-dotenv flask-cors flask-caching
118
+ ```
119
+
120
+ 3. **创建并配置环境变量**
121
+
122
+ 将`.env-example`复制为`.env`,并设置您的API密钥:
123
+
124
+ ```
125
+ # API 提供商 (OpenAI SDK )
126
+ API_PROVIDER=openai
127
+
128
+ # OpenAI API 配置
129
+ OPENAI_API_URL=***
130
+ OPENAI_API_KEY=your_api_key
131
+ OPENAI_API_MODEL=gpt-4o
132
+ NEWS_MODEL=你的可联网模型
133
+ ```
134
+
135
+ ## ⚙️ 配置说明
136
+
137
+ ### 环境变量
138
+
139
+ | 变量名 | 说明 | 默认值 |
140
+ |-------|------|-------|
141
+ | `API_PROVIDER` | API提供商选择 | `openai` |
142
+ | `OPENAI_API_KEY` | OpenAI API密钥 | 无,必须提供 |
143
+ | `OPENAI_API_URL` | OpenAI API端点URL | `https://api.openai.com/v1` |
144
+ | `OPENAI_API_MODEL` | 使用的OpenAI模型 | `gpt-4o` |
145
+ | `PORT` | Web服务器端口 | `8888` |
146
+
147
+ ### 技术指标参数
148
+
149
+ 可在`stock_analyzer.py`中的`__init__`方法中调整以下参数:
150
+
151
+ - `ma_periods`: 移动平均线周期设置
152
+ - `rsi_period`: RSI指标周期
153
+ - `bollinger_period`: 布林带周期
154
+ - `bollinger_std`: 布林带标准差
155
+ - `volume_ma_period`: 成交量均线周期
156
+ - `atr_period`: ATR周期
157
+
158
+ ### 缓存机制
159
+
160
+ 系统实现了智能缓存策略,包括:
161
+
162
+ - **股票数据缓存**:减少重复API调用
163
+ - **分析结果缓存**:避免重复计算
164
+ - **任务结果缓存**:保存已完成任务的结果
165
+ - **新闻数据缓存**:按天存储新闻数据,避免重复内容
166
+ - **自动缓存清理**:每天收盘时间(16:30左右)自动清理所有缓存,确保数据实时性
167
+
168
+ ## 🚀 使用指南
169
+
170
+ ### 启动系统
171
+
172
+ 使用提供的启动脚本:
173
+
174
+ ```bash
175
+ bash start.sh start
176
+ ```
177
+
178
+ 启动后,访问 `http://localhost:8888` 打开系统。
179
+
180
+ ### 其他管理命令
181
+
182
+ ```bash
183
+ bash start.sh stop # 停止服务
184
+ bash start.sh restart # 重启服务
185
+ bash start.sh status # 查看服务状态
186
+ bash start.sh monitor # 以监控模式运行(自动重启)
187
+ bash start.sh logs # 查看日志
188
+ ```
189
+
190
+ ### Docker启动
191
+
192
+ ```bash
193
+ docker-compose up -d
194
+ ```
195
+ 可以挂载sqlite_data,在env文件中设置USE_DATABASE=True
196
+ 可以使用redis缓存,在env文件中设置USE_REDIS_CACHE=True
197
+ 挂载.env文件到本地
198
+
199
+ ### 主要功能页面
200
+
201
+ 1. **首页** (`/`)
202
+ - 三栏式财经门户风格界面
203
+ - 左侧功能导航、中间实时财经要闻、右侧舆情热点
204
+ - 底部显示全球主要市场状态,10分钟自动刷新
205
+
206
+ 2. **智能仪表盘** (`/dashboard`)
207
+ - 输入股票代码,开始分析
208
+ - 查看多维度分析结果和AI建议
209
+
210
+ 3. **股票详情** (`/stock_detail/<stock_code>`)
211
+ - 查看单只股票的详细分析
212
+ - 支持技术图表、支撑压力位和AI分析
213
+
214
+ 4. **市场扫描** (`/market_scan`)
215
+ - 扫描指数成分股或行业股票
216
+ - 筛选高评分股票,发现投资机会
217
+
218
+ 5. **投资组合** (`/portfolio`)
219
+ - 创建和管理个人投资组合
220
+ - 分析组合表现,获取优化建议
221
+
222
+ 6. **基本面分析** (`/fundamental`)
223
+ - 查看股票财务指标和估值分析
224
+ - 分析股票成长性和财务健康状况
225
+
226
+ 7. **资金流向** (`/capital_flow`)
227
+ - 跟踪主力资金和北向资金动向
228
+ - 分析机构持仓变化
229
+
230
+ 8. **情景预测** (`/scenario_predict`)
231
+ - 预测股票未来走势的多种情景
232
+ - 提供乐观、中性、悲观三种预测
233
+
234
+ 9. **风险监控** (`/risk_monitor`)
235
+ - 分析股票和投资组合风险
236
+ - 提供风险预警和应对建议
237
+
238
+ 10. **智能问答** (`/qa`)
239
+ - 通过AI回答关于股票的专业问题
240
+ - 支持联网搜索实时信息和多轮对话
241
+
242
+ 11. **行业分析** (`/industry_analysis`)
243
+ - 分析行业整体表现和资金流向
244
+ - 对比不同行业投资机会
245
+
246
+ ### 常用操作
247
+
248
+ - **分析股票**:在智能仪表盘输入股票代码,点击"分析"
249
+ - **查看股票详情**:点击股票代码或搜索股票进入详情页
250
+ - **扫描市场**:在市场扫描页面选择指数或行业,设置最低评分,点击"扫描"
251
+ - **管理投资组合**:在投资组合页面添加/删除股票,查看组合分析
252
+ - **智能问答**:选择股票后,提问关于该股票的问题,获取AI回答
253
+ - **查看实时财经要闻**:在首页浏览最新财经新闻和舆情热点
254
+
255
+ ## 📚 API文档
256
+
257
+ 系统提供了完整的REST API,可通过Swagger文档查看:`/api/docs`
258
+
259
+ 主要API包括:
260
+
261
+ - 股票分析API:`/api/enhanced_analysis`
262
+ - 市场扫描API:`/api/start_market_scan`
263
+ - 指数成分股API:`/api/index_stocks`
264
+ - 智能问答API:`/api/qa`
265
+ - 风险分析API:`/api/risk_analysis`
266
+ - 情景预测API:`/api/scenario_predict`
267
+ - 行业分析API:`/api/industry_analysis`
268
+ - 最新新闻API:`/api/latest_news`
269
+
270
+ ## 📋 版本历史
271
+
272
+ ### v2.1.0 (当前版本)
273
+ - 优化缓存机制,增加市场收盘时自动清理缓存
274
+ - 增强错误处理和系统稳定性
275
+ - 新增智能问答功能,支持联网搜索实时信息和多轮对话
276
+ - 优化情景预测模块,提高预测精度和可视化效果
277
+ - 新增行业分析功能
278
+ - 改进首页为财经门户风格,实时显示财经要闻与舆情热点
279
+ - 增加全球主要市场状态实时监控
280
+ - 优化服务器超时处理
281
+ - 改进UI交互体验
282
+
283
+ ### v2.0.0
284
+ - 增加多维度分析能力
285
+ - 整合AI API实现AI增强分析
286
+ - 新增投资组合管理功能
287
+ - 重构用户界面,添加交互式图表
288
+ - 优化技术���析和评分系统
289
+
290
+ ### v1.0.0 (初始版本)
291
+ - 基础股票分析功能
292
+ - 技术指标计算
293
+ - 简单评分系统
294
+ - 基础Web界面
295
+
296
+ ## 🔄 扩展开发
297
+
298
+ 系统设计采用模块化架构,便于扩展开发。主要扩展点包括:
299
+
300
+ - 添加新的技术指标
301
+ - 集成其他数据源
302
+ - 开发新的分析模块
303
+ - 扩展用户界面功能
304
+
305
+ ## ⚠️ 注意
306
+
307
+ **当前版本为先驱探索版,旨在学习人工智能在指令分析方面的研究学习。AI生成的内容有很多错误,请勿当成投资建议,若由此造成的一切损失,本项目不负责!**
308
+
309
+ ## 💡 联系与支持
310
+
311
+ 如有问题或建议,请pr:
312
+
313
+ - 项目有很多问题,基础功能可以运行起来,扩充项目代码全由AI开发,所以进展比较缓慢,请谅解。
314
+ - 如你有好的想法或修复,欢迎提交GitHub Issue
315
+
316
+ 感谢使用智能分析系统!
__pycache__/capital_flow_analyzer.cpython-311.pyc ADDED
Binary file (34.6 kB). View file
 
__pycache__/database.cpython-311.pyc ADDED
Binary file (5.26 kB). View file
 
__pycache__/fundamental_analyzer.cpython-311.pyc ADDED
Binary file (6.13 kB). View file
 
__pycache__/index_industry_analyzer.cpython-311.pyc ADDED
Binary file (15.6 kB). View file
 
__pycache__/industry_analyzer.cpython-311.pyc ADDED
Binary file (26 kB). View file
 
__pycache__/risk_monitor.cpython-311.pyc ADDED
Binary file (13.1 kB). View file
 
__pycache__/scenario_predictor.cpython-311.pyc ADDED
Binary file (10.1 kB). View file
 
__pycache__/stock_analyzer.cpython-311.pyc ADDED
Binary file (69 kB). View file
 
__pycache__/stock_qa.cpython-311.pyc ADDED
Binary file (7.53 kB). View file
 
__pycache__/us_stock_service.cpython-311.pyc ADDED
Binary file (3.53 kB). View file
 
app/analysis/capital_flow_analyzer.py ADDED
@@ -0,0 +1,588 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # capital_flow_analyzer.py
2
+ import logging
3
+ import traceback
4
+ import akshare as ak
5
+ import pandas as pd
6
+ import numpy as np
7
+ from datetime import datetime, timedelta
8
+
9
+
10
+ class CapitalFlowAnalyzer:
11
+ def __init__(self):
12
+ self.data_cache = {}
13
+
14
+ # 设置日志记录
15
+ logging.basicConfig(level=logging.INFO,
16
+ format='%(asctime)s - %(levelname)s - %(message)s')
17
+ self.logger = logging.getLogger(__name__)
18
+
19
+ def get_concept_fund_flow(self, period="10日排行"):
20
+ """获取概念/行业资金流向数据"""
21
+ try:
22
+ self.logger.info(f"Getting concept fund flow for period: {period}")
23
+
24
+ # 检查缓存
25
+ cache_key = f"concept_fund_flow_{period}"
26
+ if cache_key in self.data_cache:
27
+ cache_time, cached_data = self.data_cache[cache_key]
28
+ # 如果在最近一小时内有缓存数据,则返回缓存数据
29
+ if (datetime.now() - cache_time).total_seconds() < 3600:
30
+ return cached_data
31
+
32
+ # 从akshare获取数据
33
+ concept_data = ak.stock_fund_flow_concept(symbol=period)
34
+
35
+ # 处理数据
36
+ result = []
37
+ for _, row in concept_data.iterrows():
38
+ try:
39
+ # 列名可能有所不同,所以我们使用灵活的方法
40
+ item = {
41
+ "rank": int(row.get("序号", 0)),
42
+ "sector": row.get("行业", ""),
43
+ "company_count": int(row.get("公司家数", 0)),
44
+ "sector_index": float(row.get("行业指数", 0)),
45
+ "change_percent": self._parse_percent(row.get("阶段涨跌幅", "0%")),
46
+ "inflow": float(row.get("流入资金", 0)),
47
+ "outflow": float(row.get("流出资金", 0)),
48
+ "net_flow": float(row.get("净额", 0))
49
+ }
50
+ result.append(item)
51
+ except Exception as e:
52
+ # self.logger.warning(f"Error processing row in concept fund flow: {str(e)}")
53
+ continue
54
+
55
+ # 缓存结果
56
+ self.data_cache[cache_key] = (datetime.now(), result)
57
+
58
+ return result
59
+ except Exception as e:
60
+ self.logger.error(f"Error getting concept fund flow: {str(e)}")
61
+ self.logger.error(traceback.format_exc())
62
+ # 如果API调用失败则返回模拟数据
63
+ return self._generate_mock_concept_fund_flow(period)
64
+
65
+ def get_individual_fund_flow_rank(self, period="10日"):
66
+ """获取个股资金流向排名"""
67
+ try:
68
+ self.logger.info(f"Getting individual fund flow ranking for period: {period}")
69
+
70
+ # 检查缓存
71
+ cache_key = f"individual_fund_flow_rank_{period}"
72
+ if cache_key in self.data_cache:
73
+ cache_time, cached_data = self.data_cache[cache_key]
74
+ # 如果在最近一小时内有缓存数据,则返回缓存数据
75
+ if (datetime.now() - cache_time).total_seconds() < 3600:
76
+ return cached_data
77
+
78
+ # 从akshare获取数据
79
+ stock_data = ak.stock_individual_fund_flow_rank(indicator=period)
80
+
81
+ # 处理数据
82
+ result = []
83
+ for _, row in stock_data.iterrows():
84
+ try:
85
+ # 根据不同时间段设置列名前缀
86
+ period_prefix = "" if period == "今日" else f"{period}"
87
+
88
+ item = {
89
+ "rank": int(row.get("序号", 0)),
90
+ "code": row.get("代码", ""),
91
+ "name": row.get("名称", ""),
92
+ "price": float(row.get("最新价", 0)),
93
+ "change_percent": float(row.get(f"{period_prefix}涨跌幅", 0)),
94
+ "main_net_inflow": float(row.get(f"{period_prefix}主力净流入-净额", 0)),
95
+ "main_net_inflow_percent": float(row.get(f"{period_prefix}主力净流入-净占比", 0)),
96
+ "super_large_net_inflow": float(row.get(f"{period_prefix}超大单净流入-净额", 0)),
97
+ "super_large_net_inflow_percent": float(row.get(f"{period_prefix}超大单净流入-净占比", 0)),
98
+ "large_net_inflow": float(row.get(f"{period_prefix}大单净流入-净额", 0)),
99
+ "large_net_inflow_percent": float(row.get(f"{period_prefix}大单净流入-净占比", 0)),
100
+ "medium_net_inflow": float(row.get(f"{period_prefix}中单净流入-净额", 0)),
101
+ "medium_net_inflow_percent": float(row.get(f"{period_prefix}中单净流入-净占比", 0)),
102
+ "small_net_inflow": float(row.get(f"{period_prefix}小单净流入-净额", 0)),
103
+ "small_net_inflow_percent": float(row.get(f"{period_prefix}小单净流入-净占比", 0))
104
+ }
105
+ result.append(item)
106
+ except Exception as e:
107
+ self.logger.warning(f"Error processing row in individual fund flow rank: {str(e)}")
108
+ continue
109
+
110
+ # 缓存结果
111
+ self.data_cache[cache_key] = (datetime.now(), result)
112
+
113
+ return result
114
+ except Exception as e:
115
+ self.logger.error(f"Error getting individual fund flow ranking: {str(e)}")
116
+ self.logger.error(traceback.format_exc())
117
+ # 如果API调用失败则返回模拟数据
118
+ return self._generate_mock_individual_fund_flow_rank(period)
119
+
120
+ def get_individual_fund_flow(self, stock_code, market_type="", re_date="10日"):
121
+ """获取个股资金流向数据"""
122
+ try:
123
+ self.logger.info(f"Getting fund flow for stock: {stock_code}, market: {market_type}")
124
+
125
+ # 检查缓存
126
+ cache_key = f"individual_fund_flow_{stock_code}_{market_type}"
127
+ if cache_key in self.data_cache:
128
+ cache_time, cached_data = self.data_cache[cache_key]
129
+ # 如果在一小时内有缓存数据,则返回缓存数据
130
+ if (datetime.now() - cache_time).total_seconds() < 3600:
131
+ return cached_data
132
+
133
+ # 如果未提供市场类型,则根据股票代码判断
134
+ if not market_type:
135
+ if stock_code.startswith('6'):
136
+ market_type = "sh"
137
+ elif stock_code.startswith('0') or stock_code.startswith('3'):
138
+ market_type = "sz"
139
+ else:
140
+ market_type = "sh" # Default to Shanghai
141
+
142
+ # 从akshare获取数据
143
+ flow_data = ak.stock_individual_fund_flow(stock=stock_code, market=market_type)
144
+
145
+ # 处理数据
146
+ result = {
147
+ "stock_code": stock_code,
148
+ "data": []
149
+ }
150
+
151
+ for _, row in flow_data.iterrows():
152
+ try:
153
+ item = {
154
+ "date": row.get("日期", ""),
155
+ "price": float(row.get("收盘价", 0)),
156
+ "change_percent": float(row.get("涨跌幅", 0)),
157
+ "main_net_inflow": float(row.get("主力净流入-净额", 0)),
158
+ "main_net_inflow_percent": float(row.get("主力净流入-净占比", 0)),
159
+ "super_large_net_inflow": float(row.get("超大单净流入-净额", 0)),
160
+ "super_large_net_inflow_percent": float(row.get("超大单净流入-净占比", 0)),
161
+ "large_net_inflow": float(row.get("大单净流入-净额", 0)),
162
+ "large_net_inflow_percent": float(row.get("大单净流入-净占比", 0)),
163
+ "medium_net_inflow": float(row.get("中单净流入-净额", 0)),
164
+ "medium_net_inflow_percent": float(row.get("中单净流入-净占比", 0)),
165
+ "small_net_inflow": float(row.get("小单净流入-净额", 0)),
166
+ "small_net_inflow_percent": float(row.get("小单净流入-净占比", 0))
167
+ }
168
+ result["data"].append(item)
169
+ except Exception as e:
170
+ self.logger.warning(f"Error processing row in individual fund flow: {str(e)}")
171
+ continue
172
+
173
+ # 计算汇总统计数据
174
+ if result["data"]:
175
+ # 最近数据 (最近10天)
176
+ recent_data = result["data"][:min(10, len(result["data"]))]
177
+
178
+ result["summary"] = {
179
+ "recent_days": len(recent_data),
180
+ "total_main_net_inflow": sum(item["main_net_inflow"] for item in recent_data),
181
+ "avg_main_net_inflow_percent": np.mean(
182
+ [item["main_net_inflow_percent"] for item in recent_data]),
183
+ "positive_days": sum(1 for item in recent_data if item["main_net_inflow"] > 0),
184
+ "negative_days": sum(1 for item in recent_data if item["main_net_inflow"] <= 0)
185
+ }
186
+
187
+ # Cache the result
188
+ self.data_cache[cache_key] = (datetime.now(), result)
189
+
190
+ return result
191
+ except Exception as e:
192
+ self.logger.error(f"Error getting individual fund flow: {str(e)}")
193
+ self.logger.error(traceback.format_exc())
194
+ # 如果API调用失败则返回模拟数据
195
+ return self._generate_mock_individual_fund_flow(stock_code, market_type)
196
+
197
+ def get_sector_stocks(self, sector):
198
+ """获取特定行业的股票"""
199
+ try:
200
+ self.logger.info(f"Getting stocks for sector: {sector}")
201
+
202
+ # 检查缓存
203
+ cache_key = f"sector_stocks_{sector}"
204
+ if cache_key in self.data_cache:
205
+ cache_time, cached_data = self.data_cache[cache_key]
206
+ # 如果在一小时内有缓存数据,则返回缓存数据
207
+ if (datetime.now() - cache_time).total_seconds() < 3600:
208
+ return cached_data
209
+
210
+ # 尝试从akshare获取数据
211
+ try:
212
+ # For industry sectors (using 东方财富 interface)
213
+ stocks = ak.stock_board_industry_cons_em(symbol=sector)
214
+
215
+ # 提取股票列表
216
+ if not stocks.empty and '代码' in stocks.columns:
217
+ result = []
218
+ for _, row in stocks.iterrows():
219
+ try:
220
+ item = {
221
+ "code": row.get("代码", ""),
222
+ "name": row.get("名称", ""),
223
+ "price": float(row.get("最新价", 0)),
224
+ "change_percent": float(row.get("涨跌幅", 0)) if "涨跌幅" in row else 0,
225
+ "main_net_inflow": 0, # We'll get this data separately if needed
226
+ "main_net_inflow_percent": 0 # We'll get this data separately if needed
227
+ }
228
+ result.append(item)
229
+ except Exception as e:
230
+ # self.logger.warning(f"Error processing row in sector stocks: {str(e)}")
231
+ continue
232
+
233
+ # 缓存结果
234
+ self.data_cache[cache_key] = (datetime.now(), result)
235
+ return result
236
+ except Exception as e:
237
+ self.logger.warning(f"Failed to get sector stocks from API: {str(e)}")
238
+ # 降级到模拟数据
239
+
240
+ # 如果到达这里,说明无法从API获取数据,返回模拟数据
241
+ result = self._generate_mock_sector_stocks(sector)
242
+ self.data_cache[cache_key] = (datetime.now(), result)
243
+ return result
244
+
245
+ except Exception as e:
246
+ self.logger.error(f"Error getting sector stocks: {str(e)}")
247
+ self.logger.error(traceback.format_exc())
248
+ # 如果API调用失败则返回模拟数据
249
+ return self._generate_mock_sector_stocks(sector)
250
+
251
+ def calculate_capital_flow_score(self, stock_code, market_type=""):
252
+ """计算股票资金流向评分"""
253
+ try:
254
+ self.logger.info(f"Calculating capital flow score for stock: {stock_code}")
255
+
256
+ # 获取个股资金流向数据
257
+ fund_flow = self.get_individual_fund_flow(stock_code, market_type)
258
+
259
+ if not fund_flow or not fund_flow.get("data") or not fund_flow.get("summary"):
260
+ return {
261
+ "total": 0,
262
+ "main_force": 0,
263
+ "large_order": 0,
264
+ "small_order": 0,
265
+ "details": {}
266
+ }
267
+
268
+ # Extract summary statistics
269
+ summary = fund_flow["summary"]
270
+ recent_days = summary["recent_days"]
271
+ total_main_net_inflow = summary["total_main_net_inflow"]
272
+ avg_main_net_inflow_percent = summary["avg_main_net_inflow_percent"]
273
+ positive_days = summary["positive_days"]
274
+
275
+ # Calculate main force score (0-40)
276
+ main_force_score = 0
277
+
278
+ # 基于净流入百分比的评分
279
+ if avg_main_net_inflow_percent > 3:
280
+ main_force_score += 20
281
+ elif avg_main_net_inflow_percent > 1:
282
+ main_force_score += 15
283
+ elif avg_main_net_inflow_percent > 0:
284
+ main_force_score += 10
285
+
286
+ # 基于上涨天数的评分
287
+ positive_ratio = positive_days / recent_days if recent_days > 0 else 0
288
+ if positive_ratio > 0.7:
289
+ main_force_score += 20
290
+ elif positive_ratio > 0.5:
291
+ main_force_score += 15
292
+ elif positive_ratio > 0.3:
293
+ main_force_score += 10
294
+
295
+ # 计算大单评分(0-30分)
296
+ large_order_score = 0
297
+
298
+ # 分析超大单和大单交易
299
+ recent_super_large = [item["super_large_net_inflow"] for item in
300
+ fund_flow["data"][:recent_days]]
301
+ recent_large = [item["large_net_inflow"] for item in fund_flow["data"][:recent_days]]
302
+
303
+ super_large_positive = sum(1 for x in recent_super_large if x > 0)
304
+ large_positive = sum(1 for x in recent_large if x > 0)
305
+
306
+ # 基于超大单的评分
307
+ super_large_ratio = super_large_positive / recent_days if recent_days > 0 else 0
308
+ if super_large_ratio > 0.7:
309
+ large_order_score += 15
310
+ elif super_large_ratio > 0.5:
311
+ large_order_score += 10
312
+ elif super_large_ratio > 0.3:
313
+ large_order_score += 5
314
+
315
+ # 基于大单的评分
316
+ large_ratio = large_positive / recent_days if recent_days > 0 else 0
317
+ if large_ratio > 0.7:
318
+ large_order_score += 15
319
+ elif large_ratio > 0.5:
320
+ large_order_score += 10
321
+ elif large_ratio > 0.3:
322
+ large_order_score += 5
323
+
324
+ # 计算小单评分(0-30分)
325
+ small_order_score = 0
326
+
327
+ # 分析中单和小单交易
328
+ recent_medium = [item["medium_net_inflow"] for item in fund_flow["data"][:recent_days]]
329
+ recent_small = [item["small_net_inflow"] for item in fund_flow["data"][:recent_days]]
330
+
331
+ medium_positive = sum(1 for x in recent_medium if x > 0)
332
+ small_positive = sum(1 for x in recent_small if x > 0)
333
+
334
+ # 基于中单的评分
335
+ medium_ratio = medium_positive / recent_days if recent_days > 0 else 0
336
+ if medium_ratio > 0.7:
337
+ small_order_score += 15
338
+ elif medium_ratio > 0.5:
339
+ small_order_score += 10
340
+ elif medium_ratio > 0.3:
341
+ small_order_score += 5
342
+
343
+ # 基于小单的评分
344
+ small_ratio = small_positive / recent_days if recent_days > 0 else 0
345
+ if small_ratio > 0.7:
346
+ small_order_score += 15
347
+ elif small_ratio > 0.5:
348
+ small_order_score += 10
349
+ elif small_ratio > 0.3:
350
+ small_order_score += 5
351
+
352
+ # 计算总评分
353
+ total_score = main_force_score + large_order_score + small_order_score
354
+
355
+ return {
356
+ "total": total_score,
357
+ "main_force": main_force_score,
358
+ "large_order": large_order_score,
359
+ "small_order": small_order_score,
360
+ "details": fund_flow
361
+ }
362
+ except Exception as e:
363
+ self.logger.error(f"Error calculating capital flow score: {str(e)}")
364
+ self.logger.error(traceback.format_exc())
365
+ return {
366
+ "total": 0,
367
+ "main_force": 0,
368
+ "large_order": 0,
369
+ "small_order": 0,
370
+ "details": {},
371
+ "error": str(e)
372
+ }
373
+
374
+ def _parse_percent(self, percent_str):
375
+ """将百分比字符串转换为浮点数"""
376
+ try:
377
+ if isinstance(percent_str, str) and '%' in percent_str:
378
+ return float(percent_str.replace('%', ''))
379
+ return float(percent_str)
380
+ except (ValueError, TypeError):
381
+ return 0.0
382
+
383
+ def _generate_mock_concept_fund_flow(self, period):
384
+ """生成模拟概念资金流向数据"""
385
+ # self.logger.warning(f"Generating mock concept fund flow data for period: {period}")
386
+
387
+ sectors = [
388
+ "新能源", "医药", "半导体", "芯片", "人工智能", "大数据", "云计算", "5G",
389
+ "汽车", "消费", "金融", "互联网", "游戏", "农业", "化工", "建筑", "军工",
390
+ "钢铁", "有色金属", "煤炭", "石油"
391
+ ]
392
+
393
+ result = []
394
+ for i, sector in enumerate(sectors):
395
+ # 随机数据 - 前半部分为正,后半部分为负
396
+ is_positive = i < len(sectors) // 2
397
+
398
+ inflow = round(np.random.uniform(10, 50), 2) if is_positive else round(
399
+ np.random.uniform(5, 20), 2)
400
+ outflow = round(np.random.uniform(5, 20), 2) if is_positive else round(
401
+ np.random.uniform(10, 50), 2)
402
+ net_flow = round(inflow - outflow, 2)
403
+
404
+ change_percent = round(np.random.uniform(0, 5), 2) if is_positive else round(
405
+ np.random.uniform(-5, 0), 2)
406
+
407
+ item = {
408
+ "rank": i + 1,
409
+ "sector": sector,
410
+ "company_count": np.random.randint(10, 100),
411
+ "sector_index": round(np.random.uniform(1000, 5000), 2),
412
+ "change_percent": change_percent,
413
+ "inflow": inflow,
414
+ "outflow": outflow,
415
+ "net_flow": net_flow
416
+ }
417
+ result.append(item)
418
+
419
+ # 按净流入降序排序
420
+ return sorted(result, key=lambda x: x["net_flow"], reverse=True)
421
+
422
+ def _generate_mock_individual_fund_flow_rank(self, period):
423
+ """生成模拟个股资金流向排名数据"""
424
+ # self.logger.warning(f"Generating mock individual fund flow ranking data for period: {period}")
425
+
426
+ # Sample stock data
427
+ stocks = [
428
+ {"code": "600000", "name": "浦发银行"}, {"code": "600036", "name": "招商银行"},
429
+ {"code": "601318", "name": "中国平安"}, {"code": "600519", "name": "贵州茅台"},
430
+ {"code": "000858", "name": "五粮液"}, {"code": "000333", "name": "美的集团"},
431
+ {"code": "600276", "name": "恒瑞医药"}, {"code": "601888", "name": "中国中免"},
432
+ {"code": "600030", "name": "中信证券"}, {"code": "601166", "name": "兴业银行"},
433
+ {"code": "600887", "name": "伊利股份"}, {"code": "601398", "name": "工商银行"},
434
+ {"code": "600028", "name": "中国石化"}, {"code": "601988", "name": "中国银行"},
435
+ {"code": "601857", "name": "中国石油"}, {"code": "600019", "name": "宝钢股份"},
436
+ {"code": "600050", "name": "中国联通"}, {"code": "601328", "name": "交通银行"},
437
+ {"code": "601668", "name": "中国建筑"}, {"code": "601288", "name": "农业银行"}
438
+ ]
439
+
440
+ result = []
441
+ for i, stock in enumerate(stocks):
442
+ # 随机数据 - 前半部分为正,后半部分为负
443
+ is_positive = i < len(stocks) // 2
444
+
445
+ main_net_inflow = round(np.random.uniform(1e6, 5e7), 2) if is_positive else round(
446
+ np.random.uniform(-5e7, -1e6), 2)
447
+ main_net_inflow_percent = round(np.random.uniform(1, 10), 2) if is_positive else round(
448
+ np.random.uniform(-10, -1), 2)
449
+
450
+ super_large_net_inflow = round(main_net_inflow * np.random.uniform(0.3, 0.5), 2)
451
+ super_large_net_inflow_percent = round(main_net_inflow_percent * np.random.uniform(0.3, 0.5), 2)
452
+
453
+ large_net_inflow = round(main_net_inflow * np.random.uniform(0.3, 0.5), 2)
454
+ large_net_inflow_percent = round(main_net_inflow_percent * np.random.uniform(0.3, 0.5), 2)
455
+
456
+ medium_net_inflow = round(np.random.uniform(-1e6, 1e6), 2)
457
+ medium_net_inflow_percent = round(np.random.uniform(-2, 2), 2)
458
+
459
+ small_net_inflow = round(np.random.uniform(-1e6, 1e6), 2)
460
+ small_net_inflow_percent = round(np.random.uniform(-2, 2), 2)
461
+
462
+ change_percent = round(np.random.uniform(0, 5), 2) if is_positive else round(np.random.uniform(-5, 0), 2)
463
+
464
+ item = {
465
+ "rank": i + 1,
466
+ "code": stock["code"],
467
+ "name": stock["name"],
468
+ "price": round(np.random.uniform(10, 100), 2),
469
+ "change_percent": change_percent,
470
+ "main_net_inflow": main_net_inflow,
471
+ "main_net_inflow_percent": main_net_inflow_percent,
472
+ "super_large_net_inflow": super_large_net_inflow,
473
+ "super_large_net_inflow_percent": super_large_net_inflow_percent,
474
+ "large_net_inflow": large_net_inflow,
475
+ "large_net_inflow_percent": large_net_inflow_percent,
476
+ "medium_net_inflow": medium_net_inflow,
477
+ "medium_net_inflow_percent": medium_net_inflow_percent,
478
+ "small_net_inflow": small_net_inflow,
479
+ "small_net_inflow_percent": small_net_inflow_percent
480
+ }
481
+ result.append(item)
482
+
483
+ # 按主力净流入降序排序
484
+ return sorted(result, key=lambda x: x["main_net_inflow"], reverse=True)
485
+
486
+ def _generate_mock_individual_fund_flow(self, stock_code, market_type):
487
+ """生成模拟个股资金流向数据"""
488
+ # self.logger.warning(f"Generating mock individual fund flow data for stock: {stock_code}")
489
+
490
+ # 生成30天的模拟数据
491
+ end_date = datetime.now()
492
+
493
+ result = {
494
+ "stock_code": stock_code,
495
+ "data": []
496
+ }
497
+
498
+ # 创建模拟价格趋势(使用合理的随机游走)
499
+ base_price = np.random.uniform(10, 100)
500
+ current_price = base_price
501
+
502
+ for i in range(30):
503
+ date = (end_date - timedelta(days=i)).strftime('%Y-%m-%d')
504
+
505
+ # 随机价格变化(-2%到+2%)
506
+ change_percent = np.random.uniform(-2, 2)
507
+ price = round(current_price * (1 + change_percent / 100), 2)
508
+ current_price = price
509
+
510
+ # 随机资金流向数据,与价格变化有一定相关性
511
+ is_positive = change_percent > 0
512
+
513
+ main_net_inflow = round(np.random.uniform(1e5, 5e6), 2) if is_positive else round(
514
+ np.random.uniform(-5e6, -1e5), 2)
515
+ main_net_inflow_percent = round(np.random.uniform(1, 5), 2) if is_positive else round(
516
+ np.random.uniform(-5, -1), 2)
517
+
518
+ super_large_net_inflow = round(main_net_inflow * np.random.uniform(0.3, 0.5), 2)
519
+ super_large_net_inflow_percent = round(main_net_inflow_percent * np.random.uniform(0.3, 0.5), 2)
520
+
521
+ large_net_inflow = round(main_net_inflow * np.random.uniform(0.3, 0.5), 2)
522
+ large_net_inflow_percent = round(main_net_inflow_percent * np.random.uniform(0.3, 0.5), 2)
523
+
524
+ medium_net_inflow = round(np.random.uniform(-1e5, 1e5), 2)
525
+ medium_net_inflow_percent = round(np.random.uniform(-2, 2), 2)
526
+
527
+ small_net_inflow = round(np.random.uniform(-1e5, 1e5), 2)
528
+ small_net_inflow_percent = round(np.random.uniform(-2, 2), 2)
529
+
530
+ item = {
531
+ "date": date,
532
+ "price": price,
533
+ "change_percent": round(change_percent, 2),
534
+ "main_net_inflow": main_net_inflow,
535
+ "main_net_inflow_percent": main_net_inflow_percent,
536
+ "super_large_net_inflow": super_large_net_inflow,
537
+ "super_large_net_inflow_percent": super_large_net_inflow_percent,
538
+ "large_net_inflow": large_net_inflow,
539
+ "large_net_inflow_percent": large_net_inflow_percent,
540
+ "medium_net_inflow": medium_net_inflow,
541
+ "medium_net_inflow_percent": medium_net_inflow_percent,
542
+ "small_net_inflow": small_net_inflow,
543
+ "small_net_inflow_percent": small_net_inflow_percent
544
+ }
545
+ result["data"].append(item)
546
+
547
+ # 按日期降序排序(最新的在前)
548
+ result["data"].sort(key=lambda x: x["date"], reverse=True)
549
+
550
+ # 计算汇总统计数据
551
+ recent_data = result["data"][:10]
552
+
553
+ result["summary"] = {
554
+ "recent_days": len(recent_data),
555
+ "total_main_net_inflow": sum(item["main_net_inflow"] for item in recent_data),
556
+ "avg_main_net_inflow_percent": np.mean([item["main_net_inflow_percent"] for item in recent_data]),
557
+ "positive_days": sum(1 for item in recent_data if item["main_net_inflow"] > 0),
558
+ "negative_days": sum(1 for item in recent_data if item["main_net_inflow"] <= 0)
559
+ }
560
+
561
+ return result
562
+
563
+ def _generate_mock_sector_stocks(self, sector):
564
+ """生成模拟行业股票数据"""
565
+ # self.logger.warning(f"Generating mock sector stocks for: {sector}")
566
+
567
+ # 要生成的股票数量
568
+ num_stocks = np.random.randint(20, 50)
569
+
570
+ result = []
571
+ for i in range(num_stocks):
572
+ prefix = "6" if np.random.random() > 0.5 else "0"
573
+ stock_code = prefix + str(100000 + i).zfill(5)[-5:]
574
+
575
+ change_percent = round(np.random.uniform(-5, 5), 2)
576
+
577
+ item = {
578
+ "code": stock_code,
579
+ "name": f"{sector}股票{i + 1}",
580
+ "price": round(np.random.uniform(10, 100), 2),
581
+ "change_percent": change_percent,
582
+ "main_net_inflow": round(np.random.uniform(-1e6, 1e6), 2),
583
+ "main_net_inflow_percent": round(np.random.uniform(-5, 5), 2)
584
+ }
585
+ result.append(item)
586
+
587
+ # 按主力净流入降序排序
588
+ return sorted(result, key=lambda x: x["main_net_inflow"], reverse=True)
app/analysis/etf_analyzer.py ADDED
@@ -0,0 +1,494 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import akshare as ak
3
+ import pandas as pd
4
+ from datetime import datetime, timedelta
5
+ import numpy as np
6
+ from stockstats import StockDataFrame
7
+
8
+ class EtfAnalyzer:
9
+ def __init__(self, etf_code, stock_analyzer_instance):
10
+ self.etf_code = etf_code
11
+ self.analysis_result = {}
12
+ self.hist_df = None # 用于存储历史数据以供后续分析使用
13
+ self.stock_analyzer = stock_analyzer_instance # 复用StockAnalyzer实例
14
+
15
+ def run_analysis(self):
16
+ """
17
+ 运行所有分析步骤并返回结果
18
+ """
19
+ self.get_basic_info()
20
+ self.analyze_market_performance()
21
+ self.analyze_fund_flow()
22
+ self.analyze_risk_and_tracking()
23
+ self.analyze_holdings()
24
+ self.analyze_sector()
25
+ self.get_ai_summary()
26
+
27
+ return self.analysis_result
28
+
29
+ def get_basic_info(self):
30
+ """
31
+ 1. 基本信息分析
32
+ """
33
+ print("开始获取基本信息...")
34
+ try:
35
+ # 使用akshare获取ETF基金概况
36
+ fund_info_df = ak.fund_etf_fund_info_em(fund=self.etf_code)
37
+
38
+ if fund_info_df.empty:
39
+ info_dict = {"error": "未能获取到该ETF的基本信息,请检查代码是否正确。"}
40
+ else:
41
+ # 将返回的DataFrame转换成字典,假设第一列是键,第二列是值
42
+ info_dict = {}
43
+ for _, row in fund_info_df.iterrows():
44
+ if len(row) >= 2:
45
+ info_dict[row.iloc[0]] = row.iloc[1]
46
+
47
+ self.analysis_result['basic_info'] = info_dict
48
+ print("基本信息获取完成。")
49
+
50
+ except Exception as e:
51
+ print(f"获取ETF基本信息时出错: {e}")
52
+ self.analysis_result['basic_info'] = {"error": f"获取基本信息失败: {e}"}
53
+
54
+ def analyze_market_performance(self):
55
+ """
56
+ 2. 市场表现与技术分析 (第一部分:回报率与流动性)
57
+ """
58
+ print("开始分析市场表现...")
59
+ try:
60
+ # 获取近一年的历史数据
61
+ end_date = datetime.now()
62
+ start_date = end_date - timedelta(days=365)
63
+
64
+ end_date_str = end_date.strftime('%Y%m%d')
65
+ start_date_str = start_date.strftime('%Y%m%d')
66
+
67
+ # 使用后复权数据
68
+ hist_df = ak.fund_etf_hist_em(symbol=self.etf_code, start_date=start_date_str, end_date=end_date_str, adjust="hfq")
69
+
70
+ if hist_df.empty:
71
+ self.analysis_result['market_performance'] = {"error": "未能获取到该ETF的历史行情数据。"}
72
+ print("未能获取历史行情数据。")
73
+ return
74
+
75
+ # --- 数据准备 ---
76
+ hist_df['日期'] = pd.to_datetime(hist_df['日期'])
77
+ hist_df.set_index('日期', inplace=True)
78
+ hist_df['收盘'] = pd.to_numeric(hist_df['收盘'])
79
+ hist_df['成交额'] = pd.to_numeric(hist_df['成交额'], errors='coerce').fillna(0)
80
+ hist_df['换手率'] = pd.to_numeric(hist_df['换手率'], errors='coerce').fillna(0)
81
+
82
+ # 存储历史数据以供其他方法使用
83
+ self.hist_df = hist_df
84
+
85
+ # --- 回报率计算 ---
86
+ returns = {}
87
+ if not hist_df.empty:
88
+ latest_price = hist_df['收盘'].iloc[-1]
89
+
90
+ periods = {
91
+ '近1周': 5,
92
+ '近1个月': 21,
93
+ '近3个月': 63,
94
+ '近1年': 252
95
+ }
96
+
97
+ for name, days in periods.items():
98
+ if len(hist_df) > days:
99
+ old_price = hist_df['收盘'].iloc[-days-1]
100
+ returns[name] = ((latest_price / old_price) - 1) * 100 if old_price != 0 else 0
101
+ else:
102
+ # 如果数据不足,则从第一天开始算
103
+ old_price = hist_df['收盘'].iloc[0]
104
+ returns[name] = ((latest_price / old_price) - 1) * 100 if old_price != 0 else 0
105
+
106
+ # 计算年初至今回报率
107
+ ytd_df = hist_df[hist_df.index.year == end_date.year]
108
+ if not ytd_df.empty:
109
+ ytd_start_price = ytd_df['收盘'].iloc[0]
110
+ returns['年初至今'] = ((latest_price / ytd_start_price) - 1) * 100 if ytd_start_price != 0 else 0
111
+ else:
112
+ # 如果当年没有数据,则计算从开始到现在的总回报
113
+ start_price = hist_df['收盘'].iloc[0]
114
+ returns['年初至今'] = ((latest_price / start_price) - 1) * 100 if start_price != 0 else 0
115
+
116
+ # --- 流动性分析 ---
117
+ liquidity = {}
118
+ last_month_df = hist_df.tail(21)
119
+ if not last_month_df.empty:
120
+ liquidity['日均成交额(近一月)'] = last_month_df['成交额'].mean()
121
+ liquidity['日均换手率(近一月)'] = last_month_df['换手率'].mean()
122
+ else:
123
+ liquidity['日均成交额(近一月)'] = None
124
+ liquidity['日均换手率(近一月)'] = None
125
+
126
+ # --- 技术指标计算 ---
127
+ tech_indicators = {}
128
+ if self.hist_df is not None and not self.hist_df.empty:
129
+ # stockstats需要特定的列名,创建一个副本进行操作
130
+ stock_df_for_ta = self.hist_df.copy()
131
+ stock_df_for_ta.rename(columns={'收盘': 'close', '开盘': 'open', '最高': 'high', '最低': 'low', '成交量': 'volume'}, inplace=True)
132
+
133
+ # 转换为StockDataFrame
134
+ sdf = StockDataFrame.retype(stock_df_for_ta)
135
+
136
+ # 计算指标
137
+ sdf[['macd', 'macds', 'macdh']] # MACD
138
+ sdf['rsi_14'] # RSI
139
+ sdf['close_20_sma'] # 20日均线
140
+ sdf['close_60_sma'] # 60日均线
141
+
142
+ # 获取最新的指标值
143
+ latest_indicators = sdf.iloc[-1]
144
+ tech_indicators = {
145
+ 'MA20': latest_indicators.get('close_20_sma'),
146
+ 'MA60': latest_indicators.get('close_60_sma'),
147
+ 'MACD': latest_indicators.get('macd'),
148
+ 'MACD_Signal': latest_indicators.get('macds'),
149
+ 'MACD_Hist': latest_indicators.get('macdh'),
150
+ 'RSI_14': latest_indicators.get('rsi_14')
151
+ }
152
+
153
+ # 注意:不再覆盖 self.hist_df
154
+
155
+ self.analysis_result['market_performance'] = {
156
+ "returns": returns,
157
+ "liquidity": liquidity,
158
+ "tech_indicators": tech_indicators,
159
+ "message": "回报率、流动性和技术指标分析完成。"
160
+ }
161
+ print("市场表现分析(回报率、流动性、技术指标)完成。")
162
+
163
+ # --- 与基准对比 ---
164
+ benchmark_code = 'sh000300' # 默认使用沪深300作为基准
165
+ print(f"开始与基准 {benchmark_code} 进行对比...")
166
+
167
+ benchmark_df = ak.stock_zh_index_daily(symbol=benchmark_code)
168
+ benchmark_df['date'] = pd.to_datetime(benchmark_df['date'])
169
+ benchmark_df.set_index('date', inplace=True)
170
+
171
+ # 截取与ETF相同的时间段
172
+ benchmark_df = benchmark_df.loc[self.hist_df.index[0]:self.hist_df.index[-1]]
173
+
174
+ benchmark_returns = {}
175
+ alpha = {}
176
+
177
+ if not benchmark_df.empty:
178
+ benchmark_latest_price = benchmark_df['close'].iloc[-1]
179
+
180
+ for name, days in periods.items():
181
+ if len(benchmark_df) > days:
182
+ old_price = benchmark_df['close'].iloc[-days-1]
183
+ benchmark_returns[name] = ((benchmark_latest_price / old_price) - 1) * 100 if old_price != 0 else 0
184
+ else:
185
+ old_price = benchmark_df['close'].iloc[0]
186
+ benchmark_returns[name] = ((benchmark_latest_price / old_price) - 1) * 100 if old_price != 0 else 0
187
+
188
+ # 计算超额收益
189
+ alpha[name] = returns.get(name, 0) - benchmark_returns.get(name, 0)
190
+
191
+ # 年初至今
192
+ benchmark_ytd_df = benchmark_df[benchmark_df.index.year == end_date.year]
193
+ if not benchmark_ytd_df.empty:
194
+ ytd_start_price = benchmark_ytd_df['close'].iloc[0]
195
+ benchmark_returns['年初至今'] = ((benchmark_latest_price / ytd_start_price) - 1) * 100 if ytd_start_price != 0 else 0
196
+ else:
197
+ start_price = benchmark_df['close'].iloc[0]
198
+ benchmark_returns['年初至今'] = ((benchmark_latest_price / start_price) - 1) * 100 if start_price != 0 else 0
199
+
200
+ alpha['年初至今'] = returns.get('年初至今', 0) - benchmark_returns.get('年初至今', 0)
201
+
202
+ # 更新结果
203
+ self.analysis_result['market_performance']['benchmark_returns'] = benchmark_returns
204
+ self.analysis_result['market_performance']['alpha'] = alpha
205
+ self.analysis_result['market_performance']['message'] = "完整的市场表现分析已完成。"
206
+ print("与基准对比分析完成。")
207
+
208
+ except Exception as e:
209
+ print(f"分析市场表现时出错: {e}")
210
+ self.analysis_result['market_performance'] = {"error": f"分析市场表现失败: {e}"}
211
+
212
+ def analyze_fund_flow(self):
213
+ """
214
+ 3. 资金流向分析
215
+ """
216
+ print("开始分析资金流向...")
217
+ try:
218
+ # 复用已获取的历史数据
219
+ if self.hist_df is None or self.hist_df.empty:
220
+ self.analysis_result['fund_flow'] = {"error": "历史数据缺失,无法进行资金流向分析。"}
221
+ print("历史数据缺失,跳过资金流向分析。")
222
+ return
223
+
224
+ df = self.hist_df.copy()
225
+
226
+ # akshare返回的hist_df中没有直接的份额列,需要重新请求或者寻找其他接口
227
+ # 这里我们假设'成交量'可以作为份额变化的代理指标进行估算,这是一个简化处理
228
+ # 更好的方法是找到直接提供份额历史的接口
229
+
230
+ # 计算份额变化 (此处使用成交量作为代理)
231
+ # 注意:这是一个估算,并非精确值。真实份额变化需专门接口。
232
+ df['份额变化'] = df['成交量'].diff().fillna(0)
233
+
234
+ # 估算资金净流入/流出 = 份额变化 * 当日收盘价
235
+ # 正值表示流入,负值表示流出
236
+ df['资金净流入估算'] = df['份额变化'] * df['收盘']
237
+
238
+ # --- 汇总统计 ---
239
+ flow_summary = {}
240
+ periods = {
241
+ '近1周': 5,
242
+ '近1个月': 21,
243
+ '近3个月': 63
244
+ }
245
+
246
+ for name, days in periods.items():
247
+ if len(df) >= days:
248
+ flow_summary[name] = df['资金净流入估算'].tail(days).sum()
249
+ else:
250
+ flow_summary[name] = df['资金净流入估算'].sum()
251
+
252
+ # 获取最近的资金流数据以供图表使用 (例如最近60天)
253
+ recent_flow_data = df.tail(60)[['资金净流入估算']].copy()
254
+ recent_flow_data.index = recent_flow_data.index.strftime('%Y-%m-%d')
255
+ recent_flow_data['资金净流入估算'] = (recent_flow_data['资金净流入估算'] / 1e8).round(4) # 转换为亿元
256
+
257
+ # 准备图表数据,格式为 [ [date_string, value], ... ]
258
+ chart_data_df = recent_flow_data.reset_index()
259
+ chart_data_list = chart_data_df.values.tolist()
260
+
261
+ self.analysis_result['fund_flow'] = {
262
+ "summary": flow_summary,
263
+ "daily_flow_chart_data": {"data": chart_data_list},
264
+ "message": "资金流向分析完成 (基于成交量估算)。"
265
+ }
266
+ print("资金流向分析完成。")
267
+
268
+ except Exception as e:
269
+ print(f"分析资金流向时出错: {e}")
270
+ self.analysis_result['fund_flow'] = {"error": f"分析资金流向失败: {e}"}
271
+
272
+ def analyze_risk_and_tracking(self):
273
+ """
274
+ 4. 风险与跟踪能力分析
275
+ """
276
+ print("开始分析风险与跟踪能力...")
277
+ try:
278
+ if self.hist_df is None or self.hist_df.empty:
279
+ self.analysis_result['risk_and_tracking'] = {"error": "历史数据缺失,无法进行风险分析。"}
280
+ print("历史数据缺失,跳过风险分析。")
281
+ return
282
+
283
+ df = self.hist_df.copy()
284
+ # 确保返回的是pct_change,而不是整个Series
285
+ df['etf_return'] = df['收盘'].pct_change().fillna(0)
286
+
287
+ # 1. 波动率 (年化)
288
+ annualized_volatility = df['etf_return'].std() * np.sqrt(252)
289
+
290
+ # 2. 与基准比较 (Beta, 跟踪误差, 夏普比率)
291
+ benchmark_code = 'sh000300'
292
+ benchmark_df = ak.stock_zh_index_daily(symbol=benchmark_code)
293
+ benchmark_df['date'] = pd.to_datetime(benchmark_df['date'])
294
+ benchmark_df.set_index('date', inplace=True)
295
+ benchmark_df = benchmark_df.loc[df.index.min():df.index.max()]
296
+ benchmark_df['benchmark_return'] = benchmark_df['close'].pct_change().fillna(0)
297
+
298
+ # 合并数据
299
+ merged_df = pd.merge(df[['etf_return']], benchmark_df[['benchmark_return']], left_index=True, right_index=True, how='inner')
300
+
301
+ # 计算 Beta
302
+ covariance = merged_df['etf_return'].cov(merged_df['benchmark_return'])
303
+ variance = merged_df['benchmark_return'].var()
304
+ beta = covariance / variance if variance != 0 else None
305
+
306
+ # 计算 跟踪误差 (年化)
307
+ merged_df['difference'] = merged_df['etf_return'] - merged_df['benchmark_return']
308
+ tracking_error = merged_df['difference'].std() * np.sqrt(252)
309
+
310
+ # 计算 夏普比率 (年化)
311
+ risk_free_rate_daily = (1.02 ** (1/252)) - 1 # 假设年化无风险利率为2%
312
+ avg_daily_return = merged_df['etf_return'].mean()
313
+ std_daily_return = merged_df['etf_return'].std()
314
+ sharpe_ratio = ((avg_daily_return - risk_free_rate_daily) * 252) / (std_daily_return * np.sqrt(252)) if std_daily_return != 0 else None
315
+
316
+ # 3. 溢价/折价率 (近一个月平均)
317
+ avg_premium_discount = None
318
+ df_for_premium = self.hist_df.copy()
319
+ if '单位净值' in df_for_premium.columns and not df_for_premium['单位净值'].isnull().all():
320
+ df_for_premium['单位净值'] = pd.to_numeric(df_for_premium['单位净值'], errors='coerce')
321
+ df_for_premium.dropna(subset=['单位净值'], inplace=True)
322
+ df_for_premium = df_for_premium[df_for_premium['单位净值'] != 0]
323
+
324
+ if not df_for_premium.empty:
325
+ df_for_premium['premium_discount'] = ((df_for_premium['收盘'] / df_for_premium['单位净值']) - 1) * 100
326
+ avg_premium_discount = df_for_premium.tail(21)['premium_discount'].mean()
327
+
328
+ risk_metrics = {
329
+ "annualized_volatility": annualized_volatility,
330
+ "beta": beta,
331
+ "tracking_error": tracking_error,
332
+ "sharpe_ratio": sharpe_ratio,
333
+ "avg_premium_discount_monthly": avg_premium_discount
334
+ }
335
+
336
+ self.analysis_result['risk_and_tracking'] = risk_metrics
337
+ print("风险与跟踪能力分析完成。")
338
+
339
+ except Exception as e:
340
+ print(f"分析风险与跟踪能力时出错: {e}")
341
+ self.analysis_result['risk_and_tracking'] = {"error": f"分析风险与跟踪能力失败: {e}"}
342
+
343
+ def analyze_holdings(self):
344
+ """
345
+ 5. 持仓分析
346
+ """
347
+ print("开始分析持仓...")
348
+ try:
349
+ # 获取ETF持仓明细
350
+ holdings_df = ak.fund_portfolio_hold_em(symbol=self.etf_code, date=datetime.now().strftime("%Y"))
351
+
352
+ if holdings_df.empty or '股票代码' not in holdings_df.columns:
353
+ self.analysis_result['holdings'] = {"error": "未能获取到该ETF的持仓数据。"}
354
+ print("未能获取持仓数据。")
355
+ return
356
+
357
+ # 提取前十大持仓
358
+ top_10_holdings = holdings_df.head(10)[['股票代码', '股票名称', '持仓市值', '占净值比例']].copy()
359
+ top_10_holdings.rename(columns={'占净值比例': '占净值比例(%)'}, inplace=True)
360
+ top_10_holdings['占净值比例(%)'] = pd.to_numeric(top_10_holdings['占净值比例(%)'], errors='coerce')
361
+
362
+ # 计算前十大持仓集中度
363
+ concentration = top_10_holdings['占净值比例(%)'].sum()
364
+
365
+ holdings_data = {
366
+ "top_10_holdings": top_10_holdings.to_dict('records'),
367
+ "concentration": concentration
368
+ }
369
+
370
+ self.analysis_result['holdings'] = holdings_data
371
+ print("持仓分析完成。")
372
+
373
+ except Exception as e:
374
+ print(f"分析持仓时出错: {e}")
375
+ self.analysis_result['holdings'] = {"error": f"分析持仓失败: {e}"}
376
+
377
+ def analyze_sector(self):
378
+ """
379
+ 6. 板块深度分析
380
+ """
381
+ print("开始进行板块深度分析...")
382
+ try:
383
+ # 1. 识别板块/行业
384
+ basic_info = self.analysis_result.get('basic_info', {})
385
+ tracking_index = basic_info.get('跟踪标的', '')
386
+
387
+ if not tracking_index or '指数' not in tracking_index:
388
+ self.analysis_result['sector_analysis'] = {"error": "无法从基本信息中确定ETF跟踪的板块或行业指数。"}
389
+ print("无法识别板块,跳过板块分析。")
390
+ return
391
+
392
+ # 简化处理:假设跟踪标的名称与akshare中的板块名称直接对应
393
+ # 例如 "中证白酒指数" -> 我们需要找到对应的板块名称 "白酒"
394
+ # 这是一个复杂的映射,这里我们先做一个简化假设,后续可以优化
395
+ sector_name = tracking_index.replace('指数', '').replace('中证', '').replace('国证', '')
396
+
397
+ # 尝试获取行业板块数据,如果失败,则可能是概念板块
398
+ try:
399
+ sector_df = ak.stock_board_industry_hist_em(symbol=sector_name)
400
+ except Exception:
401
+ try:
402
+ sector_df = ak.stock_board_concept_hist_em(symbol=sector_name)
403
+ except Exception as e:
404
+ self.analysis_result['sector_analysis'] = {"error": f"无法获取板块 '{sector_name}' 的行情数据: {e}"}
405
+ return
406
+
407
+ sector_df['日期'] = pd.to_datetime(sector_df['日期'])
408
+ sector_df.set_index('日期', inplace=True)
409
+
410
+ # 2. 板块回报率 (景气度)
411
+ sector_returns = {}
412
+ latest_price = sector_df['收盘'].iloc[-1]
413
+ periods = {'近1周': 5, '近1个月': 21, '近3个月': 63, '近1年': 252}
414
+ for name, days in periods.items():
415
+ if len(sector_df) > days:
416
+ old_price = sector_df['收盘'].iloc[-days-1]
417
+ sector_returns[name] = ((latest_price / old_price) - 1) * 100 if old_price != 0 else 0
418
+
419
+ # 3. 板块估值 (PE百分位)
420
+ pe_df = ak.stock_board_industry_pe_em(symbol=sector_name)
421
+ latest_pe = pe_df.iloc[-1]['滚动市盈率']
422
+ pe_percentile = (pe_df['滚动市盈率'] < latest_pe).mean() * 100 if not pe_df.empty else None
423
+
424
+ sector_data = {
425
+ "sector_name": sector_name,
426
+ "returns": sector_returns,
427
+ "valuation": {
428
+ "current_pe": latest_pe,
429
+ "pe_percentile": pe_percentile
430
+ }
431
+ }
432
+
433
+ self.analysis_result['sector_analysis'] = sector_data
434
+ print("板块深度分析完成。")
435
+
436
+ except Exception as e:
437
+ print(f"分析板块时出错: {e}")
438
+ self.analysis_result['sector_analysis'] = {"error": f"分析板块失败: {e}"}
439
+
440
+ def get_ai_summary(self):
441
+ """
442
+ 7. AI 综合诊断
443
+ """
444
+ print("开始生成AI综合诊断...")
445
+ try:
446
+ # 1. 整合所有分析结果
447
+ prompt_data = f"请为ETF代码 {self.etf_code} 生成一份全面的投资分析报告。请严格根据以下数据进行分析,不要使用外部知识:\n\n"
448
+
449
+ # 基本信息
450
+ basic_info = self.analysis_result.get('basic_info', {})
451
+ prompt_data += f"**基本信息**: 跟踪指数: {basic_info.get('跟踪标的', 'N/A')}, 规模: {basic_info.get('基金规模', 'N/A')}, 管理人: {basic_info.get('基金管理人', 'N/A')}.\n"
452
+
453
+ # 市场表现
454
+ perf = self.analysis_result.get('market_performance', {})
455
+ returns = perf.get('returns', {})
456
+ alpha = perf.get('alpha', {})
457
+ prompt_data += f"**市场表现**: 近1个月回报率: {returns.get('近1个月', 0):.2f}%, 同期超额收益(相对沪深300): {alpha.get('近1个月', 0):.2f}%. 年初至今回报率: {returns.get('年初至今', 0):.2f}%, 同期超额收益: {alpha.get('年初至今', 0):.2f}%.\n"
458
+
459
+ # 风险
460
+ risk = self.analysis_result.get('risk_and_tracking', {})
461
+ prompt_data += f"**风险指标**: 年化波动率: {risk.get('annualized_volatility', 0):.2%}, Beta值: {risk.get('beta', 0):.2f}, 年化跟踪误差: {risk.get('tracking_error', 0):.2%}.\n"
462
+
463
+ # 持仓
464
+ holdings = self.analysis_result.get('holdings', {})
465
+ prompt_data += f"**持仓结构**: 前十大持仓集中度: {holdings.get('concentration', 0):.2f}%.\n"
466
+
467
+ # 板块
468
+ sector = self.analysis_result.get('sector_analysis', {})
469
+ sector_val = sector.get('valuation', {})
470
+ prompt_data += f"**板块分析**: 所属板块: {sector.get('sector_name', 'N/A')}, 当前滚动PE находится at {sector_val.get('pe_percentile', 0):.2f}% 历史分位点.\n\n"
471
+
472
+ # 2. 构建Prompt
473
+ prompt_data += "请根据以上数据,从以下三个方面进行分析,并给出一个最终总结:\n1. **核心优势**: 这只ETF最主要的投资亮点是什么?\n2. **潜在风险**: 投资者需要注意哪些潜在风险?\n3. **板块前景**: 结合板块估值和前景,对该ETF的赛道进行评价。\n4. **最终结论**: 给出一个不超过50字的简明投资结论。"
474
+
475
+ # 3. 调用AI模型
476
+ if self.stock_analyzer:
477
+ ai_summary = self.stock_analyzer.get_ai_analysis_from_prompt(prompt_data)
478
+ else:
479
+ ai_summary = "AI分析器未初始化,无法生成摘要。"
480
+
481
+ self.analysis_result['ai_summary'] = {"message": ai_summary}
482
+ print("AI综合诊断生成完成。")
483
+
484
+ except Exception as e:
485
+ print(f"生成AI摘要时出错: {e}")
486
+ self.analysis_result['ai_summary'] = {"error": f"生成AI摘要失败: {e}"}
487
+
488
+ if __name__ == '__main__':
489
+ # For testing purposes
490
+ test_etf = '510300' # 沪深300 ETF
491
+ analyzer = EtfAnalyzer(test_etf)
492
+ results = analyzer.run_analysis()
493
+ import json
494
+ print(json.dumps(results, indent=4, ensure_ascii=False))
app/analysis/fundamental_analyzer.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 智能分析系统(股票) - 股票市场数据分析系统
4
+ 开发者:熊猫大侠
5
+ 版本:v2.1.0
6
+ 许可证:MIT License
7
+ """
8
+ # fundamental_analyzer.py
9
+ import akshare as ak
10
+ import pandas as pd
11
+ import numpy as np
12
+
13
+
14
+ class FundamentalAnalyzer:
15
+ def __init__(self):
16
+ """初始化基础分析类"""
17
+ self.data_cache = {}
18
+
19
+ def get_financial_indicators(self, stock_code, progress_callback=None):
20
+ """获取财务指标数据"""
21
+ if progress_callback:
22
+ progress_callback(5, "正在获取财务指标...")
23
+ try:
24
+ # 获取基本财务指标
25
+ financial_data = ak.stock_financial_analysis_indicator(symbol=stock_code,start_year="2022")
26
+
27
+ # 获取最新估值指标
28
+ valuation = ak.stock_value_em(symbol=stock_code)
29
+
30
+ # 整合数据
31
+ indicators = {
32
+ 'pe_ttm': float(valuation['PE(TTM)'].iloc[0]),
33
+ 'pb': float(valuation['市净率'].iloc[0]),
34
+ 'ps_ttm': float(valuation['市销率'].iloc[0]),
35
+ 'roe': float(financial_data['加权净资产收益率(%)'].iloc[0]),
36
+ 'gross_margin': float(financial_data['销售毛利率(%)'].iloc[0]),
37
+ 'net_profit_margin': float(financial_data['总资产净利润率(%)'].iloc[0]),
38
+ 'debt_ratio': float(financial_data['资产负债率(%)'].iloc[0])
39
+ }
40
+ if progress_callback:
41
+ progress_callback(10, "财务指标获取成功")
42
+ return indicators
43
+ except Exception as e:
44
+ print(f"获取财务指标出错: {str(e)}")
45
+ if progress_callback:
46
+ progress_callback(10, f"财务指标获取失败: {e}")
47
+ return {}
48
+
49
+ def get_growth_data(self, stock_code, progress_callback=None):
50
+ """获取成长性数据"""
51
+ if progress_callback:
52
+ progress_callback(15, "正在获取成长性数据...")
53
+ try:
54
+ # 获取历年财务数据
55
+ financial_data = ak.stock_financial_abstract(symbol=stock_code)
56
+
57
+ # --- 修复:兼容不同的财务字段名 ---
58
+ # 查找营业收入列
59
+ revenue_col = None
60
+ if '营业总收入' in financial_data.columns:
61
+ revenue_col = '营业总收入'
62
+ elif '营业收入' in financial_data.columns:
63
+ revenue_col = '营业收入'
64
+
65
+ # 查找净利润列
66
+ profit_col = None
67
+ if '归属母公司股东的净利润' in financial_data.columns:
68
+ profit_col = '归属母公司股东的净利润'
69
+ elif '净利润' in financial_data.columns:
70
+ profit_col = '净利润'
71
+
72
+ growth = {}
73
+ # 仅在找到列时计算
74
+ if revenue_col:
75
+ revenue = financial_data[revenue_col].astype(float)
76
+ growth['revenue_growth_3y'] = self._calculate_cagr(revenue, 3)
77
+ growth['revenue_growth_5y'] = self._calculate_cagr(revenue, 5)
78
+ else:
79
+ print(f"警告: 股票 {stock_code} 未找到 '营业总收入' 或 '营业收入' 列")
80
+
81
+ if profit_col:
82
+ net_profit = financial_data[profit_col].astype(float)
83
+ growth['profit_growth_3y'] = self._calculate_cagr(net_profit, 3)
84
+ growth['profit_growth_5y'] = self._calculate_cagr(net_profit, 5)
85
+ else:
86
+ print(f"警告: 股票 {stock_code} 未找到 '归属母公司股东的净利润' 或 '净利润' 列")
87
+ # --- 修复结束 ---
88
+
89
+ if progress_callback:
90
+ progress_callback(20, "成长性数据获取成功")
91
+ return growth
92
+ except Exception as e:
93
+ # 保持现有的异常捕获,以防akshare调用本身失败
94
+ print(f"获取成长数据出错: {str(e)}")
95
+ if progress_callback:
96
+ progress_callback(20, f"成长性数据获取失败: {e}")
97
+ return {}
98
+
99
+ def _calculate_cagr(self, series, years):
100
+ """计算复合年增长率"""
101
+ if len(series) < years:
102
+ return None
103
+
104
+ latest = series.iloc[0]
105
+ earlier = series.iloc[min(years, len(series) - 1)]
106
+
107
+ if earlier <= 0:
108
+ return None
109
+
110
+ return ((latest / earlier) ** (1 / years) - 1) * 100
111
+
112
+ def calculate_fundamental_score(self, stock_code, progress_callback=None):
113
+ """计算基本面综合评分"""
114
+ if progress_callback:
115
+ progress_callback(0, "启动基本面分析模块...")
116
+
117
+ indicators = self.get_financial_indicators(stock_code, progress_callback=progress_callback)
118
+ growth = self.get_growth_data(stock_code, progress_callback=progress_callback)
119
+
120
+ if progress_callback:
121
+ progress_callback(25, "计算基本面综合评分...")
122
+
123
+ # 估值评分 (30分)
124
+ valuation_score = 0
125
+ if 'pe_ttm' in indicators and indicators['pe_ttm'] > 0:
126
+ pe = indicators['pe_ttm']
127
+ if pe < 15:
128
+ valuation_score += 25
129
+ elif pe < 25:
130
+ valuation_score += 20
131
+ elif pe < 35:
132
+ valuation_score += 15
133
+ elif pe < 50:
134
+ valuation_score += 10
135
+ else:
136
+ valuation_score += 5
137
+
138
+ # 财务健康评分 (40分)
139
+ financial_score = 0
140
+ if 'roe' in indicators:
141
+ roe = indicators['roe']
142
+ if roe > 20:
143
+ financial_score += 15
144
+ elif roe > 15:
145
+ financial_score += 12
146
+ elif roe > 10:
147
+ financial_score += 8
148
+ elif roe > 5:
149
+ financial_score += 4
150
+
151
+ if 'debt_ratio' in indicators:
152
+ debt_ratio = indicators['debt_ratio']
153
+ if debt_ratio < 30:
154
+ financial_score += 15
155
+ elif debt_ratio < 50:
156
+ financial_score += 10
157
+ elif debt_ratio < 70:
158
+ financial_score += 5
159
+
160
+ # 成长性评分 (30分)
161
+ growth_score = 0
162
+ if 'revenue_growth_3y' in growth and growth['revenue_growth_3y']:
163
+ rev_growth = growth['revenue_growth_3y']
164
+ if rev_growth > 30:
165
+ growth_score += 15
166
+ elif rev_growth > 20:
167
+ growth_score += 12
168
+ elif rev_growth > 10:
169
+ growth_score += 8
170
+ elif rev_growth > 0:
171
+ growth_score += 4
172
+
173
+ if 'profit_growth_3y' in growth and growth['profit_growth_3y']:
174
+ profit_growth = growth['profit_growth_3y']
175
+ if profit_growth > 30:
176
+ growth_score += 15
177
+ elif profit_growth > 20:
178
+ growth_score += 12
179
+ elif profit_growth > 10:
180
+ growth_score += 8
181
+ elif profit_growth > 0:
182
+ growth_score += 4
183
+
184
+ # 计算总分
185
+ total_score = valuation_score + financial_score + growth_score
186
+
187
+ if progress_callback:
188
+ progress_callback(30, "基本面分析完成")
189
+
190
+ return {
191
+ 'total': total_score,
192
+ 'valuation': valuation_score,
193
+ 'financial_health': financial_score,
194
+ 'growth': growth_score,
195
+ 'details': {
196
+ 'indicators': indicators,
197
+ 'growth': growth
198
+ }
199
+ }
app/analysis/index_industry_analyzer.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # index_industry_analyzer.py
2
+ import akshare as ak
3
+ import pandas as pd
4
+ import numpy as np
5
+ import threading
6
+
7
+
8
+ class IndexIndustryAnalyzer:
9
+ def __init__(self, analyzer):
10
+ self.analyzer = analyzer
11
+ self.data_cache = {}
12
+
13
+ def analyze_index(self, index_code, limit=30):
14
+ """分析指数整体情况"""
15
+ try:
16
+ cache_key = f"index_{index_code}"
17
+ if cache_key in self.data_cache:
18
+ cache_time, cached_result = self.data_cache[cache_key]
19
+ # 如果缓存时间在1小时内,直接返回
20
+ if (pd.Timestamp.now() - cache_time).total_seconds() < 3600:
21
+ return cached_result
22
+
23
+ # 获取指数成分股
24
+ if index_code == '000300':
25
+ # 沪深300成分股
26
+ stocks = ak.index_stock_cons_weight_csindex(symbol="000300")
27
+ index_name = "沪深300"
28
+ elif index_code == '000905':
29
+ # 中证500成分股
30
+ stocks = ak.index_stock_cons_weight_csindex(symbol="000905")
31
+ index_name = "中证500"
32
+ elif index_code == '000852':
33
+ # 中证1000成分股
34
+ stocks = ak.index_stock_cons_weight_csindex(symbol="000852")
35
+ index_name = "中证1000"
36
+ elif index_code == '000001':
37
+ # 上证指数
38
+ stocks = ak.index_stock_cons_weight_csindex(symbol="000001")
39
+ index_name = "上证指数"
40
+ else:
41
+ return {"error": "不支持的指数代码"}
42
+
43
+ # 提取股票代码列表和权重
44
+ stock_list = []
45
+ if '成分券代码' in stocks.columns:
46
+ stock_list = stocks['成分券代码'].tolist()
47
+ weights = stocks['权重(%)'].tolist() if '权重(%)' in stocks.columns else [1] * len(stock_list)
48
+ else:
49
+ return {"error": "获取指数成分股失败"}
50
+
51
+ # 限制分析的股票数量以提高性能
52
+ if limit and len(stock_list) > limit:
53
+ # 按权重排序,取前limit只权重最大的股票
54
+ stock_weights = list(zip(stock_list, weights))
55
+ stock_weights.sort(key=lambda x: x[1], reverse=True)
56
+ stock_list = [s[0] for s in stock_weights[:limit]]
57
+ weights = [s[1] for s in stock_weights[:limit]]
58
+
59
+ # 多线程分析股票
60
+ results = []
61
+ threads = []
62
+ results_lock = threading.Lock()
63
+
64
+ def analyze_stock(stock_code, weight):
65
+ try:
66
+ # 分析股票
67
+ result = self.analyzer.quick_analyze_stock(stock_code)
68
+ result['weight'] = weight
69
+
70
+ with results_lock:
71
+ results.append(result)
72
+ except Exception as e:
73
+ print(f"分析股票 {stock_code} 时出错: {str(e)}")
74
+
75
+ # 创建并启动线程
76
+ for i, stock_code in enumerate(stock_list):
77
+ weight = weights[i] if i < len(weights) else 1
78
+ thread = threading.Thread(target=analyze_stock, args=(stock_code, weight))
79
+ threads.append(thread)
80
+ thread.start()
81
+
82
+ # 等待所有线程完成
83
+ for thread in threads:
84
+ thread.join()
85
+
86
+ # 计算指数整体情况
87
+ total_weight = sum([r.get('weight', 1) for r in results])
88
+
89
+ # 计算加权评分
90
+ index_score = 0
91
+ if total_weight > 0:
92
+ index_score = sum([r.get('score', 0) * r.get('weight', 1) for r in results]) / total_weight
93
+
94
+ # 计算其他指标
95
+ up_count = sum(1 for r in results if r.get('price_change', 0) > 0)
96
+ down_count = sum(1 for r in results if r.get('price_change', 0) < 0)
97
+ flat_count = len(results) - up_count - down_count
98
+
99
+ # 计算涨跌股比例
100
+ up_ratio = up_count / len(results) if len(results) > 0 else 0
101
+
102
+ # 计算加权平均涨跌幅
103
+ weighted_change = 0
104
+ if total_weight > 0:
105
+ weighted_change = sum([r.get('price_change', 0) * r.get('weight', 1) for r in results]) / total_weight
106
+
107
+ # 按评分对股票排序
108
+ results.sort(key=lambda x: x.get('score', 0), reverse=True)
109
+
110
+ # 整理结果
111
+ index_analysis = {
112
+ "index_code": index_code,
113
+ "index_name": index_name,
114
+ "score": round(index_score, 2),
115
+ "stock_count": len(results),
116
+ "up_count": up_count,
117
+ "down_count": down_count,
118
+ "flat_count": flat_count,
119
+ "up_ratio": up_ratio,
120
+ "weighted_change": weighted_change,
121
+ "top_stocks": results[:5] if len(results) >= 5 else results,
122
+ "results": results
123
+ }
124
+
125
+ # 缓存结果
126
+ self.data_cache[cache_key] = (pd.Timestamp.now(), index_analysis)
127
+
128
+ return index_analysis
129
+
130
+ except Exception as e:
131
+ print(f"分析指数整体情况时出错: {str(e)}")
132
+ return {"error": f"分析指数时出错: {str(e)}"}
133
+
134
+ def analyze_industry(self, industry, limit=30):
135
+ """分析行业整体情况"""
136
+ try:
137
+ cache_key = f"industry_{industry}"
138
+ if cache_key in self.data_cache:
139
+ cache_time, cached_result = self.data_cache[cache_key]
140
+ # 如果缓存时间在1小时内,直接返回
141
+ if (pd.Timestamp.now() - cache_time).total_seconds() < 3600:
142
+ return cached_result
143
+
144
+ # 获取行业成分股
145
+ stocks = ak.stock_board_industry_cons_em(symbol=industry)
146
+
147
+ # 提取股票代码列表
148
+ stock_list = stocks['代码'].tolist() if '代码' in stocks.columns else []
149
+
150
+ if not stock_list:
151
+ return {"error": "获取行业成分股失败"}
152
+
153
+ # 限制分析的股票数量以提高性能
154
+ if limit and len(stock_list) > limit:
155
+ stock_list = stock_list[:limit]
156
+
157
+ # 多线程分析股票
158
+ results = []
159
+ threads = []
160
+ results_lock = threading.Lock()
161
+
162
+ def analyze_stock(stock_code):
163
+ try:
164
+ # 分析股票
165
+ result = self.analyzer.quick_analyze_stock(stock_code)
166
+
167
+ with results_lock:
168
+ results.append(result)
169
+ except Exception as e:
170
+ print(f"分析股票 {stock_code} 时出错: {str(e)}")
171
+
172
+ # 创建并启动线程
173
+ for stock_code in stock_list:
174
+ thread = threading.Thread(target=analyze_stock, args=(stock_code,))
175
+ threads.append(thread)
176
+ thread.start()
177
+
178
+ # 等待所有线程完成
179
+ for thread in threads:
180
+ thread.join()
181
+
182
+ # 计算行业整体情况
183
+ if not results:
184
+ return {"error": "分析行业股票失败"}
185
+
186
+ # 计算平均评分
187
+ industry_score = sum([r.get('score', 0) for r in results]) / len(results)
188
+
189
+ # 计算其他指标
190
+ up_count = sum(1 for r in results if r.get('price_change', 0) > 0)
191
+ down_count = sum(1 for r in results if r.get('price_change', 0) < 0)
192
+ flat_count = len(results) - up_count - down_count
193
+
194
+ # 计算涨跌股比例
195
+ up_ratio = up_count / len(results)
196
+
197
+ # 计算平均涨跌幅
198
+ avg_change = sum([r.get('price_change', 0) for r in results]) / len(results)
199
+
200
+ # 按评分对股票排序
201
+ results.sort(key=lambda x: x.get('score', 0), reverse=True)
202
+
203
+ # 整理结果
204
+ industry_analysis = {
205
+ "industry": industry,
206
+ "score": round(industry_score, 2),
207
+ "stock_count": len(results),
208
+ "up_count": up_count,
209
+ "down_count": down_count,
210
+ "flat_count": flat_count,
211
+ "up_ratio": up_ratio,
212
+ "avg_change": avg_change,
213
+ "top_stocks": results[:5] if len(results) >= 5 else results,
214
+ "results": results
215
+ }
216
+
217
+ # 缓存结果
218
+ self.data_cache[cache_key] = (pd.Timestamp.now(), industry_analysis)
219
+
220
+ return industry_analysis
221
+
222
+ except Exception as e:
223
+ print(f"分析行业整体情况时出错: {str(e)}")
224
+ return {"error": f"分析行业时出错: {str(e)}"}
225
+
226
+ def compare_industries(self, limit=10):
227
+ """比较不同行业的表现"""
228
+ try:
229
+ # 获取行业板块数据
230
+ industry_data = ak.stock_board_industry_name_em()
231
+
232
+ # 提取行业名称列表
233
+ industries = industry_data['板块名称'].tolist() if '板块名称' in industry_data.columns else []
234
+
235
+ if not industries:
236
+ return {"error": "获取行业列表失败"}
237
+
238
+ # 限制分析的行业数量
239
+ industries = industries[:limit] if limit else industries
240
+
241
+ # 分析各行业情况
242
+ industry_results = []
243
+
244
+ for industry in industries:
245
+ try:
246
+ # 简化分析,只获取基本指标
247
+ industry_info = ak.stock_board_industry_hist_em(symbol=industry, period="3m")
248
+
249
+ # 计算行业涨跌幅
250
+ if not industry_info.empty:
251
+ latest = industry_info.iloc[0]
252
+ change = latest['涨跌幅'] if '涨跌幅' in latest.index else 0
253
+
254
+ industry_results.append({
255
+ "industry": industry,
256
+ "change": change,
257
+ "volume": latest['成交量'] if '成交量' in latest.index else 0,
258
+ "turnover": latest['成交额'] if '成交额' in latest.index else 0
259
+ })
260
+ except Exception as e:
261
+ print(f"分析行业 {industry} 时出错: {str(e)}")
262
+
263
+ # 按涨跌幅排序
264
+ industry_results.sort(key=lambda x: x.get('change', 0), reverse=True)
265
+
266
+ return {
267
+ "count": len(industry_results),
268
+ "top_industries": industry_results[:5] if len(industry_results) >= 5 else industry_results,
269
+ "bottom_industries": industry_results[-5:] if len(industry_results) >= 5 else [],
270
+ "results": industry_results
271
+ }
272
+
273
+ except Exception as e:
274
+ print(f"比较行业表现时出错: {str(e)}")
275
+ return {"error": f"比较行业表现时出错: {str(e)}"}
app/analysis/industry_analyzer.py ADDED
@@ -0,0 +1,533 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 智能分析系统(股票) - 股票市场数据分析系统
4
+ 开发者:熊猫大侠
5
+ 版本:v2.1.0
6
+ 许可证:MIT License
7
+ """
8
+ # industry_analyzer.py
9
+ import logging
10
+ import random
11
+ import akshare as ak
12
+ import pandas as pd
13
+ import numpy as np
14
+ from datetime import datetime, timedelta
15
+
16
+
17
+ class IndustryAnalyzer:
18
+ def __init__(self):
19
+ """初始化行业分析类"""
20
+ self.data_cache = {}
21
+ self.industry_code_map = {} # 缓存行业名称到代码的映射
22
+
23
+ # 设置日志记录
24
+ logging.basicConfig(level=logging.INFO,
25
+ format='%(asctime)s - %(levelname)s - %(message)s')
26
+ self.logger = logging.getLogger(__name__)
27
+
28
+ def get_industry_fund_flow(self, symbol="即时"):
29
+ """获取行业资金流向数据"""
30
+ try:
31
+ # 缓存键
32
+ cache_key = f"industry_fund_flow_{symbol}"
33
+
34
+ # 检查缓存
35
+ if cache_key in self.data_cache:
36
+ cache_time, cached_data = self.data_cache[cache_key]
37
+ # 如果缓存时间在30分钟内,直接返回
38
+ if (datetime.now() - cache_time).total_seconds() < 1800:
39
+ self.logger.info(f"从缓存获取行业资金流向数据: {symbol}")
40
+ return cached_data
41
+
42
+ # 获取行业资金流向数据
43
+ self.logger.info(f"从API获取行业资金流向数据: {symbol}")
44
+ fund_flow_data = ak.stock_fund_flow_industry(symbol=symbol)
45
+
46
+ # 打印列名以便调试
47
+ self.logger.info(f"行业资金流向数据列名: {fund_flow_data.columns.tolist()}")
48
+
49
+ # 转换为字典列表
50
+ result = []
51
+
52
+ if symbol == "即时":
53
+ for _, row in fund_flow_data.iterrows():
54
+ try:
55
+ # 安全地将值转换为对应的类型
56
+ item = {
57
+ "rank": self._safe_int(row["序号"]),
58
+ "industry": str(row["行业"]),
59
+ "index": self._safe_float(row["行业指数"]),
60
+ "change": self._safe_percent(row["行业-涨跌幅"]),
61
+ "inflow": self._safe_float(row["流入资金"]),
62
+ "outflow": self._safe_float(row["流出资金"]),
63
+ "netFlow": self._safe_float(row["净额"]),
64
+ "companyCount": self._safe_int(row["公司家数"])
65
+ }
66
+
67
+ # 添加领涨股相关数据,如果存在
68
+ if "领涨股" in row:
69
+ item["leadingStock"] = str(row["领涨股"])
70
+ if "领涨股-涨跌幅" in row:
71
+ item["leadingStockChange"] = self._safe_percent(row["领涨股-涨跌幅"])
72
+ if "当前价" in row:
73
+ item["leadingStockPrice"] = self._safe_float(row["当前价"])
74
+
75
+ result.append(item)
76
+ except Exception as e:
77
+ self.logger.warning(f"处理行业资金流向数据行时出错: {str(e)}")
78
+ continue
79
+ else:
80
+ for _, row in fund_flow_data.iterrows():
81
+ try:
82
+ item = {
83
+ "rank": self._safe_int(row["序号"]),
84
+ "industry": str(row["行业"]),
85
+ "companyCount": self._safe_int(row["公司家数"]),
86
+ "index": self._safe_float(row["行业指数"]),
87
+ "change": self._safe_percent(row["阶段涨跌幅"]),
88
+ "inflow": self._safe_float(row["流入资金"]),
89
+ "outflow": self._safe_float(row["流出资金"]),
90
+ "netFlow": self._safe_float(row["净额"])
91
+ }
92
+ result.append(item)
93
+ except Exception as e:
94
+ self.logger.warning(f"处理行业资金流向数据行时出错: {str(e)}")
95
+ continue
96
+
97
+ # 缓存结果
98
+ self.data_cache[cache_key] = (datetime.now(), result)
99
+
100
+ return result
101
+
102
+ except Exception as e:
103
+ self.logger.error(f"获取行业资金流向数据失败: {str(e)}")
104
+ # 返回更详细的错误信息,包括堆栈跟踪
105
+ import traceback
106
+ self.logger.error(traceback.format_exc())
107
+ return []
108
+
109
+ def _safe_float(self, value):
110
+ """安全地将值转换为浮点数"""
111
+ try:
112
+ if pd.isna(value):
113
+ return 0.0
114
+ return float(value)
115
+ except:
116
+ return 0.0
117
+
118
+ def _safe_int(self, value):
119
+ """安全地将值转换为整数"""
120
+ try:
121
+ if pd.isna(value):
122
+ return 0
123
+ return int(value)
124
+ except:
125
+ return 0
126
+
127
+ def _safe_percent(self, value):
128
+ """安全地将百分比值转换为字符串格式"""
129
+ try:
130
+ if pd.isna(value):
131
+ return "0.00"
132
+
133
+ # 如果是字符串并包含%,移除%符号
134
+ if isinstance(value, str) and "%" in value:
135
+ return value.replace("%", "")
136
+
137
+ # 如果是数值,直接转换成字符串
138
+ return str(float(value))
139
+ except:
140
+ return "0.00"
141
+
142
+ def _get_industry_code(self, industry_name):
143
+ """获取行业名称对应的板块代码"""
144
+ try:
145
+ # 如果已经缓存了行业代码映射,直接使用
146
+ if not self.industry_code_map:
147
+ # 获取东方财富行业板块名称及代码
148
+ industry_list = ak.stock_board_industry_name_em()
149
+
150
+ # 创建行业名称到代码的映射
151
+ for _, row in industry_list.iterrows():
152
+ if '板块名称' in industry_list.columns and '板块代码' in industry_list.columns:
153
+ name = row['板块名称']
154
+ code = row['板块代码']
155
+ self.industry_code_map[name] = code
156
+
157
+ self.logger.info(f"成功获取到 {len(self.industry_code_map)} 个行业代码映射")
158
+
159
+ # 尝试精确匹配
160
+ if industry_name in self.industry_code_map:
161
+ return self.industry_code_map[industry_name]
162
+
163
+ # 尝试模糊匹配
164
+ for name, code in self.industry_code_map.items():
165
+ if industry_name in name or name in industry_name:
166
+ self.logger.info(f"行业名称 '{industry_name}' 模糊匹配到 '{name}',代码: {code}")
167
+ return code
168
+
169
+ # 如果找不到匹配项,则返回None
170
+ self.logger.warning(f"未找到行业 '{industry_name}' 对应的代码")
171
+ return None
172
+
173
+ except Exception as e:
174
+ self.logger.error(f"获取行业代码时出错: {str(e)}")
175
+ import traceback
176
+ self.logger.error(traceback.format_exc())
177
+ return None
178
+
179
+ def get_industry_stocks(self, industry):
180
+ """获取行业成分股"""
181
+ try:
182
+ # 缓存键
183
+ cache_key = f"industry_stocks_{industry}"
184
+
185
+ # 检查缓存
186
+ if cache_key in self.data_cache:
187
+ cache_time, cached_data = self.data_cache[cache_key]
188
+ # 如果缓存时间在1小时内,直接返回
189
+ if (datetime.now() - cache_time).total_seconds() < 3600:
190
+ self.logger.info(f"从缓存获取行业成分股: {industry}")
191
+ return cached_data
192
+
193
+ # 获取行业成分股
194
+ self.logger.info(f"获取 {industry} 行业成分股")
195
+
196
+ result = []
197
+ try:
198
+ # 1. 首先尝试直接使用行业名称
199
+ try:
200
+ stocks = ak.stock_board_industry_cons_em(symbol=industry)
201
+ self.logger.info(f"使用行业名称 '{industry}' 成功获取成分股")
202
+ except Exception as direct_error:
203
+ self.logger.warning(f"使用行业名称获取成分股失败: {str(direct_error)}")
204
+ # 2. 尝试使用行业代码
205
+ industry_code = self._get_industry_code(industry)
206
+ if industry_code:
207
+ self.logger.info(f"尝试使用行业代码 {industry_code} 获取成分股")
208
+ stocks = ak.stock_board_industry_cons_em(symbol=industry_code)
209
+ else:
210
+ # 如果无法获取行业代码,抛出异常,进入模拟数据生成
211
+ raise ValueError(f"无法找到行业 '{industry}' 对应的代码")
212
+
213
+ # 打印列名以便调试
214
+ self.logger.info(f"行业成分股数据列名: {stocks.columns.tolist()}")
215
+
216
+ # 转换为字典列表
217
+ if not stocks.empty:
218
+ for _, row in stocks.iterrows():
219
+ try:
220
+ item = {
221
+ "code": str(row["代码"]),
222
+ "name": str(row["名称"]),
223
+ "price": self._safe_float(row["最新价"]),
224
+ "change": self._safe_float(row["涨跌幅"]),
225
+ "change_amount": self._safe_float(row["涨跌额"]) if "涨跌额" in row else 0.0,
226
+ "volume": self._safe_float(row["成交量"]) if "成交量" in row else 0.0,
227
+ "turnover": self._safe_float(row["成交额"]) if "成交额" in row else 0.0,
228
+ "amplitude": self._safe_float(row["振幅"]) if "振幅" in row else 0.0,
229
+ "turnover_rate": self._safe_float(row["换手率"]) if "换手率" in row else 0.0
230
+ }
231
+ result.append(item)
232
+ except Exception as e:
233
+ self.logger.warning(f"处理行业成分股数据行时出错: {str(e)}")
234
+ continue
235
+
236
+ except Exception as e:
237
+ # 3. 如果上述方法都失败,生成模拟数据
238
+ self.logger.warning(f"无法通过API获取行业成分股,使用模拟数据: {str(e)}")
239
+ result = self._generate_mock_industry_stocks(industry)
240
+
241
+ # 缓存结果
242
+ self.data_cache[cache_key] = (datetime.now(), result)
243
+
244
+ return result
245
+
246
+ except Exception as e:
247
+ self.logger.error(f"获取行业成分股失败: {str(e)}")
248
+ import traceback
249
+ self.logger.error(traceback.format_exc())
250
+ return []
251
+
252
+ def _generate_mock_industry_stocks(self, industry):
253
+ """生成模拟的行业成分股数据"""
254
+ self.logger.info(f"生成行业 {industry} 的模拟成分股数据")
255
+
256
+ # 使用来自资金流向的行业数据获取该行业的基本信息
257
+ fund_flow_data = self.get_industry_fund_flow("即时")
258
+ industry_data = next((item for item in fund_flow_data if item["industry"] == industry), None)
259
+
260
+ company_count = 20 # 默认值
261
+ if industry_data and "companyCount" in industry_data:
262
+ company_count = min(industry_data["companyCount"], 30) # 限制最多30只股票
263
+
264
+ # 生成模拟股票
265
+ result = []
266
+ for i in range(company_count):
267
+ # 生成6位数字的股票代码,确保前缀是0或6
268
+ prefix = "6" if i % 2 == 0 else "0"
269
+ code = prefix + str(100000 + i).zfill(5)[-5:]
270
+
271
+ # 生成股票价格和涨跌幅
272
+ price = round(random.uniform(10, 100), 2)
273
+ change = round(random.uniform(-5, 5), 2)
274
+
275
+ # 生成成交量和成交额
276
+ volume = round(random.uniform(100000, 10000000))
277
+ turnover = round(volume * price / 10000, 2) # 转换为万元
278
+
279
+ # 生成换手率和振幅
280
+ turnover_rate = round(random.uniform(0.5, 5), 2)
281
+ amplitude = round(random.uniform(1, 10), 2)
282
+
283
+ item = {
284
+ "code": code,
285
+ "name": f"{industry}股{i + 1}",
286
+ "price": price,
287
+ "change": change,
288
+ "change_amount": round(price * change / 100, 2),
289
+ "volume": volume,
290
+ "turnover": turnover,
291
+ "amplitude": amplitude,
292
+ "turnover_rate": turnover_rate
293
+ }
294
+ result.append(item)
295
+
296
+ # 按涨跌幅排序
297
+ result.sort(key=lambda x: x["change"], reverse=True)
298
+
299
+ return result
300
+
301
+ def get_industry_detail(self, industry):
302
+ """获取行业详细信息"""
303
+ try:
304
+ # 获取行业资金流向数据
305
+ fund_flow_data = self.get_industry_fund_flow("即时")
306
+ industry_data = next((item for item in fund_flow_data if item["industry"] == industry), None)
307
+
308
+ if not industry_data:
309
+ return None
310
+
311
+ # 获取历史资金流向数据
312
+ history_data = []
313
+
314
+ for period in ["3日排行", "5日排行", "10日排行", "20日排行"]:
315
+ period_data = self.get_industry_fund_flow(period)
316
+ industry_period_data = next((item for item in period_data if item["industry"] == industry), None)
317
+
318
+ if industry_period_data:
319
+ days = int(period.replace("日排行", ""))
320
+ date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
321
+
322
+ history_data.append({
323
+ "date": date,
324
+ "inflow": industry_period_data["inflow"],
325
+ "outflow": industry_period_data["outflow"],
326
+ "netFlow": industry_period_data["netFlow"],
327
+ "change": industry_period_data["change"]
328
+ })
329
+
330
+ # 添加即时数据
331
+ history_data.append({
332
+ "date": datetime.now().strftime("%Y-%m-%d"),
333
+ "inflow": industry_data["inflow"],
334
+ "outflow": industry_data["outflow"],
335
+ "netFlow": industry_data["netFlow"],
336
+ "change": industry_data["change"]
337
+ })
338
+
339
+ # 按日期排序
340
+ history_data.sort(key=lambda x: x["date"])
341
+
342
+ # 计算行业评分
343
+ score = self.calculate_industry_score(industry_data, history_data)
344
+
345
+ # 生成投资建议
346
+ recommendation = self.generate_industry_recommendation(score, industry_data, history_data)
347
+
348
+ # 构建结果
349
+ result = {
350
+ "industry": industry,
351
+ "index": industry_data["index"],
352
+ "change": industry_data["change"],
353
+ "companyCount": industry_data["companyCount"],
354
+ "inflow": industry_data["inflow"],
355
+ "outflow": industry_data["outflow"],
356
+ "netFlow": industry_data["netFlow"],
357
+ "leadingStock": industry_data.get("leadingStock", ""),
358
+ "leadingStockChange": industry_data.get("leadingStockChange", ""),
359
+ "leadingStockPrice": industry_data.get("leadingStockPrice", 0),
360
+ "score": score,
361
+ "recommendation": recommendation,
362
+ "flowHistory": history_data
363
+ }
364
+
365
+ return result
366
+
367
+ except Exception as e:
368
+ self.logger.error(f"获取行业详细信息失败: {str(e)}")
369
+ import traceback
370
+ self.logger.error(traceback.format_exc())
371
+ return None
372
+
373
+ def calculate_industry_score(self, industry_data, history_data):
374
+ """计算行业评分"""
375
+ try:
376
+ # 基础分数为50分
377
+ score = 50
378
+
379
+ # 根据涨跌幅增减分数(-10到+10)
380
+ change = float(industry_data["change"])
381
+ if change > 3:
382
+ score += 10
383
+ elif change > 1:
384
+ score += 5
385
+ elif change < -3:
386
+ score -= 10
387
+ elif change < -1:
388
+ score -= 5
389
+
390
+ # 根据资金流向增减分数(-20到+20)
391
+ netFlow = float(industry_data["netFlow"])
392
+
393
+ if netFlow > 5:
394
+ score += 20
395
+ elif netFlow > 2:
396
+ score += 15
397
+ elif netFlow > 0:
398
+ score += 10
399
+ elif netFlow < -5:
400
+ score -= 20
401
+ elif netFlow < -2:
402
+ score -= 15
403
+ elif netFlow < 0:
404
+ score -= 10
405
+
406
+ # 根据历史资金流向趋势增减分数(-10到+10)
407
+ if len(history_data) >= 2:
408
+ net_flow_trend = 0
409
+ for i in range(1, len(history_data)):
410
+ if float(history_data[i]["netFlow"]) > float(history_data[i - 1]["netFlow"]):
411
+ net_flow_trend += 1
412
+ else:
413
+ net_flow_trend -= 1
414
+
415
+ if net_flow_trend > 0:
416
+ score += 10
417
+ elif net_flow_trend < 0:
418
+ score -= 10
419
+
420
+ # 限制分数在0-100之间
421
+ score = max(0, min(100, score))
422
+
423
+ return round(score)
424
+
425
+ except Exception as e:
426
+ self.logger.error(f"计算行业评分时出错: {str(e)}")
427
+ return 50
428
+
429
+ def generate_industry_recommendation(self, score, industry_data, history_data):
430
+ """生成行业投资建议"""
431
+ try:
432
+ if score >= 80:
433
+ return "行业景气度高,资金持续流入,建议积极配置"
434
+ elif score >= 60:
435
+ return "行业表现良好,建议适当加仓"
436
+ elif score >= 40:
437
+ return "行业表现一般,建议谨慎参与"
438
+ else:
439
+ return "行业下行趋势明显,建议减持规避风险"
440
+
441
+ except Exception as e:
442
+ self.logger.error(f"生成行业投资建议时出错: {str(e)}")
443
+ return "无法生成投资建议"
444
+
445
+ def compare_industries(self, limit=10):
446
+ """比较不同行业的表现"""
447
+ try:
448
+ # 获取行业板块数据
449
+ industry_data = ak.stock_board_industry_name_em()
450
+
451
+ # 提取行业名称列表
452
+ industries = industry_data['板块名称'].tolist() if '板块名称' in industry_data.columns else []
453
+
454
+ if not industries:
455
+ return {"error": "获取行业列表失败"}
456
+
457
+ # 限制分析的行业数量
458
+ industries = industries[:limit] if limit else industries
459
+
460
+ # 分析各行业情况
461
+ industry_results = []
462
+
463
+ for industry in industries:
464
+ try:
465
+ # 尝试获取行业板块代码
466
+ industry_code = None
467
+ for _, row in industry_data.iterrows():
468
+ if row['板块名称'] == industry:
469
+ industry_code = row['板块代码']
470
+ break
471
+
472
+ if not industry_code:
473
+ self.logger.warning(f"未找到行业 {industry} 的板块代码")
474
+ continue
475
+
476
+ # 尝试使用不同的参数来获取行业数据 - 不使用"3m"
477
+ try:
478
+ # 尝试不使用period参数
479
+ industry_info = ak.stock_board_industry_hist_em(symbol=industry_code)
480
+ except Exception as e1:
481
+ try:
482
+ # 尝试使用daily参数
483
+ industry_info = ak.stock_board_industry_hist_em(symbol=industry_code, period="daily")
484
+ except Exception as e2:
485
+ self.logger.warning(f"分析行业 {industry} 历史数据失败: {str(e1)}, {str(e2)}")
486
+ continue
487
+
488
+ # 计算行业涨跌幅
489
+ if not industry_info.empty:
490
+ latest = industry_info.iloc[0]
491
+
492
+ # 尝试获取涨跌幅,列名可能有变化
493
+ change = 0.0
494
+ if '涨跌幅' in latest.index:
495
+ change = latest['涨跌幅']
496
+ elif '涨跌幅' in industry_info.columns:
497
+ change = latest['涨跌幅']
498
+
499
+ # 尝试获取成交量和成交额
500
+ volume = 0.0
501
+ turnover = 0.0
502
+ if '成交量' in latest.index:
503
+ volume = latest['成交量']
504
+ elif '成交量' in industry_info.columns:
505
+ volume = latest['成交量']
506
+
507
+ if '成交额' in latest.index:
508
+ turnover = latest['成交额']
509
+ elif '成交额' in industry_info.columns:
510
+ turnover = latest['成交额']
511
+
512
+ industry_results.append({
513
+ "industry": industry,
514
+ "change": float(change) if change else 0.0,
515
+ "volume": float(volume) if volume else 0.0,
516
+ "turnover": float(turnover) if turnover else 0.0
517
+ })
518
+ except Exception as e:
519
+ self.logger.error(f"分析行业 {industry} 时出错: {str(e)}")
520
+
521
+ # 按涨跌幅排序
522
+ industry_results.sort(key=lambda x: x.get('change', 0), reverse=True)
523
+
524
+ return {
525
+ "count": len(industry_results),
526
+ "top_industries": industry_results[:5] if len(industry_results) >= 5 else industry_results,
527
+ "bottom_industries": industry_results[-5:] if len(industry_results) >= 5 else [],
528
+ "results": industry_results
529
+ }
530
+
531
+ except Exception as e:
532
+ self.logger.error(f"比较行业表现时出错: {str(e)}")
533
+ return {"error": f"比较行业表现时出错: {str(e)}"}
app/analysis/news_fetcher.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # news_fetcher.py
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ 智能分析系统(股票) - 新闻数据获取模块
5
+ 功能: 获取财联社电报新闻数据并缓存到本地,避免重复内容
6
+ """
7
+
8
+ import os
9
+ import json
10
+ import logging
11
+ import time
12
+ import hashlib
13
+ from datetime import datetime, timedelta, date
14
+ import akshare as ak
15
+ import pandas as pd
16
+
17
+ # 设置日志
18
+ logging.basicConfig(level=logging.INFO,
19
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
20
+ logger = logging.getLogger('news_fetcher')
21
+
22
+ # 自定义JSON编码器,处理日期类型
23
+ class DateEncoder(json.JSONEncoder):
24
+ def default(self, obj):
25
+ if isinstance(obj, (datetime, date)):
26
+ return obj.isoformat()
27
+ if pd.isna(obj): # 处理pandas中的NaN
28
+ return None
29
+ return super(DateEncoder, self).default(obj)
30
+
31
+ class NewsFetcher:
32
+ def __init__(self, save_dir="data/news"):
33
+ """初始化新闻获取器"""
34
+ self.save_dir = save_dir
35
+ # 确保保存目录存在
36
+ os.makedirs(self.save_dir, exist_ok=True)
37
+ self.last_fetch_time = None
38
+
39
+ # 哈希集合用于快速判断新闻是否已存在
40
+ self.news_hashes = set()
41
+ # 加载已有的新闻哈希
42
+ self._load_existing_hashes()
43
+
44
+ def _load_existing_hashes(self):
45
+ """加载已有文件中的新闻哈希值"""
46
+ try:
47
+ # 获取最近3天的文件来加载哈希值
48
+ today = datetime.now()
49
+ for i in range(3): # 检查今天和前两天的数据
50
+ date = today - timedelta(days=i)
51
+ filename = self.get_news_filename(date)
52
+
53
+ if os.path.exists(filename):
54
+ with open(filename, 'r', encoding='utf-8') as f:
55
+ try:
56
+ news_data = json.load(f)
57
+ for item in news_data:
58
+ # 如果有哈希字段就直接使用,否则计算新的哈希
59
+ if 'hash' in item:
60
+ self.news_hashes.add(item['hash'])
61
+ else:
62
+ content_hash = self._calculate_hash(item['content'])
63
+ self.news_hashes.add(content_hash)
64
+ except json.JSONDecodeError:
65
+ logger.warning(f"文件 {filename} 格式错误,跳过加载哈希值")
66
+
67
+ logger.info(f"已加载 {len(self.news_hashes)} 条新闻哈希值")
68
+ except Exception as e:
69
+ logger.error(f"加载现有新闻哈希值时出错: {str(e)}")
70
+ # 出错时清空哈希集合,保证程序可以继续运行
71
+ self.news_hashes = set()
72
+
73
+ def _calculate_hash(self, content):
74
+ """计算新闻内容的哈希值"""
75
+ # 使用MD5哈希算法计算内容的哈希值
76
+ # 对于财经新闻,内容通常是唯一的标识,所以只对内容计算哈希
77
+ return hashlib.md5(str(content).encode('utf-8')).hexdigest()
78
+
79
+ def get_news_filename(self, date=None):
80
+ """获取指定日期的新闻文件名"""
81
+ if date is None:
82
+ date = datetime.now().strftime('%Y%m%d')
83
+ else:
84
+ date = date.strftime('%Y%m%d')
85
+ return os.path.join(self.save_dir, f"news_{date}.json")
86
+
87
+ def fetch_and_save(self):
88
+ """获取新闻并保存到JSON文件,避免重复内容"""
89
+ try:
90
+ # 获取当前时间
91
+ now = datetime.now()
92
+
93
+ # 调用AKShare API获取财联社电报数据
94
+ logger.info("开始获取财联社电报数据")
95
+ stock_info_global_cls_df = ak.stock_info_global_cls(symbol="全部")
96
+
97
+ if stock_info_global_cls_df.empty:
98
+ logger.warning("获取的财联社电报数据为空")
99
+ return False
100
+
101
+ # 打印DataFrame的信息和类型,帮助调试
102
+ logger.info(f"获取的数据形状: {stock_info_global_cls_df.shape}")
103
+ logger.info(f"数据列: {stock_info_global_cls_df.columns.tolist()}")
104
+ logger.info(f"数据类型: \n{stock_info_global_cls_df.dtypes}")
105
+
106
+ # 计数器
107
+ total_count = 0
108
+ new_count = 0
109
+
110
+ # 转换为列表字典格式并添加哈希值
111
+ news_list = []
112
+ for _, row in stock_info_global_cls_df.iterrows():
113
+ total_count += 1
114
+
115
+ # 安全获取内容,确保为字符串
116
+ content = str(row.get("内容", ""))
117
+
118
+ # 计算内容哈希值
119
+ content_hash = self._calculate_hash(content)
120
+
121
+ # 检查是否已存在相同内容的新闻
122
+ if content_hash in self.news_hashes:
123
+ continue # 跳过已存在的新闻
124
+
125
+ # 添加新的哈希值到集合
126
+ self.news_hashes.add(content_hash)
127
+ new_count += 1
128
+
129
+ # 安全获取日期和时间,确保为字符串格式
130
+ pub_date = row.get("发布日期", "")
131
+ if isinstance(pub_date, (datetime, date)):
132
+ pub_date = pub_date.isoformat()
133
+ else:
134
+ pub_date = str(pub_date)
135
+
136
+ pub_time = row.get("发布时间", "")
137
+ if isinstance(pub_time, (datetime, date)):
138
+ pub_time = pub_time.isoformat()
139
+ else:
140
+ pub_time = str(pub_time)
141
+
142
+ # 创建新闻项并添加哈希值
143
+ news_item = {
144
+ "title": str(row.get("标题", "")),
145
+ "content": content,
146
+ "date": pub_date,
147
+ "time": pub_time,
148
+ "datetime": f"{pub_date} {pub_time}",
149
+ "fetch_time": now.strftime('%Y-%m-%d %H:%M:%S'),
150
+ "hash": content_hash # 保存哈希值以便后续使用
151
+ }
152
+ news_list.append(news_item)
153
+
154
+ # 如果没有新的新闻,直接返回
155
+ if not news_list:
156
+ logger.info(f"没有新的新闻数据需要保存 (共检查 {total_count} 条)")
157
+ return True
158
+
159
+ # 获取文件名
160
+ filename = self.get_news_filename()
161
+
162
+ # 如果文件已存在,则合并新旧数据
163
+ if os.path.exists(filename):
164
+ with open(filename, 'r', encoding='utf-8') as f:
165
+ try:
166
+ existing_data = json.load(f)
167
+ # 合并数据,已经确保news_list中的内容都是新的
168
+ merged_news = existing_data + news_list
169
+ # 按时间排序
170
+ merged_news.sort(key=lambda x: x['datetime'], reverse=True)
171
+ except json.JSONDecodeError:
172
+ logger.warning(f"文件 {filename} 格式错误,使用新数据替换")
173
+ merged_news = sorted(news_list, key=lambda x: x['datetime'], reverse=True)
174
+ else:
175
+ # 如果文件不存在,直接使用新数据
176
+ merged_news = sorted(news_list, key=lambda x: x['datetime'], reverse=True)
177
+
178
+ # 保存合并后的数据,使用自定义编码器处理日期
179
+ with open(filename, 'w', encoding='utf-8') as f:
180
+ json.dump(merged_news, f, ensure_ascii=False, indent=2, cls=DateEncoder)
181
+
182
+ logger.info(f"成功保存 {new_count} 条新闻数据 (共检查 {total_count} 条,过滤重复 {total_count - new_count} 条)")
183
+ self.last_fetch_time = now
184
+ return True
185
+
186
+ except Exception as e:
187
+ logger.error(f"获取或保存新闻数据时出错: {str(e)}")
188
+ import traceback
189
+ logger.error(traceback.format_exc()) # 打印完整的堆栈跟踪,便于调试
190
+ return False
191
+
192
+ def get_latest_news(self, days=1, limit=50):
193
+ """获取最近几天的新闻数据,并去除重复项"""
194
+ news_data = []
195
+ today = datetime.now()
196
+ # 记录已处理的日期,便于日志
197
+ processed_dates = []
198
+
199
+ # 获取指定天数内的所有新闻
200
+ for i in range(days):
201
+ date = today - timedelta(days=i)
202
+ date_str = date.strftime('%Y%m%d')
203
+ filename = self.get_news_filename(date)
204
+
205
+ if os.path.exists(filename):
206
+ try:
207
+ with open(filename, 'r', encoding='utf-8') as f:
208
+ data = json.load(f)
209
+ news_data.extend(data)
210
+ processed_dates.append(date_str)
211
+ logger.info(f"已加载 {date_str} 新闻数据 {len(data)} 条")
212
+ except Exception as e:
213
+ logger.error(f"读取文件 {filename} 时出错: {str(e)}")
214
+ else:
215
+ logger.warning(f"日期 {date_str} 的新闻文件不存在: {filename}")
216
+
217
+ # 排序前记录总数
218
+ total_before_sort = len(news_data)
219
+
220
+ # 去除重复项
221
+ # 使用内容哈希或已有的哈希字段作为唯一标识
222
+ unique_news = {}
223
+ duplicate_count = 0
224
+
225
+ for item in news_data:
226
+ # 优先使用已有的哈希值,如果没有则计算内容哈希
227
+ item_hash = item.get('hash')
228
+ if not item_hash and 'content' in item:
229
+ item_hash = self._calculate_hash(item['content'])
230
+
231
+ # 如果是新的哈希值,则添加到结果中
232
+ if item_hash and item_hash not in unique_news:
233
+ unique_news[item_hash] = item
234
+ else:
235
+ duplicate_count += 1
236
+
237
+ # 转换回列表并按时间排序
238
+ deduplicated_news = list(unique_news.values())
239
+ deduplicated_news.sort(key=lambda x: x.get('datetime', ''), reverse=True)
240
+
241
+ # 限制返回条数
242
+ result = deduplicated_news[:limit]
243
+
244
+ logger.info(f"获取最近 {days} 天新闻(处理日期:{','.join(processed_dates)}), "
245
+ f"共 {total_before_sort} 条, 去重后 {len(deduplicated_news)} 条, "
246
+ f"移除重复 {duplicate_count} 条, 返回最新 {len(result)} 条")
247
+
248
+ return result
249
+
250
+ # 单例模式的新闻获取器
251
+ news_fetcher = NewsFetcher()
252
+
253
+ def fetch_news_task():
254
+ """执行新闻获取任务"""
255
+ logger.info("开始执行新闻获取任务")
256
+ news_fetcher.fetch_and_save()
257
+ logger.info("新闻获取任务完成")
258
+
259
+ def start_news_scheduler():
260
+ """启动新闻获取定时任务"""
261
+ import threading
262
+ import time
263
+
264
+ def _run_scheduler():
265
+ while True:
266
+ try:
267
+ fetch_news_task()
268
+ # 等待10分钟
269
+ time.sleep(600)
270
+ except Exception as e:
271
+ logger.error(f"定时任务执行出错: {str(e)}")
272
+ time.sleep(60) # 出错后等待1分钟再试
273
+
274
+ # 创建并启动定时任务线程
275
+ scheduler_thread = threading.Thread(target=_run_scheduler)
276
+ scheduler_thread.daemon = True
277
+ scheduler_thread.start()
278
+ logger.info("新闻获取定时任务已启动")
279
+
280
+ # 初始获取一次数据
281
+ if __name__ == "__main__":
282
+ fetch_news_task()
app/analysis/risk_monitor.py ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 智能分析系统(股票) - 股票市场数据分析系统
4
+ 开发者:熊猫大侠
5
+ 版本:v2.1.0
6
+ 许可证:MIT License
7
+ """
8
+ # risk_monitor.py
9
+ import pandas as pd
10
+ import numpy as np
11
+ from datetime import datetime, timedelta
12
+
13
+ class RiskMonitor:
14
+ def __init__(self, analyzer):
15
+ self.analyzer = analyzer
16
+
17
+ def analyze_stock_risk(self, stock_code, market_type='A'):
18
+ """分析单只股票的风险"""
19
+ try:
20
+ # 获取股票数据和技术指标
21
+ df = self.analyzer.get_stock_data(stock_code, market_type)
22
+ df = self.analyzer.calculate_indicators(df)
23
+
24
+ # 计算各类风险指标
25
+ volatility_risk = self._analyze_volatility_risk(df)
26
+ trend_risk = self._analyze_trend_risk(df)
27
+ reversal_risk = self._analyze_reversal_risk(df)
28
+ volume_risk = self._analyze_volume_risk(df)
29
+
30
+ # 综合评估总体风险
31
+ total_risk_score = (
32
+ volatility_risk['score'] * 0.3 +
33
+ trend_risk['score'] * 0.3 +
34
+ reversal_risk['score'] * 0.25 +
35
+ volume_risk['score'] * 0.15
36
+ )
37
+
38
+ # 确定风险等级
39
+ if total_risk_score >= 80:
40
+ risk_level = "极高"
41
+ elif total_risk_score >= 60:
42
+ risk_level = "高"
43
+ elif total_risk_score >= 40:
44
+ risk_level = "中等"
45
+ elif total_risk_score >= 20:
46
+ risk_level = "低"
47
+ else:
48
+ risk_level = "极低"
49
+
50
+ # 生成风险警报
51
+ alerts = []
52
+
53
+ if volatility_risk['score'] >= 70:
54
+ alerts.append({
55
+ "type": "volatility",
56
+ "level": "高",
57
+ "message": f"波动率风险较高 ({volatility_risk['value']:.2f}%),可能面临大幅波动"
58
+ })
59
+
60
+ if trend_risk['score'] >= 70:
61
+ alerts.append({
62
+ "type": "trend",
63
+ "level": "高",
64
+ "message": f"趋势风险较高,当前处于{trend_risk['trend']}趋势,可能面临加速下跌"
65
+ })
66
+
67
+ if reversal_risk['score'] >= 70:
68
+ alerts.append({
69
+ "type": "reversal",
70
+ "level": "高",
71
+ "message": f"趋势反转风险较高,技术指标显示可能{reversal_risk['direction']}反转"
72
+ })
73
+
74
+ if volume_risk['score'] >= 70:
75
+ alerts.append({
76
+ "type": "volume",
77
+ "level": "高",
78
+ "message": f"成交量异常,{volume_risk['pattern']},可能预示价格波动"
79
+ })
80
+
81
+ return {
82
+ "total_risk_score": total_risk_score,
83
+ "risk_level": risk_level,
84
+ "volatility_risk": volatility_risk,
85
+ "trend_risk": trend_risk,
86
+ "reversal_risk": reversal_risk,
87
+ "volume_risk": volume_risk,
88
+ "alerts": alerts
89
+ }
90
+
91
+ except Exception as e:
92
+ print(f"分析股票风险出错: {str(e)}")
93
+ return {
94
+ "error": f"分析风险时出错: {str(e)}"
95
+ }
96
+
97
+ def _analyze_volatility_risk(self, df):
98
+ """分析波动率风险"""
99
+ # 计算近期波动率
100
+ recent_volatility = df.iloc[-1]['Volatility']
101
+
102
+ # 计算波动率变化
103
+ avg_volatility = df['Volatility'].mean()
104
+ volatility_change = recent_volatility / avg_volatility - 1
105
+
106
+ # 评估风险分数
107
+ if recent_volatility > 5 and volatility_change > 0.5:
108
+ score = 90 # 极高风险
109
+ elif recent_volatility > 4 and volatility_change > 0.3:
110
+ score = 75 # 高风险
111
+ elif recent_volatility > 3 and volatility_change > 0.1:
112
+ score = 60 # 中高风险
113
+ elif recent_volatility > 2:
114
+ score = 40 # 中等风险
115
+ elif recent_volatility > 1:
116
+ score = 20 # 低风险
117
+ else:
118
+ score = 0 # 极低风险
119
+
120
+ return {
121
+ "score": score,
122
+ "value": recent_volatility,
123
+ "change": volatility_change * 100,
124
+ "risk_level": "高" if score >= 60 else "中" if score >= 30 else "低"
125
+ }
126
+
127
+ def _analyze_trend_risk(self, df):
128
+ """分析趋势风险"""
129
+ # 获取均线数据
130
+ ma5 = df.iloc[-1]['MA5']
131
+ ma20 = df.iloc[-1]['MA20']
132
+ ma60 = df.iloc[-1]['MA60']
133
+
134
+ # 判断当前趋势
135
+ if ma5 < ma20 < ma60:
136
+ trend = "下降"
137
+
138
+ # 判断下跌加速程度
139
+ ma5_ma20_gap = (ma20 - ma5) / ma20 * 100
140
+
141
+ if ma5_ma20_gap > 5:
142
+ score = 90 # 极高风险
143
+ elif ma5_ma20_gap > 3:
144
+ score = 75 # 高风险
145
+ elif ma5_ma20_gap > 1:
146
+ score = 60 # 中高风险
147
+ else:
148
+ score = 50 # 中等风险
149
+
150
+ elif ma5 > ma20 > ma60:
151
+ trend = "上升"
152
+ score = 20 # 低风险
153
+ else:
154
+ trend = "盘整"
155
+ score = 40 # 中等风险
156
+
157
+ return {
158
+ "score": score,
159
+ "trend": trend,
160
+ "risk_level": "高" if score >= 60 else "中" if score >= 30 else "低"
161
+ }
162
+
163
+ def _analyze_reversal_risk(self, df):
164
+ """分析趋势反转风险"""
165
+ # 获取最新指标
166
+ rsi = df.iloc[-1]['RSI']
167
+ macd = df.iloc[-1]['MACD']
168
+ signal = df.iloc[-1]['Signal']
169
+ price = df.iloc[-1]['close']
170
+ ma20 = df.iloc[-1]['MA20']
171
+
172
+ # 判断潜在趋势反转信号
173
+ reversal_signals = 0
174
+
175
+ # RSI超买/超卖
176
+ if rsi > 75:
177
+ reversal_signals += 1
178
+ direction = "向下"
179
+ elif rsi < 25:
180
+ reversal_signals += 1
181
+ direction = "向上"
182
+ else:
183
+ direction = "无明确方向"
184
+
185
+ # MACD死叉/金叉
186
+ if macd > signal and df.iloc[-2]['MACD'] <= df.iloc[-2]['Signal']:
187
+ reversal_signals += 1
188
+ direction = "向上"
189
+ elif macd < signal and df.iloc[-2]['MACD'] >= df.iloc[-2]['Signal']:
190
+ reversal_signals += 1
191
+ direction = "向下"
192
+
193
+ # 价格与均线关系
194
+ if price > ma20 * 1.1:
195
+ reversal_signals += 1
196
+ direction = "向下"
197
+ elif price < ma20 * 0.9:
198
+ reversal_signals += 1
199
+ direction = "向上"
200
+
201
+ # 评估风险分数
202
+ if reversal_signals >= 3:
203
+ score = 90 # 极高风险
204
+ elif reversal_signals == 2:
205
+ score = 70 # 高风险
206
+ elif reversal_signals == 1:
207
+ score = 40 # 中等风险
208
+ else:
209
+ score = 10 # 低风险
210
+
211
+ return {
212
+ "score": score,
213
+ "reversal_signals": reversal_signals,
214
+ "direction": direction,
215
+ "risk_level": "高" if score >= 60 else "中" if score >= 30 else "低"
216
+ }
217
+
218
+ def _analyze_volume_risk(self, df):
219
+ """分析成交量风险"""
220
+ # 计算成交量变化
221
+ recent_volume = df.iloc[-1]['volume']
222
+ avg_volume = df['volume'].rolling(window=20).mean().iloc[-1]
223
+ volume_ratio = recent_volume / avg_volume
224
+
225
+ # 判断成交量模式
226
+ if volume_ratio > 3:
227
+ pattern = "成交量暴增"
228
+ score = 90 # 极高风险
229
+ elif volume_ratio > 2:
230
+ pattern = "成交量显著放大"
231
+ score = 70 # 高风险
232
+ elif volume_ratio > 1.5:
233
+ pattern = "成交量温和放大"
234
+ score = 50 # 中等风险
235
+ elif volume_ratio < 0.5:
236
+ pattern = "成交量萎缩"
237
+ score = 40 # 中低风险
238
+ else:
239
+ pattern = "成交量正常"
240
+ score = 20 # 低风险
241
+
242
+ # 价格与成交量背离分析
243
+ price_change = (df.iloc[-1]['close'] - df.iloc[-5]['close']) / df.iloc[-5]['close']
244
+ volume_change = (recent_volume - df.iloc[-5]['volume']) / df.iloc[-5]['volume']
245
+
246
+ if price_change > 0.05 and volume_change < -0.3:
247
+ pattern = "价量背离(价格上涨但量能萎缩)"
248
+ score = max(score, 80) # 提高风险评分
249
+ elif price_change < -0.05 and volume_change < -0.3:
250
+ pattern = "价量同向(价格下跌且量能萎缩)"
251
+ score = max(score, 70) # 提高风险评分
252
+ elif price_change < -0.05 and volume_change > 0.5:
253
+ pattern = "价量同向(价格下跌且量能放大)"
254
+ score = max(score, 85) # 提高风险评分
255
+
256
+ return {
257
+ "score": score,
258
+ "volume_ratio": volume_ratio,
259
+ "pattern": pattern,
260
+ "risk_level": "高" if score >= 60 else "中" if score >= 30 else "低"
261
+ }
262
+
263
+ def analyze_portfolio_risk(self, portfolio):
264
+ """分析投资组合整体风险"""
265
+ try:
266
+ if not portfolio or len(portfolio) == 0:
267
+ return {"error": "投资组合为空"}
268
+
269
+ # 分析每只股票的风险
270
+ stock_risks = {}
271
+ total_weight = 0
272
+ weighted_risk_score = 0
273
+
274
+ for stock in portfolio:
275
+ stock_code = stock.get('stock_code')
276
+ weight = stock.get('weight', 1)
277
+ market_type = stock.get('market_type', 'A')
278
+
279
+ if not stock_code:
280
+ continue
281
+
282
+ # 分析股票风险
283
+ risk = self.analyze_stock_risk(stock_code, market_type)
284
+ stock_risks[stock_code] = risk
285
+
286
+ # 计算加权风险分数
287
+ total_weight += weight
288
+ weighted_risk_score += risk.get('total_risk_score', 50) * weight
289
+
290
+ # 计算组��总风险分数
291
+ if total_weight > 0:
292
+ portfolio_risk_score = weighted_risk_score / total_weight
293
+ else:
294
+ portfolio_risk_score = 0
295
+
296
+ # 确定风险等级
297
+ if portfolio_risk_score >= 80:
298
+ risk_level = "极高"
299
+ elif portfolio_risk_score >= 60:
300
+ risk_level = "高"
301
+ elif portfolio_risk_score >= 40:
302
+ risk_level = "中等"
303
+ elif portfolio_risk_score >= 20:
304
+ risk_level = "低"
305
+ else:
306
+ risk_level = "极低"
307
+
308
+ # 收集高风险股票
309
+ high_risk_stocks = [
310
+ {
311
+ "stock_code": code,
312
+ "risk_score": risk.get('total_risk_score', 0),
313
+ "risk_level": risk.get('risk_level', '未知')
314
+ }
315
+ for code, risk in stock_risks.items()
316
+ if risk.get('total_risk_score', 0) >= 60
317
+ ]
318
+
319
+ # 收集所有风险警报
320
+ all_alerts = []
321
+ for code, risk in stock_risks.items():
322
+ for alert in risk.get('alerts', []):
323
+ all_alerts.append({
324
+ "stock_code": code,
325
+ **alert
326
+ })
327
+
328
+ # 分析风险集中度
329
+ risk_concentration = self._analyze_risk_concentration(portfolio, stock_risks)
330
+
331
+ return {
332
+ "portfolio_risk_score": portfolio_risk_score,
333
+ "risk_level": risk_level,
334
+ "high_risk_stocks": high_risk_stocks,
335
+ "alerts": all_alerts,
336
+ "risk_concentration": risk_concentration,
337
+ "stock_risks": stock_risks
338
+ }
339
+
340
+ except Exception as e:
341
+ print(f"分析投资组合风险出错: {str(e)}")
342
+ return {
343
+ "error": f"分析投资组合风险时出错: {str(e)}"
344
+ }
345
+
346
+ def _analyze_risk_concentration(self, portfolio, stock_risks):
347
+ """分析风险集中度"""
348
+ # 分析行业集中度
349
+ industries = {}
350
+ for stock in portfolio:
351
+ stock_code = stock.get('stock_code')
352
+ stock_info = self.analyzer.get_stock_info(stock_code)
353
+ industry = stock_info.get('行业', '未知')
354
+ weight = stock.get('weight', 1)
355
+
356
+ if industry in industries:
357
+ industries[industry] += weight
358
+ else:
359
+ industries[industry] = weight
360
+
361
+ # 找出权重最大的行业
362
+ max_industry = max(industries.items(), key=lambda x: x[1]) if industries else ('未知', 0)
363
+
364
+ # 计算高风险股票总权重
365
+ high_risk_weight = 0
366
+ for stock in portfolio:
367
+ stock_code = stock.get('stock_code')
368
+ if stock_code in stock_risks and stock_risks[stock_code].get('total_risk_score', 0) >= 60:
369
+ high_risk_weight += stock.get('weight', 1)
370
+
371
+ return {
372
+ "max_industry": max_industry[0],
373
+ "max_industry_weight": max_industry[1],
374
+ "high_risk_weight": high_risk_weight
375
+ }
app/analysis/scenario_predictor.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 智能分析系统(股票) - 股票市场数据分析系统
4
+ 开发者:熊猫大侠
5
+ 版本:v2.1.0
6
+ 许可证:MIT License
7
+ """
8
+ # scenario_predictor.py
9
+ import os
10
+ import numpy as np
11
+ import pandas as pd
12
+ from datetime import datetime, timedelta
13
+ import openai
14
+ from openai import OpenAI
15
+ import logging
16
+ from logging.handlers import RotatingFileHandler
17
+ """
18
+
19
+ """
20
+
21
+ # 配置日志
22
+ logging.basicConfig(level=logging.INFO,
23
+ format='%(asctime)s - %(levelname)s - %(message)s')
24
+
25
+ class ScenarioPredictor:
26
+ def __init__(self, analyzer, openai_api_key=None, openai_model=None):
27
+ self.analyzer = analyzer
28
+ self.openai_api_key = os.getenv('OPENAI_API_KEY', os.getenv('OPENAI_API_KEY'))
29
+ self.openai_api_url = os.getenv('OPENAI_API_URL', 'https://api.openai.com/v1')
30
+ self.openai_model = os.getenv('OPENAI_API_MODEL', 'gemini-2.0-pro-exp-02-05')
31
+ self.client = OpenAI(
32
+ api_key=self.openai_api_key,
33
+ base_url=self.openai_api_url
34
+ )
35
+ # logging.info(f"scenario_predictor初始化完成:「{self.openai_api_key} {self.openai_api_url} {self.openai_model}」")
36
+
37
+ def generate_scenarios(self, stock_code, market_type='A', days=60):
38
+ """生成乐观、中性、悲观三种市场情景预测"""
39
+ try:
40
+ # 获取股票数据和技术指标
41
+ df = self.analyzer.get_stock_data(stock_code, market_type)
42
+ df = self.analyzer.calculate_indicators(df)
43
+
44
+ # 获取股票信息
45
+ stock_info = self.analyzer.get_stock_info(stock_code)
46
+
47
+ # 计算基础数据
48
+ current_price = df.iloc[-1]['close']
49
+ avg_volatility = df['Volatility'].mean()
50
+
51
+ # 根据历史波动率计算情景
52
+ scenarios = self._calculate_scenarios(df, days)
53
+
54
+ # 使用AI生成各情景的分析
55
+ if self.openai_api_key:
56
+ ai_analysis = self._generate_ai_analysis(stock_code, stock_info, df, scenarios)
57
+ scenarios.update(ai_analysis)
58
+
59
+ # logging.info(f"返回前的情景预测:{scenarios}")
60
+ return scenarios
61
+ except Exception as e:
62
+ # logging.info(f"生成情景预测出错: {str(e)}")
63
+ return {}
64
+
65
+ def _calculate_scenarios(self, df, days):
66
+ """基于历史数据计算三种情景的价格预测"""
67
+ current_price = df.iloc[-1]['close']
68
+
69
+ # 计算历史波动率和移动均线
70
+ volatility = df['Volatility'].mean() / 100 # 转换为小数
71
+ daily_volatility = volatility / np.sqrt(252) # 转换为日波动率
72
+ ma20 = df.iloc[-1]['MA20']
73
+ ma60 = df.iloc[-1]['MA60']
74
+
75
+ # 计算乐观情景(上涨至压力位或突破)
76
+ optimistic_return = 0.15 # 15%上涨
77
+ if df.iloc[-1]['BB_upper'] > current_price:
78
+ optimistic_target = df.iloc[-1]['BB_upper'] * 1.05 # 突破上轨5%
79
+ else:
80
+ optimistic_target = current_price * (1 + optimistic_return)
81
+
82
+ # 计算中性情景(震荡,围绕当前价格或20日均线波动)
83
+ neutral_target = (current_price + ma20) / 2
84
+
85
+ # 计算悲观情景(下跌至支撑位或跌破)
86
+ pessimistic_return = -0.12 # 12%下跌
87
+ if df.iloc[-1]['BB_lower'] < current_price:
88
+ pessimistic_target = df.iloc[-1]['BB_lower'] * 0.95 # 跌破下轨5%
89
+ else:
90
+ pessimistic_target = current_price * (1 + pessimistic_return)
91
+
92
+ # 计算预期时间
93
+ time_periods = np.arange(1, days + 1)
94
+
95
+ # 生成乐观路径
96
+ opt_path = [current_price]
97
+ for _ in range(days):
98
+ daily_return = (optimistic_target / current_price) ** (1 / days) - 1
99
+ random_component = np.random.normal(0, daily_volatility)
100
+ new_price = opt_path[-1] * (1 + daily_return + random_component / 2)
101
+ opt_path.append(new_price)
102
+
103
+ # 生成中性路径
104
+ neu_path = [current_price]
105
+ for _ in range(days):
106
+ daily_return = (neutral_target / current_price) ** (1 / days) - 1
107
+ random_component = np.random.normal(0, daily_volatility)
108
+ new_price = neu_path[-1] * (1 + daily_return + random_component)
109
+ neu_path.append(new_price)
110
+
111
+ # 生成悲观路径
112
+ pes_path = [current_price]
113
+ for _ in range(days):
114
+ daily_return = (pessimistic_target / current_price) ** (1 / days) - 1
115
+ random_component = np.random.normal(0, daily_volatility)
116
+ new_price = pes_path[-1] * (1 + daily_return + random_component / 2)
117
+ pes_path.append(new_price)
118
+
119
+ # 生成日期序列
120
+ start_date = datetime.now()
121
+ dates = [(start_date + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(days + 1)]
122
+
123
+ # 组织结果
124
+ return {
125
+ 'current_price': current_price,
126
+ 'optimistic': {
127
+ 'target_price': optimistic_target,
128
+ 'change_percent': (optimistic_target / current_price - 1) * 100,
129
+ 'path': dict(zip(dates, opt_path))
130
+ },
131
+ 'neutral': {
132
+ 'target_price': neutral_target,
133
+ 'change_percent': (neutral_target / current_price - 1) * 100,
134
+ 'path': dict(zip(dates, neu_path))
135
+ },
136
+ 'pessimistic': {
137
+ 'target_price': pessimistic_target,
138
+ 'change_percent': (pessimistic_target / current_price - 1) * 100,
139
+ 'path': dict(zip(dates, pes_path))
140
+ }
141
+ }
142
+
143
+ def _generate_ai_analysis(self, stock_code, stock_info, df, scenarios):
144
+ """使用AI生成各情景的分析说明,包含风险和机会因素"""
145
+ try:
146
+
147
+ # 提取关键数据
148
+ current_price = df.iloc[-1]['close']
149
+ ma5 = df.iloc[-1]['MA5']
150
+ ma20 = df.iloc[-1]['MA20']
151
+ ma60 = df.iloc[-1]['MA60']
152
+ rsi = df.iloc[-1]['RSI']
153
+ macd = df.iloc[-1]['MACD']
154
+ signal = df.iloc[-1]['Signal']
155
+
156
+ # 构建提示词,增加对风险和机会因素的要求
157
+ prompt = f"""分析股票{stock_code}({stock_info.get('股票名称', '未知')})的三种市场情景:
158
+
159
+ 1. 当前数据:
160
+ - 当前价格: {current_price}
161
+ - 均线: MA5={ma5}, MA20={ma20}, MA60={ma60}
162
+ - RSI: {rsi}
163
+ - MACD: {macd}, Signal: {signal}
164
+
165
+ 2. 预测目标价:
166
+ - 乐观情景: {scenarios['optimistic']['target_price']:.2f} ({scenarios['optimistic']['change_percent']:.2f}%)
167
+ - 中性情景: {scenarios['neutral']['target_price']:.2f} ({scenarios['neutral']['change_percent']:.2f}%)
168
+ - 悲观情景: {scenarios['pessimistic']['target_price']:.2f} ({scenarios['pessimistic']['change_percent']:.2f}%)
169
+
170
+ 请提供以下内容,格式为JSON:
171
+ {{
172
+ "optimistic_analysis": "乐观情景分析(100字以内)...",
173
+ "neutral_analysis": "中性情景分析(100字以内)...",
174
+ "pessimistic_analysis": "悲观情景分析(100字以内)...",
175
+ "risk_factors": ["主要风险因素1", "主要风险因素2", "主要风险因素3", "主要风险因素4", "主要风险因素5"],
176
+ "opportunity_factors": ["主要机会因素1", "主要机会因素2", "主要机会因素3", "主要机会因素4", "主要机会因素5"]
177
+ }}
178
+
179
+ 风险和机会因素应该具体说明,每条5-15个字,简明扼要。
180
+ """
181
+
182
+ # 调用AI API
183
+ response = self.client.chat.completions.create(
184
+ model=self.openai_model,
185
+ messages=[
186
+ {"role": "system", "content": "你是专业的股票分析师,擅长技术分析和情景预测。"},
187
+ {"role": "user", "content": prompt}
188
+ ],
189
+ temperature=0.7
190
+ )
191
+
192
+ # 解析AI回复
193
+ import json
194
+ try:
195
+ analysis = json.loads(response.choices[0].message.content)
196
+ # 确保返回的JSON包含所需的所有字段
197
+ if "risk_factors" not in analysis:
198
+ analysis["risk_factors"] = self._get_default_risk_factors()
199
+ if "opportunity_factors" not in analysis:
200
+ analysis["opportunity_factors"] = self._get_default_opportunity_factors()
201
+ return analysis
202
+ except:
203
+ # 如果解析失败,尝试从文本中提取JSON
204
+ import re
205
+ json_match = re.search(r'```json\s*([\s\S]*?)\s*```', response.choices[0].message.content)
206
+ if json_match:
207
+ json_str = json_match.group(1)
208
+ try:
209
+ analysis = json.loads(json_str)
210
+ # 确保包含所需的所有字段
211
+ if "risk_factors" not in analysis:
212
+ analysis["risk_factors"] = self._get_default_risk_factors()
213
+ if "opportunity_factors" not in analysis:
214
+ analysis["opportunity_factors"] = self._get_default_opportunity_factors()
215
+ return analysis
216
+ except:
217
+ # JSON解析失败时返回默认值
218
+ return self._get_default_analysis()
219
+ else:
220
+ # 无法提取JSON时返回默认值
221
+ return self._get_default_analysis()
222
+ except Exception as e:
223
+ print(f"生成AI分析出错: {str(e)}")
224
+ return self._get_default_analysis()
225
+
226
+ def _get_default_risk_factors(self):
227
+ """返回默认的风险因素"""
228
+ return [
229
+ "宏观经济下行压力增大",
230
+ "行业政策收紧可能性",
231
+ "原材料价格上涨",
232
+ "市场竞争加剧",
233
+ "技术迭代风险"
234
+ ]
235
+
236
+ def _get_default_opportunity_factors(self):
237
+ """���回默认的机会因素"""
238
+ return [
239
+ "行业景气度持续向好",
240
+ "公司新产品上市",
241
+ "成本控制措施见效",
242
+ "产能扩张计划",
243
+ "国际市场开拓机会"
244
+ ]
245
+
246
+ def _get_default_analysis(self):
247
+ """返回默认的分析结果(包含风险和机会因素)"""
248
+ return {
249
+ "optimistic_analysis": "乐观情景分析暂无",
250
+ "neutral_analysis": "中性情景分析暂无",
251
+ "pessimistic_analysis": "悲观情景分析暂无",
252
+ "risk_factors": self._get_default_risk_factors(),
253
+ "opportunity_factors": self._get_default_opportunity_factors()
254
+ }
app/analysis/stock_analyzer.py ADDED
@@ -0,0 +1,1693 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 智能分析系统(股票) - 股票市场数据分析系统
4
+ 修改:熊猫大侠
5
+ 版本:v2.1.0
6
+ 许可证:MIT License
7
+ """
8
+ # stock_analyzer.py
9
+ import time
10
+ import traceback
11
+ import pandas as pd
12
+ import numpy as np
13
+ from datetime import datetime, timedelta
14
+ import os
15
+ import requests
16
+ from typing import Dict, List, Optional, Tuple
17
+ from dotenv import load_dotenv
18
+ import logging
19
+ import math
20
+ import json
21
+ import threading
22
+ from urllib.parse import urlparse
23
+ from openai import OpenAI
24
+
25
+ # 线程局部存储
26
+ thread_local = threading.local()
27
+
28
+
29
+ class StockAnalyzer:
30
+ """
31
+ 股票分析器 - 原有API保持不变,内部实现增强
32
+ """
33
+
34
+ def __init__(self, initial_cash=1000000):
35
+ # 设置日志
36
+ self.logger = logging.getLogger(__name__)
37
+
38
+ # 加载环境变量
39
+ load_dotenv()
40
+
41
+ # 设置 OpenAI API (原 Gemini API)
42
+ self.openai_api_key = os.getenv('OPENAI_API_KEY', os.getenv('OPENAI_API_KEY'))
43
+ self.openai_api_url = os.getenv('OPENAI_API_URL', 'https://api.openai.com/v1')
44
+ self.openai_model = os.getenv('OPENAI_API_MODEL', 'gemini-2.0-pro-exp-02-05')
45
+ self.function_call_model = os.getenv('FUNCTION_CALL_MODEL','gpt-4o')
46
+ self.news_model = os.getenv('NEWS_MODEL')
47
+
48
+ self.client = OpenAI(
49
+ api_key=self.openai_api_key,
50
+ base_url=self.openai_api_url
51
+ )
52
+
53
+ # 配置参数
54
+ self.params = {
55
+ 'ma_periods': {'short': 5, 'medium': 20, 'long': 60},
56
+ 'rsi_period': 14,
57
+ 'bollinger_period': 20,
58
+ 'bollinger_std': 2,
59
+ 'volume_ma_period': 20,
60
+ 'atr_period': 14
61
+ }
62
+
63
+ # 添加缓存初始化
64
+ self.data_cache = {}
65
+
66
+ # JSON匹配标志
67
+ self.json_match_flag = True
68
+ def get_stock_data(self, stock_code, market_type='A', start_date=None, end_date=None):
69
+ """获取股票数据 - 增强版,具备更强的容错能力"""
70
+ import akshare as ak
71
+
72
+ self.logger.info(f"开始获取股票 {stock_code} 数据,市场类型: {market_type}")
73
+
74
+ cache_key = f"{stock_code}_{market_type}_{start_date}_{end_date}_price"
75
+ if cache_key in self.data_cache:
76
+ return self.data_cache[cache_key].copy()
77
+
78
+ if start_date is None:
79
+ start_date = (datetime.now() - timedelta(days=365)).strftime('%Y%m%d')
80
+ if end_date is None:
81
+ end_date = datetime.now().strftime('%Y%m%d')
82
+
83
+ try:
84
+ df = None
85
+ if market_type == 'A':
86
+ df = ak.stock_zh_a_hist(symbol=stock_code, start_date=start_date, end_date=end_date, adjust="qfq")
87
+ elif market_type == 'HK':
88
+ df = ak.stock_hk_daily(symbol=stock_code, adjust="qfq")
89
+ elif market_type == 'US':
90
+ df = ak.stock_us_hist(symbol=stock_code, start_date=start_date, end_date=end_date, adjust="qfq")
91
+ else:
92
+ raise ValueError(f"不支持的市场类型: {market_type}")
93
+
94
+ if df is None or df.empty:
95
+ raise ValueError("akshare返回了空的DataFrame")
96
+
97
+ # 1. 标准化列名
98
+ rename_map = {
99
+ "日期": "date", "开盘": "open", "收盘": "close", "最高": "high",
100
+ "最低": "low", "成交量": "volume", "成交额": "amount",
101
+ "trade_date": "date" # 兼容不同命名
102
+ }
103
+ df.rename(columns=rename_map, inplace=True)
104
+
105
+ # 2. 验证关键列是否存在
106
+ essential_columns = ['date', 'open', 'close', 'high', 'low', 'volume']
107
+ missing_cols = [col for col in essential_columns if col not in df.columns]
108
+ if missing_cols:
109
+ raise ValueError(f"数据中缺少关键列: {', '.join(missing_cols)}. 可用列: {df.columns.tolist()}")
110
+
111
+ # 3. 数据清洗和类型转换
112
+ df['date'] = pd.to_datetime(df['date'], errors='coerce')
113
+ df.dropna(subset=['date'], inplace=True)
114
+
115
+ for col in ['open', 'close', 'high', 'low', 'volume']:
116
+ df[col] = pd.to_numeric(df[col], errors='coerce')
117
+
118
+ df.dropna(subset=essential_columns, inplace=True)
119
+
120
+ if df.empty:
121
+ raise ValueError("数据清洗后DataFrame为空")
122
+
123
+ # 4. 排序并返回
124
+ result = df.sort_values('date').reset_index(drop=True)
125
+ self.data_cache[cache_key] = result.copy()
126
+
127
+ return result
128
+
129
+ except Exception as e:
130
+ self.logger.error(f"获取股票 {stock_code} 数据失败: {e}")
131
+ # 返回一个空的DataFrame以避免下游崩溃
132
+ return pd.DataFrame()
133
+
134
+ def get_north_flow_history(self, stock_code, start_date=None, end_date=None):
135
+ """获取单个股票的北向资金历史持股数据"""
136
+ try:
137
+ import akshare as ak
138
+
139
+ # 获取历史持股数据
140
+ if start_date is None and end_date is None:
141
+ # 默认获取近90天数据
142
+ north_hist_data = ak.stock_hsgt_hist_em(symbol=stock_code)
143
+ else:
144
+ north_hist_data = ak.stock_hsgt_hist_em(symbol=stock_code, start_date=start_date, end_date=end_date)
145
+
146
+ if north_hist_data.empty:
147
+ return {"history": []}
148
+
149
+ # 转换为列表格式返回
150
+ history = []
151
+ for _, row in north_hist_data.iterrows():
152
+ history.append({
153
+ "date": row.get('日期', ''),
154
+ "holding": float(row.get('持股数', 0)) if '持股数' in row else 0,
155
+ "ratio": float(row.get('持股比例', 0)) if '持股比例' in row else 0,
156
+ "change": float(row.get('持股变动', 0)) if '持股变动' in row else 0,
157
+ "market_value": float(row.get('持股市值', 0)) if '持股市值' in row else 0
158
+ })
159
+
160
+ return {"history": history}
161
+ except Exception as e:
162
+ self.logger.error(f"获取北向资金历史数据出错: {str(e)}")
163
+ return {"history": []}
164
+
165
+ def calculate_ema(self, series, period):
166
+ """计算指数移动平均线"""
167
+ return series.ewm(span=period, adjust=False).mean()
168
+
169
+ def calculate_rsi(self, series, period):
170
+ """计算RSI指标"""
171
+ delta = series.diff()
172
+ gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
173
+ loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
174
+ rs = gain / loss
175
+ return 100 - (100 / (1 + rs))
176
+
177
+ def calculate_macd(self, series):
178
+ """计算MACD指标"""
179
+ exp1 = series.ewm(span=12, adjust=False).mean()
180
+ exp2 = series.ewm(span=26, adjust=False).mean()
181
+ macd = exp1 - exp2
182
+ signal = macd.ewm(span=9, adjust=False).mean()
183
+ hist = macd - signal
184
+ return macd, signal, hist
185
+
186
+ def calculate_bollinger_bands(self, series, period, std_dev):
187
+ """计算布林带"""
188
+ middle = series.rolling(window=period).mean()
189
+ std = series.rolling(window=period).std()
190
+ upper = middle + (std * std_dev)
191
+ lower = middle - (std * std_dev)
192
+ return upper, middle, lower
193
+
194
+ def calculate_atr(self, df, period):
195
+ """计算ATR指标"""
196
+ high = df['high']
197
+ low = df['low']
198
+ close = df['close'].shift(1)
199
+
200
+ tr1 = high - low
201
+ tr2 = abs(high - close)
202
+ tr3 = abs(low - close)
203
+
204
+ tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
205
+ return tr.rolling(window=period).mean()
206
+
207
+ def format_indicator_data(self, df):
208
+ """格式化指标数据,控制小数位数"""
209
+
210
+ # 格式化价格数据 (2位小数)
211
+ price_columns = ['open', 'close', 'high', 'low', 'MA5', 'MA20', 'MA60', 'BB_upper', 'BB_middle', 'BB_lower']
212
+ for col in price_columns:
213
+ if col in df.columns:
214
+ df[col] = df[col].round(2)
215
+
216
+ # 格式化MACD相关指标 (3位小数)
217
+ macd_columns = ['MACD', 'Signal', 'MACD_hist']
218
+ for col in macd_columns:
219
+ if col in df.columns:
220
+ df[col] = df[col].round(3)
221
+
222
+ # 格式化其他技术指标 (2位小数)
223
+ other_columns = ['RSI', 'Volatility', 'ROC', 'Volume_Ratio']
224
+ for col in other_columns:
225
+ if col in df.columns:
226
+ df[col] = df[col].round(2)
227
+
228
+ return df
229
+
230
+ def calculate_indicators(self, df):
231
+ """计算技术指标"""
232
+
233
+ try:
234
+ # 计算移动平均线
235
+ df['MA5'] = self.calculate_ema(df['close'], self.params['ma_periods']['short'])
236
+ df['MA20'] = self.calculate_ema(df['close'], self.params['ma_periods']['medium'])
237
+ df['MA60'] = self.calculate_ema(df['close'], self.params['ma_periods']['long'])
238
+
239
+ # 计算RSI
240
+ df['RSI'] = self.calculate_rsi(df['close'], self.params['rsi_period'])
241
+
242
+ # 计算MACD
243
+ df['MACD'], df['Signal'], df['MACD_hist'] = self.calculate_macd(df['close'])
244
+
245
+ # 计算布林带
246
+ df['BB_upper'], df['BB_middle'], df['BB_lower'] = self.calculate_bollinger_bands(
247
+ df['close'],
248
+ self.params['bollinger_period'],
249
+ self.params['bollinger_std']
250
+ )
251
+
252
+ # 成交量分析
253
+ df['Volume_MA'] = df['volume'].rolling(window=self.params['volume_ma_period']).mean()
254
+ df['Volume_Ratio'] = df['volume'] / df['Volume_MA']
255
+
256
+ # 计算ATR和波动率
257
+ df['ATR'] = self.calculate_atr(df, self.params['atr_period'])
258
+ df['Volatility'] = df['ATR'] / df['close'] * 100
259
+
260
+ # 动量指标
261
+ df['ROC'] = df['close'].pct_change(periods=10) * 100
262
+
263
+ # 格式化数据
264
+ df = self.format_indicator_data(df)
265
+
266
+ return df
267
+
268
+ except Exception as e:
269
+ self.logger.error(f"计算技术指标时出错: {str(e)}")
270
+ raise
271
+
272
+ def calculate_score(self, df, market_type='A'):
273
+ """
274
+ 计算股票评分 - 使用时空共振交易系统增强
275
+ 根据不同的市场特征调整评分权重和标准
276
+ """
277
+ try:
278
+ score = 0
279
+ latest = df.iloc[-1]
280
+ prev_days = min(30, len(df) - 1) # Get the most recent 30 days or all available data
281
+
282
+ # 时空共振框架 - 维度1:多时间框架分析
283
+ # 基础权重配置
284
+ weights = {
285
+ 'trend': 0.30, # 趋势因子权重(日线级别)
286
+ 'volatility': 0.15, # 波动率因子权重
287
+ 'technical': 0.25, # 技术指标因子权重
288
+ 'volume': 0.20, # 成交量因子权重(能量守恒维度)
289
+ 'momentum': 0.10 # 动量因子权重(周线级别)
290
+ }
291
+
292
+ # 根据市场类型调整权重(维度1:时间框架嵌套)
293
+ if market_type == 'US':
294
+ # 美股优先考虑长期趋势
295
+ weights['trend'] = 0.35
296
+ weights['volatility'] = 0.10
297
+ weights['momentum'] = 0.15
298
+ elif market_type == 'HK':
299
+ # 港股调整波动率和成交量权重
300
+ weights['volatility'] = 0.20
301
+ weights['volume'] = 0.25
302
+
303
+ # 1. 趋势评分(最高30分)- 日线级别分析
304
+ trend_score = 0
305
+
306
+ # 均线评估 - "三线形态"分析
307
+ if latest['MA5'] > latest['MA20'] and latest['MA20'] > latest['MA60']:
308
+ # 完美多头排列(维度1:日线形态)
309
+ trend_score += 15
310
+ elif latest['MA5'] > latest['MA20']:
311
+ # 短期上升趋势(维度1:5分钟形态)
312
+ trend_score += 10
313
+ elif latest['MA20'] > latest['MA60']:
314
+ # 中期上升趋势
315
+ trend_score += 5
316
+
317
+ # 价格位置评估
318
+ if latest['close'] > latest['MA5']:
319
+ trend_score += 5
320
+ if latest['close'] > latest['MA20']:
321
+ trend_score += 5
322
+ if latest['close'] > latest['MA60']:
323
+ trend_score += 5
324
+
325
+ # 确保不超过最高分数限制
326
+ trend_score = min(30, trend_score)
327
+
328
+ # 2. 波动率评分(最高15分)- 维度2:过滤
329
+ volatility_score = 0
330
+
331
+ # 适度的波动率最理想
332
+ volatility = latest['Volatility']
333
+ if 1.0 <= volatility <= 2.5:
334
+ # 最佳波动率范围
335
+ volatility_score += 15
336
+ elif 2.5 < volatility <= 4.0:
337
+ # 较高波动率,次优选择
338
+ volatility_score += 10
339
+ elif volatility < 1.0:
340
+ # 波动率过低,缺乏能量
341
+ volatility_score += 5
342
+ else:
343
+ # 波动率过高,风险较大
344
+ volatility_score += 0
345
+
346
+ # 3. 技术指标评分(最高25分)- "峰值检测系统"
347
+ technical_score = 0
348
+
349
+ # RSI指标评估(10分)
350
+ rsi = latest['RSI']
351
+ if 40 <= rsi <= 60:
352
+ # 中性区域,趋势稳定
353
+ technical_score += 7
354
+ elif 30 <= rsi < 40 or 60 < rsi <= 70:
355
+ # 阈值区域,可能出现反转信号
356
+ technical_score += 10
357
+ elif rsi < 30:
358
+ # 超卖区域,可能出现买入机会
359
+ technical_score += 8
360
+ elif rsi > 70:
361
+ # 超买区域,可能存在卖出风险
362
+ technical_score += 2
363
+
364
+ # MACD指标评估(10分)- "峰值预警信号"
365
+ if latest['MACD'] > latest['Signal'] and latest['MACD_hist'] > 0:
366
+ # MACD金叉且柱状图为正
367
+ technical_score += 10
368
+ elif latest['MACD'] > latest['Signal']:
369
+ # MACD金叉
370
+ technical_score += 8
371
+ elif latest['MACD'] < latest['Signal'] and latest['MACD_hist'] < 0:
372
+ # MACD死叉且柱状图为负
373
+ technical_score += 0
374
+ elif latest['MACD_hist'] > df.iloc[-2]['MACD_hist']:
375
+ # MACD柱状图增长,可能出现反转信号
376
+ technical_score += 5
377
+
378
+ # 布林带位置评估(5分)
379
+ bb_position = (latest['close'] - latest['BB_lower']) / (latest['BB_upper'] - latest['BB_lower'])
380
+ if 0.3 <= bb_position <= 0.7:
381
+ # 价格在布林带中间区域,趋势稳定
382
+ technical_score += 3
383
+ elif bb_position < 0.2:
384
+ # 价格接近下轨,可能超卖
385
+ technical_score += 5
386
+ elif bb_position > 0.8:
387
+ # 价格接近上轨,可能超买
388
+ technical_score += 1
389
+
390
+ # 确保最大分数限��
391
+ technical_score = min(25, technical_score)
392
+
393
+ # 4. 成交量评分(最高20分)- "能量守恒维度"
394
+ volume_score = 0
395
+
396
+ # 成交量趋势分析
397
+ recent_vol_ratio = [df.iloc[-i]['Volume_Ratio'] for i in range(1, min(6, len(df)))]
398
+ avg_vol_ratio = sum(recent_vol_ratio) / len(recent_vol_ratio)
399
+
400
+ if avg_vol_ratio > 1.5 and latest['close'] > df.iloc[-2]['close']:
401
+ # 成交量放大且价格上涨 - "成交量能量阈值突破"
402
+ volume_score += 20
403
+ elif avg_vol_ratio > 1.2 and latest['close'] > df.iloc[-2]['close']:
404
+ # 成交量和价格同步上涨
405
+ volume_score += 15
406
+ elif avg_vol_ratio < 0.8 and latest['close'] < df.iloc[-2]['close']:
407
+ # 成交量和价格同步下跌,可能是健康回调
408
+ volume_score += 10
409
+ elif avg_vol_ratio > 1.2 and latest['close'] < df.iloc[-2]['close']:
410
+ # 成交量增加但价格下跌,可能存在较大卖压
411
+ volume_score += 0
412
+ else:
413
+ # 其他情况
414
+ volume_score += 8
415
+
416
+ # 5. 动量评分(最高10分)- 维度1:周线级别
417
+ momentum_score = 0
418
+
419
+ # ROC动量指标
420
+ roc = latest['ROC']
421
+ if roc > 5:
422
+ # Strong upward momentum
423
+ momentum_score += 10
424
+ elif 2 <= roc <= 5:
425
+ # Moderate upward momentum
426
+ momentum_score += 8
427
+ elif 0 <= roc < 2:
428
+ # Weak upward momentum
429
+ momentum_score += 5
430
+ elif -2 <= roc < 0:
431
+ # Weak downward momentum
432
+ momentum_score += 3
433
+ else:
434
+ # Strong downward momentum
435
+ momentum_score += 0
436
+
437
+ # 根据加权因子计算总分 - “共振公式”
438
+ final_score = (
439
+ trend_score * weights['trend'] / 0.30 +
440
+ volatility_score * weights['volatility'] / 0.15 +
441
+ technical_score * weights['technical'] / 0.25 +
442
+ volume_score * weights['volume'] / 0.20 +
443
+ momentum_score * weights['momentum'] / 0.10
444
+ )
445
+
446
+ # 特殊市场调整 - “市场适应机制”
447
+ if market_type == 'US':
448
+ # 美国市场额外调整因素
449
+ # 检查是否为财报季
450
+ is_earnings_season = self._is_earnings_season()
451
+ if is_earnings_season:
452
+ # Earnings season has higher volatility, adjust score certainty
453
+ final_score = 0.9 * final_score + 5 # Slight regression to the mean
454
+
455
+ elif market_type == 'HK':
456
+ # 港股特殊调整
457
+ # 检查A股联动效应
458
+ a_share_linkage = self._check_a_share_linkage(df)
459
+ if a_share_linkage > 0.7: # High linkage
460
+ # 根据大陆市场情绪调整
461
+ mainland_sentiment = self._get_mainland_market_sentiment()
462
+ if mainland_sentiment > 0:
463
+ final_score += 5
464
+ else:
465
+ final_score -= 5
466
+
467
+ # Ensure score remains within 0-100 range
468
+ final_score = max(0, min(100, round(final_score)))
469
+
470
+ # Store sub-scores for display
471
+ self.score_details = {
472
+ 'trend': trend_score,
473
+ 'volatility': volatility_score,
474
+ 'technical': technical_score,
475
+ 'volume': volume_score,
476
+ 'momentum': momentum_score,
477
+ 'total': final_score
478
+ }
479
+
480
+ return final_score
481
+
482
+ except Exception as e:
483
+ self.logger.error(f"Error calculating score: {str(e)}")
484
+ # Return neutral score on error
485
+ return 50
486
+
487
+ def calculate_position_size(self, stock_code, risk_percent=2.0, stop_loss_percent=5.0):
488
+ """
489
+ 根据风险管理原则计算最佳仓位大小
490
+ 实施时空共振系统的“仓位大小公式”
491
+
492
+ 参数:
493
+ stock_code: 要分析的股票代码
494
+ risk_percent: 在此交易中承担风险的总资本百分比(默认为2%)
495
+ stop_loss_percent: 从入场点的止损百分比(默认为5%)
496
+
497
+ 返回:
498
+ 仓位大小占总资本的百分比
499
+ """
500
+ try:
501
+ # Get stock data
502
+ df = self.get_stock_data(stock_code)
503
+ df = self.calculate_indicators(df)
504
+
505
+ # 获取波动率因子(来自维度3:能量守恒)
506
+ latest = df.iloc[-1]
507
+ volatility = latest['Volatility']
508
+
509
+ # 计算波动率调整因子(较高波动率=较小仓位)
510
+ volatility_factor = 1.0
511
+ if volatility > 4.0:
512
+ volatility_factor = 0.6 # Reduce position for high volatility stocks
513
+ elif volatility > 2.5:
514
+ volatility_factor = 0.8 # Slightly reduce position
515
+ elif volatility < 1.0:
516
+ volatility_factor = 1.2 # Can increase position for low volatility stocks
517
+
518
+ # Calculate position size using risk formula
519
+ # 公式:position_size = (风险金额) / (止损 * 波动率因子)
520
+ position_size = (risk_percent) / (stop_loss_percent * volatility_factor)
521
+
522
+ # 限制最大仓位为25%以实现多元化
523
+ position_size = min(position_size, 25.0)
524
+
525
+ return position_size
526
+
527
+ except Exception as e:
528
+ self.logger.error(f"Error calculating position size: {str(e)}")
529
+ # 返回保守的默认仓位大小(出错时)
530
+ return 5.0
531
+
532
+ def get_recommendation(self, score, market_type='A', technical_data=None, news_data=None):
533
+ """
534
+ 根据得分和附加信息生成投资建议
535
+ 使用时空共振交易系统策略增强
536
+ """
537
+ try:
538
+ # 1. Base recommendation logic - Dynamic threshold adjustment based on score
539
+ if score >= 85:
540
+ base_recommendation = '强烈建议买入'
541
+ confidence = 'high'
542
+ action = 'strong_buy'
543
+ elif score >= 70:
544
+ base_recommendation = '建议买入'
545
+ confidence = 'medium_high'
546
+ action = 'buy'
547
+ elif score >= 55:
548
+ base_recommendation = '谨慎买入'
549
+ confidence = 'medium'
550
+ action = 'cautious_buy'
551
+ elif score >= 45:
552
+ base_recommendation = '持观望态度'
553
+ confidence = 'medium'
554
+ action = 'hold'
555
+ elif score >= 30:
556
+ base_recommendation = '谨慎持有'
557
+ confidence = 'medium'
558
+ action = 'cautious_hold'
559
+ elif score >= 15:
560
+ base_recommendation = '建议减仓'
561
+ confidence = 'medium_high'
562
+ action = 'reduce'
563
+ else:
564
+ base_recommendation = '建议卖出'
565
+ confidence = 'high'
566
+ action = 'sell'
567
+
568
+ # 2. Consider market characteristics (Dimension 1: Timeframe Nesting)
569
+ market_adjustment = ""
570
+ if market_type == 'US':
571
+ # US market adjustment factors
572
+ if self._is_earnings_season():
573
+ if confidence == 'high' or confidence == 'medium_high':
574
+ confidence = 'medium'
575
+ market_adjustment = "(财报季临近,波动可能加大,建议适当控制仓位)"
576
+
577
+ elif market_type == 'HK':
578
+ # HK market adjustment factors
579
+ mainland_sentiment = self._get_mainland_market_sentiment()
580
+ if mainland_sentiment < -0.3 and (action == 'buy' or action == 'strong_buy'):
581
+ action = 'cautious_buy'
582
+ confidence = 'medium'
583
+ market_adjustment = "(受大陆市场情绪影响,建议控制风险)"
584
+
585
+ elif market_type == 'A':
586
+ # A-share specific adjustment factors
587
+ if technical_data and 'Volatility' in technical_data:
588
+ vol = technical_data.get('Volatility', 0)
589
+ if vol > 4.0 and (action == 'buy' or action == 'strong_buy'):
590
+ action = 'cautious_buy'
591
+ confidence = 'medium'
592
+ market_adjustment = "(市场波动较大,建议分批买入)"
593
+
594
+ # 3. Consider market sentiment (Dimension 2: Filtering)
595
+ sentiment_adjustment = ""
596
+ if news_data and 'market_sentiment' in news_data:
597
+ sentiment = news_data.get('market_sentiment', 'neutral')
598
+
599
+ if sentiment == 'bullish' and action in ['hold', 'cautious_hold']:
600
+ action = 'cautious_buy'
601
+ sentiment_adjustment = "(市场氛围积极,可适当提高仓位)"
602
+
603
+ elif sentiment == 'bearish' and action in ['buy', 'cautious_buy']:
604
+ action = 'hold'
605
+ sentiment_adjustment = "(市场氛围悲观,建议等待更好买点)"
606
+ elif self.json_match_flag==False and news_data:
607
+ import re
608
+
609
+ # 如果JSON解析失败,尝试从原始内容中匹配市场情绪
610
+ sentiment_pattern = r'(bullish|neutral|bearish)'
611
+ sentiment_match = re.search(sentiment_pattern, news_data.get('original_content', ''))
612
+
613
+ if sentiment_match:
614
+ sentiment_map = {
615
+ 'bullish': 'bullish',
616
+ 'neutral': 'neutral',
617
+ 'bearish': 'bearish'
618
+ }
619
+ sentiment = sentiment_map.get(sentiment_match.group(1), 'neutral')
620
+
621
+ if sentiment == 'bullish' and action in ['hold', 'cautious_hold']:
622
+ action = 'cautious_buy'
623
+ sentiment_adjustment = "(市场氛围积极,可适当提高仓位)"
624
+ elif sentiment == 'bearish' and action in ['buy', 'cautious_buy']:
625
+ action = 'hold'
626
+ sentiment_adjustment = "(市场氛围悲观,建议等待更好买点)"
627
+
628
+
629
+ # 4. Technical indicators adjustment (Dimension 2: "Peak Detection System")
630
+ technical_adjustment = ""
631
+ if technical_data:
632
+ rsi = technical_data.get('RSI', 50)
633
+ macd_signal = technical_data.get('MACD_signal', 'neutral')
634
+
635
+ # RSI overbought/oversold adjustment
636
+ if rsi > 80 and action in ['buy', 'strong_buy']:
637
+ action = 'hold'
638
+ technical_adjustment = "(RSI指标显示超买,建议等待回调)"
639
+ elif rsi < 20 and action in ['sell', 'reduce']:
640
+ action = 'hold'
641
+ technical_adjustment = "(RSI指标显示超卖,可能存在反弹机会)"
642
+
643
+ # MACD signal adjustment
644
+ if macd_signal == 'bullish' and action in ['hold', 'cautious_hold']:
645
+ action = 'cautious_buy'
646
+ if not technical_adjustment:
647
+ technical_adjustment = "(MACD显示买入信号)"
648
+ elif macd_signal == 'bearish' and action in ['cautious_buy', 'buy']:
649
+ action = 'hold'
650
+ if not technical_adjustment:
651
+ technical_adjustment = "(MACD显示卖出信号)"
652
+
653
+ # 5. Convert adjusted action to final recommendation
654
+ action_to_recommendation = {
655
+ 'strong_buy': '强烈建议买入',
656
+ 'buy': '建议买入',
657
+ 'cautious_buy': '谨慎买入',
658
+ 'hold': '持观望态度',
659
+ 'cautious_hold': '谨慎持有',
660
+ 'reduce': '建议减仓',
661
+ 'sell': '建议卖出'
662
+ }
663
+
664
+ final_recommendation = action_to_recommendation.get(action, base_recommendation)
665
+
666
+ # 6. Combine all adjustment factors
667
+ adjustments = " ".join(filter(None, [market_adjustment, sentiment_adjustment, technical_adjustment]))
668
+
669
+ if adjustments:
670
+ return f"{final_recommendation} {adjustments}"
671
+ else:
672
+ return final_recommendation
673
+
674
+ except Exception as e:
675
+ self.logger.error(f"Error generating investment recommendation: {str(e)}")
676
+ # Return safe default recommendation on error
677
+ return "无法提供明确建议,请结合多种因素谨慎决策"
678
+
679
+ def check_consecutive_losses(self, trade_history, max_consecutive_losses=3):
680
+ """
681
+ 实施“冷静期风险控制” - 连续亏损后停止交易
682
+
683
+ 参数:
684
+ trade_history: 最近交易结果列表 (True 表示盈利, False 表示亏损)
685
+ max_consecutive_losses: 允许的最大连续亏损次数
686
+
687
+ 返回:
688
+ Boolean: True 如果应该暂停交易, False 如果可以继续交易
689
+ """
690
+ consecutive_losses = 0
691
+
692
+ # Count consecutive losses from most recent trades
693
+ for trade in reversed(trade_history):
694
+ if not trade: # If trade is a loss
695
+ consecutive_losses += 1
696
+ else:
697
+ break # Break on first profitable trade
698
+
699
+ # Return True if we've hit max consecutive losses
700
+ return consecutive_losses >= max_consecutive_losses
701
+
702
+ def check_profit_taking(self, current_profit_percent, threshold=20.0):
703
+ """
704
+ 当回报超过阈值时,实施获利了结机制
705
+ 属于“能量守恒维度”的一部分
706
+
707
+ 参数:
708
+ current_profit_percent: 当前利润百分比
709
+ threshold: 用于获利了结的利润百分比阈值
710
+
711
+ 返回:
712
+ Float: 减少仓位的百分比 (0.0-1.0)
713
+ """
714
+ if current_profit_percent >= threshold:
715
+ # If profit exceeds threshold, suggest reducing position by 50%
716
+ return 0.5
717
+
718
+ return 0.0 # No position reduction recommended
719
+
720
+ def _is_earnings_season(self):
721
+ """检查当前是否处于财报季(辅助函数)"""
722
+ from datetime import datetime
723
+ current_month = datetime.now().month
724
+ # 美股财报季大致在1月、4月、7月和10月
725
+ return current_month in [1, 4, 7, 10]
726
+
727
+ def _check_a_share_linkage(self, df, window=20):
728
+ """检查港股与A股的联动性(辅助函数)"""
729
+ # 该函数需要获取对应的A股指数数据
730
+ # 简化版实现:
731
+ try:
732
+ # 获取恒生指数与上证指数的相关系数
733
+ # 实际实现中需要获取��实数据
734
+ correlation = 0.6 # 示例值
735
+ return correlation
736
+ except:
737
+ return 0.5 # 默认中等关联度
738
+
739
+ def _get_mainland_market_sentiment(self):
740
+ """获取中国大陆市场情绪(辅助函数)"""
741
+ # 实际实现中需要分析上证指数、北向资金等因素
742
+ try:
743
+ # 简化版实现,返回-1到1之间的值,1表示积极情绪
744
+ sentiment = 0.2 # 示例值
745
+ return sentiment
746
+ except:
747
+ return 0 # 默认中性情绪
748
+
749
+ def get_stock_news(self, stock_code, market_type='A', limit=5):
750
+ """
751
+ 获取股票相关新闻和实时信息,直接调用搜索工具
752
+ 参数:
753
+ stock_code: 股票代码
754
+ market_type: 市场类型 (A/HK/US)
755
+ limit: 返回的新闻条数上限
756
+ 返回:
757
+ 包含新闻和公告的字典
758
+ """
759
+ try:
760
+ self.logger.info(f"获取股票 {stock_code} 的相关新闻和信息")
761
+
762
+ # 缓存键
763
+ cache_key = f"{stock_code}_{market_type}_news"
764
+ if cache_key in self.data_cache and (
765
+ datetime.now() - self.data_cache[cache_key]['timestamp']).seconds < 3600:
766
+ # 缓存1小时内的数据
767
+ return self.data_cache[cache_key]['data']
768
+
769
+ # 获取股票基本信息
770
+ stock_info = self.get_stock_info(stock_code)
771
+ stock_name = stock_info.get('股票名称', '未知')
772
+ industry = stock_info.get('行业', '未知')
773
+ market_name = "A股" if market_type == 'A' else "港股" if market_type == 'HK' else "美股"
774
+
775
+ def search_news_local():
776
+ """实际执行搜索的函数"""
777
+ try:
778
+ # 获取API密钥
779
+ serp_api_key = os.getenv('SERP_API_KEY')
780
+ tavily_api_key = os.getenv('TAVILY_API_KEY')
781
+
782
+ if not serp_api_key and not tavily_api_key:
783
+ self.logger.error("未找到SERP_API_KEY或TAVILY_API_KEY环境变量")
784
+ return {"error": "未配置搜索API密钥"}
785
+
786
+ # 构建搜索查询
787
+ search_query = f"{stock_name} {stock_code} {market_name} 最新新闻 公告"
788
+ industry_query = f"{industry} {market_name} 行业动态 最新消息"
789
+
790
+ news_results = []
791
+ industry_news = []
792
+
793
+ # 如果配置了SERP API,使用SERP API搜索
794
+ if serp_api_key:
795
+ # ... (SERP API logic remains the same)
796
+ pass
797
+
798
+ # 如果配置了Tavily API,使用Tavily API搜索
799
+ if tavily_api_key and tavily_api_key != 'your_tavily_api_key':
800
+ self.logger.info(f"使用Tavily API搜索新闻: {search_query}")
801
+ try:
802
+ from tavily import TavilyClient
803
+ client = TavilyClient(tavily_api_key)
804
+
805
+ # Search for stock news
806
+ tavily_response = client.search(query=search_query, topic="finance", search_depth="advanced")
807
+ if "results" in tavily_response:
808
+ for item in tavily_response["results"][:limit]:
809
+ source = urlparse(item.get("url")).netloc if item.get("url") else ""
810
+ news_results.append({
811
+ "title": item.get("title", ""), "date": datetime.now().strftime("%Y-%m-%d"),
812
+ "source": source, "link": item.get("url", ""), "snippet": item.get("content", "")
813
+ })
814
+
815
+ # Search for industry news
816
+ tavily_industry_response = client.search(query=industry_query, topic="finance", search_depth="advanced")
817
+ if "results" in tavily_industry_response:
818
+ for item in tavily_industry_response["results"][:limit]:
819
+ source = urlparse(item.get("url")).netloc if item.get("url") else ""
820
+ industry_news.append({
821
+ "title": item.get("title", ""), "date": datetime.now().strftime("%Y-%m-%d"),
822
+ "source": source, "summary": item.get("content", "")
823
+ })
824
+ except ImportError:
825
+ self.logger.error("Tavily client not installed. Please run: pip install tavily-python")
826
+ except Exception as e:
827
+ self.logger.error(f"Error during Tavily API search: {e}", exc_info=True)
828
+
829
+
830
+ # 移除可能的重复结果
831
+ unique_news = [dict(t) for t in {tuple(d.items()) for d in news_results}]
832
+ unique_industry_news = [dict(t) for t in {tuple(d.items()) for d in industry_news}]
833
+
834
+ # 分析市场情绪
835
+ sentiment_keywords = {
836
+ 'bullish': ['上涨', '增长', '利好', '突破', '强势', '看好', '机会', '利润'],
837
+ 'slightly_bullish': ['回升', '改善', '企稳', '向好', '期待'],
838
+ 'neutral': ['稳定', '平稳', '持平', '不变'],
839
+ 'slightly_bearish': ['回调', '承压', '谨慎', '风险', '下滑'],
840
+ 'bearish': ['下跌', '亏损', '跌破', '利空', '警惕', '危机', '崩盘']
841
+ }
842
+ sentiment_scores = {k: 0 for k in sentiment_keywords}
843
+ all_text = " ".join([n.get("title", "") + " " + n.get("snippet", "") for n in unique_news])
844
+ for sentiment, keywords in sentiment_keywords.items():
845
+ for keyword in keywords:
846
+ if keyword in all_text:
847
+ sentiment_scores[sentiment] += 1
848
+
849
+ market_sentiment = max(sentiment_scores, key=sentiment_scores.get) if any(sentiment_scores.values()) else "neutral"
850
+
851
+ self.logger.info(f"搜索完成,共获取到 {len(unique_news)} 条新闻和 {len(unique_industry_news)} 条行业新闻")
852
+
853
+ return {
854
+ "news": unique_news,
855
+ "announcements": [],
856
+ "industry_news": unique_industry_news,
857
+ "market_sentiment": market_sentiment
858
+ }
859
+
860
+ except Exception as e:
861
+ self.logger.error(f"搜索新闻时出错: {e}", exc_info=True)
862
+ return {"error": str(e)}
863
+
864
+ news_data = search_news_local()
865
+
866
+ # 确保数据结构完整
867
+ news_data.setdefault('news', [])
868
+ news_data.setdefault('announcements', [])
869
+ news_data.setdefault('industry_news', [])
870
+ news_data.setdefault('market_sentiment', 'neutral')
871
+ news_data['timestamp'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
872
+
873
+ # 缓存结果
874
+ self.data_cache[cache_key] = {'data': news_data, 'timestamp': datetime.now()}
875
+ return news_data
876
+
877
+ except Exception as e:
878
+ self.logger.error(f"获取股票新闻时出错: {e}", exc_info=True)
879
+ return {
880
+ 'news': [], 'announcements': [], 'industry_news': [],
881
+ 'market_sentiment': 'neutral',
882
+ 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
883
+ }
884
+
885
+ def get_ai_analysis_from_prompt(self, prompt: str) -> str:
886
+ """
887
+ 接收一个已经构建好的prompt,并返回AI模型的分析结果。
888
+ """
889
+ try:
890
+ import queue
891
+ import threading
892
+
893
+ messages = [{"role": "user", "content": prompt}]
894
+ result_queue = queue.Queue()
895
+
896
+ def call_api():
897
+ try:
898
+ response = self.client.chat.completions.create(
899
+ model=self.openai_model,
900
+ messages=messages,
901
+ temperature=0.8,
902
+ max_tokens=4000,
903
+ stream=False,
904
+ timeout=300
905
+ )
906
+ result_queue.put(response)
907
+ except Exception as e:
908
+ result_queue.put(e)
909
+
910
+ api_thread = threading.Thread(target=call_api)
911
+ api_thread.daemon = True
912
+ api_thread.start()
913
+
914
+ try:
915
+ result = result_queue.get(timeout=240)
916
+ if isinstance(result, Exception):
917
+ raise result
918
+ assistant_reply = result.choices[0].message.content.strip()
919
+ return assistant_reply
920
+ except queue.Empty:
921
+ return "AI分析超时,无法获取分析结果。请稍后再试。"
922
+ except Exception as e:
923
+ return f"AI分析过程中发生错误: {str(e)}"
924
+
925
+ except Exception as e:
926
+ self.logger.error(f"从prompt进行AI分析时出错: {str(e)}")
927
+ return f"AI分析过程中发生错误,请稍后再试。错误信息: {str(e)}"
928
+
929
+ def _build_stock_prompt_and_get_analysis(self, df, stock_code, market_type='A'):
930
+ """
931
+ 为个股分析构建详细的prompt,并调用AI模型。
932
+ """
933
+ try:
934
+ # 1. 获取最近K线数据
935
+ recent_data = df.tail(20).to_dict('records')
936
+
937
+ # 2. 计算技术指标摘要
938
+ technical_summary = {
939
+ 'trend': 'upward' if df.iloc[-1]['MA5'] > df.iloc[-1]['MA20'] else 'downward',
940
+ 'volatility': f"{df.iloc[-1]['Volatility']:.2f}%",
941
+ 'volume_trend': 'increasing' if df.iloc[-1]['Volume_Ratio'] > 1 else 'decreasing',
942
+ 'rsi_level': df.iloc[-1]['RSI'],
943
+ 'macd_signal': 'bullish' if df.iloc[-1]['MACD'] > df.iloc[-1]['Signal'] else 'bearish',
944
+ 'bb_position': self._calculate_bb_position(df)
945
+ }
946
+
947
+ # 3. 获取支撑压力位
948
+ sr_levels = self.identify_support_resistance(df)
949
+
950
+ # 4. 获取股票基本信息
951
+ stock_info = self.get_stock_info(stock_code)
952
+ stock_name = stock_info.get('股票名称', '未知')
953
+ industry = stock_info.get('行业', '未知')
954
+
955
+ # 5. 获取相关新闻和实时信息
956
+ self.logger.info(f"获取 {stock_code} 的相关新闻和市场信息")
957
+ news_data = self.get_stock_news(stock_code, market_type)
958
+
959
+ # 6. 评分分解
960
+ score = self.calculate_score(df, market_type)
961
+ score_details = getattr(self, 'score_details', {'total': score})
962
+
963
+ # 7. 获取投资建议
964
+ tech_data = {
965
+ 'RSI': technical_summary['rsi_level'],
966
+ 'MACD_signal': technical_summary['macd_signal'],
967
+ 'Volatility': df.iloc[-1]['Volatility']
968
+ }
969
+ recommendation = self.get_recommendation(score, market_type, tech_data, news_data)
970
+
971
+ # 8. 构建全面的prompt
972
+ prompt = f"""作为专业的股票分析师,请对{stock_name}({stock_code})进行全面分析:
973
+
974
+ 1. 基本信息:
975
+ - 股票名称: {stock_name}
976
+ - 股票代码: {stock_code}
977
+ - 行业: {industry}
978
+ - 市场类型: {"A股" if market_type == 'A' else "港股" if market_type == 'HK' else "美股"}
979
+
980
+ 2. 技术指标摘要:
981
+ - 趋势: {technical_summary['trend']}
982
+ - 波动率: {technical_summary['volatility']}
983
+ - 成交量趋势: {technical_summary['volume_trend']}
984
+ - RSI: {technical_summary['rsi_level']:.2f}
985
+ - MACD信号: {technical_summary['macd_signal']}
986
+ - 布林带位置: {technical_summary['bb_position']}
987
+
988
+ 3. 支撑与压力位:
989
+ - 短期支撑位: {', '.join([str(level) for level in sr_levels['support_levels']['short_term']])}
990
+ - 中期支撑位: {', '.join([str(level) for level in sr_levels['support_levels']['medium_term']])}
991
+ - 短期压力位: {', '.join([str(level) for level in sr_levels['resistance_levels']['short_term']])}
992
+ - 中期压力位: {', '.join([str(level) for level in sr_levels['resistance_levels']['medium_term']])}
993
+
994
+ 4. 综合评分: {score_details['total']}分
995
+ - 趋势评分: {score_details.get('trend', 0)}
996
+ - 波动率评分: {score_details.get('volatility', 0)}
997
+ - 技术指标评分: {score_details.get('technical', 0)}
998
+ - 成交量评分: {score_details.get('volume', 0)}
999
+ - 动量评分: {score_details.get('momentum', 0)}
1000
+
1001
+ 5. 投资建议: {recommendation}"""
1002
+
1003
+ # 检查是否有JSON解析失败的情况
1004
+ if hasattr(self, 'json_match_flag') and not self.json_match_flag and 'original_content' in news_data:
1005
+ # 如果JSON解析失败,直接使用原始内容
1006
+ prompt += f"""
1007
+
1008
+ 6. 相关新闻和市场信息:
1009
+ {news_data.get('original_content', '无法获取相关新闻')}
1010
+ """
1011
+ else:
1012
+ # 正常情况下使用格式化的新闻数据
1013
+ prompt += f"""
1014
+
1015
+ 6. 近期相关新闻:
1016
+ {self._format_news_for_prompt(news_data.get('news', []))}
1017
+
1018
+ 7. 公司公告:
1019
+ {self._format_announcements_for_prompt(news_data.get('announcements', []))}
1020
+
1021
+ 8. 行业动态:
1022
+ {self._format_news_for_prompt(news_data.get('industry_news', []))}
1023
+
1024
+ 9. 市场情绪: {news_data.get('market_sentiment', 'neutral')}
1025
+
1026
+ 请提供以下内容:
1027
+ 1. 技术面分析 - 详细分析价格走势、支撑压力位、主要技术指标的信号
1028
+ 2. 行业和市场环境 - 结合新闻和行业动态分析公司所处环境
1029
+ 3. 风险因素 - 识别潜在风险点
1030
+ 4. 具体交易策略 - 给出明确的买入/卖出建议,包括入场点、止损位和目标价位
1031
+ 5. 短期(1周)、中期(1-3个月)和长期(半年)展望
1032
+
1033
+ 请基于数据给出客观分析,不要过度乐观或悲观。分析应该包含具体数据和百分比,避免模糊表述。
1034
+ """
1035
+ return self.get_ai_analysis_from_prompt(prompt)
1036
+
1037
+ except Exception as e:
1038
+ self.logger.error(f"构建个股分析prompt时出错: {str(e)}")
1039
+ return f"AI分析过程中发生错误: {str(e)}"
1040
+
1041
+ def _calculate_bb_position(self, df):
1042
+ """计算价格在布林带中的位置"""
1043
+ latest = df.iloc[-1]
1044
+ bb_width = latest['BB_upper'] - latest['BB_lower']
1045
+ if bb_width == 0:
1046
+ return "middle"
1047
+
1048
+ position = (latest['close'] - latest['BB_lower']) / bb_width
1049
+
1050
+ if position < 0.2:
1051
+ return "near lower band (potential oversold)"
1052
+ elif position < 0.4:
1053
+ return "below middle band"
1054
+ elif position < 0.6:
1055
+ return "near middle band"
1056
+ elif position < 0.8:
1057
+ return "above middle band"
1058
+ else:
1059
+ return "near upper band (potential overbought)"
1060
+
1061
+ def _format_news_for_prompt(self, news_list):
1062
+ """格式化新闻列表为prompt字符串"""
1063
+ if not news_list:
1064
+ return " 无最新相关新闻"
1065
+
1066
+ formatted = ""
1067
+ for i, news in enumerate(news_list[:3]): # 最多显示3条
1068
+ date = news.get('date', '')
1069
+ title = news.get('title', '')
1070
+ source = news.get('source', '')
1071
+ formatted += f" {i + 1}. [{date}] {title} (来源: {source})\\n"
1072
+
1073
+ return formatted
1074
+
1075
+ def _format_announcements_for_prompt(self, announcements):
1076
+ """格式化公告列表为prompt字符串"""
1077
+ if not announcements:
1078
+ return " 无最新公告"
1079
+
1080
+ formatted = ""
1081
+ for i, ann in enumerate(announcements[:3]): # 最多显示3条
1082
+ date = ann.get('date', '')
1083
+ title = ann.get('title', '')
1084
+ type_ = ann.get('type', '')
1085
+ formatted += f" {i + 1}. [{date}] {title} (类型: {type_})\\n"
1086
+
1087
+ return formatted
1088
+
1089
+ # 原有API:保持接口不变
1090
+ def analyze_stock(self, stock_code, market_type='A'):
1091
+ """分析单个股票"""
1092
+ try:
1093
+ # self.clear_cache(stock_code, market_type)
1094
+ # 获取股票数据
1095
+ df = self.get_stock_data(stock_code, market_type)
1096
+ self.logger.info(f"获取股票数据完成")
1097
+ # 计算技术指标
1098
+ df = self.calculate_indicators(df)
1099
+ self.logger.info(f"计算技术指标完成")
1100
+ # 评分系统
1101
+ score = self.calculate_score(df)
1102
+ self.logger.info(f"评分系统完成")
1103
+ # 获取最新数据
1104
+ latest = df.iloc[-1]
1105
+ prev = df.iloc[-2]
1106
+
1107
+ # 获取基本信息
1108
+ stock_info = self.get_stock_info(stock_code)
1109
+ stock_name = stock_info.get('股票名称', '未知')
1110
+ industry = stock_info.get('行业', '未知')
1111
+
1112
+ # 生成报告(保持原有格式)
1113
+ report = {
1114
+ 'stock_code': stock_code,
1115
+ 'stock_name': stock_name,
1116
+ 'industry': industry,
1117
+ 'analysis_date': datetime.now().strftime('%Y-%m-%d'),
1118
+ 'score': score,
1119
+ 'price': latest['close'],
1120
+ 'price_change': (latest['close'] - prev['close']) / prev['close'] * 100,
1121
+ 'ma_trend': 'UP' if latest['MA5'] > latest['MA20'] else 'DOWN',
1122
+ 'rsi': latest['RSI'],
1123
+ 'macd_signal': 'BUY' if latest['MACD'] > latest['Signal'] else 'SELL',
1124
+ 'volume_status': '放量' if latest['Volume_Ratio'] > 1.5 else '平量',
1125
+ 'recommendation': self.get_recommendation(score),
1126
+ 'ai_analysis': self._build_stock_prompt_and_get_analysis(df, stock_code)
1127
+ }
1128
+
1129
+ return report
1130
+
1131
+ except Exception as e:
1132
+ self.logger.error(f"分析股票时出错: {str(e)}")
1133
+ raise
1134
+
1135
+ # 原有API:保持接口不变
1136
+ def scan_market(self, stock_list, min_score=60, market_type='A'):
1137
+ """扫描市场,寻找符合条件的股票"""
1138
+ recommendations = []
1139
+ total_stocks = len(stock_list)
1140
+
1141
+ self.logger.info(f"开始市场扫描,共 {total_stocks} 只股票")
1142
+ start_time = time.time()
1143
+ processed = 0
1144
+
1145
+ # 批量处理,减少日志输出
1146
+ batch_size = 10
1147
+ for i in range(0, total_stocks, batch_size):
1148
+ batch = stock_list[i:i + batch_size]
1149
+ batch_results = []
1150
+
1151
+ for stock_code in batch:
1152
+ try:
1153
+ # 使用简化版分析以加快速度
1154
+ report = self.quick_analyze_stock(stock_code, market_type)
1155
+ if report['score'] >= min_score:
1156
+ batch_results.append(report)
1157
+ except Exception as e:
1158
+ self.logger.error(f"分析股票 {stock_code} 时出错: {str(e)}")
1159
+ continue
1160
+
1161
+ # 添加批处理结果
1162
+ recommendations.extend(batch_results)
1163
+
1164
+ # 更新处理进度
1165
+ processed += len(batch)
1166
+ elapsed = time.time() - start_time
1167
+ remaining = (elapsed / processed) * (total_stocks - processed) if processed > 0 else 0
1168
+
1169
+ self.logger.info(
1170
+ f"已处理 {processed}/{total_stocks} 只股票,耗时 {elapsed:.1f}秒,预计剩余 {remaining:.1f}秒")
1171
+
1172
+ # 按得分排序
1173
+ recommendations.sort(key=lambda x: x['score'], reverse=True)
1174
+
1175
+ total_time = time.time() - start_time
1176
+ self.logger.info(
1177
+ f"市场扫描完成,共分析 {total_stocks} 只股票,找到 {len(recommendations)} 只符合条件的股票,总耗时 {total_time:.1f}秒")
1178
+
1179
+ return recommendations
1180
+
1181
+ # def quick_analyze_stock(self, stock_code, market_type='A'):
1182
+ # """快速分析股票,用于市场扫描"""
1183
+ # try:
1184
+ # # 获取股票数据
1185
+ # df = self.get_stock_data(stock_code, market_type)
1186
+
1187
+ # # 计算技术指标
1188
+ # df = self.calculate_indicators(df)
1189
+
1190
+ # # 简化评分计算
1191
+ # score = self.calculate_score(df)
1192
+
1193
+ # # 获取最新数据
1194
+ # latest = df.iloc[-1]
1195
+ # prev = df.iloc[-2] if len(df) > 1 else latest
1196
+
1197
+ # # 尝试获取股票名称和行业
1198
+ # try:
1199
+ # stock_info = self.get_stock_info(stock_code)
1200
+ # stock_name = stock_info.get('股票名称', '未知')
1201
+ # industry = stock_info.get('行业', '未知')
1202
+ # except:
1203
+ # stock_name = '未知'
1204
+ # industry = '未知'
1205
+
1206
+ # # 生成简化报告
1207
+ # report = {
1208
+ # 'stock_code': stock_code,
1209
+ # 'stock_name': stock_name,
1210
+ # 'industry': industry,
1211
+ # 'analysis_date': datetime.now().strftime('%Y-%m-%d'),
1212
+ # 'score': score,
1213
+ # 'price': float(latest['close']),
1214
+ # 'price_change': float((latest['close'] - prev['close']) / prev['close'] * 100),
1215
+ # 'ma_trend': 'UP' if latest['MA5'] > latest['MA20'] else 'DOWN',
1216
+ # 'rsi': float(latest['RSI']),
1217
+ # 'macd_signal': 'BUY' if latest['MACD'] > latest['Signal'] else 'SELL',
1218
+ # 'volume_status': '放量' if latest['Volume_Ratio'] > 1.5 else '平量',
1219
+ # 'recommendation': self.get_recommendation(score)
1220
+ # }
1221
+
1222
+ # return report
1223
+ # except Exception as e:
1224
+ # self.logger.error(f"快速分析股票 {stock_code} 时出错: {str(e)}")
1225
+ # raise
1226
+
1227
+ def quick_analyze_stock(self, stock_code, market_type='A'):
1228
+ """快速分析股票,用于市场扫描"""
1229
+ try:
1230
+ # 获取股票数据
1231
+ df = self.get_stock_data(stock_code, market_type)
1232
+
1233
+ if df.empty:
1234
+ self.logger.warning(f"无法为 {stock_code} 获取有效数据,跳过分析。")
1235
+ raise ValueError(f"股票 {stock_code} 的数据为空或无法处理")
1236
+
1237
+ # 计算技术指标
1238
+ df = self.calculate_indicators(df)
1239
+
1240
+ # 简化评分计算
1241
+ score = self.calculate_score(df)
1242
+
1243
+ # 获取最新数据
1244
+ latest = df.iloc[-1]
1245
+ prev = df.iloc[-2] if len(df) > 1 else latest
1246
+
1247
+ # 先获取股票信息再生成报告
1248
+ try:
1249
+ stock_info = self.get_stock_info(stock_code)
1250
+ stock_name = stock_info.get('股票名称', '未知')
1251
+ industry = stock_info.get('行业', '未知')
1252
+
1253
+ # 添加日志
1254
+ self.logger.info(f"股票 {stock_code} 信息: 名称={stock_name}, 行业={industry}")
1255
+ except Exception as e:
1256
+ self.logger.error(f"获取股票 {stock_code} 信息时出错: {str(e)}")
1257
+ stock_name = '未知'
1258
+ industry = '未知'
1259
+
1260
+ # 生成简化报告
1261
+ report = {
1262
+ 'stock_code': stock_code,
1263
+ 'stock_name': stock_name,
1264
+ 'industry': industry,
1265
+ 'analysis_date': datetime.now().strftime('%Y-%m-%d'),
1266
+ 'score': score,
1267
+ 'price': float(latest['close']),
1268
+ 'price_change': float((latest['close'] - prev['close']) / prev['close'] * 100),
1269
+ 'ma_trend': 'UP' if latest['MA5'] > latest['MA20'] else 'DOWN',
1270
+ 'rsi': float(latest['RSI']),
1271
+ 'macd_signal': 'BUY' if latest['MACD'] > latest['Signal'] else 'SELL',
1272
+ 'volume_status': 'HIGH' if latest['Volume_Ratio'] > 1.5 else 'NORMAL',
1273
+ 'recommendation': self.get_recommendation(score)
1274
+ }
1275
+
1276
+ return report
1277
+ except Exception as e:
1278
+ self.logger.error(f"快速分析股票 {stock_code} 时出错: {str(e)}")
1279
+ raise
1280
+
1281
+ # ======================== 新增功能 ========================#
1282
+
1283
+ def get_stock_info(self, stock_code):
1284
+ """获取股票基本信息"""
1285
+ import akshare as ak
1286
+
1287
+ cache_key = f"{stock_code}_info"
1288
+ if cache_key in self.data_cache:
1289
+ return self.data_cache[cache_key]
1290
+
1291
+ try:
1292
+ # 获取A股股票基本信息
1293
+ stock_info = ak.stock_individual_info_em(symbol=stock_code)
1294
+
1295
+ # 修改:使用列名而不是索引访问数据
1296
+ info_dict = {}
1297
+ for _, row in stock_info.iterrows():
1298
+ # 使用iloc安全地获取数据
1299
+ if len(row) >= 2: # 确保有至少两列
1300
+ info_dict[row.iloc[0]] = row.iloc[1]
1301
+
1302
+ # 获取股票名称
1303
+ try:
1304
+ stock_name_df = ak.stock_info_a_code_name()
1305
+
1306
+ # 标准化列名
1307
+ rename_map = {
1308
+ "代码": "code", "名称": "name", "symbol": "code", "股票代码": "code", "stock_code": "code",
1309
+ "股票名称": "name", "stock_name": "name"
1310
+ }
1311
+ stock_name_df.rename(columns=lambda c: rename_map.get(c, c), inplace=True)
1312
+
1313
+ if 'code' in stock_name_df.columns and 'name' in stock_name_df.columns:
1314
+ name_series = stock_name_df.set_index('code')['name']
1315
+ name = name_series.get(str(stock_code))
1316
+ if not name:
1317
+ self.logger.warning(f"无法从 stock_info_a_code_name 找到股票代码 {stock_code} 的名称")
1318
+ name = "未知"
1319
+ else:
1320
+ self.logger.warning(f"stock_info_a_code_name 返回的DataFrame缺少 'code' 或 'name' 列: {stock_name_df.columns.tolist()}")
1321
+ name = "未知"
1322
+
1323
+ except Exception as e:
1324
+ self.logger.error(f"获取股票名称时出错: {str(e)}")
1325
+ name = "未知"
1326
+
1327
+ info_dict['股票名称'] = name
1328
+
1329
+ # 确保基本字段存在
1330
+ if '行业' not in info_dict:
1331
+ info_dict['行业'] = "未知"
1332
+ if '地区' not in info_dict:
1333
+ info_dict['地区'] = "未知"
1334
+
1335
+ # 增加更多日志来调试问题
1336
+ self.logger.info(f"获取到股票信息: 名称={name}, 行业={info_dict.get('行业', '未知')}")
1337
+
1338
+ self.data_cache[cache_key] = info_dict
1339
+ return info_dict
1340
+ except Exception as e:
1341
+ self.logger.error(f"获取股票信息失败: {str(e)}")
1342
+ return {"股票名称": "未知", "行业": "未知", "地区": "未知"}
1343
+
1344
+ def identify_support_resistance(self, df):
1345
+ """识别支撑位和压力位"""
1346
+ latest_price = df['close'].iloc[-1]
1347
+
1348
+ # 使用布林带作为支撑压力参考
1349
+ support_levels = [df['BB_lower'].iloc[-1]]
1350
+ resistance_levels = [df['BB_upper'].iloc[-1]]
1351
+
1352
+ # 添加主要均线作为支撑压力
1353
+ if latest_price < df['MA5'].iloc[-1]:
1354
+ resistance_levels.append(df['MA5'].iloc[-1])
1355
+ else:
1356
+ support_levels.append(df['MA5'].iloc[-1])
1357
+
1358
+ if latest_price < df['MA20'].iloc[-1]:
1359
+ resistance_levels.append(df['MA20'].iloc[-1])
1360
+ else:
1361
+ support_levels.append(df['MA20'].iloc[-1])
1362
+
1363
+ # 添加整数关口
1364
+ price_digits = len(str(int(latest_price)))
1365
+ base = 10 ** (price_digits - 1)
1366
+
1367
+ lower_integer = math.floor(latest_price / base) * base
1368
+ upper_integer = math.ceil(latest_price / base) * base
1369
+
1370
+ if lower_integer < latest_price:
1371
+ support_levels.append(lower_integer)
1372
+ if upper_integer > latest_price:
1373
+ resistance_levels.append(upper_integer)
1374
+
1375
+ # 排序并格式化
1376
+ support_levels = sorted(set([round(x, 2) for x in support_levels if x < latest_price]), reverse=True)
1377
+ resistance_levels = sorted(set([round(x, 2) for x in resistance_levels if x > latest_price]))
1378
+
1379
+ # 分类为短期和中期
1380
+ short_term_support = support_levels[:1] if support_levels else []
1381
+ medium_term_support = support_levels[1:2] if len(support_levels) > 1 else []
1382
+ short_term_resistance = resistance_levels[:1] if resistance_levels else []
1383
+ medium_term_resistance = resistance_levels[1:2] if len(resistance_levels) > 1 else []
1384
+
1385
+ return {
1386
+ 'support_levels': {
1387
+ 'short_term': short_term_support,
1388
+ 'medium_term': medium_term_support
1389
+ },
1390
+ 'resistance_levels': {
1391
+ 'short_term': short_term_resistance,
1392
+ 'medium_term': medium_term_resistance
1393
+ }
1394
+ }
1395
+
1396
+ def calculate_technical_score(self, df):
1397
+ """计算技术面评分 (0-40分)"""
1398
+ try:
1399
+ score = 0
1400
+ # 确保有足够的数据
1401
+ if len(df) < 2:
1402
+ self.logger.warning("数据不足,无法计算技术面评分")
1403
+ return {'total': 0, 'trend': 0, 'indicators': 0, 'support_resistance': 0, 'volatility_volume': 0}
1404
+
1405
+ latest = df.iloc[-1]
1406
+ prev = df.iloc[-2] # 获取前一个时间点的数据
1407
+ prev_close = prev['close']
1408
+
1409
+ # 1. 趋势分析 (0-10分)
1410
+ trend_score = 0
1411
+
1412
+ # 均线排列情况
1413
+ if latest['MA5'] > latest['MA20'] > latest['MA60']: # 多头排列
1414
+ trend_score += 5
1415
+ elif latest['MA5'] < latest['MA20'] < latest['MA60']: # 空头排列
1416
+ trend_score = 0
1417
+ else: # 交叉状��
1418
+ if latest['MA5'] > latest['MA20']:
1419
+ trend_score += 3
1420
+ if latest['MA20'] > latest['MA60']:
1421
+ trend_score += 2
1422
+
1423
+ # 价格与均线关系
1424
+ if latest['close'] > latest['MA5']:
1425
+ trend_score += 3
1426
+ elif latest['close'] > latest['MA20']:
1427
+ trend_score += 2
1428
+
1429
+ # 限制最大值
1430
+ trend_score = min(trend_score, 10)
1431
+ score += trend_score
1432
+
1433
+ # 2. 技术指标分析 (0-10分)
1434
+ indicator_score = 0
1435
+
1436
+ # RSI
1437
+ if 40 <= latest['RSI'] <= 60: # 中性
1438
+ indicator_score += 2
1439
+ elif 30 <= latest['RSI'] < 40 or 60 < latest['RSI'] <= 70: # 边缘区域
1440
+ indicator_score += 4
1441
+ elif latest['RSI'] < 30: # 超卖
1442
+ indicator_score += 5
1443
+ elif latest['RSI'] > 70: # 超买
1444
+ indicator_score += 0
1445
+
1446
+ # MACD
1447
+ if latest['MACD'] > latest['Signal']: # MACD金叉或在零轴上方
1448
+ indicator_score += 3
1449
+ else:
1450
+ # 修复:比较当前和前一个时间点的MACD柱状图值
1451
+ if latest['MACD_hist'] > prev['MACD_hist']: # 柱状图上升
1452
+ indicator_score += 1
1453
+
1454
+ # 限制最大值和最小值
1455
+ indicator_score = max(0, min(indicator_score, 10))
1456
+ score += indicator_score
1457
+
1458
+ # 3. 支撑压力位分析 (0-10分)
1459
+ sr_score = 0
1460
+
1461
+ # 识别支撑位和压力位
1462
+ middle_price = latest['close']
1463
+ upper_band = latest['BB_upper']
1464
+ lower_band = latest['BB_lower']
1465
+
1466
+ # 距离布林带上下轨的距离
1467
+ upper_distance = (upper_band - middle_price) / middle_price * 100
1468
+ lower_distance = (middle_price - lower_band) / middle_price * 100
1469
+
1470
+ if lower_distance < 2: # 接近下轨
1471
+ sr_score += 5
1472
+ elif lower_distance < 5:
1473
+ sr_score += 3
1474
+
1475
+ if upper_distance > 5: # 距上轨较远
1476
+ sr_score += 5
1477
+ elif upper_distance > 2:
1478
+ sr_score += 2
1479
+
1480
+ # 限制最大值
1481
+ sr_score = min(sr_score, 10)
1482
+ score += sr_score
1483
+
1484
+ # 4. 波动性和成交量分析 (0-10分)
1485
+ vol_score = 0
1486
+
1487
+ # 波动率分析
1488
+ if latest['Volatility'] < 2: # 低波动率
1489
+ vol_score += 3
1490
+ elif latest['Volatility'] < 4: # 中等波动率
1491
+ vol_score += 2
1492
+
1493
+ # 成交量分析
1494
+ if 'Volume_Ratio' in df.columns:
1495
+ if latest['Volume_Ratio'] > 1.5 and latest['close'] > prev_close: # 放量上涨
1496
+ vol_score += 4
1497
+ elif latest['Volume_Ratio'] < 0.8 and latest['close'] < prev_close: # 缩量下跌
1498
+ vol_score += 3
1499
+ elif latest['Volume_Ratio'] > 1 and latest['close'] > prev_close: # 普通放量上涨
1500
+ vol_score += 2
1501
+
1502
+ # 限制最大值
1503
+ vol_score = min(vol_score, 10)
1504
+ score += vol_score
1505
+
1506
+ # 保存各个维度的分数
1507
+ technical_scores = {
1508
+ 'total': score,
1509
+ 'trend': trend_score,
1510
+ 'indicators': indicator_score,
1511
+ 'support_resistance': sr_score,
1512
+ 'volatility_volume': vol_score
1513
+ }
1514
+
1515
+ return technical_scores
1516
+
1517
+ except Exception as e:
1518
+ self.logger.error(f"计算技术面评分时出错: {str(e)}")
1519
+ self.logger.error(f"错误详情: {traceback.format_exc()}")
1520
+ return {'total': 0, 'trend': 0, 'indicators': 0, 'support_resistance': 0, 'volatility_volume': 0}
1521
+
1522
+ def perform_enhanced_analysis(self, stock_code, market_type='A'):
1523
+ """执行增强版分析"""
1524
+ try:
1525
+ # 记录开始时间,便于性能分析
1526
+ start_time = time.time()
1527
+ self.logger.info(f"开始执行股票 {stock_code} 的增强分析")
1528
+
1529
+ # 获取股票数据
1530
+ df = self.get_stock_data(stock_code, market_type)
1531
+ data_time = time.time()
1532
+ self.logger.info(f"获取股票数据耗时: {data_time - start_time:.2f}秒")
1533
+
1534
+ # 计算技术指标
1535
+ df = self.calculate_indicators(df)
1536
+ indicator_time = time.time()
1537
+ self.logger.info(f"计算技术指标耗时: {indicator_time - data_time:.2f}秒")
1538
+
1539
+ # 获取最新数据
1540
+ latest = df.iloc[-1]
1541
+ prev = df.iloc[-2] if len(df) > 1 else latest
1542
+
1543
+ # 获取支撑压力位
1544
+ sr_levels = self.identify_support_resistance(df)
1545
+
1546
+ # 计算技术面评分
1547
+ technical_score = self.calculate_technical_score(df)
1548
+
1549
+ # 获取股票信息
1550
+ stock_info = self.get_stock_info(stock_code)
1551
+
1552
+ # 确保technical_score包含必要的字段
1553
+ if 'total' not in technical_score:
1554
+ technical_score['total'] = 0
1555
+
1556
+ # 生成增强版报告
1557
+ enhanced_report = {
1558
+ 'basic_info': {
1559
+ 'stock_code': stock_code,
1560
+ 'stock_name': stock_info.get('股票名称', '未知'),
1561
+ 'industry': stock_info.get('行业', '未知'),
1562
+ 'analysis_date': datetime.now().strftime('%Y-%m-%d')
1563
+ },
1564
+ 'price_data': {
1565
+ 'current_price': float(latest['close']), # 确保是Python原生类型
1566
+ 'price_change': float((latest['close'] - prev['close']) / prev['close'] * 100),
1567
+ 'price_change_value': float(latest['close'] - prev['close'])
1568
+ },
1569
+ 'technical_analysis': {
1570
+ 'trend': {
1571
+ 'ma_trend': 'UP' if latest['MA5'] > latest['MA20'] else 'DOWN',
1572
+ 'ma_status': "多头排列" if latest['MA5'] > latest['MA20'] > latest['MA60'] else
1573
+ "空头排列" if latest['MA5'] < latest['MA20'] < latest['MA60'] else
1574
+ "交叉状态",
1575
+ 'ma_values': {
1576
+ 'ma5': float(latest['MA5']),
1577
+ 'ma20': float(latest['MA20']),
1578
+ 'ma60': float(latest['MA60'])
1579
+ }
1580
+ },
1581
+ 'indicators': {
1582
+ # 确保所有指标都存在并是原生类型
1583
+ 'rsi': float(latest['RSI']) if 'RSI' in latest else 50.0,
1584
+ 'macd': float(latest['MACD']) if 'MACD' in latest else 0.0,
1585
+ 'macd_signal': float(latest['Signal']) if 'Signal' in latest else 0.0,
1586
+ 'macd_histogram': float(latest['MACD_hist']) if 'MACD_hist' in latest else 0.0,
1587
+ 'volatility': float(latest['Volatility']) if 'Volatility' in latest else 0.0
1588
+ },
1589
+ 'volume': {
1590
+ 'current_volume': float(latest['volume']) if 'volume' in latest else 0.0,
1591
+ 'volume_ratio': float(latest['Volume_Ratio']) if 'Volume_Ratio' in latest else 1.0,
1592
+ 'volume_status': '放量' if 'Volume_Ratio' in latest and latest['Volume_Ratio'] > 1.5 else '平量'
1593
+ },
1594
+ 'support_resistance': sr_levels
1595
+ },
1596
+ 'scores': technical_score,
1597
+ 'recommendation': {
1598
+ 'action': self.get_recommendation(technical_score['total']),
1599
+ 'key_points': []
1600
+ },
1601
+ 'ai_analysis': self._build_stock_prompt_and_get_analysis(df, stock_code)
1602
+ }
1603
+
1604
+ # 最后检查并修复报告结构
1605
+ self._validate_and_fix_report(enhanced_report)
1606
+
1607
+ # 在函数结束时记录总耗时
1608
+ end_time = time.time()
1609
+ self.logger.info(f"执行增强分析总耗时: {end_time - start_time:.2f}秒")
1610
+
1611
+ return enhanced_report
1612
+
1613
+ except Exception as e:
1614
+ self.logger.error(f"执行增强版分析时出错: {str(e)}")
1615
+ self.logger.error(traceback.format_exc())
1616
+
1617
+ # 返回基础错误报告
1618
+ return {
1619
+ 'basic_info': {
1620
+ 'stock_code': stock_code,
1621
+ 'stock_name': '分析失败',
1622
+ 'industry': '未知',
1623
+ 'analysis_date': datetime.now().strftime('%Y-%m-%d')
1624
+ },
1625
+ 'price_data': {
1626
+ 'current_price': 0.0,
1627
+ 'price_change': 0.0,
1628
+ 'price_change_value': 0.0
1629
+ },
1630
+ 'technical_analysis': {
1631
+ 'trend': {
1632
+ 'ma_trend': 'UNKNOWN',
1633
+ 'ma_status': '未知',
1634
+ 'ma_values': {'ma5': 0.0, 'ma20': 0.0, 'ma60': 0.0}
1635
+ },
1636
+ 'indicators': {
1637
+ 'rsi': 50.0,
1638
+ 'macd': 0.0,
1639
+ 'macd_signal': 0.0,
1640
+ 'macd_histogram': 0.0,
1641
+ 'volatility': 0.0
1642
+ },
1643
+ 'volume': {
1644
+ 'current_volume': 0.0,
1645
+ 'volume_ratio': 0.0,
1646
+ 'volume_status': 'NORMAL'
1647
+ },
1648
+ 'support_resistance': {
1649
+ 'support_levels': {'short_term': [], 'medium_term': []},
1650
+ 'resistance_levels': {'short_term': [], 'medium_term': []}
1651
+ }
1652
+ },
1653
+ 'scores': {'total': 0},
1654
+ 'recommendation': {'action': '分析出错,无法提供建议'},
1655
+ 'ai_analysis': f"分���过程中出错: {str(e)}"
1656
+ }
1657
+
1658
+ return error_report
1659
+
1660
+ # 添加一个辅助方法确保报告结构完整
1661
+ def _validate_and_fix_report(self, report):
1662
+ """确保分析报告结构完整"""
1663
+ # 检查必要的顶级字段
1664
+ required_sections = ['basic_info', 'price_data', 'technical_analysis', 'scores', 'recommendation',
1665
+ 'ai_analysis']
1666
+ for section in required_sections:
1667
+ if section not in report:
1668
+ self.logger.warning(f"报告缺少 {section} 部分,添加空对象")
1669
+ report[section] = {}
1670
+
1671
+ # 检查technical_analysis的结构
1672
+ if 'technical_analysis' in report:
1673
+ tech = report['technical_analysis']
1674
+ if not isinstance(tech, dict):
1675
+ report['technical_analysis'] = {}
1676
+ tech = report['technical_analysis']
1677
+
1678
+ # 检查indicators部分
1679
+ if 'indicators' not in tech or not isinstance(tech['indicators'], dict):
1680
+ tech['indicators'] = {
1681
+ 'rsi': 50.0,
1682
+ 'macd': 0.0,
1683
+ 'macd_signal': 0.0,
1684
+ 'macd_histogram': 0.0,
1685
+ 'volatility': 0.0
1686
+ }
1687
+
1688
+ # 转换所有指标为原生Python类型
1689
+ for key, value in tech['indicators'].items():
1690
+ try:
1691
+ tech['indicators'][key] = float(value)
1692
+ except (TypeError, ValueError):
1693
+ tech['indicators'][key] = 0.0
app/analysis/stock_qa.py ADDED
@@ -0,0 +1,484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 智能分析系统(股票) - 股票市场数据分析系统
4
+ 开发者:熊猫大侠
5
+ 版本:v2.1.0
6
+ 许可证:MIT License
7
+
8
+ stock_qa.py - 提供股票相关问题的智能问答功能,支持联网搜索实时信息和多轮对话
9
+ """
10
+
11
+ import os
12
+ import json
13
+ import traceback
14
+ import openai
15
+ from openai import OpenAI
16
+ from urllib.parse import urlparse
17
+ from datetime import datetime
18
+
19
+
20
+ class StockQA:
21
+ def __init__(self, analyzer, openai_api_key=None):
22
+ self.analyzer = analyzer
23
+ self.openai_api_key = os.getenv('OPENAI_API_KEY', openai_api_key)
24
+ self.openai_api_url = os.getenv('OPENAI_API_URL', 'https://api.openai.com/v1')
25
+ self.openai_model = os.getenv('OPENAI_API_MODEL', 'gpt-4o')
26
+ self.client = OpenAI(
27
+ api_key=self.openai_api_key,
28
+ base_url=self.openai_api_url
29
+ )
30
+ self.serp_api_key = os.getenv('SERP_API_KEY')
31
+ self.tavily_api_key = os.getenv('TAVILY_API_KEY')
32
+ self.max_qa_rounds = int(os.getenv('MAX_QA', '10')) # 默认保留10轮对话
33
+
34
+ # 对话历史存储 - 使用字典存储不同股票的对话历史
35
+ self.conversation_history = {}
36
+
37
+ # 设置日志记录
38
+ import logging
39
+ self.logger = logging.getLogger(__name__)
40
+
41
+ def answer_question(self, stock_code, question, market_type='A', conversation_id=None, clear_history=False):
42
+ """
43
+ 回答关于股票的问题,支持联网搜索实时信息和多轮对话
44
+
45
+ 参数:
46
+ stock_code: 股票代码
47
+ question: 用户问题
48
+ market_type: 市场类型 (A/HK/US)
49
+ conversation_id: 对话ID,用于跟踪对话历史,如果为None则自动生成
50
+ clear_history: 是否清除对话历史,开始新对话
51
+
52
+ 返回:
53
+ 包含回答和元数据的字典
54
+ """
55
+ try:
56
+ if not self.openai_api_key:
57
+ return {"error": "未配置API密钥,无法使用智能问答功能"}
58
+
59
+ # 处理对话ID和历史
60
+ if conversation_id is None:
61
+ # 生成新的对话ID
62
+ import uuid
63
+ conversation_id = f"{stock_code}_{uuid.uuid4().hex[:8]}"
64
+
65
+ # 获取或创建对话历史
66
+ if clear_history or conversation_id not in self.conversation_history:
67
+ self.conversation_history[conversation_id] = []
68
+
69
+ # 获取股票信息和技术指标 - 每次都获取最新数据
70
+ stock_context = self._get_stock_context(stock_code, market_type)
71
+ stock_name = stock_context.get("stock_name", "未知")
72
+
73
+ # 定义搜索新闻的工具
74
+ tools = [
75
+ {
76
+ "type": "function",
77
+ "function": {
78
+ "name": "search_stock_news",
79
+ "description": "搜索股票相关的最新新闻、公告和行业动态信息,以获取实时市场信息",
80
+ "parameters": {
81
+ "type": "object",
82
+ "properties": {
83
+ "query": {
84
+ "type": "string",
85
+ "description": "搜索查询词,用于查找相关新闻"
86
+ }
87
+ },
88
+ "required": ["query"]
89
+ }
90
+ }
91
+ }
92
+ ]
93
+
94
+ # 设置增强版系统提示
95
+ system_content = """你是摩根大通的高级宏观策略师和首席投资顾问,拥有哈佛经济学博士学位和20年华尔街顶级投行经验。你同时也是国家发改委、央行和证监会的政策研究顾问团专家,了解中国宏观经济和产业政策走向。
96
+
97
+ 你的特点是:
98
+ 1. 思维深度 - 从表面现象洞察深层次的经济周期、产业迁移和资本流向规律,预见市场忽视的长期趋势
99
+ 2. 全局视角 - 将个股分析放在全球经济格局、国内政策环境、产业转型、供应链重构和流动性周期的大背景下
100
+ 3. 结构化思考 - 运用专业框架如PEST分析、波特五力模型、杜邦分析、价值链分析和SWOT分析等系统评估
101
+ 4. 多层次透视 - 能同时从资本市场定价、产业发展阶段、公司竞争地位和治理结构等维度剖析股票价值
102
+ 5. 前瞻预判 - 善于前瞻性分析科技创新、产业政策和地缘政治变化对中长期市场格局的影响
103
+
104
+ 沟通时,你会:
105
+ - 将复杂的金融概念转化为简洁明了的比喻和案例,使普通投资者理解专业分析
106
+ - 强调投资思维和方法论,而非简单的买卖建议
107
+ - 提供层次分明的分析:1)微观公司基本面 2)中观产业格局 3)宏观经济环境
108
+ - 引用相关研究、历史案例或数据支持你的观点
109
+ - 在必要时搜���最新资讯,确保观点基于最新市场情况
110
+ - 兼顾短中长期视角,帮助投资者建立自己的投资决策框架
111
+
112
+ 作为金融专家,你始终:
113
+ - 谨慎评估不同情景下的概率分布,而非做出确定性预测
114
+ - 坦承市场的不确定性和你认知的边界
115
+ - 同时提供乐观和保守的观点,帮助用户全面权衡
116
+ - 强调风险管理和长期投资价值
117
+ - 避免传播市场谣言或未经证实的信息
118
+
119
+ 请记住,你的价值在于提供深度思考框架和专业视角,帮助投资者做出明智决策,而非简单的投资指令。在需要时,使用search_stock_news工具获取最新市场信息。
120
+ """
121
+
122
+ # 准备对话消息列表,加入系统提示和股票上下文
123
+ messages = [
124
+ {"role": "system", "content": system_content},
125
+ {"role": "user", "content": f"以下是关于股票的基础信息,作为我们对话的背景资料:\n\n{stock_context['context']}"}
126
+ ]
127
+
128
+ # 添加对话历史记录
129
+ messages.extend(self.conversation_history[conversation_id])
130
+
131
+ # 添加当前问题
132
+ messages.append({"role": "user", "content": question})
133
+
134
+
135
+ # 调用AI API
136
+ #第一步:调用模型,让它决定是否使用工具
137
+ first_response = self.client.chat.completions.create(
138
+ model=self.openai_model,
139
+ messages=messages,
140
+ tools=tools,
141
+ tool_choice="auto",
142
+ temperature=0.7
143
+ )
144
+
145
+ # 获取初始响应
146
+ assistant_message = first_response.choices[0].message
147
+ response_content = assistant_message.content
148
+ used_search_tool = False
149
+
150
+ # 检查是否需要使用工具调用
151
+ if hasattr(assistant_message, 'tool_calls') and assistant_message.tool_calls:
152
+ used_search_tool = True
153
+ # 创建新的消息列表,包含工具调用
154
+ tool_messages = list(messages) # 复制原始消息列表
155
+ tool_messages.append({"role": "assistant", "tool_calls": assistant_message.tool_calls})
156
+
157
+ # 处理工具调用
158
+ for tool_call in assistant_message.tool_calls:
159
+ function_name = tool_call.function.name
160
+ function_args = json.loads(tool_call.function.arguments)
161
+
162
+ if function_name == "search_stock_news":
163
+ # 执行新闻搜索
164
+ search_query = function_args.get("query")
165
+ self.logger.info(f"执行新闻搜索: {search_query}")
166
+ search_results = self.search_stock_news(
167
+ search_query,
168
+ stock_context.get("stock_name", ""),
169
+ stock_code,
170
+ stock_context.get("industry", ""),
171
+ market_type
172
+ )
173
+
174
+ # 添加工具响应
175
+ tool_messages.append({
176
+ "tool_call_id": tool_call.id,
177
+ "role": "tool",
178
+ "name": function_name,
179
+ "content": json.dumps(search_results, ensure_ascii=False)
180
+ })
181
+
182
+ # 第二步:让模型根据工具调用结果生成最终响应
183
+ second_response = self.client.chat.completions.create(
184
+ model=self.openai_model,
185
+ messages=tool_messages,
186
+ temperature=0.7
187
+ )
188
+
189
+ response_content = second_response.choices[0].message.content
190
+ assistant_message = {"role": "assistant", "content": response_content}
191
+ else:
192
+ assistant_message = {"role": "assistant", "content": response_content}
193
+
194
+ # 更新对话历史
195
+ self.conversation_history[conversation_id].append({"role": "user", "content": question})
196
+ self.conversation_history[conversation_id].append(assistant_message)
197
+
198
+ # 限制对话历史长度
199
+ if len(self.conversation_history[conversation_id]) > self.max_qa_rounds * 2:
200
+ # 保留最近的MAX_QA轮对话
201
+ self.conversation_history[conversation_id] = self.conversation_history[conversation_id][-self.max_qa_rounds * 2:]
202
+
203
+ # 返回结果
204
+ return {
205
+ "conversation_id": conversation_id,
206
+ "question": question,
207
+ "answer": response_content,
208
+ "stock_code": stock_code,
209
+ "stock_name": stock_name,
210
+ "used_search_tool": used_search_tool,
211
+ "conversation_length": len(self.conversation_history[conversation_id]) // 2 # 轮数
212
+ }
213
+
214
+ except Exception as e:
215
+ self.logger.error(f"智能问答出错: {str(e)}")
216
+ self.logger.error(traceback.format_exc())
217
+ return {
218
+ "question": question,
219
+ "answer": f"抱歉,回答问题时出错: {str(e)}",
220
+ "stock_code": stock_code,
221
+ "error": str(e)
222
+ }
223
+
224
+ def _get_stock_context(self, stock_code, market_type='A'):
225
+ """获取股票上下文信息"""
226
+ try:
227
+ # 获取股票信息
228
+ stock_info = self.analyzer.get_stock_info(stock_code)
229
+ stock_name = stock_info.get('股票名称', '未知')
230
+ industry = stock_info.get('行业', '未知')
231
+
232
+ # 获取技术指标数据
233
+ df = self.analyzer.get_stock_data(stock_code, market_type)
234
+ df = self.analyzer.calculate_indicators(df)
235
+
236
+ # 提取最新数据
237
+ latest = df.iloc[-1]
238
+
239
+ # 计算评分
240
+ score = self.analyzer.calculate_score(df)
241
+
242
+ # 获取支撑压力位
243
+ sr_levels = self.analyzer.identify_support_resistance(df)
244
+
245
+ # 构建上下文
246
+ context = f"""股票信息:
247
+ - 代码: {stock_code}
248
+ - 名称: {stock_name}
249
+ - 行业: {industry}
250
+
251
+ 技术指标(最新数据):
252
+ - 价格: {latest['close']}
253
+ - 5日均线: {latest['MA5']}
254
+ - 20日均线: {latest['MA20']}
255
+ - 60日均线: {latest['MA60']}
256
+ - RSI: {latest['RSI']}
257
+ - MACD: {latest['MACD']}
258
+ - MACD信号线: {latest['Signal']}
259
+ - 布林带上轨: {latest['BB_upper']}
260
+ - 布林带中轨: {latest['BB_middle']}
261
+ - 布林带下轨: {latest['BB_lower']}
262
+ - 波动率: {latest['Volatility']}%
263
+
264
+ 技术评分: {score}分
265
+
266
+ 支撑位:
267
+ - 短期: {', '.join([str(level) for level in sr_levels['support_levels']['short_term']])}
268
+ - 中期: {', '.join([str(level) for level in sr_levels['support_levels']['medium_term']])}
269
+
270
+ 压力位:
271
+ - 短期: {', '.join([str(level) for level in sr_levels['resistance_levels']['short_term']])}
272
+ - 中期: {', '.join([str(level) for level in sr_levels['resistance_levels']['medium_term']])}"""
273
+
274
+ # 尝试获取基本面数据
275
+ try:
276
+ # 导入基本面分析器
277
+ from app.analysis.fundamental_analyzer import FundamentalAnalyzer
278
+ fundamental = FundamentalAnalyzer()
279
+
280
+ # 获取基本面数据
281
+ indicators = fundamental.get_financial_indicators(stock_code)
282
+
283
+ # 添加到上下文
284
+ context += f"""
285
+
286
+ 基本面指标:
287
+ - PE(TTM): {indicators.get('pe_ttm', '未知')}
288
+ - PB: {indicators.get('pb', '未知')}
289
+ - ROE: {indicators.get('roe', '未知')}%
290
+ - 毛利率: {indicators.get('gross_margin', '未知')}%
291
+ - 净利率: {indicators.get('net_profit_margin', '未知')}%"""
292
+ except Exception as e:
293
+ self.logger.warning(f"获取基本面数据失败: {str(e)}")
294
+ context += "\n\n注意:未能获取基本面数据"
295
+
296
+ return {
297
+ "context": context,
298
+ "stock_name": stock_name,
299
+ "industry": industry
300
+ }
301
+ except Exception as e:
302
+ self.logger.error(f"获取股票上下文信息出错: {str(e)}")
303
+ return {
304
+ "context": f"无法获取股票 {stock_code} 的完整信息: {str(e)}",
305
+ "stock_name": "未知",
306
+ "industry": "未知"
307
+ }
308
+
309
+ def clear_conversation(self, conversation_id=None, stock_code=None):
310
+ """
311
+ 清除特定对话或与特定股票相关的所有对话历史
312
+
313
+ 参数:
314
+ conversation_id: 指定要清除的对话ID
315
+ stock_code: 指定要清除的股票相关的所有对话
316
+ """
317
+ if conversation_id and conversation_id in self.conversation_history:
318
+ # 清除特定对话
319
+ del self.conversation_history[conversation_id]
320
+ return {"message": f"已清除对话 {conversation_id}"}
321
+
322
+ elif stock_code:
323
+ # 清除与特定股票相关的所有对话
324
+ removed = []
325
+ for conv_id in list(self.conversation_history.keys()):
326
+ if conv_id.startswith(f"{stock_code}_"):
327
+ del self.conversation_history[conv_id]
328
+ removed.append(conv_id)
329
+ return {"message": f"已清除与股票 {stock_code} 相关的 {len(removed)} 个对话"}
330
+
331
+ else:
332
+ # 清除所有对话
333
+ count = len(self.conversation_history)
334
+ self.conversation_history.clear()
335
+ return {"message": f"已清除所有 {count} 个对话"}
336
+
337
+ def get_conversation_history(self, conversation_id):
338
+ """获取特定对话的历史记录"""
339
+ if conversation_id not in self.conversation_history:
340
+ return {"error": f"找不到对话 {conversation_id}"}
341
+
342
+ # 提取用户问题和助手回答
343
+ history = []
344
+ conversation = self.conversation_history[conversation_id]
345
+
346
+ # 按对话轮次提取历史
347
+ for i in range(0, len(conversation), 2):
348
+ if i+1 < len(conversation):
349
+ history.append({
350
+ "question": conversation[i]["content"],
351
+ "answer": conversation[i+1]["content"]
352
+ })
353
+
354
+ return {
355
+ "conversation_id": conversation_id,
356
+ "history": history,
357
+ "round_count": len(history)
358
+ }
359
+
360
+ def search_stock_news(self, query, stock_name, stock_code, industry, market_type='A'):
361
+ """搜索股票相关新闻和实时信息"""
362
+ try:
363
+ self.logger.info(f"搜索股票新闻: {query}")
364
+
365
+ # 确定市场名称
366
+ market_name = "A股" if market_type == 'A' else "港股" if market_type == 'HK' else "美股"
367
+
368
+ # 检查API密钥
369
+ if not self.serp_api_key and not self.tavily_api_key:
370
+ self.logger.warning("未配置搜索API密钥")
371
+ return {
372
+ "message": "无法搜索新闻,未配置搜索API密钥",
373
+ "results": []
374
+ }
375
+
376
+ news_results = []
377
+
378
+ # 使用SERP API搜索
379
+ if self.serp_api_key:
380
+ try:
381
+ import requests
382
+
383
+ # 构建搜索查询
384
+ search_query = f"{stock_name} {stock_code} {market_name} {query}"
385
+
386
+ # 调用SERP API
387
+ url = "https://serpapi.com/search"
388
+ params = {
389
+ "engine": "google",
390
+ "q": search_query,
391
+ "api_key": self.serp_api_key,
392
+ "tbm": "nws", # 新闻搜索
393
+ "num": 5 # 获取5条结果
394
+ }
395
+
396
+ response = requests.get(url, params=params)
397
+ search_results = response.json()
398
+
399
+ # 提取新闻结果
400
+ if "news_results" in search_results:
401
+ for item in search_results["news_results"]:
402
+ news_results.append({
403
+ "title": item.get("title", ""),
404
+ "date": item.get("date", ""),
405
+ "source": item.get("source", ""),
406
+ "snippet": item.get("snippet", ""),
407
+ "link": item.get("link", "")
408
+ })
409
+ except Exception as e:
410
+ self.logger.error(f"SERP API搜索出错: {str(e)}")
411
+
412
+ # 使用Tavily API搜索
413
+ if self.tavily_api_key:
414
+ try:
415
+ from tavily import TavilyClient
416
+
417
+ client = TavilyClient(self.tavily_api_key)
418
+
419
+ # 构建搜索查询
420
+ search_query = f"{stock_name} {stock_code} {market_name} {query}"
421
+
422
+ # 调用Tavily API
423
+ response = client.search(
424
+ query=search_query,
425
+ topic="finance",
426
+ search_depth="advanced"
427
+ )
428
+
429
+ # 提取结果
430
+ if "results" in response:
431
+ for item in response["results"]:
432
+ # 从URL提取域名作为来源
433
+ source = ""
434
+ if item.get("url"):
435
+ try:
436
+ parsed_url = urlparse(item.get("url"))
437
+ source = parsed_url.netloc
438
+ except:
439
+ source = "未知来源"
440
+
441
+ news_results.append({
442
+ "title": item.get("title", ""),
443
+ "date": datetime.now().strftime("%Y-%m-%d"), # Tavily不提供日期
444
+ "source": source,
445
+ "snippet": item.get("content", ""),
446
+ "link": item.get("url", "")
447
+ })
448
+ except ImportError:
449
+ self.logger.warning("未安装Tavily客户端库,请使用pip install tavily-python安装")
450
+ except Exception as e:
451
+ self.logger.error(f"Tavily API搜索出错: {str(e)}")
452
+
453
+ # 去重并限制结果数量
454
+ unique_results = []
455
+ seen_titles = set()
456
+
457
+ for item in news_results:
458
+ title = item.get("title", "").strip()
459
+ if title and title not in seen_titles:
460
+ seen_titles.add(title)
461
+ unique_results.append(item)
462
+ if len(unique_results) >= 5: # 最多返回5条结果
463
+ break
464
+
465
+ # 创建格式化的摘要文本
466
+ summary_text = ""
467
+ for i, item in enumerate(unique_results):
468
+ summary_text += f"{i+1}、{item.get('title', '')}\n"
469
+ summary_text += f"{item.get('snippet', '')}\n"
470
+ summary_text += f"来源: {item.get('source', '')} {item.get('date', '')}\n\n"
471
+
472
+ return {
473
+ "message": f"找到 {len(unique_results)} 条相关新闻",
474
+ "results": unique_results,
475
+ "summary": summary_text
476
+ }
477
+
478
+ except Exception as e:
479
+ self.logger.error(f"搜索股票新闻时出错: {str(e)}")
480
+ self.logger.error(traceback.format_exc())
481
+ return {
482
+ "message": f"搜索新闻时出错: {str(e)}",
483
+ "results": []
484
+ }
app/analysis/us_stock_service.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 智能分析系统(股票) - 股票市场数据分析系统
4
+ 修改:熊猫大侠
5
+ 版本:v2.1.0
6
+ """
7
+ # us_stock_service.py
8
+ import akshare as ak
9
+ import pandas as pd
10
+ import logging
11
+
12
+
13
+ class USStockService:
14
+ def __init__(self):
15
+ logging.basicConfig(level=logging.INFO,
16
+ format='%(asctime)s - %(levelname)s - %(message)s')
17
+ self.logger = logging.getLogger(__name__)
18
+
19
+ def search_us_stocks(self, keyword):
20
+ """
21
+ 搜索美股代码
22
+ :param keyword: 搜索关键词
23
+ :return: 匹配的股票列表
24
+ """
25
+ try:
26
+ # 获取美股数据
27
+ df = ak.stock_us_spot_em()
28
+
29
+ # 转换列名
30
+ df = df.rename(columns={
31
+ "序号": "index",
32
+ "名称": "name",
33
+ "最新价": "price",
34
+ "涨跌额": "price_change",
35
+ "涨跌幅": "price_change_percent",
36
+ "开盘价": "open",
37
+ "最高价": "high",
38
+ "最低价": "low",
39
+ "昨收价": "pre_close",
40
+ "总市值": "market_value",
41
+ "市盈率": "pe_ratio",
42
+ "成交量": "volume",
43
+ "成交额": "turnover",
44
+ "振幅": "amplitude",
45
+ "换手率": "turnover_rate",
46
+ "代码": "symbol"
47
+ })
48
+
49
+ # 模糊匹配搜索
50
+ mask = df['name'].str.contains(keyword, case=False, na=False)
51
+ results = df[mask]
52
+
53
+ # 格式化返回结果并处理 NaN 值
54
+ formatted_results = []
55
+ for _, row in results.iterrows():
56
+ formatted_results.append({
57
+ 'name': row['name'] if pd.notna(row['name']) else '',
58
+ 'symbol': str(row['symbol']) if pd.notna(row['symbol']) else '',
59
+ 'price': float(row['price']) if pd.notna(row['price']) else 0.0,
60
+ 'market_value': float(row['market_value']) if pd.notna(row['market_value']) else 0.0
61
+ })
62
+
63
+ return formatted_results
64
+
65
+ except Exception as e:
66
+ self.logger.error(f"搜索美股代码时出错: {str(e)}")
67
+ raise Exception(f"搜索美股代码失败: {str(e)}")
app/core/database.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, Text, JSON
3
+ from sqlalchemy.ext.declarative import declarative_base
4
+ from sqlalchemy.orm import sessionmaker
5
+ from datetime import datetime
6
+
7
+ # 读取配置
8
+ DATABASE_URL = os.getenv('DATABASE_URL', 'sqlite:///data/stock_analyzer.db')
9
+ USE_DATABASE = os.getenv('USE_DATABASE', 'False').lower() == 'true'
10
+
11
+ # 创建引擎
12
+ engine = create_engine(DATABASE_URL)
13
+ Base = declarative_base()
14
+
15
+
16
+ # 定义模型
17
+ class StockInfo(Base):
18
+ __tablename__ = 'stock_info'
19
+
20
+ id = Column(Integer, primary_key=True)
21
+ stock_code = Column(String(10), nullable=False, index=True)
22
+ stock_name = Column(String(50))
23
+ market_type = Column(String(5))
24
+ industry = Column(String(50))
25
+ updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
26
+
27
+ def to_dict(self):
28
+ return {
29
+ 'stock_code': self.stock_code,
30
+ 'stock_name': self.stock_name,
31
+ 'market_type': self.market_type,
32
+ 'industry': self.industry,
33
+ 'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None
34
+ }
35
+
36
+
37
+ class AnalysisResult(Base):
38
+ __tablename__ = 'analysis_results'
39
+
40
+ id = Column(Integer, primary_key=True)
41
+ stock_code = Column(String(10), nullable=False, index=True)
42
+ market_type = Column(String(5))
43
+ analysis_date = Column(DateTime, default=datetime.now)
44
+ score = Column(Float)
45
+ recommendation = Column(String(100))
46
+ technical_data = Column(JSON)
47
+ fundamental_data = Column(JSON)
48
+ capital_flow_data = Column(JSON)
49
+ ai_analysis = Column(Text)
50
+
51
+ def to_dict(self):
52
+ return {
53
+ 'stock_code': self.stock_code,
54
+ 'market_type': self.market_type,
55
+ 'analysis_date': self.analysis_date.strftime('%Y-%m-%d %H:%M:%S') if self.analysis_date else None,
56
+ 'score': self.score,
57
+ 'recommendation': self.recommendation,
58
+ 'technical_data': self.technical_data,
59
+ 'fundamental_data': self.fundamental_data,
60
+ 'capital_flow_data': self.capital_flow_data,
61
+ 'ai_analysis': self.ai_analysis
62
+ }
63
+
64
+
65
+ class Portfolio(Base):
66
+ __tablename__ = 'portfolios'
67
+
68
+ id = Column(Integer, primary_key=True)
69
+ user_id = Column(String(50), nullable=False, index=True)
70
+ name = Column(String(100))
71
+ created_at = Column(DateTime, default=datetime.now)
72
+ updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
73
+ stocks = Column(JSON) # 存储股票列表的JSON
74
+
75
+ def to_dict(self):
76
+ return {
77
+ 'id': self.id,
78
+ 'user_id': self.user_id,
79
+ 'name': self.name,
80
+ 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
81
+ 'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None,
82
+ 'stocks': self.stocks
83
+ }
84
+
85
+
86
+ # 创建会话工厂
87
+ Session = sessionmaker(bind=engine)
88
+
89
+
90
+ # 初始化数据库
91
+ def init_db():
92
+ Base.metadata.create_all(engine)
93
+
94
+
95
+ # 获取数据库会话
96
+ def get_session():
97
+ return Session()
98
+
99
+
100
+ # 如果启用数据库,则初始化
101
+ if USE_DATABASE:
102
+ init_db()
app/web/auth_middleware.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import wraps
2
+ from flask import request, jsonify
3
+ import os
4
+ import time
5
+ import hashlib
6
+ import hmac
7
+
8
+
9
+ def get_api_key():
10
+ return os.getenv('API_KEY', 'UZXJfw3YNX80DLfN')
11
+
12
+
13
+ def require_api_key(f):
14
+ """需要API密钥验证的装饰器"""
15
+ @wraps(f)
16
+ def decorated_function(*args, **kwargs):
17
+ api_key = request.headers.get('X-API-Key')
18
+ if not api_key:
19
+ return jsonify({'error': '缺少API密钥'}), 401
20
+
21
+ if api_key != get_api_key():
22
+ return jsonify({'error': '无效的API密钥'}), 403
23
+
24
+ return f(*args, **kwargs)
25
+ return decorated_function
26
+
27
+
28
+ def generate_hmac_signature(data, secret_key=None):
29
+ if secret_key is None:
30
+ secret_key = os.getenv('HMAC_SECRET', 'default_hmac_secret_for_development')
31
+
32
+ if isinstance(data, dict):
33
+ # 对字典进行排序,确保相同的数据产生相同的签名
34
+ data = '&'.join(f"{k}={v}" for k, v in sorted(data.items()))
35
+
36
+ # 使用HMAC-SHA256生成签名
37
+ signature = hmac.new(
38
+ secret_key.encode(),
39
+ data.encode(),
40
+ hashlib.sha256
41
+ ).hexdigest()
42
+
43
+ return signature
44
+
45
+
46
+ def verify_hmac_signature(request_signature, data, secret_key=None):
47
+ expected_signature = generate_hmac_signature(data, secret_key)
48
+ return hmac.compare_digest(request_signature, expected_signature)
49
+
50
+
51
+ def require_hmac_auth(f):
52
+ """需要HMAC认证的装饰器"""
53
+ @wraps(f)
54
+ def decorated_function(*args, **kwargs):
55
+ request_signature = request.headers.get('X-HMAC-Signature')
56
+ if not request_signature:
57
+ return jsonify({'error': '缺少HMAC签名'}), 401
58
+
59
+ # 获取请求数据
60
+ data = request.get_json(silent=True) or {}
61
+
62
+ # 添加时间戳防止重放攻击
63
+ timestamp = request.headers.get('X-Timestamp')
64
+ if not timestamp:
65
+ return jsonify({'error': '缺少时间戳'}), 401
66
+
67
+ # 验证时间戳有效性(有效期5分钟)
68
+ current_time = int(time.time())
69
+ if abs(current_time - int(timestamp)) > 300:
70
+ return jsonify({'error': '时间戳已过期'}), 401
71
+
72
+ # 将时间戳加入验证数据
73
+ verification_data = {**data, 'timestamp': timestamp}
74
+
75
+ # 验证签名
76
+ if not verify_hmac_signature(request_signature, verification_data):
77
+ return jsonify({'error': '签名无效'}), 403
78
+ return f(*args, **kwargs)
79
+ return decorated_function
app/web/industry_api_endpoints.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 智能分析系统(股票) - 股票市场数据分析系统
4
+ 开发者:熊猫大侠
5
+ 版本:v2.1.0
6
+ 许可证:MIT License
7
+ """
8
+ # industry_api_endpoints.py
9
+ # 预留接口
app/web/static/css/theme.css ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg-color: #f8f9fa;
3
+ --text-color: #212529;
4
+ --card-bg-color: #ffffff;
5
+ --border-color: #dee2e6;
6
+ --primary-color: #0d6efd;
7
+ --sidebar-bg-color: #343a40;
8
+ --sidebar-text-color: #ced4da;
9
+ --sidebar-hover-bg-color: rgba(255, 255, 255, 0.1);
10
+ --sidebar-active-bg-color: rgba(255, 255, 255, 0.2);
11
+ --link-color: #0d6efd;
12
+ --link-hover-color: #0a58ca;
13
+ }
14
+
15
+ html[data-theme='dark'] {
16
+ --bg-color: #1c1c1e;
17
+ --text-color: #e0e0e0;
18
+ --card-bg-color: #2c2c2e;
19
+ --border-color: #444444;
20
+ --primary-color: #4da6ff;
21
+ --sidebar-bg-color: #232325;
22
+ --sidebar-text-color: #b0b0b0;
23
+ --sidebar-hover-bg-color: rgba(255, 255, 255, 0.08);
24
+ --sidebar-active-bg-color: rgba(255, 255, 255, 0.15);
25
+ --link-color: #4da6ff;
26
+ --link-hover-color: #87c3ff;
27
+ --navbar-bg-color: #343a40;
28
+ --btn-secondary-bg: #495057;
29
+ --btn-secondary-color: #f8f9fa;
30
+ }
31
+
32
+ body {
33
+ background-color: var(--bg-color);
34
+ color: var(--text-color);
35
+ }
36
+
37
+ .card {
38
+ background-color: var(--card-bg-color);
39
+ border-color: var(--border-color);
40
+ }
41
+
42
+ .card-header {
43
+ background-color: var(--card-bg-color);
44
+ border-bottom: 1px solid var(--border-color);
45
+ }
46
+
47
+ .table {
48
+ --bs-table-bg: var(--card-bg-color);
49
+ --bs-table-border-color: var(--border-color);
50
+ --bs-table-color: var(--text-color);
51
+ --bs-table-striped-bg: rgba(0, 0, 0, 0.02);
52
+ }
53
+
54
+ html[data-theme='dark'] .table-striped > tbody > tr:nth-of-type(odd) > * {
55
+ --bs-table-accent-bg: rgba(255, 255, 255, 0.03);
56
+ }
57
+
58
+ .form-control, .form-select {
59
+ background-color: var(--bg-color);
60
+ color: var(--text-color);
61
+ border-color: var(--border-color);
62
+ }
63
+
64
+ .form-control:focus, .form-select:focus {
65
+ background-color: var(--bg-color);
66
+ color: var(--text-color);
67
+ }
68
+
69
+ .input-group-text {
70
+ background-color: var(--bg-color);
71
+ color: var(--text-color);
72
+ border-color: var(--border-color);
73
+ }
74
+
75
+ .sidebar {
76
+ background-color: var(--sidebar-bg-color);
77
+ }
78
+
79
+ .sidebar .nav-link {
80
+ color: var(--sidebar-text-color);
81
+ }
82
+
83
+ .sidebar .nav-link:hover {
84
+ background-color: var(--sidebar-hover-bg-color);
85
+ color: #fff;
86
+ }
87
+
88
+ .sidebar .nav-link.active {
89
+ background-color: var(--sidebar-active-bg-color);
90
+ color: #fff;
91
+ }
92
+
93
+ html[data-theme='dark'] .navbar.bg-primary {
94
+ background-color: var(--navbar-bg-color) !important;
95
+ }
96
+
97
+ html[data-theme='dark'] .btn-light {
98
+ background-color: var(--btn-secondary-bg);
99
+ color: var(--btn-secondary-color);
100
+ border-color: var(--border-color);
101
+ }
102
+
103
+ html[data-theme='dark'] .form-control {
104
+ background-color: var(--card-bg-color) !important;
105
+ color: var(--text-color) !important;
106
+ border-color: var(--border-color) !important;
107
+ }
108
+
109
+ html[data-theme='dark'] .form-control::placeholder {
110
+ color: #6c757d;
111
+ }
112
+
113
+ html[data-theme='dark'] .alert-success {
114
+ background-color: #1a3e29;
115
+ color: #a3d9b8;
116
+ border-color: #2c6846;
117
+ }
118
+
119
+ html[data-theme='dark'] .alert-danger {
120
+ background-color: #4f2125;
121
+ color: #f5c2c7;
122
+ border-color: #8d3d44;
123
+ }
124
+
125
+ html[data-theme='dark'] .alert-info {
126
+ background-color: #1c3a4f;
127
+ color: #b8d4e9;
128
+ border-color: #2f658b;
129
+ }
130
+
131
+ html[data-theme='dark'] .badge.bg-light {
132
+ background-color: var(--btn-secondary-bg) !important;
133
+ color: var(--btn-secondary-color) !important;
134
+ }
135
+
136
+ html[data-theme='dark'] #loading-overlay {
137
+ background-color: rgba(0, 0, 0, 0.7);
138
+ }
139
+
140
+ html[data-theme='dark'] .modal-content {
141
+ background-color: var(--card-bg-color);
142
+ color: var(--text-color);
143
+ }
144
+
145
+ html[data-theme='dark'] .modal-header {
146
+ border-bottom-color: var(--border-color);
147
+ }
148
+
149
+ html[data-theme='dark'] .modal-footer {
150
+ border-top-color: var(--border-color);
151
+ }
152
+
153
+ html[data-theme='dark'] .btn-close {
154
+ filter: invert(1) grayscale(100%) brightness(200%);
155
+ }
156
+
157
+
158
+ /* Dark mode styles for finance portal */
159
+ html[data-theme='dark'] .finance-portal-container {
160
+ background-color: var(--bg-color);
161
+ }
162
+
163
+ html[data-theme='dark'] .portal-sidebar,
164
+ html[data-theme='dark'] .portal-news,
165
+ html[data-theme='dark'] .portal-hotspot,
166
+ html[data-theme='dark'] .portal-footer {
167
+ background-color: var(--card-bg-color);
168
+ box-shadow: 0 2px 5px rgba(0,0,0,0.3);
169
+ }
170
+
171
+ html[data-theme='dark'] .sidebar-header,
172
+ html[data-theme='dark'] .news-header,
173
+ html[data-theme='dark'] .hotspot-header {
174
+ border-bottom-color: var(--border-color);
175
+ background-color: var(--card-bg-color);
176
+ }
177
+
178
+ html[data-theme='dark'] .sidebar-header h5,
179
+ html[data-theme='dark'] .news-header h5,
180
+ html[data-theme='dark'] .hotspot-header h5,
181
+ html[data-theme='dark'] .hotspot-title,
182
+ html[data-theme='dark'] .sidebar-nav a,
183
+ html[data-theme='dark'] .group-title,
184
+ html[data-theme='dark'] .status-item,
185
+ html[data-theme='dark'] .current-time,
186
+ html[data-theme='dark'] .news-content,
187
+ html[data-theme='dark'] .ticker-item,
188
+ html[data-theme='dark'] .time-label,
189
+ html[data-theme='dark'] .time-date {
190
+ color: var(--text-color);
191
+ }
192
+
193
+ html[data-theme='dark'] .sidebar-nav a:hover {
194
+ background-color: var(--sidebar-hover-bg-color);
195
+ }
196
+
197
+ html[data-theme='dark'] .time-point:before {
198
+ background-color: var(--primary-color);
199
+ }
200
+
201
+ html[data-theme='dark'] .time-point:after {
202
+ background-color: var(--border-color);
203
+ }
204
+
205
+ html[data-theme='dark'] .news-items {
206
+ background-color: #3a3a3c;
207
+ }
208
+
209
+ html[data-theme='dark'] .news-item,
210
+ html[data-theme='dark'] .hotspot-item,
211
+ html[data-theme='dark'] .market-status {
212
+ border-bottom-color: var(--border-color);
213
+ }
214
+
215
+ html[data-theme='dark'] .hotspot-rank {
216
+ background-color: #495057;
217
+ color: var(--text-color);
218
+ }
219
+
220
+ html[data-theme='dark'] .hotspot-rank.rank-top {
221
+ background-color: #fb6340;
222
+ color: #fff;
223
+ }
224
+
225
+ html[data-theme='dark'] .ticker-news {
226
+ background-color: #3a3a3c;
227
+ }
228
+
229
+ /* Dark mode fixes for custom styles */
230
+ html[data-theme='dark'] .analysis-para,
231
+ html[data-theme='dark'] .analysis-para strong,
232
+ html[data-theme='dark'] .analysis-para b {
233
+ color: var(--text-color);
234
+ }
235
+
236
+ html[data-theme='dark'] .keyword {
237
+ color: #58a6ff; /* Lighter blue */
238
+ }
239
+
240
+ html[data-theme='dark'] .term {
241
+ color: #ff85b3; /* Lighter pink */
242
+ }
243
+
244
+ html[data-theme='dark'] .price {
245
+ color: #7ee787; /* Lighter green */
246
+ background: #2d332d;
247
+ }
248
+
249
+ html[data-theme='dark'] .date {
250
+ color: #8b949e; /* Lighter grey */
251
+ }
252
+
253
+ /* Ensure card headers and titles are readable */
254
+ html[data-theme='dark'] .card-header,
255
+ html[data-theme='dark'] .card-title,
256
+ html[data-theme='dark'] h1,
257
+ html[data-theme='dark'] h2,
258
+ html[data-theme='dark'] h3,
259
+ html[data-theme='dark'] h4,
260
+ html[data-theme='dark'] h5,
261
+ html[data-theme='dark'] h6,
262
+ html[data-theme='dark'] .text-dark,
263
+ html[data-theme='dark'] [class*="text-"] {
264
+ color: var(--text-color) !important;
265
+ }
266
+
267
+ html[data-theme='dark'] .text-muted {
268
+ color: #8b949e !important;
269
+ }
270
+
271
+
272
+ /* ApexCharts dark theme overrides */
273
+ html[data-theme='dark'] .apexcharts-tooltip,
274
+ html[data-theme='dark'] .apexcharts-xaxistooltip,
275
+ html[data-theme='dark'] .apexcharts-yaxistooltip {
276
+ background: #3c3c3e !important;
277
+ border-color: #545456 !important;
278
+ color: var(--text-color) !important;
279
+ }
280
+
281
+ html[data-theme='dark'] .apexcharts-tooltip-title {
282
+ background: #4a4a4c !important;
283
+ border-bottom-color: #545456 !important;
284
+ color: var(--text-color) !important;
285
+ }
286
+
287
+ html[data-theme='dark'] .apexcharts-text,
288
+ html[data-theme='dark'] .apexcharts-xaxis-label,
289
+ html[data-theme='dark'] .apexcharts-yaxis-label,
290
+ html[data-theme='dark'] .apexcharts-datalabel {
291
+ fill: var(--text-color);
292
+ }
293
+ html[data-theme='dark'] .apexcharts-radar-series path {
294
+ stroke-opacity: 0.5;
295
+ }
296
+ html[data-theme='dark'] .apexcharts-radar-series .apexcharts-datalabels text {
297
+ fill: #ffffff !important;
298
+ font-weight: bold;
299
+ }
300
+
301
+ /* --- MORE DARK MODE FIXES --- */
302
+
303
+ /* Brighter navbar text */
304
+ html[data-theme='dark'] .navbar-dark .navbar-nav .nav-link,
305
+ html[data-theme='dark'] .navbar-dark .navbar-brand {
306
+ color: var(--text-color);
307
+ }
308
+
309
+ /* General text inside cards */
310
+ html[data-theme='dark'] .card-body,
311
+ html[data-theme='dark'] .card-title,
312
+ html[data-theme='dark'] .card-subtitle,
313
+ html[data-theme='dark'] .card-text,
314
+ html[data-theme='dark'] .list-group-item {
315
+ color: var(--text-color) !important;
316
+ }
317
+
318
+ html[data-theme='dark'] .list-group-item {
319
+ background-color: var(--card-bg-color);
320
+ border-color: var(--border-color);
321
+ }
322
+
323
+ /* Fix for colored text like stock price changes */
324
+ html[data-theme='dark'] .text-success {
325
+ color: #2dce89 !important;
326
+ }
327
+
328
+ html[data-theme='dark'] .text-danger {
329
+ color: #f5365c !important;
330
+ }
331
+
332
+ /* Specific fix for the main price display */
333
+ html[data-theme='dark'] h2.text-success,
334
+ html[data-theme='dark'] .display-4 {
335
+ color: #2dce89 !important;
336
+ }
337
+
338
+ /* Labels in cards (e.g., "今开", "最高") */
339
+ html[data-theme='dark'] .small, html[data-theme='dark'] small {
340
+ color: #8b949e !important;
341
+ }
342
+
343
+ /* Make AI analysis keywords even brighter */
344
+ html[data-theme='dark'] .keyword {
345
+ color: #79c0ff;
346
+ }
347
+ html[data-theme='dark'] .price {
348
+ color: #8eff97;
349
+ background: #223c24;
350
+ }
351
+
352
+
353
+
354
+
app/web/static/favicon.ico ADDED

Git LFS Details

  • SHA256: cccb8db926822477a84ea0d6a8316b4c16c7348254d81911e87449afc3b41458
  • Pointer size: 131 Bytes
  • Size of remote file: 270 kB
app/web/static/swagger.json ADDED
@@ -0,0 +1,573 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "swagger": "2.0",
3
+ "info": {
4
+ "title": "股票智能分析系统 API",
5
+ "description": "股票智能分析系统的REST API文档",
6
+ "version": "2.1.0"
7
+ },
8
+ "host": "localhost:8888",
9
+ "basePath": "/",
10
+ "schemes": ["http", "https"],
11
+ "paths": {
12
+ "/analyze": {
13
+ "post": {
14
+ "summary": "分析股票",
15
+ "description": "分析单只或多只股票",
16
+ "parameters": [
17
+ {
18
+ "name": "body",
19
+ "in": "body",
20
+ "required": true,
21
+ "schema": {
22
+ "type": "object",
23
+ "properties": {
24
+ "stock_codes": {
25
+ "type": "array",
26
+ "items": {
27
+ "type": "string"
28
+ },
29
+ "example": ["600519", "000858"]
30
+ },
31
+ "market_type": {
32
+ "type": "string",
33
+ "example": "A"
34
+ }
35
+ }
36
+ }
37
+ }
38
+ ],
39
+ "responses": {
40
+ "200": {
41
+ "description": "成功分析股票"
42
+ },
43
+ "400": {
44
+ "description": "请求参数错误"
45
+ },
46
+ "500": {
47
+ "description": "服务器内部错误"
48
+ }
49
+ }
50
+ }
51
+ },
52
+ "/api/start_stock_analysis": {
53
+ "post": {
54
+ "summary": "启动股票分析任务",
55
+ "description": "启动异步股票分析任务",
56
+ "parameters": [
57
+ {
58
+ "name": "body",
59
+ "in": "body",
60
+ "required": true,
61
+ "schema": {
62
+ "type": "object",
63
+ "properties": {
64
+ "stock_code": {
65
+ "type": "string",
66
+ "example": "600519"
67
+ },
68
+ "market_type": {
69
+ "type": "string",
70
+ "example": "A"
71
+ }
72
+ }
73
+ }
74
+ }
75
+ ],
76
+ "responses": {
77
+ "200": {
78
+ "description": "成功启动分析任务"
79
+ }
80
+ }
81
+ }
82
+ },
83
+ "/api/analysis_status/{task_id}": {
84
+ "get": {
85
+ "summary": "获取分析任务状态",
86
+ "description": "获取异步分析任务的状态和结果",
87
+ "parameters": [
88
+ {
89
+ "name": "task_id",
90
+ "in": "path",
91
+ "required": true,
92
+ "type": "string"
93
+ }
94
+ ],
95
+ "responses": {
96
+ "200": {
97
+ "description": "成功获取任务状态和结果"
98
+ },
99
+ "404": {
100
+ "description": "找不到指定的任务"
101
+ }
102
+ }
103
+ }
104
+ },
105
+ "/api/stock_data": {
106
+ "get": {
107
+ "summary": "获取股票数据",
108
+ "description": "获取股票历史数据和技术指标",
109
+ "parameters": [
110
+ {
111
+ "name": "stock_code",
112
+ "in": "query",
113
+ "required": true,
114
+ "type": "string"
115
+ },
116
+ {
117
+ "name": "market_type",
118
+ "in": "query",
119
+ "required": false,
120
+ "type": "string",
121
+ "default": "A"
122
+ },
123
+ {
124
+ "name": "period",
125
+ "in": "query",
126
+ "required": false,
127
+ "type": "string",
128
+ "enum": ["1m", "3m", "6m", "1y"],
129
+ "default": "1y"
130
+ }
131
+ ],
132
+ "responses": {
133
+ "200": {
134
+ "description": "成功获取股票数据"
135
+ },
136
+ "400": {
137
+ "description": "请求参数错误"
138
+ },
139
+ "500": {
140
+ "description": "服务器内部错误"
141
+ }
142
+ }
143
+ }
144
+ },
145
+ "/api/start_market_scan": {
146
+ "post": {
147
+ "summary": "启动市场扫描任务",
148
+ "description": "启动异步市场扫描任务",
149
+ "parameters": [
150
+ {
151
+ "name": "body",
152
+ "in": "body",
153
+ "required": true,
154
+ "schema": {
155
+ "type": "object",
156
+ "properties": {
157
+ "stock_list": {
158
+ "type": "array",
159
+ "items": {
160
+ "type": "string"
161
+ },
162
+ "example": ["600519", "000858"]
163
+ },
164
+ "min_score": {
165
+ "type": "integer",
166
+ "example": 60
167
+ },
168
+ "market_type": {
169
+ "type": "string",
170
+ "example": "A"
171
+ }
172
+ }
173
+ }
174
+ }
175
+ ],
176
+ "responses": {
177
+ "200": {
178
+ "description": "成功启动扫描任务"
179
+ }
180
+ }
181
+ }
182
+ },
183
+ "/api/scan_status/{task_id}": {
184
+ "get": {
185
+ "summary": "获取扫描任务状态",
186
+ "description": "获取异步扫描任务的状态和结果",
187
+ "parameters": [
188
+ {
189
+ "name": "task_id",
190
+ "in": "path",
191
+ "required": true,
192
+ "type": "string"
193
+ }
194
+ ],
195
+ "responses": {
196
+ "200": {
197
+ "description": "成功获取任务状态和结果"
198
+ },
199
+ "404": {
200
+ "description": "找不到指定的任务"
201
+ }
202
+ }
203
+ }
204
+ },
205
+ "/api/index_stocks": {
206
+ "get": {
207
+ "summary": "获取指数成分股",
208
+ "description": "获取指定指数的成分股列表",
209
+ "parameters": [
210
+ {
211
+ "name": "index_code",
212
+ "in": "query",
213
+ "required": true,
214
+ "type": "string",
215
+ "example": "000300"
216
+ }
217
+ ],
218
+ "responses": {
219
+ "200": {
220
+ "description": "成功获取指数成分股"
221
+ },
222
+ "400": {
223
+ "description": "请求参数错误"
224
+ },
225
+ "500": {
226
+ "description": "服务器内部错误"
227
+ }
228
+ }
229
+ }
230
+ },
231
+ "/api/industry_stocks": {
232
+ "get": {
233
+ "summary": "获取行业成分股",
234
+ "description": "获取指定行业的成分股列表",
235
+ "parameters": [
236
+ {
237
+ "name": "industry",
238
+ "in": "query",
239
+ "required": true,
240
+ "type": "string",
241
+ "example": "银行"
242
+ }
243
+ ],
244
+ "responses": {
245
+ "200": {
246
+ "description": "成功获取行业成分股"
247
+ },
248
+ "400": {
249
+ "description": "请求参数错误"
250
+ },
251
+ "500": {
252
+ "description": "服务器内部错误"
253
+ }
254
+ }
255
+ }
256
+ },
257
+ "/api/fundamental_analysis": {
258
+ "post": {
259
+ "summary": "基本面分析",
260
+ "description": "获取股票的基本面分析结果",
261
+ "parameters": [
262
+ {
263
+ "name": "body",
264
+ "in": "body",
265
+ "required": true,
266
+ "schema": {
267
+ "type": "object",
268
+ "properties": {
269
+ "stock_code": {
270
+ "type": "string",
271
+ "example": "600519"
272
+ }
273
+ }
274
+ }
275
+ }
276
+ ],
277
+ "responses": {
278
+ "200": {
279
+ "description": "成功获取基本面分析结果"
280
+ }
281
+ }
282
+ }
283
+ },
284
+ "/api/capital_flow": {
285
+ "post": {
286
+ "summary": "资金流向分析",
287
+ "description": "获取股票的资金流向分析结果",
288
+ "parameters": [
289
+ {
290
+ "name": "body",
291
+ "in": "body",
292
+ "required": true,
293
+ "schema": {
294
+ "type": "object",
295
+ "properties": {
296
+ "stock_code": {
297
+ "type": "string",
298
+ "example": "600519"
299
+ },
300
+ "days": {
301
+ "type": "integer",
302
+ "example": 10
303
+ }
304
+ }
305
+ }
306
+ }
307
+ ],
308
+ "responses": {
309
+ "200": {
310
+ "description": "成功获取资金流向分析结果"
311
+ }
312
+ }
313
+ }
314
+ },
315
+ "/api/scenario_predict": {
316
+ "post": {
317
+ "summary": "情景预测",
318
+ "description": "获取股票的多情景预测结果",
319
+ "parameters": [
320
+ {
321
+ "name": "body",
322
+ "in": "body",
323
+ "required": true,
324
+ "schema": {
325
+ "type": "object",
326
+ "properties": {
327
+ "stock_code": {
328
+ "type": "string",
329
+ "example": "600519"
330
+ },
331
+ "market_type": {
332
+ "type": "string",
333
+ "example": "A"
334
+ },
335
+ "days": {
336
+ "type": "integer",
337
+ "example": 60
338
+ }
339
+ }
340
+ }
341
+ }
342
+ ],
343
+ "responses": {
344
+ "200": {
345
+ "description": "成功获取情景预测结果"
346
+ }
347
+ }
348
+ }
349
+ },
350
+ "/api/qa": {
351
+ "post": {
352
+ "summary": "智能问答",
353
+ "description": "获取股票相关问题的智能回答",
354
+ "parameters": [
355
+ {
356
+ "name": "body",
357
+ "in": "body",
358
+ "required": true,
359
+ "schema": {
360
+ "type": "object",
361
+ "properties": {
362
+ "stock_code": {
363
+ "type": "string",
364
+ "example": "600519"
365
+ },
366
+ "question": {
367
+ "type": "string",
368
+ "example": "这只股票的主要支撑位是多少?"
369
+ },
370
+ "market_type": {
371
+ "type": "string",
372
+ "example": "A"
373
+ }
374
+ }
375
+ }
376
+ }
377
+ ],
378
+ "responses": {
379
+ "200": {
380
+ "description": "成功获取智能回答"
381
+ }
382
+ }
383
+ }
384
+ },
385
+ "/api/risk_analysis": {
386
+ "post": {
387
+ "summary": "风险分析",
388
+ "description": "获取股票的风险分析结果",
389
+ "parameters": [
390
+ {
391
+ "name": "body",
392
+ "in": "body",
393
+ "required": true,
394
+ "schema": {
395
+ "type": "object",
396
+ "properties": {
397
+ "stock_code": {
398
+ "type": "string",
399
+ "example": "600519"
400
+ },
401
+ "market_type": {
402
+ "type": "string",
403
+ "example": "A"
404
+ }
405
+ }
406
+ }
407
+ }
408
+ ],
409
+ "responses": {
410
+ "200": {
411
+ "description": "成功获取风险分析结果"
412
+ }
413
+ }
414
+ }
415
+ },
416
+ "/api/portfolio_risk": {
417
+ "post": {
418
+ "summary": "投资组合风险分析",
419
+ "description": "获取投资组合的整体风险分析结果",
420
+ "parameters": [
421
+ {
422
+ "name": "body",
423
+ "in": "body",
424
+ "required": true,
425
+ "schema": {
426
+ "type": "object",
427
+ "properties": {
428
+ "portfolio": {
429
+ "type": "array",
430
+ "items": {
431
+ "type": "object",
432
+ "properties": {
433
+ "stock_code": {
434
+ "type": "string"
435
+ },
436
+ "weight": {
437
+ "type": "number"
438
+ },
439
+ "market_type": {
440
+ "type": "string"
441
+ }
442
+ }
443
+ },
444
+ "example": [
445
+ {
446
+ "stock_code": "600519",
447
+ "weight": 30,
448
+ "market_type": "A"
449
+ },
450
+ {
451
+ "stock_code": "000858",
452
+ "weight": 20,
453
+ "market_type": "A"
454
+ }
455
+ ]
456
+ }
457
+ }
458
+ }
459
+ }
460
+ ],
461
+ "responses": {
462
+ "200": {
463
+ "description": "成功获取投资组合风险分析结果"
464
+ }
465
+ }
466
+ }
467
+ },
468
+ "/api/index_analysis": {
469
+ "get": {
470
+ "summary": "指数分析",
471
+ "description": "获取指数的整体分析结果",
472
+ "parameters": [
473
+ {
474
+ "name": "index_code",
475
+ "in": "query",
476
+ "required": true,
477
+ "type": "string",
478
+ "example": "000300"
479
+ },
480
+ {
481
+ "name": "limit",
482
+ "in": "query",
483
+ "required": false,
484
+ "type": "integer",
485
+ "example": 30
486
+ }
487
+ ],
488
+ "responses": {
489
+ "200": {
490
+ "description": "成功获取指数分析结果"
491
+ }
492
+ }
493
+ }
494
+ },
495
+ "/api/industry_analysis": {
496
+ "get": {
497
+ "summary": "行业分析",
498
+ "description": "获取行业的整体分析结果",
499
+ "parameters": [
500
+ {
501
+ "name": "industry",
502
+ "in": "query",
503
+ "required": true,
504
+ "type": "string",
505
+ "example": "银行"
506
+ },
507
+ {
508
+ "name": "limit",
509
+ "in": "query",
510
+ "required": false,
511
+ "type": "integer",
512
+ "example": 30
513
+ }
514
+ ],
515
+ "responses": {
516
+ "200": {
517
+ "description": "成功获取行业分析结果"
518
+ }
519
+ }
520
+ }
521
+ },
522
+ "/api/industry_compare": {
523
+ "get": {
524
+ "summary": "行业比较",
525
+ "description": "比较不同行业的表现",
526
+ "parameters": [
527
+ {
528
+ "name": "limit",
529
+ "in": "query",
530
+ "required": false,
531
+ "type": "integer",
532
+ "example": 10
533
+ }
534
+ ],
535
+ "responses": {
536
+ "200": {
537
+ "description": "成功获取行业比较结果"
538
+ }
539
+ }
540
+ }
541
+ }
542
+ },
543
+ "definitions": {
544
+ "Stock": {
545
+ "type": "object",
546
+ "properties": {
547
+ "stock_code": {
548
+ "type": "string"
549
+ },
550
+ "stock_name": {
551
+ "type": "string"
552
+ },
553
+ "price": {
554
+ "type": "number"
555
+ },
556
+ "price_change": {
557
+ "type": "number"
558
+ }
559
+ }
560
+ },
561
+ "AnalysisResult": {
562
+ "type": "object",
563
+ "properties": {
564
+ "score": {
565
+ "type": "number"
566
+ },
567
+ "recommendation": {
568
+ "type": "string"
569
+ }
570
+ }
571
+ }
572
+ }
573
+ }
app/web/templates/agent_analysis.html ADDED
@@ -0,0 +1,703 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block head %}
4
+ <style>
5
+ /* 修复“开始分析”按钮中加载动画和文字的对齐问题 */
6
+ #start-analysis-btn {
7
+ display: flex;
8
+ align-items: center;
9
+ justify-content: center;
10
+ gap: 0.5rem; /* 在图标/加载动画和文字之间添加一些间距 */
11
+ }
12
+
13
+ /* 为市场下拉菜单应用自定义样式 */
14
+ #market-type.form-select {
15
+ -webkit-appearance: none;
16
+ -moz-appearance: none;
17
+ appearance: none;
18
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
19
+ background-repeat: no-repeat;
20
+ background-position: right 0.75rem center;
21
+ background-size: 16px 12px;
22
+ }
23
+ </style>
24
+ {% endblock %}
25
+
26
+ {% block title %}智能体分析 - 智能分析系统{% endblock %}
27
+
28
+ {% block content %}
29
+ <div class="container-fluid py-3">
30
+ <div id="alerts-container"></div>
31
+
32
+ <!-- Page Title -->
33
+ <div class="row mb-3">
34
+ <div class="col-12">
35
+ <h2 class="mb-0">智能体分析</h2>
36
+ <p class="text-muted">利用多智能体框架进行深度股票评估</p>
37
+ </div>
38
+ </div>
39
+
40
+ <!-- Input Form Card -->
41
+ <div class="row">
42
+ <div class="col-12">
43
+ <div class="card">
44
+ <div class="card-header py-2">
45
+ <h5 class="mb-0">分析设置</h5>
46
+ </div>
47
+ <div class="card-body py-2">
48
+ <form id="agent-analysis-form" class="row g-3 align-items-end">
49
+ <div class="col-md-3">
50
+ <label for="stock-code" class="form-label">股票代码</label>
51
+ <div class="input-group">
52
+ <span class="input-group-text"><i class="fas fa-barcode"></i></span>
53
+ <input type="text" class="form-control" id="stock-code" placeholder="例如: 600519, AAPL" required>
54
+ </div>
55
+ </div>
56
+ <div class="col-md-2">
57
+ <label for="market-type" class="form-label">市场</label>
58
+ <div class="input-group">
59
+ <select class="form-select" id="market-type">
60
+ <option value="A" selected>A股</option>
61
+ <option value="US">美股</option>
62
+ <option value="HK">港股</option>
63
+ </select>
64
+ </div>
65
+ </div>
66
+ <div class="col-md-2">
67
+ <label for="analysis-date" class="form-label">分析日期</label>
68
+ <div class="input-group">
69
+ <input type="date" class="form-control" id="analysis-date">
70
+ </div>
71
+ </div>
72
+ <div class="col-md-3">
73
+ <label for="research-depth" class="form-label">研究深度</label>
74
+ <select class="form-select" id="research-depth">
75
+ <option value="1">1级 - 快速</option>
76
+ <option value="2">2级 - 基础</option>
77
+ <option value="3" selected>3级 - 标准</option>
78
+ <option value="4">4级 - 深度</option>
79
+ <option value="5">5级 - 全面</option>
80
+ </select>
81
+ </div>
82
+ <div class="col-md-2">
83
+ <button type="submit" id="start-analysis-btn" class="btn btn-primary w-100">
84
+ <i class="fas fa-rocket"></i> 开始分析
85
+ </button>
86
+ </div>
87
+
88
+ <div class="col-12 mt-2">
89
+ <label class="form-label mb-1">选择分析师团队:</label>
90
+ <div id="analyst-selection" class="d-flex flex-wrap gap-3">
91
+ <div class="form-check form-check-inline">
92
+ <input class="form-check-input" type="checkbox" id="analyst-market" value="market" checked>
93
+ <label class="form-check-label" for="analyst-market">市场</label>
94
+ </div>
95
+ <div class="form-check form-check-inline">
96
+ <input class="form-check-input" type="checkbox" id="analyst-news" value="news" checked>
97
+ <label class="form-check-label" for="analyst-news">新闻</label>
98
+ </div>
99
+ <div class="form-check form-check-inline">
100
+ <input class="form-check-input" type="checkbox" id="analyst-social" value="social" checked>
101
+ <label class="form-check-label" for="analyst-social">社交</label>
102
+ </div>
103
+ <div class="form-check form-check-inline">
104
+ <input class="form-check-input" type="checkbox" id="analyst-fundamentals" value="fundamentals" checked>
105
+ <label class="form-check-label" for="analyst-fundamentals">基本面</label>
106
+ </div>
107
+ </div>
108
+ </div>
109
+
110
+ <div class="col-md-9 mt-2">
111
+ <label for="max-output-length" class="form-label">最大输出长度: <span id="max-output-length-value" class="fw-bold text-primary">2048</span> tokens</label>
112
+ <input type="range" class="form-range" id="max-output-length" min="512" max="8192" step="256" value="2048">
113
+ </div>
114
+ <div class="col-md-3 mt-2 d-flex align-items-end justify-content-end">
115
+ <div class="form-check form-switch pb-2">
116
+ <input class="form-check-input" type="checkbox" id="enable-memory" checked>
117
+ <label class="form-check-label" for="enable-memory">启用记忆功能</label>
118
+ </div>
119
+ </div>
120
+ </form>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ </div>
125
+
126
+ <!-- Results Area -->
127
+ <div id="results-area" class="mt-4" style="display: none;">
128
+ <!-- Progress Bar -->
129
+ <div id="progress-container" class="mb-3">
130
+ <h5 id="progress-step" class="text-center mb-2">正在初始化...</h5>
131
+ <div class="progress" style="height: 20px;">
132
+ <div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div>
133
+ </div>
134
+ </div>
135
+
136
+ <!-- Analysis Results Card -->
137
+ <div id="results-card" class="card" style="display: none;">
138
+ <div class="card-header d-flex justify-content-between align-items-center">
139
+ <h5 class="mb-0">分析报告</h5>
140
+ <div id="export-buttons">
141
+ <!-- Export buttons will be added here -->
142
+ </div>
143
+ </div>
144
+ <div class="card-body">
145
+ <div id="results-content">
146
+ <!-- Dynamic results will be rendered here -->
147
+ </div>
148
+ </div>
149
+ </div>
150
+ </div>
151
+ <!-- History Card -->
152
+ <div class="row mt-4">
153
+ <div class="col-12">
154
+ <div class="card">
155
+ <div class="card-header d-flex justify-content-between align-items-center py-2">
156
+ <h5 class="mb-0"><i class="fas fa-history me-2"></i>分析历史</h5>
157
+ <button id="refresh-history-btn" class="btn btn-sm btn-outline-primary">
158
+ <i class="fas fa-sync-alt"></i> 刷新
159
+ </button>
160
+ </div>
161
+ <div class="card-body">
162
+ <div id="history-table-container">
163
+ <!-- History table will be rendered here -->
164
+ </div>
165
+ </div>
166
+ </div>
167
+ </div>
168
+ </div>
169
+ </div>
170
+ {% endblock %}
171
+
172
+ {% block scripts %}
173
+ <script>
174
+ $(document).ready(function() {
175
+ let pollingInterval;
176
+
177
+ $('#agent-analysis-form').submit(function(e) {
178
+ e.preventDefault();
179
+ const stockCode = $('#stock-code').val().trim();
180
+ const researchDepth = $('#research-depth').val();
181
+ const marketType = $('#market-type').val();
182
+ const analysisDate = $('#analysis-date').val();
183
+ const enableMemory = $('#enable-memory').is(':checked');
184
+ const maxOutputLength = $('#max-output-length').val();
185
+ const selectedAnalysts = $('#analyst-selection input:checked').map(function() {
186
+ return $(this).val();
187
+ }).get();
188
+
189
+ if (!stockCode) {
190
+ showError('请输入股票代码!');
191
+ return;
192
+ }
193
+
194
+ if (selectedAnalysts.length === 0) {
195
+ showError('请至少选择一个分析师!');
196
+ return;
197
+ }
198
+
199
+ // Disable button and show progress
200
+ $('#start-analysis-btn').prop('disabled', true).html('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 分析中...');
201
+ $('#results-area').show();
202
+ $('#progress-container').show();
203
+ $('#results-card').hide();
204
+ updateProgress(0, '任务已提交...');
205
+
206
+ // Start analysis
207
+ $.ajax({
208
+ url: '/api/start_agent_analysis',
209
+ type: 'POST',
210
+ contentType: 'application/json',
211
+ data: JSON.stringify({
212
+ stock_code: stockCode,
213
+ market_type: marketType,
214
+ selected_analysts: selectedAnalysts,
215
+ research_depth: parseInt(researchDepth),
216
+ analysis_date: analysisDate,
217
+ enable_memory: enableMemory,
218
+ max_output_length: parseInt(maxOutputLength)
219
+ }),
220
+ success: function(response) {
221
+ if (response.task_id) {
222
+ startPolling(response.task_id);
223
+ } else {
224
+ showError(response.error || '无法启动分析任务');
225
+ resetForm();
226
+ }
227
+ },
228
+ error: function(xhr) {
229
+ const errorMsg = xhr.responseJSON ? xhr.responseJSON.error : '请求失败';
230
+ showError('启动分析失败: ' + errorMsg);
231
+ resetForm();
232
+ }
233
+ });
234
+ });
235
+
236
+ function startPolling(taskId) {
237
+ pollingInterval = setInterval(function() {
238
+ $.ajax({
239
+ url: `/api/agent_analysis_status/${taskId}`,
240
+ type: 'GET',
241
+ cache: false, // 禁用缓存,确保每次都获取最新状态
242
+ success: function(response) {
243
+ if (!response) return;
244
+
245
+ // 优先使用 result.current_step,如果没有则使用默认文本
246
+ const currentStep = response.result ? response.result.current_step : '处理中...';
247
+ updateProgress(response.progress, currentStep);
248
+
249
+ if (response.status === 'completed' || response.status === 'failed') {
250
+ clearInterval(pollingInterval);
251
+ if (response.status === 'completed') {
252
+ displayResults(response.result);
253
+ } else {
254
+ showError(response.error || '分析任务失败');
255
+ }
256
+ resetForm();
257
+ }
258
+ },
259
+ error: function(xhr) {
260
+ clearInterval(pollingInterval);
261
+ const errorMsg = xhr.responseJSON ? xhr.responseJSON.error : '请求失败';
262
+ showError('获取状态失败: ' + errorMsg);
263
+ resetForm();
264
+ }
265
+ });
266
+ }, 3000); // Poll every 3 seconds
267
+ }
268
+
269
+ function updateProgress(percent, step) {
270
+ $('#alerts-container').empty(); // 清除“正在恢复”等提示信息
271
+ percent = Math.min(percent, 100);
272
+ $('#progress-bar').css('width', percent + '%').attr('aria-valuenow', percent).text(percent + '%');
273
+ $('#progress-step').text(step);
274
+ }
275
+
276
+ function displayResults(result) {
277
+ $('#progress-container').hide();
278
+ $('#results-card').show();
279
+
280
+ let contentHtml = '<h4><i class="fas fa-exclamation-triangle text-danger me-2"></i>分析结果加载失败</h4>';
281
+
282
+ if (result && result.decision) {
283
+ const decision = result.decision;
284
+ const state = result.final_state || {};
285
+ const stockInfo = {
286
+ code: state.company_of_interest || 'N/A',
287
+ name: state.company_name || '未知公司',
288
+ market: state.market_type || 'N/A'
289
+ };
290
+
291
+ // --- Helper functions ---
292
+ const parseFundamentals = (report) => {
293
+ if (typeof report !== 'string') {
294
+ return {};
295
+ }
296
+ const metrics = {};
297
+ const patterns = {
298
+ debt_ratio: /\*\*?资产负债率\*\*?\s*[::\s是为]*\s*([\d\.]+)/i,
299
+ liquidity_ratio: /\*\*?流动比率\*\*?\s*[::\s是为]*\s*([\d\.]+)/i,
300
+ quick_ratio: /\*\*?速动比率\*\*?\s*[::\s是为]*\s*([\d\.]+)/i,
301
+ gross_margin: /\*\*?毛利率\*\*?\s*[::\s是为]*\s*([\d\.]+)/i,
302
+ net_margin: /\*\*?净利率\*\*?\s*[::\s是为]*\s*([\d\.]+)/i,
303
+ pb_ratio: /\*\*?市净率(?:\(PB\))?\*\*?\s*[::\s是为]*\s*([\d\.]+)/i
304
+ };
305
+
306
+ for (const key in patterns) {
307
+ const match = report.match(patterns[key]);
308
+ if (match && match[1]) {
309
+ metrics[key] = parseFloat(match[1]);
310
+ } else {
311
+ metrics[key] = null;
312
+ }
313
+ }
314
+ return metrics;
315
+ };
316
+
317
+ const fundamentals = parseFundamentals(state.fundamentals_report);
318
+
319
+ const getRatingClass = (value, thresholds, ascending = true) => {
320
+ if (value === null || value === undefined || isNaN(value)) return 'text-muted';
321
+ const [low, mid, high] = thresholds;
322
+ if (ascending) {
323
+ if (value >= high) return 'text-success';
324
+ if (value >= mid) return 'text-primary';
325
+ if (value >= low) return 'text-warning';
326
+ return 'text-danger';
327
+ } else {
328
+ if (value <= low) return 'text-success';
329
+ if (value <= mid) return 'text-primary';
330
+ if (value <= high) return 'text-warning';
331
+ return 'text-danger';
332
+ }
333
+ };
334
+
335
+ const getRatingText = (value, thresholds, labels, ascending = true) => {
336
+ if (value === null || value === undefined || isNaN(value)) return 'N/A';
337
+ const [low, mid, high] = thresholds;
338
+ const [labelLow, labelMid, labelHigh, labelVeryHigh] = labels;
339
+ if (ascending) {
340
+ if (value >= high) return labelVeryHigh;
341
+ if (value >= mid) return labelHigh;
342
+ if (value >= low) return labelMid;
343
+ return labelLow;
344
+ } else {
345
+ if (value <= low) return labelVeryHigh;
346
+ if (value <= mid) return labelHigh;
347
+ if (value <= high) return labelMid;
348
+ return labelLow;
349
+ }
350
+ };
351
+
352
+ const renderTable = (title, headers, rows) => {
353
+ let tableHtml = `<div class="card h-100 shadow-sm">
354
+ <div class="card-header bg-light">
355
+ <h6 class="mb-0">${title}</h6>
356
+ </div>
357
+ <div class="card-body p-0">
358
+ <table class="table table-sm table-hover mb-0">
359
+ <thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead>
360
+ <tbody>`;
361
+ rows.forEach(row => {
362
+ tableHtml += `<tr>${row.map(cell => `<td>${cell}</td>`).join('')}</tr>`;
363
+ });
364
+ tableHtml += `</tbody></table></div></div>`;
365
+ return tableHtml;
366
+ };
367
+
368
+ // --- Main Content ---
369
+ contentHtml = `
370
+ <!-- Header -->
371
+ <div class="text-center mb-4">
372
+ <h2>${stockInfo.name} (${stockInfo.code}) - 智能体深度分析报告</h2>
373
+ <p class="text-muted">报告生成日期: ${new Date().toLocaleDateString()}</p>
374
+ </div>
375
+
376
+ <!-- Investment Suggestion -->
377
+ <div class="alert ${getDecisionClass(decision.action).replace('text-', 'alert-')} border-0 shadow-lg mb-4">
378
+ <h4 class="alert-heading"><i class="fas fa-lightbulb me-2"></i>投资建议: <span class="fw-bold">${decision.action}</span></h4>
379
+ <p class="mb-0">${decision.reasoning}</p>
380
+ </div>
381
+
382
+ <!-- Key Metrics -->
383
+ <div class="row mb-4">
384
+ <div class="col-md-4">
385
+ <div class="card text-center h-100 shadow-sm">
386
+ <div class="card-body">
387
+ <h6 class="text-muted">置信度</h6>
388
+ <p class="display-5 fw-bold mb-0">${(decision.confidence * 100).toFixed(1)}%</p>
389
+ </div>
390
+ </div>
391
+ </div>
392
+ <div class="col-md-4">
393
+ <div class="card text-center h-100 shadow-sm">
394
+ <div class="card-body">
395
+ <h6 class="text-muted">风险评分</h6>
396
+ <p class="display-5 fw-bold mb-0 ${getRatingClass(decision.risk_score * 100, [30, 50, 70], false)}">${(decision.risk_score * 100).toFixed(1)}%</p>
397
+ </div>
398
+ </div>
399
+ </div>
400
+ <div class="col-md-4">
401
+ <div class="card text-center h-100 shadow-sm">
402
+ <div class="card-body">
403
+ <h6 class="text-muted">市场情绪</h6>
404
+ <p class="display-5 fw-bold mb-0">${state.sentiment_score || 'N/A'}</p>
405
+ </div>
406
+ </div>
407
+ </div>
408
+ </div>
409
+
410
+ <!-- Financial, Profitability & Valuation Tables -->
411
+ <div class="row g-4 mb-4">
412
+ <div class="col-lg-6">
413
+ ${renderTable(
414
+ '<i class="fas fa-balance-scale me-2"></i>财务状况评估',
415
+ ['指标', '数值', '评估'],
416
+ [
417
+ ['资产负债率', fundamentals.debt_ratio !== null ? `${fundamentals.debt_ratio}%` : 'N/A', `<span class="${getRatingClass(fundamentals.debt_ratio, [40, 60, 80], false)}">${getRatingText(fundamentals.debt_ratio, [40, 60, 80], ['高风险', '较高风险', '中等', '良好'], false)}</span>`],
418
+ ['流动比率', fundamentals.liquidity_ratio !== null ? fundamentals.liquidity_ratio : 'N/A', `<span class="${getRatingClass(fundamentals.liquidity_ratio, [1, 1.5, 2], true)}">${getRatingText(fundamentals.liquidity_ratio, [1, 1.5, 2], ['风险', '吃紧', '良好', '优秀'], true)}</span>`],
419
+ ['速动比率', fundamentals.quick_ratio !== null ? fundamentals.quick_ratio : 'N/A', `<span class="${getRatingClass(fundamentals.quick_ratio, [0.8, 1, 1.2], true)}">${getRatingText(fundamentals.quick_ratio, [0.8, 1, 1.2], ['危险', '风险', '尚可', '良好'], true)}</span>`]
420
+ ]
421
+ )}
422
+ </div>
423
+ <div class="col-lg-6">
424
+ ${renderTable(
425
+ '<i class="fas fa-chart-line me-2"></i>盈利与估值',
426
+ ['指标', '数值', '评估'],
427
+ [
428
+ ['毛利率', fundamentals.gross_margin !== null ? `${fundamentals.gross_margin}%` : 'N/A', `<span class="${getRatingClass(fundamentals.gross_margin, [10, 20, 30], true)}">${getRatingText(fundamentals.gross_margin, [10, 20, 30], ['极低', '偏低', '中等', '优秀'], true)}</span>`],
429
+ ['净利率', fundamentals.net_margin !== null ? `${fundamentals.net_margin}%` : 'N/A', `<span class="${getRatingClass(fundamentals.net_margin, [0, 5, 10], true)}">${getRatingText(fundamentals.net_margin, [0, 5, 10], ['亏损', '微利', '良好', '优秀'], true)}</span>`],
430
+ ['市净率(PB)', fundamentals.pb_ratio !== null ? fundamentals.pb_ratio : 'N/A', `<span class="${getRatingClass(fundamentals.pb_ratio, [1, 2, 4], false)}">${getRatingText(fundamentals.pb_ratio, [1, 2, 4], ['高估', '偏高', '合理', '低估'], false)}</span>`]
431
+ ]
432
+ )}
433
+ </div>
434
+ </div>
435
+
436
+ <!-- Detailed Reports Accordion -->
437
+ <h3 class="mt-5 mb-3">详细分析报告</h3>
438
+ <div class="accordion" id="reports-accordion">
439
+ `;
440
+
441
+ const reports = [
442
+ { title: '<i class="fas fa-chart-pie me-2"></i>市场分析报告', content: state.market_report, id: 'market' },
443
+ { title: '<i class="fas fa-comments me-2"></i>社交媒体情绪报告', content: state.sentiment_report, id: 'social' },
444
+ { title: '<i class="far fa-newspaper me-2"></i>新闻分析报告', content: state.news_report, id: 'news' },
445
+ { title: '<i class="fas fa-file-invoice-dollar me-2"></i>基本面分析报告', content: state.fundamentals_report, id: 'fundamentals' },
446
+ { title: '<i class="fas fa-gavel me-2"></i>投资辩论总结', content: state.investment_debate_state?.judge_decision, id: 'invest_debate' },
447
+ { title: '<i class="fas fa-exclamation-triangle me-2"></i>风险分析总结', content: state.risk_debate_state?.judge_decision, id: 'risk_debate' }
448
+ ];
449
+
450
+ reports.forEach((report, index) => {
451
+ if (report.content && (typeof report.content === 'string' ? report.content.trim() !== '' : true)) {
452
+ let reportContent;
453
+ if (typeof report.content === 'object') {
454
+ reportContent = `<pre>${JSON.stringify(report.content, null, 2)}</pre>`;
455
+ } else if (typeof report.content === 'string') {
456
+ // 使用Marked.js渲染Markdown
457
+ reportContent = marked.parse(report.content);
458
+ } else {
459
+ reportContent = '<p>内容格式无法解析。</p>';
460
+ }
461
+
462
+ contentHtml += `
463
+ <div class="accordion-item">
464
+ <h2 class="accordion-header" id="heading-${report.id}">
465
+ <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-${report.id}" aria-expanded="false" aria-controls="collapse-${report.id}">
466
+ <strong>${report.title}</strong>
467
+ </button>
468
+ </h2>
469
+ <div id="collapse-${report.id}" class="accordion-collapse collapse" aria-labelledby="heading-${report.id}" data-bs-parent="#reports-accordion">
470
+ <div class="accordion-body bg-light">
471
+ ${reportContent}
472
+ </div>
473
+ </div>
474
+ </div>
475
+ `;
476
+ }
477
+ });
478
+
479
+ contentHtml += '</div>';
480
+ }
481
+
482
+ $('#results-content').html(contentHtml);
483
+ }
484
+
485
+ function getDecisionClass(action) {
486
+ if (action.toLowerCase().includes('buy') || action.toLowerCase().includes('买入')) {
487
+ return 'text-success';
488
+ } else if (action.toLowerCase().includes('sell') || action.toLowerCase().includes('卖出')) {
489
+ return 'text-danger';
490
+ }
491
+ return 'text-warning';
492
+ }
493
+
494
+ function resetForm() {
495
+ $('#start-analysis-btn').prop('disabled', false).html('<i class="fas fa-rocket"></i> 开始分析');
496
+ }
497
+
498
+ // You can reuse the showError function from layout.html if it's globally available
499
+ function showError(message) {
500
+ const alertHtml = `
501
+ <div class="alert alert-danger alert-dismissible fade show" role="alert">
502
+ ${message}
503
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
504
+ </div>
505
+ `;
506
+ $('#alerts-container').html(alertHtml);
507
+ }
508
+
509
+ // Check for task_id in URL on page load to resume polling
510
+ const urlParams = new URLSearchParams(window.location.search);
511
+ const taskIdFromUrl = urlParams.get('task_id');
512
+ if (taskIdFromUrl) {
513
+ showInfo('正在恢复分析任务...');
514
+ $('#results-area').show();
515
+ $('#progress-container').show();
516
+ $('#results-card').hide();
517
+ // Fetch initial task state to get stock code
518
+ $.ajax({
519
+ url: `/api/agent_analysis_status/${taskIdFromUrl}`,
520
+ type: 'GET',
521
+ success: function(task) {
522
+ if (task && task.params && task.params.stock_code) {
523
+ $('#stock-code').val(task.params.stock_code);
524
+ }
525
+ startPolling(taskIdFromUrl);
526
+ },
527
+ error: function() {
528
+ showError('无法恢复分析任务,任务ID可能已失效。');
529
+ }
530
+ });
531
+ }
532
+
533
+ // --- History Section Logic ---
534
+ function loadHistory() {
535
+ $('#history-table-container').html('<div class="text-center p-3"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div></div>');
536
+
537
+ $.ajax({
538
+ url: '/api/agent_analysis_history',
539
+ type: 'GET',
540
+ cache: false,
541
+ success: function(response) {
542
+ if (response && response.history) {
543
+ renderHistoryTable(response.history);
544
+ } else {
545
+ showError('无法加载分析历史。');
546
+ }
547
+ },
548
+ error: function(xhr) {
549
+ const errorMsg = xhr.responseJSON ? xhr.responseJSON.error : '请求失败';
550
+ showError('加载历史失败: ' + errorMsg);
551
+ $('#history-table-container').html('<div class="alert alert-danger">加载历史记录失败。</div>');
552
+ }
553
+ });
554
+ }
555
+
556
+ function renderHistoryTable(history) {
557
+ if (history.length === 0) {
558
+ $('#history-table-container').html('<div class="alert alert-info">没有找到任何分析历史记录。</div>');
559
+ return;
560
+ }
561
+
562
+ let tableHtml = `
563
+ <div class="mb-3">
564
+ <button id="delete-selected-btn" class="btn btn-danger" disabled>
565
+ <i class="fas fa-trash-alt me-2"></i>删除选中项
566
+ </button>
567
+ </div>
568
+ <div class="table-responsive">
569
+ <table class="table table-hover table-sm align-middle">
570
+ <thead>
571
+ <tr>
572
+ <th><input class="form-check-input" type="checkbox" id="select-all-checkbox"></th>
573
+ <th>#</th>
574
+ <th>股票代码</th>
575
+ <th>状态</th>
576
+ <th>研究深度</th>
577
+ <th>分析师</th>
578
+ <th>创建时间</th>
579
+ <th>更新时间</th>
580
+ <th class="text-end">操作</th>
581
+ </tr>
582
+ </thead>
583
+ <tbody>
584
+ `;
585
+
586
+ history.forEach((task, index) => {
587
+ const params = task.params || {};
588
+ const analysts = params.selected_analysts || [];
589
+ const statusBadge = getStatusBadge(task.status);
590
+
591
+ tableHtml += `
592
+ <tr data-task-id="${task.id}">
593
+ <td><input class="form-check-input row-checkbox" type="checkbox" value="${task.id}"></td>
594
+ <td>${index + 1}</td>
595
+ <td><strong>${params.stock_code || 'N/A'}</strong> <span class="badge bg-secondary">${params.market_type || ''}</span></td>
596
+ <td>${statusBadge}</td>
597
+ <td><span class="badge bg-info">${params.research_depth || 'N/A'}级</span></td>
598
+ <td><small>${analysts.join(', ')}</small></td>
599
+ <td><small>${task.created_at}</small></td>
600
+ <td><small>${task.updated_at}</small></td>
601
+ <td class="text-end">
602
+ <a href="/agent_analysis?task_id=${task.id}" class="btn btn-sm btn-primary">
603
+ <i class="fas fa-eye"></i> 查看
604
+ </a>
605
+ <button class="btn btn-sm btn-outline-danger delete-single-btn" data-task-id="${task.id}">
606
+ <i class="fas fa-trash-alt"></i>
607
+ </button>
608
+ </td>
609
+ </tr>
610
+ `;
611
+ });
612
+
613
+ tableHtml += '</tbody></table></div>';
614
+ $('#history-table-container').html(tableHtml);
615
+ }
616
+
617
+ function getStatusBadge(status) {
618
+ if (status === 'completed') {
619
+ return '<span class="badge bg-success">已完成</span>';
620
+ } else if (status === 'failed') {
621
+ return '<span class="badge bg-danger">失败</span>';
622
+ }
623
+ return `<span class="badge bg-secondary">${status}</span>`;
624
+ }
625
+
626
+ $('#refresh-history-btn').click(function() {
627
+ loadHistory();
628
+ });
629
+
630
+ // Initial load of history
631
+ loadHistory();
632
+
633
+ // --- Deletion and Selection Logic ---
634
+ function performDeletion(taskIds) {
635
+ if (!confirm(`您确定要删除 ${taskIds.length} 个分析记录吗?此操作无法撤销。`)) {
636
+ return;
637
+ }
638
+
639
+ $.ajax({
640
+ url: '/api/delete_agent_analysis',
641
+ type: 'POST',
642
+ contentType: 'application/json',
643
+ data: JSON.stringify({ task_ids: taskIds }),
644
+ success: function(response) {
645
+ if (response.success) {
646
+ showSuccess(response.message || '成功删除记录');
647
+ loadHistory(); // Refresh the history table
648
+ } else {
649
+ showError(response.error || '删除失败');
650
+ }
651
+ },
652
+ error: function(xhr) {
653
+ const errorMsg = xhr.responseJSON ? xhr.responseJSON.error : '请求失败';
654
+ showError('删除操作失败: ' + errorMsg);
655
+ }
656
+ });
657
+ }
658
+
659
+ // Event delegation for dynamically added elements
660
+ $('#history-table-container').on('click', '.delete-single-btn', function() {
661
+ const taskId = $(this).data('task-id');
662
+ performDeletion([taskId]);
663
+ });
664
+
665
+ $('#history-table-container').on('click', '#delete-selected-btn', function() {
666
+ const selectedIds = $('.row-checkbox:checked').map(function() {
667
+ return $(this).val();
668
+ }).get();
669
+
670
+ if (selectedIds.length > 0) {
671
+ performDeletion(selectedIds);
672
+ }
673
+ });
674
+
675
+ // Checkbox selection logic
676
+ $('#history-table-container').on('change', '#select-all-checkbox', function() {
677
+ $('.row-checkbox').prop('checked', $(this).prop('checked'));
678
+ updateDeleteButtonState();
679
+ });
680
+
681
+ $('#history-table-container').on('change', '.row-checkbox', function() {
682
+ if ($('.row-checkbox:checked').length === $('.row-checkbox').length) {
683
+ $('#select-all-checkbox').prop('checked', true);
684
+ } else {
685
+ $('#select-all-checkbox').prop('checked', false);
686
+ }
687
+ updateDeleteButtonState();
688
+ });
689
+
690
+ // Set analysis date to today by default
691
+ document.getElementById('analysis-date').valueAsDate = new Date();
692
+
693
+ // Handle max output length slider
694
+ const maxLengthSlider = document.getElementById('max-output-length');
695
+ const maxLengthValue = document.getElementById('max-output-length-value');
696
+ if (maxLengthSlider) {
697
+ maxLengthSlider.addEventListener('input', function() {
698
+ maxLengthValue.textContent = this.value;
699
+ });
700
+ }
701
+ });
702
+ </script>
703
+ {% endblock %}
app/web/templates/capital_flow.html ADDED
@@ -0,0 +1,866 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}资金流向 - 智能分析系统{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="container-fluid py-3">
7
+ <div id="alerts-container"></div>
8
+
9
+ <div class="row mb-3">
10
+ <div class="col-12">
11
+ <div class="card">
12
+ <div class="card-header py-2">
13
+ <h5 class="mb-0">资金流向分析</h5>
14
+ </div>
15
+ <div class="card-body py-2">
16
+ <form id="capital-flow-form" class="row g-2">
17
+ <div class="col-md-3">
18
+ <div class="input-group input-group-sm">
19
+ <span class="input-group-text">数据类型</span>
20
+ <select class="form-select" id="data-type">
21
+ <option value="concept" selected>概念资金流</option>
22
+ <option value="individual" >个股资金流</option>
23
+ </select>
24
+ </div>
25
+ </div>
26
+ <div class="col-md-3">
27
+ <div class="input-group input-group-sm">
28
+ <span class="input-group-text">周期</span>
29
+ <select class="form-select" id="period-select">
30
+ <option value="10日排行" selected>10日排行</option>
31
+ <option value="5日排行">5日排行</option>
32
+ <option value="3日排行">3日排行</option>
33
+ </select>
34
+ </div>
35
+ </div>
36
+ <div class="col-md-4 stock-input" style="display: none;">
37
+ <div class="input-group input-group-sm">
38
+ <span class="input-group-text">股票代码</span>
39
+ <input type="text" class="form-control" id="stock-code" placeholder="例如: 600519">
40
+ </div>
41
+ </div>
42
+ <div class="col-md-2">
43
+ <button type="submit" class="btn btn-primary btn-sm w-100">
44
+ <i class="fas fa-search"></i> 查询
45
+ </button>
46
+ </div>
47
+ </form>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ </div>
52
+
53
+ <!-- Loading Panel -->
54
+ <div id="loading-panel" class="text-center py-5" style="display: none;">
55
+ <div class="spinner-border text-primary" role="status">
56
+ <span class="visually-hidden">Loading...</span>
57
+ </div>
58
+ <p class="mt-3 mb-0">正在获取资金流向数据...</p>
59
+ </div>
60
+
61
+ <!-- Concept Fund Flow Panel -->
62
+ <div id="concept-flow-panel" class="row g-3 mb-3" style="display: none;">
63
+ <div class="col-12">
64
+ <div class="card">
65
+ <div class="card-header py-2 d-flex justify-content-between align-items-center">
66
+ <h5 class="mb-0">概念资金流向</h5>
67
+ <span id="concept-period-badge" class="badge bg-primary">10日排行</span>
68
+ </div>
69
+ <div class="card-body">
70
+ <div class="table-responsive">
71
+ <table class="table table-sm table-striped table-hover">
72
+ <thead>
73
+ <tr>
74
+ <th>序号</th>
75
+ <th>概念/行业</th>
76
+ <th data-sort="sector_index">行业指数 <i class="fas fa-sort"></i></th>
77
+ <th data-sort="change_percent">涨跌幅 <i class="fas fa-sort"></i></th>
78
+ <th data-sort="inflow">流入资金(亿) <i class="fas fa-sort"></i></th>
79
+ <th data-sort="outflow">流出资金(亿) <i class="fas fa-sort"></i></th>
80
+ <th data-sort="net_flow">净额(亿) <i class="fas fa-sort"></i></th>
81
+ <th data-sort="company_count">公司家数 <i class="fas fa-sort"></i></th>
82
+ <th>操作</th>
83
+ </tr>
84
+ </thead>
85
+ <tbody id="concept-flow-table">
86
+ <!-- 资金流向数据将在JS中填充 -->
87
+ </tbody>
88
+ </table>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ </div>
93
+ </div>
94
+
95
+ <!-- Concept Stocks Panel -->
96
+ <div id="concept-stocks-panel" class="row g-3 mb-3" style="display: none;">
97
+ <div class="col-12">
98
+ <div class="card">
99
+ <div class="card-header py-2">
100
+ <h5 id="concept-stocks-title" class="mb-0">概念成分股</h5>
101
+ </div>
102
+ <div class="card-body">
103
+ <div class="table-responsive">
104
+ <table class="table table-sm table-striped table-hover">
105
+ <thead>
106
+ <tr>
107
+ <th>代码</th>
108
+ <th>名称</th>
109
+ <th>最新价</th>
110
+ <th>涨跌幅</th>
111
+ <th>主力净流入</th>
112
+ <th>主力净流入占比</th>
113
+ <th>操作</th>
114
+ </tr>
115
+ </thead>
116
+ <tbody id="concept-stocks-table">
117
+ <!-- 概念成分股数据将在JS中填充 -->
118
+ </tbody>
119
+ </table>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ </div>
125
+
126
+ <!-- Individual Fund Flow Panel -->
127
+ <div id="individual-flow-panel" class="row g-3 mb-3" style="display: none;">
128
+ <div class="col-12">
129
+ <div class="card">
130
+ <div class="card-header py-2 d-flex justify-content-between align-items-center">
131
+ <h5 id="individual-flow-title" class="mb-0">个股资金流向</h5>
132
+ <span id="individual-period-badge" class="badge bg-primary">10日排行</span>
133
+ </div>
134
+ <div class="card-body">
135
+ <div class="row">
136
+ <div class="col-md-6">
137
+ <h6>资金流向概览</h6>
138
+ <table class="table table-sm">
139
+ <tbody id="individual-flow-summary">
140
+ <!-- 个股资金流向概览将在JS中填充 -->
141
+ </tbody>
142
+ </table>
143
+ </div>
144
+ <div class="col-md-6">
145
+ <h6>资金流入占比</h6>
146
+ <div id="fund-flow-pie-chart" style="height: 200px;"></div>
147
+ </div>
148
+ </div>
149
+ <div class="row mt-3">
150
+ <div class="col-12">
151
+ <h6>资金流向历史</h6>
152
+ <div id="fund-flow-history-chart" style="height: 300px;"></div>
153
+ </div>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ </div>
159
+
160
+ <!-- Individual Fund Flow Rank Panel -->
161
+ <div id="individual-rank-panel" class="row g-3 mb-3" style="display: none;">
162
+ <div class="col-12">
163
+ <div class="card">
164
+ <div class="card-header py-2 d-flex justify-content-between align-items-center">
165
+ <h5 class="mb-0">个股资金流向排名</h5>
166
+ <span id="individual-rank-period-badge" class="badge bg-primary">10日排行</span>
167
+ </div>
168
+ <div class="card-body">
169
+ <div class="table-responsive">
170
+ <table class="table table-sm table-striped table-hover">
171
+ <thead>
172
+ <tr>
173
+ <th>序号</th>
174
+ <th>代码</th>
175
+ <th>名称</th>
176
+ <th data-sort="price">最新价 <i class="fas fa-sort"></i></th>
177
+ <th data-sort="change_percent">涨跌幅 <i class="fas fa-sort"></i></th>
178
+ <th data-sort="main_net_inflow">主力净流入 <i class="fas fa-sort"></i></th>
179
+ <th data-sort="main_net_inflow_percent">主力净流入占比 <i class="fas fa-sort"></i></th>
180
+ <th data-sort="super_large_net_inflow">超大单净流入 <i class="fas fa-sort"></i></th>
181
+ <th data-sort="large_net_inflow">大单净流入 <i class="fas fa-sort"></i></th>
182
+ <th data-sort="medium_net_inflow">中单净流入 <i class="fas fa-sort"></i></th>
183
+ <th data-sort="small_net_inflow">小单净流入 <i class="fas fa-sort"></i></th>
184
+ <th>操作</th>
185
+ </tr>
186
+ </thead>
187
+ <tbody id="individual-rank-table">
188
+ <!-- 个股资金流向排名数据将在JS中填充 -->
189
+ </tbody>
190
+ </table>
191
+ </div>
192
+ </div>
193
+ </div>
194
+ </div>
195
+ </div>
196
+ </div>
197
+ {% endblock %}
198
+
199
+ {% block scripts %}
200
+ <script>
201
+ let conceptData = [];
202
+ let individualRankData = [];
203
+ let sortConfig = {
204
+ concept: { key: 'rank', order: 'asc' },
205
+ individual: { key: 'rank', order: 'asc' }
206
+ };
207
+
208
+ $(document).ready(function() {
209
+ // 默认加载概念资金流向
210
+ loadConceptFundFlow('10日排行');
211
+
212
+ // 表单提交事件
213
+ $('#capital-flow-form').submit(function(e) {
214
+ e.preventDefault();
215
+ const dataType = $('#data-type').val();
216
+ const period = $('#period-select').val();
217
+ const stockCode = $('#stock-code').val().trim();
218
+
219
+ if (dataType === 'concept') {
220
+ loadConceptFundFlow(period);
221
+ } else if (dataType === 'individual') {
222
+ if (stockCode) {
223
+ loadIndividualFundFlow(stockCode);
224
+ } else {
225
+ loadIndividualFundFlowRank(period);
226
+ }
227
+ }
228
+ });
229
+
230
+ // 数据类型切换事件
231
+ $('#data-type').change(function() {
232
+ const dataType = $(this).val();
233
+ if (dataType === 'individual') {
234
+ $('.stock-input').show();
235
+ } else {
236
+ $('.stock-input').hide();
237
+ }
238
+ });
239
+
240
+ // 排序事件
241
+ $('#concept-flow-panel').on('click', 'th[data-sort]', function() {
242
+ handleSort('concept', $(this).data('sort'));
243
+ });
244
+
245
+ $('#individual-rank-panel').on('click', 'th[data-sort]', function() {
246
+ handleSort('individual', $(this).data('sort'));
247
+ });
248
+ });
249
+
250
+ function handleSort(type, key) {
251
+ const config = sortConfig[type];
252
+ if (config.key === key) {
253
+ config.order = config.order === 'asc' ? 'desc' : 'asc';
254
+ } else {
255
+ config.key = key;
256
+ config.order = 'asc';
257
+ }
258
+
259
+ if (type === 'concept') {
260
+ renderConceptFundFlow(conceptData, $('#concept-period-badge').text());
261
+ } else {
262
+ renderIndividualFundFlowRank(individualRankData, $('#individual-rank-period-badge').text());
263
+ }
264
+ }
265
+
266
+ // 加载概念资金流向
267
+ function loadConceptFundFlow(period) {
268
+ $('#loading-panel').show();
269
+ $('#concept-flow-panel, #concept-stocks-panel, #individual-flow-panel, #individual-rank-panel').hide();
270
+
271
+ $.ajax({
272
+ url: `/api/concept_fund_flow?period=${period}`,
273
+ type: 'GET',
274
+ success: function(response) {
275
+ renderConceptFundFlow(response, period);
276
+ $('#loading-panel').hide();
277
+ $('#concept-flow-panel').show();
278
+ },
279
+ error: function(xhr, status, error) {
280
+ $('#loading-panel').hide();
281
+ showError('获取概念资金流向数据失败: ' + error);
282
+ }
283
+ });
284
+ }
285
+
286
+ // 加载概念成分股
287
+ function loadConceptStocks(sector) {
288
+ $('#loading-panel').show();
289
+ $('#concept-stocks-panel').hide();
290
+
291
+ $.ajax({
292
+ url: `/api/sector_stocks?sector=${encodeURIComponent(sector)}`,
293
+ type: 'GET',
294
+ success: function(response) {
295
+ renderConceptStocks(response, sector);
296
+ $('#loading-panel').hide();
297
+ $('#concept-stocks-panel').show();
298
+ },
299
+ error: function(xhr, status, error) {
300
+ $('#loading-panel').hide();
301
+ showError('获取概念成分股数据失败: ' + error);
302
+ }
303
+ });
304
+ }
305
+
306
+ // 加载个股资金流向
307
+ function loadIndividualFundFlow(stockCode) {
308
+ $('#loading-panel').show();
309
+ $('#concept-flow-panel, #concept-stocks-panel, #individual-flow-panel, #individual-rank-panel').hide();
310
+
311
+ $.ajax({
312
+ url: `/api/individual_fund_flow?stock_code=${stockCode}`,
313
+ type: 'GET',
314
+ success: function(response) {
315
+ renderIndividualFundFlow(response);
316
+ $('#loading-panel').hide();
317
+ $('#individual-flow-panel').show();
318
+ },
319
+ error: function(xhr, status, error) {
320
+ $('#loading-panel').hide();
321
+ showError('获取个股资金流向数据失败: ' + error);
322
+ }
323
+ });
324
+ }
325
+
326
+ // 加载个股资金流向排名
327
+ function loadIndividualFundFlowRank(period) {
328
+ $('#loading-panel').show();
329
+ $('#concept-flow-panel, #concept-stocks-panel, #individual-flow-panel, #individual-rank-panel').hide();
330
+
331
+ $.ajax({
332
+ url: `/api/individual_fund_flow_rank?period=${period}`,
333
+ type: 'GET',
334
+ success: function(response) {
335
+ renderIndividualFundFlowRank(response, period);
336
+ $('#loading-panel').hide();
337
+ $('#individual-rank-panel').show();
338
+ },
339
+ error: function(xhr, status, error) {
340
+ $('#loading-panel').hide();
341
+ showError('获取个股资金流向排名数据失败: ' + error);
342
+ }
343
+ });
344
+ }
345
+
346
+ // 渲染概念资金流向
347
+ function renderConceptFundFlow(data, period) {
348
+ if (data) {
349
+ conceptData = data;
350
+ }
351
+ $('#concept-period-badge').text(period);
352
+
353
+ // 更新排序图标
354
+ updateSortIcons('concept');
355
+
356
+ // 排序数据
357
+ const sortedData = sortData(conceptData, sortConfig.concept.key, sortConfig.concept.order);
358
+
359
+ let html = '';
360
+ if (!sortedData || sortedData.length === 0) {
361
+ html = '<tr><td colspan="9" class="text-center">暂无数据</td></tr>';
362
+ } else {
363
+ sortedData.forEach((item, index) => {
364
+ const changeClass = parseFloat(item.change_percent) >= 0 ? 'trend-up' : 'trend-down';
365
+ const changeIcon = parseFloat(item.change_percent) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
366
+
367
+ const netFlowClass = parseFloat(item.net_flow) >= 0 ? 'trend-up' : 'trend-down';
368
+ const netFlowIcon = parseFloat(item.net_flow) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
369
+
370
+ html += `
371
+ <tr>
372
+ <td>${item.rank}</td>
373
+ <td><a href="javascript:void(0)" onclick="loadConceptStocks('${item.sector}')">${item.sector}</a></td>
374
+ <td>${formatNumber(item.sector_index, 2)}</td>
375
+ <td class="${changeClass}">${changeIcon} ${formatPercent(item.change_percent)}</td>
376
+ <td>${formatNumber(item.inflow, 2)}</td>
377
+ <td>${formatNumber(item.outflow, 2)}</td>
378
+ <td class="${netFlowClass}">${netFlowIcon} ${formatNumber(item.net_flow, 2)}</td>
379
+ <td>${item.company_count}</td>
380
+ <td>
381
+ <button class="btn btn-sm btn-outline-primary" onclick="loadConceptStocks('${item.sector}')">
382
+ <i class="fas fa-search"></i>
383
+ </button>
384
+ </td>
385
+ </tr>
386
+ `;
387
+ });
388
+ }
389
+
390
+ $('#concept-flow-table').html(html);
391
+ }
392
+
393
+ // 渲染概念成分股
394
+ function renderConceptStocks(data, sector) {
395
+ $('#concept-stocks-title').text(`${sector} 成分股`);
396
+
397
+ let html = '';
398
+ if (!data || data.length === 0) {
399
+ html = '<tr><td colspan="7" class="text-center">暂无数据</td></tr>';
400
+ } else {
401
+ data.forEach((item) => {
402
+ const changeClass = parseFloat(item.change_percent) >= 0 ? 'trend-up' : 'trend-down';
403
+ const changeIcon = parseFloat(item.change_percent) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
404
+
405
+ const netFlowClass = parseFloat(item.main_net_inflow) >= 0 ? 'trend-up' : 'trend-down';
406
+ const netFlowIcon = parseFloat(item.main_net_inflow) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
407
+
408
+ html += `
409
+ <tr>
410
+ <td>${item.code}</td>
411
+ <td>${item.name}</td>
412
+ <td>${formatNumber(item.price, 2)}</td>
413
+ <td class="${changeClass}">${changeIcon} ${formatPercent(item.change_percent)}</td>
414
+ <td class="${netFlowClass}">${netFlowIcon} ${formatMoney(item.main_net_inflow)}</td>
415
+ <td class="${netFlowClass}">${formatPercent(item.main_net_inflow_percent)}</td>
416
+ <td>
417
+ <a href="/stock_detail/${item.code}" class="btn btn-sm btn-outline-primary">
418
+ <i class="fas fa-chart-line"></i>
419
+ </a>
420
+ <button class="btn btn-sm btn-outline-info" onclick="loadIndividualFundFlow('${item.code}')">
421
+ <i class="fas fa-money-bill-wave"></i>
422
+ </button>
423
+ </td>
424
+ </tr>
425
+ `;
426
+ });
427
+ }
428
+
429
+ $('#concept-stocks-table').html(html);
430
+ }
431
+
432
+ // 渲染个股资金流向
433
+ function renderIndividualFundFlow(data) {
434
+ if (!data || !data.data || data.data.length === 0) {
435
+ showError('未获取到有效的个股资金流向数据');
436
+ return;
437
+ }
438
+
439
+ // Sort data by date (descending - newest first)
440
+ data.data.sort((a, b) => {
441
+ // Parse dates to ensure proper comparison
442
+ let dateA = new Date(a.date);
443
+ let dateB = new Date(b.date);
444
+ return dateB - dateA;
445
+ });
446
+
447
+ // Re-calculate summary for 90 days instead of relying on backend calculation
448
+ recalculateSummary(data, 90);
449
+
450
+ // 设置标题
451
+ $('#individual-flow-title').text(`${data.stock_code} 资金流向`);
452
+
453
+ // ��染概览
454
+ renderIndividualFlowSummary(data);
455
+
456
+ // 渲染资金流入占比饼图
457
+ renderFundFlowPieChart(data);
458
+
459
+ // 渲染资金流向历史图表
460
+ renderFundFlowHistoryChart(data);
461
+ }
462
+
463
+ function recalculateSummary(data, days) {
464
+ // Get recent data (up to the specified number of days)
465
+ const recent_data = data.data.slice(0, Math.min(days, data.data.length));
466
+
467
+ // Calculate summary statistics
468
+ const total_main_net_inflow = recent_data.reduce((sum, item) => sum + item.main_net_inflow, 0);
469
+ const avg_main_net_inflow_percent = recent_data.reduce((sum, item) => sum + item.main_net_inflow_percent, 0) / recent_data.length;
470
+ const positive_days = recent_data.filter(item => item.main_net_inflow > 0).length;
471
+ const negative_days = recent_data.length - positive_days;
472
+
473
+ // Create or update summary object
474
+ data.summary = {
475
+ recent_days: recent_data.length,
476
+ total_main_net_inflow: total_main_net_inflow,
477
+ avg_main_net_inflow_percent: avg_main_net_inflow_percent,
478
+ positive_days: positive_days,
479
+ negative_days: negative_days
480
+ };
481
+ }
482
+
483
+ // 渲染个股资金流向概览
484
+ function renderIndividualFlowSummary(data) {
485
+ if (!data.summary) return;
486
+
487
+ const summary = data.summary;
488
+ // Now using the first item after sorting
489
+ const recent = data.data[0]; // 最近一天的数据
490
+
491
+ let html = `
492
+ <tr>
493
+ <td>最新日期:</td>
494
+ <td>${recent.date}</td>
495
+ <td>最新价:</td>
496
+ <td>${formatNumber(recent.price, 2)}</td>
497
+ </tr>
498
+ <tr>
499
+ <td>涨跌幅:</td>
500
+ <td class="${recent.change_percent >= 0 ? 'trend-up' : 'trend-down'}">
501
+ ${recent.change_percent >= 0 ? '↑' : '↓'} ${formatPercent(recent.change_percent)}
502
+ </td>
503
+ <td>分析周期:</td>
504
+ <td>${summary.recent_days}天</td>
505
+ </tr>
506
+ <tr>
507
+ <td>主力净流入:</td>
508
+ <td class="${summary.total_main_net_inflow >= 0 ? 'trend-up' : 'trend-down'}">
509
+ ${summary.total_main_net_inflow >= 0 ? '↑' : '↓'} ${formatMoney(summary.total_main_net_inflow)}
510
+ </td>
511
+ <td>净流入占比:</td>
512
+ <td class="${summary.avg_main_net_inflow_percent >= 0 ? 'trend-up' : 'trend-down'}">
513
+ ${summary.avg_main_net_inflow_percent >= 0 ? '↑' : '↓'} ${formatPercent(summary.avg_main_net_inflow_percent)}
514
+ </td>
515
+ </tr>
516
+ <tr>
517
+ <td>资金流入天数:</td>
518
+ <td>${summary.positive_days}天</td>
519
+ <td>资金流出天数:</td>
520
+ <td>${summary.negative_days}天</td>
521
+ </tr>
522
+ `;
523
+
524
+ $('#individual-flow-summary').html(html);
525
+ }
526
+
527
+ // 渲染资金流入占比饼图
528
+ function renderFundFlowPieChart(data) {
529
+ if (!data.data || data.data.length === 0) return;
530
+
531
+ // Using the first item after sorting
532
+ const recent = data.data[0]; // 最近一天的数据
533
+
534
+ // 计算资金流入总额(绝对值)
535
+ const totalInflow = Math.abs(recent.super_large_net_inflow) +
536
+ Math.abs(recent.large_net_inflow) +
537
+ Math.abs(recent.medium_net_inflow) +
538
+ Math.abs(recent.small_net_inflow);
539
+
540
+ // 计算各类型占比
541
+ const superLargePct = Math.abs(recent.super_large_net_inflow) / totalInflow * 100;
542
+ const largePct = Math.abs(recent.large_net_inflow) / totalInflow * 100;
543
+ const mediumPct = Math.abs(recent.medium_net_inflow) / totalInflow * 100;
544
+ const smallPct = Math.abs(recent.small_net_inflow) / totalInflow * 100;
545
+
546
+ const options = {
547
+ series: [superLargePct, largePct, mediumPct, smallPct],
548
+ chart: {
549
+ type: 'pie',
550
+ height: 200
551
+ },
552
+ labels: ['超大单', '大单', '中单', '小单'],
553
+ colors: ['#0d6efd', '#198754', '#ffc107', '#dc3545'],
554
+ legend: {
555
+ position: 'bottom'
556
+ },
557
+ tooltip: {
558
+ y: {
559
+ formatter: function(value) {
560
+ return value.toFixed(2) + '%';
561
+ }
562
+ }
563
+ }
564
+ };
565
+
566
+ // 清除旧图表
567
+ $('#fund-flow-pie-chart').empty();
568
+
569
+ const chart = new ApexCharts(document.querySelector("#fund-flow-pie-chart"), options);
570
+ chart.render();
571
+ }
572
+
573
+ // 渲染资金流向历史图表
574
+ function renderFundFlowHistoryChart(data) {
575
+ if (!data.data || data.data.length === 0) return;
576
+
577
+ // 最近90天的数据
578
+ // Since we've already sorted the data, just get the first 90 and reverse for chronological display
579
+ const historyData = data.data.slice(0, 90).reverse();
580
+
581
+ const dates = historyData.map(item => item.date);
582
+ const mainNetInflow = historyData.map(item => item.main_net_inflow);
583
+ const superLargeInflow = historyData.map(item => item.super_large_net_inflow);
584
+ const largeInflow = historyData.map(item => item.large_net_inflow);
585
+ const mediumInflow = historyData.map(item => item.medium_net_inflow);
586
+ const smallInflow = historyData.map(item => item.small_net_inflow);
587
+ const priceChanges = historyData.map(item => item.change_percent);
588
+
589
+ const options = {
590
+ series: [
591
+ {
592
+ name: '主力净流入',
593
+ type: 'column',
594
+ data: mainNetInflow
595
+ },
596
+ {
597
+ name: '超大单',
598
+ type: 'line',
599
+ data: superLargeInflow
600
+ },
601
+ {
602
+ name: '大单',
603
+ type: 'line',
604
+ data: largeInflow
605
+ },
606
+ {
607
+ name: '价格涨跌幅',
608
+ type: 'line',
609
+ data: priceChanges
610
+ }
611
+ ],
612
+ chart: {
613
+ height: 300,
614
+ type: 'line',
615
+ toolbar: {
616
+ show: false
617
+ }
618
+ },
619
+ stroke: {
620
+ width: [0, 2, 2, 2],
621
+ curve: 'smooth'
622
+ },
623
+ plotOptions: {
624
+ bar: {
625
+ columnWidth: '50%'
626
+ }
627
+ },
628
+ colors: ['#0d6efd', '#198754', '#ffc107', '#dc3545'],
629
+ dataLabels: {
630
+ enabled: false
631
+ },
632
+ labels: dates,
633
+ xaxis: {
634
+ type: 'category'
635
+ },
636
+ yaxis: [
637
+ {
638
+ title: {
639
+ text: '资金流入(亿)',
640
+ style: {
641
+ fontSize: '12px'
642
+ }
643
+ },
644
+ labels: {
645
+ formatter: function(val) {
646
+ // Convert to 亿 for display (divide by 100 million)
647
+ return (val / 100000000).toFixed(2);
648
+ }
649
+ }
650
+ },
651
+ {
652
+ opposite: true,
653
+ title: {
654
+ text: '价格涨跌幅(%)',
655
+ style: {
656
+ fontSize: '12px'
657
+ }
658
+ },
659
+ labels: {
660
+ formatter: function(val) {
661
+ return val.toFixed(2);
662
+ }
663
+ },
664
+ min: -10,
665
+ max: 10,
666
+ tickAmount: 5
667
+ }
668
+ ],
669
+ tooltip: {
670
+ shared: true,
671
+ intersect: false,
672
+ y: {
673
+ formatter: function(y, { seriesIndex }) {
674
+ if (seriesIndex === 3) {
675
+ return y.toFixed(2) + '%';
676
+ }
677
+ // Display money values in 亿 (hundred million) units
678
+ return (y / 100000000).toFixed(2) + ' 亿';
679
+ }
680
+ }
681
+ },
682
+ legend: {
683
+ position: 'top'
684
+ }
685
+ };
686
+
687
+ // 清除旧图表
688
+ $('#fund-flow-history-chart').empty();
689
+
690
+ const chart = new ApexCharts(document.querySelector("#fund-flow-history-chart"), options);
691
+ chart.render();
692
+ }
693
+
694
+ // 渲染个股资金流向排名
695
+ function renderIndividualFundFlowRank(data, period) {
696
+ if (data) {
697
+ individualRankData = data;
698
+ }
699
+ $('#individual-rank-period-badge').text(period);
700
+
701
+ // 更新排序图标
702
+ updateSortIcons('individual');
703
+
704
+ // 排序数据
705
+ const sortedData = sortData(individualRankData, sortConfig.individual.key, sortConfig.individual.order);
706
+
707
+ let html = '';
708
+ if (!sortedData || sortedData.length === 0) {
709
+ html = '<tr><td colspan="12" class="text-center">暂无数据</td></tr>';
710
+ } else {
711
+ sortedData.forEach((item) => {
712
+ const changeClass = parseFloat(item.change_percent) >= 0 ? 'trend-up' : 'trend-down';
713
+ const changeIcon = parseFloat(item.change_percent) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
714
+
715
+ const mainNetClass = parseFloat(item.main_net_inflow) >= 0 ? 'trend-up' : 'trend-down';
716
+ const mainNetIcon = parseFloat(item.main_net_inflow) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
717
+
718
+ html += `
719
+ <tr>
720
+ <td>${item.rank}</td>
721
+ <td>${item.code}</td>
722
+ <td>${item.name}</td>
723
+ <td>${formatNumber(item.price, 2)}</td>
724
+ <td class="${changeClass}">${changeIcon} ${formatPercent(item.change_percent)}</td>
725
+ <td class="${mainNetClass}">${mainNetIcon} ${formatMoney(item.main_net_inflow)}</td>
726
+ <td class="${mainNetClass}">${formatPercent(item.main_net_inflow_percent)}</td>
727
+ <td>${formatMoney(item.super_large_net_inflow)}</td>
728
+ <td>${formatMoney(item.large_net_inflow)}</td>
729
+ <td>${formatMoney(item.medium_net_inflow)}</td>
730
+ <td>${formatMoney(item.small_net_inflow)}</td>
731
+ <td>
732
+ <a href="/stock_detail/${item.code}" class="btn btn-sm btn-outline-primary">
733
+ <i class="fas fa-chart-line"></i>
734
+ </a>
735
+ <button class="btn btn-sm btn-outline-info" onclick="loadIndividualFundFlow('${item.code}')">
736
+ <i class="fas fa-money-bill-wave"></i>
737
+ </button>
738
+ </td>
739
+ </tr>
740
+ `;
741
+ });
742
+ }
743
+
744
+ $('#individual-rank-table').html(html);
745
+ }
746
+
747
+ // 格式化资金数字(支持大数字缩写)
748
+ function formatCompactNumber(num) {
749
+ if (Math.abs(num) >= 1.0e9) {
750
+ return (num / 1.0e9).toFixed(2) + "B";
751
+ } else if (Math.abs(num) >= 1.0e6) {
752
+ return (num / 1.0e6).toFixed(2) + "M";
753
+ } else if (Math.abs(num) >= 1.0e3) {
754
+ return (num / 1.0e3).toFixed(2) + "K";
755
+ } else {
756
+ return num.toFixed(2);
757
+ }
758
+ }
759
+
760
+ // 格式化资金
761
+ function formatMoney(value) {
762
+ if (value === null || value === undefined) {
763
+ return '--';
764
+ }
765
+
766
+ value = parseFloat(value);
767
+ if (isNaN(value)) {
768
+ return '--';
769
+ }
770
+
771
+ if (Math.abs(value) >= 1e8) {
772
+ return (value / 1e8).toFixed(2) + ' 亿';
773
+ } else if (Math.abs(value) >= 1e4) {
774
+ return (value / 1e4).toFixed(2) + ' 万';
775
+ } else {
776
+ return value.toFixed(2) + ' 元';
777
+ }
778
+ }
779
+
780
+ // 格式化百分比
781
+ function formatPercent(value) {
782
+ if (value === null || value === undefined) {
783
+ return '--';
784
+ }
785
+
786
+ value = parseFloat(value);
787
+ if (isNaN(value)) {
788
+ return '--';
789
+ }
790
+
791
+ return value.toFixed(2) + '%';
792
+ }
793
+
794
+ // 格式化数字
795
+ function formatNumber(value, decimals = 2) {
796
+ if (value === null || value === undefined) {
797
+ return '--';
798
+ }
799
+ value = parseFloat(value);
800
+ if (isNaN(value)) {
801
+ return '--';
802
+ }
803
+ return value.toFixed(decimals);
804
+ }
805
+
806
+ function sortData(data, key, order) {
807
+ if (!data) return [];
808
+ return data.sort((a, b) => {
809
+ let valA = a[key];
810
+ let valB = b[key];
811
+
812
+ if (typeof valA === 'string') {
813
+ valA = parseFloat(valA.replace(/,/g, ''));
814
+ valB = parseFloat(valB.replace(/,/g, ''));
815
+ }
816
+
817
+ if (order === 'asc') {
818
+ return valA - valB;
819
+ } else {
820
+ return valB - valA;
821
+ }
822
+ });
823
+ }
824
+
825
+ function updateSortIcons(type) {
826
+ const panelId = type === 'concept' ? '#concept-flow-panel' : '#individual-rank-panel';
827
+ const config = sortConfig[type];
828
+
829
+ $(`${panelId} th[data-sort] i`).removeClass('fa-sort-up fa-sort-down').addClass('fa-sort');
830
+
831
+ const icon = config.order === 'asc' ? 'fa-sort-up' : 'fa-sort-down';
832
+ $(`${panelId} th[data-sort="${config.key}"] i`).removeClass('fa-sort').addClass(icon);
833
+ }
834
+
835
+ document.addEventListener('DOMContentLoaded', function() {
836
+ const dataType = document.getElementById('data-type');
837
+ const periodSelect = document.getElementById('period-select');
838
+ const stockInput = document.querySelector('.stock-input');
839
+
840
+ // 初始加载时检查默认值
841
+ toggleOptions();
842
+
843
+ dataType.addEventListener('change', toggleOptions);
844
+
845
+ function toggleOptions() {
846
+ if (dataType.value === 'individual') {
847
+ // 个股资金流选项
848
+ periodSelect.innerHTML = `
849
+ <option value="3日">3日</option>
850
+ <option value="5日">5日</option>
851
+ <option value="10日">10日</option>
852
+ `;
853
+ stockInput.style.display = 'block';
854
+ } else {
855
+ // 概念资金流选项
856
+ periodSelect.innerHTML = `
857
+ <option value="10日排行" selected>10日排行</option>
858
+ <option value="5日排行">5日排��</option>
859
+ <option value="3日排行">3日排行</option>
860
+ `;
861
+ stockInput.style.display = 'none';
862
+ }
863
+ }
864
+ });
865
+ </script>
866
+ {% endblock %}
app/web/templates/dashboard.html ADDED
@@ -0,0 +1,679 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}智能仪表盘 - 智能分析系统{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="container-fluid py-3">
7
+ <div id="alerts-container"></div>
8
+ <div class="row g-3 mb-3">
9
+ <div class="col-12">
10
+ <div class="card">
11
+ <div class="card-header py-1"> <!-- 减少padding-top和padding-bottom -->
12
+ <h5 class="mb-0">智能股票分析</h5>
13
+ </div>
14
+ <div class="card-body py-2"> <!-- 减少padding-top和padding-bottom -->
15
+ <form id="analysis-form" class="row g-2"> <!-- 减少间距g-3到g-2 -->
16
+ <div class="col-md-4">
17
+ <div class="input-group input-group-sm"> <!-- 添加input-group-sm使输入框更小 -->
18
+ <span class="input-group-text">股票代码</span>
19
+ <input type="text" class="form-control" id="stock-code" placeholder="例如: 600519" required>
20
+ </div>
21
+ </div>
22
+ <div class="col-md-3">
23
+ <div class="input-group input-group-sm"> <!-- 添加input-group-sm使下拉框更小 -->
24
+ <span class="input-group-text">市场</span>
25
+ <select class="form-select" id="market-type">
26
+ <option value="A" selected>A股</option>
27
+ <option value="HK">港股</option>
28
+ <option value="US">美股</option>
29
+ </select>
30
+ </div>
31
+ </div>
32
+ <div class="col-md-3">
33
+ <div class="input-group input-group-sm"> <!-- 添加input-group-sm使下拉框更小 -->
34
+ <span class="input-group-text">周期</span>
35
+ <select class="form-select" id="analysis-period">
36
+ <option value="1m">1个月</option>
37
+ <option value="3m">3个月</option>
38
+ <option value="6m">6个月</option>
39
+ <option value="1y" selected>1年</option>
40
+ </select>
41
+ </div>
42
+ </div>
43
+ <div class="col-md-2">
44
+ <button type="submit" class="btn btn-primary btn-sm w-100"> <!-- 使用btn-sm减小按钮尺寸 -->
45
+ <i class="fas fa-chart-line"></i> 分析
46
+ </button>
47
+ </div>
48
+ </form>
49
+ </div>
50
+ </div>
51
+ </div>
52
+ </div>
53
+
54
+ <div id="analysis-result" style="display: none;">
55
+ <div class="row g-3 mb-3">
56
+ <div class="col-md-6">
57
+ <div class="card h-100">
58
+ <div class="card-header py-2">
59
+ <h5 class="mb-0">股票概要</h5>
60
+ </div>
61
+ <div class="card-body">
62
+ <div class="row mb-3">
63
+ <div class="col-md-7">
64
+ <h2 id="stock-name" class="mb-0 fs-4"></h2>
65
+ <p id="stock-info" class="text-muted mb-0 small"></p>
66
+ </div>
67
+ <div class="col-md-5 text-end">
68
+ <h3 id="stock-price" class="mb-0 fs-4"></h3>
69
+ <p id="price-change" class="mb-0"></p>
70
+ </div>
71
+ </div>
72
+ <div class="row">
73
+ <div class="col-md-6">
74
+ <div class="mb-2">
75
+ <span class="text-muted small">综合评分:</span>
76
+ <div class="mt-1">
77
+ <span id="total-score" class="badge rounded-pill score-pill"></span>
78
+ </div>
79
+ </div>
80
+ <div class="mb-2">
81
+ <span class="text-muted small">投资建议:</span>
82
+ <p id="recommendation" class="mb-0 text-strong"></p>
83
+ </div>
84
+ </div>
85
+ <div class="col-md-6">
86
+ <div class="mb-2">
87
+ <span class="text-muted small">技术面指标:</span>
88
+ <ul class="list-unstyled mt-1 mb-0 small">
89
+ <li><span class="text-muted">RSI:</span> <span id="rsi-value"></span></li>
90
+ <li><span class="text-muted">MA趋势:</span> <span id="ma-trend"></span></li>
91
+ <li><span class="text-muted">MACD信号:</span> <span id="macd-signal"></span></li>
92
+ <li><span class="text-muted">波动率:</span> <span id="volatility"></span></li>
93
+ </ul>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ </div>
100
+ <div class="col-md-6">
101
+ <div class="card h-100">
102
+ <div class="card-header py-2">
103
+ <h5 class="mb-0">多维度评分</h5>
104
+ </div>
105
+ <div class="card-body">
106
+ <div id="radar-chart" style="height: 200px;"></div>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ </div>
111
+
112
+ <div class="row g-3 mb-3">
113
+ <div class="col-12">
114
+ <div class="card">
115
+ <div class="card-header py-2">
116
+ <h5 class="mb-0">价格与技术指标</h5>
117
+ </div>
118
+ <div class="card-body p-0">
119
+ <div id="price-chart" style="height: 400px;"></div>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ </div>
124
+
125
+ <div class="row g-3 mb-3">
126
+ <div class="col-md-4">
127
+ <div class="card h-100">
128
+ <div class="card-header py-2">
129
+ <h5 class="mb-0">支撑与压力位</h5>
130
+ </div>
131
+ <div class="card-body">
132
+ <table class="table table-sm">
133
+ <thead>
134
+ <tr>
135
+ <th>类型</th>
136
+ <th>价格</th>
137
+ <th>距离</th>
138
+ </tr>
139
+ </thead>
140
+ <tbody id="support-resistance-table">
141
+ <!-- 支撑压力位数据将在JS中动态填充 -->
142
+ </tbody>
143
+ </table>
144
+ </div>
145
+ </div>
146
+ </div>
147
+ <div class="col-md-8">
148
+ <div class="card h-100">
149
+ <div class="card-header py-2">
150
+ <h5 class="mb-0">AI分析建议</h5>
151
+ </div>
152
+ <div class="card-body">
153
+ <div id="ai-analysis" class="analysis-section">
154
+ <!-- AI分析结果将在JS中动态填充 -->
155
+ <div class="loading">
156
+ <div class="spinner-border text-primary" role="status">
157
+ <span class="visually-hidden">Loading...</span>
158
+ </div>
159
+ </div>
160
+ </div>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ </div>
165
+ </div>
166
+ </div>
167
+ {% endblock %}
168
+
169
+ {% block scripts %}
170
+ <script>
171
+ let stockData = [];
172
+ let analysisResult = null;
173
+
174
+ // === THEME-AWARE CHART LOGIC START ===
175
+ let charts = {}; // Store chart instances
176
+
177
+ function getApexChartThemeOptions() {
178
+ const isDarkMode = $('html').attr('data-theme') === 'dark';
179
+ const options = {
180
+ theme: {
181
+ mode: isDarkMode ? 'dark' : 'light'
182
+ },
183
+ chart: {
184
+ background: 'transparent'
185
+ },
186
+ tooltip: {
187
+ theme: isDarkMode ? 'dark' : 'light'
188
+ }
189
+ };
190
+
191
+ if(isDarkMode) {
192
+ options.plotOptions = {
193
+ radar: {
194
+ polygons: {
195
+ strokeColors: '#555',
196
+ fill: { colors: ['#393939', '#444444'] }
197
+ }
198
+ }
199
+ };
200
+ } else {
201
+ options.plotOptions = {
202
+ radar: {
203
+ polygons: {
204
+ strokeColors: '#e9e9e9',
205
+ fill: { colors: ['#f8f8f8', '#fff'] }
206
+ }
207
+ }
208
+ };
209
+ }
210
+ return options;
211
+ }
212
+
213
+ function destroyAllCharts() {
214
+ Object.values(charts).forEach(chart => {
215
+ if (chart && typeof chart.destroy === 'function') {
216
+ chart.destroy();
217
+ }
218
+ });
219
+ charts = {};
220
+ }
221
+
222
+ function rerenderAllCharts() {
223
+ if (!analysisResult) return;
224
+ destroyAllCharts();
225
+ renderRadarChart();
226
+ renderPriceChart();
227
+ }
228
+ // === THEME-AWARE CHART LOGIC END ===
229
+
230
+
231
+ // 提交表单进行分析
232
+ $('#analysis-form').submit(function(e) {
233
+ e.preventDefault();
234
+ const stockCode = $('#stock-code').val().trim();
235
+ const marketType = $('#market-type').val();
236
+ const period = $('#analysis-period').val();
237
+
238
+ if (!stockCode) {
239
+ showError('请输入股票代码!');
240
+ return;
241
+ }
242
+
243
+ // 重定向到股票详情页
244
+ window.location.href = `/stock_detail/${stockCode}?market_type=${marketType}&period=${period}`;
245
+ });
246
+
247
+ // Observe theme changes for charts
248
+ const observer = new MutationObserver(function(mutations) {
249
+ mutations.forEach(function(mutation) {
250
+ if (mutation.attributeName === "data-theme") {
251
+ rerenderAllCharts();
252
+ }
253
+ });
254
+ });
255
+
256
+ observer.observe(document.documentElement, {
257
+ attributes: true
258
+ });
259
+
260
+
261
+ // Format AI analysis text
262
+ function formatAIAnalysis(text) {
263
+ if (!text) return '';
264
+
265
+ // First, make the text safe for HTML
266
+ const safeText = text
267
+ .replace(/&/g, '&amp;')
268
+ .replace(/</g, '&lt;')
269
+ .replace(/>/g, '&gt;');
270
+
271
+ // Replace basic Markdown elements
272
+ let formatted = safeText
273
+ // Bold text with ** or __
274
+ .replace(/\*\*(.*?)\*\*/g, '<strong class="keyword">$1</strong>')
275
+ .replace(/__(.*?)__/g, '<strong>$1</strong>')
276
+
277
+ // Italic text with * or _
278
+ .replace(/\*(.*?)\*/g, '<em>$1</em>')
279
+ .replace(/_(.*?)_/g, '<em>$1</em>')
280
+
281
+ // Headers
282
+ .replace(/^# (.*?)$/gm, '<h4 class="mt-3 mb-2">$1</h4>')
283
+ .replace(/^## (.*?)$/gm, '<h5 class="mt-2 mb-2">$1</h5>')
284
+
285
+ // Apply special styling to financial terms
286
+ .replace(/支撑位/g, '<span class="keyword">支撑位</span>')
287
+ .replace(/压力位/g, '<span class="keyword">压力位</span>')
288
+ .replace(/趋势/g, '<span class="keyword">趋势</span>')
289
+ .replace(/均线/g, '<span class="keyword">均线</span>')
290
+ .replace(/MACD/g, '<span class="term">MACD</span>')
291
+ .replace(/RSI/g, '<span class="term">RSI</span>')
292
+ .replace(/KDJ/g, '<span class="term">KDJ</span>')
293
+
294
+ // Highlight price patterns and movements
295
+ .replace(/([上涨升])/g, '<span class="trend-up">$1</span>')
296
+ .replace(/([下跌降])/g, '<span class="trend-down">$1</span>')
297
+ .replace(/(买入|做多|多头|突破)/g, '<span class="trend-up">$1</span>')
298
+ .replace(/(卖出|做空|空头|跌破)/g, '<span class="trend-down">$1</span>')
299
+
300
+ // Highlight price values (matches patterns like 31.25, 120.50)
301
+ .replace(/(\d+\.\d{2})/g, '<span class="price">$1</span>')
302
+
303
+ // Convert line breaks to paragraph tags
304
+ .replace(/\n\n+/g, '</p><p class="analysis-para">')
305
+ .replace(/\n/g, '<br>');
306
+
307
+ // Wrap in paragraph tags for consistent styling
308
+ return '<p class="analysis-para">' + formatted + '</p>';
309
+ }
310
+
311
+ // 获取股票数据
312
+ function fetchStockData(stockCode, marketType, period) {
313
+ showLoading();
314
+
315
+ $.ajax({
316
+ url: `/api/stock_data?stock_code=${stockCode}&market_type=${marketType}&period=${period}`,
317
+ type: 'GET',
318
+ dataType: 'json',
319
+ success: function(response) {
320
+
321
+ // 检查response是否有data属性
322
+ if (!response.data) {
323
+ hideLoading();
324
+ showError('响应格式不正确: 缺少data字段');
325
+ return;
326
+ }
327
+
328
+ if (response.data.length === 0) {
329
+ hideLoading();
330
+ showError('未找到股票数据');
331
+ return;
332
+ }
333
+
334
+ stockData = response.data;
335
+
336
+ // 获取增强分析数据
337
+ fetchEnhancedAnalysis(stockCode, marketType);
338
+ },
339
+ error: function(xhr, status, error) {
340
+ hideLoading();
341
+
342
+ let errorMsg = '获取股票数据失败';
343
+ if (xhr.responseJSON && xhr.responseJSON.error) {
344
+ errorMsg += ': ' + xhr.responseJSON.error;
345
+ } else if (error) {
346
+ errorMsg += ': ' + error;
347
+ }
348
+ showError(errorMsg);
349
+ }
350
+ });
351
+ }
352
+
353
+ // 获取增强分析数据
354
+ function fetchEnhancedAnalysis(stockCode, marketType) {
355
+
356
+ $.ajax({
357
+ url: '/api/enhanced_analysis?_=' + new Date().getTime(),
358
+ type: 'POST',
359
+ contentType: 'application/json',
360
+ data: JSON.stringify({
361
+ stock_code: stockCode,
362
+ market_type: marketType
363
+ }),
364
+ success: function(response) {
365
+
366
+ if (!response.result) {
367
+ hideLoading();
368
+ showError('增强分析响应格式不正确');
369
+ return;
370
+ }
371
+
372
+ analysisResult = response.result;
373
+ renderAnalysisResult();
374
+ hideLoading();
375
+ $('#analysis-result').show();
376
+ },
377
+ error: function(xhr, status, error) {
378
+ hideLoading();
379
+
380
+ let errorMsg = '获取分析数据失败';
381
+ if (xhr.responseJSON && xhr.responseJSON.error) {
382
+ errorMsg += ': ' + xhr.responseJSON.error;
383
+ } else if (error) {
384
+ errorMsg += ': ' + error;
385
+ }
386
+ showError(errorMsg);
387
+ }
388
+ });
389
+ }
390
+
391
+ // 渲染分析结果
392
+ function renderAnalysisResult() {
393
+ if (!analysisResult) return;
394
+
395
+ // 渲染股票基本信息
396
+ $('#stock-name').text(analysisResult.basic_info.stock_name + ' (' + analysisResult.basic_info.stock_code + ')');
397
+ $('#stock-info').text(analysisResult.basic_info.industry + ' | ' + analysisResult.basic_info.analysis_date);
398
+
399
+ // 渲染价格信息
400
+ $('#stock-price').text('¥' + formatNumber(analysisResult.price_data.current_price, 2));
401
+ const priceChangeClass = analysisResult.price_data.price_change >= 0 ? 'trend-up' : 'trend-down';
402
+ const priceChangeIcon = analysisResult.price_data.price_change >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
403
+ $('#price-change').html(`<span class="${priceChangeClass}">${priceChangeIcon} ${formatNumber(analysisResult.price_data.price_change_value, 2)} (${formatPercent(analysisResult.price_data.price_change, 2)})</span>`);
404
+
405
+ // 渲染评分和建议
406
+ const scoreClass = getScoreColorClass(analysisResult.scores.total_score);
407
+ $('#total-score').text(analysisResult.scores.total_score).addClass(scoreClass);
408
+ $('#recommendation').text(analysisResult.recommendation.action);
409
+
410
+ // 渲染技术指标
411
+ $('#rsi-value').text(formatNumber(analysisResult.technical_analysis.indicators.rsi, 2));
412
+
413
+ const maTrendClass = getTrendColorClass(analysisResult.technical_analysis.trend.ma_trend);
414
+ const maTrendIcon = getTrendIcon(analysisResult.technical_analysis.trend.ma_trend);
415
+ $('#ma-trend').html(`<span class="${maTrendClass}">${maTrendIcon} ${analysisResult.technical_analysis.trend.ma_status}</span>`);
416
+
417
+ const macdSignal = analysisResult.technical_analysis.indicators.macd > analysisResult.technical_analysis.indicators.macd_signal ? 'BUY' : 'SELL';
418
+ const macdClass = macdSignal === 'BUY' ? 'trend-up' : 'trend-down';
419
+ const macdIcon = macdSignal === 'BUY' ? '<i class="fas fa-arrow-up"></i>' : '<i class="fas fa-arrow-down"></i>';
420
+ $('#macd-signal').html(`<span class="${macdClass}">${macdIcon} ${macdSignal}</span>`);
421
+
422
+ $('#volatility').text(formatPercent(analysisResult.technical_analysis.indicators.volatility, 2));
423
+
424
+ // 渲染支撑压力位
425
+ let supportResistanceHtml = '';
426
+
427
+ // 渲染压力位
428
+ if (analysisResult.technical_analysis.support_resistance.resistance &&
429
+ analysisResult.technical_analysis.support_resistance.resistance.short_term &&
430
+ analysisResult.technical_analysis.support_resistance.resistance.short_term.length > 0) {
431
+ const resistance = analysisResult.technical_analysis.support_resistance.resistance.short_term[0];
432
+ const distance = ((resistance - analysisResult.price_data.current_price) / analysisResult.price_data.current_price * 100).toFixed(2);
433
+ supportResistanceHtml += `
434
+ <tr>
435
+ <td><span class="badge bg-danger">短期压力</span></td>
436
+ <td>${formatNumber(resistance, 2)}</td>
437
+ <td>+${distance}%</td>
438
+ </tr>
439
+ `;
440
+ }
441
+
442
+ if (analysisResult.technical_analysis.support_resistance.resistance &&
443
+ analysisResult.technical_analysis.support_resistance.resistance.medium_term &&
444
+ analysisResult.technical_analysis.support_resistance.resistance.medium_term.length > 0) {
445
+ const resistance = analysisResult.technical_analysis.support_resistance.resistance.medium_term[0];
446
+ const distance = ((resistance - analysisResult.price_data.current_price) / analysisResult.price_data.current_price * 100).toFixed(2);
447
+ supportResistanceHtml += `
448
+ <tr>
449
+ <td><span class="badge bg-warning text-dark">中期压力</span></td>
450
+ <td>${formatNumber(resistance, 2)}</td>
451
+ <td>+${distance}%</td>
452
+ </tr>
453
+ `;
454
+ }
455
+
456
+ // 渲染支撑位
457
+ if (analysisResult.technical_analysis.support_resistance.support &&
458
+ analysisResult.technical_analysis.support_resistance.support.short_term &&
459
+ analysisResult.technical_analysis.support_resistance.support.short_term.length > 0) {
460
+ const support = analysisResult.technical_analysis.support_resistance.support.short_term[0];
461
+ const distance = ((support - analysisResult.price_data.current_price) / analysisResult.price_data.current_price * 100).toFixed(2);
462
+ supportResistanceHtml += `
463
+ <tr>
464
+ <td><span class="badge bg-success">短期支撑</span></td>
465
+ <td>${formatNumber(support, 2)}</td>
466
+ <td>${distance}%</td>
467
+ </tr>
468
+ `;
469
+ }
470
+
471
+ if (analysisResult.technical_analysis.support_resistance.support &&
472
+ analysisResult.technical_analysis.support_resistance.support.medium_term &&
473
+ analysisResult.technical_analysis.support_resistance.support.medium_term.length > 0) {
474
+ const support = analysisResult.technical_analysis.support_resistance.support.medium_term[0];
475
+ const distance = ((support - analysisResult.price_data.current_price) / analysisResult.price_data.current_price * 100).toFixed(2);
476
+ supportResistanceHtml += `
477
+ <tr>
478
+ <td><span class="badge bg-info">中期支撑</span></td>
479
+ <td>${formatNumber(support, 2)}</td>
480
+ <td>${distance}%</td>
481
+ </tr>
482
+ `;
483
+ }
484
+
485
+ $('#support-resistance-table').html(supportResistanceHtml);
486
+
487
+ // 渲染AI分析
488
+ $('#ai-analysis').html(formatAIAnalysis(analysisResult.ai_analysis));
489
+
490
+ // 绘制雷达图
491
+ renderRadarChart();
492
+
493
+ // 绘制价格图表
494
+ renderPriceChart();
495
+ }
496
+
497
+ // 绘制雷达图
498
+ function renderRadarChart() {
499
+ if (!analysisResult) return;
500
+
501
+ const options = {
502
+ series: [{
503
+ name: '评分',
504
+ data: [
505
+ analysisResult.scores.trend_score || 0,
506
+ analysisResult.scores.indicators_score || 0,
507
+ analysisResult.scores.support_resistance_score || 0,
508
+ analysisResult.scores.volatility_volume_score || 0
509
+ ]
510
+ }],
511
+ chart: {
512
+ height: 200,
513
+ type: 'radar',
514
+ toolbar: {
515
+ show: false
516
+ }
517
+ },
518
+ title: {
519
+ text: '多维度技术分析评分',
520
+ style: {
521
+ fontSize: '14px'
522
+ }
523
+ },
524
+ xaxis: {
525
+ categories: ['趋势分析', '技术指标', '支撑压力位', '波动与成交量']
526
+ },
527
+ yaxis: {
528
+ max: 10,
529
+ min: 0
530
+ },
531
+ fill: {
532
+ opacity: 0.5,
533
+ colors: ['#4e73df']
534
+ },
535
+ markers: {
536
+ size: 4
537
+ }
538
+ };
539
+
540
+ // 清除旧图表
541
+ $('#radar-chart').empty();
542
+
543
+ const finalOptions = $.extend(true, {}, options, getApexChartThemeOptions());
544
+ const chart = new ApexCharts(document.querySelector("#radar-chart"), finalOptions);
545
+ charts.radar = chart;
546
+ chart.render();
547
+ }
548
+
549
+ // 绘制价格图表
550
+ function renderPriceChart() {
551
+ if (!stockData || stockData.length === 0) return;
552
+
553
+ // 准备价格数据
554
+ const seriesData = [];
555
+
556
+ // 添加蜡烛图数据
557
+ const candleData = stockData.map(item => ({
558
+ x: new Date(item.date),
559
+ y: [item.open, item.high, item.low, item.close]
560
+ }));
561
+ seriesData.push({
562
+ name: '价格',
563
+ type: 'candlestick',
564
+ data: candleData
565
+ });
566
+
567
+ // 添加均线数据
568
+ const ma5Data = stockData.map(item => ({
569
+ x: new Date(item.date),
570
+ y: item.MA5
571
+ }));
572
+ seriesData.push({
573
+ name: 'MA5',
574
+ type: 'line',
575
+ data: ma5Data
576
+ });
577
+
578
+ const ma20Data = stockData.map(item => ({
579
+ x: new Date(item.date),
580
+ y: item.MA20
581
+ }));
582
+ seriesData.push({
583
+ name: 'MA20',
584
+ type: 'line',
585
+ data: ma20Data
586
+ });
587
+
588
+ const ma60Data = stockData.map(item => ({
589
+ x: new Date(item.date),
590
+ y: item.MA60
591
+ }));
592
+ seriesData.push({
593
+ name: 'MA60',
594
+ type: 'line',
595
+ data: ma60Data
596
+ });
597
+
598
+ // 创建图表
599
+ const options = {
600
+ series: seriesData,
601
+ chart: {
602
+ height: 400,
603
+ type: 'candlestick',
604
+ toolbar: {
605
+ show: true,
606
+ tools: {
607
+ download: true,
608
+ selection: true,
609
+ zoom: true,
610
+ zoomin: true,
611
+ zoomout: true,
612
+ pan: true,
613
+ reset: true
614
+ }
615
+ }
616
+ },
617
+ title: {
618
+ text: `${analysisResult.basic_info.stock_name} (${analysisResult.basic_info.stock_code}) 价格走势`,
619
+ align: 'left',
620
+ style: {
621
+ fontSize: '14px'
622
+ }
623
+ },
624
+ xaxis: {
625
+ type: 'datetime'
626
+ },
627
+ yaxis: {
628
+ tooltip: {
629
+ enabled: true
630
+ },
631
+ labels: {
632
+ formatter: function(value) {
633
+ return formatNumber(value, 2); // 统一使用2位小数
634
+ }
635
+ }
636
+ },
637
+ tooltip: {
638
+ shared: true,
639
+ custom: [
640
+ function({ seriesIndex, dataPointIndex, w }) {
641
+ if (seriesIndex === 0) {
642
+ const o = w.globals.seriesCandleO[seriesIndex][dataPointIndex];
643
+ const h = w.globals.seriesCandleH[seriesIndex][dataPointIndex];
644
+ const l = w.globals.seriesCandleL[seriesIndex][dataPointIndex];
645
+ const c = w.globals.seriesCandleC[seriesIndex][dataPointIndex];
646
+
647
+ return `
648
+ <div class="apexcharts-tooltip-candlestick">
649
+ <div>开盘: <span>${formatNumber(o, 2)}</span></div>
650
+ <div>最高: <span>${formatNumber(h, 2)}</span></div>
651
+ <div>最低: <span>${formatNumber(l, 2)}</span></div>
652
+ <div>收盘: <span>${formatNumber(c, 2)}</span></div>
653
+ </div>
654
+ `;
655
+ }
656
+ return '';
657
+ }
658
+ ]
659
+ },
660
+ plotOptions: {
661
+ candlestick: {
662
+ colors: {
663
+ upward: '#3C90EB',
664
+ downward: '#DF7D46'
665
+ }
666
+ }
667
+ }
668
+ };
669
+
670
+ // 清除旧图表
671
+ $('#price-chart').empty();
672
+
673
+ const finalOptions = $.extend(true, {}, options, getApexChartThemeOptions());
674
+ const chart = new ApexCharts(document.querySelector("#price-chart"), finalOptions);
675
+ charts.price = chart;
676
+ chart.render();
677
+ }
678
+ </script>
679
+ {% endblock %}
app/web/templates/error.html ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}错误 {{ error_code }} - 股票智能分析系统{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="container py-5">
7
+ <div class="row justify-content-center">
8
+ <div class="col-md-8">
9
+ <div class="card">
10
+ <div class="card-header bg-danger text-white">
11
+ <h4 class="mb-0">错误 {{ error_code }}</h4>
12
+ </div>
13
+ <div class="card-body text-center py-5">
14
+ <i class="fas fa-exclamation-triangle fa-5x text-danger mb-4"></i>
15
+ <h2>出现错误</h2>
16
+ <p class="lead">{{ message }}</p>
17
+ <div class="mt-4">
18
+ <a href="/" class="btn btn-primary me-2">
19
+ <i class="fas fa-home"></i> 返回首页
20
+ </a>
21
+ <button class="btn btn-outline-secondary" onclick="history.back()">
22
+ <i class="fas fa-arrow-left"></i> 返回上一页
23
+ </button>
24
+ </div>
25
+ </div>
26
+ </div>
27
+ </div>
28
+ </div>
29
+ </div>
30
+ {% endblock %}
app/web/templates/etf_analysis.html ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ {% extends "layout.html" %}
3
+
4
+ {% block title %}ETF 分析{% endblock %}
5
+
6
+ {% block content %}
7
+ <div class="container mt-4">
8
+ <h1 class="mb-4">ETF 综合分析</h1>
9
+
10
+ <!-- 输入区域 -->
11
+ <div class="card mb-4">
12
+ <div class="card-body">
13
+ <h5 class="card-title">输入ETF代码</h5>
14
+ <div class="input-group">
15
+ <input type="text" id="etf-code-input" class="form-control" placeholder="例如: 510300">
16
+ <button id="analyze-btn" class="btn btn-primary">开始分析</button>
17
+ </div>
18
+ <small class="form-text text-muted">输入您想分析的ETF基金代码,然后点击“开始分析”按钮。</small>
19
+ </div>
20
+ </div>
21
+
22
+ <!-- 加载与状态显示 -->
23
+ <div id="status-container" class="mb-4" style="display: none;">
24
+ <div class="d-flex align-items-center">
25
+ <strong>正在分析中...</strong>
26
+ <div class="spinner-border ms-auto" role="status" aria-hidden="true"></div>
27
+ </div>
28
+ <div class="progress mt-2">
29
+ <div id="progress-bar" class="progress-bar" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div>
30
+ </div>
31
+ </div>
32
+
33
+ <!-- 错误提示 -->
34
+ <div id="error-alert" class="alert alert-danger" style="display: none;" role="alert"></div>
35
+
36
+ <!-- 结果显示区域 -->
37
+ <div id="results-container" class="mt-4" style="display: none;">
38
+
39
+ <!-- Tab 导航 -->
40
+ <ul class="nav nav-tabs" id="analysisTab" role="tablist">
41
+ <li class="nav-item" role="presentation">
42
+ <button class="nav-link active" id="summary-tab" data-bs-toggle="tab" data-bs-target="#summary" type="button" role="tab" aria-controls="summary" aria-selected="true">AI总结与概览</button>
43
+ </li>
44
+ <li class="nav-item" role="presentation">
45
+ <button class="nav-link" id="performance-tab" data-bs-toggle="tab" data-bs-target="#performance" type="button" role="tab" aria-controls="performance" aria-selected="false">市场表现</button>
46
+ </li>
47
+ <li class="nav-item" role="presentation">
48
+ <button class="nav-link" id="fund-flow-tab" data-bs-toggle="tab" data-bs-target="#fund-flow" type="button" role="tab" aria-controls="fund-flow" aria-selected="false">资金流向</button>
49
+ </li>
50
+ <li class="nav-item" role="presentation">
51
+ <button class="nav-link" id="risk-tab" data-bs-toggle="tab" data-bs-target="#risk" type="button" role="tab" aria-controls="risk" aria-selected="false">风险与跟踪</button>
52
+ </li>
53
+ <li class="nav-item" role="presentation">
54
+ <button class="nav-link" id="holdings-tab" data-bs-toggle="tab" data-bs-target="#holdings" type="button" role="tab" aria-controls="holdings" aria-selected="false">持仓分析</button>
55
+ </li>
56
+ <li class="nav-item" role="presentation">
57
+ <button class="nav-link" id="sector-tab" data-bs-toggle="tab" data-bs-target="#sector" type="button" role="tab" aria-controls="sector" aria-selected="false">板块分析</button>
58
+ </li>
59
+ </ul>
60
+
61
+ <!-- Tab 内容 -->
62
+ <div class="tab-content" id="analysisTabContent">
63
+ <!-- AI总结与概览 -->
64
+ <div class="tab-pane fade show active" id="summary" role="tabpanel" aria-labelledby="summary-tab">
65
+ <div class="row mt-3">
66
+ <div class="col-md-8">
67
+ <div class="card">
68
+ <div class="card-header">AI 综合诊断</div>
69
+ <div class="card-body" id="ai-summary-content"><p>AI分析摘要将显示在这里。</p></div>
70
+ </div>
71
+ </div>
72
+ <div class="col-md-4">
73
+ <div class="card">
74
+ <div class="card-header">基本信息</div>
75
+ <div class="card-body" id="basic-info-content"><p>ETF基本信息将显示在这里。</p></div>
76
+ </div>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ <!-- 市场表现 -->
81
+ <div class="tab-pane fade" id="performance" role="tabpanel" aria-labelledby="performance-tab">
82
+ <div class="card mt-3">
83
+ <div class="card-header">市场表现与技术分析</div>
84
+ <div class="card-body" id="performance-content"><p>回报率、技术指标图表将显示在这里。</p></div>
85
+ </div>
86
+ </div>
87
+ <!-- 资金流向 -->
88
+ <div class="tab-pane fade" id="fund-flow" role="tabpanel" aria-labelledby="fund-flow-tab">
89
+ <div class="card mt-3">
90
+ <div class="card-header">资金流向分析</div>
91
+ <div class="card-body" id="fund-flow-content"><p>资金流向图表将显��在这里。</p></div>
92
+ </div>
93
+ </div>
94
+ <!-- 风险与跟踪 -->
95
+ <div class="tab-pane fade" id="risk" role="tabpanel" aria-labelledby="risk-tab">
96
+ <div class="card mt-3">
97
+ <div class="card-header">风险与跟踪能力分析</div>
98
+ <div class="card-body" id="risk-content"><p>波动率、跟踪误差、溢价率将显示在这里。</p></div>
99
+ </div>
100
+ </div>
101
+ <!-- 持仓分析 -->
102
+ <div class="tab-pane fade" id="holdings" role="tabpanel" aria-labelledby="holdings-tab">
103
+ <div class="card mt-3">
104
+ <div class="card-header">持仓分析</div>
105
+ <div class="card-body" id="holdings-content"><p>前十大持仓表格将显示在这里。</p></div>
106
+ </div>
107
+ </div>
108
+ <!-- 板块分析 -->
109
+ <div class="tab-pane fade" id="sector" role="tabpanel" aria-labelledby="sector-tab">
110
+ <div class="card mt-3">
111
+ <div class="card-header">板块深度分析</div>
112
+ <div class="card-body" id="sector-content"><p>板块景气度、估值水平将显示在这里。</p></div>
113
+ </div>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ </div>
118
+ {% endblock %}
119
+
120
+ {% block scripts %}
121
+ <script>
122
+ $(document).ready(function() {
123
+ let taskId = null;
124
+ let pollingInterval = null;
125
+
126
+ // Override showError from layout to use the local error alert
127
+ function showError(message) {
128
+ $('#error-alert').text(message).show();
129
+ }
130
+
131
+ $('#analyze-btn').click(function() {
132
+ const etfCode = $('#etf-code-input').val().trim();
133
+ if (!etfCode) {
134
+ showError('请输入有效的ETF代码。');
135
+ return;
136
+ }
137
+
138
+ // Reset UI
139
+ $('#results-container').hide();
140
+ $('#error-alert').hide();
141
+ $('#status-container').show();
142
+ $('#progress-bar').css('width', '0%').attr('aria-valuenow', 0).text('0%');
143
+
144
+ // Start analysis
145
+ $.ajax({
146
+ url: '/api/start_etf_analysis',
147
+ type: 'POST',
148
+ contentType: 'application/json',
149
+ data: JSON.stringify({ etf_code: etfCode }),
150
+ success: function(response) {
151
+ taskId = response.task_id;
152
+ if (response.status === 'completed') {
153
+ displayResults(response.result);
154
+ } else {
155
+ startPolling();
156
+ }
157
+ },
158
+ error: function(xhr) {
159
+ const errorMsg = xhr.responseJSON ? xhr.responseJSON.error : '未知错误';
160
+ showError('启动分析任务失败: ' + errorMsg);
161
+ $('#status-container').hide();
162
+ }
163
+ });
164
+ });
165
+
166
+ function startPolling() {
167
+ if (pollingInterval) clearInterval(pollingInterval);
168
+
169
+ pollingInterval = setInterval(function() {
170
+ if (!taskId) {
171
+ clearInterval(pollingInterval);
172
+ return;
173
+ }
174
+
175
+ $.ajax({
176
+ url: `/api/etf_analysis_status/${taskId}`,
177
+ type: 'GET',
178
+ success: function(response) {
179
+ $('#progress-bar').css('width', response.progress + '%').attr('aria-valuenow', response.progress).text(response.progress + '%');
180
+
181
+ if (response.status === 'completed') {
182
+ clearInterval(pollingInterval);
183
+ pollingInterval = null;
184
+ displayResults(response.result);
185
+ } else if (response.status === 'failed') {
186
+ clearInterval(pollingInterval);
187
+ pollingInterval = null;
188
+ showError('分析任务失败: ' + response.error);
189
+ $('#status-container').hide();
190
+ }
191
+ },
192
+ error: function(xhr) {
193
+ clearInterval(pollingInterval);
194
+ pollingInterval = null;
195
+ const errorMsg = xhr.responseJSON ? xhr.responseJSON.error : '未知错误';
196
+ showError('获取任务状态失败: ' + errorMsg);
197
+ $('#status-container').hide();
198
+ }
199
+ });
200
+ }, 2000); // Poll every 2 seconds
201
+ }
202
+
203
+ function displayResults(results) {
204
+ $('#status-container').hide();
205
+ $('#results-container').show();
206
+
207
+ // 1. AI 总结
208
+ const summary = results.ai_summary;
209
+ if (summary && summary.message) {
210
+ // 使用 marked.js 将 Markdown 转换为 HTML
211
+ $('#ai-summary-content').html(marked.parse(summary.message));
212
+ } else {
213
+ $('#ai-summary-content').html(`<div class="p-3"><p class="text-danger">${summary.error || 'AI总结加载失败'}</p></div>`);
214
+ }
215
+
216
+ // 2. 基本信息
217
+ const basicInfo = results.basic_info;
218
+ console.log('ETF anlaysis basic info:', basicInfo);
219
+ if (basicInfo && !basicInfo.error) {
220
+ let infoHtml = '<ul class="list-group list-group-flush">';
221
+ const keyMap = {'基金简称': '简称', '基金代码': '代码', '跟踪标的': '跟踪指数', '基金规模': '规模', '成立日期': '成立日', '基金管理人': '管理人'};
222
+ for (const [key, value] of Object.entries(keyMap)) {
223
+ if (basicInfo[key]) {
224
+ infoHtml += `<li class="list-group-item d-flex justify-content-between align-items-center">${value} <span class="badge bg-primary rounded-pill">${basicInfo[key]}</span></li>`;
225
+ }
226
+ }
227
+ $('#basic-info-content').html(infoHtml + '</ul>');
228
+ } else {
229
+ $('#basic-info-content').html(`<div class="p-3"><p class="text-danger">${basicInfo.error || '信息加载失败'}</p></div>`);
230
+ }
231
+
232
+ // 3. 市场表现
233
+ const perf = results.market_performance;
234
+ if (perf && !perf.error) {
235
+ let perfHtml = '<div class="row"><div class="col-lg-6"><h6 class="text-center mb-3">回报率 vs. 基准(沪深300)</h6><table class="table table-sm table-hover"><thead><tr><th>周期</th><th>ETF</th><th>基准</th><th>超额</th></tr></thead><tbody>';
236
+ const returns = perf.returns || {}, ben_returns = perf.benchmark_returns || {}, alpha = perf.alpha || {};
237
+ for(const period in returns) {
238
+ const r = returns[period] || 0;
239
+ const br = ben_returns[period] || 0;
240
+ const a = alpha[period] || 0;
241
+ perfHtml += `<tr><td>${period}</td><td class="${r >= 0 ? 'text-success' : 'text-danger'} fw-bold">${r.toFixed(2)}%</td><td class="${br >= 0 ? 'text-success' : 'text-danger'}">${br.toFixed(2)}%</td><td class="${a >= 0 ? 'text-success' : 'text-danger'} fw-bold">${a.toFixed(2)}%</td></tr>`;
242
+ }
243
+ perfHtml += '</tbody></table></div><div class="col-lg-3"><h6 class="text-center mb-3">流动性</h6><ul class="list-group">';
244
+ const liq = perf.liquidity || {};
245
+ const avg_volume = liq['日均成交额(近一月)'] ? (liq['日均成交额(近一月)']/1e8).toFixed(2) + ' 亿' : 'N/A';
246
+ const avg_turnover = liq['日均换手率(近一月)'] ? liq['日均换手率(近一月)'].toFixed(2) + ' %' : 'N/A';
247
+ perfHtml += `<li class="list-group-item">日均成交额<br><strong class="h5">${avg_volume}</strong></li>`;
248
+ perfHtml += `<li class="list-group-item">日均换手率<br><strong class="h5">${avg_turnover}</strong></li></ul></div>`;
249
+ perfHtml += '<div class="col-lg-3"><h6 class="text-center mb-3">技术指标</h6><ul class="list-group">';
250
+ const ind = perf.tech_indicators || {};
251
+ for(const [name, value] of Object.entries(ind)) {
252
+ perfHtml += `<li class="list-group-item d-flex justify-content-between"><span>${name}</span> <span class="fw-bold">${value ? value.toFixed(2) : 'N/A'}</span></li>`;
253
+ }
254
+ $('#performance-content').html(perfHtml + '</ul></div></div>');
255
+ } else {
256
+ $('#performance-content').html(`<div class="p-3"><p class="text-danger">${perf.error || '表现数据加载失败'}</p></div>`);
257
+ }
258
+
259
+ // 4. 资金流向
260
+ const flow = results.fund_flow;
261
+ if (flow && !flow.error) {
262
+ let flowHtml = '<div class="row"><div class="col-md-4"><h6 class="text-center mb-3">累计资金净流入(估算)</h6><ul class="list-group">';
263
+ for(const [period, amount] of Object.entries(flow.summary || {})) {
264
+ flowHtml += `<li class="list-group-item d-flex justify-content-between"><span>${period}</span> <span class="${amount >= 0 ? 'text-success' : 'text-danger'} fw-bold">${(amount / 1e8).toFixed(2)} 亿</span></li>`;
265
+ }
266
+ flowHtml += '</ul></div><div class="col-md-8"><div id="fund-flow-chart"></div></div></div>';
267
+ $('#fund-flow-content').html(flowHtml);
268
+ if (flow.daily_flow_chart_data && flow.daily_flow_chart_data.data) {
269
+ const chartData = flow.daily_flow_chart_data.data.map(item => ({ x: item[0], y: item[1] }));
270
+ const options = { series: [{ name: '资金净流入(亿元)', data: chartData }], chart: { type: 'bar', height: 350, toolbar: { show: true }, zoom: { enabled: true } }, plotOptions: { bar: { colors: { ranges: [{ from: -Infinity, to: 0, color: '#dc3545' }, { from: 0, to: Infinity, color: '#28a745' }] } } }, xaxis: { type: 'datetime' }, yaxis: { title: { text: '资金净流入估算 (亿元)' } }, tooltip: { y: { formatter: (val) => `${val.toFixed(4)} 亿` } }, title: { text: '每日资金净流入估算 (近60日)', align: 'center' } };
271
+ new ApexCharts(document.querySelector("#fund-flow-chart"), options).render();
272
+ }
273
+ } else {
274
+ $('#fund-flow-content').html(`<div class="p-3"><p class="text-danger">${flow.error || '资金流数据加载失���'}</p></div>`);
275
+ }
276
+
277
+ // 5. 风险与跟踪能力
278
+ const risk = results.risk_and_tracking;
279
+ if (risk && !risk.error) {
280
+ let riskHtml = `<div class="row text-center"><div class="col-md-3"><div class="card p-2"><h6 class="card-title">年化波动率</h6><p class="h4">${(risk.annualized_volatility * 100).toFixed(2)}%</p></div></div>`;
281
+ riskHtml += `<div class="col-md-2"><div class="card p-2"><h6 class="card-title">Beta</h6><p class="h4">${risk.beta.toFixed(2)}</p></div></div>`;
282
+ riskHtml += `<div class="col-md-2"><div class="card p-2"><h6 class="card-title">夏普比率</h6><p class="h4">${risk.sharpe_ratio.toFixed(2)}</p></div></div>`;
283
+ riskHtml += `<div class="col-md-2"><div class="card p-2"><h6 class="card-title">跟踪误差</h6><p class="h4">${(risk.tracking_error * 100).toFixed(2)}%</p></div></div>`;
284
+ const premium = risk.avg_premium_discount_monthly || 0;
285
+ riskHtml += `<div class="col-md-3"><div class="card p-2"><h6 class="card-title">月均溢价率</h6><p class="h4 ${premium >= 0 ? 'text-danger' : 'text-success'}">${premium.toFixed(2)}%</p></div></div></div>`; // 溢价为红,折价为绿
286
+ $('#risk-content').html(riskHtml);
287
+ } else {
288
+ $('#risk-content').html(`<div class="p-3"><p class="text-danger">${risk.error || '风险数据加载失败'}</p></div>`);
289
+ }
290
+
291
+ // 6. 持仓分析
292
+ const holdings = results.holdings;
293
+ if (holdings && !holdings.error) {
294
+ let holdingsHtml = `<div class="row"><div class="col-md-8"><h6 class="text-center mb-3">前十大持仓股</h6><table class="table table-sm table-hover"><thead><tr><th>代码</th><th>名称</th><th>持仓市值(元)</th><th>占净值比例(%)</th></tr></thead><tbody>`;
295
+ for (const stock of holdings.top_10_holdings) {
296
+ holdingsHtml += `<tr><td>${stock['股票代码']}</td><td>${stock['股票名称']}</td><td>${stock['持仓市值']}</td><td>${stock['占净值比例(%)'].toFixed(2)}</td></tr>`;
297
+ }
298
+ holdingsHtml += `</tbody></table></div><div class="col-md-4"><h6 class="text-center mb-3">持仓集中度</h6><div class="card p-3 text-center"><p class="h1">${holdings.concentration.toFixed(2)}%</p><small class="text-muted">前十大持仓占比</small></div></div></div>`;
299
+ $('#holdings-content').html(holdingsHtml);
300
+ } else {
301
+ $('#holdings-content').html(`<div class="p-3"><p class="text-danger">${holdings.error || '持仓数据加载失败'}</p></div>`);
302
+ }
303
+
304
+ // 7. 板块深度分析
305
+ const sector = results.sector_analysis;
306
+ if (sector && !sector.error) {
307
+ let sectorHtml = `<h5 class="text-center mb-4">板块: ${sector.sector_name}</h5><div class="row"><div class="col-md-6"><h6 class="text-center mb-3">板块回报率</h6><ul class="list-group">`;
308
+ for (const [period, rate] of Object.entries(sector.returns)) {
309
+ sectorHtml += `<li class="list-group-item d-flex justify-content-between">${period}<span class="${rate >= 0 ? 'text-success' : 'text-danger'} fw-bold">${rate.toFixed(2)}%</span></li>`;
310
+ }
311
+ const pe = sector.valuation.current_pe || 0;
312
+ const percentile = sector.valuation.pe_percentile || 0;
313
+ sectorHtml += `</ul></div><div class="col-md-6"><h6 class="text-center mb-3">板块估值</h6><div class="card p-3 text-center"><p>当前滚动PE: <strong class="h5">${pe.toFixed(2)}</strong></p><p>位于历史 <strong class="h5">${percentile.toFixed(2)}%</strong> 分位点</p></div></div></div>`;
314
+ $('#sector-content').html(sectorHtml);
315
+ } else {
316
+ $('#sector-content').html(`<div class="p-3"><p class="text-danger">${sector.error || '板块数据加载失败'}</p></div>`);
317
+ }
318
+ }
319
+ });
320
+ </script>
321
+ {% endblock %}
app/web/templates/fundamental.html ADDED
@@ -0,0 +1,405 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}基本面分析 - {{ stock_code }} - 智能分析系统{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="container-fluid py-3">
7
+ <div id="alerts-container"></div>
8
+
9
+ <div class="row mb-3">
10
+ <div class="col-12">
11
+ <div class="card">
12
+ <div class="card-header py-2">
13
+ <h5 class="mb-0">基本面分析</h5>
14
+ </div>
15
+ <div class="card-body py-2">
16
+ <form id="fundamental-form" class="row g-2">
17
+ <div class="col-md-4">
18
+ <div class="input-group input-group-sm">
19
+ <span class="input-group-text">股票代码</span>
20
+ <input type="text" class="form-control" id="stock-code" placeholder="例如: 600519" required>
21
+ </div>
22
+ </div>
23
+ <div class="col-md-3">
24
+ <div class="input-group input-group-sm">
25
+ <span class="input-group-text">市场</span>
26
+ <select class="form-select" id="market-type">
27
+ <option value="A" selected>A股</option>
28
+ <option value="HK">港股</option>
29
+ <option value="US">美股</option>
30
+ </select>
31
+ </div>
32
+ </div>
33
+ <div class="col-md-3">
34
+ <button type="submit" class="btn btn-primary btn-sm w-100">
35
+ <i class="fas fa-search"></i> 分析
36
+ </button>
37
+ </div>
38
+ </form>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ </div>
43
+
44
+ <div id="fundamental-result" style="display: none;">
45
+ <div class="row g-3 mb-3">
46
+ <div class="col-md-6">
47
+ <div class="card h-100">
48
+ <div class="card-header py-2">
49
+ <h5 class="mb-0">财务概况</h5>
50
+ </div>
51
+ <div class="card-body">
52
+ <div class="row mb-3">
53
+ <div class="col-md-7">
54
+ <h3 id="stock-name" class="mb-0 fs-4"></h3>
55
+ <p id="stock-info" class="text-muted mb-0 small"></p>
56
+ </div>
57
+ <div class="col-md-5 text-end">
58
+ <span id="fundamental-score" class="badge rounded-pill score-pill"></span>
59
+ </div>
60
+ </div>
61
+ <div class="row">
62
+ <div class="col-md-6">
63
+ <h6>估值指标</h6>
64
+ <ul class="list-unstyled mt-1 mb-0 small">
65
+ <li><span class="text-muted">PE(TTM):</span> <span id="pe-ttm"></span></li>
66
+ <li><span class="text-muted">PB:</span> <span id="pb"></span></li>
67
+ <li><span class="text-muted">PS(TTM):</span> <span id="ps-ttm"></span></li>
68
+ </ul>
69
+ </div>
70
+ <div class="col-md-6">
71
+ <h6>盈利能力</h6>
72
+ <ul class="list-unstyled mt-1 mb-0 small">
73
+ <li><span class="text-muted">ROE:</span> <span id="roe"></span></li>
74
+ <li><span class="text-muted">毛利率:</span> <span id="gross-margin"></span></li>
75
+ <li><span class="text-muted">净利润率:</span> <span id="net-profit-margin"></span></li>
76
+ </ul>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ </div>
82
+ <div class="col-md-6">
83
+ <div class="card h-100">
84
+ <div class="card-header py-2">
85
+ <h5 class="mb-0">成长性分析</h5>
86
+ </div>
87
+ <div class="card-body">
88
+ <div class="row">
89
+ <div class="col-md-6">
90
+ <h6>营收增长</h6>
91
+ <ul class="list-unstyled mt-1 mb-0 small">
92
+ <li><span class="text-muted">3年CAGR:</span> <span id="revenue-growth-3y"></span></li>
93
+ <li><span class="text-muted">5年CAGR:</span> <span id="revenue-growth-5y"></span></li>
94
+ </ul>
95
+ </div>
96
+ <div class="col-md-6">
97
+ <h6>利润增长</h6>
98
+ <ul class="list-unstyled mt-1 mb-0 small">
99
+ <li><span class="text-muted">3年CAGR:</span> <span id="profit-growth-3y"></span></li>
100
+ <li><span class="text-muted">5年CAGR:</span> <span id="profit-growth-5y"></span></li>
101
+ </ul>
102
+ </div>
103
+ </div>
104
+ <div class="mt-3">
105
+ <div id="growth-chart" style="height: 150px;"></div>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ </div>
111
+
112
+ <div class="row g-3 mb-3">
113
+ <div class="col-md-4">
114
+ <div class="card h-100">
115
+ <div class="card-header py-2">
116
+ <h5 class="mb-0">估值评分</h5>
117
+ </div>
118
+ <div class="card-body">
119
+ <div id="valuation-chart" style="height: 200px;"></div>
120
+ <p id="valuation-comment" class="small text-muted mt-2"></p>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ <div class="col-md-4">
125
+ <div class="card h-100">
126
+ <div class="card-header py-2">
127
+ <h5 class="mb-0">财务健康评分</h5>
128
+ </div>
129
+ <div class="card-body">
130
+ <div id="financial-chart" style="height: 200px;"></div>
131
+ <p id="financial-comment" class="small text-muted mt-2"></p>
132
+ </div>
133
+ </div>
134
+ </div>
135
+ <div class="col-md-4">
136
+ <div class="card h-100">
137
+ <div class="card-header py-2">
138
+ <h5 class="mb-0">成长性评分</h5>
139
+ </div>
140
+ <div class="card-body">
141
+ <div id="growth-score-chart" style="height: 200px;"></div>
142
+ <p id="growth-comment" class="small text-muted mt-2"></p>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ </div>
147
+ </div>
148
+ </div>
149
+ {% endblock %}
150
+
151
+ {% block scripts %}
152
+ <script>
153
+ $(document).ready(function() {
154
+ $('#fundamental-form').submit(function(e) {
155
+ e.preventDefault();
156
+ const stockCode = $('#stock-code').val().trim();
157
+ const marketType = $('#market-type').val();
158
+
159
+ if (!stockCode) {
160
+ showError('请输入股票代码!');
161
+ return;
162
+ }
163
+
164
+ fetchFundamentalAnalysis(stockCode);
165
+ });
166
+ });
167
+
168
+ function fetchFundamentalAnalysis(stockCode) {
169
+ showLoading();
170
+
171
+ $.ajax({
172
+ url: '/api/fundamental_analysis',
173
+ type: 'POST',
174
+ contentType: 'application/json',
175
+ data: JSON.stringify({
176
+ stock_code: stockCode
177
+ }),
178
+ success: function(response) {
179
+ hideLoading();
180
+ renderFundamentalAnalysis(response, stockCode);
181
+ $('#fundamental-result').show();
182
+ },
183
+ error: function(xhr, status, error) {
184
+ hideLoading();
185
+ let errorMsg = '获取基本面分析失败';
186
+ if (xhr.responseJSON && xhr.responseJSON.error) {
187
+ errorMsg += ': ' + xhr.responseJSON.error;
188
+ } else if (error) {
189
+ errorMsg += ': ' + error;
190
+ }
191
+ showError(errorMsg);
192
+ }
193
+ });
194
+ }
195
+
196
+ function renderFundamentalAnalysis(data, stockCode) {
197
+ // 设置基本信息
198
+ $('#stock-name').text(data.details.indicators.stock_name || stockCode);
199
+ $('#stock-info').text(data.details.indicators.industry || '未知行业');
200
+
201
+ // 设置评分
202
+ const scoreClass = getScoreColorClass(data.total);
203
+ $('#fundamental-score').text(data.total).addClass(scoreClass);
204
+
205
+ // 设置估值指标
206
+ $('#pe-ttm').text(formatNumber(data.details.indicators.pe_ttm, 2));
207
+ $('#pb').text(formatNumber(data.details.indicators.pb, 2));
208
+ $('#ps-ttm').text(formatNumber(data.details.indicators.ps_ttm, 2));
209
+
210
+ // 设置盈利能力
211
+ $('#roe').text(formatPercent(data.details.indicators.roe, 2));
212
+ $('#gross-margin').text(formatPercent(data.details.indicators.gross_margin, 2));
213
+ $('#net-profit-margin').text(formatPercent(data.details.indicators.net_profit_margin, 2));
214
+
215
+ // 设置成长率
216
+ $('#revenue-growth-3y').text(formatPercent(data.details.growth.revenue_growth_3y, 2));
217
+ $('#revenue-growth-5y').text(formatPercent(data.details.growth.revenue_growth_5y, 2));
218
+ $('#profit-growth-3y').text(formatPercent(data.details.growth.profit_growth_3y, 2));
219
+ $('#profit-growth-5y').text(formatPercent(data.details.growth.profit_growth_5y, 2));
220
+
221
+ // 评论
222
+ $('#valuation-comment').text("估值处于行业" + (data.valuation > 20 ? "合理水平" : "偏高水平"));
223
+ $('#financial-comment').text("财务状况" + (data.financial_health > 30 ? "良好" : "一般"));
224
+ $('#growth-comment').text("成长性" + (data.growth > 20 ? "较好" : "一般"));
225
+
226
+ // 渲染图表
227
+ renderValuationChart(data.valuation);
228
+ renderFinancialChart(data.financial_health);
229
+ renderGrowthScoreChart(data.growth);
230
+ renderGrowthChart(data.details.growth);
231
+ }
232
+
233
+ function renderValuationChart(score) {
234
+ const options = {
235
+ series: [score],
236
+ chart: {
237
+ height: 200,
238
+ type: 'radialBar',
239
+ },
240
+ plotOptions: {
241
+ radialBar: {
242
+ hollow: {
243
+ size: '70%',
244
+ },
245
+ dataLabels: {
246
+ name: {
247
+ fontSize: '22px',
248
+ },
249
+ value: {
250
+ fontSize: '16px',
251
+ },
252
+ total: {
253
+ show: true,
254
+ label: '估值',
255
+ formatter: function() {
256
+ return score;
257
+ }
258
+ }
259
+ }
260
+ }
261
+ },
262
+ colors: ['#1ab7ea'],
263
+ labels: ['估值'],
264
+ };
265
+
266
+ const chart = new ApexCharts(document.querySelector("#valuation-chart"), options);
267
+ chart.render();
268
+ }
269
+
270
+ function renderFinancialChart(score) {
271
+ const options = {
272
+ series: [score],
273
+ chart: {
274
+ height: 200,
275
+ type: 'radialBar',
276
+ },
277
+ plotOptions: {
278
+ radialBar: {
279
+ hollow: {
280
+ size: '70%',
281
+ },
282
+ dataLabels: {
283
+ name: {
284
+ fontSize: '22px',
285
+ },
286
+ value: {
287
+ fontSize: '16px',
288
+ },
289
+ total: {
290
+ show: true,
291
+ label: '财务',
292
+ formatter: function() {
293
+ return score;
294
+ }
295
+ }
296
+ }
297
+ }
298
+ },
299
+ colors: ['#20E647'],
300
+ labels: ['财务'],
301
+ };
302
+
303
+ const chart = new ApexCharts(document.querySelector("#financial-chart"), options);
304
+ chart.render();
305
+ }
306
+
307
+ function renderGrowthScoreChart(score) {
308
+ const options = {
309
+ series: [score],
310
+ chart: {
311
+ height: 200,
312
+ type: 'radialBar',
313
+ },
314
+ plotOptions: {
315
+ radialBar: {
316
+ hollow: {
317
+ size: '70%',
318
+ },
319
+ dataLabels: {
320
+ name: {
321
+ fontSize: '22px',
322
+ },
323
+ value: {
324
+ fontSize: '16px',
325
+ },
326
+ total: {
327
+ show: true,
328
+ label: '成长',
329
+ formatter: function() {
330
+ return score;
331
+ }
332
+ }
333
+ }
334
+ }
335
+ },
336
+ colors: ['#F9CE1D'],
337
+ labels: ['成长'],
338
+ };
339
+
340
+ const chart = new ApexCharts(document.querySelector("#growth-score-chart"), options);
341
+ chart.render();
342
+ }
343
+
344
+ function renderGrowthChart(growthData) {
345
+ const options = {
346
+ series: [{
347
+ name: '营收增长率',
348
+ data: [
349
+ growthData.revenue_growth_3y || 0,
350
+ growthData.revenue_growth_5y || 0
351
+ ]
352
+ }, {
353
+ name: '利润增长率',
354
+ data: [
355
+ growthData.profit_growth_3y || 0,
356
+ growthData.profit_growth_5y || 0
357
+ ]
358
+ }],
359
+ chart: {
360
+ type: 'bar',
361
+ height: 150,
362
+ toolbar: {
363
+ show: false
364
+ }
365
+ },
366
+ plotOptions: {
367
+ bar: {
368
+ horizontal: false,
369
+ columnWidth: '55%',
370
+ endingShape: 'rounded'
371
+ },
372
+ },
373
+ dataLabels: {
374
+ enabled: false
375
+ },
376
+ stroke: {
377
+ show: true,
378
+ width: 2,
379
+ colors: ['transparent']
380
+ },
381
+ xaxis: {
382
+ categories: ['3年CAGR', '5年CAGR'],
383
+ },
384
+ yaxis: {
385
+ title: {
386
+ text: '百分比 (%)'
387
+ }
388
+ },
389
+ fill: {
390
+ opacity: 1
391
+ },
392
+ tooltip: {
393
+ y: {
394
+ formatter: function(val) {
395
+ return val + "%"
396
+ }
397
+ }
398
+ }
399
+ };
400
+
401
+ const chart = new ApexCharts(document.querySelector("#growth-chart"), options);
402
+ chart.render();
403
+ }
404
+ </script>
405
+ {% endblock %}
app/web/templates/index.html ADDED
@@ -0,0 +1,636 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}首页 - 智能分析系统{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="finance-portal-container">
7
+ <!-- 左侧导航栏 -->
8
+ <div class="portal-sidebar">
9
+ <div class="sidebar-menu">
10
+ <div class="sidebar-header">
11
+ <h5><i class="fas fa-th-list"></i> 功能导航</h5>
12
+ </div>
13
+ <div class="sidebar-content">
14
+ <ul class="sidebar-nav">
15
+ <li><a href="/dashboard"><i class="fas fa-chart-line"></i> 智能仪表盘</a></li>
16
+ <li><a href="/fundamental"><i class="fas fa-file-invoice-dollar"></i> 基本面分析</a></li>
17
+ <li><a href="/capital_flow"><i class="fas fa-money-bill-wave"></i> 资金流向</a></li>
18
+ <li><a href="/market_scan"><i class="fas fa-search"></i> 市场扫描</a></li>
19
+ <li><a href="/scenario_predict"><i class="fas fa-lightbulb"></i> 情景预测</a></li>
20
+ <li><a href="/portfolio"><i class="fas fa-briefcase"></i> 投资组合</a></li>
21
+ <li><a href="/qa"><i class="fas fa-question-circle"></i> 智能问答</a></li>
22
+ <li><a href="/risk_monitor"><i class="fas fa-exclamation-triangle"></i> 风险监控</a></li>
23
+ <li><a href="/industry_analysis"><i class="fas fa-industry"></i> 行业分析</a></li>
24
+ <li><a href="/agent_analysis"><i class="fas fa-robot"></i> 智能体分析</a></li>
25
+ </ul>
26
+ </div>
27
+ </div>
28
+
29
+ <!-- <div class="quick-search mt-4">-->
30
+ <!-- <div class="card">-->
31
+ <!-- <div class="card-header">-->
32
+ <!-- <h5 class="mb-0"><i class="fas fa-search"></i> 快速分析</h5>-->
33
+ <!-- </div>-->
34
+ <!-- <div class="card-body">-->
35
+ <!-- <form id="quick-analysis-form">-->
36
+ <!-- <div class="mb-3">-->
37
+ <!-- <label for="quick-stock-code" class="form-label">股票代码</label>-->
38
+ <!-- <input type="text" class="form-control" id="quick-stock-code" placeholder="例如: 600519">-->
39
+ <!-- </div>-->
40
+ <!-- <div class="mb-3">-->
41
+ <!-- <label for="quick-market-type" class="form-label">市场类型</label>-->
42
+ <!-- <select class="form-select" id="quick-market-type">-->
43
+ <!-- <option value="A" selected>A股</option>-->
44
+ <!-- <option value="HK">港股</option>-->
45
+ <!-- <option value="US">美股</option>-->
46
+ <!-- </select>-->
47
+ <!-- </div>-->
48
+ <!-- <div class="d-grid">-->
49
+ <!-- <button type="button" class="btn btn-primary" id="quick-analysis-btn">开始分析</button>-->
50
+ <!-- </div>-->
51
+ <!-- </form>-->
52
+ <!-- </div>-->
53
+ <!-- </div>-->
54
+ <!-- </div>-->
55
+ </div>
56
+
57
+ <!-- 中间新闻区域 -->
58
+ <div class="portal-news">
59
+ <div class="news-header">
60
+ <h5><i class="fas fa-newspaper"></i> 实时快讯(来源:财联社)</h5>
61
+ <div class="news-tools">
62
+ <button class="btn btn-sm btn-outline-primary refresh-news-btn">
63
+ <i class="fas fa-sync-alt"></i>
64
+ </button>
65
+ <div class="form-check form-check-inline ms-2">
66
+ <input class="form-check-input" type="checkbox" id="only-important">
67
+ <label class="form-check-label" for="only-important">只看重要</label>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ <div class="news-content" id="news-timeline">
72
+ <div class="text-center py-5">
73
+ <div class="spinner-border text-primary" role="status">
74
+ <span class="visually-hidden">Loading...</span>
75
+ </div>
76
+ <p class="mt-2">加载新闻中...</p>
77
+ </div>
78
+ </div>
79
+ </div>
80
+
81
+ <!-- 右侧热点区域 -->
82
+ <div class="portal-hotspot">
83
+ <div class="hotspot-header">
84
+ <h5><i class="fas fa-fire"></i> 热点</h5>
85
+ </div>
86
+ <div class="hotspot-content" id="hotspot-list">
87
+ <div class="text-center py-5">
88
+ <div class="spinner-border text-primary" role="status">
89
+ <span class="visually-hidden">Loading...</span>
90
+ </div>
91
+ <p class="mt-2">加载热点中...</p>
92
+ </div>
93
+ </div>
94
+ </div>
95
+
96
+ <!-- 页脚固定区域 -->
97
+ <div class="portal-footer">
98
+ <!-- 页脚市场状态部分 -->
99
+ <div class="market-status">
100
+ <div class="market-group">
101
+ <div class="group-title">亚太市场</div>
102
+ <div class="status-group">
103
+ <div class="status-item" id="china-market">
104
+ <i class="fas fa-circle"></i> A股
105
+ <span class="status-text">加载中...</span>
106
+ </div>
107
+ <div class="status-item" id="hk-market">
108
+ <i class="fas fa-circle"></i> 港股
109
+ <span class="status-text">加载中...</span>
110
+ </div>
111
+ <div class="status-item" id="taiwan-market">
112
+ <i class="fas fa-circle"></i> MSCI中国台湾
113
+ <span class="status-text">加载中...</span>
114
+ </div>
115
+ <div class="status-item" id="japan-market">
116
+ <i class="fas fa-circle"></i> 日经225
117
+ <span class="status-text">加载中...</span>
118
+ </div>
119
+ </div>
120
+ </div>
121
+
122
+ <div class="market-group">
123
+ <div class="group-title">欧非中东</div>
124
+ <div class="status-group">
125
+ <div class="status-item" id="uk-market">
126
+ <i class="fas fa-circle"></i> 富时100
127
+ <span class="status-text">加载中...</span>
128
+ </div>
129
+ <div class="status-item" id="german-market">
130
+ <i class="fas fa-circle"></i> 德国DAX
131
+ <span class="status-text">加载中...</span>
132
+ </div>
133
+ <div class="status-item" id="france-market">
134
+ <i class="fas fa-circle"></i> 法国MOEX
135
+ <span class="status-text">加载中...</span>
136
+ </div>
137
+ </div>
138
+ </div>
139
+
140
+ <div class="market-group">
141
+ <div class="group-title">美洲市场</div>
142
+ <div class="status-group">
143
+ <div class="status-item" id="us-market">
144
+ <i class="fas fa-circle"></i> 美股
145
+ <span class="status-text">加载中...</span>
146
+ </div>
147
+ <div class="status-item" id="nasdaq-market">
148
+ <i class="fas fa-circle"></i> 纳指
149
+ <span class="status-text">加载中...</span>
150
+ </div>
151
+ <div class="status-item" id="brazil-market">
152
+ <i class="fas fa-circle"></i> 巴西
153
+ <span class="status-text">加载中...</span>
154
+ </div>
155
+ </div>
156
+ </div>
157
+
158
+ <div class="current-time" id="current-time">
159
+ 当前时间: <span>{{ current_time }}</span>
160
+ <span class="refresh-time" id="refresh-time">刷新: 5:00</span>
161
+ </div>
162
+ </div>
163
+ <div class="ticker-news" id="ticker-container">
164
+ <div class="ticker-wrapper">
165
+ <div class="ticker-item">最新消息加载中...</div>
166
+ </div>
167
+ </div>
168
+ </div>
169
+ </div>
170
+ {% endblock %}
171
+
172
+ {% block scripts %}
173
+ <script>
174
+ $(document).ready(function() {
175
+ // 快速分析按钮点击事件
176
+ $('#quick-analysis-btn').click(function() {
177
+ const stockCode = $('#quick-stock-code').val().trim();
178
+ const marketType = $('#quick-market-type').val();
179
+
180
+ if (!stockCode) {
181
+ alert('请输入股票代码');
182
+ return;
183
+ }
184
+
185
+ // 跳转到股票详情页
186
+ window.location.href = `/stock_detail/${stockCode}?market_type=${marketType}`;
187
+ });
188
+
189
+ // 回车键提交表单
190
+ $('#quick-stock-code').keypress(function(e) {
191
+ if (e.which === 13) {
192
+ $('#quick-analysis-btn').click();
193
+ return false;
194
+ }
195
+ });
196
+
197
+ // 加载最新新闻
198
+ loadLatestNews();
199
+
200
+ // 加载舆情热点
201
+ loadHotspots();
202
+
203
+ // 更新市场状态
204
+ updateMarketStatus();
205
+
206
+ // 启动滚动新闻
207
+ startTickerNews();
208
+
209
+ // 刷新按钮点击事件
210
+ $('.refresh-news-btn').click(function() {
211
+ loadLatestNews();
212
+ });
213
+
214
+ // 只看重要切换事件
215
+ $('#only-important').change(function() {
216
+ loadLatestNews();
217
+ });
218
+
219
+ // 定时刷新
220
+ setInterval(function() {
221
+ updateMarketStatus();
222
+ // 每5分钟自动刷新新闻
223
+ loadLatestNews(true); // 静默刷新
224
+ startTickerNews();
225
+ }, 300000); // 5分钟
226
+
227
+ // 每秒更新当前时间
228
+ setInterval(function() {
229
+ updateCurrentTime();
230
+ }, 1000);
231
+
232
+ // 每秒更新一次时间和倒计时
233
+ setInterval(function() {
234
+ updateCurrentTime();
235
+ updateRefreshCountdown();
236
+ }, 1000);
237
+
238
+
239
+ });
240
+
241
+ // 加载最新新闻函数
242
+ function loadLatestNews(silent = false) {
243
+ if (!silent) {
244
+ $('#news-timeline').html(`
245
+ <div class="text-center py-5">
246
+ <div class="spinner-border text-primary" role="status">
247
+ <span class="visually-hidden">Loading...</span>
248
+ </div>
249
+ <p class="mt-2">加载新闻中...</p>
250
+ </div>
251
+ `);
252
+ }
253
+
254
+ const onlyImportant = $('#only-important').is(':checked');
255
+
256
+ $.ajax({
257
+ url: '/api/latest_news',
258
+ method: 'GET',
259
+ data: {
260
+ days: 2,
261
+ limit: 500,
262
+ important: onlyImportant ? 1 : 0
263
+ },
264
+ success: function(response) {
265
+ if (response.success && response.news && response.news.length > 0) {
266
+ displayNewsTimeline(response.news);
267
+ } else {
268
+ if (!silent) {
269
+ $('#news-timeline').html('<div class="alert alert-info">暂无最新新闻</div>');
270
+ }
271
+ }
272
+ },
273
+ error: function(err) {
274
+ console.error('获取新闻失败:', err);
275
+ if (!silent) {
276
+ $('#news-timeline').html('<div class="alert alert-danger">获取新闻失败,请稍后重试</div>');
277
+ }
278
+ }
279
+ });
280
+ }
281
+
282
+ // 加载舆情热点函数
283
+ function loadHotspots() {
284
+ $('#hotspot-list').html(`
285
+ <div class="text-center py-5">
286
+ <div class="spinner-border text-primary" role="status">
287
+ <span class="visually-hidden">Loading...</span>
288
+ </div>
289
+ <p class="mt-2">加载热点中...</p>
290
+ </div>
291
+ `);
292
+
293
+ $.ajax({
294
+ url: '/api/latest_news',
295
+ method: 'GET',
296
+ data: {
297
+ days: 1,
298
+ limit: 10,
299
+ type: 'hotspot'
300
+ },
301
+ success: function(response) {
302
+ if (response.success && response.news && response.news.length > 0) {
303
+ displayHotspots(response.news);
304
+ } else {
305
+ $('#hotspot-list').html('<div class="alert alert-info">暂无舆情热点</div>');
306
+ }
307
+ },
308
+ error: function(err) {
309
+ console.error('获取热点失败:', err);
310
+ $('#hotspot-list').html('<div class="alert alert-danger">获取热点失败,请稍后重试</div>');
311
+ }
312
+ });
313
+ }
314
+
315
+ // 显示热点列表
316
+ function displayHotspots(hotspots) {
317
+ if (hotspots.length === 0) {
318
+ $('#hotspot-list').html('<div class="alert alert-info">暂无舆情热点</div>');
319
+ return;
320
+ }
321
+
322
+ let hotspotsHtml = '<div class="hotspot-list">';
323
+
324
+ hotspots.forEach((item, index) => {
325
+ const rankClass = index < 3 ? 'rank-top' : '';
326
+ hotspotsHtml += `
327
+ <div class="hotspot-item">
328
+ <span class="hotspot-rank ${rankClass}">${index + 1}</span>
329
+ <span class="hotspot-title">${item.title || item.content}</span>
330
+ </div>
331
+ `;
332
+ });
333
+
334
+ hotspotsHtml += '</div>';
335
+
336
+ $('#hotspot-list').html(hotspotsHtml);
337
+ }
338
+
339
+ // 替换现有的displayNewsTimeline函数
340
+ function displayNewsTimeline(newsList) {
341
+ if (newsList.length === 0) {
342
+ $('#news-timeline').html('<div class="alert alert-info">暂无新闻</div>');
343
+ return;
344
+ }
345
+
346
+ let timelineHtml = '<div class="news-timeline-container">';
347
+
348
+ // 首先按完整的日期时间排序,确保最新消息在最前面
349
+ newsList.sort((a, b) => {
350
+ // 构建完整的日期时间字符串 (YYYY-MM-DD HH:MM)
351
+ const dateTimeA = (a.date || '') + ' ' + (a.time || '00:00');
352
+ const dateTimeB = (b.date || '') + ' ' + (b.time || '00:00');
353
+
354
+ // 转换为Date对象进行比较
355
+ const timeA = new Date(dateTimeA);
356
+ const timeB = new Date(dateTimeB);
357
+
358
+ // 返回降序结果(最新的在前)
359
+ return timeB - timeA;
360
+ });
361
+
362
+ // 按天和时间点分组
363
+ const newsGroups = {};
364
+ newsList.forEach(news => {
365
+ // 创建格式为"YYYY-MM-DD HH:MM"的键
366
+ const date = news.date || '';
367
+ const time = news.time || '00:00';
368
+
369
+ // 显示用的键:日期+时间
370
+ const displayKey = `${date} ${time.substring(0, 5)}`;
371
+
372
+ if (!newsGroups[displayKey]) {
373
+ newsGroups[displayKey] = [];
374
+ }
375
+ newsGroups[displayKey].push(news);
376
+ });
377
+
378
+ // 获取并按时间降序排列所有组键
379
+ const sortedKeys = Object.keys(newsGroups).sort((a, b) => {
380
+ const timeA = new Date(a);
381
+ const timeB = new Date(b);
382
+ return timeB - timeA;
383
+ });
384
+
385
+ // 生成时间线HTML
386
+ sortedKeys.forEach(displayKey => {
387
+ const newsItems = newsGroups[displayKey];
388
+ const parts = displayKey.split(' ');
389
+ const date = parts[0];
390
+ const time = parts[1];
391
+
392
+ // 格式化显示日期(只在新的一天开始时显示)
393
+ const formattedDate = formatDate(date);
394
+
395
+ timelineHtml += `
396
+ <div class="time-point">
397
+ <div class="time-label">${time}</div>
398
+ <div class="time-date">${formattedDate}</div>
399
+ <div class="news-items">
400
+ `;
401
+
402
+ newsItems.forEach(news => {
403
+ let contentClass = '';
404
+ // 根据内容中是否含有特定关键词添加样式
405
+ if (news.content && (news.content.includes('增长') || news.content.includes('上涨') || news.content.includes('利好'))) {
406
+ contentClass = 'text-success';
407
+ } else if (news.content && (news.content.includes('下跌') || news.content.includes('下降') || news.content.includes('利空'))) {
408
+ contentClass = 'text-danger';
409
+ }
410
+
411
+ timelineHtml += `
412
+ <div class="news-item">
413
+ <div class="news-content ${contentClass}">${news.content || ''}</div>
414
+ </div>
415
+ `;
416
+ });
417
+
418
+ timelineHtml += `
419
+ </div>
420
+ </div>
421
+ `;
422
+ });
423
+
424
+ timelineHtml += '</div>';
425
+
426
+ // 更新DOM
427
+ $('#news-timeline').html(timelineHtml);
428
+ }
429
+
430
+ // 日期格式化辅助函数
431
+ function formatDate(dateStr) {
432
+ // 检查是否与当天日期相同
433
+ const today = new Date();
434
+ const todayStr = today.toISOString().split('T')[0];
435
+
436
+ if (dateStr === todayStr) {
437
+ return '';
438
+ }
439
+
440
+ // 昨天
441
+ const yesterday = new Date(today);
442
+ yesterday.setDate(yesterday.getDate() - 1);
443
+ const yesterdayStr = yesterday.toISOString().split('T')[0];
444
+
445
+ if (dateStr === yesterdayStr) {
446
+ return '昨天';
447
+ }
448
+
449
+ // 其他日期用中文格式
450
+ const date = new Date(dateStr);
451
+ return `${date.getMonth() + 1}月${date.getDate()}日`;
452
+ }
453
+
454
+ // 添加页面自动刷新功能
455
+ let refreshCountdown = 300; // 5分钟倒计时(秒)
456
+
457
+ // 更新市场状态
458
+ function updateMarketStatus() {
459
+ const now = new Date();
460
+ const hours = now.getHours();
461
+ const minutes = now.getMinutes();
462
+ const weekday = now.getDay(); // 0为周日,6为周六
463
+
464
+ // 检查是否为工作日
465
+ const isWeekend = weekday === 0 || weekday === 6;
466
+
467
+ // 亚太市场时区
468
+ // A股状态 (9:30-11:30, 13:00-15:00)
469
+ let chinaStatus = { open: false, text: '未开市' };
470
+ if (!isWeekend && ((hours === 9 && minutes >= 30) || hours === 10 || (hours === 11 && minutes <= 30) ||
471
+ (hours >= 13 && hours < 15))) {
472
+ chinaStatus = { open: true, text: '交易中' };
473
+ }
474
+
475
+ // 港股状态 (9:30-12:00, 13:00-16:00)
476
+ let hkStatus = { open: false, text: '未开市' };
477
+ if (!isWeekend && ((hours === 9 && minutes >= 30) || hours === 10 || hours === 11 ||
478
+ (hours >= 13 && hours < 16))) {
479
+ hkStatus = { open: true, text: '交易中' };
480
+ }
481
+
482
+ // 台股状态 (9:00-13:30)
483
+ let taiwanStatus = { open: false, text: '未开市' };
484
+ if (!isWeekend && ((hours === 9) || hours === 10 || hours === 11 || hours === 12 ||
485
+ (hours === 13 && minutes <= 30))) {
486
+ taiwanStatus = { open: true, text: '交易中' };
487
+ }
488
+
489
+ // 日本股市 (9:00-11:30, 12:30-15:00)
490
+ let japanStatus = { open: false, text: '未开市' };
491
+ if (!isWeekend && ((hours === 9) || hours === 10 || (hours === 11 && minutes <= 30) ||
492
+ (hours === 12 && minutes >= 30) || hours === 13 || hours === 14)) {
493
+ japanStatus = { open: true, text: '交易中' };
494
+ }
495
+
496
+ // 欧洲市场 - 需要调整时区,这里是基于欧洲夏令时(UTC+2)与北京时间(UTC+8)相差6小时计算
497
+ // 英国股市 (伦敦,北京时间15:00-23:30)
498
+ let ukStatus = { open: false, text: '未开市' };
499
+ if (!isWeekend && ((hours >= 15 && hours < 23) || (hours === 23 && minutes <= 30))) {
500
+ ukStatus = { open: true, text: '交易中' };
501
+ }
502
+
503
+ // 德国股市 (法兰克福,北京时间15:00-23:30)
504
+ let germanStatus = { open: false, text: '未开市' };
505
+ if (!isWeekend && ((hours >= 15 && hours < 23) || (hours === 23 && minutes <= 30))) {
506
+ germanStatus = { open: true, text: '交易中' };
507
+ }
508
+
509
+ // 法国股市 (巴黎,北京时间15:00-23:30)
510
+ let franceStatus = { open: false, text: '未开市' };
511
+ if (!isWeekend && ((hours >= 15 && hours < 23) || (hours === 23 && minutes <= 30))) {
512
+ franceStatus = { open: true, text: '交易中' };
513
+ }
514
+
515
+ // 美洲市场
516
+ // 美股状态 (纽约,北京时间21:30-4:00)
517
+ let usStatus = { open: false, text: '未开市' };
518
+ if ((hours >= 21 && minutes >= 30) || hours >= 22 || hours < 4) {
519
+ // 检查美股的工作日 (当北京时间是周六早上,美国还是周五)
520
+ const usDay = hours < 12 ? (weekday === 6 ? 5 : weekday - 1) : weekday;
521
+ if (usDay !== 0 && usDay !== 6) {
522
+ usStatus = { open: true, text: '交易中' };
523
+ }
524
+ }
525
+
526
+ // 纳斯达克与美股相同
527
+ let nasdaqStatus = usStatus;
528
+
529
+ // 巴西股市 (圣保罗,北京时间20:30-3:00)
530
+ let brazilStatus = { open: false, text: '未开市' };
531
+ if ((hours >= 20 && minutes >= 30) || hours >= 21 || hours < 3) {
532
+ const brazilDay = hours < 12 ? (weekday === 6 ? 5 : weekday - 1) : weekday;
533
+ if (brazilDay !== 0 && brazilDay !== 6) {
534
+ brazilStatus = { open: true, text: '交易中' };
535
+ }
536
+ }
537
+
538
+ // 更新DOM
539
+ updateMarketStatusUI('china-market', chinaStatus);
540
+ updateMarketStatusUI('hk-market', hkStatus);
541
+ updateMarketStatusUI('taiwan-market', taiwanStatus);
542
+ updateMarketStatusUI('japan-market', japanStatus);
543
+
544
+ updateMarketStatusUI('uk-market', ukStatus);
545
+ updateMarketStatusUI('german-market', germanStatus);
546
+ updateMarketStatusUI('france-market', franceStatus);
547
+
548
+ updateMarketStatusUI('us-market', usStatus);
549
+ updateMarketStatusUI('nasdaq-market', nasdaqStatus);
550
+ updateMarketStatusUI('brazil-market', brazilStatus);
551
+ }
552
+
553
+ // 更新市场状态UI
554
+ function updateMarketStatusUI(elementId, status) {
555
+ const element = $(`#${elementId}`);
556
+ const iconElement = element.find('i');
557
+ const textElement = element.find('.status-text');
558
+
559
+ if (status.open) {
560
+ iconElement.removeClass('status-closed').addClass('status-open');
561
+ } else {
562
+ iconElement.removeClass('status-open').addClass('status-closed');
563
+ }
564
+
565
+ textElement.text(status.text);
566
+ }
567
+
568
+ // 更新倒计时
569
+ function updateRefreshCountdown() {
570
+ refreshCountdown--;
571
+
572
+ if (refreshCountdown <= 0) {
573
+ // 重新加载页面
574
+ window.location.reload();
575
+ return;
576
+ }
577
+
578
+ const minutes = Math.floor(refreshCountdown / 60);
579
+ const seconds = refreshCountdown % 60;
580
+ $('#refresh-time').text(`刷新: ${minutes}:${seconds < 10 ? '0' + seconds : seconds}`);
581
+ }
582
+
583
+ // 更新当前时间
584
+ function updateCurrentTime() {
585
+ const now = new Date();
586
+ const timeString = now.toLocaleTimeString('zh-CN', { hour12: false });
587
+ $('#current-time span').text(timeString);
588
+ }
589
+
590
+ // 启动滚动新闻
591
+ function startTickerNews() {
592
+ $.ajax({
593
+ url: '/api/latest_news',
594
+ method: 'GET',
595
+ data: {
596
+ days: 1,
597
+ limit: 10
598
+ },
599
+ success: function(response) {
600
+ if (response.success && response.news && response.news.length > 0) {
601
+ displayTickerNews(response.news);
602
+ } else {
603
+ $('#ticker-container .ticker-wrapper').html('<div class="ticker-item">暂无最新消息</div>');
604
+ }
605
+ },
606
+ error: function(err) {
607
+ console.error('获取滚动新闻失败:', err);
608
+ $('#ticker-container .ticker-wrapper').html('<div class="ticker-item">获取最新消息失败</div>');
609
+ }
610
+ });
611
+ }
612
+
613
+ // 显示滚动新闻
614
+ function displayTickerNews(newsList) {
615
+ if (newsList.length === 0) {
616
+ $('#ticker-container .ticker-wrapper').html('<div class="ticker-item">暂无最新消息</div>');
617
+ return;
618
+ }
619
+
620
+ let tickerItems = '';
621
+
622
+ newsList.forEach(news => {
623
+ tickerItems += `<div class="ticker-item">${news.content || ''}</div>`;
624
+ });
625
+
626
+ $('#ticker-container .ticker-wrapper').html(tickerItems);
627
+
628
+ // 启动滚动效果
629
+ const tickerWidth = $('#ticker-container').width();
630
+ const wrapper = $('#ticker-container .ticker-wrapper');
631
+
632
+ wrapper.width(tickerWidth * 2);
633
+ wrapper.css('animation', 'ticker 30s linear infinite');
634
+ }
635
+ </script>
636
+ {% endblock %}
app/web/templates/industry_analysis.html ADDED
@@ -0,0 +1,1135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}行业分析 - 智能分析系统{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="container-fluid py-3">
7
+ <div id="alerts-container"></div>
8
+
9
+ <div class="row mb-3">
10
+ <div class="col-12">
11
+ <div class="card">
12
+ <div class="card-header py-2">
13
+ <h5 class="mb-0">行业资金流向分析</h5>
14
+ </div>
15
+ <div class="card-body py-2">
16
+ <form id="industry-form" class="row g-2">
17
+ <div class="col-md-3">
18
+ <div class="input-group input-group-sm">
19
+ <span class="input-group-text">周期</span>
20
+ <select class="form-select" id="fund-flow-period">
21
+ <option value="即时" selected>即时</option>
22
+ <option value="3日排行">3日排行</option>
23
+ <option value="5日排行">5日排行</option>
24
+ <option value="10日排行">10日排行</option>
25
+ <option value="20日排行">20日排行</option>
26
+ </select>
27
+ </div>
28
+ </div>
29
+ <div class="col-md-3">
30
+ <div class="input-group input-group-sm">
31
+ <span class="input-group-text">行业</span>
32
+ <select class="form-select" id="industry-selector">
33
+ <option value="">-- 选择行业 --</option>
34
+ <!-- 行业选项将通过JS动态填充 -->
35
+ </select>
36
+ </div>
37
+ </div>
38
+ <div class="col-md-2">
39
+ <button type="submit" class="btn btn-primary btn-sm w-100">
40
+ <i class="fas fa-search"></i> 分析
41
+ </button>
42
+ </div>
43
+ </form>
44
+ </div>
45
+ </div>
46
+ </div>
47
+ </div>
48
+
49
+ <div id="loading-panel" class="text-center py-5" style="display: none;">
50
+ <div class="spinner-border text-primary" role="status">
51
+ <span class="visually-hidden">Loading...</span>
52
+ </div>
53
+ <p class="mt-3 mb-0">正在获取行业数据...</p>
54
+ </div>
55
+
56
+ <!-- 行业资金流向概览 -->
57
+ <div id="industry-overview" class="row g-3 mb-3" style="display: none;">
58
+ <div class="col-12">
59
+ <div class="card">
60
+ <div class="card-header py-2 d-flex justify-content-between align-items-center">
61
+ <h5 class="mb-0">行业资金流向概览</h5>
62
+ <div class="d-flex">
63
+ <span id="period-badge" class="badge bg-primary ms-2">即时</span>
64
+ <button class="btn btn-sm btn-outline-primary ms-2" id="export-btn">
65
+ <i class="fas fa-download"></i> 导出数据
66
+ </button>
67
+ </div>
68
+ </div>
69
+ <div class="card-body p-0">
70
+ <div class="table-responsive">
71
+ <table class="table table-sm table-striped table-hover mb-0">
72
+ <thead>
73
+ <tr>
74
+ <th>序号</th>
75
+ <th>行业</th>
76
+ <th>行业指数</th>
77
+ <th>涨跌幅</th>
78
+ <th>流入资金(亿)</th>
79
+ <th>流出资金(亿)</th>
80
+ <th>净额(亿)</th>
81
+ <th>公司家数</th>
82
+ <th>领涨股</th>
83
+ <th>操作</th>
84
+ </tr>
85
+ </thead>
86
+ <tbody id="industry-table">
87
+ <!-- 行业资金流向数据将在JS中动态填充 -->
88
+ </tbody>
89
+ </table>
90
+ </div>
91
+ </div>
92
+ </div>
93
+ </div>
94
+ </div>
95
+
96
+ <!-- 行业详细分析 -->
97
+ <div id="industry-detail" class="row g-3 mb-3" style="display: none;">
98
+ <div class="col-md-6">
99
+ <div class="card h-100">
100
+ <div class="card-header py-2">
101
+ <h5 id="industry-name" class="mb-0">行业详情</h5>
102
+ </div>
103
+ <div class="card-body">
104
+ <div class="row">
105
+ <div class="col-md-6">
106
+ <h6>行业概况</h6>
107
+ <p><span class="text-muted">行业指数:</span> <span id="industry-index" class="fw-bold"></span></p>
108
+ <p><span class="text-muted">涨跌幅:</span> <span id="industry-change" class="fw-bold"></span></p>
109
+ <p><span class="text-muted">公司家数:</span> <span id="industry-company-count" class="fw-bold"></span></p>
110
+ </div>
111
+ <div class="col-md-6">
112
+ <h6>资金流向</h6>
113
+ <p><span class="text-muted">流入资金:</span> <span id="industry-inflow" class="fw-bold"></span></p>
114
+ <p><span class="text-muted">流出资金:</span> <span id="industry-outflow" class="fw-bold"></span></p>
115
+ <p><span class="text-muted">净额:</span> <span id="industry-net-flow" class="fw-bold"></span></p>
116
+ </div>
117
+ </div>
118
+ <div class="mt-3">
119
+ <div id="industry-flow-chart" style="height: 200px;"></div>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ </div>
124
+
125
+ <div class="col-md-6">
126
+ <div class="card h-100">
127
+ <div class="card-header py-2">
128
+ <h5 class="mb-0">行业评分</h5>
129
+ </div>
130
+ <div class="card-body">
131
+ <div class="row">
132
+ <div class="col-md-5">
133
+ <div id="industry-score-chart" style="height: 150px;"></div>
134
+ <h4 id="industry-score" class="text-center mt-2">--</h4>
135
+ <p class="text-muted text-center">综合评分</p>
136
+ </div>
137
+ <div class="col-md-7">
138
+ <div class="mb-3">
139
+ <div class="d-flex justify-content-between mb-1">
140
+ <span>技术面</span>
141
+ <span id="technical-score">--/40</span>
142
+ </div>
143
+ <div class="progress">
144
+ <div id="technical-progress" class="progress-bar bg-info" role="progressbar" style="width: 0%"></div>
145
+ </div>
146
+ </div>
147
+ <div class="mb-3">
148
+ <div class="d-flex justify-content-between mb-1">
149
+ <span>基本面</span>
150
+ <span id="fundamental-score">--/40</span>
151
+ </div>
152
+ <div class="progress">
153
+ <div id="fundamental-progress" class="progress-bar bg-success" role="progressbar" style="width: 0%"></div>
154
+ </div>
155
+ </div>
156
+ <div class="mb-3">
157
+ <div class="d-flex justify-content-between mb-1">
158
+ <span>资金面</span>
159
+ <span id="capital-flow-score">--/20</span>
160
+ </div>
161
+ <div class="progress">
162
+ <div id="capital-flow-progress" class="progress-bar bg-warning" role="progressbar" style="width: 0%"></div>
163
+ </div>
164
+ </div>
165
+ </div>
166
+ </div>
167
+ <div class="mt-3">
168
+ <h6>投资建议</h6>
169
+ <p id="industry-recommendation" class="mb-0"></p>
170
+ </div>
171
+ </div>
172
+ </div>
173
+ </div>
174
+ </div>
175
+
176
+ <!-- 行业成分股表现 -->
177
+ <div id="industry-stocks" class="row g-3 mb-3" style="display: none;">
178
+ <div class="col-12">
179
+ <div class="card">
180
+ <div class="card-header py-2">
181
+ <h5 class="mb-0">行业成分股表现</h5>
182
+ </div>
183
+ <div class="card-body p-0">
184
+ <div class="table-responsive">
185
+ <table class="table table-sm table-striped table-hover mb-0">
186
+ <thead>
187
+ <tr>
188
+ <th>代码</th>
189
+ <th>名称</th>
190
+ <th>最新价</th>
191
+ <th>涨跌幅</th>
192
+ <th>成交量</th>
193
+ <th>成交额(万)</th>
194
+ <th>换手率</th>
195
+ <th>评分</th>
196
+ <th>操作</th>
197
+ </tr>
198
+ </thead>
199
+ <tbody id="industry-stocks-table">
200
+ <!-- 行业成分股数据将在JS中动态填充 -->
201
+ </tbody>
202
+ </table>
203
+ </div>
204
+ </div>
205
+ </div>
206
+ </div>
207
+ </div>
208
+
209
+ <!-- 行业对比分析 -->
210
+ <div class="row g-3 mb-3">
211
+ <div class="col-12">
212
+ <div class="card">
213
+ <div class="card-header py-2">
214
+ <h5 class="mb-0">行业对比分析</h5>
215
+ </div>
216
+ <div class="card-body">
217
+ <ul class="nav nav-tabs" id="industry-compare-tabs" role="tablist">
218
+ <li class="nav-item" role="presentation">
219
+ <button class="nav-link active" id="fund-flow-tab" data-bs-toggle="tab" data-bs-target="#fund-flow" type="button" role="tab" aria-controls="fund-flow" aria-selected="true">资金流向</button>
220
+ </li>
221
+ <li class="nav-item" role="presentation">
222
+ <button class="nav-link" id="performance-tab" data-bs-toggle="tab" data-bs-target="#performance" type="button" role="tab" aria-controls="performance" aria-selected="false">行业涨跌幅</button>
223
+ </li>
224
+ </ul>
225
+ <div class="tab-content mt-3" id="industry-compare-tabs-content">
226
+ <div class="tab-pane fade show active" id="fund-flow" role="tabpanel" aria-labelledby="fund-flow-tab">
227
+ <div class="row">
228
+ <div class="col-md-6">
229
+ <h6>资金净流入前10行业</h6>
230
+ <div id="top-inflow-chart" style="height: 300px;"></div>
231
+ </div>
232
+ <div class="col-md-6">
233
+ <h6>资金净流出前10行业</h6>
234
+ <div id="top-outflow-chart" style="height: 300px;"></div>
235
+ </div>
236
+ </div>
237
+ </div>
238
+ <div class="tab-pane fade" id="performance" role="tabpanel" aria-labelledby="performance-tab">
239
+ <div class="row">
240
+ <div class="col-md-6">
241
+ <h6>涨幅前10行业</h6>
242
+ <div id="top-gainers-chart" style="height: 300px;"></div>
243
+ </div>
244
+ <div class="col-md-6">
245
+ <h6>跌幅前10行业</h6>
246
+ <div id="top-losers-chart" style="height: 300px;"></div>
247
+ </div>
248
+ </div>
249
+ </div>
250
+ </div>
251
+ </div>
252
+ </div>
253
+ </div>
254
+ </div>
255
+ </div>
256
+ {% endblock %}
257
+
258
+ {% block scripts %}
259
+ <script>
260
+
261
+ $(document).ready(function() {
262
+ // 加载行业资金流向数据
263
+ loadIndustryFundFlow();
264
+
265
+ $('#industry-form').submit(function(e) {
266
+ e.preventDefault();
267
+
268
+ // 获取选择的行业
269
+ const industry = $('#industry-selector').val();
270
+
271
+ if (!industry) {
272
+ showError('请选择行业名称');
273
+ return;
274
+ }
275
+
276
+ // 分析行业
277
+ loadIndustryDetail(industry);
278
+ });
279
+
280
+ // 资金流向周期切换
281
+ $('#fund-flow-period').change(function() {
282
+ const period = $(this).val();
283
+ loadIndustryFundFlow(period);
284
+ });
285
+
286
+ // 导出按钮点击事件
287
+ $('#export-btn').click(function() {
288
+ exportToCSV();
289
+ });
290
+ });
291
+
292
+ function analyzeIndustry(industry) {
293
+ $('#loading-panel').show();
294
+ $('#industry-result').hide();
295
+
296
+ // 1. 获取行业详情
297
+ $.ajax({
298
+ url: `/api/industry_detail?industry=${encodeURIComponent(industry)}`,
299
+ type: 'GET',
300
+ success: function(industryDetail) {
301
+ console.log("Industry detail loaded successfully:", industryDetail);
302
+
303
+ // 2. 获取行业成分股
304
+ $.ajax({
305
+ url: `/api/industry_stocks?industry=${encodeURIComponent(industry)}`,
306
+ type: 'GET',
307
+ success: function(stocksResponse) {
308
+ console.log("Industry stocks loaded successfully:", stocksResponse);
309
+
310
+ $('#loading-panel').hide();
311
+
312
+ // 渲染行业详情和成分股
313
+ renderIndustryDetail(industryDetail);
314
+ renderIndustryStocks(stocksResponse);
315
+
316
+ $('#industry-detail').show();
317
+ $('#industry-stocks').show();
318
+ },
319
+ error: function(xhr, status, error) {
320
+ $('#loading-panel').hide();
321
+ console.error("Error loading industry stocks:", error);
322
+ showError('获取行业成分股失败: ' + (xhr.responseJSON?.error || error));
323
+ }
324
+ });
325
+ },
326
+ error: function(xhr, status, error) {
327
+ $('#loading-panel').hide();
328
+ console.error("Error loading industry detail:", error);
329
+ showError('获取行业详情失败: ' + (xhr.responseJSON?.error || error));
330
+ }
331
+ });
332
+ }
333
+
334
+ // 加载行业资金流向数据
335
+ function loadIndustryFundFlow(period = '即时') {
336
+ $('#loading-panel').show();
337
+ $('#industry-overview').hide();
338
+ $('#industry-detail').hide();
339
+ $('#industry-stocks').hide();
340
+
341
+ $.ajax({
342
+ url: `/api/industry_fund_flow?symbol=${encodeURIComponent(period)}`,
343
+ type: 'GET',
344
+ dataType: 'json',
345
+ success: function(response) {
346
+ if (Array.isArray(response) && response.length > 0) {
347
+ renderIndustryFundFlow(response, period);
348
+ populateIndustrySelector(response);
349
+
350
+ // 加载行业对比数据
351
+ loadIndustryCompare();
352
+
353
+ $('#loading-panel').hide();
354
+ $('#industry-overview').show();
355
+ } else {
356
+ showError('获取行业资金流向数据失败:返回数据为空');
357
+ $('#loading-panel').hide();
358
+ }
359
+ },
360
+ error: function(xhr, status, error) {
361
+ $('#loading-panel').hide();
362
+ let errorMsg = '获取行业资金流向数据失败';
363
+ if (xhr.responseJSON && xhr.responseJSON.error) {
364
+ errorMsg += ': ' + xhr.responseJSON.error;
365
+ } else if (error) {
366
+ errorMsg += ': ' + error;
367
+ }
368
+ showError(errorMsg);
369
+ }
370
+ });
371
+ }
372
+
373
+
374
+ // 统一的行业详情加载函数
375
+ function loadIndustryDetail(industry) {
376
+ console.log(`Loading industry detail for: ${industry}`);
377
+ $('#loading-panel').show();
378
+ $('#industry-overview').hide();
379
+ $('#industry-detail').hide();
380
+ $('#industry-stocks').hide();
381
+
382
+ // 并行加载行业详情和行业成分股
383
+ $.when(
384
+ // 获取行业详情
385
+ $.ajax({
386
+ url: `/api/industry_detail?industry=${encodeURIComponent(industry)}`,
387
+ type: 'GET',
388
+ dataType: 'json'
389
+ }),
390
+ // 获取行业成分股
391
+ $.ajax({
392
+ url: `/api/industry_stocks?industry=${encodeURIComponent(industry)}`,
393
+ type: 'GET',
394
+ dataType: 'json'
395
+ })
396
+ ).done(function(detailResponse, stocksResponse) {
397
+ // 处理行业详情数据
398
+ const industryData = detailResponse[0];
399
+
400
+ // 处理行业成分股数据
401
+ const stocksData = stocksResponse[0];
402
+
403
+ console.log("Industry detail loaded:", industryData);
404
+ console.log("Industry stocks loaded:", stocksData);
405
+
406
+ renderIndustryDetail(industryData);
407
+ renderIndustryStocks(stocksData);
408
+
409
+ $('#loading-panel').hide();
410
+ $('#industry-detail').show();
411
+ $('#industry-stocks').show();
412
+ }).fail(function(jqXHR, textStatus, errorThrown) {
413
+ $('#loading-panel').hide();
414
+ console.error("Error loading industry data:", textStatus, errorThrown);
415
+ let errorMsg = '获取行业数据失败';
416
+ try {
417
+ if (jqXHR.responseJSON && jqXHR.responseJSON.error) {
418
+ errorMsg += ': ' + jqXHR.responseJSON.error;
419
+ } else if (errorThrown) {
420
+ errorMsg += ': ' + errorThrown;
421
+ }
422
+ } catch (e) {
423
+ console.error("Error parsing error response:", e);
424
+ }
425
+ showError(errorMsg);
426
+ });
427
+ }
428
+
429
+ // 加载行业对比数据
430
+ function loadIndustryCompare() {
431
+ $.ajax({
432
+ url: '/api/industry_compare',
433
+ type: 'GET',
434
+ dataType: 'json',
435
+ success: function(response) {
436
+ try {
437
+ if (response && response.results) {
438
+ // 按资金净流入排序
439
+ const sortedByNetFlow = [...response.results]
440
+ .filter(item => item.netFlow !== undefined)
441
+ .sort((a, b) => parseFloat(b.netFlow || 0) - parseFloat(a.netFlow || 0));
442
+
443
+ // 按涨跌幅排序
444
+ const sortedByChange = [...response.results]
445
+ .filter(item => item.change !== undefined)
446
+ .sort((a, b) => parseFloat(b.change || 0) - parseFloat(a.change || 0));
447
+
448
+ // 资金净流入前10行业
449
+ const topInflow = sortedByNetFlow.slice(0, 10);
450
+ renderBarChart('top-inflow-chart',
451
+ topInflow.map(item => item.industry),
452
+ topInflow.map(item => parseFloat(item.netFlow || 0)),
453
+ '资金净流入(亿)',
454
+ '#00E396');
455
+
456
+ // 资金净流出前10行业
457
+ const bottomInflow = [...sortedByNetFlow].reverse().slice(0, 10);
458
+ renderBarChart('top-outflow-chart',
459
+ bottomInflow.map(item => item.industry),
460
+ bottomInflow.map(item => Math.abs(parseFloat(item.netFlow || 0))),
461
+ '资金净流出(亿)',
462
+ '#FF4560');
463
+
464
+ // 涨幅前10行业
465
+ const topGainers = sortedByChange.slice(0, 10);
466
+ renderBarChart('top-gainers-chart',
467
+ topGainers.map(item => item.industry),
468
+ topGainers.map(item => parseFloat(item.change || 0)),
469
+ '涨幅(%)',
470
+ '#00E396');
471
+
472
+ // 跌幅前10行业
473
+ const topLosers = [...sortedByChange].reverse().slice(0, 10);
474
+ renderBarChart('top-losers-chart',
475
+ topLosers.map(item => item.industry),
476
+ topLosers.map(item => Math.abs(parseFloat(item.change || 0))),
477
+ '跌幅(%)',
478
+ '#FF4560');
479
+ }
480
+ } catch (e) {
481
+ console.error("Error processing industry comparison data:", e);
482
+ }
483
+ },
484
+ error: function(xhr, status, error) {
485
+ console.error('获取行业对比数据失败:', error);
486
+ }
487
+ });
488
+ }
489
+
490
+ // 渲染行业资金流向表格
491
+ function renderIndustryFundFlow(data, period) {
492
+ $('#period-badge').text(period);
493
+
494
+ let html = '';
495
+ if (data.length === 0) {
496
+ html = '<tr><td colspan="10" class="text-center">暂无数据</td></tr>';
497
+ } else {
498
+ data.forEach((item, index) => {
499
+ const changeClass = parseFloat(item.change) >= 0 ? 'trend-up' : 'trend-down';
500
+ const changeIcon = parseFloat(item.change) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
501
+
502
+ const netFlowClass = parseFloat(item.netFlow) >= 0 ? 'trend-up' : 'trend-down';
503
+ const netFlowIcon = parseFloat(item.netFlow) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
504
+
505
+ html += `
506
+ <tr>
507
+ <td>${item.rank}</td>
508
+ <td>
509
+ <a href="javascript:void(0)" onclick="loadIndustryDetail('${item.industry}')" class="industry-link">
510
+ ${item.industry}
511
+ </a>
512
+ </td>
513
+ <td>${formatNumber(item.index, 2)}</td>
514
+ <td class="${changeClass}">${changeIcon} ${item.change}%</td>
515
+ <td>${formatNumber(item.inflow, 2)}</td>
516
+ <td>${formatNumber(item.outflow, 2)}</td>
517
+ <td class="${netFlowClass}">${netFlowIcon} ${formatNumber(item.netFlow, 2)}</td>
518
+ <td>${item.companyCount}</td>
519
+ <td>${item.leadingStock || '-'}</td>
520
+ <td>
521
+ <button class="btn btn-sm btn-outline-primary" onclick="loadIndustryDetail('${item.industry}')">
522
+ <i class="fas fa-search"></i>
523
+ </button>
524
+ </td>
525
+ </tr>
526
+ `;
527
+ });
528
+ }
529
+
530
+ $('#industry-table').html(html);
531
+ }
532
+
533
+ // 渲染行业详情
534
+ function renderIndustryDetail(data) {
535
+ if (!data) {
536
+ console.error("renderIndustryDetail: No data provided");
537
+ return;
538
+ }
539
+
540
+ console.log("Rendering industry detail:", data);
541
+
542
+ // 设置基本信息
543
+ $('#industry-name').text(data.industry);
544
+
545
+ // 设置行业评分
546
+ const scoreClass = getScoreColorClass(data.score);
547
+ $('#industry-score').text(data.score).removeClass().addClass(scoreClass);
548
+
549
+ // 设置技术面、基本面、资金面分数 (模拟分数)
550
+ const technicalScore = Math.round(data.score * 0.4);
551
+ const fundamentalScore = Math.round(data.score * 0.4);
552
+ const capitalFlowScore = Math.round(data.score * 0.2);
553
+
554
+ $('#technical-score').text(`${technicalScore}/40`);
555
+ $('#fundamental-score').text(`${fundamentalScore}/40`);
556
+ $('#capital-flow-score').text(`${capitalFlowScore}/20`);
557
+
558
+ $('#technical-progress').css('width', `${technicalScore / 40 * 100}%`);
559
+ $('#fundamental-progress').css('width', `${fundamentalScore / 40 * 100}%`);
560
+ $('#capital-flow-progress').css('width', `${capitalFlowScore / 20 * 100}%`);
561
+
562
+ // 设置行业基本信息
563
+ $('#industry-index').text(formatNumber(data.index, 2));
564
+ $('#industry-company-count').text(data.companyCount);
565
+
566
+ // 设置涨跌幅
567
+ const changeClass = parseFloat(data.change) >= 0 ? 'trend-up' : 'trend-down';
568
+ const changeIcon = parseFloat(data.change) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
569
+ $('#industry-change').html(`<span class="${changeClass}">${changeIcon} ${data.change}%</span>`);
570
+
571
+ // 设置资金流向
572
+ $('#industry-inflow').text(formatNumber(data.inflow, 2) + ' 亿');
573
+ $('#industry-outflow').text(formatNumber(data.outflow, 2) + ' 亿');
574
+
575
+ const netFlowClass = parseFloat(data.netFlow) >= 0 ? 'trend-up' : 'trend-down';
576
+ const netFlowIcon = parseFloat(data.netFlow) >= 0 ? '<i class="fas fa-arrow-up"></i>' : '<i class="fas fa-arrow-down"></i>';
577
+ $('#industry-net-flow').html(`<span class="${netFlowClass}">${netFlowIcon} ${formatNumber(data.netFlow, 2)} 亿</span>`);
578
+
579
+ // 设置投资建议
580
+ $('#industry-recommendation').text(data.recommendation);
581
+
582
+ // 绘制行业评分图表
583
+ renderIndustryScoreChart(data.score);
584
+
585
+ // 绘制资金流向图表
586
+ renderIndustryFlowChart(data.flowHistory);
587
+ }
588
+
589
+
590
+ // 渲染行业成分股表格
591
+ function renderIndustryStocks(data) {
592
+ if (!data) {
593
+ console.error("renderIndustryStocks: No data provided");
594
+ return;
595
+ }
596
+
597
+ console.log("Rendering industry stocks:", data);
598
+
599
+ let html = '';
600
+
601
+ if (!Array.isArray(data) || data.length === 0) {
602
+ html = '<tr><td colspan="9" class="text-center">暂无成分股数据</td></tr>';
603
+ } else {
604
+ data.forEach(stock => {
605
+ const changeClass = parseFloat(stock.change) >= 0 ? 'trend-up' : 'trend-down';
606
+ const changeIcon = parseFloat(stock.change) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
607
+
608
+ html += `
609
+ <tr>
610
+ <td>${stock.code}</td>
611
+ <td>${stock.name}</td>
612
+ <td>${formatNumber(stock.price, 2)}</td>
613
+ <td class="${changeClass}">${changeIcon} ${formatNumber(stock.change, 2)}%</td>
614
+ <td>${formatNumber(stock.volume, 0)}</td>
615
+ <td>${formatMoney(stock.turnover)}</td>
616
+ <td>${formatNumber(stock.turnover_rate || stock.turnoverRate, 2)}%</td>
617
+ <td>${stock.score ? formatNumber(stock.score, 0) : '-'}</td>
618
+ <td>
619
+ <a href="/stock_detail/${stock.code}" class="btn btn-sm btn-outline-primary">
620
+ <i class="fas fa-chart-line"></i>
621
+ </a>
622
+ </td>
623
+ </tr>
624
+ `;
625
+ });
626
+ }
627
+
628
+ $('#industry-stocks-table').html(html);
629
+ }
630
+
631
+ function renderCapitalFlowChart(flowHistory) {
632
+
633
+ // 添加数据检查
634
+ if (!flowHistory || !Array.isArray(flowHistory) || flowHistory.length === 0) {
635
+ // 如果没有历史数据,显示提示信息
636
+ document.querySelector("#industry-flow-chart").innerHTML =
637
+ '<div class="text-center text-muted py-5">暂无资金流向历史数据</div>';
638
+ return;
639
+ }
640
+
641
+ const dates = flowHistory.map(item => item.date);
642
+ const netFlows = flowHistory.map(item => parseFloat(item.netFlow));
643
+ const changes = flowHistory.map(item => parseFloat(item.change));
644
+
645
+ // 确保所有数组都有值
646
+ if (dates.length === 0 || netFlows.length === 0 || changes.length === 0) {
647
+ document.querySelector("#industry-flow-chart").innerHTML =
648
+ '<div class="text-center text-muted py-5">资金流向数据格式不正确</div>';
649
+ return;
650
+ }
651
+
652
+ const options = {
653
+ series: [
654
+ {
655
+ name: '净流入(亿)',
656
+ type: 'column',
657
+ data: netFlows
658
+ },
659
+ {
660
+ name: '涨跌幅(%)',
661
+ type: 'line',
662
+ data: changes
663
+ }
664
+ ],
665
+ chart: {
666
+ height: 265,
667
+ type: 'line',
668
+ toolbar: {
669
+ show: false
670
+ }
671
+ },
672
+ plotOptions: {
673
+ bar: {
674
+ borderRadius: 2,
675
+ dataLabels: {
676
+ position: 'top'
677
+ }
678
+ }
679
+ },
680
+ dataLabels: {
681
+ enabled: false
682
+ },
683
+ stroke: {
684
+ width: [0, 3]
685
+ },
686
+ colors: ['#0d6efd', '#dc3545'],
687
+ xaxis: {
688
+ categories: dates,
689
+ labels: {
690
+ formatter: function(value) {
691
+ return value.slice(5); // 只显示月-日
692
+ }
693
+ }
694
+ },
695
+ yaxis: [
696
+ {
697
+ title: {
698
+ text: '净流入(亿)',
699
+ style: {
700
+ fontSize: '12px'
701
+ }
702
+ },
703
+ labels: {
704
+ formatter: function(val) {
705
+ return val.toFixed(2);
706
+ }
707
+ }
708
+ },
709
+ {
710
+ opposite: true,
711
+ title: {
712
+ text: '涨跌幅(%)',
713
+ style: {
714
+ fontSize: '12px'
715
+ }
716
+ },
717
+ labels: {
718
+ formatter: function(val) {
719
+ return val.toFixed(2);
720
+ }
721
+ }
722
+ }
723
+ ],
724
+ tooltip: {
725
+ shared: true,
726
+ intersect: false,
727
+ y: {
728
+ formatter: function(value, { seriesIndex }) {
729
+ if (seriesIndex === 0) {
730
+ return value.toFixed(2) + ' 亿';
731
+ }
732
+ return value.toFixed(2) + '%';
733
+ }
734
+ }
735
+ },
736
+ legend: {
737
+ position: 'top'
738
+ }
739
+ };
740
+
741
+ // 清除任何现有图表
742
+ document.querySelector("#industry-flow-chart").innerHTML = '';
743
+ const chart = new ApexCharts(document.querySelector("#industry-flow-chart"), options);
744
+ chart.render();
745
+ }
746
+
747
+ // 填充行业选择器
748
+ function populateIndustrySelector(data) {
749
+ let options = '<option value="">-- 选择行业 --</option>';
750
+ const industries = data.map(item => item.industry);
751
+
752
+ industries.forEach(industry => {
753
+ options += `<option value="${industry}">${industry}</option>`;
754
+ });
755
+
756
+ $('#industry-selector').html(options);
757
+ }
758
+
759
+ // 评分颜色类
760
+ function getScoreColorClass(score) {
761
+ if (score >= 80) return 'badge rounded-pill bg-success';
762
+ if (score >= 60) return 'badge rounded-pill bg-primary';
763
+ if (score >= 40) return 'badge rounded-pill bg-warning text-dark';
764
+ return 'badge rounded-pill bg-danger';
765
+ }
766
+
767
+ // 获取评分颜色
768
+ function getScoreColor(score) {
769
+ if (score >= 80) return '#28a745'; // 绿色
770
+ if (score >= 60) return '#007bff'; // 蓝色
771
+ if (score >= 40) return '#ffc107'; // 黄色
772
+ return '#dc3545'; // 红色
773
+ }
774
+
775
+ // 格式化金额(单位:万元)
776
+ function formatMoney(value) {
777
+ if (value === undefined || value === null) {
778
+ return '--';
779
+ }
780
+
781
+ value = parseFloat(value);
782
+ if (isNaN(value)) {
783
+ return '--';
784
+ }
785
+
786
+ if (value >= 100000000) {
787
+ return (value / 100000000).toFixed(2) + ' 亿';
788
+ } else if (value >= 10000) {
789
+ return (value / 10000).toFixed(2) + ' 万';
790
+ } else {
791
+ return value.toFixed(2);
792
+ }
793
+ }
794
+
795
+ // 渲染行业资金流向图表
796
+ function renderIndustryFlowChart(data) {
797
+ const options = {
798
+ series: [
799
+ {
800
+ name: '流入资金',
801
+ data: data.flowHistory.map(item => item.inflow)
802
+ },
803
+ {
804
+ name: '流出资金',
805
+ data: data.flowHistory.map(item => item.outflow)
806
+ },
807
+ {
808
+ name: '净流入',
809
+ data: data.flowHistory.map(item => item.netFlow)
810
+ }
811
+ ],
812
+ chart: {
813
+ type: 'bar',
814
+ height: 200,
815
+ toolbar: {
816
+ show: false
817
+ }
818
+ },
819
+ plotOptions: {
820
+ bar: {
821
+ horizontal: false,
822
+ columnWidth: '55%',
823
+ endingShape: 'rounded'
824
+ },
825
+ },
826
+ dataLabels: {
827
+ enabled: false
828
+ },
829
+ stroke: {
830
+ show: true,
831
+ width: 2,
832
+ colors: ['transparent']
833
+ },
834
+ xaxis: {
835
+ categories: data.flowHistory.map(item => item.date)
836
+ },
837
+ yaxis: {
838
+ title: {
839
+ text: '亿元'
840
+ }
841
+ },
842
+ fill: {
843
+ opacity: 1
844
+ },
845
+ tooltip: {
846
+ y: {
847
+ formatter: function(val) {
848
+ return val + " 亿元";
849
+ }
850
+ }
851
+ },
852
+ colors: ['#00E396', '#FF4560', '#008FFB']
853
+ };
854
+
855
+ const chart = new ApexCharts(document.querySelector("#industry-flow-chart"), options);
856
+ chart.render();
857
+ }
858
+
859
+ // 绘制行业评分图表
860
+ function renderIndustryScoreChart(score) {
861
+ const options = {
862
+ series: [score],
863
+ chart: {
864
+ height: 150,
865
+ type: 'radialBar',
866
+ },
867
+ plotOptions: {
868
+ radialBar: {
869
+ hollow: {
870
+ size: '70%',
871
+ },
872
+ dataLabels: {
873
+ show: false
874
+ }
875
+ }
876
+ },
877
+ colors: [getScoreColor(score)],
878
+ stroke: {
879
+ lineCap: 'round'
880
+ }
881
+ };
882
+
883
+ // 清除旧图表并创建新图表
884
+ $('#industry-score-chart').empty();
885
+ const chart = new ApexCharts(document.querySelector("#industry-score-chart"), options);
886
+ chart.render();
887
+ }
888
+
889
+ // 绘制行业资金流向图表
890
+ function renderIndustryFlowChart(flowHistory) {
891
+ if (!flowHistory || !Array.isArray(flowHistory) || flowHistory.length === 0) {
892
+ console.error("renderIndustryFlowChart: Invalid flow history data");
893
+ return;
894
+ }
895
+
896
+ console.log("Rendering flow chart with data:", flowHistory);
897
+
898
+ const dates = flowHistory.map(item => item.date);
899
+ const netFlows = flowHistory.map(item => parseFloat(item.netFlow));
900
+ const changes = flowHistory.map(item => parseFloat(item.change));
901
+
902
+ const options = {
903
+ series: [
904
+ {
905
+ name: '净流入(亿)',
906
+ type: 'column',
907
+ data: netFlows
908
+ },
909
+ {
910
+ name: '涨跌幅(%)',
911
+ type: 'line',
912
+ data: changes
913
+ }
914
+ ],
915
+ chart: {
916
+ height: 200,
917
+ type: 'line',
918
+ toolbar: {
919
+ show: false
920
+ }
921
+ },
922
+ plotOptions: {
923
+ bar: {
924
+ borderRadius: 2,
925
+ dataLabels: {
926
+ position: 'top'
927
+ }
928
+ }
929
+ },
930
+ dataLabels: {
931
+ enabled: false
932
+ },
933
+ stroke: {
934
+ width: [0, 3]
935
+ },
936
+ colors: ['#0d6efd', '#dc3545'],
937
+ xaxis: {
938
+ categories: dates,
939
+ labels: {
940
+ formatter: function(value) {
941
+ // Only show month-day if it's a date string
942
+ if (typeof value === 'string' && value.includes('-')) {
943
+ return value.slice(5); // 只显示月-日
944
+ }
945
+ return value;
946
+ }
947
+ }
948
+ },
949
+ yaxis: [
950
+ {
951
+ title: {
952
+ text: '净流入(亿)',
953
+ style: {
954
+ fontSize: '12px'
955
+ }
956
+ },
957
+ labels: {
958
+ formatter: function(val) {
959
+ return val.toFixed(2);
960
+ }
961
+ }
962
+ },
963
+ {
964
+ opposite: true,
965
+ title: {
966
+ text: '涨跌幅(%)',
967
+ style: {
968
+ fontSize: '12px'
969
+ }
970
+ },
971
+ labels: {
972
+ formatter: function(val) {
973
+ return val.toFixed(2);
974
+ }
975
+ }
976
+ }
977
+ ],
978
+ tooltip: {
979
+ shared: true,
980
+ intersect: false,
981
+ y: {
982
+ formatter: function(value, { seriesIndex }) {
983
+ if (seriesIndex === 0) {
984
+ return value.toFixed(2) + ' 亿';
985
+ }
986
+ return value.toFixed(2) + '%';
987
+ }
988
+ }
989
+ },
990
+ legend: {
991
+ position: 'top'
992
+ }
993
+ };
994
+
995
+ // 清除旧图表并创建新图表
996
+ $('#industry-flow-chart').empty();
997
+ try {
998
+ const chart = new ApexCharts(document.querySelector("#industry-flow-chart"), options);
999
+ chart.render();
1000
+ } catch (e) {
1001
+ console.error("Error rendering flow chart:", e);
1002
+ }
1003
+ }
1004
+
1005
+ // 渲染行业对比图表
1006
+ function renderIndustryCompareCharts(data) {
1007
+ // 按资金净流入排序
1008
+ const sortedByNetFlow = [...data].sort((a, b) => b.netFlow - a.netFlow);
1009
+
1010
+ // 资金净流入前10
1011
+ const topInflow = sortedByNetFlow.slice(0, 10);
1012
+ renderBarChart('top-inflow-chart', topInflow.map(item => item.industry), topInflow.map(item => item.netFlow), '资金净流入(亿元)', '#00E396');
1013
+
1014
+ // 资金净流出前10
1015
+ const bottomInflow = [...sortedByNetFlow].reverse().slice(0, 10);
1016
+ renderBarChart('top-outflow-chart', bottomInflow.map(item => item.industry), bottomInflow.map(item => Math.abs(item.netFlow)), '资金净流出(亿元)', '#FF4560');
1017
+
1018
+ // 按涨跌幅排序
1019
+ const sortedByChange = [...data].sort((a, b) => parseFloat(b.change) - parseFloat(a.change));
1020
+
1021
+ // 涨幅前10
1022
+ const topGainers = sortedByChange.slice(0, 10);
1023
+ renderBarChart('top-gainers-chart', topGainers.map(item => item.industry), topGainers.map(item => parseFloat(item.change)), '涨幅(%)', '#00E396');
1024
+
1025
+ // 跌幅前10
1026
+ const topLosers = [...sortedByChange].reverse().slice(0, 10);
1027
+ renderBarChart('top-losers-chart', topLosers.map(item => item.industry), topLosers.map(item => Math.abs(parseFloat(item.change))), '跌幅(%)', '#FF4560');
1028
+ }
1029
+
1030
+ // 通用水平条形图
1031
+ function renderBarChart(elementId, categories, data, title, color) {
1032
+ if (!categories || !data || categories.length === 0 || data.length === 0) {
1033
+ console.error(`renderBarChart: Invalid data for ${elementId}`);
1034
+ return;
1035
+ }
1036
+
1037
+ const options = {
1038
+ series: [{
1039
+ name: title,
1040
+ data: data
1041
+ }],
1042
+ chart: {
1043
+ type: 'bar',
1044
+ height: 300,
1045
+ toolbar: {
1046
+ show: false
1047
+ }
1048
+ },
1049
+ plotOptions: {
1050
+ bar: {
1051
+ horizontal: true,
1052
+ dataLabels: {
1053
+ position: 'top',
1054
+ },
1055
+ }
1056
+ },
1057
+ dataLabels: {
1058
+ enabled: true,
1059
+ offsetX: -6,
1060
+ style: {
1061
+ fontSize: '12px',
1062
+ colors: ['#fff']
1063
+ },
1064
+ formatter: function(val) {
1065
+ return val.toFixed(2);
1066
+ }
1067
+ },
1068
+ stroke: {
1069
+ show: true,
1070
+ width: 1,
1071
+ colors: ['#fff']
1072
+ },
1073
+ xaxis: {
1074
+ categories: categories
1075
+ },
1076
+ yaxis: {
1077
+ title: {
1078
+ text: title
1079
+ }
1080
+ },
1081
+ fill: {
1082
+ opacity: 1
1083
+ },
1084
+ colors: [color]
1085
+ };
1086
+
1087
+ // 清除旧图表并创建新图表
1088
+ $(`#${elementId}`).empty();
1089
+ try {
1090
+ const chart = new ApexCharts(document.querySelector(`#${elementId}`), options);
1091
+ chart.render();
1092
+ } catch (e) {
1093
+ console.error(`Error rendering chart ${elementId}:`, e);
1094
+ }
1095
+ }
1096
+
1097
+ // 导出CSV
1098
+ function exportToCSV() {
1099
+ // 获取表格数据
1100
+ const table = document.querySelector('#industry-overview table');
1101
+ let csv = [];
1102
+ let rows = table.querySelectorAll('tr');
1103
+
1104
+ for (let i = 0; i < rows.length; i++) {
1105
+ let row = [], cols = rows[i].querySelectorAll('td, th');
1106
+
1107
+ for (let j = 0; j < cols.length - 1; j++) { // 跳过最后一列(操作列)
1108
+ // 获取单元格的文本内容,去除HTML标签
1109
+ let text = cols[j].innerText.replace(/(\r\n|\n|\r)/gm, '').replace(/,/g, ',');
1110
+ row.push(text);
1111
+ }
1112
+
1113
+ csv.push(row.join(','));
1114
+ }
1115
+
1116
+ // 下载CSV文件
1117
+ const period = $('#period-badge').text();
1118
+ const csvString = csv.join('\n');
1119
+ const filename = `行业资金流向_${period}_${new Date().toISOString().slice(0, 10)}.csv`;
1120
+
1121
+ const blob = new Blob(['\uFEFF' + csvString], { type: 'text/csv;charset=utf-8;' });
1122
+ const link = document.createElement('a');
1123
+ link.href = URL.createObjectURL(blob);
1124
+ link.download = filename;
1125
+
1126
+ link.style.display = 'none';
1127
+ document.body.appendChild(link);
1128
+
1129
+ link.click();
1130
+
1131
+ document.body.removeChild(link);
1132
+ }
1133
+
1134
+ </script>
1135
+ {% endblock %}
app/web/templates/layout.html ADDED
@@ -0,0 +1,1091 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <link rel="icon" href="/static/favicon.ico" type="image/x-icon">
6
+ <link rel="shortcut icon" href="/static/favicon.ico" type="image/x-icon">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
8
+ <title>{% block title %}智能分析系统{% endblock %}</title>
9
+ <!-- Bootstrap CSS -->
10
+ <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
11
+ <!-- Font Awesome -->
12
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
13
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
14
+ <!-- ApexCharts -->
15
+ <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/apexcharts.min.css" rel="stylesheet">
16
+ <!-- Download PDF -->
17
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
18
+
19
+ <!-- Custom CSS -->
20
+ <style>
21
+ body {
22
+ font-family: 'Helvetica Neue', Arial, sans-serif;
23
+ background-color: var(--bg-color);
24
+ color: var(--text-color);
25
+ }
26
+ .navbar-brand {
27
+ font-weight: bold;
28
+ }
29
+ .nav-item {
30
+ margin-left: 10px;
31
+ }
32
+ .sidebar {
33
+ background-color: var(--sidebar-bg-color);
34
+ color: white;
35
+ min-height: calc(100vh - 56px);
36
+ }
37
+ .sidebar .nav-link {
38
+ color: var(--sidebar-text-color);
39
+ padding: 0.75rem 1rem;
40
+ }
41
+ .sidebar .nav-link:hover {
42
+ color: #fff;
43
+ background-color: var(--sidebar-hover-bg-color);
44
+ }
45
+ .sidebar .nav-link.active {
46
+ color: #fff;
47
+ background-color: var(--sidebar-active-bg-color);
48
+ }
49
+ .sidebar .nav-link i {
50
+ margin-right: 10px;
51
+ width: 20px;
52
+ text-align: center;
53
+ }
54
+ .main-content {
55
+ padding: 20px;
56
+ }
57
+ .card {
58
+ margin-bottom: 20px;
59
+ box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
60
+ overflow: hidden; /* Prevent content from stretching container */
61
+ }
62
+
63
+ .card-header {
64
+ padding: 0.5rem 1rem;
65
+ height: auto !important;
66
+ max-height: 50px;
67
+ }
68
+
69
+ .form-control, .form-select, .input-group-text {
70
+ font-size: 0.875rem;
71
+ }
72
+
73
+ .input-group-sm .input-group-text {
74
+ padding: 0.25rem 0.5rem;
75
+ }
76
+
77
+ .card-body.py-2 {
78
+ padding-top: 0.5rem;
79
+ padding-bottom: 0.5rem;
80
+ }
81
+
82
+
83
+ .card-body {
84
+ padding: 1.25rem;
85
+ overflow: hidden; /* Prevent content from stretching container */
86
+ }
87
+ .loading {
88
+ display: flex;
89
+ justify-content: center;
90
+ align-items: center;
91
+ height: 200px;
92
+ }
93
+ .spinner-border {
94
+ width: 3rem;
95
+ height: 3rem;
96
+ }
97
+ .badge-success {
98
+ background-color: #28a745;
99
+ }
100
+ .badge-danger {
101
+ background-color: #dc3545;
102
+ }
103
+ .badge-warning {
104
+ background-color: #ffc107;
105
+ color: #212529;
106
+ }
107
+ .score-pill {
108
+ font-size: 1.2rem;
109
+ padding: 0.5rem 1rem;
110
+ }
111
+ #loading-overlay {
112
+ position: fixed;
113
+ top: 0;
114
+ left: 0;
115
+ width: 100%;
116
+ height: 100%;
117
+ background-color: rgba(255, 255, 255, 0.8);
118
+ display: none;
119
+ justify-content: center;
120
+ align-items: center;
121
+ z-index: 9999;
122
+ }
123
+ .text-strong {
124
+ font-weight: bold;
125
+ }
126
+ .text-larger {
127
+ font-size: 1.1em;
128
+ }
129
+ .trend-up {
130
+ color: #28a745;
131
+ }
132
+ .trend-down {
133
+ color: #dc3545;
134
+ }
135
+ .analysis-section {
136
+ margin-bottom: 1.5rem;
137
+ }
138
+
139
+ /* Fix for chart container heights */
140
+ #price-chart {
141
+ height: 400px !important;
142
+ max-height: 400px;
143
+ }
144
+
145
+ /* Fix for indicators chart container */
146
+ #indicators-chart {
147
+ height: 350px !important;
148
+ max-height: 350px;
149
+ }
150
+
151
+ /* Fix chart containers */
152
+ .apexcharts-canvas {
153
+ overflow: visible !important;
154
+ }
155
+
156
+ /* Fix for radar chart */
157
+ #radar-chart {
158
+ height: 200px !important;
159
+ max-height: 200px;
160
+ }
161
+
162
+ /* Fix for score chart */
163
+ #score-chart {
164
+ height: 200px !important;
165
+ max-height: 200px;
166
+ }
167
+
168
+ /* Fix header alignment */
169
+ .card-header h5 {
170
+ margin-bottom: 0;
171
+ display: flex;
172
+ align-items: center;
173
+ }
174
+
175
+ .apexcharts-tooltip {
176
+ background: #fff !important;
177
+ border: 1px solid #e3e3e3 !important;
178
+ box-shadow: 0 2px 5px rgba(0,0,0,0.1) !important;
179
+ border-radius: 4px !important;
180
+ padding: 8px !important;
181
+ font-size: 13px !important;
182
+ }
183
+
184
+ .apexcharts-tooltip-title {
185
+ background: #f8f9fa !important;
186
+ border-bottom: 1px solid #e3e3e3 !important;
187
+ padding: 6px 8px !important;
188
+ margin-bottom: 4px !important;
189
+ font-weight: 600 !important;
190
+ }
191
+
192
+ .apexcharts-tooltip-y-group {
193
+ padding: 3px 0 !important;
194
+ }
195
+
196
+ .apexcharts-tooltip-candlestick {
197
+ padding: 5px 8px !important;
198
+ }
199
+
200
+ .apexcharts-tooltip-candlestick div {
201
+ margin: 3px 0 !important;
202
+ }
203
+
204
+ .apexcharts-tooltip-candlestick span {
205
+ font-weight: 600 !important;
206
+ }
207
+
208
+ .apexcharts-crosshairs {
209
+ stroke-width: 1px !important;
210
+ stroke: #90A4AE !important;
211
+ stroke-dasharray: 0 !important;
212
+ opacity: 0.8 !important;
213
+ }
214
+
215
+ .apexcharts-tooltip-marker {
216
+ width: 10px !important;
217
+ height: 10px !important;
218
+ display: inline-block !important;
219
+ margin-right: 5px !important;
220
+ border-radius: 50% !important;
221
+ }
222
+
223
+ .apexcharts-tooltip-series-group {
224
+ padding: 4px 8px !important;
225
+ border-bottom: 1px solid #eee !important;
226
+ }
227
+
228
+ .apexcharts-tooltip-series-group:last-child {
229
+ border-bottom: none !important;
230
+ }
231
+
232
+ .apexcharts-tooltip-text-y-value {
233
+ font-weight: 600 !important;
234
+ }
235
+
236
+ .apexcharts-xaxistooltip {
237
+ background: #fff !important;
238
+ border: 1px solid #e3e3e3 !important;
239
+ border-radius: 2px !important;
240
+ box-shadow: 0 2px 5px rgba(0,0,0,0.1) !important;
241
+ padding: 4px 8px !important;
242
+ font-size: 12px !important;
243
+ color: #333 !important;
244
+ }
245
+
246
+ .apexcharts-yaxistooltip {
247
+ background: #fff !important;
248
+ border: 1px solid #e3e3e3 !important;
249
+ border-radius: 2px !important;
250
+ box-shadow: 0 2px 5px rgba(0,0,0,0.1) !important;
251
+ padding: 4px 8px !important;
252
+ font-size: 12px !important;
253
+ color: #333 !important;
254
+ }
255
+
256
+ /* AI分析样式 */
257
+ .analysis-para {
258
+ line-height: 1.8;
259
+ margin-bottom: 1.2rem;
260
+ color: #333;
261
+ }
262
+
263
+ .keyword {
264
+ color: #2c7be5;
265
+ font-weight: 600;
266
+ }
267
+
268
+ .term {
269
+ color: #d6336c;
270
+ font-weight: 500;
271
+ padding: 0 2px;
272
+ }
273
+
274
+ .price {
275
+ color: #00a47c;
276
+ font-family: 'Roboto Mono', monospace;
277
+ background: #f3faf8;
278
+ padding: 2px 4px;
279
+ border-radius: 3px;
280
+ }
281
+
282
+ .date {
283
+ color: #6c757d;
284
+ font-family: 'Roboto Mono', monospace;
285
+ }
286
+
287
+ strong.keyword {
288
+ border-bottom: 2px solid #2c7be5;
289
+ }
290
+
291
+ .table-info {
292
+ position: relative;
293
+ }
294
+
295
+ .table-info:after {
296
+ content: '';
297
+ position: absolute;
298
+ top: 0;
299
+ left: 0;
300
+ right: 0;
301
+ bottom: 0;
302
+ background: rgba(0, 123, 255, 0.1);
303
+ animation: pulse 1.5s infinite;
304
+ }
305
+
306
+ @keyframes pulse {
307
+ 0% { opacity: 0.5; }
308
+ 50% { opacity: 0.3; }
309
+ 100% { opacity: 0.5; }
310
+ }
311
+
312
+ /* 财经门户样式 */
313
+ .finance-portal-container {
314
+ display: grid;
315
+ grid-template-columns: 250px 1fr 300px;
316
+ grid-template-rows: 1fr 80px;
317
+ grid-template-areas:
318
+ "sidebar news hotspot"
319
+ "footer footer footer";
320
+ height: calc(100vh - 56px);
321
+ overflow: hidden;
322
+ gap: 15px;
323
+ padding: 15px;
324
+ background-color: #f5f7fa;
325
+ }
326
+
327
+ /* 左侧栏样式 */
328
+ .portal-sidebar {
329
+ grid-area: sidebar;
330
+ display: flex;
331
+ flex-direction: column;
332
+ background-color: #fff;
333
+ border-radius: 8px;
334
+ box-shadow: 0 2px 5px rgba(0,0,0,0.05);
335
+ overflow-y: auto;
336
+ padding: 15px;
337
+ }
338
+
339
+ .sidebar-header {
340
+ padding: 10px 0;
341
+ border-bottom: 1px solid #eee;
342
+ }
343
+
344
+ .sidebar-header h5 {
345
+ margin: 0;
346
+ color: #333;
347
+ font-size: 16px;
348
+ }
349
+
350
+ .sidebar-nav {
351
+ list-style: none;
352
+ padding: 0;
353
+ margin: 10px 0;
354
+ }
355
+
356
+ .sidebar-nav li {
357
+ margin-bottom: 5px;
358
+ }
359
+
360
+ .sidebar-nav a {
361
+ display: block;
362
+ padding: 10px 15px;
363
+ color: #444;
364
+ text-decoration: none;
365
+ border-radius: 5px;
366
+ transition: all 0.2s;
367
+ }
368
+
369
+ .sidebar-nav a:hover {
370
+ background-color: #f0f5ff;
371
+ color: #1a73e8;
372
+ }
373
+
374
+ .sidebar-nav i {
375
+ width: 20px;
376
+ margin-right: 8px;
377
+ text-align: center;
378
+ }
379
+
380
+ /* 中间新闻区域样式 */
381
+ .portal-news {
382
+ grid-area: news;
383
+ display: flex;
384
+ flex-direction: column;
385
+ background-color: #fff;
386
+ border-radius: 8px;
387
+ box-shadow: 0 2px 5px rgba(0,0,0,0.05);
388
+ overflow: hidden;
389
+ }
390
+
391
+ .news-header {
392
+ display: flex;
393
+ justify-content: space-between;
394
+ align-items: center;
395
+ padding: 12px 15px;
396
+ border-bottom: 1px solid #eee;
397
+ background-color: #fff;
398
+ }
399
+
400
+ .news-header h5 {
401
+ margin: 0;
402
+ color: #333;
403
+ font-size: 16px;
404
+ }
405
+
406
+ .news-tools {
407
+ display: flex;
408
+ align-items: center;
409
+ }
410
+
411
+ .news-content {
412
+ flex: 1;
413
+ overflow-y: auto;
414
+ padding: 0;
415
+ }
416
+
417
+ /* 新闻时间线改进样式 */
418
+ .news-timeline-container {
419
+ padding: 15px;
420
+ }
421
+
422
+ .time-point {
423
+ position: relative;
424
+ padding: 0 0 15px 65px;
425
+ min-height: 50px;
426
+ }
427
+
428
+ .time-point:before {
429
+ content: '';
430
+ position: absolute;
431
+ left: 40px;
432
+ top: 8px;
433
+ width: 12px;
434
+ height: 12px;
435
+ border-radius: 50%;
436
+ background-color: #1a73e8;
437
+ z-index: 1;
438
+ }
439
+
440
+ .time-point:after {
441
+ content: '';
442
+ position: absolute;
443
+ left: 45px;
444
+ top: 15px;
445
+ width: 2px;
446
+ height: calc(100% - 8px);
447
+ background-color: #e3e6ea;
448
+ }
449
+
450
+ .time-point:last-child:after {
451
+ display: none;
452
+ }
453
+
454
+ .time-label {
455
+ position: absolute;
456
+ left: 0;
457
+ top: 5px;
458
+ width: 35px;
459
+ text-align: right;
460
+ font-weight: bold;
461
+ font-size: 13px;
462
+ color: #444;
463
+ }
464
+
465
+ .news-items {
466
+ background-color: #f9f9f9;
467
+ border-radius: 8px;
468
+ box-shadow: 0 1px 3px rgba(0,0,0,0.03);
469
+ }
470
+
471
+ .news-item {
472
+ padding: 10px 15px;
473
+ border-bottom: 1px solid #eee;
474
+ }
475
+
476
+ .news-item:last-child {
477
+ border-bottom: none;
478
+ }
479
+
480
+ .news-content {
481
+ font-size: 14px;
482
+ line-height: 1.6;
483
+ color: #333;
484
+ }
485
+
486
+ /* 右侧热点区域样式 */
487
+ .portal-hotspot {
488
+ grid-area: hotspot;
489
+ display: flex;
490
+ flex-direction: column;
491
+ background-color: #fff;
492
+ border-radius: 8px;
493
+ box-shadow: 0 2px 5px rgba(0,0,0,0.05);
494
+ overflow: hidden;
495
+ }
496
+
497
+ .hotspot-header {
498
+ padding: 12px 15px;
499
+ border-bottom: 1px solid #eee;
500
+ background-color: #fff;
501
+ }
502
+
503
+ .hotspot-header h5 {
504
+ margin: 0;
505
+ color: #333;
506
+ font-size: 16px;
507
+ }
508
+
509
+ .hotspot-content {
510
+ overflow-y: auto;
511
+ padding: 10px 15px;
512
+ flex: 1;
513
+ }
514
+
515
+ .hotspot-list {
516
+ display: flex;
517
+ flex-direction: column;
518
+ gap: 10px;
519
+ }
520
+
521
+ .hotspot-item {
522
+ display: flex;
523
+ align-items: flex-start;
524
+ gap: 10px;
525
+ padding: 8px 0;
526
+ border-bottom: 1px solid #f0f0f0;
527
+ }
528
+
529
+ .hotspot-item:last-child {
530
+ border-bottom: none;
531
+ }
532
+
533
+ .hotspot-rank {
534
+ display: inline-flex;
535
+ align-items: center;
536
+ justify-content: center;
537
+ width: 20px;
538
+ height: 20px;
539
+ border-radius: 4px;
540
+ background-color: #e9ecef;
541
+ color: #666;
542
+ font-size: 12px;
543
+ font-weight: bold;
544
+ }
545
+
546
+ .hotspot-rank.rank-top {
547
+ background-color: #fb6340;
548
+ color: #fff;
549
+ }
550
+
551
+ .hotspot-title {
552
+ flex: 1;
553
+ font-size: 14px;
554
+ line-height: 1.4;
555
+ color: #333;
556
+ }
557
+
558
+ /* 页脚区域样式 */
559
+ .portal-footer {
560
+ grid-area: footer;
561
+ display: flex;
562
+ flex-direction: column;
563
+ background-color: #fff;
564
+ border-radius: 8px;
565
+ box-shadow: 0 2px 5px rgba(0,0,0,0.05);
566
+ }
567
+
568
+ /* 修改后的市场状态样式 */
569
+ .market-status {
570
+ display: flex;
571
+ justify-content: space-between;
572
+ align-items: center;
573
+ height: 40px;
574
+ border-bottom: 1px solid #eee;
575
+ padding: 0 10px;
576
+ }
577
+
578
+ .market-group {
579
+ display: flex;
580
+ align-items: center;
581
+ gap: 10px;
582
+ }
583
+
584
+ .group-title {
585
+ font-size: 12px;
586
+ font-weight: bold;
587
+ color: #666;
588
+ white-space: nowrap;
589
+ }
590
+
591
+ .status-group {
592
+ display: flex;
593
+ gap: 15px;
594
+ }
595
+
596
+ .status-item {
597
+ display: flex;
598
+ align-items: center;
599
+ gap: 4px;
600
+ font-size: 12px;
601
+ white-space: nowrap;
602
+ }
603
+
604
+ .status-item i {
605
+ font-size: 10px;
606
+ }
607
+
608
+ .current-time {
609
+ display: flex;
610
+ align-items: center;
611
+ gap: 15px;
612
+ color: #666;
613
+ font-size: 12px;
614
+ }
615
+
616
+ .refresh-time {
617
+ color: #888;
618
+ }
619
+
620
+ i.status-open {
621
+ color: #2dce89;
622
+ }
623
+
624
+ i.status-closed {
625
+ color: #8898aa;
626
+ }
627
+
628
+ .ticker-news {
629
+ height: 40px;
630
+ overflow: hidden;
631
+ position: relative;
632
+ background-color: #f8f9fa;
633
+ }
634
+
635
+ .ticker-wrapper {
636
+ display: flex;
637
+ position: absolute;
638
+ white-space: nowrap;
639
+ }
640
+
641
+ .ticker-item {
642
+ padding: 0 30px;
643
+ line-height: 40px;
644
+ color: #333;
645
+ }
646
+
647
+ @keyframes ticker {
648
+ 0% {
649
+ transform: translate3d(0, 0, 0);
650
+ }
651
+ 100% {
652
+ transform: translate3d(-50%, 0, 0);
653
+ }
654
+ }
655
+
656
+ /* 响应式调整 */
657
+ @media (max-width: 1200px) {
658
+ .finance-portal-container {
659
+ grid-template-columns: 200px 1fr 250px;
660
+ }
661
+ }
662
+
663
+ @media (max-width: 992px) {
664
+ .finance-portal-container {
665
+ grid-template-columns: 1fr;
666
+ grid-template-rows: auto 1fr auto auto;
667
+ grid-template-areas:
668
+ "sidebar"
669
+ "news"
670
+ "hotspot"
671
+ "footer";
672
+ height: auto;
673
+ overflow: auto;
674
+ }
675
+
676
+ .portal-news, .portal-hotspot {
677
+ height: 500px;
678
+ }
679
+
680
+ .portal-footer {
681
+ position: fixed;
682
+ bottom: 0;
683
+ left: 0;
684
+ right: 0;
685
+ z-index: 1000;
686
+ border-radius: 0;
687
+ }
688
+ }
689
+
690
+ .time-date {
691
+ position: absolute;
692
+ left: 0;
693
+ top: 25px;
694
+ width: 35px;
695
+ text-align: right;
696
+ font-size: 11px;
697
+ color: #666;
698
+ font-weight: normal;
699
+ }
700
+
701
+ /* Global Task Monitor Styles */
702
+ #task-monitor {
703
+ position: fixed;
704
+ bottom: 20px;
705
+ right: 20px;
706
+ width: 350px;
707
+ max-height: 400px;
708
+ z-index: 1050;
709
+ display: none; /* Initially hidden */
710
+ }
711
+ #task-monitor .card-header {
712
+ cursor: pointer;
713
+ }
714
+ #task-monitor .task-item {
715
+ padding: 8px 12px;
716
+ border-bottom: 1px solid #eee;
717
+ }
718
+ #task-monitor .task-item:last-child {
719
+ border-bottom: none;
720
+ }
721
+ #task-monitor .task-item a {
722
+ text-decoration: none;
723
+ color: inherit;
724
+ }
725
+ #task-monitor .task-item small {
726
+ display: block;
727
+ color: #6c757d;
728
+ }
729
+ #task-monitor .progress {
730
+ height: 8px;
731
+ margin-top: 5px;
732
+ }
733
+
734
+ /* 调整时间点样式,为日期留出空间 */
735
+ .time-point {
736
+ position: relative;
737
+ padding: 0 0 15px 65px;
738
+ min-height: 60px; /* 增加高度 */
739
+ }
740
+
741
+ </style>
742
+ <script>
743
+ // Immediately apply the theme on page load to prevent FOUC
744
+ (function() {
745
+ const theme = localStorage.getItem('theme') || 'light';
746
+ if (theme === 'dark') {
747
+ document.documentElement.setAttribute('data-theme', 'dark');
748
+ }
749
+ })();
750
+ </script>
751
+ {% block head %}{% endblock %}
752
+ </head>
753
+ <body>
754
+ <!-- Loading Overlay -->
755
+ <div id="loading-overlay">
756
+ <div class="spinner-border text-primary" role="status">
757
+ <span class="visually-hidden">Loading...</span>
758
+ </div>
759
+ </div>
760
+
761
+ <!-- Top Navigation -->
762
+ <nav class="navbar navbar-expand-lg navbar-dark bg-primary">
763
+ <div class="container-fluid">
764
+ <a class="navbar-brand" href="/">智能分析系统</a>
765
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
766
+ <span class="navbar-toggler-icon"></span>
767
+ </button>
768
+ <!-- 在layout.html的导航栏部分修改 -->
769
+ <div class="collapse navbar-collapse" id="navbarNav">
770
+ <ul class="navbar-nav me-auto">
771
+ <li class="nav-item">
772
+ <a class="nav-link {% if request.path == '/' %}active{% endif %}" href="/"><i class="fas fa-home"></i> 主页</a>
773
+ </li>
774
+ <li class="nav-item">
775
+ <a class="nav-link {% if request.path == '/dashboard' %}active{% endif %}" href="/dashboard"><i class="fas fa-chart-line"></i> 智能仪表盘</a>
776
+ </li>
777
+ <!-- 新增菜单项 - 基本面分析 -->
778
+ <li class="nav-item">
779
+ <a class="nav-link {% if request.path.startswith('/fundamental') %}active{% endif %}" href="/fundamental"><i class="fas fa-file-invoice-dollar"></i> 基本面分析</a>
780
+ </li>
781
+ <!-- 新增菜单项 - 资金流向 -->
782
+ <li class="nav-item">
783
+ <a class="nav-link {% if request.path.startswith('/capital_flow') %}active{% endif %}" href="/capital_flow"><i class="fas fa-money-bill-wave"></i> 资金流向</a>
784
+ </li>
785
+ <!-- 新增菜单项 - 情景预测 -->
786
+ <li class="nav-item">
787
+ <a class="nav-link {% if request.path.startswith('/scenario') %}active{% endif %}" href="/scenario_predict"><i class="fas fa-lightbulb"></i> 情景预测</a>
788
+ </li>
789
+ <li class="nav-item">
790
+ <a class="nav-link {% if request.path == '/market_scan' %}active{% endif %}" href="/market_scan"><i class="fas fa-search"></i> 市场扫描</a>
791
+ </li>
792
+ <li class="nav-item">
793
+ <a class="nav-link {% if request.path == '/portfolio' %}active{% endif %}" href="/portfolio"><i class="fas fa-briefcase"></i> 投资组合</a>
794
+ </li>
795
+ <!-- 新增菜单项 - 风险监控 -->
796
+ <li class="nav-item">
797
+ <a class="nav-link {% if request.path.startswith('/risk') %}active{% endif %}" href="/risk_monitor"><i class="fas fa-exclamation-triangle"></i> 风险监控</a>
798
+ </li>
799
+ <!-- 新增菜单项 - 智能问答 -->
800
+ <li class="nav-item">
801
+ <a class="nav-link {% if request.path == '/qa' %}active{% endif %}" href="/qa"><i class="fas fa-question-circle"></i> 智能问答</a>
802
+ </li>
803
+ <li class="nav-item">
804
+ <a class="nav-link {% if request.path == '/agent_analysis' %}active{% endif %}" href="/agent_analysis"><i class="fas fa-robot"></i> 智能体分析</a>
805
+ </li>
806
+ <li class="nav-item">
807
+ <a class="nav-link {% if request.path == '/etf_analysis' %}active{% endif %}" href="/etf_analysis"><i class="fas fa-chart-pie"></i> ETF分析</a>
808
+ </li>
809
+ </ul>
810
+ <div class="d-flex align-items-center">
811
+ <button class="btn text-white me-2" id="theme-toggle" title="切换主题">
812
+ <i class="fas fa-moon"></i>
813
+ </button>
814
+ <div class="input-group">
815
+ <input type="text" id="search-stock" class="form-control" placeholder="搜索股票代码/名称" aria-label="搜索股票">
816
+ <button class="btn btn-light" type="button" id="search-button">
817
+ <i class="fas fa-search"></i>
818
+ </button>
819
+ </div>
820
+ </div>
821
+ </div>
822
+ </div>
823
+ </nav>
824
+
825
+ <div class="container-fluid">
826
+ <div class="row">
827
+ {% block sidebar %}{% endblock %}
828
+
829
+ <main class="{% if self.sidebar()|trim %}col-md-9 ms-sm-auto col-lg-10 px-md-4{% else %}col-12{% endif %} main-content">
830
+ <div id="alerts-container"></div>
831
+ {% block content %}{% endblock %}
832
+ </main>
833
+ </div>
834
+ </div>
835
+
836
+ <!-- Global Task Monitor -->
837
+ <div id="task-monitor" class="card shadow-lg">
838
+ <div class="card-header bg-primary text-white d-flex justify-content-between align-items-center py-2" data-bs-toggle="collapse" href="#task-monitor-body">
839
+ <h6 class="mb-0"><i class="fas fa-tasks me-2"></i>后台分析任务</h6>
840
+ <span class="badge bg-light text-primary" id="task-count">0</span>
841
+ </div>
842
+ <div class="collapse show" id="task-monitor-body">
843
+ <div class="card-body p-0" id="task-list" style="max-height: 300px; overflow-y: auto;">
844
+ <!-- Active tasks will be injected here -->
845
+ </div>
846
+ </div>
847
+ </div>
848
+
849
+ <!-- Bootstrap JS with Popper -->
850
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
851
+ <!-- jQuery -->
852
+ <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
853
+ <!-- ApexCharts -->
854
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/apexcharts.min.js"></script>
855
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
856
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
857
+ <!-- Common JS -->
858
+ <script>
859
+ // 显示加载中覆盖层
860
+ function showLoading() {
861
+ $('#loading-overlay').css('display', 'flex');
862
+ }
863
+
864
+ // 隐藏加载中覆盖层
865
+ function hideLoading() {
866
+ $('#loading-overlay').css('display', 'none');
867
+ }
868
+
869
+ // 显示错误提示
870
+ function showError(message) {
871
+ const alertHtml = `
872
+ <div class="alert alert-danger alert-dismissible fade show" role="alert">
873
+ ${message}
874
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
875
+ </div>
876
+ `;
877
+ $('#alerts-container').html(alertHtml);
878
+ }
879
+
880
+ // 显示信息提示
881
+ function showInfo(message) {
882
+ const alertHtml = `
883
+ <div class="alert alert-info alert-dismissible fade show" role="alert">
884
+ <i class="fas fa-info-circle me-2"></i>${message}
885
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
886
+ </div>
887
+ `;
888
+ $('#alerts-container').html(alertHtml);
889
+ }
890
+
891
+ // 显示成功提示
892
+ function showSuccess(message) {
893
+ const alertHtml = `
894
+ <div class="alert alert-success alert-dismissible fade show" role="alert">
895
+ ${message}
896
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
897
+ </div>
898
+ `;
899
+ $('#alerts-container').html(alertHtml);
900
+ }
901
+
902
+ // 搜索股票并跳转到详情页
903
+ $('#search-button').click(function() {
904
+ const stockCode = $('#search-stock').val().trim();
905
+ if (stockCode) {
906
+ window.location.href = `/stock_detail/${stockCode}`;
907
+ }
908
+ });
909
+
910
+ // 回车键搜索
911
+ $('#search-stock').keypress(function(e) {
912
+ if (e.which === 13) {
913
+ $('#search-button').click();
914
+ }
915
+ });
916
+
917
+ // Theme Toggle Logic
918
+ (function() {
919
+ const themeToggle = document.getElementById('theme-toggle');
920
+ if (!themeToggle) return;
921
+
922
+ const themeIcon = themeToggle.querySelector('i');
923
+ const htmlElement = document.documentElement;
924
+
925
+ // Set initial icon based on theme
926
+ if (htmlElement.getAttribute('data-theme') === 'dark') {
927
+ themeIcon.classList.remove('fa-moon');
928
+ themeIcon.classList.add('fa-sun');
929
+ }
930
+
931
+ themeToggle.addEventListener('click', function() {
932
+ const isDark = htmlElement.hasAttribute('data-theme');
933
+ if (isDark) {
934
+ htmlElement.removeAttribute('data-theme');
935
+ localStorage.setItem('theme', 'light');
936
+ themeIcon.classList.remove('fa-sun');
937
+ themeIcon.classList.add('fa-moon');
938
+ } else {
939
+ htmlElement.setAttribute('data-theme', 'dark');
940
+ localStorage.setItem('theme', 'dark');
941
+ themeIcon.classList.remove('fa-moon');
942
+ themeIcon.classList.add('fa-sun');
943
+ }
944
+ });
945
+ })();
946
+
947
+
948
+ // 格式化数字 - 增强版
949
+ function formatNumber(num, digits = 2) {
950
+ console.log('formatNumber called with:', num);
951
+ if (num === null || num === undefined) return '-';
952
+ return parseFloat(num).toFixed(digits);
953
+ }
954
+
955
+ // 格式化技术指标 - 新增函数
956
+ function formatIndicator(value, indicatorType) {
957
+ if (value === null || value === undefined) return '-';
958
+
959
+ // 根据指标类型使用不同的小数位数
960
+ if (indicatorType === 'MACD' || indicatorType === 'Signal' || indicatorType === 'Histogram') {
961
+ return parseFloat(value).toFixed(3); // MACD相关指标使用3位小数
962
+ } else if (indicatorType === 'RSI') {
963
+ return parseFloat(value).toFixed(2); // RSI使用2位小数
964
+ } else {
965
+ return parseFloat(value).toFixed(2); // 默认使用2位小数
966
+ }
967
+ }
968
+
969
+ // 格式化百分比
970
+ function formatPercent(num, digits = 2) {
971
+ console.log('formatPercent called with:', num);
972
+ if (num === null || num === undefined) return '-';
973
+ return parseFloat(num).toFixed(digits) + '%';
974
+ }
975
+
976
+ // 根据评分获取颜色类
977
+ function getScoreColorClass(score) {
978
+ if (score >= 80) return 'bg-success';
979
+ if (score >= 60) return 'bg-primary';
980
+ if (score >= 40) return 'bg-warning';
981
+ return 'bg-danger';
982
+ }
983
+
984
+ // 根据趋势获取颜色类
985
+ function getTrendColorClass(trend) {
986
+ return trend === 'UP' ? 'trend-up' : 'trend-down';
987
+ }
988
+
989
+ // 根据趋势获取图标
990
+ function getTrendIcon(trend) {
991
+ return trend === 'UP' ? '<i class="fas fa-arrow-up"></i>' : '<i class="fas fa-arrow-down"></i>';
992
+ }
993
+ // Global Task Monitor Logic
994
+ $(document).ready(function() {
995
+ const monitor = $('#task-monitor');
996
+ const taskList = $('#task-list');
997
+ const taskCount = $('#task-count');
998
+
999
+ function checkActiveTasks() {
1000
+ $.ajax({
1001
+ url: '/api/active_tasks',
1002
+ type: 'GET',
1003
+ success: function(response) {
1004
+ const tasks = response.active_tasks;
1005
+
1006
+ if (tasks && tasks.length > 0) {
1007
+ taskList.empty();
1008
+ taskCount.text(tasks.length);
1009
+
1010
+ tasks.forEach(task => {
1011
+ const taskHtml = `
1012
+ <div class="task-item d-flex justify-content-between align-items-center">
1013
+ <a href="/agent_analysis?task_id=${task.task_id}" class="text-decoration-none text-reset flex-grow-1">
1014
+ <strong>股票: ${task.stock_code}</strong>
1015
+ <small class="d-block">${task.current_step}</small>
1016
+ <div class="progress">
1017
+ <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: ${task.progress}%;" aria-valuenow="${task.progress}" aria-valuemin="0" aria-valuemax="100"></div>
1018
+ </div>
1019
+ </a>
1020
+ <button class="btn btn-sm btn-outline-danger delete-task-btn ms-2 flex-shrink-0" data-task-id="${task.task_id}" title="删除任务" style="border: none;">
1021
+ <i class="fas fa-times"></i>
1022
+ </button>
1023
+ </div>
1024
+ `;
1025
+ taskList.append(taskHtml);
1026
+ });
1027
+ monitor.fadeIn();
1028
+ } else {
1029
+ monitor.fadeOut();
1030
+ }
1031
+ },
1032
+ error: function(xhr) {
1033
+ // Hide monitor on error to avoid confusion
1034
+ monitor.fadeOut();
1035
+ console.error("Failed to fetch active tasks:", xhr.responseText);
1036
+ }
1037
+ });
1038
+ }
1039
+
1040
+ // Check for active tasks every 15 seconds
1041
+ setInterval(checkActiveTasks, 15000);
1042
+ // Initial check on page load
1043
+ checkActiveTasks();
1044
+
1045
+ // Handle task deletion
1046
+ $(document).on('click', '#task-list .delete-task-btn', function(e) {
1047
+ e.preventDefault();
1048
+ e.stopPropagation();
1049
+ const button = $(this);
1050
+ const taskId = button.data('task-id');
1051
+ const monitor = $('#task-monitor');
1052
+ const taskCount = $('#task-count');
1053
+
1054
+ if (!taskId) {
1055
+ console.error("Delete button clicked, but no task-id found.");
1056
+ return;
1057
+ }
1058
+
1059
+ if (confirm(`确定要删除任务 ${taskId} 吗?`)) {
1060
+ $.ajax({
1061
+ url: '/api/delete_agent_analysis',
1062
+ type: 'POST',
1063
+ contentType: 'application/json',
1064
+ data: JSON.stringify({ task_ids: [taskId] }),
1065
+ success: function(response) {
1066
+ if (response.success) {
1067
+ button.closest('.task-item').fadeOut(300, function() {
1068
+ $(this).remove();
1069
+ const currentCount = $('#task-list .task-item').length;
1070
+ taskCount.text(currentCount);
1071
+ if (currentCount === 0) {
1072
+ monitor.fadeOut();
1073
+ }
1074
+ });
1075
+ showSuccess(`任务 ${taskId} 已成功删除。`);
1076
+ } else {
1077
+ showError(response.error || '删除任务失败。');
1078
+ }
1079
+ },
1080
+ error: function(xhr) {
1081
+ showError('删除任务时连接服务器失败。');
1082
+ console.error("Failed to delete task:", xhr.responseText);
1083
+ }
1084
+ });
1085
+ }
1086
+ });
1087
+ });
1088
+ </script>
1089
+ {% block scripts %}{% endblock %}
1090
+ </body>
1091
+ </html>
app/web/templates/market_scan.html ADDED
@@ -0,0 +1,591 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}市场扫描 - 智能分析系统{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="container-fluid py-4">
7
+ <div id="alerts-container"></div>
8
+
9
+ <div class="row mb-4">
10
+ <div class="col-12">
11
+ <div class="card">
12
+ <div class="card-header d-flex justify-content-between">
13
+ <h5 class="mb-0">市场扫描</h5>
14
+ </div>
15
+ <div class="card-body">
16
+ <form id="scan-form" class="row g-3">
17
+ <div class="col-md-3">
18
+ <div class="input-group">
19
+ <span class="input-group-text">选择指数</span>
20
+ <select class="form-select" id="index-selector">
21
+ <option value="">-- 选择指数 --</option>
22
+ <option value="000300">沪深300</option>
23
+ <option value="000905">中证500</option>
24
+ <option value="000852">中证1000</option>
25
+ <option value="000001">上证指数</option>
26
+ </select>
27
+ </div>
28
+ </div>
29
+ <div class="col-md-3">
30
+ <div class="input-group">
31
+ <span class="input-group-text">选择行业</span>
32
+ <select class="form-select" id="industry-selector">
33
+ <option value="">-- 选择行业 --</option>
34
+ <option value="保险">保险</option>
35
+ <option value="食品饮料">食品饮料</option>
36
+ <option value="多元金融">多元金融</option>
37
+ <option value="游戏">游戏</option>
38
+ <option value="酿酒行业">酿酒行业</option>
39
+ <option value="商业百货">商业百货</option>
40
+ <option value="证券">证券</option>
41
+ <option value="船舶制造">船舶制造</option>
42
+ <option value="家用轻工">家用轻工</option>
43
+ <option value="旅游酒店">旅游酒店</option>
44
+ <option value="美容护理">美容护理</option>
45
+ <option value="医疗服务">医疗服务</option>
46
+ <option value="软件开发">软件开发</option>
47
+ <option value="化学制药">化学制药</option>
48
+ <option value="医疗器械">医疗器械</option>
49
+ <option value="家电行业">家电行业</option>
50
+ <option value="汽车服务">汽车服务</option>
51
+ <option value="造纸印刷">造纸印刷</option>
52
+ <option value="纺织服装">纺织服装</option>
53
+ <option value="光伏设备">光伏设备</option>
54
+ <option value="房地产服务">房地产服务</option>
55
+ <option value="文化传媒">文化传媒</option>
56
+ <option value="医药商业">医药商业</option>
57
+ <option value="中药">中药</option>
58
+ <option value="专业服务">专业服务</option>
59
+ <option value="生物制品">生物制品</option>
60
+ <option value="仪器仪表">仪器仪表</option>
61
+ <option value="房地产开发">房地产开发</option>
62
+ <option value="教育">教育</option>
63
+ <option value="半导体">半导体</option>
64
+ <option value="玻璃玻纤">玻璃玻纤</option>
65
+ <option value="汽车整车">汽车整车</option>
66
+ <option value="消费电子">消费电子</option>
67
+ <option value="贸易行业">贸易行业</option>
68
+ <option value="包装材料">包装材料</option>
69
+ <option value="汽车零部件">汽车零部件</option>
70
+ <option value="电子化学品">电子化学品</option>
71
+ <option value="电子元件">电子元件</option>
72
+ <option value="装修建材">装修建材</option>
73
+ <option value="交运设备">交运设备</option>
74
+ <option value="农牧饲渔">农牧饲渔</option>
75
+ <option value="塑料制品">塑料制品</option>
76
+ <option value="珠宝首饰">珠宝首饰</option>
77
+ <option value="贵金属">贵金属</option>
78
+ <option value="非金属材料">非金属材料</option>
79
+ <option value="装修装饰">装修装饰</option>
80
+ <option value="风电设备">风电设备</option>
81
+ <option value="工程咨询服务">工程咨询服务</option>
82
+ <option value="专用设备">专用设备</option>
83
+ <option value="光学光电子">光学光电子</option>
84
+ <option value="航空机场">航空机场</option>
85
+ <option value="小金属">小金属</option>
86
+ <option value="物流行业">物流行业</option>
87
+ <option value="通用设备">通用设备</option>
88
+ <option value="计算机设备">计算机设备</option>
89
+ <option value="环保行业">环保行业</option>
90
+ <option value="航运港口">航运港口</option>
91
+ <option value="通信设备">通信设备</option>
92
+ <option value="水泥建材">水泥建材</option>
93
+ <option value="电池">电池</option>
94
+ <option value="化肥行业">化肥行业</option>
95
+ <option value="互联网服务">互联网服务</option>
96
+ <option value="工程建设">工程建设</option>
97
+ <option value="橡胶制品">橡胶制品</option>
98
+ <option value="化学原料">化学原料</option>
99
+ <option value="化纤行业">化纤行业</option>
100
+ <option value="农药兽药">农药兽药</option>
101
+ <option value="化学制品">化学制品</option>
102
+ <option value="能源金属">能源金属</option>
103
+ <option value="有色金属">有色金属</option>
104
+ <option value="采掘行业">采掘行业</option>
105
+ <option value="燃气">燃气</option>
106
+ <option value="综合行业">综合行业</option>
107
+ <option value="工程机械">工程机械</option>
108
+ <option value="银行">银行</option>
109
+ <option value="铁路公路">铁路公路</option>
110
+ <option value="石油行业">石油行业</option>
111
+ <option value="公用事业">公用事业</option>
112
+ <option value="电机">电机</option>
113
+ <option value="通信服务">通信服务</option>
114
+ <option value="钢铁行业">钢铁行业</option>
115
+ <option value="电力行业">电力行业</option>
116
+ <option value="电网设备">电网设备</option>
117
+ <option value="煤炭行业">煤炭行业</option>
118
+ <option value="电源设备">电源设备</option>
119
+ <option value="航天航空">航天航空</option>
120
+ </select>
121
+ </div>
122
+ </div>
123
+ <div class="col-md-3">
124
+ <div class="input-group">
125
+ <span class="input-group-text">自定义股票</span>
126
+ <input type="text" class="form-control" id="custom-stocks" placeholder="多个股票代码用逗号分隔">
127
+ </div>
128
+ </div>
129
+ <div class="col-md-3">
130
+ <div class="input-group">
131
+ <span class="input-group-text">最低分数</span>
132
+ <input type="number" class="form-control" id="min-score" value="60" min="0" max="100">
133
+ <button type="submit" class="btn btn-primary">
134
+ <i class="fas fa-search"></i> 扫描
135
+ </button>
136
+ </div>
137
+ </div>
138
+ </form>
139
+ </div>
140
+ </div>
141
+ </div>
142
+ </div>
143
+
144
+ <div class="row mb-4">
145
+ <div class="col-12">
146
+ <div class="card">
147
+ <div class="card-header d-flex justify-content-between">
148
+ <h5 class="mb-0">扫描结果</h5>
149
+ <div>
150
+ <span class="badge bg-primary ms-2" id="result-count">0</span>
151
+ <button class="btn btn-sm btn-outline-primary ms-2" id="export-btn" style="display: none;">
152
+ <i class="fas fa-download"></i> 导出结果
153
+ </button>
154
+ </div>
155
+ </div>
156
+ <div class="card-body">
157
+ <div id="scan-loading" class="text-center py-5" style="display: none;">
158
+ <div class="spinner-border text-primary" role="status">
159
+ <span class="visually-hidden">Loading...</span>
160
+ </div>
161
+ <p class="mt-2" id="scan-message">正在扫描市场,请稍候...</p>
162
+ <div class="progress mt-3" style="height: 5px;">
163
+ <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%"></div>
164
+ </div>
165
+ <button id="cancel-scan-btn" class="btn btn-outline-secondary mt-3">
166
+ <i class="fas fa-times"></i> 取消扫描
167
+ </button>
168
+ </div>
169
+
170
+ <!-- 添加错误重试区域 -->
171
+ <div id="scan-error-retry" class="text-center mt-3" style="display: none;">
172
+ <button id="scan-retry-button" class="btn btn-primary mt-2">
173
+ <i class="fas fa-sync-alt"></i> 重试扫描
174
+ </button>
175
+ <p class="text-muted small mt-2">
176
+ 已超负载
177
+ </p>
178
+ </div>
179
+
180
+ <div id="scan-results">
181
+ <table class="table table-hover">
182
+ <thead>
183
+ <tr>
184
+ <th>代码</th>
185
+ <th>名称</th>
186
+ <th>行业</th>
187
+ <th>得分</th>
188
+ <th>价格</th>
189
+ <th>涨跌幅</th>
190
+ <th>RSI</th>
191
+ <th>MA趋势</th>
192
+ <th>成交量</th>
193
+ <th>建议</th>
194
+ <th>操作</th>
195
+ </tr>
196
+ </thead>
197
+ <tbody id="results-table">
198
+ <tr>
199
+ <td colspan="11" class="text-center">暂无数据,请开始扫描</td>
200
+ </tr>
201
+ </tbody>
202
+ </table>
203
+ </div>
204
+ </div>
205
+ </div>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ {% endblock %}
210
+
211
+ {% block scripts %}
212
+ <script>
213
+ $(document).ready(function() {
214
+ // 表单提交
215
+ $('#scan-form').submit(function(e) {
216
+ e.preventDefault();
217
+
218
+ // 获取股票列表
219
+ let stockList = [];
220
+
221
+ // 获取指数股票
222
+ const indexCode = $('#index-selector').val();
223
+ if (indexCode) {
224
+ fetchIndexStocks(indexCode);
225
+ return;
226
+ }
227
+
228
+ // 获取行业股票
229
+ const industry = $('#industry-selector').val();
230
+ if (industry) {
231
+ fetchIndustryStocks(industry);
232
+ return;
233
+ }
234
+
235
+ // 获取自定义股票
236
+ const customStocks = $('#custom-stocks').val().trim();
237
+ if (customStocks) {
238
+ stockList = customStocks.split(',').map(s => s.trim());
239
+ scanMarket(stockList);
240
+ } else {
241
+ showError('请至少选择一种方式获取股票列表');
242
+ }
243
+ });
244
+
245
+ // 指数选择变化
246
+ $('#index-selector').change(function() {
247
+ if ($(this).val()) {
248
+ $('#industry-selector').val('');
249
+ }
250
+ });
251
+
252
+ // 行业选择变化
253
+ $('#industry-selector').change(function() {
254
+ if ($(this).val()) {
255
+ $('#index-selector').val('');
256
+ }
257
+ });
258
+
259
+ // 导出结果
260
+ $('#export-btn').click(function() {
261
+ exportToCSV();
262
+ });
263
+
264
+ // 获取指数成分股
265
+ function fetchIndexStocks(indexCode) {
266
+ $('#scan-loading').show();
267
+ $('#scan-results').hide();
268
+
269
+ $.ajax({
270
+ url: `/api/index_stocks?index_code=${indexCode}`,
271
+ type: 'GET',
272
+ dataType: 'json',
273
+ success: function(response) {
274
+ const stockList = response.stock_list;
275
+ if (stockList && stockList.length > 0) {
276
+ // 保存最近的扫描列表用于重试
277
+ window.lastScanList = stockList;
278
+
279
+ scanMarket(stockList);
280
+ } else {
281
+ $('#scan-loading').hide();
282
+ $('#scan-results').show();
283
+ showError('获取指数成分股失败,或成分股列表为空');
284
+ }
285
+ },
286
+ error: function(error) {
287
+ $('#scan-loading').hide();
288
+ $('#scan-results').show();
289
+ showError('获取指数成分股失败: ' + (error.responseJSON ? error.responseJSON.error : error.statusText));
290
+ }
291
+ });
292
+ }
293
+
294
+ // 获取行业成分股
295
+ function fetchIndustryStocks(industry) {
296
+ $('#scan-loading').show();
297
+ $('#scan-results').hide();
298
+
299
+ $.ajax({
300
+ url: `/api/industry_stocks?industry=${encodeURIComponent(industry)}`,
301
+ type: 'GET',
302
+ dataType: 'json',
303
+ success: function(response) {
304
+ const stockList = response.stock_list;
305
+ if (stockList && stockList.length > 0) {
306
+ // 保存最近的扫描列表用于重试
307
+ window.lastScanList = stockList;
308
+
309
+ scanMarket(stockList);
310
+ } else {
311
+ $('#scan-loading').hide();
312
+ $('#scan-results').show();
313
+ showError('获取行业成分股失败,或成分股列表为空');
314
+ }
315
+ },
316
+ error: function(error) {
317
+ $('#scan-loading').hide();
318
+ $('#scan-results').show();
319
+ showError('获取行业成分股失败: ' + (error.responseJSON ? error.responseJSON.error : error.statusText));
320
+ }
321
+ });
322
+ }
323
+
324
+ // 扫描市场
325
+ function scanMarket(stockList) {
326
+ $('#scan-loading').show();
327
+ $('#scan-results').hide();
328
+ $('#scan-error-retry').hide();
329
+
330
+ // 添加处理时间计数器
331
+ let processingTime = 0;
332
+ let stockCount = stockList.length;
333
+
334
+ // 保存上次扫描列表
335
+ window.lastScanList = stockList;
336
+
337
+ // 更新扫描提示消息
338
+ $('#scan-message').html(`正在准备扫描${stockCount}只股票,请稍候...`);
339
+
340
+ const minScore = parseInt($('#min-score').val() || 60);
341
+
342
+ // 第一步:启动扫描任务
343
+ $.ajax({
344
+ url: '/api/start_market_scan',
345
+ type: 'POST',
346
+ contentType: 'application/json',
347
+ data: JSON.stringify({
348
+ stock_list: stockList,
349
+ min_score: minScore,
350
+ market_type: 'A'
351
+ }),
352
+ success: function(response) {
353
+ const taskId = response.task_id;
354
+
355
+ if (!taskId) {
356
+ showError('启动扫描任务失败:未获取到任务ID');
357
+ $('#scan-loading').hide();
358
+ $('#scan-results').show();
359
+ $('#scan-error-retry').show();
360
+ return;
361
+ }
362
+
363
+ // 启动轮询任务状态
364
+ pollScanStatus(taskId, processingTime);
365
+ },
366
+ error: function(xhr, status, error) {
367
+ $('#scan-loading').hide();
368
+ $('#scan-results').show();
369
+
370
+ let errorMsg = '启动扫描任务失败';
371
+ if (xhr.responseJSON && xhr.responseJSON.error) {
372
+ errorMsg += ': ' + xhr.responseJSON.error;
373
+ } else if (error) {
374
+ errorMsg += ': ' + error;
375
+ }
376
+
377
+ showError(errorMsg);
378
+ $('#scan-error-retry').show();
379
+ }
380
+ });
381
+ }
382
+
383
+ // 轮询扫描任务状态
384
+ function pollScanStatus(taskId, startTime) {
385
+ let elapsedTime = startTime || 0;
386
+ let pollInterval;
387
+
388
+ // 立即执行一次,然后设置定时器
389
+ checkStatus();
390
+
391
+ function checkStatus() {
392
+ $.ajax({
393
+ url: `/api/scan_status/${taskId}`,
394
+ type: 'GET',
395
+ success: function(response) {
396
+ // 更新计时和进度
397
+ elapsedTime++;
398
+ const progress = response.progress || 0;
399
+
400
+ // 更新进度消息
401
+ $('#scan-message').html(`正在扫描市场...<br>
402
+ 进度: ${progress}% 完成<br>
403
+ 已处理 ${Math.round(response.total * progress / 100)} / ${response.total} 只股票<br>
404
+ 耗时: ${elapsedTime}秒`);
405
+
406
+ // 检查任务状态
407
+ if (response.status === 'completed') {
408
+ // 扫描完成,停止轮询
409
+ clearInterval(pollInterval);
410
+
411
+ // 显示结果
412
+ renderResults(response.result || []);
413
+ $('#scan-loading').hide();
414
+ $('#scan-results').show();
415
+
416
+ // 如果结果为空,显示提示
417
+ if (!response.result || response.result.length === 0) {
418
+ $('#results-table').html('<tr><td colspan="11" class="text-center">未找到符合条件的股票</td></tr>');
419
+ $('#result-count').text('0');
420
+ $('#export-btn').hide();
421
+ }
422
+
423
+ } else if (response.status === 'failed') {
424
+ // 扫描失败,停止轮询
425
+ clearInterval(pollInterval);
426
+
427
+ $('#scan-loading').hide();
428
+ $('#scan-results').show();
429
+
430
+ showError('扫描任务失败: ' + (response.error || '未知错误'));
431
+ $('#scan-error-retry').show();
432
+
433
+ } else {
434
+ // 任务仍在进行中,继续轮询
435
+ // 轮询间隔根据进度动态调整
436
+ if (!pollInterval) {
437
+ pollInterval = setInterval(checkStatus, 2000);
438
+ }
439
+ }
440
+ },
441
+ error: function(xhr, status, error) {
442
+
443
+ // 尝试继续轮询
444
+ if (!pollInterval) {
445
+ pollInterval = setInterval(checkStatus, 3000);
446
+ }
447
+
448
+ // 更新进度消息
449
+ $('#scan-message').html(`正在扫描市场...<br>
450
+ 无法获取最新进度<br>
451
+ 耗时: ${elapsedTime}秒`);
452
+ }
453
+ });
454
+ }
455
+ }
456
+
457
+ // 取消扫描任务
458
+ function cancelScan(taskId) {
459
+ $.ajax({
460
+ url: `/api/cancel_scan/${taskId}`,
461
+ type: 'POST',
462
+ success: function(response) {
463
+ $('#scan-loading').hide();
464
+ $('#scan-results').show();
465
+ showError('扫描任务已取消');
466
+ $('#scan-error-retry').show();
467
+ },
468
+ error: function(xhr, status, error) {
469
+ console.error('取消扫描任务失败:', error);
470
+ }
471
+ });
472
+ }
473
+
474
+ // 渲染扫描结果
475
+ function renderResults(results) {
476
+ if (!results || results.length === 0) {
477
+ $('#results-table').html('<tr><td colspan="11" class="text-center">未找到符合条件的股票</td></tr>');
478
+ $('#result-count').text('0');
479
+ $('#export-btn').hide();
480
+ return;
481
+ }
482
+
483
+ let html = '';
484
+ results.forEach(result => {
485
+ // 获取股票评分的颜色类
486
+ const scoreClass = getScoreColorClass(result.score);
487
+
488
+ // 获取MA趋势的类和图标
489
+ const maTrendClass = getTrendColorClass(result.ma_trend);
490
+ const maTrendIcon = getTrendIcon(result.ma_trend);
491
+
492
+ // 获取价格变动的类和图标
493
+ const priceChangeClass = result.price_change >= 0 ? 'trend-up' : 'trend-down';
494
+ const priceChangeIcon = result.price_change >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
495
+
496
+ html += `
497
+ <tr>
498
+ <td>${result.stock_code}</td>
499
+ <td>${result.stock_name || '未知'}</td>
500
+ <td>${result.industry || '-'}</td>
501
+ <td><span class="badge ${scoreClass}">${result.score}</span></td>
502
+ <td>${formatNumber(result.price)}</td>
503
+ <td class="${priceChangeClass}">${priceChangeIcon} ${formatPercent(result.price_change)}</td>
504
+ <td>${formatNumber(result.rsi)}</td>
505
+ <td class="${maTrendClass}">${maTrendIcon} ${result.ma_trend}</td>
506
+ <td>${result.volume_status}</td>
507
+ <td>${result.recommendation}</td>
508
+ <td>
509
+ <a href="/stock_detail/${result.stock_code}" class="btn btn-sm btn-primary">
510
+ <i class="fas fa-chart-line"></i> 详情
511
+ </a>
512
+ </td>
513
+ </tr>
514
+ `;
515
+ });
516
+
517
+ $('#results-table').html(html);
518
+ $('#result-count').text(results.length);
519
+ $('#export-btn').show();
520
+ }
521
+
522
+ // 导出到CSV
523
+ function exportToCSV() {
524
+ // 获取表格数据
525
+ const table = document.querySelector('#scan-results table');
526
+ let csv = [];
527
+ let rows = table.querySelectorAll('tr');
528
+
529
+ for (let i = 0; i < rows.length; i++) {
530
+ let row = [], cols = rows[i].querySelectorAll('td, th');
531
+
532
+ for (let j = 0; j < cols.length - 1; j++) { // 跳过最后一列(操作列)
533
+ // 获取单元格的文本内容,去除HTML标签
534
+ let text = cols[j].innerText.replace(/(\r\n|\n|\r)/gm, '').replace(/,/g, ',');
535
+ row.push(text);
536
+ }
537
+
538
+ csv.push(row.join(','));
539
+ }
540
+
541
+ // 下载CSV文件
542
+ const csvString = csv.join('\n');
543
+ const filename = '市场扫描结果_' + new Date().toISOString().slice(0, 10) + '.csv';
544
+
545
+ const blob = new Blob(['\uFEFF' + csvString], { type: 'text/csv;charset=utf-8;' });
546
+ const link = document.createElement('a');
547
+ link.href = URL.createObjectURL(blob);
548
+ link.download = filename;
549
+
550
+ link.style.display = 'none';
551
+ document.body.appendChild(link);
552
+
553
+ link.click();
554
+
555
+ document.body.removeChild(link);
556
+ }
557
+ });
558
+
559
+
560
+ // 添加到script部分
561
+ let currentTaskId = null; // 存储当前任务ID
562
+
563
+ // 取消按钮点击事件
564
+ $('#cancel-scan-btn').click(function() {
565
+ if (currentTaskId) {
566
+ cancelScan(currentTaskId);
567
+ } else {
568
+ $('#scan-loading').hide();
569
+ $('#scan-results').show();
570
+ }
571
+ });
572
+
573
+ // 修改启动成功处理
574
+ function handleStartSuccess(response) {
575
+ const taskId = response.task_id;
576
+ currentTaskId = taskId; // 保存当前任务ID
577
+
578
+ if (!taskId) {
579
+ showError('启动扫描任务失败:未获取到任务ID');
580
+ $('#scan-loading').hide();
581
+ $('#scan-results').show();
582
+ $('#scan-error-retry').show();
583
+ return;
584
+ }
585
+
586
+ // 启动轮询任务状态
587
+ pollScanStatus(taskId, 0);
588
+ }
589
+
590
+ </script>
591
+ {% endblock %}
app/web/templates/portfolio.html ADDED
@@ -0,0 +1,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ {% extends "layout.html" %}
3
+
4
+ {% block title %}投资组合 - 智能分析系统{% endblock %}
5
+
6
+ {% block content %}
7
+ <div class="container-fluid py-4">
8
+ <div id="alerts-container"></div>
9
+
10
+ <div class="row mb-4">
11
+ <div class="col-12">
12
+ <div class="card">
13
+ <div class="card-header d-flex justify-content-between">
14
+ <h5 class="mb-0">我的投资组合</h5>
15
+ <button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addStockModal">
16
+ <i class="fas fa-plus"></i> 添加股票
17
+ </button>
18
+ </div>
19
+ <div class="card-body">
20
+ <div id="portfolio-empty" class="text-center py-4">
21
+ <i class="fas fa-folder-open fa-3x text-muted mb-3"></i>
22
+ <p>您的投资组合还是空的,请添加股票</p>
23
+ <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addStockModal">
24
+ <i class="fas fa-plus"></i> 添加股票
25
+ </button>
26
+ </div>
27
+
28
+ <div id="portfolio-content" style="display: none;">
29
+ <div class="table-responsive">
30
+ <table class="table table-hover">
31
+ <thead>
32
+ <tr>
33
+ <th>代码</th>
34
+ <th>名称</th>
35
+ <th>行业</th>
36
+ <th>持仓比例</th>
37
+ <th>当前价格</th>
38
+ <th>今日涨跌</th>
39
+ <th>综合评分</th>
40
+ <th>建议</th>
41
+ <th>操作</th>
42
+ </tr>
43
+ </thead>
44
+ <tbody id="portfolio-table">
45
+ <!-- 投资组合数据将在JS中动态填充 -->
46
+ </tbody>
47
+ </table>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ </div>
54
+
55
+ <div id="portfolio-analysis" class="row mb-4" style="display: none;">
56
+ <div class="col-md-6">
57
+ <div class="card h-100">
58
+ <div class="card-header">
59
+ <h5 class="mb-0">投资组合评分</h5>
60
+ </div>
61
+ <div class="card-body">
62
+ <div class="row">
63
+ <div class="col-md-4 text-center">
64
+ <div id="portfolio-score-chart"></div>
65
+ <h4 id="portfolio-score" class="mt-2">--</h4>
66
+ <p class="text-muted">综合评分</p>
67
+ </div>
68
+ <div class="col-md-8">
69
+ <h5 class="mb-3">维度评分</h5>
70
+ <div class="mb-3">
71
+ <div class="d-flex justify-content-between mb-1">
72
+ <span>技术面</span>
73
+ <span id="technical-score">--/40</span>
74
+ </div>
75
+ <div class="progress">
76
+ <div id="technical-progress" class="progress-bar bg-info" role="progressbar" style="width: 0%"></div>
77
+ </div>
78
+ </div>
79
+ <div class="mb-3">
80
+ <div class="d-flex justify-content-between mb-1">
81
+ <span>基本面</span>
82
+ <span id="fundamental-score">--/40</span>
83
+ </div>
84
+ <div class="progress">
85
+ <div id="fundamental-progress" class="progress-bar bg-success" role="progressbar" style="width: 0%"></div>
86
+ </div>
87
+ </div>
88
+ <div class="mb-3">
89
+ <div class="d-flex justify-content-between mb-1">
90
+ <span>资金面</span>
91
+ <span id="capital-flow-score">--/20</span>
92
+ </div>
93
+ <div class="progress">
94
+ <div id="capital-flow-progress" class="progress-bar bg-warning" role="progressbar" style="width: 0%"></div>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ </div>
100
+ </div>
101
+ </div>
102
+ <div class="col-md-6">
103
+ <div class="card h-100">
104
+ <div class="card-header">
105
+ <h5 class="mb-0">行业分布</h5>
106
+ </div>
107
+ <div class="card-body">
108
+ <div id="industry-chart"></div>
109
+ </div>
110
+ </div>
111
+ </div>
112
+ </div>
113
+
114
+ <div id="portfolio-recommendations" class="row mb-4" style="display: none;">
115
+ <div class="col-12">
116
+ <div class="card">
117
+ <div class="card-header">
118
+ <h5 class="mb-0">投资建议</h5>
119
+ </div>
120
+ <div class="card-body">
121
+ <ul class="list-group" id="recommendations-list">
122
+ <!-- 投资建议将在JS中动态填充 -->
123
+ </ul>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ </div>
129
+
130
+ <!-- 添加股票模态框 -->
131
+ <div class="modal fade" id="addStockModal" tabindex="-1" aria-labelledby="addStockModalLabel" aria-hidden="true">
132
+ <div class="modal-dialog">
133
+ <div class="modal-content">
134
+ <div class="modal-header">
135
+ <h5 class="modal-title" id="addStockModalLabel">添加股票到投资组合</h5>
136
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
137
+ </div>
138
+ <div class="modal-body">
139
+ <form id="add-stock-form">
140
+ <div class="mb-3">
141
+ <label for="add-stock-code" class="form-label">股票代码</label>
142
+ <input type="text" class="form-control" id="add-stock-code" required>
143
+ </div>
144
+ <div class="mb-3">
145
+ <label for="add-stock-weight" class="form-label">持仓比例 (%)</label>
146
+ <input type="number" class="form-control" id="add-stock-weight" min="1" max="100" value="10" required>
147
+ </div>
148
+ </form>
149
+ </div>
150
+ <div class="modal-footer">
151
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
152
+ <button type="button" class="btn btn-primary" id="add-stock-btn">添加</button>
153
+ </div>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ {% endblock %}
158
+
159
+ {% block scripts %}
160
+ <script>
161
+ // 投资组合数据
162
+ let portfolio = [];
163
+ let portfolioAnalysis = null;
164
+
165
+ $(document).ready(function() {
166
+ // 从本地存储加载投资组合
167
+ loadPortfolio();
168
+
169
+ // 添加股票按钮点击事件
170
+ $('#add-stock-btn').click(function() {
171
+ addStockToPortfolio();
172
+ });
173
+ });
174
+
175
+ // 从本地存储加载投资组合
176
+ function loadPortfolio() {
177
+ const savedPortfolio = localStorage.getItem('portfolio');
178
+ if (savedPortfolio) {
179
+ portfolio = JSON.parse(savedPortfolio);
180
+ renderPortfolio(); // 先用缓存数据渲染一次,避免白屏
181
+
182
+ // 为每个股票获取最新数据
183
+ portfolio.forEach((stock, index) => {
184
+ fetchStockData(stock.stock_code);
185
+ });
186
+ }
187
+ }
188
+
189
+ // 渲染投资组合
190
+ function renderPortfolio() {
191
+ if (portfolio.length === 0) {
192
+ $('#portfolio-empty').show();
193
+ $('#portfolio-content').hide();
194
+ $('#portfolio-analysis').hide();
195
+ $('#portfolio-recommendations').hide();
196
+ return;
197
+ }
198
+
199
+ $('#portfolio-empty').hide();
200
+ $('#portfolio-content').show();
201
+ $('#portfolio-analysis').show();
202
+ $('#portfolio-recommendations').show();
203
+
204
+ let html = '';
205
+ portfolio.forEach((stock, index) => {
206
+ const scoreClass = getScoreColorClass(stock.score || 0);
207
+ const priceChangeClass = (stock.price_change || 0) >= 0 ? 'trend-up' : 'trend-down';
208
+ const priceChangeIcon = (stock.price_change || 0) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
209
+
210
+ // 显示加载状态或实际数据
211
+ const stockName = stock.loading ?
212
+ '<span class="text-muted"><i class="fas fa-spinner fa-pulse"></i> 加载中...</span>' :
213
+ (stock.stock_name || '未知');
214
+
215
+ const industryDisplay = stock.industry || '-';
216
+
217
+ html += `
218
+ <tr>
219
+ <td>${stock.stock_code}</td>
220
+ <td>${stockName}</td>
221
+ <td>${industryDisplay}</td>
222
+ <td>${stock.weight}%</td>
223
+ <td>${stock.price ? formatNumber(stock.price, 2) : '-'}</td>
224
+ <td class="${priceChangeClass}">${stock.price_change ? (priceChangeIcon + ' ' + formatPercent(stock.price_change, 2)) : '-'}</td>
225
+ <td><span class="badge ${scoreClass}">${stock.score || '-'}</span></td>
226
+ <td>${stock.recommendation || '-'}</td>
227
+ <td>
228
+ <div class="btn-group btn-group-sm" role="group">
229
+ <a href="/stock_detail/${stock.stock_code}" class="btn btn-outline-primary">
230
+ <i class="fas fa-chart-line"></i>
231
+ </a>
232
+ <button type="button" class="btn btn-outline-danger" onclick="removeStock(${index})">
233
+ <i class="fas fa-trash"></i>
234
+ </button>
235
+ </div>
236
+ </td>
237
+ </tr>
238
+ `;
239
+ });
240
+
241
+ $('#portfolio-table').html(html);
242
+ }
243
+
244
+ // 添加股票到投资组合
245
+ function addStockToPortfolio() {
246
+ const stockCode = $('#add-stock-code').val().trim();
247
+ const weight = parseInt($('#add-stock-weight').val() || 10);
248
+
249
+ if (!stockCode) {
250
+ showError('请输入股票代码');
251
+ return;
252
+ }
253
+
254
+ // 检查是否已存在
255
+ const existingIndex = portfolio.findIndex(s => s.stock_code === stockCode);
256
+ if (existingIndex >= 0) {
257
+ showError('此股票已在投资组合中');
258
+ return;
259
+ }
260
+
261
+ // 添加到投资组合
262
+ portfolio.push({
263
+ stock_code: stockCode,
264
+ weight: weight,
265
+ stock_name: '加载中...',
266
+ industry: '-',
267
+ price: null,
268
+ price_change: null,
269
+ score: null,
270
+ recommendation: null,
271
+ loading: true,
272
+ isNew: true // 标记为新添加的股票
273
+ });
274
+
275
+ savePortfolio();
276
+ $('#addStockModal').modal('hide');
277
+ $('#add-stock-form')[0].reset();
278
+ fetchStockData(stockCode);
279
+ }
280
+
281
+ // 添加重试加载功能
282
+ function retryFetchStockData(stockCode) {
283
+ showInfo(`正在重新获取 ${stockCode} 的数据...`);
284
+ fetchStockData(stockCode);
285
+ }
286
+
287
+ // 在渲染函数中添加重试按钮
288
+ html += `
289
+ <tr>
290
+ <td>${stock.stock_code}</td>
291
+ <td>${stockName} ${stock.stock_name === '获取失败' ?
292
+ `<button class="btn btn-sm btn-link p-0 ml-2" onclick="retryFetchStockData('${stock.stock_code}')">
293
+ <i class="fas fa-sync-alt"></i> 重试
294
+ </button>` : ''}
295
+ </td>
296
+ ...
297
+ `;
298
+
299
+ // 获取股票数据
300
+ function fetchStockData(stockCode) {
301
+ const index = portfolio.findIndex(s => s.stock_code === stockCode);
302
+ if (index < 0) return;
303
+
304
+ // 显示加载状态
305
+ portfolio[index].loading = true;
306
+ savePortfolio();
307
+ renderPortfolio();
308
+
309
+ $.ajax({
310
+ url: '/analyze',
311
+ type: 'POST',
312
+ contentType: 'application/json',
313
+ data: JSON.stringify({
314
+ stock_codes: [stockCode],
315
+ market_type: 'A'
316
+ }),
317
+ success: function(response) {
318
+ if (response.results && response.results.length > 0) {
319
+ const result = response.results[0];
320
+
321
+ portfolio[index].stock_name = result.stock_name || '未知';
322
+ portfolio[index].industry = result.industry || '未知';
323
+ portfolio[index].price = result.price || 0;
324
+ portfolio[index].price_change = result.price_change || 0;
325
+ portfolio[index].score = result.score || 0;
326
+ portfolio[index].recommendation = result.recommendation || '-';
327
+ portfolio[index].loading = false;
328
+
329
+ savePortfolio();
330
+ analyzePortfolio();
331
+
332
+ // 只在添加新股票时显示成功消息
333
+ if (portfolio[index].isNew) {
334
+ showSuccess(`已添加 ${result.stock_name || stockCode} 到投资组合`);
335
+ portfolio[index].isNew = false;
336
+ }
337
+ } else {
338
+ portfolio[index].stock_name = '数据获取失败';
339
+ portfolio[index].loading = false;
340
+ savePortfolio();
341
+ renderPortfolio();
342
+ showError(`获取股票 ${stockCode} 数据失败`);
343
+ }
344
+ },
345
+ error: function(error) {
346
+ portfolio[index].stock_name = '获取失败';
347
+ portfolio[index].loading = false;
348
+ savePortfolio();
349
+ renderPortfolio();
350
+ showError(`获取股票 ${stockCode} 数据失败`);
351
+ }
352
+ });
353
+ }
354
+
355
+ // 从投资组合中移除股票
356
+ function removeStock(index) {
357
+ if (confirm('确定要从投资组合中移除此股票吗?')) {
358
+ portfolio.splice(index, 1);
359
+ savePortfolio();
360
+ renderPortfolio();
361
+ analyzePortfolio();
362
+ }
363
+ }
364
+
365
+ // 保存投资组合到本地存储
366
+ function savePortfolio() {
367
+ localStorage.setItem('portfolio', JSON.stringify(portfolio));
368
+ renderPortfolio();
369
+ }
370
+
371
+
372
+ // 分析投资组合
373
+ function analyzePortfolio() {
374
+ if (portfolio.length === 0) return;
375
+
376
+ // 计算投资组合评分
377
+ let totalScore = 0;
378
+ let totalWeight = 0;
379
+ let industriesMap = {};
380
+
381
+ portfolio.forEach(stock => {
382
+ if (stock.score) {
383
+ totalScore += stock.score * stock.weight;
384
+ totalWeight += stock.weight;
385
+
386
+ // 统计行业分布
387
+ const industry = stock.industry || '其他';
388
+ if (industriesMap[industry]) {
389
+ industriesMap[industry] += stock.weight;
390
+ } else {
391
+ industriesMap[industry] = stock.weight;
392
+ }
393
+ }
394
+ });
395
+
396
+ // 确保总权重不为零
397
+ if (totalWeight > 0) {
398
+ const portfolioScore = Math.round(totalScore / totalWeight);
399
+
400
+ // 更新评分显示
401
+ $('#portfolio-score').text(portfolioScore);
402
+
403
+ // 简化的维度评分计算
404
+ const technicalScore = Math.round(portfolioScore * 0.4);
405
+ const fundamentalScore = Math.round(portfolioScore * 0.4);
406
+ const capitalFlowScore = Math.round(portfolioScore * 0.2);
407
+
408
+ $('#technical-score').text(technicalScore + '/40');
409
+ $('#fundamental-score').text(fundamentalScore + '/40');
410
+ $('#capital-flow-score').text(capitalFlowScore + '/20');
411
+
412
+ $('#technical-progress').css('width', (technicalScore / 40 * 100) + '%');
413
+ $('#fundamental-progress').css('width', (fundamentalScore / 40 * 100) + '%');
414
+ $('#capital-flow-progress').css('width', (capitalFlowScore / 20 * 100) + '%');
415
+
416
+ // 更新投资组合评分图表
417
+ renderPortfolioScoreChart(portfolioScore);
418
+
419
+ // 更新行业分布图表
420
+ renderIndustryChart(industriesMap);
421
+
422
+ // 生成投资建议
423
+ generateRecommendations(portfolioScore);
424
+ }
425
+ }
426
+
427
+ // 渲染投资组合评分图表
428
+ function renderPortfolioScoreChart(score) {
429
+ const options = {
430
+ series: [score],
431
+ chart: {
432
+ height: 150,
433
+ type: 'radialBar',
434
+ },
435
+ plotOptions: {
436
+ radialBar: {
437
+ hollow: {
438
+ size: '70%',
439
+ },
440
+ dataLabels: {
441
+ show: false
442
+ }
443
+ }
444
+ },
445
+ colors: [getScoreColor(score)],
446
+ stroke: {
447
+ lineCap: 'round'
448
+ }
449
+ };
450
+
451
+ // 清除旧图表
452
+ $('#portfolio-score-chart').empty();
453
+
454
+ const chart = new ApexCharts(document.querySelector("#portfolio-score-chart"), options);
455
+ chart.render();
456
+ }
457
+
458
+ // 渲染行业分布图表
459
+ function renderIndustryChart(industriesMap) {
460
+ // 转换数据格式为图表所需
461
+ const seriesData = [];
462
+ const labels = [];
463
+
464
+ for (const industry in industriesMap) {
465
+ if (industriesMap.hasOwnProperty(industry)) {
466
+ seriesData.push(industriesMap[industry]);
467
+ labels.push(industry);
468
+ }
469
+ }
470
+
471
+ const options = {
472
+ series: seriesData,
473
+ chart: {
474
+ type: 'pie',
475
+ height: 300
476
+ },
477
+ labels: labels,
478
+ responsive: [{
479
+ breakpoint: 480,
480
+ options: {
481
+ chart: {
482
+ height: 200
483
+ },
484
+ legend: {
485
+ position: 'bottom'
486
+ }
487
+ }
488
+ }],
489
+ tooltip: {
490
+ y: {
491
+ formatter: function(value) {
492
+ return value + '%';
493
+ }
494
+ }
495
+ }
496
+ };
497
+
498
+ // 清除旧图表
499
+ $('#industry-chart').empty();
500
+
501
+ const chart = new ApexCharts(document.querySelector("#industry-chart"), options);
502
+ chart.render();
503
+ }
504
+
505
+ // 生成投资建议
506
+ function generateRecommendations(portfolioScore) {
507
+ let recommendations = [];
508
+
509
+ // 根据总分生成基本建议
510
+ if (portfolioScore >= 80) {
511
+ recommendations.push({
512
+ text: '您的投资组合整体评级优秀,当前市场环境下建议保持较高仓位',
513
+ type: 'success'
514
+ });
515
+ } else if (portfolioScore >= 60) {
516
+ recommendations.push({
517
+ text: '您的投资组合整体评级良好,可以考虑适度增加仓位',
518
+ type: 'primary'
519
+ });
520
+ } else if (portfolioScore >= 40) {
521
+ recommendations.push({
522
+ text: '您的投资组合��体评级一般,建议持币观望,等待更好的入场时机',
523
+ type: 'warning'
524
+ });
525
+ } else {
526
+ recommendations.push({
527
+ text: '您的投资组合整体评级较弱,建议减仓规避风险',
528
+ type: 'danger'
529
+ });
530
+ }
531
+
532
+ // 检查行业集中度
533
+ const industries = {};
534
+ let totalWeight = 0;
535
+
536
+ portfolio.forEach(stock => {
537
+ const industry = stock.industry || '其他';
538
+ if (industries[industry]) {
539
+ industries[industry] += stock.weight;
540
+ } else {
541
+ industries[industry] = stock.weight;
542
+ }
543
+ totalWeight += stock.weight;
544
+ });
545
+
546
+ // 计算行业集中度
547
+ let maxIndustryWeight = 0;
548
+ let maxIndustry = '';
549
+
550
+ for (const industry in industries) {
551
+ if (industries[industry] > maxIndustryWeight) {
552
+ maxIndustryWeight = industries[industry];
553
+ maxIndustry = industry;
554
+ }
555
+ }
556
+
557
+ const industryConcentration = maxIndustryWeight / totalWeight;
558
+
559
+ if (industryConcentration > 0.5) {
560
+ recommendations.push({
561
+ text: `行业集中度较高,${maxIndustry}行业占比${Math.round(industryConcentration * 100)}%,建议适当分散投资降低非系统性风险`,
562
+ type: 'warning'
563
+ });
564
+ }
565
+
566
+ // 检查需要调整的个股
567
+ const weakStocks = portfolio.filter(stock => stock.score && stock.score < 40);
568
+ if (weakStocks.length > 0) {
569
+ const stockNames = weakStocks.map(s => `${s.stock_name}(${s.stock_code})`).join('、');
570
+ recommendations.push({
571
+ text: `以下个股评分较低,建议考虑调整:${stockNames}`,
572
+ type: 'danger'
573
+ });
574
+ }
575
+
576
+ const strongStocks = portfolio.filter(stock => stock.score && stock.score > 70);
577
+ if (strongStocks.length > 0 && portfolioScore < 60) {
578
+ const stockNames = strongStocks.map(s => `${s.stock_name}(${s.stock_code})`).join('、');
579
+ recommendations.push({
580
+ text: `以下个股表现强势,可考虑增加配置比例:${stockNames}`,
581
+ type: 'success'
582
+ });
583
+ }
584
+
585
+ // 渲染建议
586
+ let html = '';
587
+ recommendations.forEach(rec => {
588
+ html += `<li class="list-group-item list-group-item-${rec.type}">${rec.text}</li>`;
589
+ });
590
+
591
+ $('#recommendations-list').html(html);
592
+ }
593
+
594
+ // 获取评分颜色
595
+ function getScoreColor(score) {
596
+ if (score >= 80) return '#28a745'; // 绿色
597
+ if (score >= 60) return '#007bff'; // 蓝色
598
+ if (score >= 40) return '#ffc107'; // 黄色
599
+ return '#dc3545'; // 红色
600
+ }
601
+ </script>
602
+ {% endblock %}