From 569c1c881394d4ec888f85be79a3f8afb2dd2e44 Mon Sep 17 00:00:00 2001 From: ycg <3208975282@qq.com> Date: Mon, 1 Dec 2025 15:44:25 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E8=82=A1=E7=A5=A8=E7=9B=91?= =?UTF-8?q?=E6=8E=A7=E7=B3=BB=E7=BB=9F=EF=BC=9A=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E5=8D=87=E7=BA=A7=E4=B8=8E=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构数据访问层:引入DAO模式,支持MySQL/SQLite双数据库 - 新增数据库架构:完整的股票数据、AI分析、自选股管理表结构 - 升级AI分析服务:集成豆包大模型,支持多维度分析 - 优化API路由:分离市场数据API,提供更清晰的接口设计 - 完善项目文档:添加数据库迁移指南、新功能指南等 - 清理冗余文件:删除旧的缓存文件和无用配置 - 新增调度器:支持定时任务和数据自动更新 - 改进前端模板:简化的股票展示页面 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .claude/settings.local.json | 34 + .dockerignore | 10 + 16915e87-8099-402c-80d4-f4018d56dcf9.png | Bin 65747 -> 0 bytes CLAUDE.md | 155 ++ README.md | 348 ++- app/__init__.py | 4 +- app/api/market_routes.py | 355 +++ app/api/stock_routes.py | 4 + app/dao/__init__.py | 17 + app/dao/ai_analysis_dao.py | 219 ++ app/dao/base_dao.py | 114 + app/dao/config_dao.py | 171 ++ app/dao/stock_dao.py | 208 ++ app/dao/watchlist_dao.py | 172 ++ app/models/__init__.py | 1 + app/scheduler.py | 403 +++ ...s_service.py => ai_analysis_service_db.py} | 578 ++-- app/services/kline_service.py | 466 ++++ app/services/market_data_service.py | 400 +++ app/services/stock_service.py | 587 ---- app/services/stock_service_db.py | 603 +++++ app/templates/stocks_simple.html | 678 +++++ .../json_backup_20251124_093028/config.json | 22 + docs/database/apply_extended_schema.py | 124 + docs/database/database_schema.sql | 137 + docs/database/database_schema_extended.sql | 213 ++ docs/database/database_schema_simple.sql | 128 + docs/database/init_database.py | 159 ++ docs/database/migrate_to_database.py | 325 +++ docs/database/test_database.py | 239 ++ docs/guides/DATABASE_MIGRATION_GUIDE.md | 182 ++ docs/guides/NEW_FEATURES_GUIDE.md | 237 ++ dokcer/DockerFile | 7 - run.py | 4 +- stock_cache.json | 2376 ----------------- 项目文档.txt | 193 -- 36 files changed, 6224 insertions(+), 3649 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .dockerignore delete mode 100644 16915e87-8099-402c-80d4-f4018d56dcf9.png create mode 100644 CLAUDE.md create mode 100644 app/api/market_routes.py create mode 100644 app/dao/__init__.py create mode 100644 app/dao/ai_analysis_dao.py create mode 100644 app/dao/base_dao.py create mode 100644 app/dao/config_dao.py create mode 100644 app/dao/stock_dao.py create mode 100644 app/dao/watchlist_dao.py create mode 100644 app/models/__init__.py create mode 100644 app/scheduler.py rename app/services/{ai_analysis_service.py => ai_analysis_service_db.py} (56%) create mode 100644 app/services/kline_service.py create mode 100644 app/services/market_data_service.py delete mode 100644 app/services/stock_service.py create mode 100644 app/services/stock_service_db.py create mode 100644 app/templates/stocks_simple.html create mode 100644 backup/json_backup_20251124_093028/config.json create mode 100644 docs/database/apply_extended_schema.py create mode 100644 docs/database/database_schema.sql create mode 100644 docs/database/database_schema_extended.sql create mode 100644 docs/database/database_schema_simple.sql create mode 100644 docs/database/init_database.py create mode 100644 docs/database/migrate_to_database.py create mode 100644 docs/database/test_database.py create mode 100644 docs/guides/DATABASE_MIGRATION_GUIDE.md create mode 100644 docs/guides/NEW_FEATURES_GUIDE.md delete mode 100644 dokcer/DockerFile delete mode 100644 stock_cache.json delete mode 100644 项目文档.txt diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..96abb51 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,34 @@ +{ + "permissions": { + "allow": [ + "Bash(docker-compose:*)", + "Bash(docker run:*)", + "Bash(docker rmi:*)", + "WebFetch(domain:github.com)", + "mcp__mcp-all-in-one__mcp-all-in-one-validate-mcp-config", + "Bash(mysql:*)", + "mcp__mcp-all-in-one__mcp-all-in-one-show-mcp-config", + "mcp__mcp-all-in-one__mcp-all-in-one-set-mcp-config", + "Bash(python:*)", + "Bash(curl:*)", + "Bash(tasklist:*)", + "Bash(findstr:*)", + "Bash(dir:*)", + "mcp__chrome-devtools__take_snapshot", + "mcp__chrome-devtools__list_console_messages", + "mcp__chrome-devtools__navigate_page", + "Bash(taskkill:*)", + "Bash(netstat:*)", + "mcp__chrome-devtools__new_page", + "mcp__chrome-devtools__list_pages", + "Bash(timeout:*)", + "Bash(ping:*)", + "mcp__chrome-devtools__click", + "Bash(rm:*)", + "Bash(mkdir:*)", + "Bash(mv:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8494696 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +venv/ +.git/ +.gitignore +README.md +docs/ +.idea/ +*.png +项目文档.txt +stock_cache.json +watchlist.json \ No newline at end of file diff --git a/16915e87-8099-402c-80d4-f4018d56dcf9.png b/16915e87-8099-402c-80d4-f4018d56dcf9.png deleted file mode 100644 index 69f6e20d4b44e13a4781c15fe989dc5739a3287c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65747 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ν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 z z{o{v-knXbMeaBfSe8Z+NGmg6>Z>R4+=8$15KIs(Vl5NpJXYA&>U)QuI0pbzkB$sUn z(6O_yw0kwHVf*=>3-Gu}QKj`9H3XQNkon{77;}PKXS*eeIIRf?zbh7F!9G8!DS1{k$%{`mYuUp!^X7~q$h8z74ezt z|8d;-5tNIo7_}zAZS}!cRP?KWhP#Y^iG-tXYAW%;ZAWi=uj{GHn@~vkkn>aQkIO#_ zQ@x~x#D!?wzH^iN;{g~RZ_&Yeic0;55?cM2dXIF|ft6bikbLj-c z)~LrlSxC#|MAnF6g|*PR?lLxpoF zbMZZW$LIiFd&&r;FT>k=B=lDw0*b=ESJ9KA#Q?eQv#0lzP8Yw5^>27++PXjGVY?bZ zPgz5z$L}Y?bs4RVjn*qKszzk5K`y;i5XRGv_U;5}k6c`_R*?>~3v2B;R<5pN-&#T; zQ_D)SFImBScib#|he!@o`~wu8k1ua#vRLfnKDVKC&3Q~Y)6&agR6)yWzI{e>f9**JRoQ{KsK zlLgq?KT}e{iGwiu5%=R^OW-3Za%H7d|Alcx#{c937_K}ov?Gfi@$u1Io06hQ0lCL; zSLbopsZZK&*QNc9{3>Un8%Aj%QZh0GHoOjsw$vI-s!(Cm-F1XYR>@LLD8XbQ(NIyE z(8;B+C3U@uw!T-l`0M$GymfLyO?Qc~8=xU%Y9cZ3{{7ZX*k6+%r#1W^^Q|EBKIAdq z)EwrCQoIVnNqXOvN+^{*dW(!SVQNl&ywKNJ44_!?bQ`cgh(-(ivhI+%1hbkUKCRUw{hn0+HXRtU6gl$7jZUe_Tdnn z(0YZI!fjF0c}a1hG&XIBhVJyGA{CddRW=oL#<{dI2}HPv2&!T$#t(6uk!7Xx>2BFW zU>6AjZ^d8jq0D?HJq-pMjQ%<+$o$SrhjXdeJE@q{@cKn6p)N@TUr^AXWEKnHd%qjs5Y}v6Pja_8-BW0CoUCaC{SSAHGbtH{;TyB7ft?QH&#O{mO(3*}kmS z#Wc3`c~FAu5Zl4L&!^MnunZ01|DnbMzQo(YT;=XB`Tzqg1cpbKD-^B}*qDy%Z1dD8Q!Q+6g-^GZSt1vOE)l!K z2!@+}3$`yU1P#A^51HPTV-x*1Qhz%1-03G=qu-onnWFwGc1O) zK=M@H(9r4VuAuYPG*TcJljLEI-HmEjwZrD&pA?)w@->vZ;FqMouY@IPDlR@=EO2_Tn-lNC<1*>%N^y zH9xA{gRKL|fl~A<=+)Tj9=o2gyW7A^Prr)KRsZYNf3qK&@7WrjS$7Qll5r8jsEO(3jsWr$#U zn(rbiYSW=v<8b2=(Ir~N|L(^Fr@{ZaC-D34-krSbQRh+5jJof_Ia2%81JttqUKWI2 zv5>K?1O+%L3ua$|-0yTE;*TvcgT91yTPk75J?Bj!$jqY;v+HU;I zD<4_to4n6ZP~*%Cy^?RA9%TjuSTYLNz2Epe>uABBjaTc!sZq-^smRh9H9uK-!l4%_ zY={2vF~eR`VF=2@YcnF~CY6x%6-I?EaK<6hLAtd(obItHIR=1_BbQG196?HsSSlgw4af9 zO+H-)y6Qh;fzo{PRJ_vCBauZR{iOh$kB*z~DGB7UgcxYD7(~W%*qFIvw}ov$yQP+L zdmv+d6L@TgnU^Go+OGruhEJ7B@Z+nc4*n<+8^|yAT8bJdil719Zey%iZM6F>JyK-f z!fu1E=5kPE2RA=}s{(A>wh?Diwl3uu2v$1MDV2dN&f-pQ1m^+DnFBnmP<=jK#U0%@%>rgRCvAfw#d&IbAA06 z=BIyvcMjG+MiUuXQITnhh$&kaa&XKf*?QUAfaczTFY^E;Q4ya{NXYK(754lXI8aZ; zV;I6Q4kvUbTOw27Z2NLo!8K78u)Z$-9(`qIgWY=JP@R?jYWTr)wwIWaShd}b6JoSe za{&eJ6pcAQc3NLLy4YVAfMy+^&K}yEegQS8p0F5)7Wk+z4EL-`~*{_KW;o9}Z{j<2N`-YPUusJCK1?^X^%}bGzER7+vw><^HGx59d!8pTE}E z@AdW-+bqM*j^fblbuz1iJR*4OE^)}2-~y(K7*gn3>S42u~igwI4At-X6kXva|nC^JJjM%}jC51GQ!`$)=*S;imIbQ(QB-&!y>tkfb~K zsihW*n0=nuj`gs3Bmo5D-!mmp-qTwk(R*9v^4QfV(Un($553mf>sywMCy_LSdMLoL z9bSHjuyZ}7Jcq;f&;(^If|U9F`Fi=X$jz>qI+n$+=-8F~so02)jh-YW6*?M;@yg@! zXS4$7MJl`bmydwP`n17>i{L?=4UmlnPiC0Q5v2B+hdgq2WhcXclctMu_~#`W9Zd~?@(}>D z?|F^xKnYs=B&-Rr;`8U@8#jQHoSO$Y{ma1tnjK@kEyl=1C@h?!%y5<`bp>t4U9V0i zV^`N`r}emRQXdLd=IvCYZUC7Szk_(!f?|JtBA|O0r>U!}b%7c@jV(wo&LSomS*W9~ zwCTs|l?;jWgkaDRh=h8sj!m%_t ziXl;tl1p$svm}1y14ua=i+%{H@eBk-ud)TNii_Ng=hMcU1 z?%oYF>DXn6znT}q{Jh5I!AUk((>!6-ILTS%zwra*k&LU#$ZwYdYPoNzIs|ZfF9=1{ znogFymX(Y6^=jzr*Py}Ne}ltnaS;p(h!HYU;ZIXK_dG7*a4IfFP%H6&+{wr^)&oxJ z_^bE7KI|&l_Rbc76d5*dlNc5jmXE$7&X3|KYn}lup)a~l#k+uUq)dRYztLgeGvAD#&FoKAzraBfHMX5gV`17leDfv0qAwdQ@z zYh&JdljTi|dTp-z=E`XWiZR?hdY^h4IKkvuBnjS~WokKcf-ah33#{tLn_mwExStg{ zAeF^?Ki+11#^UDMq)DPqR4c#QT`MQEoaurSnWgs5>)rIgj*j06KI{em8RdEHu~LMw zhhzFngh4UiB6~sBZ6GKXe@v5hGNRQCk)qHVarS4SAmb2rie{}0se9Btjm~x$a&AqiT?YzE`ok*K?9>hRW zpLqgQ99&_QM`+{Y<{T^3iL5vBfl^nWH$ocW`@$a?AdpQ~z%0Q81}{gm*vR;i6X&u= za`GA{uLG+@8EN)bhKLACF)fr55kRQ_M&=87oQs%cNBTUpkVCe`0BMaH!KjQ4lSF8Y zD^Q-Fhum}drKZ;=yT3G4l1zhr$3ZSesflWh2tyXlWao73sE!1kv!&3c7qoU@Nm2Fo z7JuZHBYMs)*T*d<=I-|1OzE`$>>vamw~JZkbOJ6H`(kwGNa!G*n&4=0!J5}Gs2q(E z&*H_9k&zsPN`vXMziU?ZVJ2$wzL@(FJa*rV@BJMk2C{c+-4So+`bpWimw7W{@N@5R za&h&4l?Wn_a_9L~w~%Xb3j$f#(m)osbky-V9_M_U(=IPJYi-+EWY1W^J|P^qQMw{v zVsGVV$TQxLXKX{lq}=;i6tM9O6Z(0bNbql9!GerzalQtvQ`I@zzLe<_9ToqLE(~s- zn)50St(ehSa|dt$#8MA7Z0sak!BQR%pPguI?Co2ruYbJ<8crgFv?A8p|8B){N;~Lm zjE^1G#pdP$!;lEwfiDScj!)woqAC~572=^g&3Y4Tf7q8CbbQfhfdh8z2u84+6DvPs z)e5^w=EpDlFZ!hRf-aCd>n9XA+c+}X`Br_qQHAWEl()A7yA{t0g`}5TWou$I%I%1=m6l~7|CHvl<4g=9#dr@C0LBBxYB!yHxAulH7^Pj z;^|(*S}vvZ7NuP&$$p3fl(}PnNIWby+qtT>eD+?pC9rrIWOon|DNP%NPu&=)sgLbb za`FJJ6Pw?|Y6wdLNY?@`=;e_+2$Fwehu?=X*T1m)GQEhb<9uh)CjGoqF z4ItVt<<;pw!XV+-u>doy9q_(O#*%D(9r8ZqD9 z$Ojs9Srfc$!69PmvTAQ4y)u|bEt<=A$rck@2i-{csd~i%`B0$Rzh}*}9}bH~hVptk z&!^%Je6A+a#;e9d$m?w1Z8Sc0NZSqB*5(oJQ(pX2fBWM6z`I3a4(m*YO;bnC(eXTN zn8)r=O9z3P`Efo-J?6YHoj=_6P^Pt-sHluc0G_r!0H*BXfPIw6OV0*X=Y{DhD4_ zY2|(JMK)eivHG9>Lh;-&ozvfEs-!?(K9GPaPz0fg_!Gs(rbab;dH>KyWYIVXRrD)} zncQN{xISc z`z>%OJ3vY^cpuHbZ~j#~+i461H;m=&AK(=s4NX^rOVI|zOK==iY9-6B)thT*(@UV8mNp(PG*a|EM%VFHL;!HWKT zu^ANuLMsKO_t(`+YFJlLJQSwgf@o)PtZo^lM z7Qs^!JSHuE;4)b+p2(YGZku8wi%XG_$AW!bj?TO(r;&?AYp5n+Dhs>JP+Kd*Y94e2 z_uz}w7iXU;_opwc@^Kw9qfl^53RK3+cU=_A{&M{)I6Iv~%l(cIJNodQYwlB!7rjpR z`<4;aZx%m`L=4?-XFFF@I^ec^6#}@F_|feVl=)4ZjjX{MvUr#5tIj-{D>*+e{cn_Y z8KqobvhNSLy;9a-ZFEms#MZKetB$WjP*zIb;)6fyWa8@!W|McwoMLOa2PY0-@MEm~ z1$})6pZue?w)A=pxj*$C1N`%TSq7@{j_3vVi_L`yAw!6_ie!|mjt2YA!h^bV5)cw?;@QcSR9?xX3CaL7C z4tAC=(!XF5EvFqQcLG?HY80YB`0)&E{@PFxrPD!-{L>e2DGqe)a(3T@|8*LU z<>AP)wRYoZCY$?Ch>$;sJNF!M|LuZ8Vj%-1DPB;4Y z#m3^lqV&u71bHK>NS!Nr!x+*m&hm%eeR*`CyR~}|WR2T}aU38z+f95xkaZBmQOoz% zX_>M|t#0EYFymmYCxo~w>)!<4S$ph+Ptz>8UJx@>FHy4%1hNR$#Q(?&a_wU0)e|!U zAs30=if>D7r27>b zq$(r&GHuGyyOl_=*5)S9(0+a67LHV>*nrTsDl6S&f{hWe>?A&&wFH&^o^P|)%olka zJ>6pICBBOq)Rn>o3YUP?o_?$w@=-|!7uw=*f36LIg>Vo^Gk{U)TZzNQ2Yw39&xcS$ z!nR@7-Ta$t>ozlIY&7hdBndk{mB$k0A9suo4eIhkEYEDa%ixA5rvSlA9f(5<=) zyD$LtCmcWi#LS@0T2?}U;UFiogHUO|u1S$Lp`{+77N$oa#6=B_Y@8f!zJtx#5KEya zPMTTnGM9roY@Ny6cPD5rKUjTzPvg{ROEoq0O#Ah{3YEjY-Q)SWg}*z@rve@~`UGq8 zI={0o)&Qh8Q`1c6&0^>c%wp2bW4@gp{_0LWG~XA0%btm(X%4}67m7@m)3Obbe#S>J zk&B?FNefZ1=;ZK;zjfK~)1%u`ZQO)x4-1@8O_=ck>^RwYMhp}#C4yV2(C*#X+TOPH}GOq1JI z^|-UT`V_;Jvhk=p-%$O8G@i<3_0TV8b*2eI)Epws7r6_d0`Jl*JQ_;|wb=}%F{WY2 zH{?+tSs3Tll~%e{O^CtT_&~yC2^(iX%e8liNUDZtH&%C+Xus-` zpQ+3t;7ofGUFn7)@#C_S1vY2tjw`$s36YbSI1cyrT6U+pm&YR-`L#uci+L@+__D&` zajxqQtAiowZ^)7BdgV_vZ}5e>L)aZOpoH08TbF)}2ix;XzE=xQ4vH7kK94&=x% zf?U%2K%>@RfQss|iUCZ}X+`p>Q~siM`DW~T*)1Nk>EOWSLQs}qSHC@h^qiH0qoBPN zHlI|FU!Jo38z0XAMhUirZPA3#-@iwjHspqjd0*e^eu|^#x*Pi1H<*qJ8m66TH=%JJ zA}l1Zv^D*^fghE>xiAIWYg={53j7%rB@BDhpC9wW#csFw0DXqVqcNH4Qk8g{^%$?* zc`(l9HJ;{89_3WQe!)W17 zq^XHpFOMg!Xmbux1vLcmdO)hVLBC_&qG)OHP?5ZMd%i(b76O}FEAjXiEI zX69!ZHoE6&(+a_(g@!|bKpk-8qTjlnTWG<@rruzEk}}zG`P5V&)EcL8Z~37@S+)cQ zd@eAP*|K!GB|E)t;@#Pv1LF94q@njHN7qMU6&D&|Dc@yomInr|h8UpQW4PD>Pweax zvH}VCX3sV2=jA z(nB3cQ#Xl^z@Mo_@H%6S9DWU1GDB+NUd zMJ(bltqnL0j?1on^g)ZYwNnV=0Kfp3qbL{}4PSHpD;w9A+5QtM?Z}%#FSqlkg;I%% zO2}&Zi&_4~^GliIXBA|Y?Bga71v}?HvAGs3ANnSRagU3`Uz^q2ed>VW$1BmgaRI5Q zve=yK1}KEYZE>HUkZa-KMToSxyjY&d H`!D|oF?}V7 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0b5f154 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,155 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 常用命令 + +### 开发环境 +```bash +# 安装依赖 +pip install -r requirements.txt + +# 启动开发服务器 +python run.py + +# 初始化数据库(首次使用) +python init_database.py + +# 数据迁移(从JSON文件迁移到数据库) +python migrate_to_database.py +``` + +### 生产环境部署 +```bash +# Docker构建和运行 +docker-compose up -d + +# 查看运行状态 +docker-compose ps + +# 查看日志 +docker-compose logs stock-monitor +``` + +### 测试和调试 +```bash +# 查看API文档 +# 访问 http://localhost:8000/docs + +# 测试数据库连接 +python -c "from app.dao import StockDAO; print('数据库连接成功')" + +# 测试Tushare API +python -c "import tushare as ts; ts.set_token('your_token'); print(ts.pro_api().trade_cal(exchange='SSE', start_date='20240101', end_date='20240105'))" +``` + +## 核心架构 + +### 技术栈 +- **后端**: FastAPI + SQLAlchemy + MySQL/SQLite +- **前端**: HTML5 + CSS3 + JavaScript + Bootstrap + ECharts +- **数据源**: Tushare API (股票数据) + 豆包大模型 (AI分析) +- **部署**: Docker + Uvicorn + +### 项目结构 +``` +stock-monitor/ +├── app/ # 应用主目录 +│ ├── api/ # API路由层 +│ │ ├── stock_routes.py # 股票相关API +│ │ └── market_routes.py # 市场数据API +│ ├── services/ # 业务逻辑层 +│ │ ├── stock_service_db.py # 股票数据服务 +│ │ ├── ai_analysis_service_db.py # AI分析服务 +│ │ ├── market_data_service.py # 市场数据服务 +│ │ └── kline_service.py # K线数据服务 +│ ├── dao/ # 数据访问层 (DAO模式) +│ │ ├── base_dao.py # 基础DAO类 +│ │ ├── stock_dao.py # 股票数据DAO +│ │ ├── watchlist_dao.py # 自选股DAO +│ │ ├── ai_analysis_dao.py # AI分析DAO +│ │ └── config_dao.py # 配置DAO +│ ├── models/ # 数据模型 +│ └── templates/ # HTML模板 +├── config.template.py # 配置文件模板 +├── database_schema.sql # MySQL数据库表结构 +├── init_database.py # 数据库初始化脚本 +└── run.py # 应用启动脚本 +``` + +### 数据架构 +- **MySQL生产数据库**: 包含stocks、stock_data、watchlist、ai_analysis等核心表 +- **SQLite开发数据库**: 用于本地开发和测试 +- **JSON缓存**: 历史数据使用JSON文件缓存 +- **DAO模式**: 统一的数据访问接口,便于数据库切换 + +### 核心功能模块 + +#### 1. 股票数据管理 (app/services/stock_service_db.py) +- 从Tushare API获取实时股票数据 +- 数据缓存机制(优先从数据库读取) +- 支持强制刷新和增量更新 + +#### 2. AI智能分析 (app/services/ai_analysis_service_db.py) +- 集成豆包大模型进行股票分析 +- 支持多维度分析:价值投资、道德经视角、投资大师分析 +- 分析结果持久化存储 + +#### 3. 自选股管理 (app/services/watchlist_service.py) +- 添加/删除自选股票 +- 设置市值预警范围 +- 目标市值监控 + +#### 4. 市场数据服务 (app/services/market_data_service.py) +- 沪深指数实时行情 +- K线图表数据 +- 板块涨跌排行 + +### API设计 +- RESTful API设计 +- 自动生成API文档 (`/docs`) +- 统一错误处理和响应格式 +- 支持CORS跨域请求 + +### 配置管理 +- **Tushare Token**: 需要在config.py中配置 +- **豆包大模型API**: 需要配置API Key和模型ID +- **数据库配置**: 支持MySQL和SQLite切换 +- **环境变量**: 支持通过环境变量覆盖配置 + +## 重要说明 + +### 数据获取限制 +- Tushare API有调用频率限制,建议适当控制请求频率 +- 免费账户每分钟最多调用200次 +- 生产环境建议使用付费套餐 + +### AI分析成本 +- 豆包大模型按量计费,有免费额度 +- 超出免费额度后按实际使用量收费 +- 建议合理控制AI分析次数 + +### 数据免责声明 +- 项目提供的股票数据仅供参考,不构成投资建议 +- 股票投资有风险,入市需谨慎 +- AI分析结果基于公开数据和模型计算 + +### 开发建议 +- 首次使用需要先执行`python init_database.py`初始化数据库 +- 开发时可使用SQLite数据库,生产环境推荐MySQL +- 建议监控股票数量不超过30只以保证性能 +- 定时刷新间隔建议60秒以上 + +## 常见问题 + +### Q: Tushare Token如何获取? +A: 访问 https://tushare.pro 注册账号并获取API Token + +### Q: 豆包大模型API如何配置? +A: 访问火山引擎控制台创建应用,获取API Key和模型接入点ID + +### Q: 如何切换数据库? +A: 修改config.py中的数据库连接配置,重新执行init_database.py + +### Q: Docker部署端口冲突? +A: 修改docker-compose.yml中的端口映射,如将"15348:8000"改为其他端口 \ No newline at end of file diff --git a/README.md b/README.md index db0813c..c5a618c 100644 --- a/README.md +++ b/README.md @@ -1,165 +1,271 @@ -# AI价值投资盯盘系统 +# 📈 股票智能监控系统 -一个基于Python的A股智能监控和AI分析系统,集成了多维度的投资分析功能,包括传统价值投资分析、道德经智慧分析以及知名投资大师的视角分析。 +一个基于FastAPI和AI技术的实时股票监控与分析平台,提供股票行情展示、智能分析、自选股管理等功能。 -## 系统界面预览 -![系统主界面](docs/images/main.png) -![输入图片说明](docs/images/image2.png) -![输入图片说明](docs/images/image.png) -## 🚀 核心功能 +## ✨ 功能特性 -### 1. 股票监控 -- 实时股票数据监控 -- 自定义目标市值设置 -- 目标市值预警(高估,低估,合理) -- 多维度指标展示(PE、PB、ROE等价值投资指标) +### 🎯 核心功能 +- **实时股票监控**: 支持A股市场实时行情数据 +- **智能AI分析**: 集成豆包大模型,提供专业的股票分析和投资建议 +- **自选股管理**: 添加/删除自选股票,设置市值预警范围 +- **指数行情**: 沪深指数实时行情展示 +- **多维分析**: 价值投资分析、道德经视角、投资大师分析 -### 2. 指数行情 -- 主要指数实时行情展示 +### 📊 数据来源 +- **Tushare API**: 专业的金融数据接口,提供准确的中国股市数据 +- **实时行情**: 支持实时价格、成交量、涨跌幅等关键指标 +- **历史数据**: 提供历史K线数据用于技术分析 +### 🤖 AI智能分析 +- 集成豆包大模型(Volces API) +- 自动生成股票分析报告 +- 提供投资建议和风险评估 +- 支持多种分析维度: + - **传统价值投资分析**: 财务指标、估值分析、风险评估 + - **道德经智慧分析**: 企业道德评估、可持续发展分析 + - **投资大师视角**: 巴菲特、格雷厄姆、林园等大师分析方法 -### 3. AI智能分析 -- 基础价值投资分析 - - 财务指标分析 - - 估值分析 - - 风险评估 - - 投资建议 -- 道德经分析视角 - - 企业道德评估 - - 可持续发展分析 - - 长期投资价值判断 -- 国内外价值投资大咖分析 - - 巴菲特视角 - - 格雷厄姆视角 - - 林园视角 - - 李大霄视角 - - 段永平视角 +## 🏗️ 技术架构 -### 4. 数据管理 -- 本地数据缓存 -- 历史数据查询 -- 分析报告导出 -- 自动数据更新 +### 后端技术栈 +- **FastAPI**: 现代、快速的Python Web框架 +- **SQLAlchemy**: ORM数据库操作 +- **SQLite**: 轻量级数据库存储 +- **Tushare**: 金融数据获取 +- **OpenAI SDK**: AI大模型接口 -## 🛠️ 技术栈 +### 前端技术栈 +- **HTML5 + CSS3**: 现代化界面设计 +- **JavaScript**: 交互逻辑实现 +- **Bootstrap**: 响应式UI组件 +- **ECharts**: 数据可视化图表 -### 后端 +## 🚀 快速开始 + +### 环境要求 - Python 3.8+ -- FastAPI:高性能Web框架 -- Tushare:金融数据API -- 豆包大模型:AI分析引擎 -- SQLite:本地数据存储 +- pip 或 conda -### 前端 -- Bootstrap 5:响应式UI框架 -- ECharts:数据可视化 -- jQuery:DOM操作 -- WebSocket:实时数据推送 +### 安装步骤 -## 📦 安装部署 - -### 1. 环境准备 +1. **克隆项目** ```bash -# 克隆项目 -git clone https://gitee.com/zyj118/stock-monitor.git +git clone cd stock-monitor +``` -# 创建虚拟环境 +2. **创建虚拟环境** +```bash python -m venv venv -source venv/bin/activate # Linux/Mac -venv\\Scripts\\activate # Windows +# Windows +venv\Scripts\activate +# Linux/Mac +source venv/bin/activate +``` -# 安装依赖 +3. **安装依赖** +```bash pip install -r requirements.txt ``` -### 2. API配置获取 - -#### Tushare API -1. 访问 [Tushare官网](https://tushare.pro/register?reg=431380) -2. 注册并获取Token -3. 可选:充值获取更高级别权限 - -#### 豆包大模型API -1. 访问[火山引擎豆包大模型](https://www.volcengine.com/product/doubao) -2. 注册账号(支持个人开发者注册) -3. 进入控制台创建应用 -4. 获取API Key和Model ID -5. 按量付费,支持免费额度体验 - -### 3. 环境配置 -修改配置文件 -找到:app/services/ai_analysis_service.py 替换自己API的模型接入点ID,豆包大模型APIkey,base_url 。 -![输入图片说明](16915e87-8099-402c-80d4-f4018d56dcf9.png) -``` -class AIAnalysisService: - def __init__(self): - # 配置OpenAI客户端连接到Volces API - self.model = "" # Volces 模型接入点ID - self.client = OpenAI( - api_key = "", # 豆包大模型APIkey - base_url = "https://ark.cn-beijing.volces.com/api/v3" - ) +4. **配置环境** +- 获取Tushare Token: 访问 https://tushare.pro 注册获取 +- 获取豆包大模型API: 访问火山引擎控制台创建应用 +- 修改配置文件 `app/services/ai_analysis_service.py`: +```python +self.model = "your_model_id" # Volces 模型接入点ID +self.client = OpenAI( + api_key = "your_api_key", # 豆包大模型APIkey + base_url = "https://ark.cn-beijing.volces.com/api/v3" +) ``` +5. **初始化数据库** +```bash +python docs/database/init_database.py +``` -### 4. 启动系统 +6. **启动服务** ```bash python run.py ``` -访问 http://localhost:8000 即可使用系统 -## 🤝 联系作者 +7. **访问应用** +- 主页: http://localhost:8000 +- API文档: http://localhost:8000/docs -如果您对系统有任何问题或建议,欢迎联系: +## 📁 项目结构 -- 微信:zyj118 -- QQ:693696817 -- Email:693696817@qq.com +``` +stock-monitor/ +├── app/ # 应用主目录 +│ ├── __init__.py # FastAPI应用初始化 +│ ├── api/ # API路由 +│ │ ├── stock_routes.py # 股票相关API +│ │ └── market_routes.py # 市场数据API +│ ├── services/ # 业务逻辑层 +│ │ ├── stock_service_db.py # 股票数据服务 +│ │ ├── ai_analysis_service_db.py # AI分析服务 +│ │ ├── market_data_service.py # 市场数据服务 +│ │ └── kline_service.py # K线数据服务 +│ ├── dao/ # 数据访问层 +│ │ ├── base_dao.py # 基础DAO类 +│ │ ├── stock_dao.py # 股票数据DAO +│ │ ├── watchlist_dao.py # 自选股DAO +│ │ └── ai_analysis_dao.py # AI分析DAO +│ ├── models/ # 数据模型 +│ │ └── stock.py # 股票数据模型 +│ ├── templates/ # HTML模板 +│ │ ├── index.html # 主页模板 +│ │ ├── market.html # 市场模板 +│ │ └── stocks_simple.html # 股票页面模板 +│ ├── static/ # 静态文件 +│ ├── database.py # 数据库配置 +│ ├── config.py # 应用配置 +│ └── scheduler.py # 定时任务调度器 +├── docs/ # 文档目录 +│ ├── database/ # 数据库相关文档和脚本 +│ │ ├── database_schema*.sql # 数据库表结构文件 +│ │ ├── init_database.py # 数据库初始化脚本 +│ │ └── migrate_to_database.py # 数据迁移脚本 +│ └── guides/ # 使用指南 +│ ├── DATABASE_MIGRATION_GUIDE.md # 数据迁移指南 +│ └── NEW_FEATURES_GUIDE.md # 新功能指南 +├── backup/ # 备份文件目录 +│ └── json_backup_20251124_093028/ # JSON数据备份 +├── run.py # 应用启动脚本 +├── requirements.txt # Python依赖 +├── config.template.py # 配置文件模板 +├── docker-compose.yml # Docker部署配置 +├── CLAUDE.md # Claude Code 使用指南 +└── README.md # 项目说明文档 +``` -## 📝 使用说明 +## 📱 功能模块详解 -### 1. 添加监控股票 -1. 在主界面输入股票代码 -2. 设置目标市值范围 -3. 点击添加即可 +### 1. 股票搜索与查看 +- 支持股票代码搜索 +- 展示实时价格、涨跌幅、成交量 +- 公司基本信息展示 +- 多维度财务指标(PE、PB、ROE等) -### 2. 查看AI分析 -1. 点击股票行右侧的分析按钮 -2. 选择需要的分析维度(基本面/道德经/国内外价值投资大咖) -3. 等待AI分析结果 +### 2. 自选股管理 +- 添加关注股票到自选列表 +- 设置市值预警区间 +- 目标市值预警(高估、低估、合理) +- 一键移除自选股票 -### 3. 指数行情查看 -1. 点击顶部导航栏的"指数行情" -2. 查看实时指数数据和K线图 +### 3. AI智能分析 +- **价值投资分析**: 基于财务数据的深度分析 +- **道德经视角**: 从传统文化角度分析企业价值 +- **投资大师视角**: 模拟知名投资家的分析方法 +- 实时生成专业分析报告 -## ⚠️ 注意事项 +### 4. 市场数据 +- 沪深指数实时行情 +- 板块涨跌排行 +- 市场概况展示 +- K线图表分析 -1. API使用限制 - - Tushare免费账号有调用频率限制 - - 豆包大模型API提供免费额度,超出后按量计费 +## 🛠️ API接口 -2. 数据时效性 - - 行情数据实时更新 - - AI分析结果默认缓存1小时 +主要RESTful API接口: -3. 系统性能 - - 建议监控股票数量不超过30只 - - 定时刷新间隔建议60秒以上 +- `GET /` - 主页 +- `GET /stocks` - 股票页面 +- `GET /market` - 市场页面 +- `GET /api/stock_info/{stock_code}` - 获取股票信息 +- `GET /api/watchlist` - 获取自选股列表 +- `POST /api/add_watch` - 添加自选股 +- `DELETE /api/remove_watch/{stock_code}` - 删除自选股 +- `GET /api/index_info` - 获取指数信息 +- `GET /api/company_detail/{stock_code}` - 获取公司详情 -4.投资有风险,入市需谨慎。此系统仅为基于大数据和AI大模型的价值投资辅助分析,并不构成任何操作建议,风险自担! +## ⚙️ 配置说明 -## 📄 许可证 +### API配置要求 -MIT License +**Tushare配置** +- 访问 https://tushare.pro 注册账号 +- 获取API Token并填入配置文件 +- 免费账户有调用频率限制 + +**豆包大模型配置** +- 访问火山引擎控制台开通服务 +- 获取API Key和模型接入点ID +- 按量付费,支持免费额度体验 + +### 数据库配置 +- 默认使用SQLite数据库 +- 数据文件位置: `data/stocks.db` +- 支持扩展到MySQL等其他数据库 + +## ⚠️ 重要提示 + +### 数据免责声明 +- 本项目提供的股票数据仅供参考,不构成投资建议 +- 股票投资有风险,入市需谨慎 +- AI分析结果基于公开数据和模型计算,请以官方数据为准 + +### API使用限制 +- Tushare API有调用频率限制 +- 豆包大模型API提供免费额度,超出后按量计费 +- 请合理控制API调用频率 + +### 系统性能建议 +- 建议监控股票数量不超过30只 +- 定时刷新间隔建议60秒以上 +- 生产环境建议使用专业服务器部署 + +## 📋 部署指南 + +### 开发环境 +```bash +# 安装依赖 +pip install -r requirements.txt + +# 启动开发服务器 +python run.py +``` + +### 生产环境部署 +- 推荐使用Docker容器化部署 +- 配置Nginx反向代理 +- 使用Gunicorn作为WSGI服务器 +- 配置SSL证书确保安全访问 + +### Docker部署示例 +```bash +# 构建镜像 +docker build -t stock-monitor . + +# 运行容器 +docker run -d -p 8000:8000 stock-monitor +``` ## 🤝 贡献指南 -1. Fork 本仓库 -2. 新建 feature_xxx 分支 -3. 提交代码 -4. 新建 Pull Request +欢迎提交Issue和Pull Request来改进项目! -欢迎提交Issue和Pull Request! -本系统欢迎任何形式的二次开发,如果您觉得该系统对您有帮助,欢迎打赏!您对作者的鼓励是更新该系统的动力! -![输入图片说明](docs/images/wechat.png) \ No newline at end of file +### 提交规范 +- Bug修复: `fix: 修复xxx问题` +- 新功能: `feat: 添加xxx功能` +- 文档更新: `docs: 更新xxx文档` +- 代码重构: `refactor: 重构xxx模块` + +## 📞 联系方式 + +如有问题或建议,欢迎通过以下方式联系: + +- 提交Issue: [GitHub Issues](https://github.com/your-username/stock-monitor/issues) +- 邮箱: your-email@example.com + +## 📄 许可证 + +本项目采用MIT许可证,详见[LICENSE](LICENSE)文件。 + +--- + +**⚡ 投资有风险,入市需谨慎!本系统仅为价值投资辅助分析工具,不构成任何投资建议,投资决策请谨慎,风险自担!** + +**⭐ 如果这个项目对你有帮助,请给个Star支持一下!** \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index d771cde..87d9515 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -23,4 +23,6 @@ templates = Jinja2Templates(directory=Config.TEMPLATES_DIR) # 导入路由 from app.api import stock_routes -app.include_router(stock_routes.router) \ No newline at end of file +from app.api import market_routes +app.include_router(stock_routes.router) +app.include_router(market_routes.router) \ No newline at end of file diff --git a/app/api/market_routes.py b/app/api/market_routes.py new file mode 100644 index 0000000..fe6de00 --- /dev/null +++ b/app/api/market_routes.py @@ -0,0 +1,355 @@ +""" +市场数据和股票浏览API路由 +""" +from fastapi import APIRouter, Query +from typing import Optional, List +from app.services.market_data_service import MarketDataService +from app.services.kline_service import KlineService +from app.scheduler import run_manual_task, get_scheduler_status +from datetime import datetime +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/market") +market_service = MarketDataService() +kline_service = KlineService() + + +@router.get("/stocks") +async def get_all_stocks( + page: int = Query(1, description="页码"), + size: int = Query(50, description="每页数量"), + industry: Optional[str] = Query(None, description="行业代码"), + sector: Optional[str] = Query(None, description="概念板块代码"), + search: Optional[str] = Query(None, description="搜索关键词") +): + """获取所有股票列表,支持分页、行业筛选、概念筛选、搜索""" + try: + # 基础查询 + stocks = market_service._get_stock_list_from_db() + + # 筛选 + if industry: + stocks = [s for s in stocks if s.get('industry_code') == industry] + + if sector: + # 需要查询股票-板块关联表 + from app.database import DatabaseManager + db_manager = DatabaseManager() + with db_manager.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT stock_code FROM stock_sector_relations WHERE sector_code = %s + """, (sector,)) + sector_stocks = {row[0] for row in cursor.fetchall()} + cursor.close() + stocks = [s for s in stocks if s['stock_code'] in sector_stocks] + + if search: + search_lower = search.lower() + stocks = [ + s for s in stocks + if search_lower in s['stock_name'].lower() or search_lower in s['stock_code'] + ] + + # 分页 + total = len(stocks) + start = (page - 1) * size + end = start + size + page_stocks = stocks[start:end] + + return { + "total": total, + "page": page, + "size": size, + "pages": (total + size - 1) // size, + "data": page_stocks + } + + except Exception as e: + logger.error(f"获取股票列表失败: {e}") + return {"error": f"获取股票列表失败: {str(e)}"} + + +@router.get("/industries") +async def get_industries(): + """获取所有行业分类""" + try: + industries = market_service.get_industry_list() + return {"data": industries} + + except Exception as e: + logger.error(f"获取行业列表失败: {e}") + return {"error": f"获取行业列表失败: {str(e)}"} + + +@router.get("/sectors") +async def get_sectors(): + """获取所有概念板块""" + try: + sectors = market_service.get_sector_list() + return {"data": sectors} + + except Exception as e: + logger.error(f"获取概念板块失败: {e}") + return {"error": f"获取概念板块失败: {str(e)}"} + + +@router.get("/stocks/{stock_code}") +async def get_stock_detail(stock_code: str): + """获取股票详细信息""" + try: + # 获取股票基础信息 + from app.database import DatabaseManager + db_manager = DatabaseManager() + with db_manager.get_connection() as conn: + cursor = conn.cursor(dictionary=True) + query = """ + SELECT s.*, i.industry_name, + GROUP_CONCAT(DISTINCT sec.sector_name) as sector_names + FROM stocks s + LEFT JOIN industries i ON s.industry_code = i.industry_code + LEFT JOIN stock_sector_relations ssr ON s.stock_code = ssr.stock_code + LEFT JOIN sectors sec ON ssr.sector_code = sec.sector_code + WHERE s.stock_code = %s + GROUP BY s.stock_code + """ + cursor.execute(query, (stock_code,)) + stock = cursor.fetchone() + cursor.close() + + if not stock: + return {"error": "股票不存在"} + + return {"data": stock} + + except Exception as e: + logger.error(f"获取股票详情失败: {stock_code}, 错误: {e}") + return {"error": f"获取股票详情失败: {str(e)}"} + + +@router.get("/stocks/{stock_code}/kline") +async def get_kline_data( + stock_code: str, + kline_type: str = Query("daily", description="K线类型: daily/weekly/monthly"), + days: int = Query(30, description="获取天数"), + start_date: Optional[str] = Query(None, description="开始日期 YYYYMMDD"), + end_date: Optional[str] = Query(None, description="结束日期 YYYYMMDD") +): + """获取股票K线数据""" + try: + # 确定时间范围 + limit = days + if start_date and end_date: + # 如果指定了日期范围,不限制数量 + limit = 1000 + + kline_data = kline_service.get_kline_data( + stock_code=stock_code, + kline_type=kline_type, + start_date=start_date, + end_date=end_date, + limit=limit + ) + + # 获取股票基本信息 + from app.services.stock_service_db import StockServiceDB + stock_service = StockServiceDB() + stock_info = stock_service.get_stock_info(stock_code) + + return { + "stock_info": stock_info, + "kline_type": kline_type, + "data": kline_data + } + + except Exception as e: + logger.error(f"获取K线数据失败: {stock_code}, 错误: {e}") + return {"error": f"获取K线数据失败: {str(e)}"} + + +@router.get("/overview") +async def get_market_overview(): + """获取市场概览数据""" + try: + overview = kline_service.get_market_overview() + return {"data": overview} + + except Exception as e: + logger.error(f"获取市场概览失败: {e}") + return {"error": f"获取市场概览失败: {str(e)}"} + + +@router.get("/hot-stocks") +async def get_hot_stocks( + rank_type: str = Query("volume", description="排行榜类型: volume/amount/change"), + limit: int = Query(20, description="返回数量") +): + """获取热门股票排行榜""" + try: + from app.database import DatabaseManager + db_manager = DatabaseManager() + with db_manager.get_connection() as conn: + cursor = conn.cursor(dictionary=True) + + today = datetime.now().strftime('%Y-%m-%d') + + if rank_type == "volume": + query = """ + SELECT s.stock_code, s.stock_name, k.close_price, k.volume, + k.change_percent, k.amount, i.industry_name + FROM kline_data k + JOIN stocks s ON k.stock_code = s.stock_code + LEFT JOIN industries i ON s.industry_code = i.industry_code + WHERE k.kline_type = 'daily' AND k.trade_date = %s + ORDER BY k.volume DESC + LIMIT %s + """ + elif rank_type == "amount": + query = """ + SELECT s.stock_code, s.stock_name, k.close_price, k.volume, + k.change_percent, k.amount, i.industry_name + FROM kline_data k + JOIN stocks s ON k.stock_code = s.stock_code + LEFT JOIN industries i ON s.industry_code = i.industry_code + WHERE k.kline_type = 'daily' AND k.trade_date = %s + ORDER BY k.amount DESC + LIMIT %s + """ + elif rank_type == "change": + query = """ + SELECT s.stock_code, s.stock_name, k.close_price, k.volume, + k.change_percent, k.amount, i.industry_name + FROM kline_data k + JOIN stocks s ON k.stock_code = s.stock_code + LEFT JOIN industries i ON s.industry_code = i.industry_code + WHERE k.kline_type = 'daily' AND k.trade_date = %s AND k.change_percent IS NOT NULL + ORDER BY k.change_percent DESC + LIMIT %s + """ + else: + return {"error": "不支持的排行榜类型"} + + cursor.execute(query, (today, limit)) + stocks = cursor.fetchall() + cursor.close() + + return {"data": stocks, "rank_type": rank_type} + + except Exception as e: + logger.error(f"获取热门股票失败: {e}") + return {"error": f"获取热门股票失败: {str(e)}"} + + +@router.post("/tasks/{task_name}") +async def run_manual_task(task_name: str): + """手动执行定时任务""" + try: + result = run_manual_task(task_name) + return {"data": result} + + except Exception as e: + logger.error(f"手动执行任务失败: {task_name}, 错误: {e}") + return {"error": f"手动执行任务失败: {str(e)}"} + + +@router.get("/tasks/status") +async def get_task_status( + task_type: Optional[str] = Query(None, description="任务类型"), + days: int = Query(7, description="查询天数") +): + """获取任务执行状态""" + try: + tasks = get_scheduler_status(task_type, days) + return {"data": tasks} + + except Exception as e: + logger.error(f"获取任务状态失败: {e}") + return {"error": f"获取任务状态失败: {str(e)}"} + + +@router.post("/sync") +async def sync_market_data(): + """同步市场数据""" + try: + # 更新股票列表 + stocks = market_service.get_all_stock_list(force_refresh=True) + stock_count = len(stocks) + + # 更新概念分类 + concept_count = market_service.update_stock_sectors() + + # 更新当日K线数据 + kline_result = kline_service.batch_update_kline_data(days_back=1) + + return { + "message": "市场数据同步完成", + "stocks_updated": stock_count, + "concepts_updated": concept_count, + "kline_updated": kline_result + } + + except Exception as e: + logger.error(f"同步市场数据失败: {e}") + return {"error": f"同步市场数据失败: {str(e)}"} + + +@router.get("/statistics") +async def get_market_statistics( + days: int = Query(30, description="统计天数") +): + """获取市场统计数据""" + try: + from app.database import DatabaseManager + from datetime import datetime, timedelta + + db_manager = DatabaseManager() + with db_manager.get_connection() as conn: + cursor = conn.cursor(dictionary=True) + + start_date = (datetime.now() - timedelta(days=days)).date() + + # 获取市场统计数据 + query = """ + SELECT stat_date, market_code, total_stocks, up_stocks, down_stocks, + flat_stocks, total_amount, total_volume + FROM market_statistics + WHERE stat_date >= %s + ORDER BY stat_date DESC, market_code + """ + cursor.execute(query, (start_date,)) + stats = cursor.fetchall() + + # 获取行业分布统计 + cursor.execute(""" + SELECT i.industry_name, COUNT(s.stock_code) as stock_count + FROM stocks s + LEFT JOIN industries i ON s.industry_code = i.industry_code + WHERE s.is_active = TRUE AND i.industry_name IS NOT NULL + GROUP BY i.industry_name + ORDER BY stock_count DESC + """) + industry_stats = cursor.fetchall() + + # 获取市场规模统计 + cursor.execute(""" + SELECT market_type, COUNT(*) as stock_count + FROM stocks + WHERE is_active = TRUE + GROUP BY market_type + """) + market_type_stats = cursor.fetchall() + + cursor.close() + + return { + "statistics": stats, + "industry_distribution": industry_stats, + "market_type_distribution": market_type_stats, + "period_days": days + } + + except Exception as e: + logger.error(f"获取市场统计数据失败: {e}") + return {"error": f"获取市场统计数据失败: {str(e)}"} \ No newline at end of file diff --git a/app/api/stock_routes.py b/app/api/stock_routes.py index 0f36bce..71993e0 100644 --- a/app/api/stock_routes.py +++ b/app/api/stock_routes.py @@ -40,6 +40,10 @@ async def get_index_info(): async def market(request: Request): return templates.TemplateResponse("market.html", {"request": request}) +@router.get("/stocks") +async def stocks(request: Request): + return templates.TemplateResponse("stocks_simple.html", {"request": request}) + @router.get("/api/company_detail/{stock_code}") async def get_company_detail(stock_code: str): return stock_service.get_company_detail(stock_code) diff --git a/app/dao/__init__.py b/app/dao/__init__.py new file mode 100644 index 0000000..da2ae8e --- /dev/null +++ b/app/dao/__init__.py @@ -0,0 +1,17 @@ +""" +数据访问对象模块 +""" + +from .base_dao import BaseDAO +from .stock_dao import StockDAO +from .watchlist_dao import WatchlistDAO +from .ai_analysis_dao import AIAnalysisDAO +from .config_dao import ConfigDAO + +__all__ = [ + 'BaseDAO', + 'StockDAO', + 'WatchlistDAO', + 'AIAnalysisDAO', + 'ConfigDAO' +] \ No newline at end of file diff --git a/app/dao/ai_analysis_dao.py b/app/dao/ai_analysis_dao.py new file mode 100644 index 0000000..b82d3db --- /dev/null +++ b/app/dao/ai_analysis_dao.py @@ -0,0 +1,219 @@ +""" +AI分析数据访问对象 +""" +from typing import Dict, List, Optional, Any +import json +from datetime import datetime, date + +from .base_dao import BaseDAO + + +class AIAnalysisDAO(BaseDAO): + """AI分析数据访问对象""" + + def save_analysis(self, stock_code: str, analysis_type: str, analysis_data: Dict, + analysis_date: str = None) -> bool: + """保存AI分析结果""" + if analysis_date is None: + analysis_date = self.get_today_date() + + try: + # 检查是否已存在当日的分析 + existing = self.get_analysis(stock_code, analysis_type, analysis_date) + + investment_summary = analysis_data.get('investment_suggestion', {}) + investment_key_points = json.dumps(investment_summary.get('key_points', []), ensure_ascii=False) + investment_summary_text = investment_summary.get('summary', '') + investment_action = investment_summary.get('action', '') + + price_analysis = analysis_data.get('price_analysis', {}) + + if existing: + # 更新现有分析 + query = """ + UPDATE ai_analysis SET + investment_summary = %s, + investment_action = %s, + investment_key_points = %s, + valuation_analysis = %s, + financial_analysis = %s, + growth_analysis = %s, + risk_analysis = %s, + reasonable_price_min = %s, + reasonable_price_max = %s, + target_market_value_min = %s, + target_market_value_max = %s, + from_cache = %s, + updated_at = CURRENT_TIMESTAMP + WHERE stock_code = %s AND analysis_type = %s AND analysis_date = %s + """ + self._execute_update(query, ( + investment_summary_text, + investment_action, + investment_key_points, + analysis_data.get('analysis', {}).get('估值分析', ''), + analysis_data.get('analysis', {}).get('财务健康状况', ''), + analysis_data.get('analysis', {}).get('成长潜力', ''), + analysis_data.get('analysis', {}).get('风险评估', ''), + self.parse_float(price_analysis.get('合理价格区间', [None, None])[0]), + self.parse_float(price_analysis.get('合理价格区间', [None, None])[1]), + self.parse_float(price_analysis.get('目标市值区间', [None, None])[0]), + self.parse_float(price_analysis.get('目标市值区间', [None, None])[1]), + bool(analysis_data.get('from_cache', False)), + stock_code, + analysis_type, + analysis_date + )) + else: + # 插入新分析 + query = """ + INSERT INTO ai_analysis ( + stock_code, analysis_type, analysis_date, + investment_summary, investment_action, investment_key_points, + valuation_analysis, financial_analysis, growth_analysis, risk_analysis, + reasonable_price_min, reasonable_price_max, + target_market_value_min, target_market_value_max, from_cache + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """ + self._execute_insert(query, ( + stock_code, analysis_type, analysis_date, + investment_summary_text, investment_action, investment_key_points, + analysis_data.get('analysis', {}).get('估值分析', ''), + analysis_data.get('analysis', {}).get('财务健康状况', ''), + analysis_data.get('analysis', {}).get('成长潜力', ''), + analysis_data.get('analysis', {}).get('风险评估', ''), + self.parse_float(price_analysis.get('合理价格区间', [None, None])[0]), + self.parse_float(price_analysis.get('合理价格区间', [None, None])[1]), + self.parse_float(price_analysis.get('目标市值区间', [None, None])[0]), + self.parse_float(price_analysis.get('目标市值区间', [None, None])[1]), + bool(analysis_data.get('from_cache', False)) + )) + + self.log_data_update(f'ai_analysis_{analysis_type}', stock_code, 'success', 'Analysis saved') + return True + + except Exception as e: + self.logger.error(f"保存AI分析失败: {stock_code}, {analysis_type}, 错误: {e}") + self.log_data_update(f'ai_analysis_{analysis_type}', stock_code, 'failed', str(e)) + return False + + def get_analysis(self, stock_code: str, analysis_type: str, analysis_date: str = None) -> Optional[Dict]: + """获取AI分析结果""" + if analysis_date is None: + analysis_date = self.get_today_date() + + query = """ + SELECT * FROM ai_analysis + WHERE stock_code = %s AND analysis_type = %s AND analysis_date = %s + """ + return self._execute_single_query(query, (stock_code, analysis_type, analysis_date)) + + def get_latest_analysis(self, stock_code: str, analysis_type: str) -> Optional[Dict]: + """获取最新的AI分析结果""" + query = """ + SELECT * FROM ai_analysis + WHERE stock_code = %s AND analysis_type = %s + ORDER BY analysis_date DESC + LIMIT 1 + """ + return self._execute_single_query(query, (stock_code, analysis_type)) + + def get_all_analysis_types(self, stock_code: str, analysis_date: str = None) -> List[Dict]: + """获取股票的所有类型分析""" + if analysis_date is None: + analysis_date = self.get_today_date() + + query = """ + SELECT * FROM ai_analysis + WHERE stock_code = %s AND analysis_date = %s + ORDER BY analysis_type + """ + return self._execute_query(query, (stock_code, analysis_date)) + + def format_analysis_data(self, analysis_record: Dict) -> Dict: + """将数据库记录格式化为原始分析数据格式""" + if not analysis_record: + return {} + + # 解析JSON字段 + key_points = [] + if analysis_record.get('investment_key_points'): + try: + key_points = json.loads(analysis_record['investment_key_points']) + except json.JSONDecodeError: + key_points = [] + + # 构建投资建议 + investment_suggestion = { + 'summary': analysis_record.get('investment_summary', ''), + 'action': analysis_record.get('investment_action', ''), + 'key_points': key_points + } + + # 构建分析详情 + analysis = {} + if analysis_record.get('valuation_analysis'): + analysis['估值分析'] = analysis_record['valuation_analysis'] + if analysis_record.get('financial_analysis'): + analysis['财务健康状况'] = analysis_record['financial_analysis'] + if analysis_record.get('growth_analysis'): + analysis['成长潜力'] = analysis_record['growth_analysis'] + if analysis_record.get('risk_analysis'): + analysis['风险评估'] = analysis_record['risk_analysis'] + + # 构建价格分析 + price_analysis = {} + if analysis_record.get('reasonable_price_min') or analysis_record.get('reasonable_price_max'): + price_analysis['合理价格区间'] = [ + analysis_record.get('reasonable_price_min'), + analysis_record.get('reasonable_price_max') + ] + if analysis_record.get('target_market_value_min') or analysis_record.get('target_market_value_max'): + price_analysis['目标市值区间'] = [ + analysis_record.get('target_market_value_min'), + analysis_record.get('target_market_value_max') + ] + + return { + 'investment_suggestion': investment_suggestion, + 'analysis': analysis, + 'price_analysis': price_analysis, + 'from_cache': analysis_record.get('from_cache', False) + } + + def get_analysis_history(self, stock_code: str, analysis_type: str, + days: int = 30) -> List[Dict]: + """获取分析历史""" + query = """ + SELECT * FROM ai_analysis + WHERE stock_code = %s AND analysis_type = %s + AND analysis_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY) + ORDER BY analysis_date DESC + """ + return self._execute_query(query, (stock_code, analysis_type, days)) + + def delete_analysis(self, stock_code: str, analysis_type: str, + before_date: str = None) -> int: + """删除分析数据""" + if before_date: + query = """ + DELETE FROM ai_analysis + WHERE stock_code = %s AND analysis_type = %s AND analysis_date < %s + """ + return self._execute_update(query, (stock_code, analysis_type, before_date)) + else: + query = """ + DELETE FROM ai_analysis + WHERE stock_code = %s AND analysis_type = %s + """ + return self._execute_update(query, (stock_code, analysis_type)) + + def get_analysis_count(self, analysis_type: str = None) -> int: + """获取分析数量""" + if analysis_type: + query = "SELECT COUNT(*) as count FROM ai_analysis WHERE analysis_type = %s" + result = self._execute_single_query(query, (analysis_type,)) + else: + query = "SELECT COUNT(*) as count FROM ai_analysis" + result = self._execute_single_query(query) + return result['count'] if result else 0 \ No newline at end of file diff --git a/app/dao/base_dao.py b/app/dao/base_dao.py new file mode 100644 index 0000000..09463ec --- /dev/null +++ b/app/dao/base_dao.py @@ -0,0 +1,114 @@ +""" +基础数据访问对象 +""" +from abc import ABC, abstractmethod +from typing import Dict, Any, Optional, List +import logging +from datetime import datetime, date + +from app.database import DatabaseManager + + +class BaseDAO(ABC): + """数据访问对象基类""" + + def __init__(self): + self.db_manager = DatabaseManager() + self.logger = logging.getLogger(self.__class__.__name__) + + def _execute_query(self, query: str, params: Optional[tuple] = None) -> List[Dict]: + """执行查询语句""" + try: + with self.db_manager.get_cursor() as cursor: + cursor.execute(query, params) + return cursor.fetchall() + except Exception as e: + self.logger.error(f"查询执行失败: {query}, 参数: {params}, 错误: {e}") + raise + + def _execute_single_query(self, query: str, params: Optional[tuple] = None) -> Optional[Dict]: + """执行单条记录查询""" + try: + with self.db_manager.get_cursor() as cursor: + cursor.execute(query, params) + return cursor.fetchone() + except Exception as e: + self.logger.error(f"单条查询执行失败: {query}, 参数: {params}, 错误: {e}") + raise + + def _execute_update(self, query: str, params: Optional[tuple] = None) -> int: + """执行更新语句,返回影响的行数""" + try: + with self.db_manager.get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(query, params) + affected_rows = cursor.rowcount + conn.commit() + return affected_rows + except Exception as e: + self.logger.error(f"更新执行失败: {query}, 参数: {params}, 错误: {e}") + raise + + def _execute_insert(self, query: str, params: Optional[tuple] = None) -> int: + """执行插入语句,返回插入的ID""" + try: + with self.db_manager.get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(query, params) + inserted_id = cursor.lastrowid + conn.commit() + return inserted_id + except Exception as e: + self.logger.error(f"插入执行失败: {query}, 参数: {params}, 错误: {e}") + raise + + def _execute_batch_insert(self, query: str, params_list: List[tuple]) -> int: + """批量插入数据,返回插入的总行数""" + if not params_list: + return 0 + + try: + with self.db_manager.get_connection() as conn: + with conn.cursor() as cursor: + cursor.executemany(query, params_list) + affected_rows = cursor.rowcount + conn.commit() + return affected_rows + except Exception as e: + self.logger.error(f"批量插入失败: {query}, 参数数量: {len(params_list)}, 错误: {e}") + raise + + def log_data_update(self, data_type: str, stock_code: str, status: str, + message: str = None, execution_time: float = None): + """记录数据更新日志""" + try: + query = """ + INSERT INTO data_update_log + (data_type, stock_code, update_status, update_message, execution_time) + VALUES (%s, %s, %s, %s, %s) + """ + self._execute_insert(query, (data_type, stock_code, status, message, execution_time)) + except Exception as e: + self.logger.error(f"记录更新日志失败: {e}") + + def get_today_date(self) -> str: + """获取今天的日期字符串""" + return date.today().strftime('%Y-%m-%d') + + def parse_float(self, value: Any) -> Optional[float]: + """解析浮点数""" + if value is None or value == '': + return None + try: + return float(value) + except (ValueError, TypeError): + return None + + def parse_int(self, value: Any) -> Optional[int]: + """解析整数""" + if value is None or value == '': + return None + try: + return int(value) + except (ValueError, TypeError): + return None \ No newline at end of file diff --git a/app/dao/config_dao.py b/app/dao/config_dao.py new file mode 100644 index 0000000..1241022 --- /dev/null +++ b/app/dao/config_dao.py @@ -0,0 +1,171 @@ +""" +系统配置数据访问对象 +""" +from typing import Dict, List, Optional, Any +import json +from datetime import datetime, date + +from .base_dao import BaseDAO + + +class ConfigDAO(BaseDAO): + """系统配置数据访问对象""" + + def get_config(self, key: str, default_value: Any = None) -> Any: + """获取配置值""" + query = "SELECT config_value, config_type FROM system_config WHERE config_key = %s" + result = self._execute_single_query(query, (key,)) + + if not result: + return default_value + + config_value = result['config_value'] + config_type = result['config_type'] + + # 根据类型转换值 + if config_type == 'integer': + try: + return int(config_value) if config_value else default_value + except (ValueError, TypeError): + return default_value + elif config_type == 'float': + try: + return float(config_value) if config_value else default_value + except (ValueError, TypeError): + return default_value + elif config_type == 'boolean': + return config_value.lower() in ('true', '1', 'yes', 'on') if config_value else default_value + elif config_type == 'json': + try: + return json.loads(config_value) if config_value else default_value + except json.JSONDecodeError: + return default_value + else: # string + return config_value if config_value else default_value + + def set_config(self, key: str, value: Any, config_type: str = 'string') -> bool: + """设置配置值""" + try: + # 转换值为字符串存储 + if config_type == 'json': + str_value = json.dumps(value, ensure_ascii=False) + elif config_type == 'boolean': + str_value = str(value).lower() + else: + str_value = str(value) + + # 检查配置是否存在 + existing = self._execute_single_query( + "SELECT id FROM system_config WHERE config_key = %s", (key,) + ) + + if existing: + # 更新现有配置 + query = """ + UPDATE system_config + SET config_value = %s, config_type = %s, updated_at = CURRENT_TIMESTAMP + WHERE config_key = %s + """ + self._execute_update(query, (str_value, config_type, key)) + else: + # 插入新配置 + query = """ + INSERT INTO system_config (config_key, config_value, config_type) + VALUES (%s, %s, %s) + """ + self._execute_insert(query, (key, str_value, config_type)) + + self.log_data_update('config', key, 'success', f'Config updated: {key}={value}') + return True + + except Exception as e: + self.logger.error(f"设置配置失败: {key}={value}, 错误: {e}") + self.log_data_update('config', key, 'failed', str(e)) + return False + + def get_all_configs(self) -> Dict[str, Dict]: + """获取所有配置""" + query = "SELECT * FROM system_config ORDER BY config_key" + results = self._execute_query(query) + + configs = {} + for result in results: + key = result['config_key'] + configs[key] = { + 'value': self.get_config(key), + 'type': result['config_type'], + 'created_at': result['created_at'], + 'updated_at': result['updated_at'] + } + + return configs + + def delete_config(self, key: str) -> bool: + """删除配置""" + try: + query = "DELETE FROM system_config WHERE config_key = %s" + affected_rows = self._execute_update(query, (key,)) + success = affected_rows > 0 + + if success: + self.log_data_update('config', key, 'success', 'Config deleted') + else: + self.log_data_update('config', key, 'failed', 'Config not found') + + return success + + except Exception as e: + self.logger.error(f"删除配置失败: {key}, 错误: {e}") + self.log_data_update('config', key, 'failed', str(e)) + return False + + def increment_counter(self, key: str, increment: int = 1) -> int: + """递增计数器配置""" + try: + current_value = self.get_config(key, 0) + new_value = current_value + increment + self.set_config(key, new_value, 'integer') + return new_value + except Exception as e: + self.logger.error(f"递增计数器失败: {key}, 错误: {e}") + return 0 + + def reset_daily_counters(self) -> None: + """重置每日计数器""" + daily_counters = [ + 'tushare_api_calls_today', + ] + + for counter in daily_counters: + self.set_config(counter, 0, 'integer') + + # 更新最后重置日期 + self.set_config('last_counter_reset_date', self.get_today_date(), 'date') + + def get_last_data_update_date(self) -> Optional[str]: + """获取最后数据更新日期""" + return self.get_config('last_data_update_date') + + def set_last_data_update_date(self, date_str: str) -> bool: + """设置最后数据更新日期""" + return self.set_config('last_data_update_date', date_str, 'date') + + def get_cache_expiration_hours(self) -> int: + """获取缓存过期时间(小时)""" + return self.get_config('cache_expiration_hours', 24) + + def get_max_watchlist_size(self) -> int: + """获取最大监控列表大小""" + return self.get_config('max_watchlist_size', 50) + + def is_cache_expired(self, data_date: str) -> bool: + """检查缓存是否过期""" + try: + cache_hours = self.get_cache_expiration_hours() + current_date = date.today() + data_date_obj = datetime.strptime(data_date, '%Y-%m-%d').date() + + days_diff = (current_date - data_date_obj).days + return days_diff > 0 # 如果不是今天的数据,就算过期 + except Exception: + return True # 如果无法解析日期,认为过期 \ No newline at end of file diff --git a/app/dao/stock_dao.py b/app/dao/stock_dao.py new file mode 100644 index 0000000..7088f5b --- /dev/null +++ b/app/dao/stock_dao.py @@ -0,0 +1,208 @@ +""" +股票数据访问对象 +""" +from typing import Dict, List, Optional, Tuple +import json +from datetime import datetime, date + +from .base_dao import BaseDAO + + +class StockDAO(BaseDAO): + """股票数据访问对象""" + + def get_stock_by_code(self, stock_code: str) -> Optional[Dict]: + """根据股票代码获取股票信息""" + query = "SELECT * FROM stocks WHERE stock_code = %s" + return self._execute_single_query(query, (stock_code,)) + + def add_or_update_stock(self, stock_code: str, stock_name: str, market: str) -> int: + """添加或更新股票信息""" + existing = self.get_stock_by_code(stock_code) + + if existing: + # 更新现有股票 + query = """ + UPDATE stocks + SET stock_name = %s, market = %s, updated_at = CURRENT_TIMESTAMP + WHERE stock_code = %s + """ + self._execute_update(query, (stock_name, market, stock_code)) + return existing['id'] + else: + # 添加新股票 + query = """ + INSERT INTO stocks (stock_code, stock_name, market) + VALUES (%s, %s, %s) + """ + return self._execute_insert(query, (stock_code, stock_name, market)) + + def get_stock_data(self, stock_code: str, data_date: str = None) -> Optional[Dict]: + """获取股票数据""" + if data_date is None: + data_date = self.get_today_date() + + query = """ + SELECT sd.*, s.stock_name + FROM stock_data sd + JOIN stocks s ON sd.stock_code = s.stock_code + WHERE sd.stock_code = %s AND sd.data_date = %s + """ + return self._execute_single_query(query, (stock_code, data_date)) + + def save_stock_data(self, stock_code: str, stock_info: Dict, data_date: str = None) -> bool: + """保存股票数据""" + if data_date is None: + data_date = self.get_today_date() + + try: + # 确保股票信息存在 + self.add_or_update_stock( + stock_code, + stock_info.get('name', ''), + 'SH' if stock_code.startswith('6') else 'SZ' + ) + + # 检查是否已存在当日数据 + existing = self.get_stock_data(stock_code, data_date) + + if existing: + # 更新现有数据 + query = """ + UPDATE stock_data SET + price = %s, + change_percent = %s, + market_value = %s, + pe_ratio = %s, + pb_ratio = %s, + ps_ratio = %s, + dividend_yield = %s, + roe = %s, + gross_profit_margin = %s, + net_profit_margin = %s, + debt_to_assets = %s, + revenue_yoy = %s, + net_profit_yoy = %s, + bps = %s, + ocfps = %s, + from_cache = %s, + updated_at = CURRENT_TIMESTAMP + WHERE stock_code = %s AND data_date = %s + """ + self._execute_update(query, ( + self.parse_float(stock_info.get('price')), + self.parse_float(stock_info.get('change_percent')), + self.parse_float(stock_info.get('market_value')), + self.parse_float(stock_info.get('pe_ratio')), + self.parse_float(stock_info.get('pb_ratio')), + self.parse_float(stock_info.get('ps_ratio')), + self.parse_float(stock_info.get('dividend_yield')), + self.parse_float(stock_info.get('roe')), + self.parse_float(stock_info.get('gross_profit_margin')), + self.parse_float(stock_info.get('net_profit_margin')), + self.parse_float(stock_info.get('debt_to_assets')), + self.parse_float(stock_info.get('revenue_yoy')), + self.parse_float(stock_info.get('net_profit_yoy')), + self.parse_float(stock_info.get('bps')), + self.parse_float(stock_info.get('ocfps')), + bool(stock_info.get('from_cache', False)), + stock_code, + data_date + )) + else: + # 插入新数据 + query = """ + INSERT INTO stock_data ( + stock_code, data_date, price, change_percent, market_value, + pe_ratio, pb_ratio, ps_ratio, dividend_yield, + roe, gross_profit_margin, net_profit_margin, debt_to_assets, + revenue_yoy, net_profit_yoy, bps, ocfps, from_cache + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """ + self._execute_insert(query, ( + stock_code, data_date, + self.parse_float(stock_info.get('price')), + self.parse_float(stock_info.get('change_percent')), + self.parse_float(stock_info.get('market_value')), + self.parse_float(stock_info.get('pe_ratio')), + self.parse_float(stock_info.get('pb_ratio')), + self.parse_float(stock_info.get('ps_ratio')), + self.parse_float(stock_info.get('dividend_yield')), + self.parse_float(stock_info.get('roe')), + self.parse_float(stock_info.get('gross_profit_margin')), + self.parse_float(stock_info.get('net_profit_margin')), + self.parse_float(stock_info.get('debt_to_assets')), + self.parse_float(stock_info.get('revenue_yoy')), + self.parse_float(stock_info.get('net_profit_yoy')), + self.parse_float(stock_info.get('bps')), + self.parse_float(stock_info.get('ocfps')), + bool(stock_info.get('from_cache', False)) + )) + + return True + + except Exception as e: + self.logger.error(f"保存股票数据失败: {stock_code}, 错误: {e}") + self.log_data_update('stock_data', stock_code, 'failed', str(e)) + return False + + def get_latest_stock_data(self, stock_code: str) -> Optional[Dict]: + """获取最新的股票数据""" + query = """ + SELECT sd.*, s.stock_name + FROM stock_data sd + JOIN stocks s ON sd.stock_code = s.stock_code + WHERE sd.stock_code = %s + ORDER BY sd.data_date DESC + LIMIT 1 + """ + return self._execute_single_query(query, (stock_code,)) + + def get_multiple_stocks_data(self, stock_codes: List[str], data_date: str = None) -> List[Dict]: + """批量获取股票数据""" + if not stock_codes: + return [] + + if data_date is None: + data_date = self.get_today_date() + + placeholders = ','.join(['%s'] * len(stock_codes)) + query = f""" + SELECT sd.*, s.stock_name + FROM stock_data sd + JOIN stocks s ON sd.stock_code = s.stock_code + WHERE sd.stock_code IN ({placeholders}) AND sd.data_date = %s + """ + + return self._execute_query(query, tuple(stock_codes + [data_date])) + + def get_stock_data_history(self, stock_code: str, days: int = 30) -> List[Dict]: + """获取股票历史数据""" + query = """ + SELECT sd.*, s.stock_name + FROM stock_data sd + JOIN stocks s ON sd.stock_code = s.stock_code + WHERE sd.stock_code = %s AND sd.data_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY) + ORDER BY sd.data_date DESC + """ + return self._execute_query(query, (stock_code, days)) + + def delete_stock_data(self, stock_code: str, before_date: str = None) -> int: + """删除股票数据""" + if before_date: + query = "DELETE FROM stock_data WHERE stock_code = %s AND data_date < %s" + return self._execute_update(query, (stock_code, before_date)) + else: + query = "DELETE FROM stock_data WHERE stock_code = %s" + return self._execute_update(query, (stock_code,)) + + def get_stock_count(self) -> int: + """获取股票总数""" + query = "SELECT COUNT(*) as count FROM stocks" + result = self._execute_single_query(query) + return result['count'] if result else 0 + + def get_data_date_range(self) -> Optional[Dict]: + """获取数据的日期范围""" + query = "SELECT MIN(data_date) as min_date, MAX(data_date) as max_date FROM stock_data" + return self._execute_single_query(query) \ No newline at end of file diff --git a/app/dao/watchlist_dao.py b/app/dao/watchlist_dao.py new file mode 100644 index 0000000..c6c0366 --- /dev/null +++ b/app/dao/watchlist_dao.py @@ -0,0 +1,172 @@ +""" +监控列表数据访问对象 +""" +from typing import Dict, List, Optional, Tuple +from datetime import datetime + +from .base_dao import BaseDAO + + +class WatchlistDAO(BaseDAO): + """监控列表数据访问对象""" + + def get_watchlist(self) -> List[Dict]: + """获取完整的监控列表,包含股票信息""" + query = """ + SELECT + w.stock_code, + s.stock_name, + s.market, + w.target_market_value_min, + w.target_market_value_max, + w.created_at, + w.updated_at + FROM watchlist w + JOIN stocks s ON w.stock_code = s.stock_code + ORDER BY w.created_at DESC + """ + return self._execute_query(query) + + def add_to_watchlist(self, stock_code: str, target_min: float = None, + target_max: float = None) -> bool: + """添加股票到监控列表""" + try: + # 检查是否已在监控列表中 + existing = self.get_watchlist_item(stock_code) + if existing: + # 更新现有的目标市值 + return self.update_watchlist_item(stock_code, target_min, target_max) + + # 添加新项到监控列表 + query = """ + INSERT INTO watchlist (stock_code, target_market_value_min, target_market_value_max) + VALUES (%s, %s, %s) + """ + self._execute_insert(query, (stock_code, target_min, target_max)) + + self.log_data_update('watchlist', stock_code, 'success', 'Added to watchlist') + return True + + except Exception as e: + self.logger.error(f"添加到监控列表失败: {stock_code}, 错误: {e}") + self.log_data_update('watchlist', stock_code, 'failed', str(e)) + return False + + def remove_from_watchlist(self, stock_code: str) -> bool: + """从监控列表移除股票""" + try: + query = "DELETE FROM watchlist WHERE stock_code = %s" + affected_rows = self._execute_update(query, (stock_code,)) + success = affected_rows > 0 + + if success: + self.log_data_update('watchlist', stock_code, 'success', 'Removed from watchlist') + else: + self.log_data_update('watchlist', stock_code, 'failed', 'Stock not found in watchlist') + + return success + + except Exception as e: + self.logger.error(f"从监控列表移除失败: {stock_code}, 错误: {e}") + self.log_data_update('watchlist', stock_code, 'failed', str(e)) + return False + + def get_watchlist_item(self, stock_code: str) -> Optional[Dict]: + """获取监控列表中的单个项目""" + query = """ + SELECT + w.stock_code, + s.stock_name, + s.market, + w.target_market_value_min, + w.target_market_value_max, + w.created_at, + w.updated_at + FROM watchlist w + JOIN stocks s ON w.stock_code = s.stock_code + WHERE w.stock_code = %s + """ + return self._execute_single_query(query, (stock_code,)) + + def update_watchlist_item(self, stock_code: str, target_min: float = None, + target_max: float = None) -> bool: + """更新监控列表项目""" + try: + query = """ + UPDATE watchlist + SET target_market_value_min = %s, + target_market_value_max = %s, + updated_at = CURRENT_TIMESTAMP + WHERE stock_code = %s + """ + affected_rows = self._execute_update(query, (target_min, target_max, stock_code)) + success = affected_rows > 0 + + if success: + self.log_data_update('watchlist', stock_code, 'success', 'Updated watchlist item') + else: + self.log_data_update('watchlist', stock_code, 'failed', 'Stock not found in watchlist') + + return success + + except Exception as e: + self.logger.error(f"更新监控列表失败: {stock_code}, 错误: {e}") + self.log_data_update('watchlist', stock_code, 'failed', str(e)) + return False + + def get_watchlist_with_data(self, data_date: str = None) -> List[Dict]: + """获取监控列表及其股票数据""" + if data_date is None: + data_date = self.get_today_date() + + query = """ + SELECT + w.stock_code, + s.stock_name, + s.market, + w.target_market_value_min, + w.target_market_value_max, + sd.price, + sd.change_percent, + sd.market_value as current_market_value, + sd.pe_ratio, + sd.pb_ratio, + sd.from_cache + FROM watchlist w + JOIN stocks s ON w.stock_code = s.stock_code + LEFT JOIN stock_data sd ON w.stock_code = sd.stock_code AND sd.data_date = %s + ORDER BY w.created_at DESC + """ + return self._execute_query(query, (data_date,)) + + def clear_watchlist(self) -> bool: + """清空监控列表""" + try: + query = "DELETE FROM watchlist" + self._execute_update(query) + self.log_data_update('watchlist', 'all', 'success', 'Cleared watchlist') + return True + except Exception as e: + self.logger.error(f"清空监控列表失败: {e}") + self.log_data_update('watchlist', 'all', 'failed', str(e)) + return False + + def get_watchlist_count(self) -> int: + """获取监控列表股票数量""" + query = "SELECT COUNT(*) as count FROM watchlist" + result = self._execute_single_query(query) + return result['count'] if result else 0 + + def get_stocks_needing_update(self, data_date: str = None) -> List[str]: + """获取需要更新数据的股票代码列表""" + if data_date is None: + data_date = self.get_today_date() + + query = """ + SELECT DISTINCT w.stock_code + FROM watchlist w + LEFT JOIN stock_data sd ON w.stock_code = sd.stock_code AND sd.data_date = %s + WHERE sd.stock_code IS NULL OR sd.data_date < %s + """ + results = self._execute_query(query, (data_date, data_date)) + return [item['stock_code'] for item in results] \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..5558664 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1 @@ +# 数据模型模块 \ No newline at end of file diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 0000000..525cfd0 --- /dev/null +++ b/app/scheduler.py @@ -0,0 +1,403 @@ +""" +定时任务调度器 +负责自动更新股票数据、K线数据等定时任务 +""" +import asyncio +import logging +from datetime import datetime, time, timedelta +from typing import Dict, List, Optional +import threading +from app.services.market_data_service import MarketDataService +from app.services.kline_service import KlineService +from app.services.stock_service_db import StockServiceDB +from app.database import DatabaseManager + +logger = logging.getLogger(__name__) + + +class TaskScheduler: + def __init__(self): + self.market_service = MarketDataService() + self.kline_service = KlineService() + self.stock_service = StockServiceDB() + self.db_manager = DatabaseManager() + self.logger = logging.getLogger(__name__) + self.running = False + self.scheduler_thread = None + + def start(self): + """启动定时任务调度器""" + if self.running: + self.logger.warning("任务调度器已在运行") + return + + self.running = True + self.scheduler_thread = threading.Thread(target=self._run_scheduler, daemon=True) + self.scheduler_thread.start() + self.logger.info("任务调度器已启动") + + def stop(self): + """停止定时任务调度器""" + self.running = False + if self.scheduler_thread: + self.scheduler_thread.join(timeout=10) + self.logger.info("任务调度器已停止") + + def _run_scheduler(self): + """运行调度器主循环""" + self.logger.info("任务调度器开始运行") + + while self.running: + try: + current_time = datetime.now() + + # 检查是否到了执行时间 + self._check_and_run_tasks(current_time) + + # 每5分钟检查一次 + for _ in range(60): # 5分钟 = 300秒,每5秒检查一次 + if not self.running: + break + asyncio.sleep(5) + + except Exception as e: + self.logger.error(f"任务调度器运行错误: {e}") + asyncio.sleep(30) # 出错后等待30秒再继续 + + def _check_and_run_tasks(self, current_time: datetime): + """检查并执行定时任务""" + try: + # 每日上午9:00更新股票列表(每周一) + if current_time.weekday() == 0 and current_time.time() >= time(9, 0): + if self._should_run_task('update_stock_list', current_time): + self._run_task_async('update_stock_list', self._update_stock_list) + + # 每日上午9:30更新K线数据 + if current_time.time() >= time(9, 30): + if self._should_run_task('update_daily_kline', current_time): + self._run_task_async('update_daily_kline', self._update_daily_kline) + + # 每日收盘后(16:00)更新市场统计 + if current_time.time() >= time(16, 0): + if self._should_run_task('update_market_stats', current_time): + self._run_task_async('update_market_stats', self._update_market_statistics) + + # 每日晚上20:00更新监控列表数据 + if current_time.time() >= time(20, 0): + if self._should_run_task('update_watchlist', current_time): + self._run_task_async('update_watchlist', self._update_watchlist_data) + + # 每周日凌晨2:00清理旧数据 + if current_time.weekday() == 6 and current_time.time() >= time(2, 0): + if self._should_run_task('clean_old_data', current_time): + self._run_task_async('clean_old_data', self._clean_old_data) + + except Exception as e: + self.logger.error(f"检查和执行任务失败: {e}") + + def _should_run_task(self, task_name: str, current_time: datetime) -> bool: + """检查任务是否应该执行(避免重复执行)""" + try: + with self.db_manager.get_connection() as conn: + cursor = conn.cursor(dictionary=True) + + # 检查今天是否已经执行过该任务 + today = current_time.date() + query = """ + SELECT COUNT(*) as count + FROM data_update_tasks + WHERE task_type = %s AND DATE(created_at) = %s AND status = 'completed' + """ + cursor.execute(query, (task_name, today)) + result = cursor.fetchone() + + cursor.close() + return result['count'] == 0 + + except Exception as e: + self.logger.error(f"检查任务执行状态失败: {task_name}, 错误: {e}") + return False + + def _run_task_async(self, task_name: str, task_func): + """异步执行任务""" + def run_task(): + try: + self._create_task_record(task_name, 'running') + + start_time = datetime.now() + result = task_func() + end_time = datetime.now() + + duration = (end_time - start_time).total_seconds() + + if isinstance(result, dict) and 'error' in result: + self._update_task_record(task_name, 'failed', + error_message=result['error'], + duration=duration) + else: + self._update_task_record(task_name, 'completed', + processed_count=result.get('processed_count', 0), + total_count=result.get('total_count', 0), + duration=duration) + + except Exception as e: + self.logger.error(f"执行任务失败: {task_name}, 错误: {e}") + self._update_task_record(task_name, 'failed', error_message=str(e)) + + # 在新线程中执行任务 + task_thread = threading.Thread(target=run_task, daemon=True) + task_thread.start() + + def _create_task_record(self, task_name: str, task_type: str): + """创建任务记录""" + try: + with self.db_manager.get_connection() as conn: + cursor = conn.cursor() + query = """ + INSERT INTO data_update_tasks (task_name, task_type, status, start_time) + VALUES (%s, %s, %s, NOW()) + """ + cursor.execute(query, (task_name, task_type, 'running')) + conn.commit() + cursor.close() + + except Exception as e: + self.logger.error(f"创建任务记录失败: {task_name}, 错误: {e}") + + def _update_task_record(self, task_name: str, status: str, + processed_count: int = 0, total_count: int = 0, + error_message: str = None, duration: float = None): + """更新任务记录""" + try: + with self.db_manager.get_connection() as conn: + cursor = conn.cursor() + query = """ + UPDATE data_update_tasks + SET status = %s, end_time = NOW(), + processed_count = %s, total_count = %s, + error_message = %s + WHERE task_name = %s AND status = 'running' + ORDER BY created_at DESC + LIMIT 1 + """ + cursor.execute(query, (status, processed_count, total_count, error_message, task_name)) + conn.commit() + cursor.close() + + except Exception as e: + self.logger.error(f"更新任务记录失败: {task_name}, 错误: {e}") + + def _update_stock_list(self) -> Dict: + """更新股票列表""" + try: + self.logger.info("开始更新股票列表") + result = self.market_service.get_all_stock_list(force_refresh=True) + + # 更新概念分类 + self.market_service.update_stock_sectors() + + return { + 'total_count': len(result), + 'processed_count': len(result) + } + + except Exception as e: + self.logger.error(f"更新股票列表失败: {e}") + return {'error': str(e)} + + def _update_daily_kline(self) -> Dict: + """更新日K线数据""" + try: + self.logger.info("开始更新日K线数据") + result = self.kline_service.batch_update_kline_data(days_back=1) + return result + + except Exception as e: + self.logger.error(f"更新日K线数据失败: {e}") + return {'error': str(e)} + + def _update_watchlist_data(self) -> Dict: + """更新监控列表数据""" + try: + self.logger.info("开始更新监控列表数据") + result = self.stock_service.batch_update_watchlist_data() + return result + + except Exception as e: + self.logger.error(f"更新监控列表数据失败: {e}") + return {'error': str(e)} + + def _update_market_statistics(self) -> Dict: + """更新市场统计数据""" + try: + self.logger.info("开始更新市场统计数据") + return self._calculate_market_stats() + + except Exception as e: + self.logger.error(f"更新市场统计数据失败: {e}") + return {'error': str(e)} + + def _calculate_market_stats(self) -> Dict: + """计算市场统计数据""" + try: + today = datetime.now().date() + + with self.db_manager.get_connection() as conn: + cursor = conn.cursor(dictionary=True) + + # 计算市场统计 + query = """ + INSERT INTO market_statistics ( + stat_date, market_code, total_stocks, up_stocks, down_stocks, + flat_stocks, total_volume, total_amount, created_at + ) + SELECT + %s as stat_date, + market, + COUNT(*) as total_stocks, + SUM(CASE WHEN change_percent > 0 THEN 1 ELSE 0 END) as up_stocks, + SUM(CASE WHEN change_percent < 0 THEN 1 ELSE 0 END) as down_stocks, + SUM(CASE WHEN change_percent = 0 THEN 1 ELSE 0 END) as flat_stocks, + COALESCE(SUM(volume), 0) as total_volume, + COALESCE(SUM(amount), 0) as total_amount, + NOW() + FROM ( + SELECT + CASE WHEN stock_code LIKE '6%' THEN 'SH' + WHEN stock_code LIKE '0%' OR stock_code LIKE '3%' THEN 'SZ' + ELSE 'OTHER' END as market, + change_percent, + volume, + amount + FROM kline_data + WHERE kline_type = 'daily' AND trade_date = %s + ) as daily_data + GROUP BY market + ON DUPLICATE KEY UPDATE + total_stocks = VALUES(total_stocks), + up_stocks = VALUES(up_stocks), + down_stocks = VALUES(down_stocks), + flat_stocks = VALUES(flat_stocks), + total_volume = VALUES(total_volume), + total_amount = VALUES(total_amount), + updated_at = NOW() + """ + + cursor.execute(query, (today, today)) + affected_rows = cursor.rowcount + conn.commit() + cursor.close() + + return { + 'processed_count': affected_rows, + 'total_count': affected_rows + } + + except Exception as e: + self.logger.error(f"计算市场统计数据失败: {e}") + return {'error': str(e)} + + def _clean_old_data(self) -> Dict: + """清理旧数据""" + try: + self.logger.info("开始清理旧数据") + + # 清理6个月前的K线数据 + deleted_count = self.kline_service.clean_old_kline_data(days_to_keep=180) + + # 清理3个月前的任务记录 + cutoff_date = datetime.now() - timedelta(days=90) + with self.db_manager.get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM data_update_tasks WHERE created_at < %s", (cutoff_date,)) + task_deleted = cursor.rowcount + conn.commit() + cursor.close() + + return { + 'processed_count': deleted_count + task_deleted, + 'deleted_kline_count': deleted_count, + 'deleted_task_count': task_deleted + } + + except Exception as e: + self.logger.error(f"清理旧数据失败: {e}") + return {'error': str(e)} + + def get_task_status(self, task_type: str = None, days: int = 7) -> List[Dict]: + """获取任务执行状态""" + try: + with self.db_manager.get_connection() as conn: + cursor = conn.cursor(dictionary=True) + + query = """ + SELECT task_name, task_type, status, start_time, end_time, + processed_count, total_count, error_message, + TIMESTAMPDIFF(SECOND, start_time, end_time) as duration_seconds + FROM data_update_tasks + WHERE created_at >= DATE_SUB(NOW(), INTERVAL %s DAY) + """ + params = [days] + + if task_type: + query += " AND task_type = %s" + params.append(task_type) + + query += " ORDER BY created_at DESC" + + cursor.execute(query, params) + tasks = cursor.fetchall() + cursor.close() + + return tasks + + except Exception as e: + self.logger.error(f"获取任务状态失败: {e}") + return [] + + def run_manual_task(self, task_name: str, **kwargs) -> Dict: + """手动执行任务""" + try: + self.logger.info(f"手动执行任务: {task_name}") + + task_map = { + 'update_stock_list': self._update_stock_list, + 'update_daily_kline': lambda: self._update_daily_kline(), + 'update_watchlist': self._update_watchlist_data, + 'update_market_stats': self._update_market_statistics, + 'clean_old_data': self._clean_old_data + } + + if task_name not in task_map: + return {'error': f'未知任务: {task_name}'} + + task_func = task_map[task_name] + return task_func() + + except Exception as e: + self.logger.error(f"手动执行任务失败: {task_name}, 错误: {e}") + return {'error': str(e)} + + +# 全局调度器实例 +task_scheduler = TaskScheduler() + + +def start_scheduler(): + """启动任务调度器""" + task_scheduler.start() + + +def stop_scheduler(): + """停止任务调度器""" + task_scheduler.stop() + + +def get_scheduler_status(task_type: str = None, days: int = 7) -> List[Dict]: + """获取调度器状态""" + return task_scheduler.get_task_status(task_type, days) + + +def run_manual_task(task_name: str, **kwargs) -> Dict: + """手动执行任务""" + return task_scheduler.run_manual_task(task_name, **kwargs) \ No newline at end of file diff --git a/app/services/ai_analysis_service.py b/app/services/ai_analysis_service_db.py similarity index 56% rename from app/services/ai_analysis_service.py rename to app/services/ai_analysis_service_db.py index d2ec131..9faa967 100644 --- a/app/services/ai_analysis_service.py +++ b/app/services/ai_analysis_service_db.py @@ -1,97 +1,29 @@ +""" +基于数据库的AI分析服务 +""" import json -import os +from datetime import datetime, date from openai import OpenAI +from app.dao import AIAnalysisDAO, ConfigDAO from app.config import Config +import logging -class AIAnalysisService: +logger = logging.getLogger(__name__) + + +class AIAnalysisServiceDB: def __init__(self): # 配置OpenAI客户端连接到Volces API self.model = "ep-20251113170010-6qdcp" # Volces 模型接入点ID self.client = OpenAI( - api_key = "ec3ebae6-e131-4b1e-a5ae-30f70468e165", # 豆包大模型APIkey - base_url = "https://ark.cn-beijing.volces.com/api/v3" + api_key="ec3ebae6-e131-4b1e-a5ae-30f70468e165", # 豆包大模型APIkey + base_url="https://ark.cn-beijing.volces.com/api/v3" ) - # 创建AI分析结果缓存目录 - self.cache_dir = os.path.join(Config.BASE_DIR, "ai_stock_analysis") - self.dao_cache_dir = os.path.join(Config.BASE_DIR, "dao_analysis") - self.daka_cache_dir = os.path.join(Config.BASE_DIR, "daka_analysis") - - # 确保所有缓存目录存在 - for directory in [self.cache_dir, self.dao_cache_dir, self.daka_cache_dir]: - if not os.path.exists(directory): - os.makedirs(directory) - def get_cache_path(self, stock_code: str) -> str: - """获取缓存文件路径""" - return os.path.join(self.cache_dir, f"{stock_code}.json") - - def get_dao_cache_path(self, stock_code: str) -> str: - """获取道德经分析缓存文件路径""" - return os.path.join(self.dao_cache_dir, f"{stock_code}.json") - - def get_daka_cache_path(self, stock_code: str) -> str: - """获取大咖分析缓存文件路径""" - return os.path.join(self.daka_cache_dir, f"{stock_code}.json") - - def load_cache(self, stock_code: str): - """加载缓存的AI分析结果""" - cache_path = self.get_cache_path(stock_code) - if os.path.exists(cache_path): - try: - with open(cache_path, 'r', encoding='utf-8') as f: - return json.load(f) - except Exception as e: - print(f"读取AI分析缓存失败: {str(e)}") - return None - - def save_cache(self, stock_code: str, analysis_result: dict): - """保存AI分析结果到缓存""" - cache_path = self.get_cache_path(stock_code) - try: - with open(cache_path, 'w', encoding='utf-8') as f: - json.dump(analysis_result, f, ensure_ascii=False, indent=4) - except Exception as e: - print(f"保存AI分析缓存失败: {str(e)}") - - def load_dao_cache(self, stock_code: str): - """加载缓存的道德经分析结果""" - cache_path = self.get_dao_cache_path(stock_code) - if os.path.exists(cache_path): - try: - with open(cache_path, 'r', encoding='utf-8') as f: - return json.load(f) - except Exception as e: - print(f"读取道德经分析缓存失败: {str(e)}") - return None - - def save_dao_cache(self, stock_code: str, analysis_result: dict): - """保存道德经分析结果到缓存""" - cache_path = self.get_dao_cache_path(stock_code) - try: - with open(cache_path, 'w', encoding='utf-8') as f: - json.dump(analysis_result, f, ensure_ascii=False, indent=4) - except Exception as e: - print(f"保存道德经分析缓存失败: {str(e)}") - - def load_daka_cache(self, stock_code: str): - """加载缓存的大咖分析结果""" - cache_path = self.get_daka_cache_path(stock_code) - if os.path.exists(cache_path): - try: - with open(cache_path, 'r', encoding='utf-8') as f: - return json.load(f) - except Exception as e: - print(f"读取大咖分析缓存失败: {str(e)}") - return None - - def save_daka_cache(self, stock_code: str, analysis_result: dict): - """保存大咖分析结果到缓存""" - cache_path = self.get_daka_cache_path(stock_code) - try: - with open(cache_path, 'w', encoding='utf-8') as f: - json.dump(analysis_result, f, ensure_ascii=False, indent=4) - except Exception as e: - print(f"保存大咖分析缓存失败: {str(e)}") + # 数据访问对象 + self.ai_dao = AIAnalysisDAO() + self.config_dao = ConfigDAO() + self.logger = logging.getLogger(__name__) def analyze_value_investment(self, analysis_data: dict, force_refresh: bool = False): """ @@ -102,23 +34,21 @@ class AIAnalysisService: """ try: stock_code = analysis_data["stock_info"]["code"] - - # 如果不是强制刷新,尝试从缓存加载 + today = self.ai_dao.get_today_date() + + # 如果不是强制刷新,尝试从数据库加载 if not force_refresh: - cached_result = self.load_cache(stock_code) + cached_result = self.ai_dao.get_analysis(stock_code, 'stock', today) if cached_result: - print(f"从缓存加载AI分析结果: {stock_code}") - return cached_result + logger.info(f"从数据库加载AI分析结果: {stock_code}") + return self.ai_dao.format_analysis_data(cached_result) # 打印输入数据用于调试 - print(f"输入的分析数据: {json.dumps(analysis_data, ensure_ascii=False, indent=2)}") - + logger.info(f"开始AI价值投资分析: {stock_code}") + # 构建提示词 prompt = self._build_analysis_prompt(analysis_data) - - # 打印提示词用于调试 - print(f"AI分析提示词: {prompt}") - + # 调用API response = self.client.chat.completions.create( model=self.model, @@ -134,23 +64,27 @@ class AIAnalysisService: } ] ) - + # 获取分析结果 analysis_text = response.choices[0].message.content - print(f"AI原始返回结果: {analysis_text}") - + logger.info(f"AI分析完成: {stock_code}") + try: # 尝试解析JSON analysis_result = json.loads(analysis_text) - print(f"解析后的JSON结果: {json.dumps(analysis_result, ensure_ascii=False, indent=2)}") - - # 保存到缓存 - self.save_cache(stock_code, analysis_result) - + + # 添加缓存标识 + analysis_result['from_cache'] = False + + # 保存到数据库 + success = self.ai_dao.save_analysis(stock_code, 'stock', analysis_result, today) + if not success: + logger.warning(f"保存AI分析结果失败: {stock_code}") + return analysis_result - + except json.JSONDecodeError as e: - print(f"JSON解析失败: {str(e)}") + logger.error(f"JSON解析失败: {str(e)}") # 如果JSON解析失败,返回错误信息 error_result = { 'stock_info': analysis_data.get('stock_info', {}), @@ -164,113 +98,172 @@ class AIAnalysisService: 'analysis_result': { "error": "AI返回的结果不是有效的JSON格式", "raw_text": analysis_text - } + }, + 'from_cache': False } return error_result - + except Exception as e: - print(f"AI分析失败: {str(e)}") + logger.error(f"AI分析失败: {str(e)}") return {"error": f"AI分析失败: {str(e)}"} - - def _parse_analysis_result(self, analysis_text, current_price): + + def analyze_tao_philosophy(self, company_info: dict, force_refresh: bool = False): """ - 解析AI返回的分析文本,提取结构化信息 + 基于道德经理念分析公司 + :param company_info: 公司信息 + :param force_refresh: 是否强制刷新分析结果 + :return: AI分析结果 """ try: - print(f"开始解析分析文本...") - - # 提取投资建议 - suggestion_pattern = r"投资建议[::]([\s\S]*?)(?=\n\n|$)" - suggestion_match = re.search(suggestion_pattern, analysis_text, re.MULTILINE | re.DOTALL) - investment_suggestion = suggestion_match.group(1).strip() if suggestion_match else "" - print(f"提取到的投资建议: {investment_suggestion}") - - # 提取合理价格区间 - price_pattern = r"合理股价区间[::]\s*(\d+\.?\d*)\s*[元-]\s*(\d+\.?\d*)[元]" - price_match = re.search(price_pattern, analysis_text) - if price_match: - price_min = float(price_match.group(1)) - price_max = float(price_match.group(2)) - else: - price_min = current_price * 0.8 - price_max = current_price * 1.2 - print(f"提取到的价格区间: {price_min}-{price_max}") - - # 提取目标市值区间(单位:亿元) - market_value_pattern = r"目标市值区间[::]\s*(\d+\.?\d*)\s*[亿-]\s*(\d+\.?\d*)[亿]" - market_value_match = re.search(market_value_pattern, analysis_text) - if market_value_match: - market_value_min = float(market_value_match.group(1)) - market_value_max = float(market_value_match.group(2)) - else: - # 尝试从文本中提取计算得出的市值 - calc_pattern = r"最低市值[=≈约]*(\d+\.?\d*)[亿].*最高市值[=≈约]*(\d+\.?\d*)[亿]" - calc_match = re.search(calc_pattern, analysis_text) - if calc_match: - market_value_min = float(calc_match.group(1)) - market_value_max = float(calc_match.group(2)) - else: - market_value_min = 0 - market_value_max = 0 - print(f"提取到的市值区间: {market_value_min}-{market_value_max}") - - # 提取各个分析维度的内容 - analysis_patterns = { - "valuation_analysis": r"估值分析([\s\S]*?)(?=###\s*财务状况分析|###\s*成长性分析|$)", - "financial_health": r"财务状况分析([\s\S]*?)(?=###\s*成长性分析|###\s*风险评估|$)", - "growth_potential": r"成长性分析([\s\S]*?)(?=###\s*风险评估|###\s*投资建议|$)", - "risk_assessment": r"风险评估([\s\S]*?)(?=###\s*投资建议|$)" - } - - analysis_results = {} - for key, pattern in analysis_patterns.items(): - match = re.search(pattern, analysis_text, re.MULTILINE | re.DOTALL) - content = match.group(1).strip() if match else "" - # 移除markdown标记和多余的空白字符 - content = re.sub(r'[#\-*]', '', content).strip() - analysis_results[key] = content - print(f"提取到的{key}: {content[:100]}...") - - return { - "investment_suggestion": investment_suggestion, - "analysis": analysis_results, - "price_analysis": { - "reasonable_price_range": { - "min": price_min, - "max": price_max - }, - "target_market_value": { - "min": market_value_min, - "max": market_value_max + stock_code = company_info.get('basic_info', {}).get('code') + today = self.ai_dao.get_today_date() + + # 如果不是强制刷新,尝试从数据库加载 + if not force_refresh and stock_code: + cached_result = self.ai_dao.get_analysis(stock_code, 'dao', today) + if cached_result: + logger.info(f"从数据库加载道德经分析结果: {stock_code}") + return self.ai_dao.format_analysis_data(cached_result) + + # 构建提示词 + prompt = self._build_tao_analysis_prompt(company_info) + + # 调用API + response = self.client.chat.completions.create( + model=self.model, + messages=[ + { + "role": "user", + "content": prompt } - } - } - + ] + ) + + # 获取分析结果 + analysis_text = response.choices[0].message.content + logger.info(f"道德经分析完成: {stock_code}") + + try: + # 解析JSON结果 + analysis_result = json.loads(analysis_text) + + # 添加缓存标识 + analysis_result['from_cache'] = False + + # 保存到数据库 + if stock_code: + success = self.ai_dao.save_analysis(stock_code, 'dao', analysis_result, today) + if not success: + logger.warning(f"保存道德经分析结果失败: {stock_code}") + + return analysis_result + except json.JSONDecodeError as e: + logger.error(f"道德经分析结果JSON解析失败: {str(e)}") + return {"error": "分析结果格式错误", "from_cache": False} + except Exception as e: - print(f"解析分析结果失败: {str(e)}") - print(f"错误详情: {e.__class__.__name__}") - import traceback - print(f"错误堆栈: {traceback.format_exc()}") - return { - "investment_suggestion": "分析结果解析失败", - "analysis": { - "valuation_analysis": "解析失败", - "financial_health": "解析失败", - "growth_potential": "解析失败", - "risk_assessment": "解析失败" - }, - "price_analysis": { - "reasonable_price_range": { - "min": current_price * 0.8, - "max": current_price * 1.2 - }, - "target_market_value": { - "min": 0, - "max": 0 + logger.error(f"道德经分析失败: {str(e)}") + return {"error": f"道德经分析失败: {str(e)}", "from_cache": False} + + def analyze_by_masters(self, company_info: dict, value_analysis: dict, force_refresh: bool = False): + """ + 基于各位价值投资大咖的理念分析公司 + :param company_info: 公司信息 + :param value_analysis: 价值分析数据 + :param force_refresh: 是否强制刷新分析结果 + :return: AI分析结果 + """ + try: + stock_code = company_info.get('basic_info', {}).get('code') + today = self.ai_dao.get_today_date() + + # 如果不是强制刷新,尝试从数据库加载 + if not force_refresh and stock_code: + cached_result = self.ai_dao.get_analysis(stock_code, 'daka', today) + if cached_result: + logger.info(f"从数据库加载大咖分析结果: {stock_code}") + return self.ai_dao.format_analysis_data(cached_result) + + logger.info(f"开始大咖分析: {stock_code}") + + # 构建提示词 + prompt = self._build_masters_analysis_prompt(company_info, value_analysis) + + # 调用API + response = self.client.chat.completions.create( + model=self.model, + messages=[ + { + "role": "user", + "content": prompt } - } - } - + ] + ) + + # 获取分析结果 + analysis_text = response.choices[0].message.content + logger.info(f"大咖分析完成: {stock_code}") + + try: + # 解析JSON结果 + analysis_result = json.loads(analysis_text) + + # 添加缓存标识 + analysis_result['from_cache'] = False + + # 保存到数据库 + if stock_code: + success = self.ai_dao.save_analysis(stock_code, 'daka', analysis_result, today) + if not success: + logger.warning(f"保存大咖分析结果失败: {stock_code}") + + return analysis_result + except json.JSONDecodeError as e: + logger.error(f"大咖分析结果JSON解析失败: {str(e)}") + return {"error": "分析结果格式错误", "from_cache": False} + + except Exception as e: + logger.error(f"价值投资大咖分析失败: {str(e)}") + return {"error": f"价值投资大咖分析失败: {str(e)}", "from_cache": False} + + def get_analysis_history(self, stock_code: str, analysis_type: str, days: int = 30): + """获取分析历史""" + try: + return self.ai_dao.get_analysis_history(stock_code, analysis_type, days) + except Exception as e: + logger.error(f"获取分析历史失败: {stock_code}, {analysis_type}, 错误: {e}") + return [] + + def get_latest_analysis(self, stock_code: str, analysis_type: str): + """获取最新的分析结果""" + try: + latest = self.ai_dao.get_latest_analysis(stock_code, analysis_type) + if latest: + return self.ai_dao.format_analysis_data(latest) + return None + except Exception as e: + logger.error(f"获取最新分析失败: {stock_code}, {analysis_type}, 错误: {e}") + return None + + def get_all_analysis_types(self, stock_code: str, analysis_date: str = None): + """获取股票的所有类型分析""" + try: + if analysis_date is None: + analysis_date = self.ai_dao.get_today_date() + + records = self.ai_dao.get_all_analysis_types(stock_code, analysis_date) + results = {} + + for record in records: + analysis_type = record['analysis_type'] + results[analysis_type] = self.ai_dao.format_analysis_data(record) + + return results + except Exception as e: + logger.error(f"获取所有分析类型失败: {stock_code}, 错误: {e}") + return {} + + # 复用原有的提示词构建方法 def _build_analysis_prompt(self, data): """ 构建AI分析提示词 @@ -283,7 +276,7 @@ class AIAnalysisService: solvency = data.get('solvency', {}) cash_flow = data.get('cash_flow', {}) per_share = data.get('per_share', {}) - + # 格式化数值,保留4位小数 def format_number(value): try: @@ -304,7 +297,7 @@ class AIAnalysisService: return str(value) except: return "0.0000" - + # 格式化百分比,保留2位小数 def format_percent(value): try: @@ -403,66 +396,15 @@ class AIAnalysisService: # 组合完整的提示词 prompt = data_section + analysis_requirements - - return prompt - def analyze_tao_philosophy(self, company_info: dict, force_refresh: bool = False): - """ - 基于道德经理念分析公司 - :param company_info: 公司信息 - :param force_refresh: 是否强制刷新分析结果 - :return: AI分析结果 - """ - try: - stock_code = company_info.get('basic_info', {}).get('code') - - # 如果不是强制刷新,尝试从缓存加载 - if not force_refresh and stock_code: - cached_result = self.load_dao_cache(stock_code) - if cached_result: - print(f"从缓存加载道德经分析结果: {stock_code}") - return cached_result - - # 构建提示词 - prompt = self._build_tao_analysis_prompt(company_info) - - # 调用API - response = self.client.chat.completions.create( - model=self.model, - messages=[ - { - "role": "user", - "content": prompt - } - ] - ) - - # 获取分析结果 - analysis_text = response.choices[0].message.content - - try: - # 解析JSON结果 - analysis_result = json.loads(analysis_text) - - # 保存到缓存 - if stock_code: - self.save_dao_cache(stock_code, analysis_result) - - return analysis_result - except json.JSONDecodeError as e: - print(f"道德经分析结果JSON解析失败: {str(e)}") - return {"error": "分析结果格式错误"} - - except Exception as e: - print(f"道德经分析失败: {str(e)}") - return {"error": f"道德经分析失败: {str(e)}"} - + return prompt + def _build_tao_analysis_prompt(self, company_info: dict): """ 构建道德经分析提示词 """ basic_info = company_info.get('basic_info', {}) - + prompt = f"""请作为一位精通道德经的智者,运用道德经的智慧来分析{basic_info.get('name', '')}({basic_info.get('code', '')})这家公司。 公司基本信息: @@ -493,81 +435,20 @@ class AIAnalysisService: - 持有建议 请以JSON格式返回分析结果,包含以下字段: -1. tao_philosophy: 道德经视角的分析 -2. business_ethics: 企业道德评估 -3. investment_advice: 投资建议 +1. investment_suggestion: 投资建议(summary, action, key_points) +2. analysis: 详细分析(道德经视角, 企业道德评估, 风险评估) +3. price_analysis: 价格分析(合理价格区间, 目标市值区间) 分析要客观、专业、深入,同时体现道德经的智慧。""" - - return prompt - def analyze_by_masters(self, company_info: dict, value_analysis: dict, force_refresh: bool = False): - """ - 基于各位价值投资大咖的理念分析公司 - :param company_info: 公司信息 - :param value_analysis: 价值分析数据 - :param force_refresh: 是否强制刷新分析结果 - :return: AI分析结果 - """ - try: - stock_code = company_info.get('basic_info', {}).get('code') - - # 如果不是强制刷新,尝试从缓存加载 - if not force_refresh and stock_code: - cached_result = self.load_daka_cache(stock_code) - if cached_result: - print(f"从缓存加载大咖分析结果: {stock_code}") - return cached_result - - # 打印输入数据用于调试 - print(f"公司信息: {json.dumps(company_info, ensure_ascii=False, indent=2)}") - print(f"价值分析数据: {json.dumps(value_analysis, ensure_ascii=False, indent=2)}") - - # 构建提示词 - prompt = self._build_masters_analysis_prompt(company_info, value_analysis) - - # 打印提示词用于调试 - print(f"大咖分析提示词: {prompt}") - - # 调用API - response = self.client.chat.completions.create( - model=self.model, - messages=[ - { - "role": "user", - "content": prompt - } - ] - ) - - # 获取分析结果 - analysis_text = response.choices[0].message.content - print(f"AI原始返回结果: {analysis_text}") - - try: - # 解析JSON结果 - analysis_result = json.loads(analysis_text) - print(f"解析后的JSON结果: {json.dumps(analysis_result, ensure_ascii=False, indent=2)}") - - # 保存到缓存 - if stock_code: - self.save_daka_cache(stock_code, analysis_result) - - return analysis_result - except json.JSONDecodeError as e: - print(f"大咖分析结果JSON解析失败: {str(e)}") - return {"error": "分析结果格式错误"} - - except Exception as e: - print(f"价值投资大咖分析失败: {str(e)}") - return {"error": f"价值投资大咖分析失败: {str(e)}"} - + return prompt + def _build_masters_analysis_prompt(self, company_info: dict, value_analysis: dict): """ 构建价值投资大咖分析提示词 """ basic_info = company_info.get('basic_info', {}) - + # 从value_analysis中获取财务数据 valuation = value_analysis.get('valuation', {}) profitability = value_analysis.get('profitability', {}) @@ -577,7 +458,7 @@ class AIAnalysisService: cash_flow = value_analysis.get('cash_flow', {}) per_share = value_analysis.get('per_share', {}) stock_info = value_analysis.get('stock_info', {}) - + # 格式化百分比 def format_percent(value): if value is None: @@ -590,7 +471,7 @@ class AIAnalysisService: return f"{value:.2f}%" except: return '-' - + # 格式化数字 def format_number(value): if value is None: @@ -601,7 +482,7 @@ class AIAnalysisService: return f"{value:.4f}" except: return '-' - + prompt = f"""请分别以五位价值投资大咖的视角,分析{basic_info.get('name', '')}({basic_info.get('code', '')})这家公司。 公司基本信息: @@ -620,9 +501,6 @@ class AIAnalysisService: 当前市场信息: - 当前股价:{format_number(stock_info.get('current_price'))}元 - 总市值:{format_number(valuation.get('total_market_value'))}亿元 -- 流通市值:{format_number(valuation.get('circulating_market_value'))}亿元 -- 流通比例:{format_percent(valuation.get('circulating_ratio'))} -- 换手率:{format_percent(stock_info.get('turnover_ratio'))} 估值指标: - 市盈率(PE):{format_number(valuation.get('pe_ratio'))} @@ -632,42 +510,20 @@ class AIAnalysisService: 盈利能力指标: - ROE:{format_percent(profitability.get('roe'))} -- ROE(扣非):{format_percent(profitability.get('deducted_roe'))} -- ROA:{format_percent(profitability.get('roa'))} - 毛利率:{format_percent(profitability.get('gross_margin'))} - 净利率:{format_percent(profitability.get('net_margin'))} 成长能力指标: - 净利润增长率:{format_percent(growth.get('net_profit_growth'))} -- 扣非净利润增长率:{format_percent(growth.get('deducted_net_profit_growth'))} -- 营业总收入增长率:{format_percent(growth.get('revenue_growth'))} -- 营业收入增长率:{format_percent(growth.get('operating_revenue_growth'))} - -运营能力指标: -- 总资产周转率:{format_number(operation.get('asset_turnover'))} -- 存货周转率:{format_number(operation.get('inventory_turnover'))} -- 应收账款周转率:{format_number(operation.get('receivables_turnover'))} -- 流动资产周转率:{format_number(operation.get('current_asset_turnover'))} +- 营收增长率:{format_percent(growth.get('revenue_growth'))} 偿债能力指标: -- 流动比率:{format_number(solvency.get('current_ratio'))} -- 速动比率:{format_number(solvency.get('quick_ratio'))} - 资产负债率:{format_percent(solvency.get('debt_to_assets'))} -- 产权比率:{format_number(solvency.get('equity_ratio'))} - -现金流指标: -- 经营现金流/营收:{format_percent(cash_flow.get('ocf_to_revenue'))} -- 经营现金流/经营利润:{format_percent(cash_flow.get('ocf_to_operating_profit'))} -- 经营现金流同比增长:{format_percent(cash_flow.get('ocf_growth'))} 每股指标: - 每股收益(EPS):{format_number(per_share.get('eps'))}元 -- 每股收益(扣非):{format_number(per_share.get('deducted_eps'))}元 - 每股净资产:{format_number(per_share.get('bps'))}元 - 每股经营现金流:{format_number(per_share.get('ocfps'))}元 -- 每股留存收益:{format_number(per_share.get('retained_eps'))}元 -- 每股现金流量:{format_number(per_share.get('cfps'))}元 -- 每股息税前利润:{format_number(per_share.get('ebit_ps'))}元 请分别从以下五位投资大师的视角进行分析: @@ -708,12 +564,10 @@ class AIAnalysisService: - 是否值得长期持有(投资价值判断) 请以JSON格式返回分析结果,包含以下字段: -1. buffett_analysis: 巴菲特的分析观点 -2. graham_analysis: 格雷厄姆的分析观点 -3. lin_yuan_analysis: 林园的分析观点 -4. li_daxiao_analysis: 李大霄的分析观点 -5. duan_yongping_analysis: 段永平的分析观点 +1. investment_suggestion: 投资建议(summary, action, key_points) +2. analysis: 详细分析(巴菲特视角, 格雷厄姆视角, 林园视角, 李大霄视角, 段永平视角) +3. price_analysis: 价格分析(合理价格区间, 目标市值区间) -分析要客观、专业、深入,并体现每位投资大师的独特投资理念。请基于上述详细的财务数据进行分析(如果指标缺失或异常,请联网获取),尤其是定量指标的解读。""" - - return prompt \ No newline at end of file +分析要客观、专业、深入,并体现每位投资大师的独特投资理念。请基于上述详细的财务数据进行分析,尤其是定量指标的解读。""" + + return prompt \ No newline at end of file diff --git a/app/services/kline_service.py b/app/services/kline_service.py new file mode 100644 index 0000000..26dc7a6 --- /dev/null +++ b/app/services/kline_service.py @@ -0,0 +1,466 @@ +""" +K线数据服务 +获取和管理股票的K线数据(日K、周K、月K) +""" +import pandas as pd +import logging +from datetime import datetime, date, timedelta +from typing import List, Dict, Optional, Tuple +from app import pro +from app.database import DatabaseManager + +logger = logging.getLogger(__name__) + + +class KlineService: + def __init__(self): + self.db_manager = DatabaseManager() + self.logger = logging.getLogger(__name__) + + def get_kline_data(self, stock_code: str, kline_type: str = 'daily', + start_date: str = None, end_date: str = None, + limit: int = 100) -> List[Dict]: + """获取K线数据 + + Args: + stock_code: 股票代码 + kline_type: K线类型 (daily/weekly/monthly) + start_date: 开始日期 (YYYYMMDD) + end_date: 结束日期 (YYYYMMDD) + limit: 返回数据条数限制 + + Returns: + K线数据列表 + """ + try: + # 优先从数据库获取 + kline_data = self._get_kline_from_db(stock_code, kline_type, start_date, end_date, limit) + if kline_data: + return kline_data + + # 从API获取数据 + self.logger.info(f"从API获取 {stock_code} 的{self._get_kline_name(kline_type)}数据") + api_data = self._fetch_kline_from_api(stock_code, kline_type, start_date, end_date, limit) + + # 保存到数据库 + if api_data: + self._save_kline_to_db(api_data, kline_type) + return api_data + + return [] + + except Exception as e: + self.logger.error(f"获取K线数据失败: {stock_code}, {kline_type}, 错误: {e}") + return [] + + def _get_kline_from_db(self, stock_code: str, kline_type: str, + start_date: str = None, end_date: str = None, + limit: int = 100) -> List[Dict]: + """从数据库获取K线数据""" + try: + with self.db_manager.get_connection() as conn: + cursor = conn.cursor(dictionary=True) + + # 构建查询条件 + conditions = ["stock_code = %s", "kline_type = %s"] + params = [stock_code, kline_type] + + if start_date: + conditions.append("trade_date >= %s") + params.append(start_date) + + if end_date: + conditions.append("trade_date <= %s") + params.append(end_date) + + query = f""" + SELECT * FROM kline_data + WHERE {' AND '.join(conditions)} + ORDER BY trade_date DESC + LIMIT %s + """ + params.append(limit) + + cursor.execute(query, params) + klines = cursor.fetchall() + + # 转换日期格式并处理数据类型 + result = [] + for kline in klines: + result.append({ + 'date': kline['trade_date'].strftime('%Y-%m-%d'), + 'open': float(kline['open_price']), + 'high': float(kline['high_price']), + 'low': float(kline['low_price']), + 'close': float(kline['close_price']), + 'volume': int(kline['volume']), + 'amount': float(kline['amount']), + 'change_percent': float(kline['change_percent']) if kline['change_percent'] else None, + 'turnover_rate': float(kline['turnover_rate']) if kline['turnover_rate'] else None, + 'pe_ratio': float(kline['pe_ratio']) if kline['pe_ratio'] else None, + 'pb_ratio': float(kline['pb_ratio']) if kline['pb_ratio'] else None + }) + + cursor.close() + return result + + except Exception as e: + self.logger.error(f"从数据库获取K线数据失败: {e}") + return [] + + def _fetch_kline_from_api(self, stock_code: str, kline_type: str, + start_date: str = None, end_date: str = None, + limit: int = 100) -> List[Dict]: + """从tushare API获取K线数据""" + try: + # 确定ts_code格式 + if stock_code.startswith('6'): + ts_code = f"{stock_code}.SH" + elif stock_code.startswith(('0', '3')): + ts_code = f"{stock_code}.SZ" + elif stock_code.startswith('68'): + ts_code = f"{stock_code}.SH" + else: + self.logger.error(f"不支持的股票代码: {stock_code}") + return [] + + # 根据K线类型选择API接口 + if kline_type == 'daily': + df = self._fetch_daily_data(ts_code, start_date, end_date, limit) + elif kline_type == 'weekly': + df = self._fetch_weekly_data(ts_code, start_date, end_date, limit) + elif kline_type == 'monthly': + df = self._fetch_monthly_data(ts_code, start_date, end_date, limit) + else: + self.logger.error(f"不支持的K线类型: {kline_type}") + return [] + + if df is None or df.empty: + self.logger.warning(f"未获取到 {stock_code} 的{self._get_kline_name(kline_type)}数据") + return [] + + # 转换为标准格式 + result = [] + for _, row in df.iterrows(): + try: + kline_data = { + 'stock_code': stock_code, + 'trade_date': pd.to_datetime(row['trade_date']).date(), + 'open_price': float(row['open']), + 'high_price': float(row['high']), + 'low_price': float(row['low']), + 'close_price': float(row['close']), + 'volume': int(row['vol']) if pd.notna(row.get('vol')) else 0, + 'amount': float(row.get('amount', 0)) / 10000 if pd.notna(row.get('amount')) else 0, # 转换为万元 + 'change_percent': float(row['pct_chg']) / 100 if pd.notna(row.get('pct_chg')) else 0, # 转换为小数 + 'change_amount': float(row.get('change', 0)) if pd.notna(row.get('change')) else 0, + } + + # 获取额外的估值指标 + self._add_valuation_data(kline_data, ts_code, row['trade_date']) + + result.append(kline_data) + + except Exception as e: + self.logger.error(f"处理K线数据行失败: {e}") + continue + + self.logger.info(f"从API获取到 {len(result)} 条{self._get_kline_name(kline_type)}数据") + return result + + except Exception as e: + self.logger.error(f"从API获取K线数据失败: {stock_code}, {kline_type}, 错误: {e}") + return [] + + def _fetch_daily_data(self, ts_code: str, start_date: str = None, + end_date: str = None, limit: int = 100) -> pd.DataFrame: + """获取日线数据""" + try: + return pro.daily( + ts_code=ts_code, + start_date=start_date, + end_date=end_date, + limit=limit + ) + except Exception as e: + self.logger.error(f"获取日线数据失败: {ts_code}, 错误: {e}") + return pd.DataFrame() + + def _fetch_weekly_data(self, ts_code: str, start_date: str = None, + end_date: str = None, limit: int = 100) -> pd.DataFrame: + """获取周线数据""" + try: + return pro.weekly( + ts_code=ts_code, + start_date=start_date, + end_date=end_date, + limit=limit + ) + except Exception as e: + self.logger.error(f"获取周线数据失败: {ts_code}, 错误: {e}") + return pd.DataFrame() + + def _fetch_monthly_data(self, ts_code: str, start_date: str = None, + end_date: str = None, limit: int = 100) -> pd.DataFrame: + """获取月线数据""" + try: + return pro.monthly( + ts_code=ts_code, + start_date=start_date, + end_date=end_date, + limit=limit + ) + except Exception as e: + self.logger.error(f"获取月线数据失败: {ts_code}, 错误: {e}") + return pd.DataFrame() + + def _add_valuation_data(self, kline_data: Dict, ts_code: str, trade_date: str): + """添加估值数据""" + try: + # 获取当日的基本数据 + daily_basic = pro.daily_basic( + ts_code=ts_code, + trade_date=trade_date, + fields='ts_code,trade_date,pe,pb,dv_ratio,turnover_rate' + ) + + if not daily_basic.empty: + row = daily_basic.iloc[0] + kline_data['pe_ratio'] = float(row['pe']) if pd.notna(row['pe']) else None + kline_data['pb_ratio'] = float(row['pb']) if pd.notna(row['pb']) else None + kline_data['turnover_rate'] = float(row['turnover_rate']) if pd.notna(row['turnover_rate']) else None + kline_data['dividend_yield'] = float(row['dv_ratio']) / 100 if pd.notna(row['dv_ratio']) else 0 + + except Exception as e: + # 估值数据获取失败不影响主要数据 + pass + + def _save_kline_to_db(self, kline_data_list: List[Dict], kline_type: str): + """保存K线数据到数据库""" + try: + if not kline_data_list: + return + + with self.db_manager.get_connection() as conn: + cursor = conn.cursor() + + # 使用INSERT ... ON DUPLICATE KEY UPDATE批量保存 + query = """ + INSERT INTO kline_data ( + stock_code, kline_type, trade_date, open_price, high_price, low_price, + close_price, volume, amount, change_percent, change_amount, + turnover_rate, pe_ratio, pb_ratio, created_at + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()) + ON DUPLICATE KEY UPDATE + open_price = VALUES(open_price), + high_price = VALUES(high_price), + low_price = VALUES(low_price), + close_price = VALUES(close_price), + volume = VALUES(volume), + amount = VALUES(amount), + change_percent = VALUES(change_percent), + change_amount = VALUES(change_amount), + turnover_rate = VALUES(turnover_rate), + pe_ratio = VALUES(pe_ratio), + pb_ratio = VALUES(pb_ratio), + updated_at = NOW() + """ + + # 批量插入数据 + batch_data = [] + for kline_data in kline_data_list: + batch_data.append(( + kline_data['stock_code'], + kline_type, + kline_data['trade_date'], + kline_data['open_price'], + kline_data['high_price'], + kline_data['low_price'], + kline_data['close_price'], + kline_data['volume'], + kline_data['amount'], + kline_data['change_percent'], + kline_data['change_amount'], + kline_data.get('turnover_rate'), + kline_data.get('pe_ratio'), + kline_data.get('pb_ratio') + )) + + cursor.executemany(query, batch_data) + conn.commit() + cursor.close() + + self.logger.info(f"成功保存 {len(kline_data_list)} 条{self._get_kline_name(kline_type)}数据") + + except Exception as e: + self.logger.error(f"保存K线数据到数据库失败: {e}") + + def _get_kline_name(self, kline_type: str) -> str: + """获取K线类型的中文名称""" + type_names = { + 'daily': '日K', + 'weekly': '周K', + 'monthly': '月K' + } + return type_names.get(kline_type, kline_type) + + def batch_update_kline_data(self, stock_codes: List[str] = None, + kline_type: str = 'daily', + days_back: int = 30) -> Dict: + """批量更新K线数据 + + Args: + stock_codes: 股票代码列表,None表示更新所有股票 + kline_type: K线类型 + days_back: 更新最近多少天的数据 + + Returns: + 更新结果统计 + """ + try: + if stock_codes is None: + # 获取所有活跃股票 + with self.db_manager.get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT stock_code FROM stocks WHERE is_active = TRUE") + stock_codes = [row[0] for row in cursor.fetchall()] + cursor.close() + + total_count = len(stock_codes) + success_count = 0 + failed_count = 0 + + # 计算日期范围 + end_date = datetime.now().strftime('%Y%m%d') + start_date = (datetime.now() - timedelta(days=days_back)).strftime('%Y%m%d') + + self.logger.info(f"开始批量更新 {total_count} 只股票的{self._get_kline_name(kline_type)}数据") + + for i, stock_code in enumerate(stock_codes): + try: + kline_data = self._fetch_kline_from_api( + stock_code, kline_type, start_date, end_date, days_back + ) + + if kline_data: + self._save_kline_to_db(kline_data, kline_type) + success_count += 1 + else: + failed_count += 1 + + # 进度日志 + if (i + 1) % 50 == 0: + self.logger.info(f"进度: {i + 1}/{total_count}, 成功: {success_count}, 失败: {failed_count}") + + except Exception as e: + self.logger.error(f"更新股票 {stock_code} 的K线数据失败: {e}") + failed_count += 1 + continue + + result = { + 'total': total_count, + 'success': success_count, + 'failed': failed_count, + 'kline_type': kline_type, + 'days_back': days_back + } + + self.logger.info(f"批量更新完成: {result}") + return result + + except Exception as e: + self.logger.error(f"批量更新K线数据失败: {e}") + return {'error': str(e)} + + def get_market_overview(self, limit: int = 20) -> Dict: + """获取市场概览数据""" + try: + today = datetime.now().strftime('%Y-%m-%d') + + with self.db_manager.get_connection() as conn: + cursor = conn.cursor(dictionary=True) + + # 获取涨跌统计 + query = """ + SELECT + COUNT(*) as total_count, + SUM(CASE WHEN change_percent > 0 THEN 1 ELSE 0 END) as up_count, + SUM(CASE WHEN change_percent < 0 THEN 1 ELSE 0 END) as down_count, + SUM(CASE WHEN change_percent = 0 THEN 1 ELSE 0 END) as flat_count, + SUM(CASE WHEN change_percent >= 0.095 THEN 1 ELSE 0 END) as limit_up_count, + SUM(CASE WHEN change_percent <= -0.095 THEN 1 ELSE 0 END) as limit_down_count, + AVG(change_percent) as avg_change, + SUM(volume) as total_volume, + SUM(amount) as total_amount + FROM kline_data + WHERE kline_type = 'daily' AND trade_date = %s + """ + cursor.execute(query, (today,)) + stats = cursor.fetchone() + + # 获取涨幅榜 + cursor.execute(""" + SELECT stock_code, change_percent, close_price, volume + FROM kline_data + WHERE kline_type = 'daily' AND trade_date = %s AND change_percent IS NOT NULL + ORDER BY change_percent DESC + LIMIT %s + """, (today, limit)) + top_gainers = cursor.fetchall() + + # 获取跌幅榜 + cursor.execute(""" + SELECT stock_code, change_percent, close_price, volume + FROM kline_data + WHERE kline_type = 'daily' AND trade_date = %s AND change_percent IS NOT NULL + ORDER BY change_percent ASC + LIMIT %s + """, (today, limit)) + top_losers = cursor.fetchall() + + # 获取成交量榜 + cursor.execute(""" + SELECT stock_code, volume, amount, change_percent, close_price + FROM kline_data + WHERE kline_type = 'daily' AND trade_date = %s + ORDER BY volume DESC + LIMIT %s + """, (today, limit)) + volume_leaders = cursor.fetchall() + + cursor.close() + + return { + 'date': today, + 'statistics': stats, + 'top_gainers': top_gainers, + 'top_losers': top_losers, + 'volume_leaders': volume_leaders + } + + except Exception as e: + self.logger.error(f"获取市场概览失败: {e}") + return {} + + def clean_old_kline_data(self, days_to_keep: int = 365): + """清理旧的K线数据""" + try: + cutoff_date = (datetime.now() - timedelta(days=days_to_keep)).date() + + with self.db_manager.get_connection() as conn: + cursor = conn.cursor() + + # 删除指定日期之前的数据 + query = "DELETE FROM kline_data WHERE trade_date < %s" + cursor.execute(query, (cutoff_date,)) + deleted_count = cursor.rowcount + + conn.commit() + cursor.close() + + self.logger.info(f"清理了 {deleted_count} 条旧的K线数据") + return deleted_count + + except Exception as e: + self.logger.error(f"清理旧K线数据失败: {e}") + return 0 \ No newline at end of file diff --git a/app/services/market_data_service.py b/app/services/market_data_service.py new file mode 100644 index 0000000..c92318c --- /dev/null +++ b/app/services/market_data_service.py @@ -0,0 +1,400 @@ +""" +全市场股票数据服务 +获取和管理所有A股股票的基础数据、行业分类、K线数据等 +""" +import pandas as pd +import logging +from datetime import datetime, date, timedelta +from typing import List, Dict, Optional, Tuple +from app import pro +from app.dao import StockDAO +from app.database import DatabaseManager + +logger = logging.getLogger(__name__) + + +class MarketDataService: + def __init__(self): + self.stock_dao = StockDAO() + self.db_manager = DatabaseManager() + self.logger = logging.getLogger(__name__) + + def get_all_stock_list(self, force_refresh: bool = False) -> List[Dict]: + """获取所有A股股票列表""" + try: + # 如果不是强制刷新,先从数据库获取 + if not force_refresh: + stocks = self._get_stock_list_from_db() + if stocks: + self.logger.info(f"从数据库获取到 {len(stocks)} 只股票") + return stocks + + # 从tushare获取最新的股票列表 + self.logger.info("从tushare获取股票列表...") + return self._fetch_stock_list_from_api() + + except Exception as e: + self.logger.error(f"获取股票列表失败: {e}") + return [] + + def _get_stock_list_from_db(self) -> List[Dict]: + """从数据库获取股票列表""" + try: + with self.db_manager.get_connection() as conn: + cursor = conn.cursor(dictionary=True) + query = """ + SELECT s.*, i.industry_name, + GROUP_CONCAT(DISTINCT sec.sector_name) as sector_names + FROM stocks s + LEFT JOIN industries i ON s.industry_code = i.industry_code + LEFT JOIN stock_sector_relations ssr ON s.stock_code = ssr.stock_code + LEFT JOIN sectors sec ON ssr.sector_code = sec.sector_code + WHERE s.is_active = TRUE + GROUP BY s.stock_code + ORDER BY s.stock_code + """ + cursor.execute(query) + stocks = cursor.fetchall() + cursor.close() + return stocks + + except Exception as e: + self.logger.error(f"从数据库获取股票列表失败: {e}") + return [] + + def _fetch_stock_list_from_api(self) -> List[Dict]: + """从tushare API获取股票列表""" + try: + all_stocks = [] + + # 获取A股列表 + stock_basic = pro.stock_basic( + exchange='', + list_status='L', # L代表上市 + fields='ts_code,symbol,name,area,industry,market,list_date' + ) + + if stock_basic.empty: + self.logger.warning("未获取到股票数据") + return [] + + self.logger.info(f"获取到 {len(stock_basic)} 只股票基础信息") + + # 处理每只股票 + for _, row in stock_basic.iterrows(): + try: + stock_info = { + 'stock_code': row['symbol'], # 股票代码 (6位) + 'stock_name': row['name'], + 'market': row['market'], # 市场主板/创业板等 + 'industry_code': self._map_industry_code(row['industry']), + 'area': row.get('area', ''), + 'list_date': pd.to_datetime(row['list_date']).date() if pd.notna(row['list_date']) else None, + 'market_type': self._get_market_type(row['symbol'], row['market']), + 'is_active': True + } + + # 保存到数据库 + self._save_stock_to_db(stock_info) + all_stocks.append(stock_info) + + except Exception as e: + self.logger.error(f"处理股票 {row.get('symbol', 'unknown')} 失败: {e}") + continue + + self.logger.info(f"成功保存 {len(all_stocks)} 只股票到数据库") + return all_stocks + + except Exception as e: + self.logger.error(f"从API获取股票列表失败: {e}") + return [] + + def _save_stock_to_db(self, stock_info: Dict) -> bool: + """保存股票信息到数据库""" + try: + with self.db_manager.get_connection() as conn: + cursor = conn.cursor() + + # 使用INSERT ... ON DUPLICATE KEY UPDATE + query = """ + INSERT INTO stocks ( + stock_code, stock_name, market, industry_code, area, + list_date, market_type, is_active, created_at + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW()) + ON DUPLICATE KEY UPDATE + stock_name = VALUES(stock_name), + market = VALUES(market), + industry_code = VALUES(industry_code), + area = VALUES(area), + list_date = VALUES(list_date), + market_type = VALUES(market_type), + is_active = VALUES(is_active), + updated_at = NOW() + """ + + cursor.execute(query, ( + stock_info['stock_code'], + stock_info['stock_name'], + stock_info['market'], + stock_info['industry_code'], + stock_info['area'], + stock_info['list_date'], + stock_info['market_type'], + stock_info['is_active'] + )) + + conn.commit() + cursor.close() + return True + + except Exception as e: + self.logger.error(f"保存股票信息失败: {stock_info['stock_code']}, 错误: {e}") + return False + + def _map_industry_code(self, industry_name: str) -> Optional[str]: + """将行业名称映射到行业代码""" + if pd.isna(industry_name) or not industry_name: + return None + + industry_mapping = { + '计算机': 'I09', + '通信': 'I09', + '软件和信息技术服务业': 'I09', + '医药生物': 'Q17', + '生物医药': 'Q17', + '医疗器械': 'Q17', + '电子': 'C03', + '机械设备': 'C03', + '化工': 'C03', + '汽车': 'C03', + '房地产': 'K11', + '银行': 'J10', + '非银金融': 'J10', + '食品饮料': 'C03', + '农林牧渔': 'A01', + '采掘': 'B02', + '钢铁': 'C03', + '有色金属': 'C03', + '建筑材料': 'C03', + '建筑装饰': 'E05', + '电气设备': 'C03', + '国防军工': 'M13', + '交通运输': 'G07', + '公用事业': 'D04', + '传媒': 'R18', + '休闲服务': 'R18', + '家用电器': 'C03', + '纺织服装': 'C03', + '轻工制造': 'C03', + '商业贸易': 'F06', + '综合': 'S19' + } + + # 精确匹配 + if industry_name in industry_mapping: + return industry_mapping[industry_name] + + # 模糊匹配 + for key, code in industry_mapping.items(): + if key in industry_name or industry_name in key: + return code + + return 'C03' # 默认制造业 + + def _get_market_type(self, stock_code: str, market: str) -> str: + """获取市场类型""" + if stock_code.startswith('688'): + return '科创板' + elif stock_code.startswith('300'): + return '创业板' + elif stock_code.startswith('600') or stock_code.startswith('601') or stock_code.startswith('603') or stock_code.startswith('605'): + return '主板' + elif stock_code.startswith('000') or stock_code.startswith('001') or stock_code.startswith('002') or stock_code.startswith('003'): + return '主板' + elif stock_code.startswith('8') or stock_code.startswith('43'): + return '新三板' + else: + return market or '其他' + + def update_stock_sectors(self, stock_codes: List[str] = None) -> int: + """更新股票概念板块信息""" + try: + if stock_codes is None: + # 获取所有股票 + stock_codes = [stock['stock_code'] for stock in self._get_stock_list_from_db()] + + updated_count = 0 + total_count = len(stock_codes) + + for stock_code in stock_codes: + try: + # 这里可以调用概念板块API获取股票所属概念 + # 由于tushare概念接口限制,这里先做一些基础映射 + self._update_stock_concepts(stock_code) + updated_count += 1 + + if updated_count % 100 == 0: + self.logger.info(f"已更新 {updated_count}/{total_count} 只股票的概念信息") + + except Exception as e: + self.logger.error(f"更新股票 {stock_code} 概念信息失败: {e}") + continue + + self.logger.info(f"完成更新 {updated_count} 只股票的概念信息") + return updated_count + + except Exception as e: + self.logger.error(f"批量更新股票概念信息失败: {e}") + return 0 + + def _update_stock_concepts(self, stock_code: str): + """更新单个股票的概念信息""" + try: + # 基于股票代码做一些基础的概念分类 + concepts = [] + + # 根据股票代码前缀推断概念 + if stock_code.startswith('688'): + concepts.append('BK0500') # 半导体 + elif stock_code.startswith('300'): + concepts.append('BK0896') # 国产软件 + concepts.append('BK0735') # 新基建 + + # 这里可以扩展更多的概念匹配逻辑 + # 也可以调用第三方API获取更准确的概念分类 + + if concepts: + self._save_stock_concepts(stock_code, concepts) + + except Exception as e: + self.logger.error(f"更新股票 {stock_code} 概念失败: {e}") + + def _save_stock_concepts(self, stock_code: str, concept_codes: List[str]): + """保存股票概念关联关系""" + try: + with self.db_manager.get_connection() as conn: + cursor = conn.cursor() + + # 先删除现有的概念关联 + cursor.execute("DELETE FROM stock_sector_relations WHERE stock_code = %s", (stock_code,)) + + # 添加新的概念关联 + for concept_code in concept_codes: + query = """ + INSERT IGNORE INTO stock_sector_relations (stock_code, sector_code) + VALUES (%s, %s) + """ + cursor.execute(query, (stock_code, concept_code)) + + conn.commit() + cursor.close() + + except Exception as e: + self.logger.error(f"保存股票概念关联失败: {stock_code}, 错误: {e}") + + def get_stock_by_industry(self, industry_code: str = None, limit: int = 100) -> List[Dict]: + """根据行业获取股票列表""" + try: + with self.db_manager.get_connection() as conn: + cursor = conn.cursor(dictionary=True) + + if industry_code: + query = """ + SELECT s.*, i.industry_name + FROM stocks s + LEFT JOIN industries i ON s.industry_code = i.industry_code + WHERE s.industry_code = %s AND s.is_active = TRUE + ORDER BY s.stock_code + LIMIT %s + """ + cursor.execute(query, (industry_code, limit)) + else: + query = """ + SELECT s.*, i.industry_name + FROM stocks s + LEFT JOIN industries i ON s.industry_code = i.industry_code + WHERE s.is_active = TRUE + ORDER BY s.stock_code + LIMIT %s + """ + cursor.execute(query, (limit,)) + + stocks = cursor.fetchall() + cursor.close() + return stocks + + except Exception as e: + self.logger.error(f"根据行业获取股票失败: {e}") + return [] + + def get_stock_by_sector(self, sector_code: str, limit: int = 100) -> List[Dict]: + """根据概念板块获取股票列表""" + try: + with self.db_manager.get_connection() as conn: + cursor = conn.cursor(dictionary=True) + + query = """ + SELECT s.*, sec.sector_name + FROM stocks s + JOIN stock_sector_relations ssr ON s.stock_code = ssr.stock_code + JOIN sectors sec ON ssr.sector_code = sec.sector_code + WHERE ssr.sector_code = %s AND s.is_active = TRUE + ORDER BY s.stock_code + LIMIT %s + """ + cursor.execute(query, (sector_code, limit)) + + stocks = cursor.fetchall() + cursor.close() + return stocks + + except Exception as e: + self.logger.error(f"根据概念获取股票失败: {e}") + return [] + + def get_industry_list(self) -> List[Dict]: + """获取所有行业列表""" + try: + with self.db_manager.get_connection() as conn: + cursor = conn.cursor(dictionary=True) + + query = """ + SELECT i.industry_code, i.industry_name, i.level, + COUNT(s.stock_code) as stock_count + FROM industries i + LEFT JOIN stocks s ON i.industry_code = s.industry_code AND s.is_active = TRUE + GROUP BY i.industry_code, i.industry_name, i.level + ORDER BY i.industry_code + """ + cursor.execute(query) + industries = cursor.fetchall() + cursor.close() + return industries + + except Exception as e: + self.logger.error(f"获取行业列表失败: {e}") + return [] + + def get_sector_list(self) -> List[Dict]: + """获取所有概念板块列表""" + try: + with self.db_manager.get_connection() as conn: + cursor = conn.cursor(dictionary=True) + + query = """ + SELECT s.sector_code, s.sector_name, s.description, + COUNT(ssr.stock_code) as stock_count + FROM sectors s + LEFT JOIN stock_sector_relations ssr ON s.sector_code = ssr.sector_code + LEFT JOIN stocks st ON ssr.stock_code = st.stock_code AND st.is_active = TRUE + GROUP BY s.sector_code, s.sector_name, s.description + ORDER BY s.sector_code + """ + cursor.execute(query) + sectors = cursor.fetchall() + cursor.close() + return sectors + + except Exception as e: + self.logger.error(f"获取概念板块列表失败: {e}") + return [] \ No newline at end of file diff --git a/app/services/stock_service.py b/app/services/stock_service.py deleted file mode 100644 index a24fa6a..0000000 --- a/app/services/stock_service.py +++ /dev/null @@ -1,587 +0,0 @@ -import json -import os -from datetime import datetime -import pandas as pd -from app import pro -from app.config import Config -import numpy as np - -class StockService: - def __init__(self): - self.watchlist = {} - self.cache_file = os.path.join(Config.BASE_DIR, "stock_cache.json") - self.load_watchlist() - self.load_cache() - - def load_watchlist(self): - try: - if os.path.exists(Config.CONFIG_FILE): - with open(Config.CONFIG_FILE, 'r', encoding='utf-8') as f: - data = json.load(f) - self.watchlist = data.get('watchlist', {}) - except Exception as e: - print(f"Error loading watchlist: {str(e)}") - self.watchlist = {} - - def _save_watchlist(self): - try: - with open(Config.CONFIG_FILE, 'w', encoding='utf-8') as f: - json.dump({'watchlist': self.watchlist}, f, ensure_ascii=False, indent=4) - except Exception as e: - print(f"Error saving watchlist: {str(e)}") - - def load_cache(self): - try: - if os.path.exists(self.cache_file): - with open(self.cache_file, 'r', encoding='utf-8') as f: - self.cache_data = json.load(f) - else: - self.cache_data = {} - except Exception as e: - print(f"Error loading cache: {str(e)}") - self.cache_data = {} - - def save_cache(self, stock_code, data): - try: - self.cache_data[stock_code] = { - 'data': data, - 'timestamp': datetime.now().strftime('%Y-%m-%d') - } - with open(self.cache_file, 'w', encoding='utf-8') as f: - json.dump(self.cache_data, f, ensure_ascii=False, indent=4) - except Exception as e: - print(f"Error saving cache: {str(e)}") - - def get_stock_info(self, stock_code: str, force_refresh: bool = False): - try: - # 检查缓存 - today = datetime.now().strftime('%Y-%m-%d') - if not force_refresh and stock_code in self.cache_data and self.cache_data[stock_code]['timestamp'] == today: - print(f"从缓存获取股票 {stock_code} 的数据") - cached_data = self.cache_data[stock_code]['data'] - cached_data['stock_info']['from_cache'] = True - return cached_data - - # 如果强制刷新或缓存不存在或已过期,从API获取数据 - print(f"从API获取股票 {stock_code} 的数据...") - - # 处理股票代码格式 - if len(stock_code) != 6: - return {"error": "股票代码格式错误"} - - # 确定交易所 - if stock_code.startswith('6'): - ts_code = f"{stock_code}.SH" - elif stock_code.startswith(('0', '3')): - ts_code = f"{stock_code}.SZ" - else: - return {"error": "不支持的股票代码"} - - # 获取基本信息和总市值 - basic_info = pro.daily_basic(ts_code=ts_code, fields='ts_code,total_mv', limit=1) - if basic_info.empty: - return {"error": "股票代码不存在"} - - # 获取股票名称 - stock_name = pro.stock_basic(ts_code=ts_code, fields='name').iloc[0]['name'] - - # 获取最新财务指标 - fina_indicator = pro.fina_indicator(ts_code=ts_code, period=datetime.now().strftime('%Y%m%d'), fields='roe,grossprofit_margin,netprofit_margin,debt_to_assets,op_income_yoy,netprofit_yoy,bps,ocfps') - if fina_indicator.empty: - fina_indicator = pro.fina_indicator(ts_code=ts_code, limit=1) - - # 获取实时行情 - today = datetime.now().strftime('%Y%m%d') - daily_data = pro.daily(ts_code=basic_info['ts_code'].iloc[0], start_date=today, end_date=today) - if daily_data.empty: - daily_data = pro.daily(ts_code=basic_info['ts_code'].iloc[0], limit=1) - if daily_data.empty: - return {"error": "无法获取股票行情数据"} - - # 获取市值信息(用于其他指标) - daily_basic = pro.daily_basic(ts_code=basic_info['ts_code'].iloc[0], - fields='ts_code,trade_date,pe,pb,ps,dv_ratio', - limit=1) - - if daily_basic.empty: - return {"error": "无法获取股票基础数据"} - - latest_basic = daily_basic.iloc[0] - latest_fina = fina_indicator.iloc[0] if not fina_indicator.empty else pd.Series() - - # 计算实时总市值(单位:亿元) - current_price = float(daily_data['close'].iloc[0]) - market_value = float(basic_info['total_mv'].iloc[0]) / 10000 # 转换为亿元 - print(f"市值计算: 当前价格={current_price}, 总市值={market_value}亿元") - - # 处理股息率:tushare返回的是百分比值,需要转换为小数 - dv_ratio = float(latest_basic['dv_ratio']) if pd.notna(latest_basic['dv_ratio']) else 0 - dividend_yield = round(dv_ratio / 100, 4) # 转换为小数 - - # 处理财务指标,确保所有值都有默认值0,转换为小数 - roe = round(float(latest_fina['roe']) / 100, 4) if pd.notna(latest_fina.get('roe')) else 0 - gross_profit_margin = round(float(latest_fina['grossprofit_margin']) / 100, 4) if pd.notna(latest_fina.get('grossprofit_margin')) else 0 - net_profit_margin = round(float(latest_fina['netprofit_margin']) / 100, 4) if pd.notna(latest_fina.get('netprofit_margin')) else 0 - debt_to_assets = round(float(latest_fina['debt_to_assets']) / 100, 4) if pd.notna(latest_fina.get('debt_to_assets')) else 0 - revenue_yoy = round(float(latest_fina['op_income_yoy']) / 100, 4) if pd.notna(latest_fina.get('op_income_yoy')) else 0 - net_profit_yoy = round(float(latest_fina['netprofit_yoy']) / 100, 4) if pd.notna(latest_fina.get('netprofit_yoy')) else 0 - bps = round(float(latest_fina['bps']), 3) if pd.notna(latest_fina.get('bps')) else 0 # 保留3位小数 - ocfps = round(float(latest_fina['ocfps']), 3) if pd.notna(latest_fina.get('ocfps')) else 0 # 保留3位小数 - - stock_info = { - "code": stock_code, - "name": stock_name, - "market_value": round(market_value, 2), # 总市值(亿元) - "pe_ratio": round(float(latest_basic['pe']), 2) if pd.notna(latest_basic['pe']) else 0, # 市盈率 - "pb_ratio": round(float(latest_basic['pb']), 2) if pd.notna(latest_basic['pb']) else 0, # 市净率 - "ps_ratio": round(float(latest_basic['ps']), 2) if pd.notna(latest_basic['ps']) else 0, # 市销率 - "dividend_yield": dividend_yield, # 股息率(小数) - "price": round(current_price, 2), # 股价保留2位小数 - "change_percent": round(float(daily_data['pct_chg'].iloc[0]) / 100, 4), # 涨跌幅转换为小数 - # 财务指标(全部转换为小数) - "roe": roe, # ROE(小数) - "gross_profit_margin": gross_profit_margin, # 毛利率(小数) - "net_profit_margin": net_profit_margin, # 净利率(小数) - "debt_to_assets": debt_to_assets, # 资产负债率(小数) - "revenue_yoy": revenue_yoy, # 营收增长率(小数) - "net_profit_yoy": net_profit_yoy, # 净利润增长率(小数) - "bps": bps, # 每股净资产 - "ocfps": ocfps, # 每股经营现金流 - "from_cache": False - } - - # 获取目标值 - targets = self.watchlist.get(stock_code, {}) - - result = { - "stock_info": stock_info, - "targets": targets - } - - # 保存到缓存 - self.save_cache(stock_code, result) - - return result - except Exception as e: - print(f"Error fetching stock info: {str(e)}") - import traceback - print(f"详细错误: {traceback.format_exc()}") - return {"error": f"获取股票数据失败: {str(e)}"} - - def get_watchlist(self): - result = [] - for stock_code, targets in self.watchlist.items(): - try: - # 从缓存获取数据 - today = datetime.now().strftime('%Y-%m-%d') - if stock_code in self.cache_data and self.cache_data[stock_code]['timestamp'] == today: - result.append(self.cache_data[stock_code]['data']) - continue - - # 如果没有缓存,只获取基本信息 - if stock_code.startswith('6'): - ts_code = f"{stock_code}.SH" - elif stock_code.startswith(('0', '3')): - ts_code = f"{stock_code}.SZ" - else: - print(f"不支持的股票代码: {stock_code}") - continue - - # 获取股票名称 - stock_name = pro.stock_basic(ts_code=ts_code, fields='name').iloc[0]['name'] - - result.append({ - "stock_info": { - "code": stock_code, - "name": stock_name - }, - "targets": targets - }) - except Exception as e: - print(f"Error getting watchlist info for {stock_code}: {str(e)}") - continue - return result - - def add_watch(self, stock_code: str, target_market_value_min: float = None, target_market_value_max: float = None): - self.watchlist[stock_code] = { - "target_market_value": { - "min": target_market_value_min, - "max": target_market_value_max - } - } - self._save_watchlist() - return {"status": "success"} - - def remove_watch(self, stock_code: str): - if stock_code in self.watchlist: - del self.watchlist[stock_code] - # 同时删除缓存 - if stock_code in self.cache_data: - del self.cache_data[stock_code] - try: - with open(self.cache_file, 'w', encoding='utf-8') as f: - json.dump(self.cache_data, f, ensure_ascii=False, indent=4) - except Exception as e: - print(f"Error saving cache after removal: {str(e)}") - self._save_watchlist() - return {"status": "success"} - - def update_target(self, stock_code: str, target_market_value_min: float = None, target_market_value_max: float = None): - """更新股票的目标市值""" - if stock_code not in self.watchlist: - return {"error": "股票不在监控列表中"} - - self.watchlist[stock_code] = { - "target_market_value": { - "min": target_market_value_min, - "max": target_market_value_max - } - } - self._save_watchlist() - return {"status": "success"} - - def get_index_info(self): - """获取主要指数数据""" - try: - # 主要指数代码列表 - index_codes = { - '000001.SH': '上证指数', - '399001.SZ': '深证成指', - '399006.SZ': '创业板指', - '000016.SH': '上证50', - '000300.SH': '沪深300', - '000905.SH': '中证500', - '000852.SH': '中证1000', - '899050.BJ': '北证50', - } - - result = [] - for ts_code, name in index_codes.items(): - try: - # 获取指数基本信息 - df = pro.index_daily(ts_code=ts_code, limit=1) - if not df.empty: - data = df.iloc[0] - # 获取K线数据(最近20天) - kline_df = pro.index_daily(ts_code=ts_code, limit=20) - kline_data = [] - if not kline_df.empty: - for _, row in kline_df.iterrows(): - kline_data.append({ - 'date': row['trade_date'], - 'open': float(row['open']), - 'close': float(row['close']), - 'high': float(row['high']), - 'low': float(row['low']), - 'vol': float(row['vol']) - }) - - result.append({ - 'code': ts_code, - 'name': name, - 'price': float(data['close']), - 'change': float(data['pct_chg']), - 'kline_data': kline_data - }) - except Exception as e: - print(f"获取指数 {ts_code} 数据失败: {str(e)}") - continue - - return result - except Exception as e: - print(f"获取指数数据失败: {str(e)}") - return [] - - def get_company_detail(self, stock_code: str): - try: - print(f"开始获取公司详情: {stock_code}") - - # 处理股票代码格式 - if stock_code.startswith('6'): - ts_code = f"{stock_code}.SH" - elif stock_code.startswith(('0', '3')): - ts_code = f"{stock_code}.SZ" - else: - print(f"不支持的股票代码格式: {stock_code}") - return {"error": "不支持的股票代码"} - - print(f"转换后的ts_code: {ts_code}") - - # 获取公司基本信息 - basic = pro.stock_basic(ts_code=ts_code, fields='name,industry,area,list_date') - if basic.empty: - print(f"无法获取公司基本信息: {ts_code}") - return {"error": "无法获取公司信息"} - - company_info = basic.iloc[0] - print(f"获取到的公司基本信息: {company_info.to_dict()}") - - # 获取公司详细信息 - try: - company_detail = pro.stock_company(ts_code=ts_code) - if not company_detail.empty: - detail_info = company_detail.iloc[0] - company_detail_dict = { - "com_name": str(detail_info.get('com_name', '')), - "chairman": str(detail_info.get('chairman', '')), - "manager": str(detail_info.get('manager', '')), - "secretary": str(detail_info.get('secretary', '')), - "reg_capital": float(detail_info.get('reg_capital', 0)) if pd.notna(detail_info.get('reg_capital')) else 0, - "setup_date": str(detail_info.get('setup_date', '')), - "province": str(detail_info.get('province', '')), - "city": str(detail_info.get('city', '')), - "introduction": str(detail_info.get('introduction', '')), - "website": f"http://{str(detail_info.get('website', '')).strip('http://').strip('https://')}" if detail_info.get('website') else "", - "email": str(detail_info.get('email', '')), - "office": str(detail_info.get('office', '')), - "employees": int(detail_info.get('employees', 0)) if pd.notna(detail_info.get('employees')) else 0, - "main_business": str(detail_info.get('main_business', '')), - "business_scope": str(detail_info.get('business_scope', '')) - } - else: - company_detail_dict = { - "com_name": "", "chairman": "", "manager": "", "secretary": "", - "reg_capital": 0, "setup_date": "", "province": "", "city": "", - "introduction": "", "website": "", "email": "", "office": "", - "employees": 0, "main_business": "", "business_scope": "" - } - except Exception as e: - print(f"获取公司详细信息失败: {str(e)}") - company_detail_dict = { - "com_name": "", "chairman": "", "manager": "", "secretary": "", - "reg_capital": 0, "setup_date": "", "province": "", "city": "", - "introduction": "", "website": "", "email": "", "office": "", - "employees": 0, "main_business": "", "business_scope": "" - } - - # 获取最新财务指标 - try: - fina = pro.fina_indicator(ts_code=ts_code, period=datetime.now().strftime('%Y%m%d')) - if fina.empty: - print("当前期间无财务数据,尝试获取最新一期数据") - fina = pro.fina_indicator(ts_code=ts_code, limit=1) - - if fina.empty: - print(f"无法获取财务指标数据: {ts_code}") - return {"error": "无法获取财务数据"} - - fina_info = fina.iloc[0] - print(f"获取到的财务指标: {fina_info.to_dict()}") - except Exception as e: - print(f"获取财务指标失败: {str(e)}") - return {"error": "获取财务指标失败"} - - # 获取市值信息(用于PE、PB等指标) - try: - daily_basic = pro.daily_basic(ts_code=ts_code, fields='pe,pb,ps,dv_ratio', limit=1) - if not daily_basic.empty: - latest_basic = daily_basic.iloc[0] - else: - print("无法获取PE/PB数据") - latest_basic = pd.Series({'pe': 0, 'pb': 0, 'ps': 0, 'dv_ratio': 0}) - except Exception as e: - print(f"获取PE/PB失败: {str(e)}") - latest_basic = pd.Series({'pe': 0, 'pb': 0, 'ps': 0, 'dv_ratio': 0}) - - result = { - "basic_info": { - "name": str(company_info['name']), - "industry": str(company_info['industry']), - "list_date": str(company_info['list_date']), - "area": str(company_info['area']), - **company_detail_dict - }, - "financial_info": { - # 估值指标 - "pe_ratio": float(latest_basic['pe']) if pd.notna(latest_basic['pe']) else 0, - "pb_ratio": float(latest_basic['pb']) if pd.notna(latest_basic['pb']) else 0, - "ps_ratio": float(latest_basic['ps']) if pd.notna(latest_basic['ps']) else 0, - "dividend_yield": float(latest_basic['dv_ratio'])/100 if pd.notna(latest_basic['dv_ratio']) else 0, - - # 盈利能力 - "roe": float(fina_info['roe']) if pd.notna(fina_info.get('roe')) else 0, - "roe_dt": float(fina_info['roe_dt']) if pd.notna(fina_info.get('roe_dt')) else 0, - "roa": float(fina_info['roa']) if pd.notna(fina_info.get('roa')) else 0, - "grossprofit_margin": float(fina_info['grossprofit_margin']) if pd.notna(fina_info.get('grossprofit_margin')) else 0, - "netprofit_margin": float(fina_info['netprofit_margin']) if pd.notna(fina_info.get('netprofit_margin')) else 0, - - # 成长能力 - "netprofit_yoy": float(fina_info['netprofit_yoy']) if pd.notna(fina_info.get('netprofit_yoy')) else 0, - "dt_netprofit_yoy": float(fina_info['dt_netprofit_yoy']) if pd.notna(fina_info.get('dt_netprofit_yoy')) else 0, - "tr_yoy": float(fina_info['tr_yoy']) if pd.notna(fina_info.get('tr_yoy')) else 0, - "or_yoy": float(fina_info['or_yoy']) if pd.notna(fina_info.get('or_yoy')) else 0, - - # 营运能力 - "assets_turn": float(fina_info['assets_turn']) if pd.notna(fina_info.get('assets_turn')) else 0, - "inv_turn": float(fina_info['inv_turn']) if pd.notna(fina_info.get('inv_turn')) else 0, - "ar_turn": float(fina_info['ar_turn']) if pd.notna(fina_info.get('ar_turn')) else 0, - "ca_turn": float(fina_info['ca_turn']) if pd.notna(fina_info.get('ca_turn')) else 0, - - # 偿债能力 - "current_ratio": float(fina_info['current_ratio']) if pd.notna(fina_info.get('current_ratio')) else 0, - "quick_ratio": float(fina_info['quick_ratio']) if pd.notna(fina_info.get('quick_ratio')) else 0, - "debt_to_assets": float(fina_info['debt_to_assets']) if pd.notna(fina_info.get('debt_to_assets')) else 0, - "debt_to_eqt": float(fina_info['debt_to_eqt']) if pd.notna(fina_info.get('debt_to_eqt')) else 0, - - # 现金流 - "ocf_to_or": float(fina_info['ocf_to_or']) if pd.notna(fina_info.get('ocf_to_or')) else 0, - "ocf_to_opincome": float(fina_info['ocf_to_opincome']) if pd.notna(fina_info.get('ocf_to_opincome')) else 0, - "ocf_yoy": float(fina_info['ocf_yoy']) if pd.notna(fina_info.get('ocf_yoy')) else 0, - - # 每股指标 - "eps": float(fina_info['eps']) if pd.notna(fina_info.get('eps')) else 0, - "dt_eps": float(fina_info['dt_eps']) if pd.notna(fina_info.get('dt_eps')) else 0, - "bps": float(fina_info['bps']) if pd.notna(fina_info.get('bps')) else 0, - "ocfps": float(fina_info['ocfps']) if pd.notna(fina_info.get('ocfps')) else 0, - "retainedps": float(fina_info['retainedps']) if pd.notna(fina_info.get('retainedps')) else 0, - "cfps": float(fina_info['cfps']) if pd.notna(fina_info.get('cfps')) else 0, - "ebit_ps": float(fina_info['ebit_ps']) if pd.notna(fina_info.get('ebit_ps')) else 0, - "fcff_ps": float(fina_info['fcff_ps']) if pd.notna(fina_info.get('fcff_ps')) else 0, - "fcfe_ps": float(fina_info['fcfe_ps']) if pd.notna(fina_info.get('fcfe_ps')) else 0 - } - } - - print(f"返回结果: {result}") - return result - - except Exception as e: - print(f"Error getting company detail: {str(e)}") - import traceback - print(f"详细错误: {traceback.format_exc()}") - return {"error": f"获取公司详情失败: {str(e)}"} - - def get_top_holders(self, stock_code: str): - """获取前十大股东数据""" - try: - # 处理股票代码格式 - if stock_code.startswith('6'): - ts_code = f"{stock_code}.SH" - elif stock_code.startswith(('0', '3')): - ts_code = f"{stock_code}.SZ" - else: - return {"error": "不支持的股票代码"} - - # 获取最新一期的股东数据 - df = pro.top10_holders(ts_code=ts_code, limit=10) - if df.empty: - return {"error": "暂无股东数据"} - - # 按持股比例降序排序 - df = df.sort_values('hold_ratio', ascending=False) - - # 获取最新的报告期 - latest_end_date = df['end_date'].max() - latest_data = df[df['end_date'] == latest_end_date] - - holders = [] - for _, row in latest_data.iterrows(): - holders.append({ - "holder_name": str(row['holder_name']), - "hold_amount": float(row['hold_amount']) if pd.notna(row['hold_amount']) else 0, - "hold_ratio": float(row['hold_ratio']) if pd.notna(row['hold_ratio']) else 0, - "hold_change": float(row['hold_change']) if pd.notna(row['hold_change']) else 0, - "ann_date": str(row['ann_date']), - "end_date": str(row['end_date']) - }) - - result = { - "holders": holders, - "total_ratio": sum(holder['hold_ratio'] for holder in holders), # 合计持股比例 - "report_date": str(latest_end_date) # 报告期 - } - - return result - - except Exception as e: - print(f"获取股东数据失败: {str(e)}") - import traceback - print(f"详细错误: {traceback.format_exc()}") - return {"error": f"获取股东数据失败: {str(e)}"} - - def get_value_analysis_data(self, stock_code: str): - """获取价值投资分析所需的关键财务指标""" - try: - # 处理股票代码格式 - if stock_code.startswith('6'): - ts_code = f"{stock_code}.SH" - elif stock_code.startswith(('0', '3')): - ts_code = f"{stock_code}.SZ" - else: - return {"error": "不支持的股票代码"} - - # 获取最新每日指标(估值数据) - daily_basic = pro.daily_basic(ts_code=ts_code, fields='pe,pb,ps,dv_ratio,total_mv', limit=1) - if daily_basic.empty: - return {"error": "无法获取股票估值数据"} - - # 获取最新财务指标 - fina = pro.fina_indicator(ts_code=ts_code, fields='''roe,grossprofit_margin,netprofit_margin, - netprofit_yoy,dt_netprofit_yoy,tr_yoy,or_yoy,assets_turn,inv_turn,ar_turn,current_ratio, - quick_ratio,debt_to_assets,ocf_to_or,ocf_yoy,eps,bps,cfps,ocfps,retainedps''', limit=1) - if fina.empty: - return {"error": "无法获取财务指标数据"} - - # 获取股票名称和当前价格 - basic_info = pro.daily(ts_code=ts_code, fields='close,trade_date', limit=1) - stock_name = pro.stock_basic(ts_code=ts_code, fields='name').iloc[0]['name'] - - # 整合数据 - latest_daily = daily_basic.iloc[0] - latest_fina = fina.iloc[0] - latest_price = basic_info.iloc[0] - - analysis_data = { - "stock_info": { - "code": stock_code, - "name": stock_name, - "current_price": float(latest_price['close']), - "trade_date": str(latest_price['trade_date']) - }, - "valuation": { - "pe_ratio": float(latest_daily['pe']) if pd.notna(latest_daily['pe']) else None, - "pb_ratio": float(latest_daily['pb']) if pd.notna(latest_daily['pb']) else None, - "ps_ratio": float(latest_daily['ps']) if pd.notna(latest_daily['ps']) else None, - "dividend_yield": float(latest_daily['dv_ratio'])/100 if pd.notna(latest_daily['dv_ratio']) else None, - "total_market_value": float(latest_daily['total_mv'])/10000 if pd.notna(latest_daily['total_mv']) else None # 转换为亿元 - }, - "profitability": { - "roe": float(latest_fina['roe'])/100 if pd.notna(latest_fina['roe']) else None, - "gross_margin": float(latest_fina['grossprofit_margin'])/100 if pd.notna(latest_fina['grossprofit_margin']) else None, - "net_margin": float(latest_fina['netprofit_margin'])/100 if pd.notna(latest_fina['netprofit_margin']) else None - }, - "growth": { - "net_profit_growth": float(latest_fina['netprofit_yoy'])/100 if pd.notna(latest_fina['netprofit_yoy']) else None, - "deducted_net_profit_growth": float(latest_fina['dt_netprofit_yoy'])/100 if pd.notna(latest_fina['dt_netprofit_yoy']) else None, - "revenue_growth": float(latest_fina['tr_yoy'])/100 if pd.notna(latest_fina['tr_yoy']) else None, - "operating_revenue_growth": float(latest_fina['or_yoy'])/100 if pd.notna(latest_fina['or_yoy']) else None - }, - "operation": { - "asset_turnover": float(latest_fina['assets_turn']) if pd.notna(latest_fina['assets_turn']) else None, - "inventory_turnover": float(latest_fina['inv_turn']) if pd.notna(latest_fina['inv_turn']) else None, - "receivables_turnover": float(latest_fina['ar_turn']) if pd.notna(latest_fina['ar_turn']) else None - }, - "solvency": { - "current_ratio": float(latest_fina['current_ratio']) if pd.notna(latest_fina['current_ratio']) else None, - "quick_ratio": float(latest_fina['quick_ratio']) if pd.notna(latest_fina['quick_ratio']) else None, - "debt_to_assets": float(latest_fina['debt_to_assets'])/100 if pd.notna(latest_fina['debt_to_assets']) else None - }, - "cash_flow": { - "ocf_to_revenue": float(latest_fina['ocf_to_or'])/100 if pd.notna(latest_fina['ocf_to_or']) else None, - "ocf_growth": float(latest_fina['ocf_yoy'])/100 if pd.notna(latest_fina['ocf_yoy']) else None - }, - "per_share": { - "eps": float(latest_fina['eps']) if pd.notna(latest_fina['eps']) else None, - "bps": float(latest_fina['bps']) if pd.notna(latest_fina['bps']) else None, - "cfps": float(latest_fina['cfps']) if pd.notna(latest_fina['cfps']) else None, - "ocfps": float(latest_fina['ocfps']) if pd.notna(latest_fina['ocfps']) else None, - "retained_eps": float(latest_fina['retainedps']) if pd.notna(latest_fina['retainedps']) else None - } - } - - return analysis_data - - except Exception as e: - print(f"获取价值投资分析数据失败: {str(e)}") - import traceback - print(f"详细错误: {traceback.format_exc()}") - return {"error": f"获取价值投资分析数据失败: {str(e)}"} \ No newline at end of file diff --git a/app/services/stock_service_db.py b/app/services/stock_service_db.py new file mode 100644 index 0000000..a66ab3a --- /dev/null +++ b/app/services/stock_service_db.py @@ -0,0 +1,603 @@ +""" +基于数据库的股票服务 +""" +import pandas as pd +from datetime import datetime, date +from app import pro +from app.dao import StockDAO, WatchlistDAO, ConfigDAO +from app.config import Config +import logging + +logger = logging.getLogger(__name__) + + +class StockServiceDB: + def __init__(self): + self.stock_dao = StockDAO() + self.watchlist_dao = WatchlistDAO() + self.config_dao = ConfigDAO() + self.logger = logging.getLogger(__name__) + + def get_stock_info(self, stock_code: str, force_refresh: bool = False): + """获取股票信息""" + try: + today = self.stock_dao.get_today_date() + + # 检查缓存 + if not force_refresh: + cached_data = self.stock_dao.get_stock_data(stock_code, today) + if cached_data: + self.logger.info(f"从数据库获取股票 {stock_code} 的数据") + return self._format_stock_data(cached_data, self.watchlist_dao.get_watchlist_item(stock_code)) + + # 从API获取数据 + self.logger.info(f"从API获取股票 {stock_code} 的数据...") + api_data = self._fetch_stock_from_api(stock_code) + if 'error' in api_data: + return api_data + + # 保存到数据库 + stock_info = api_data['stock_info'] + success = self.stock_dao.save_stock_data(stock_code, stock_info, today) + if not success: + self.logger.warning(f"保存股票数据失败: {stock_code}") + + # 获取目标值 + targets = self.watchlist_dao.get_watchlist_item(stock_code) + if targets: + api_data['targets'] = { + "target_market_value": { + "min": targets['target_market_value_min'], + "max": targets['target_market_value_max'] + } + } + + return api_data + + except Exception as e: + self.logger.error(f"获取股票信息失败: {stock_code}, 错误: {e}") + return {"error": f"获取股票数据失败: {str(e)}"} + + def _fetch_stock_from_api(self, stock_code: str): + """从API获取股票数据""" + try: + # 验证股票代码格式 + if len(stock_code) != 6: + return {"error": "股票代码格式错误"} + + # 确定交易所 + if stock_code.startswith('6'): + ts_code = f"{stock_code}.SH" + elif stock_code.startswith(('0', '3')): + ts_code = f"{stock_code}.SZ" + else: + return {"error": "不支持的股票代码"} + + # 获取基本信息 + basic_info = pro.daily_basic(ts_code=ts_code, fields='ts_code,total_mv', limit=1) + if basic_info.empty: + return {"error": "股票代码不存在"} + + # 获取股票名称 + stock_name = pro.stock_basic(ts_code=ts_code, fields='name').iloc[0]['name'] + + # 确保股票信息在数据库中 + market = 'SH' if stock_code.startswith('6') else 'SZ' + self.stock_dao.add_or_update_stock(stock_code, stock_name, market) + + # 获取最新财务指标 + fina_indicator = pro.fina_indicator( + ts_code=ts_code, + period=datetime.now().strftime('%Y%m%d'), + fields='roe,grossprofit_margin,netprofit_margin,debt_to_assets,op_income_yoy,netprofit_yoy,bps,ocfps' + ) + if fina_indicator.empty: + fina_indicator = pro.fina_indicator(ts_code=ts_code, limit=1) + + # 获取实时行情 + today = datetime.now().strftime('%Y%m%d') + daily_data = pro.daily(ts_code=basic_info['ts_code'].iloc[0], start_date=today, end_date=today) + if daily_data.empty: + daily_data = pro.daily(ts_code=basic_info['ts_code'].iloc[0], limit=1) + if daily_data.empty: + return {"error": "无法获取股票行情数据"} + + # 获取估值指标 + daily_basic = pro.daily_basic( + ts_code=basic_info['ts_code'].iloc[0], + fields='ts_code,trade_date,pe,pe_ttm,pb,ps,dv_ratio', + limit=1 + ) + + if daily_basic.empty: + return {"error": "无法获取股票基础数据"} + + latest_basic = daily_basic.iloc[0] + latest_fina = fina_indicator.iloc[0] if not fina_indicator.empty else pd.Series() + + # 计算市值 + current_price = float(daily_data['close'].iloc[0]) + market_value = float(basic_info['total_mv'].iloc[0]) / 10000 + + # 处理各种指标 + dv_ratio = float(latest_basic['dv_ratio']) if pd.notna(latest_basic['dv_ratio']) else 0 + dividend_yield = round(dv_ratio / 100, 4) + + stock_info = { + "code": stock_code, + "name": stock_name, + "market_value": round(market_value, 2), + "pe_ratio": round(float(latest_basic['pe']), 2) if pd.notna(latest_basic['pe']) else 0, + "pe_ttm": round(float(latest_basic['pe_ttm']), 2) if pd.notna(latest_basic.get('pe_ttm')) else 0, + "pb_ratio": round(float(latest_basic['pb']), 2) if pd.notna(latest_basic['pb']) else 0, + "ps_ratio": round(float(latest_basic['ps']), 2) if pd.notna(latest_basic['ps']) else 0, + "dividend_yield": dividend_yield, + "price": round(current_price, 2), + "change_percent": round(float(daily_data['pct_chg'].iloc[0]) / 100, 4), + # 财务指标 + "roe": round(float(latest_fina['roe']) / 100, 4) if pd.notna(latest_fina.get('roe')) else 0, + "gross_profit_margin": round(float(latest_fina['grossprofit_margin']) / 100, 4) if pd.notna(latest_fina.get('grossprofit_margin')) else 0, + "net_profit_margin": round(float(latest_fina['netprofit_margin']) / 100, 4) if pd.notna(latest_fina.get('netprofit_margin')) else 0, + "debt_to_assets": round(float(latest_fina['debt_to_assets']) / 100, 4) if pd.notna(latest_fina.get('debt_to_assets')) else 0, + "revenue_yoy": round(float(latest_fina['op_income_yoy']) / 100, 4) if pd.notna(latest_fina.get('op_income_yoy')) else 0, + "net_profit_yoy": round(float(latest_fina['netprofit_yoy']) / 100, 4) if pd.notna(latest_fina.get('netprofit_yoy')) else 0, + "bps": round(float(latest_fina['bps']), 3) if pd.notna(latest_fina.get('bps')) else 0, + "ocfps": round(float(latest_fina['ocfps']), 3) if pd.notna(latest_fina.get('ocfps')) else 0, + "from_cache": False + } + + return {"stock_info": stock_info, "targets": {}} + + except Exception as e: + self.logger.error(f"从API获取股票数据失败: {stock_code}, 错误: {e}") + return {"error": f"获取股票数据失败: {str(e)}"} + + def _format_stock_data(self, stock_data: dict, watchlist_item: dict = None): + """格式化股票数据为API返回格式""" + stock_info = { + "code": stock_data['stock_code'], + "name": stock_data['stock_name'], + "market_value": stock_data['market_value'], + "pe_ratio": stock_data['pe_ratio'], + "pb_ratio": stock_data['pb_ratio'], + "ps_ratio": stock_data['ps_ratio'], + "dividend_yield": stock_data['dividend_yield'], + "price": stock_data['price'], + "change_percent": stock_data['change_percent'], + "roe": stock_data['roe'], + "gross_profit_margin": stock_data['gross_profit_margin'], + "net_profit_margin": stock_data['net_profit_margin'], + "debt_to_assets": stock_data['debt_to_assets'], + "revenue_yoy": stock_data['revenue_yoy'], + "net_profit_yoy": stock_data['net_profit_yoy'], + "bps": stock_data['bps'], + "ocfps": stock_data['ocfps'], + "from_cache": stock_data['from_cache'] + } + + targets = {} + if watchlist_item: + targets = { + "target_market_value": { + "min": watchlist_item['target_market_value_min'], + "max": watchlist_item['target_market_value_max'] + } + } + + return {"stock_info": stock_info, "targets": targets} + + def get_watchlist(self): + """获取监控列表""" + try: + watchlist_data = self.watchlist_dao.get_watchlist_with_data() + result = [] + + for item in watchlist_data: + # 处理股票信息 + stock_info = { + "code": item['stock_code'], + "name": item['stock_name'] + } + + # 如果有股票数据,添加更多信息 + if item.get('price') is not None: + stock_info.update({ + "price": float(item['price']) if item['price'] else None, + "change_percent": float(item['change_percent']) if item['change_percent'] else None, + "market_value": float(item['current_market_value']) if item['current_market_value'] else None, + "pe_ratio": float(item['pe_ratio']) if item['pe_ratio'] else None, + "pb_ratio": float(item['pb_ratio']) if item['pb_ratio'] else None, + "from_cache": bool(item.get('from_cache', False)) + }) + + # 处理目标市值 + targets = {} + if item.get('target_market_value_min') is not None or item.get('target_market_value_max') is not None: + targets["target_market_value"] = { + "min": item.get('target_market_value_min'), + "max": item.get('target_market_value_max') + } + + result.append({ + "stock_info": stock_info, + "targets": targets + }) + + return result + except Exception as e: + self.logger.error(f"获取监控列表失败: {e}") + return [] + + def add_watch(self, stock_code: str, target_market_value_min: float = None, target_market_value_max: float = None): + """添加股票到监控列表""" + try: + success = self.watchlist_dao.add_to_watchlist( + stock_code, target_market_value_min, target_market_value_max + ) + return {"status": "success" if success else "failed"} + except Exception as e: + self.logger.error(f"添加监控股票失败: {stock_code}, 错误: {e}") + return {"error": f"添加监控股票失败: {str(e)}"} + + def remove_watch(self, stock_code: str): + """从监控列表移除股票""" + try: + success = self.watchlist_dao.remove_from_watchlist(stock_code) + return {"status": "success" if success else "failed"} + except Exception as e: + self.logger.error(f"移除监控股票失败: {stock_code}, 错误: {e}") + return {"error": f"移除监控股票失败: {str(e)}"} + + def update_target(self, stock_code: str, target_market_value_min: float = None, target_market_value_max: float = None): + """更新股票的目标市值""" + try: + success = self.watchlist_dao.update_watchlist_item( + stock_code, target_market_value_min, target_market_value_max + ) + return {"status": "success" if success else "failed"} + except Exception as e: + self.logger.error(f"更新目标市值失败: {stock_code}, 错误: {e}") + return {"error": f"更新目标市值失败: {str(e)}"} + + def get_index_info(self): + """获取主要指数数据(此功能保持不变,不需要数据库存储)""" + try: + index_codes = { + '000001.SH': '上证指数', + '399001.SZ': '深证成指', + '399006.SZ': '创业板指', + '000016.SH': '上证50', + '000300.SH': '沪深300', + '000905.SH': '中证500', + '000852.SH': '中证1000', + '899050.BJ': '北证50', + } + + result = [] + for ts_code, name in index_codes.items(): + try: + df = pro.index_daily(ts_code=ts_code, limit=1) + if not df.empty: + data = df.iloc[0] + # 获取K线数据(最近20天) + kline_df = pro.index_daily(ts_code=ts_code, limit=20) + kline_data = [] + if not kline_df.empty: + for _, row in kline_df.iterrows(): + kline_data.append({ + 'date': row['trade_date'], + 'open': float(row['open']), + 'close': float(row['close']), + 'high': float(row['high']), + 'low': float(row['low']), + 'vol': float(row['vol']) + }) + + result.append({ + 'code': ts_code, + 'name': name, + 'price': float(data['close']), + 'change': float(data['pct_chg']), + 'kline_data': kline_data + }) + except Exception as e: + self.logger.error(f"获取指数 {ts_code} 数据失败: {str(e)}") + continue + + return result + except Exception as e: + self.logger.error(f"获取指数数据失败: {str(e)}") + return [] + + def batch_update_watchlist_data(self): + """批量更新监控列表的股票数据""" + try: + # 获取需要更新的股票 + stocks_to_update = self.watchlist_dao.get_stocks_needing_update() + updated_count = 0 + failed_count = 0 + + for stock_code in stocks_to_update: + try: + result = self.get_stock_info(stock_code, force_refresh=True) + if 'error' not in result: + updated_count += 1 + else: + failed_count += 1 + except Exception as e: + self.logger.error(f"更新股票数据失败: {stock_code}, 错误: {e}") + failed_count += 1 + + # 更新最后更新日期 + self.config_dao.set_last_data_update_date(self.stock_dao.get_today_date()) + + return { + "total": len(stocks_to_update), + "updated": updated_count, + "failed": failed_count + } + + except Exception as e: + self.logger.error(f"批量更新监控列表数据失败: {e}") + return {"error": f"批量更新失败: {str(e)}"} + + # 保持原有的其他方法不变,这些方法不需要数据库存储 + def get_company_detail(self, stock_code: str): + """获取公司详情(从API获取,实时数据)""" + try: + # 处理股票代码格式 + if stock_code.startswith('6'): + ts_code = f"{stock_code}.SH" + elif stock_code.startswith(('0', '3')): + ts_code = f"{stock_code}.SZ" + else: + return {"error": "不支持的股票代码"} + + # 获取公司基本信息 + basic = pro.stock_basic(ts_code=ts_code, fields='name,industry,area,list_date') + if basic.empty: + return {"error": "无法获取公司信息"} + + company_info = basic.iloc[0] + + # 获取公司详细信息 + try: + company_detail = pro.stock_company(ts_code=ts_code) + if not company_detail.empty: + detail_info = company_detail.iloc[0] + company_detail_dict = { + "com_name": str(detail_info.get('com_name', '')), + "chairman": str(detail_info.get('chairman', '')), + "manager": str(detail_info.get('manager', '')), + "secretary": str(detail_info.get('secretary', '')), + "reg_capital": float(detail_info.get('reg_capital', 0)) if pd.notna(detail_info.get('reg_capital')) else 0, + "setup_date": str(detail_info.get('setup_date', '')), + "province": str(detail_info.get('province', '')), + "city": str(detail_info.get('city', '')), + "introduction": str(detail_info.get('introduction', '')), + "website": f"http://{str(detail_info.get('website', '')).strip('http://').strip('https://')}" if detail_info.get('website') else "", + "email": str(detail_info.get('email', '')), + "office": str(detail_info.get('office', '')), + "employees": int(detail_info.get('employees', 0)) if pd.notna(detail_info.get('employees')) else 0, + "main_business": str(detail_info.get('main_business', '')), + "business_scope": str(detail_info.get('business_scope', '')) + } + else: + company_detail_dict = { + "com_name": "", "chairman": "", "manager": "", "secretary": "", + "reg_capital": 0, "setup_date": "", "province": "", "city": "", + "introduction": "", "website": "", "email": "", "office": "", + "employees": 0, "main_business": "", "business_scope": "" + } + except Exception as e: + self.logger.error(f"获取公司详细信息失败: {str(e)}") + company_detail_dict = { + "com_name": "", "chairman": "", "manager": "", "secretary": "", + "reg_capital": 0, "setup_date": "", "province": "", "city": "", + "introduction": "", "website": "", "email": "", "office": "", + "employees": 0, "main_business": "", "business_scope": "" + } + + # 获取最新财务指标 + try: + fina = pro.fina_indicator(ts_code=ts_code, period=datetime.now().strftime('%Y%m%d')) + if fina.empty: + fina = pro.fina_indicator(ts_code=ts_code, limit=1) + + if fina.empty: + return {"error": "无法获取财务数据"} + + fina_info = fina.iloc[0] + except Exception as e: + self.logger.error(f"获取财务指标失败: {str(e)}") + return {"error": "获取财务指标失败"} + + # 获取市值信息(用于PE、PB等指标) + try: + daily_basic = pro.daily_basic(ts_code=ts_code, fields='pe,pb,ps,dv_ratio', limit=1) + if not daily_basic.empty: + latest_basic = daily_basic.iloc[0] + else: + latest_basic = pd.Series({'pe': 0, 'pb': 0, 'ps': 0, 'dv_ratio': 0}) + except Exception as e: + self.logger.error(f"获取PE/PB失败: {str(e)}") + latest_basic = pd.Series({'pe': 0, 'pb': 0, 'ps': 0, 'dv_ratio': 0}) + + result = { + "basic_info": { + "name": str(company_info['name']), + "industry": str(company_info['industry']), + "list_date": str(company_info['list_date']), + "area": str(company_info['area']), + **company_detail_dict + }, + "financial_info": { + # 估值指标 + "pe_ratio": float(latest_basic['pe']) if pd.notna(latest_basic['pe']) else 0, + "pb_ratio": float(latest_basic['pb']) if pd.notna(latest_basic['pb']) else 0, + "ps_ratio": float(latest_basic['ps']) if pd.notna(latest_basic['ps']) else 0, + "dividend_yield": float(latest_basic['dv_ratio'])/100 if pd.notna(latest_basic['dv_ratio']) else 0, + + # 盈利能力 + "roe": float(fina_info['roe']) if pd.notna(fina_info.get('roe')) else 0, + "grossprofit_margin": float(fina_info['grossprofit_margin']) if pd.notna(fina_info.get('grossprofit_margin')) else 0, + "netprofit_margin": float(fina_info['netprofit_margin']) if pd.notna(fina_info.get('netprofit_margin')) else 0, + + # 成长能力 + "netprofit_yoy": float(fina_info['netprofit_yoy']) if pd.notna(fina_info.get('netprofit_yoy')) else 0, + "or_yoy": float(fina_info['or_yoy']) if pd.notna(fina_info.get('or_yoy')) else 0, + + # 偿债能力 + "debt_to_assets": float(fina_info['debt_to_assets']) if pd.notna(fina_info.get('debt_to_assets')) else 0, + + # 每股指标 + "eps": float(fina_info['eps']) if pd.notna(fina_info.get('eps')) else 0, + "bps": float(fina_info['bps']) if pd.notna(fina_info.get('bps')) else 0, + "ocfps": float(fina_info['ocfps']) if pd.notna(fina_info.get('ocfps')) else 0, + } + } + + return result + + except Exception as e: + self.logger.error(f"获取公司详情失败: {stock_code}, 错误: {e}") + return {"error": f"获取公司详情失败: {str(e)}"} + + def get_top_holders(self, stock_code: str): + """获取前十大股东数据(从API获取,实时数据)""" + try: + # 处理股票代码格式 + if stock_code.startswith('6'): + ts_code = f"{stock_code}.SH" + elif stock_code.startswith(('0', '3')): + ts_code = f"{stock_code}.SZ" + else: + return {"error": "不支持的股票代码"} + + # 获取最新一期的股东数据 + df = pro.top10_holders(ts_code=ts_code, limit=10) + if df.empty: + return {"error": "暂无股东数据"} + + # 按持股比例降序排序 + df = df.sort_values('hold_ratio', ascending=False) + + # 获取最新的报告期 + latest_end_date = df['end_date'].max() + latest_data = df[df['end_date'] == latest_end_date] + + holders = [] + for _, row in latest_data.iterrows(): + holders.append({ + "holder_name": str(row['holder_name']), + "hold_amount": float(row['hold_amount']) if pd.notna(row['hold_amount']) else 0, + "hold_ratio": float(row['hold_ratio']) if pd.notna(row['hold_ratio']) else 0, + "hold_change": float(row['hold_change']) if pd.notna(row['hold_change']) else 0, + "ann_date": str(row['ann_date']), + "end_date": str(row['end_date']) + }) + + result = { + "holders": holders, + "total_ratio": sum(holder['hold_ratio'] for holder in holders), + "report_date": str(latest_end_date) + } + + return result + + except Exception as e: + self.logger.error(f"获取股东数据失败: {stock_code}, 错误: {e}") + return {"error": f"获取股东数据失败: {str(e)}"} + + def get_value_analysis_data(self, stock_code: str): + """获取价值投资分析数据(优先从数据库,如果没有则从API获取)""" + try: + # 先尝试从数据库获取今日数据 + today = self.stock_dao.get_today_date() + cached_data = self.stock_dao.get_stock_data(stock_code, today) + + if cached_data and not cached_data['from_cache']: + # 如果有今日的API数据(非缓存),直接使用 + return self._format_value_analysis_data(cached_data) + + # 否则从API获取 + api_result = self.get_stock_info(stock_code, force_refresh=True) + if 'error' in api_result: + return api_result + + stock_info = api_result['stock_info'] + return self._format_value_analysis_data_from_info(stock_info) + + except Exception as e: + self.logger.error(f"获取价值投资分析数据失败: {stock_code}, 错误: {e}") + return {"error": f"获取价值投资分析数据失败: {str(e)}"} + + def _format_value_analysis_data(self, stock_data: dict): + """格式化价值投资分析数据""" + return { + "stock_info": { + "code": stock_data['stock_code'], + "name": stock_data['stock_name'], + "current_price": stock_data['price'], + "trade_date": stock_data.get('data_date', self.stock_dao.get_today_date()) + }, + "valuation": { + "pe_ratio": stock_data['pe_ratio'], + "pb_ratio": stock_data['pb_ratio'], + "ps_ratio": stock_data['ps_ratio'], + "dividend_yield": stock_data['dividend_yield'], + "total_market_value": stock_data['market_value'] + }, + "profitability": { + "roe": stock_data['roe'], + "gross_margin": stock_data['gross_profit_margin'], + "net_margin": stock_data['net_profit_margin'] + }, + "growth": { + "net_profit_growth": stock_data['net_profit_yoy'], + "revenue_growth": stock_data['revenue_yoy'] + }, + "solvency": { + "debt_to_assets": stock_data['debt_to_assets'] + }, + "per_share": { + "eps": stock_data.get('eps', 0), # 这个字段在基础数据中没有,需要计算 + "bps": stock_data['bps'], + "ocfps": stock_data['ocfps'] + } + } + + def _format_value_analysis_data_from_info(self, stock_info: dict): + """从股票信息格式化价值投资分析数据""" + return { + "stock_info": { + "code": stock_info['code'], + "name": stock_info['name'], + "current_price": stock_info['price'], + "trade_date": self.stock_dao.get_today_date() + }, + "valuation": { + "pe_ratio": stock_info['pe_ratio'], + "pb_ratio": stock_info['pb_ratio'], + "ps_ratio": stock_info['ps_ratio'], + "dividend_yield": stock_info['dividend_yield'], + "total_market_value": stock_info['market_value'] + }, + "profitability": { + "roe": stock_info['roe'], + "gross_margin": stock_info['gross_profit_margin'], + "net_margin": stock_info['net_profit_margin'] + }, + "growth": { + "net_profit_growth": stock_info['net_profit_yoy'], + "revenue_growth": stock_info['revenue_yoy'] + }, + "solvency": { + "debt_to_assets": stock_info['debt_to_assets'] + }, + "per_share": { + "eps": stock_info.get('eps', 0), + "bps": stock_info['bps'], + "ocfps": stock_info['ocfps'] + } + } \ No newline at end of file diff --git a/app/templates/stocks_simple.html b/app/templates/stocks_simple.html new file mode 100644 index 0000000..11d9704 --- /dev/null +++ b/app/templates/stocks_simple.html @@ -0,0 +1,678 @@ + + + + + + 股票市场 - 股票监控系统 + + + + + +
+ + + +
+ +
+
+
+
+
+ + 市场概览 + +
+
+
+
+
+
+ 加载中... +
+

加载市场数据中...

+
+
+
+
+
总股票数
+

[[ overview.statistics.total_count ]]

+
+
+
上涨
+

[[ overview.statistics.up_count ]]

+
+
+
下跌
+

[[ overview.statistics.down_count ]]

+
+
+
成交量
+

[[ formatVolume(overview.statistics.total_volume) ]]

+
+
+
成交额
+

[[ formatAmount(overview.statistics.total_amount) ]]

+
+
+
+

暂无市场数据

+
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+
+ + +
+
+
+
+
+ + 股票列表 + [[ pagination.total ]] 只 +
+
+ + 显示第 [[ (pagination.page - 1) * pagination.size + 1 ]] - + [[ Math.min(pagination.page * pagination.size, pagination.total) ]] 条 + +
+
+
+
+
+ 加载中... +
+

加载股票数据中...

+
+ +
+ +

暂无股票数据,请先同步数据

+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
股票代码股票名称所属行业概念板块最新价涨跌幅成交量操作
+ [[ stock.stock_code ]] + [[ stock.market ]] + [[ stock.stock_name ]] + + [[ stock.industry_name || '未分类' ]] + + + + [[ sector ]] + + + [[ stock.price.toFixed(2) ]] + - + + [[ (stock.change_percent * 100).toFixed(2) ]]% + + - + [[ formatVolume(stock.volume) ]] + - + + +
+
+ + + +
+
+
+
+
+
+ + + +
+ + + + + + + + \ No newline at end of file diff --git a/backup/json_backup_20251124_093028/config.json b/backup/json_backup_20251124_093028/config.json new file mode 100644 index 0000000..4e5ae27 --- /dev/null +++ b/backup/json_backup_20251124_093028/config.json @@ -0,0 +1,22 @@ +{ + "watchlist": { + "600179": { + "target_market_value": { + "min": null, + "max": null + } + }, + "600589": { + "target_market_value": { + "min": null, + "max": null + } + }, + "002065": { + "target_market_value": { + "min": null, + "max": null + } + } + } +} \ No newline at end of file diff --git a/docs/database/apply_extended_schema.py b/docs/database/apply_extended_schema.py new file mode 100644 index 0000000..926c19f --- /dev/null +++ b/docs/database/apply_extended_schema.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +应用扩展数据库结构脚本 +执行新的数据库表结构和索引创建 +""" +import sys +import os +from pathlib import Path + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +from app.database import DatabaseManager + + +def apply_extended_schema(): + """应用扩展的数据库结构""" + print("正在应用扩展的数据库结构...") + + # 读取SQL脚本 + schema_file = project_root / "database_schema_extended.sql" + if not schema_file.exists(): + print(f"✗ 扩展数据库结构文件不存在: {schema_file}") + return False + + with open(schema_file, 'r', encoding='utf-8') as f: + sql_content = f.read() + + db_manager = DatabaseManager() + + try: + with db_manager.get_connection() as conn: + cursor = conn.cursor() + + # 分割SQL语句并执行 + statements = [stmt.strip() for stmt in sql_content.split(';') if stmt.strip()] + + for statement in statements: + if statement: + try: + cursor.execute(statement) + print(f"✓ 执行成功: {statement[:50]}...") + except Exception as e: + # 忽略表已存在的错误 + if "already exists" not in str(e) and "Duplicate" not in str(e): + print(f"⚠️ 警告: 执行SQL语句失败: {statement[:50]}... 错误: {e}") + + conn.commit() + print("✓ 扩展数据库结构应用成功") + cursor.close() + + # 验证新表是否创建成功 + verify_tables_created() + + return True + + except Exception as e: + print(f"✗ 应用扩展数据库结构失败: {e}") + return False + + +def verify_tables_created(): + """验证新表是否创建成功""" + print("正在验证新表是否创建成功...") + + db_manager = DatabaseManager() + new_tables = [ + 'industries', 'sectors', 'kline_data', 'stock_sector_relations', + 'market_statistics', 'data_update_tasks', 'hot_stocks' + ] + + try: + with db_manager.get_connection() as conn: + cursor = conn.cursor() + + for table_name in new_tables: + cursor.execute(f"SHOW TABLES LIKE '{table_name}'") + exists = cursor.fetchone() + + if exists: + print(f"✓ 表 {table_name} 创建成功") + else: + print(f"✗ 表 {table_name} 创建失败") + + cursor.close() + + except Exception as e: + print(f"✗ 验证表创建失败: {e}") + + +def main(): + """主函数""" + print("=" * 60) + print("应用扩展数据库结构") + print("=" * 60) + + success = apply_extended_schema() + + if success: + print("\n" + "=" * 60) + print("扩展数据库结构应用完成!") + print("=" * 60) + print("\n下一步操作:") + print("1. 启动应用系统: python run.py") + print("2. 访问 http://localhost:8000/api/market/stocks 查看股票列表") + print("3. 访问 http://localhost:8000/api/market/sync 同步市场数据") + print("=" * 60) + else: + print("✗ 扩展数据库结构应用失败,请检查错误信息") + + return success + + +if __name__ == "__main__": + try: + success = main() + sys.exit(0 if success else 1) + except KeyboardInterrupt: + print("\n数据库结构应用被用户中断") + sys.exit(1) + except Exception as e: + print(f"\n数据库结构应用过程中发生错误: {e}") + sys.exit(1) \ No newline at end of file diff --git a/docs/database/database_schema.sql b/docs/database/database_schema.sql new file mode 100644 index 0000000..d7a3bb5 --- /dev/null +++ b/docs/database/database_schema.sql @@ -0,0 +1,137 @@ +-- 股票监控系统数据库表结构 +-- Database: stock_monitor + +-- 1. 股票基础信息表 +CREATE TABLE IF NOT EXISTS stocks ( + id INT AUTO_INCREMENT PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL UNIQUE COMMENT '股票代码', + stock_name VARCHAR(50) NOT NULL COMMENT '股票名称', + market VARCHAR(10) NOT NULL COMMENT '市场(SH/SZ)', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_stock_code (stock_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='股票基础信息表'; + +-- 2. 股票实时数据表 +CREATE TABLE IF NOT EXISTS stock_data ( + id INT AUTO_INCREMENT PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + data_date DATE NOT NULL COMMENT '数据日期', + + -- 基本信息 + price DECIMAL(10,3) DEFAULT NULL COMMENT '当前股价', + change_percent DECIMAL(8,4) DEFAULT NULL COMMENT '涨跌幅', + market_value DECIMAL(12,3) DEFAULT NULL COMMENT '总市值(亿元)', + + -- 估值指标 + pe_ratio DECIMAL(8,4) DEFAULT NULL COMMENT '市盈率', + pb_ratio DECIMAL(8,4) DEFAULT NULL COMMENT '市净率', + ps_ratio DECIMAL(8,4) DEFAULT NULL COMMENT '市销率', + dividend_yield DECIMAL(8,4) DEFAULT NULL COMMENT '股息率', + + -- 财务指标 + roe DECIMAL(8,4) DEFAULT NULL COMMENT '净资产收益率', + gross_profit_margin DECIMAL(8,4) DEFAULT NULL COMMENT '销售毛利率', + net_profit_margin DECIMAL(8,4) DEFAULT NULL COMMENT '销售净利率', + debt_to_assets DECIMAL(8,4) DEFAULT NULL COMMENT '资产负债率', + revenue_yoy DECIMAL(8,4) DEFAULT NULL COMMENT '营收同比增长率', + net_profit_yoy DECIMAL(8,4) DEFAULT NULL COMMENT '净利润同比增长率', + bps DECIMAL(8,4) DEFAULT NULL COMMENT '每股净资产', + ocfps DECIMAL(8,4) DEFAULT NULL COMMENT '每股经营现金流', + + -- 元数据 + from_cache BOOLEAN DEFAULT FALSE COMMENT '是否来自缓存', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uk_stock_date (stock_code, data_date), + INDEX idx_stock_code (stock_code), + INDEX idx_data_date (data_date), + FOREIGN KEY (stock_code) REFERENCES stocks(stock_code) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='股票实时数据表'; + +-- 3. 用户监控列表表 +CREATE TABLE IF NOT EXISTS watchlist ( + id INT AUTO_INCREMENT PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + target_market_value_min DECIMAL(12,3) DEFAULT NULL COMMENT '目标市值最小值(亿元)', + target_market_value_max DECIMAL(12,3) DEFAULT NULL COMMENT '目标市值最大值(亿元)', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uk_stock_code (stock_code), + FOREIGN KEY (stock_code) REFERENCES stocks(stock_code) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户监控列表表'; + +-- 4. AI分析结果表 +CREATE TABLE IF NOT EXISTS ai_analysis ( + id INT AUTO_INCREMENT PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + analysis_type VARCHAR(20) NOT NULL COMMENT '分析类型(stock/dao/daka)', + analysis_date DATE NOT NULL COMMENT '分析日期', + + -- 投资建议 + investment_summary TEXT COMMENT '投资建议摘要', + investment_action TEXT COMMENT '建议操作', + investment_key_points JSON COMMENT '关键要点', + + -- 详细分析 + valuation_analysis TEXT COMMENT '估值分析', + financial_analysis TEXT COMMENT '财务分析', + growth_analysis TEXT COMMENT '成长性分析', + risk_analysis TEXT COMMENT '风险分析', + + -- 价格分析 + reasonable_price_min DECIMAL(10,3) DEFAULT NULL COMMENT '合理价格最小值', + reasonable_price_max DECIMAL(10,3) DEFAULT NULL COMMENT '合理价格最大值', + target_market_value_min DECIMAL(12,3) DEFAULT NULL COMMENT '目标市值最小值(亿元)', + target_market_value_max DECIMAL(12,3) DEFAULT NULL COMMENT '目标市值最大值(亿元)', + + -- 元数据 + from_cache BOOLEAN DEFAULT FALSE COMMENT '是否来自缓存', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uk_stock_type_date (stock_code, analysis_type, analysis_date), + INDEX idx_stock_code (stock_code), + INDEX idx_analysis_type (analysis_type), + INDEX idx_analysis_date (analysis_date), + FOREIGN KEY (stock_code) REFERENCES stocks(stock_code) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI分析结果表'; + +-- 5. 系统配置表 +CREATE TABLE IF NOT EXISTS system_config ( + id INT AUTO_INCREMENT PRIMARY KEY, + config_key VARCHAR(50) NOT NULL UNIQUE COMMENT '配置键', + config_value TEXT COMMENT '配置值', + config_type VARCHAR(20) DEFAULT 'string' COMMENT '配置类型', + description VARCHAR(200) DEFAULT NULL COMMENT '配置描述', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_config_key (config_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表'; + +-- 6. 数据更新日志表 +CREATE TABLE IF NOT EXISTS data_update_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + data_type VARCHAR(20) NOT NULL COMMENT '数据类型', + stock_code VARCHAR(10) DEFAULT NULL COMMENT '股票代码', + update_status ENUM('success', 'failed', 'partial') NOT NULL COMMENT '更新状态', + update_message TEXT COMMENT '更新消息', + execution_time DECIMAL(8,3) DEFAULT NULL COMMENT '执行时间(秒)', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + INDEX idx_data_type (data_type), + INDEX idx_stock_code (stock_code), + INDEX idx_update_status (update_status), + INDEX idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='数据更新日志表'; + +-- 插入系统默认配置 +INSERT INTO system_config (config_key, config_value, config_type, description) VALUES +('tushare_api_calls_today', '0', 'integer', 'Tushare API calls today'), +('last_data_update_date', '', 'date', 'Last data update date'), +('cache_expiration_hours', '24', 'integer', 'Cache expiration time in hours'), +('max_watchlist_size', '50', 'integer', 'Maximum watchlist size') +ON DUPLICATE KEY UPDATE config_value = VALUES(config_value); \ No newline at end of file diff --git a/docs/database/database_schema_extended.sql b/docs/database/database_schema_extended.sql new file mode 100644 index 0000000..bb9ef56 --- /dev/null +++ b/docs/database/database_schema_extended.sql @@ -0,0 +1,213 @@ +-- 股票监控系统扩展数据库结构 (支持全市场股票) +-- 在原有表结构基础上添加新功能 + +-- 1. 行业分类表 +CREATE TABLE IF NOT EXISTS industries ( + id INT AUTO_INCREMENT PRIMARY KEY, + industry_code VARCHAR(20) NOT NULL UNIQUE, + industry_name VARCHAR(100) NOT NULL, + parent_code VARCHAR(20) NULL, + level INT DEFAULT 1 COMMENT '行业层级', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_industry_code (industry_code), + INDEX idx_parent_code (parent_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 2. 概念/板块表 +CREATE TABLE IF NOT EXISTS sectors ( + id INT AUTO_INCREMENT PRIMARY KEY, + sector_code VARCHAR(20) NOT NULL UNIQUE, + sector_name VARCHAR(100) NOT NULL, + description TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_sector_code (sector_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 3. 扩展股票表,添加行业和板块信息 +ALTER TABLE stocks ADD COLUMN IF NOT EXISTS industry_code VARCHAR(20) NULL; +ALTER TABLE stocks ADD COLUMN IF NOT EXISTS sector_code VARCHAR(20) NULL; +ALTER TABLE stocks ADD COLUMN IF NOT EXISTS list_date DATE NULL COMMENT '上市日期'; +ALTER TABLE stocks ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT TRUE COMMENT '是否活跃交易'; +ALTER TABLE stocks ADD COLUMN IF NOT EXISTS market_type VARCHAR(20) NULL COMMENT '市场类型:主板/创业板/科创板等'; + +-- 添加外键约束 +ALTER TABLE stocks +ADD CONSTRAINT fk_stock_industry +FOREIGN KEY (industry_code) REFERENCES industries(industry_code) +ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE stocks +ADD CONSTRAINT fk_stock_sector +FOREIGN KEY (sector_code) REFERENCES sectors(sector_code) +ON DELETE SET NULL ON UPDATE CASCADE; + +-- 4. K线数据表 (日K、周K、月K) +CREATE TABLE IF NOT EXISTS kline_data ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + kline_type ENUM('daily', 'weekly', 'monthly') NOT NULL DEFAULT 'daily', + trade_date DATE NOT NULL, + open_price DECIMAL(10,3) NOT NULL, + high_price DECIMAL(10,3) NOT NULL, + low_price DECIMAL(10,3) NOT NULL, + close_price DECIMAL(10,3) NOT NULL, + volume BIGINT NOT NULL DEFAULT 0, + amount DECIMAL(15,2) NOT NULL DEFAULT 0 COMMENT '成交额(万元)', + change_percent DECIMAL(8,4) DEFAULT 0 COMMENT '涨跌幅(%)', + change_amount DECIMAL(10,3) DEFAULT 0 COMMENT '涨跌额', + turnover_rate DECIMAL(8,4) DEFAULT 0 COMMENT '换手率(%)', + pe_ratio DECIMAL(10,2) DEFAULT NULL COMMENT '市盈率', + pb_ratio DECIMAL(10,2) DEFAULT NULL COMMENT '市净率', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_stock_kline_date (stock_code, kline_type, trade_date), + INDEX idx_stock_code (stock_code), + INDEX idx_trade_date (trade_date), + INDEX idx_kline_type (kline_type), + INDEX idx_stock_type_date (stock_code, kline_type, trade_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 5. 股票-板块关联表 (支持多个概念板块) +CREATE TABLE IF NOT EXISTS stock_sector_relations ( + id INT AUTO_INCREMENT PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + sector_code VARCHAR(20) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_stock_sector (stock_code, sector_code), + INDEX idx_stock_code (stock_code), + INDEX idx_sector_code (sector_code), + FOREIGN KEY (stock_code) REFERENCES stocks(stock_code) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (sector_code) REFERENCES sectors(sector_code) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 6. 市场行情统计表 (每日统计) +CREATE TABLE IF NOT EXISTS market_statistics ( + id INT AUTO_INCREMENT PRIMARY KEY, + stat_date DATE NOT NULL UNIQUE, + market_code VARCHAR(10) NOT NULL COMMENT '市场代码: SH/SZ/BJ', + total_stocks INT DEFAULT 0 COMMENT '总股票数', + up_stocks INT DEFAULT 0 COMMENT '上涨股票数', + down_stocks INT DEFAULT 0 COMMENT '下跌股票数', + flat_stocks INT DEFAULT 0 COMMENT '平盘股票数', + limit_up_stocks INT DEFAULT 0 COMMENT '涨停股票数', + limit_down_stocks INT DEFAULT 0 COMMENT '跌停股票数', + total_volume BIGINT DEFAULT 0 COMMENT '总成交量', + total_amount DECIMAL(15,2) DEFAULT 0 COMMENT '总成交额(亿元)', + index_change DECIMAL(8,4) DEFAULT 0 COMMENT '主要指数涨跌幅', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_stat_date (stat_date), + INDEX idx_market_code (market_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 7. 数据更新任务表 +CREATE TABLE IF NOT EXISTS data_update_tasks ( + id INT AUTO_INCREMENT PRIMARY KEY, + task_name VARCHAR(100) NOT NULL, + task_type ENUM('daily_basic', 'kline_data', 'stock_list', 'industry_data') NOT NULL, + status ENUM('pending', 'running', 'completed', 'failed') DEFAULT 'pending', + start_time TIMESTAMP NULL, + end_time TIMESTAMP NULL, + processed_count INT DEFAULT 0 COMMENT '已处理数量', + total_count INT DEFAULT 0 COMMENT '总数量', + error_message TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_task_type (task_type), + INDEX idx_status (status), + INDEX idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 8. 热门股票统计表 +CREATE TABLE IF NOT EXISTS hot_stocks ( + id INT AUTO_INCREMENT PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + stat_date DATE NOT NULL, + rank_type ENUM('volume', 'amount', 'change', 'turnover') NOT NULL, + rank_position INT NOT NULL COMMENT '排名位置', + rank_value DECIMAL(15,2) NOT NULL COMMENT '排名值', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_hot_stock_rank (stock_code, stat_date, rank_type), + INDEX idx_stat_date (stat_date), + INDEX idx_rank_type (rank_type), + FOREIGN KEY (stock_code) REFERENCES stocks(stock_code) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 初始化基础行业数据 +INSERT IGNORE INTO industries (industry_code, industry_name, level) VALUES +('A01', '农林牧渔', 1), +('B02', '采矿业', 1), +('C03', '制造业', 1), +('D04', '电力、热力、燃气及水生产和供应业', 1), +('E05', '建筑业', 1), +('F06', '批发和零售业', 1), +('G07', '交通运输、仓储和邮政业', 1), +('H08', '住宿和餐饮业', 1), +('I09', '信息传输、软件和信息技术服务业', 1), +('J10', '金融业', 1), +('K11', '房地产业', 1), +('L12', '租赁和商务服务业', 1), +('M13', '科学研究和技术服务业', 1), +('N14', '水利、环境和公共设施管理业', 1), +('O15', '居民服务、修理和其他服务业', 1), +('P16', '教育', 1), +('Q17', '卫生和社会工作', 1), +('R18', '文化、体育和娱乐业', 1), +('S19', '综合', 1); + +-- 初始化主要概念板块 +INSERT IGNORE INTO sectors (sector_code, sector_name, description) VALUES +('BK0453', '新能源汽车', '新能源汽车产业链相关股票'), +('BK0885', '人工智能', '人工智能技术应用相关股票'), +('BK0500', '半导体', '半导体芯片设计、制造、封测相关股票'), +('BK0476', '医疗器械', '医疗器械设备和服务相关股票'), +('BK0727', '军工', '国防军工装备制造相关股票'), +('BK0489', '光伏概念', '光伏产业链相关股票'), +('BK0729', '5G概念', '第五代移动通信技术相关股票'), +('BK0896', '国产软件', '国产软件和信息服务相关股票'), +('BK0582', '碳中和', '碳中和发展目标相关股票'), +('BK0456', '生物医药', '生物制药和医药研发相关股票'), +('BK0857', '数字货币', '数字货币和区块链相关股票'), +('BK0735', '新基建', '新型基础设施建设相关股票'), +('BK0557', '大消费', '消费升级相关股票'), +('BK0726', '国企改革', '国有企业改革相关股票'), +('BK0439', '雄安新区', '雄安新区建设相关股票'); + +-- 更新 stocks 表的索引 +ALTER TABLE stocks ADD INDEX IF NOT EXISTS idx_industry_code (industry_code); +ALTER TABLE stocks ADD INDEX IF NOT EXISTS idx_sector_code (sector_code); +ALTER TABLE stocks ADD INDEX IF NOT EXISTS idx_market_type (market_type); +ALTER TABLE stocks ADD INDEX IF NOT EXISTS idx_is_active (is_active); + +-- 为现有数据添加一些示例的行业和板块分类 (如果需要的话) +UPDATE stocks +SET industry_code = 'I09', market_type = '创业板' +WHERE stock_code LIKE '00%' AND stock_code IN ('002065', '002415', '002230'); + +UPDATE stocks +SET industry_code = 'C03', market_type = '主板' +WHERE stock_code LIKE '60%' AND stock_code IN ('600589', '600179', '600000'); + +UPDATE stocks +SET industry_code = 'C03', market_type = '科创板' +WHERE stock_code LIKE '68%'; + +-- 创建视图便于查询 +CREATE OR REPLACE VIEW v_stock_detail AS +SELECT + s.stock_code, + s.stock_name, + s.market, + s.market_type, + i.industry_name, + GROUP_CONCAT(sec.sector_name) as sector_names, + s.list_date, + s.is_active, + s.created_at +FROM stocks s +LEFT JOIN industries i ON s.industry_code = i.industry_code +LEFT JOIN stock_sector_relations ssr ON s.stock_code = ssr.stock_code +LEFT JOIN sectors sec ON ssr.sector_code = sec.sector_code +WHERE s.is_active = TRUE +GROUP BY s.stock_code, s.stock_name, s.market, s.market_type, i.industry_name, s.list_date, s.is_active, s.created_at; \ No newline at end of file diff --git a/docs/database/database_schema_simple.sql b/docs/database/database_schema_simple.sql new file mode 100644 index 0000000..ce000be --- /dev/null +++ b/docs/database/database_schema_simple.sql @@ -0,0 +1,128 @@ +-- Stock Monitor Database Schema +-- Database: stock_monitor + +-- 1. Stocks table +CREATE TABLE IF NOT EXISTS stocks ( + id INT AUTO_INCREMENT PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL UNIQUE, + stock_name VARCHAR(50) NOT NULL, + market VARCHAR(10) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_stock_code (stock_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 2. Stock data table +CREATE TABLE IF NOT EXISTS stock_data ( + id INT AUTO_INCREMENT PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + data_date DATE NOT NULL, + + -- Basic info + price DECIMAL(10,3) DEFAULT NULL, + change_percent DECIMAL(8,4) DEFAULT NULL, + market_value DECIMAL(12,3) DEFAULT NULL, + + -- Valuation metrics + pe_ratio DECIMAL(8,4) DEFAULT NULL, + pb_ratio DECIMAL(8,4) DEFAULT NULL, + ps_ratio DECIMAL(8,4) DEFAULT NULL, + dividend_yield DECIMAL(8,4) DEFAULT NULL, + + -- Financial metrics + roe DECIMAL(8,4) DEFAULT NULL, + gross_profit_margin DECIMAL(8,4) DEFAULT NULL, + net_profit_margin DECIMAL(8,4) DEFAULT NULL, + debt_to_assets DECIMAL(8,4) DEFAULT NULL, + revenue_yoy DECIMAL(8,4) DEFAULT NULL, + net_profit_yoy DECIMAL(8,4) DEFAULT NULL, + bps DECIMAL(8,4) DEFAULT NULL, + ocfps DECIMAL(8,4) DEFAULT NULL, + + -- Metadata + from_cache BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uk_stock_date (stock_code, data_date), + INDEX idx_stock_code (stock_code), + INDEX idx_data_date (data_date), + FOREIGN KEY (stock_code) REFERENCES stocks(stock_code) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 3. Watchlist table +CREATE TABLE IF NOT EXISTS watchlist ( + id INT AUTO_INCREMENT PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + target_market_value_min DECIMAL(12,3) DEFAULT NULL, + target_market_value_max DECIMAL(12,3) DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uk_stock_code (stock_code), + FOREIGN KEY (stock_code) REFERENCES stocks(stock_code) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 4. AI Analysis table +CREATE TABLE IF NOT EXISTS ai_analysis ( + id INT AUTO_INCREMENT PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + analysis_type VARCHAR(20) NOT NULL, + analysis_date DATE NOT NULL, + + -- Investment suggestions + investment_summary TEXT, + investment_action TEXT, + investment_key_points JSON, + + -- Detailed analysis + valuation_analysis TEXT, + financial_analysis TEXT, + growth_analysis TEXT, + risk_analysis TEXT, + + -- Price analysis + reasonable_price_min DECIMAL(10,3) DEFAULT NULL, + reasonable_price_max DECIMAL(10,3) DEFAULT NULL, + target_market_value_min DECIMAL(12,3) DEFAULT NULL, + target_market_value_max DECIMAL(12,3) DEFAULT NULL, + + -- Metadata + from_cache BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uk_stock_type_date (stock_code, analysis_type, analysis_date), + INDEX idx_stock_code (stock_code), + INDEX idx_analysis_type (analysis_type), + INDEX idx_analysis_date (analysis_date), + FOREIGN KEY (stock_code) REFERENCES stocks(stock_code) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 5. System config table +CREATE TABLE IF NOT EXISTS system_config ( + id INT AUTO_INCREMENT PRIMARY KEY, + config_key VARCHAR(50) NOT NULL UNIQUE, + config_value TEXT, + config_type VARCHAR(20) DEFAULT 'string', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_config_key (config_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 6. Data update log table +CREATE TABLE IF NOT EXISTS data_update_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + data_type VARCHAR(20) NOT NULL, + stock_code VARCHAR(10) DEFAULT NULL, + update_status ENUM('success', 'failed', 'partial') NOT NULL, + update_message TEXT, + execution_time DECIMAL(8,3) DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + INDEX idx_data_type (data_type), + INDEX idx_stock_code (stock_code), + INDEX idx_update_status (update_status), + INDEX idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; \ No newline at end of file diff --git a/docs/database/init_database.py b/docs/database/init_database.py new file mode 100644 index 0000000..0fd06bb --- /dev/null +++ b/docs/database/init_database.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +数据库初始化脚本 +创建数据库表结构并初始化基础数据 +""" +import sys +import os +from pathlib import Path + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +from app.database import DatabaseManager +from app.config import Config + + +def create_database(): + """创建数据库""" + print("正在创建数据库...") + + # 创建数据库管理器,连接到MySQL服务器(不指定数据库) + db_manager = DatabaseManager() + + try: + with db_manager.get_connection() as conn: + cursor = conn.cursor() + + # 创建数据库 + cursor.execute(f"CREATE DATABASE IF NOT EXISTS {Config.MYSQL_DATABASE} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") + print(f"✓ 数据库 {Config.MYSQL_DATABASE} 创建成功") + + cursor.close() + + except Exception as e: + print(f"✗ 创建数据库失败: {e}") + return False + + return True + + +def create_tables(): + """创建数据表""" + print("正在创建数据表...") + + # 读取SQL脚本 + schema_file = project_root / "database_schema.sql" + if not schema_file.exists(): + print(f"✗ 数据库表结构文件不存在: {schema_file}") + return False + + with open(schema_file, 'r', encoding='utf-8') as f: + sql_content = f.read() + + db_manager = DatabaseManager() + + try: + with db_manager.get_connection() as conn: + cursor = conn.cursor() + + # 分割SQL语句并执行 + statements = [stmt.strip() for stmt in sql_content.split(';') if stmt.strip()] + + for statement in statements: + if statement: + try: + cursor.execute(statement) + except Exception as e: + # 忽略表已存在的错误 + if "already exists" not in str(e): + print(f"警告: 执行SQL语句失败: {statement[:50]}... 错误: {e}") + + conn.commit() + print("✓ 数据表创建成功") + cursor.close() + + except Exception as e: + print(f"✗ 创建数据表失败: {e}") + return False + + return True + + +def test_connection(): + """测试数据库连接""" + print("正在测试数据库连接...") + + try: + from app.dao import StockDAO, WatchlistDAO, AIAnalysisDAO, ConfigDAO + + # 测试各个DAO + stock_dao = StockDAO() + watchlist_dao = WatchlistDAO() + ai_dao = AIAnalysisDAO() + config_dao = ConfigDAO() + + # 获取数据库状态 + stock_count = stock_dao.get_stock_count() + watchlist_count = watchlist_dao.get_watchlist_count() + ai_count = ai_dao.get_analysis_count() + + print(f"✓ 数据库连接成功") + print(f" - 股票数量: {stock_count}") + print(f" - 监控列表: {watchlist_count}") + print(f" - AI分析: {ai_count}") + + return True + + except Exception as e: + print(f"✗ 数据库连接失败: {e}") + return False + + +def main(): + """主函数""" + print("=" * 60) + print("股票监控系统数据库初始化") + print("=" * 60) + print(f"数据库主机: {Config.MYSQL_HOST}:{Config.MYSQL_PORT}") + print(f"数据库名称: {Config.MYSQL_DATABASE}") + print(f"数据库用户: {Config.MYSQL_USER}") + print("=" * 60) + + # 1. 创建数据库 + if not create_database(): + print("数据库创建失败,初始化终止") + return False + + # 2. 创建数据表 + if not create_tables(): + print("数据表创建失败,初始化终止") + return False + + # 3. 测试连接 + if not test_connection(): + print("数据库连接测试失败,初始化终止") + return False + + print("\n" + "=" * 60) + print("数据库初始化完成!") + print("=" * 60) + print("\n下一步操作:") + print("1. 运行数据迁移脚本: python migrate_to_database.py") + print("2. 启动应用系统") + print("=" * 60) + + return True + + +if __name__ == "__main__": + try: + success = main() + sys.exit(0 if success else 1) + except KeyboardInterrupt: + print("\n数据库初始化被用户中断") + sys.exit(1) + except Exception as e: + print(f"\n数据库初始化过程中发生错误: {e}") + sys.exit(1) \ No newline at end of file diff --git a/docs/database/migrate_to_database.py b/docs/database/migrate_to_database.py new file mode 100644 index 0000000..24ce627 --- /dev/null +++ b/docs/database/migrate_to_database.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +""" +数据迁移脚本:将JSON文件数据迁移到MySQL数据库 +""" +import json +import os +import sys +from datetime import datetime +from pathlib import Path + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +from app.dao import StockDAO, WatchlistDAO, AIAnalysisDAO, ConfigDAO +from app.config import Config + + +class DataMigration: + def __init__(self): + self.stock_dao = StockDAO() + self.watchlist_dao = WatchlistDAO() + self.ai_dao = AIAnalysisDAO() + self.config_dao = ConfigDAO() + + # JSON文件路径 + self.config_file = Config.CONFIG_FILE + self.cache_file = os.path.join(Config.BASE_DIR, "stock_cache.json") + self.ai_cache_dir = os.path.join(Config.BASE_DIR, "ai_stock_analysis") + self.dao_cache_dir = os.path.join(Config.BASE_DIR, "dao_analysis") + self.daka_cache_dir = os.path.join(Config.BASE_DIR, "daka_analysis") + + print("数据迁移工具初始化完成") + print(f"配置文件: {self.config_file}") + print(f"股票缓存文件: {self.cache_file}") + print(f"AI分析缓存目录: {self.ai_cache_dir}") + + def migrate_watchlist(self): + """迁移监控列表""" + print("\n开始迁移监控列表...") + + if not os.path.exists(self.config_file): + print("配置文件不存在,跳过监控列表迁移") + return 0 + + try: + with open(self.config_file, 'r', encoding='utf-8') as f: + config_data = json.load(f) + + watchlist = config_data.get('watchlist', {}) + migrated_count = 0 + + for stock_code, targets in watchlist.items(): + try: + target_min = targets.get('target_market_value', {}).get('min') + target_max = targets.get('target_market_value', {}).get('max') + + success = self.watchlist_dao.add_to_watchlist( + stock_code, target_min, target_max + ) + + if success: + migrated_count += 1 + print(f"✓ 迁移监控股票: {stock_code}") + else: + print(f"✗ 迁移失败: {stock_code}") + + except Exception as e: + print(f"✗ 迁移股票 {stock_code} 失败: {e}") + + print(f"监控列表迁移完成,共迁移 {migrated_count} 支股票") + return migrated_count + + except Exception as e: + print(f"监控列表迁移失败: {e}") + return 0 + + def migrate_stock_cache(self): + """迁移股票缓存数据""" + print("\n开始迁移股票缓存数据...") + + if not os.path.exists(self.cache_file): + print("股票缓存文件不存在,跳过缓存数据迁移") + return 0 + + try: + with open(self.cache_file, 'r', encoding='utf-8') as f: + cache_data = json.load(f) + + migrated_count = 0 + + for stock_code, data in cache_data.items(): + try: + stock_info = data.get('data', {}).get('stock_info', {}) + timestamp = data.get('timestamp', datetime.now().strftime('%Y-%m-%d')) + + # 迁移股票信息 + if stock_info: + stock_name = stock_info.get('name', '') + market = 'SH' if stock_code.startswith('6') else 'SZ' + + # 添加或更新股票基础信息 + self.stock_dao.add_or_update_stock(stock_code, stock_name, market) + + # 保存股票数据 + success = self.stock_dao.save_stock_data( + stock_code, stock_info, timestamp + ) + + if success: + migrated_count += 1 + print(f"✓ 迁移股票数据: {stock_code} ({timestamp})") + else: + print(f"✗ 迁移失败: {stock_code}") + + except Exception as e: + print(f"✗ 迁移股票数据 {stock_code} 失败: {e}") + + print(f"股票缓存数据迁移完成,共迁移 {migrated_count} 条记录") + return migrated_count + + except Exception as e: + print(f"股票缓存数据迁移失败: {e}") + return 0 + + def migrate_ai_analysis(self, cache_dir: str, analysis_type: str): + """迁移AI分析数据""" + if not os.path.exists(cache_dir): + print(f"分析缓存目录不存在: {cache_dir}") + return 0 + + migrated_count = 0 + + try: + for filename in os.listdir(cache_dir): + if filename.endswith('.json'): + stock_code = filename[:-5] # 移除.json后缀 + file_path = os.path.join(cache_dir, filename) + + try: + with open(file_path, 'r', encoding='utf-8') as f: + analysis_data = json.load(f) + + # 获取文件修改时间作为分析日期 + file_mtime = os.path.getmtime(file_path) + analysis_date = datetime.fromtimestamp(file_mtime).strftime('%Y-%m-%d') + + # 保存到数据库 + success = self.ai_dao.save_analysis( + stock_code, analysis_type, analysis_data, analysis_date + ) + + if success: + migrated_count += 1 + print(f"✓ 迁移{analysis_type}分析: {stock_code}") + else: + print(f"✗ 迁移失败: {stock_code}") + + except Exception as e: + print(f"✗ 迁移{analysis_type}分析 {stock_code} 失败: {e}") + + return migrated_count + + except Exception as e: + print(f"{analysis_type}分析数据迁移失败: {e}") + return 0 + + def migrate_all_ai_analysis(self): + """迁移所有AI分析数据""" + print("\n开始迁移AI分析数据...") + + total_migrated = 0 + + # 迁移标准AI分析 + print("迁移标准AI分析...") + count = self.migrate_ai_analysis(self.ai_cache_dir, 'stock') + total_migrated += count + print(f"标准AI分析迁移完成,共 {count} 条") + + # 迁移道德经分析 + print("\n迁移道德经分析...") + count = self.migrate_ai_analysis(self.dao_cache_dir, 'dao') + total_migrated += count + print(f"道德经分析迁移完成,共 {count} 条") + + # 迁移大咖分析 + print("\n迁移大咖分析...") + count = self.migrate_ai_analysis(self.daka_cache_dir, 'daka') + total_migrated += count + print(f"大咖分析迁移完成,共 {count} 条") + + print(f"\nAI分析数据迁移完成,共迁移 {total_migrated} 条记录") + return total_migrated + + def backup_json_files(self): + """备份JSON文件""" + print("\n备份JSON文件...") + + backup_dir = os.path.join(Config.BASE_DIR, f"json_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}") + os.makedirs(backup_dir, exist_ok=True) + + files_to_backup = [ + (self.config_file, "config.json"), + (self.cache_file, "stock_cache.json") + ] + + directories_to_backup = [ + (self.ai_cache_dir, "ai_stock_analysis"), + (self.dao_cache_dir, "dao_analysis"), + (self.daka_cache_dir, "daka_analysis") + ] + + import shutil + + # 备份文件 + for file_path, filename in files_to_backup: + if os.path.exists(file_path): + shutil.copy2(file_path, os.path.join(backup_dir, filename)) + print(f"✓ 备份文件: {filename}") + + # 备份目录 + for dir_path, dirname in directories_to_backup: + if os.path.exists(dir_path): + shutil.copytree(dir_path, os.path.join(backup_dir, dirname), dirs_exist_ok=True) + print(f"✓ 备份目录: {dirname}") + + print(f"JSON文件备份完成,备份位置: {backup_dir}") + return backup_dir + + def run_full_migration(self): + """执行完整数据迁移""" + print("=" * 60) + print("开始从JSON到数据库的完整数据迁移") + print("=" * 60) + + # 备份JSON文件 + backup_dir = self.backup_json_files() + + # 执行迁移 + try: + watchlist_count = self.migrate_watchlist() + stock_cache_count = self.migrate_stock_cache() + ai_analysis_count = self.migrate_all_ai_analysis() + + print("\n" + "=" * 60) + print("数据迁移完成!") + print("=" * 60) + print(f"监控列表: {watchlist_count} 条") + print(f"股票缓存数据: {stock_cache_count} 条") + print(f"AI分析数据: {ai_analysis_count} 条") + print(f"JSON文件备份: {backup_dir}") + print("=" * 60) + + return True + + except Exception as e: + print(f"\n数据迁移过程中发生错误: {e}") + print("请检查数据库连接和权限设置") + return False + + def verify_migration(self): + """验证迁移结果""" + print("\n验证迁移结果...") + + try: + # 检查股票数据 + stock_count = self.stock_dao.get_stock_count() + print(f"数据库中股票数量: {stock_count}") + + # 检查监控列表 + watchlist_count = self.watchlist_dao.get_watchlist_count() + print(f"监控列表股票数量: {watchlist_count}") + + # 检查AI分析数据 + ai_analysis_count = self.ai_dao.get_analysis_count() + print(f"AI分析记录数量: {ai_analysis_count}") + + # 检查日期范围 + date_range = self.stock_dao.get_data_date_range() + if date_range: + print(f"数据日期范围: {date_range.get('min_date')} 至 {date_range.get('max_date')}") + + return True + + except Exception as e: + print(f"验证迁移结果失败: {e}") + return False + + +def main(): + """主函数""" + migration = DataMigration() + + print("数据迁移工具") + print("1. 执行完整迁移") + print("2. 仅迁移监控列表") + print("3. 仅迁移股票缓存") + print("4. 仅迁移AI分析") + print("5. 验证迁移结果") + + try: + choice = input("\n请选择操作 (1-5): ").strip() + + if choice == '1': + migration.run_full_migration() + migration.verify_migration() + elif choice == '2': + migration.migrate_watchlist() + elif choice == '3': + migration.migrate_stock_cache() + elif choice == '4': + migration.migrate_all_ai_analysis() + elif choice == '5': + migration.verify_migration() + else: + print("无效选择") + + except KeyboardInterrupt: + print("\n\n迁移被用户中断") + except Exception as e: + print(f"\n迁移过程中发生错误: {e}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/docs/database/test_database.py b/docs/database/test_database.py new file mode 100644 index 0000000..4f01b0e --- /dev/null +++ b/docs/database/test_database.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +数据库功能测试脚本 +""" +import sys +import os +from pathlib import Path + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +from app.dao import StockDAO, WatchlistDAO, AIAnalysisDAO, ConfigDAO +from app.services.stock_service_db import StockServiceDB +from app.services.ai_analysis_service_db import AIAnalysisServiceDB + + +def test_database_connection(): + """测试数据库连接""" + print("1. 测试数据库连接...") + + try: + from app.database import DatabaseManager + db_manager = DatabaseManager() + + with db_manager.get_cursor() as cursor: + cursor.execute("SELECT 1 as test") + result = cursor.fetchone() + + if result and result['test'] == 1: + print(" ✓ 数据库连接正常") + return True + else: + print(" ✗ 数据库连接异常") + return False + + except Exception as e: + print(f" ✗ 数据库连接失败: {e}") + return False + + +def test_dao_functions(): + """测试DAO层功能""" + print("\n2. 测试DAO层功能...") + + try: + # 测试各个DAO + stock_dao = StockDAO() + watchlist_dao = WatchlistDAO() + ai_dao = AIAnalysisDAO() + config_dao = ConfigDAO() + + # 测试基础查询 + stock_count = stock_dao.get_stock_count() + watchlist_count = watchlist_dao.get_watchlist_count() + ai_count = ai_dao.get_analysis_count() + + print(f" ✓ 股票数量: {stock_count}") + print(f" ✓ 监控列表: {watchlist_count}") + print(f" ✓ AI分析: {ai_count}") + + # 测试配置读写 + config_dao.set_config('test_key', 'test_value', 'string') + test_value = config_dao.get_config('test_key') + + if test_value == 'test_value': + print(" ✓ 配置读写正常") + else: + print(" ✗ 配置读写异常") + return False + + # 清理测试数据 + config_dao.delete_config('test_key') + + return True + + except Exception as e: + print(f" ✗ DAO层测试失败: {e}") + return False + + +def test_stock_service(): + """测试股票服务""" + print("\n3. 测试股票服务...") + + try: + stock_service = StockServiceDB() + + # 测试监控列表功能 + watchlist = stock_service.get_watchlist() + print(f" ✓ 获取监控列表: {len(watchlist)} 项") + + if watchlist: + # 测试获取股票信息(使用第一只股票) + stock_code = watchlist[0].get('stock_code') or watchlist[0].get('code') + if stock_code: + print(f" ✓ 测试股票: {stock_code}") + + # 测试获取股票信息 + stock_info = stock_service.get_stock_info(stock_code) + if 'error' not in stock_info: + print(" ✓ 股票信息获取正常") + else: + print(f" ✗ 股票信息获取失败: {stock_info.get('error')}") + return False + + # 测试指数信息 + index_info = stock_service.get_index_info() + if index_info: + print(f" ✓ 指数信息获取正常: {len(index_info)} 个指数") + else: + print(" ✗ 指数信息获取失败") + return False + + return True + + except Exception as e: + print(f" ✗ 股票服务测试失败: {e}") + return False + + +def test_ai_service(): + """测试AI分析服务""" + print("\n4. 测试AI分析服务...") + + try: + ai_service = AIAnalysisServiceDB() + stock_service = StockServiceDB() + + # 获取一只测试股票 + watchlist = stock_service.get_watchlist() + if not watchlist: + print(" ⚠️ 监控列表为空,跳过AI服务测试") + return True + + stock_code = watchlist[0].get('stock_code') or watchlist[0].get('code') + + # 测试价值分析数据获取 + value_data = stock_service.get_value_analysis_data(stock_code) + if 'error' not in value_data: + print(" ✓ 价值分析数据获取正常") + else: + print(f" ✗ 价值分析数据获取失败: {value_data.get('error')}") + return False + + # 测试AI分析历史记录 + history = ai_service.get_analysis_history(stock_code, 'stock', 7) + print(f" ✓ AI分析历史记录: {len(history)} 条") + + return True + + except Exception as e: + print(f" ✗ AI服务测试失败: {e}") + return False + + +def test_api_compatibility(): + """测试API兼容性""" + print("\n5. 测试API兼容性...") + + try: + from app.services.stock_service_db import StockServiceDB + from app.services.ai_analysis_service_db import AIAnalysisServiceDB + + # 测试服务实例化 + stock_service = StockServiceDB() + ai_service = AIAnalysisServiceDB() + + print(" ✓ 数据库服务实例化正常") + + # 测试方法是否存在 + required_methods = [ + 'get_stock_info', 'get_watchlist', 'add_watch', 'remove_watch', + 'update_target', 'get_index_info' + ] + + for method in required_methods: + if hasattr(stock_service, method): + print(f" ✓ 方法存在: {method}") + else: + print(f" ✗ 方法缺失: {method}") + return False + + return True + + except Exception as e: + print(f" ✗ API兼容性测试失败: {e}") + return False + + +def main(): + """主测试函数""" + print("=" * 60) + print("股票监控系统数据库功能测试") + print("=" * 60) + + tests = [ + test_database_connection, + test_dao_functions, + test_stock_service, + test_ai_service, + test_api_compatibility + ] + + passed = 0 + total = len(tests) + + for test in tests: + try: + if test(): + passed += 1 + except Exception as e: + print(f" 测试异常: {e}") + + print("\n" + "=" * 60) + print(f"测试完成!") + print(f"通过: {passed}/{total}") + print("=" * 60) + + if passed == total: + print("🎉 所有测试通过!数据库迁移成功!") + print("\n系统现在可以正常使用数据库存储。") + print("如需回滚到JSON文件存储,请参考 DATABASE_MIGRATION_GUIDE.md") + else: + print("⚠️ 部分测试未通过,请检查配置和数据库连接。") + + return passed == total + + +if __name__ == "__main__": + try: + success = main() + sys.exit(0 if success else 1) + except KeyboardInterrupt: + print("\n测试被用户中断") + sys.exit(1) + except Exception as e: + print(f"\n测试过程中发生错误: {e}") + sys.exit(1) \ No newline at end of file diff --git a/docs/guides/DATABASE_MIGRATION_GUIDE.md b/docs/guides/DATABASE_MIGRATION_GUIDE.md new file mode 100644 index 0000000..323abe6 --- /dev/null +++ b/docs/guides/DATABASE_MIGRATION_GUIDE.md @@ -0,0 +1,182 @@ +# 股票监控系统数据库迁移指南 + +本指南将帮助您将股票监控系统从JSON文件存储迁移到MySQL数据库存储。 + +## 迁移概述 + +### 迁移前状态 +- 监控列表:存储在 `config.json` 文件 +- 股票缓存:存储在 `stock_cache.json` 文件 +- AI分析结果:存储在各个目录的JSON文件中 + +### 迁移后状态 +- 所有数据统一存储在 `stock_monitor` MySQL数据库中 +- 支持数据查询、历史记录、缓存管理 +- 更好的性能和数据一致性 + +## 数据库结构 + +### 主要数据表 +1. **stocks** - 股票基础信息表 +2. **stock_data** - 股票实时数据表 +3. **watchlist** - 监控列表表 +4. **ai_analysis** - AI分析结果表 +5. **system_config** - 系统配置表 +6. **data_update_log** - 数据更新日志表 + +详细的表结构请参考 `database_schema.sql` 文件。 + +## 迁移步骤 + +### 1. 准备数据库环境 + +确保MySQL服务已启动,并且数据库连接配置正确。 + +在 `app/config.py` 中检查数据库配置: +```python +MYSQL_HOST = os.getenv('MYSQL_HOST', 'localhost') +MYSQL_PORT = int(os.getenv('MYSQL_PORT', 3306)) +MYSQL_USER = os.getenv('MYSQL_USER', 'root') +MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD', 'password') +MYSQL_DATABASE = os.getenv('MYSQL_DATABASE', 'stock_monitor') +``` + +### 2. 初始化数据库 + +运行数据库初始化脚本: + +```bash +python init_database.py +``` + +该脚本将: +- 创建 `stock_monitor` 数据库 +- 创建所有必要的数据表 +- 插入系统默认配置 +- 测试数据库连接 + +### 3. 执行数据迁移 + +运行数据迁移脚本: + +```bash +python migrate_to_database.py +``` + +该脚本提供以下选项: +1. 执行完整迁移 +2. 仅迁移监控列表 +3. 仅迁移股票缓存 +4. 仅迁移AI分析 +5. 验证迁移结果 + +建议选择选项1执行完整迁移。 + +### 4. 备份原始数据 + +迁移脚本会自动创建原始JSON文件的备份,备份位置格式为: +``` +json_backup_YYYYMMDD_HHMMSS/ +``` + +## 系统更改 + +### 新增文件 +- `app/database.py` - 数据库连接管理 +- `app/dao/` - 数据访问对象层 +- `app/services/stock_service_db.py` - 基于数据库的股票服务 +- `app/services/ai_analysis_service_db.py` - 基于数据库的AI分析服务 +- `init_database.py` - 数据库初始化脚本 +- `migrate_to_database.py` - 数据迁移脚本 + +### 修改文件 +- `app/api/stock_routes.py` - 更新为使用数据库服务 +- `app/config.py` - 添加数据库配置 + +## 使用说明 + +### 启动系统 +迁移完成后,正常启动系统即可: +```bash +uvicorn app.main:app --reload +``` + +### 验证迁移 +1. 检查监控列表是否完整迁移 +2. 验证股票数据是否正确加载 +3. 确认AI分析功能正常工作 +4. 测试新增和删除功能 + +### 功能对比 +| 功能 | 迁移前 | 迁移后 | +|------|--------|--------| +| 监控列表 | JSON文件 | 数据库表 | +| 股票数据缓存 | JSON文件 | 数据库表 | +| AI分析结果 | 文件缓存 | 数据库表 | +| 历史记录 | 无 | 支持 | +| 数据查询 | 文件解析 | SQL查询 | +| 性能 | 文件I/O | 数据库优化 | +| 数据备份 | 手动 | 数据库工具 | + +## 维护说明 + +### 数据备份 +定期备份数据库: +```bash +mysqldump -u root -p stock_monitor > backup_$(date +%Y%m%d).sql +``` + +### 日志监控 +查看 `data_update_log` 表了解数据更新状态: +```sql +SELECT * FROM data_update_log +WHERE update_status = 'failed' +ORDER BY created_at DESC; +``` + +### 性能优化 +1. 定期清理过期的股票数据 +2. 监控数据库连接池 +3. 优化查询索引 + +## 回滚方案 + +如果需要回滚到JSON文件存储: + +1. 恢复备份的JSON文件 +2. 修改 `app/api/stock_routes.py` 中的导入: + ```python + # 注释掉数据库服务 + # from app.services.stock_service_db import StockServiceDB + # from app.services.ai_analysis_service_db import AIAnalysisServiceDB + + # 恢复原始服务 + from app.services.stock_service import StockService + from app.services.ai_analysis_service import AIAnalysisService + ``` + +## 常见问题 + +### Q: 迁移过程中断怎么办? +A: 迁移脚本支持断点续传,重新运行会跳过已迁移的数据。 + +### Q: 数据库连接失败怎么解决? +A: 检查MySQL服务是否启动,连接配置是否正确,防火墙设置是否允许连接。 + +### Q: 迁移后数据不一致怎么办? +A: 使用验证功能检查迁移结果,或者重新执行迁移覆盖现有数据。 + +### Q: 如何清理测试数据? +A: 可以清空数据库表重新迁移,或者使用SQL语句删除特定数据。 + +## 技术支持 + +如果遇到迁移问题,请检查: +1. MySQL服务状态 +2. 数据库权限设置 +3. 网络连接 +4. 日志文件中的错误信息 + +--- + +**迁移完成后,您的股票监控系统将具备更好的性能、可靠性和扩展性!** \ No newline at end of file diff --git a/docs/guides/NEW_FEATURES_GUIDE.md b/docs/guides/NEW_FEATURES_GUIDE.md new file mode 100644 index 0000000..7695666 --- /dev/null +++ b/docs/guides/NEW_FEATURES_GUIDE.md @@ -0,0 +1,237 @@ +# 股票监控系统新功能使用指南 + +## 🎉 新功能概览 + +我们已经成功为股票监控系统添加了以下全市场股票功能: + +### ✅ 已实现的功能 + +1. **📊 全市场股票数据** + - 获取所有A股股票的基础信息 + - 支持按行业、概念板块分类浏览 + - 实时股票搜索和筛选 + +2. **📈 K线数据管理** + - 日K、周K、月K线数据存储 + - 支持历史K线数据查询 + - K线图表可视化展示 + +3. **🤖 自动化定时任务** + - 每日自动更新股票列表 + - 自动更新K线数据 + - 市场统计数据计算 + - 数据清理和维护 + +4. **🖥️ 前端用户界面** + - 股票市场浏览页面 + - 实时市场概览 + - 股票详情和K线图表 + - 行业和概念筛选 + +## 🚀 快速开始 + +### 1. 应用数据库结构 +```bash +python apply_extended_schema.py +``` +此脚本会创建新的数据库表结构,支持全市场股票数据。 + +### 2. 启动系统 +```bash +python run.py +``` +系统启动后,定时任务会自动开始运行。 + +### 3. 访问新功能 +- **股票市场页面**: http://localhost:8000/stocks +- **原有监控页面**: http://localhost:8000/ +- **指数行情页面**: http://localhost:8000/market + +## 📋 主要功能说明 + +### 股票市场页面 (/stocks) + +#### 市场概览 +- 显示全市场涨跌统计 +- 总成交量和成交额 +- 实时刷新市场数据 + +#### 股票浏览 +- **搜索功能**: 支持股票代码和名称搜索 +- **行业筛选**: 按行业分类浏览股票 +- **概念筛选**: 按概念板块浏览股票 +- **热门排行**: 成交量、成交额、涨幅排行榜 +- **分页显示**: 高效显示大量股票数据 + +#### 股票详情 +- 点击股票查看详细信息 +- K线图表展示(60天历史数据) +- 基本面指标和估值数据 +- 一键添加到监控列表 + +### API接口 + +#### 股票数据接口 +```bash +# 获取所有股票列表(支持分页和筛选) +GET /api/market/stocks?page=1&size=50&industry=I09&search=银行 + +# 获取股票详细信息 +GET /api/market/stocks/000001 + +# 获取K线数据 +GET /api/market/stocks/000001/kline?kline_type=daily&days=30 + +# 获取行业列表 +GET /api/market/industries + +# 获取概念板块列表 +GET /api/market/sectors +``` + +#### 市场统计接口 +```bash +# 获取市场概览 +GET /api/market/overview + +# 获取热门股票排行榜 +GET /api/market/hot-stocks?rank_type=volume&limit=20 + +# 同步市场数据 +POST /api/market/sync +``` + +#### 定时任务接口 +```bash +# 手动执行任务 +POST /api/market/tasks/update_stock_list +POST /api/market/tasks/update_daily_kline + +# 获取任务执行状态 +GET /api/market/tasks/status?days=7 +``` + +## 🔄 定时任务说明 + +系统内置了以下自动任务: + +### 每日任务 +- **09:00** - 更新股票列表(每周一) +- **09:30** - 更新当日K线数据 +- **16:00** - 计算市场统计数据 +- **20:00** - 更新监控列表数据 + +### 每周任务 +- **周日02:00** - 清理旧数据(保留6个月) + +### 数据更新策略 +- 股票列表:每周一更新一次 +- K线数据:每个交易日更新 +- 市场统计:每个交易日计算 +- 数据清理:每周日凌晨执行 + +## 📊 数据库表结构 + +### 新增表结构 + +1. **industries** - 行业分类表 +2. **sectors** - 概念板块表 +3. **kline_data** - K线数据表 +4. **stock_sector_relations** - 股票-板块关联表 +5. **market_statistics** - 市场统计表 +6. **data_update_tasks** - 任务执行记录表 +7. **hot_stocks** - 热门股票统计表 + +### 扩展表结构 + +- **stocks** 表增加了行业、板块、市场类型等字段 + +## 🎨 前端技术栈 + +- **Vue.js 3** - 前端框架 +- **Bootstrap 5** - UI组件库 +- **ECharts** - 图表库 +- **Axios** - HTTP客户端 + +## 🛠️ 使用技巧 + +### 1. 数据同步 +首次使用时,点击"同步数据"按钮获取最新的股票数据。 + +### 2. 股票筛选 +- 使用搜索框快速定位特定股票 +- 通过行业和概念筛选发现投资机会 +- 查看热门排行榜了解市场热点 + +### 3. K线图表 +- 点击股票查看详细的K线图表 +- 支持日K、周K、月K不同周期 +- 结合成交量分析价格走势 + +### 4. 监控管理 +- 在股票详情页面一键添加到监控列表 +- 原有监控功能完全保持兼容 +- AI分析功能支持新添加的股票 + +## 📈 系统性能 + +### 优化策略 +- 数据库索引优化查询性能 +- 分页加载减少内存占用 +- 缓存机制减少API调用 +- 异步任务处理提升响应速度 + +### 容量规划 +- 支持5000+股票实时数据 +- 历史K线数据按需清理 +- 任务执行状态监控和日志 + +## 🔧 故障排除 + +### 常见问题 + +1. **股票列表为空** + - 检查是否已执行数据同步 + - 确认数据库连接正常 + - 查看任务执行状态 + +2. **K线图表不显示** + - 确认股票代码正确 + - 检查网络连接 + - 查看浏览器控制台错误信息 + +3. **数据更新不及时** + - 检查定时任务是否正常运行 + - 确认Tushare API配额充足 + - 查看任务执行日志 + +### 日志查看 +```bash +# 查看任务执行状态 +curl http://localhost:8000/api/market/tasks/status + +# 手动触发数据同步 +curl -X POST http://localhost:8000/api/market/sync +``` + +## 🎯 下一步优化方向 + +1. **技术分析指标**: 添加更多技术分析指标 +2. **实时推送**: WebSocket实时数据推送 +3. **数据导出**: 支持Excel、CSV格式导出 +4. **用户个性化**: 自定义筛选条件和提醒 +5. **移动端适配**: 响应式设计优化 + +--- + +## 🎊 总结 + +新功能完全兼容原有系统,在保持监控列表功能的同时,大大扩展了系统的数据覆盖面和分析能力。现在您可以: + +- 🔍 **浏览全市场5000+股票** +- 📊 **查看实时K线图表** +- 🏭 **按行业概念分类筛选** +- 🔥 **追踪市场热点股票** +- ⏰ **享受全自动数据更新** + +祝您投资顺利!🚀 \ No newline at end of file diff --git a/dokcer/DockerFile b/dokcer/DockerFile deleted file mode 100644 index 6f14d75..0000000 --- a/dokcer/DockerFile +++ /dev/null @@ -1,7 +0,0 @@ -FROM python:3.10-slim -WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt # 禁用缓存减小体积 -COPY . . -EXPOSE 8000 -CMD ["python3","run.py"] \ No newline at end of file diff --git a/run.py b/run.py index cdff16b..9ffca63 100644 --- a/run.py +++ b/run.py @@ -8,5 +8,5 @@ if __name__ == "__main__": port=8000, # 修改为8000端口 reload=True, # 启用热重载 log_level="debug", # 设置日志级别为debug - workers=1 # 开发模式使用单个worker - ) \ No newline at end of file + workers=1 # 开发模式使用单个workervg + ) \ No newline at end of file diff --git a/stock_cache.json b/stock_cache.json deleted file mode 100644 index 8716bcf..0000000 --- a/stock_cache.json +++ /dev/null @@ -1,2376 +0,0 @@ -{ - "300059": { - "data": { - "stock_info": { - "code": "300059", - "name": "东方财富", - "market_value": 3971.55, - "pe_ratio": 41.33, - "pb_ratio": 4.47, - "ps_ratio": 127.67, - "dividend_yield": 0.0, - "price": 25.13, - "change_percent": 0.0178, - "roe": 0.1073, - "gross_profit_margin": 0.8413, - "net_profit_margin": 3.5769, - "debt_to_assets": 0.7663, - "revenue_yoy": 0, - "net_profit_yoy": 0.5057, - "bps": 5.624, - "ocfps": -0.417, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 2500.0, - "max": 2800.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "601318": { - "data": { - "stock_info": { - "code": "601318", - "name": "中国平安", - "market_value": 11023.93, - "pe_ratio": 8.71, - "pb_ratio": 1.14, - "ps_ratio": 1.07, - "dividend_yield": 0.0, - "price": 60.88, - "change_percent": 0.0142, - "roe": 0.1388, - "gross_profit_margin": 0, - "net_profit_margin": 0.1862, - "debt_to_assets": 0.8994, - "revenue_yoy": 0, - "net_profit_yoy": 0.1147, - "bps": 54.475, - "ocfps": 18.785, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 8000.0, - "max": 9500.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "600085": { - "data": { - "stock_info": { - "code": "600085", - "name": "同仁堂", - "market_value": 480.29, - "pe_ratio": 31.47, - "pb_ratio": 3.52, - "ps_ratio": 2.58, - "dividend_yield": 0.0, - "price": 35.02, - "change_percent": 0.0081, - "roe": 0.0877, - "gross_profit_margin": 0.4386, - "net_profit_margin": 0.1197, - "debt_to_assets": 0.3257, - "revenue_yoy": 0, - "net_profit_yoy": -0.1278, - "bps": 9.963, - "ocfps": 1.449, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 336.0, - "max": 429.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "002714": { - "data": { - "stock_info": { - "code": "002714", - "name": "牧原股份", - "market_value": 2762.52, - "pe_ratio": 15.45, - "pb_ratio": 3.9, - "ps_ratio": 2.0, - "dividend_yield": 0.0, - "price": 50.57, - "change_percent": 0.0086, - "roe": 0.1985, - "gross_profit_margin": 0.1873, - "net_profit_margin": 0.1352, - "debt_to_assets": 0.555, - "revenue_yoy": 0, - "net_profit_yoy": 0.4101, - "bps": 13.886, - "ocfps": 5.232, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 1650.0, - "max": 2200.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "000538": { - "data": { - "stock_info": { - "code": "000538", - "name": "云南白药", - "market_value": 1018.1, - "pe_ratio": 21.44, - "pb_ratio": 2.56, - "ps_ratio": 2.54, - "dividend_yield": 0.0, - "price": 57.06, - "change_percent": -0.001, - "roe": 0.1216, - "gross_profit_margin": 0.3006, - "net_profit_margin": 0.1562, - "debt_to_assets": 0.2536, - "revenue_yoy": 0, - "net_profit_yoy": 0.1041, - "bps": 22.269, - "ocfps": 2.497, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 807.0, - "max": 1210.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "000423": { - "data": { - "stock_info": { - "code": "000423", - "name": "东阿阿胶", - "market_value": 316.51, - "pe_ratio": 20.33, - "pb_ratio": 3.18, - "ps_ratio": 5.35, - "dividend_yield": 0.0, - "price": 49.15, - "change_percent": -0.0008, - "roe": 0.1258, - "gross_profit_margin": 0.7369, - "net_profit_margin": 0.2679, - "debt_to_assets": 0.2177, - "revenue_yoy": 0, - "net_profit_yoy": 0.1053, - "bps": 15.448, - "ocfps": 2.034, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 270.0, - "max": 330.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "000963": { - "data": { - "stock_info": { - "code": "000963", - "name": "华东医药", - "market_value": 765.63, - "pe_ratio": 21.8, - "pb_ratio": 3.17, - "ps_ratio": 1.83, - "dividend_yield": 0.0, - "price": 43.65, - "change_percent": 0.0199, - "roe": 0.1165, - "gross_profit_margin": 0.3352, - "net_profit_margin": 0.0839, - "debt_to_assets": 0.3865, - "revenue_yoy": 0, - "net_profit_yoy": 0.0724, - "bps": 13.759, - "ocfps": 1.488, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 500.0, - "max": 650.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "002415": { - "data": { - "stock_info": { - "code": "002415", - "name": "海康威视", - "market_value": 2904.35, - "pe_ratio": 24.25, - "pb_ratio": 3.89, - "ps_ratio": 3.14, - "dividend_yield": 0.0, - "price": 31.69, - "change_percent": 0.0083, - "roe": 0.1172, - "gross_profit_margin": 0.4537, - "net_profit_margin": 0.1559, - "debt_to_assets": 0.338, - "revenue_yoy": 0, - "net_profit_yoy": 0.1494, - "bps": 8.554, - "ocfps": 1.494, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 2000.0, - "max": 2200.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "002007": { - "data": { - "stock_info": { - "code": "002007", - "name": "华兰生物", - "market_value": 306.46, - "pe_ratio": 28.17, - "pb_ratio": 2.93, - "ps_ratio": 7.0, - "dividend_yield": 0.0, - "price": 16.77, - "change_percent": 0.0012, - "roe": 0.0675, - "gross_profit_margin": 0.5768, - "net_profit_margin": 0.245, - "debt_to_assets": 0.1855, - "revenue_yoy": 0, - "net_profit_yoy": -0.1507, - "bps": 6.223, - "ocfps": 0.247, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 240.0, - "max": 288.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "600132": { - "data": { - "stock_info": { - "code": "600132", - "name": "重庆啤酒", - "market_value": 263.52, - "pe_ratio": 23.64, - "pb_ratio": 13.16, - "ps_ratio": 1.8, - "dividend_yield": 0.0, - "price": 54.45, - "change_percent": 0.0015, - "roe": 0.7785, - "gross_profit_margin": 0.5017, - "net_profit_margin": 0.1901, - "debt_to_assets": 0.6792, - "revenue_yoy": 0, - "net_profit_yoy": -0.0683, - "bps": 4.138, - "ocfps": 8.118, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 220.0, - "max": 290.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "002049": { - "data": { - "stock_info": { - "code": "002049", - "name": "紫光国微", - "market_value": 658.88, - "pe_ratio": 55.87, - "pb_ratio": 5.02, - "ps_ratio": 11.96, - "dividend_yield": 0.0, - "price": 77.55, - "change_percent": 0.0201, - "roe": 0.0983, - "gross_profit_margin": 0.566, - "net_profit_margin": 0.2574, - "debt_to_assets": 0.2702, - "revenue_yoy": 0, - "net_profit_yoy": 0.2504, - "bps": 15.447, - "ocfps": 0.336, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 360.0, - "max": 480.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "600436": { - "data": { - "stock_info": { - "code": "600436", - "name": "片仔癀", - "market_value": 1096.95, - "pe_ratio": 36.85, - "pb_ratio": 7.58, - "ps_ratio": 10.17, - "dividend_yield": 0.0, - "price": 181.82, - "change_percent": -0.0023, - "roe": 0.1482, - "gross_profit_margin": 0.3893, - "net_profit_margin": 0.2866, - "debt_to_assets": 0.1395, - "revenue_yoy": 0, - "net_profit_yoy": -0.2074, - "bps": 23.988, - "ocfps": 0.807, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 960.0, - "max": 1080.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "002216": { - "data": { - "stock_info": { - "code": "002216", - "name": "三全食品", - "market_value": 105.85, - "pe_ratio": 19.52, - "pb_ratio": 2.34, - "ps_ratio": 1.6, - "dividend_yield": 0.0, - "price": 12.04, - "change_percent": 0.0067, - "roe": 0.0885, - "gross_profit_margin": 0.2377, - "net_profit_margin": 0.0792, - "debt_to_assets": 0.4016, - "revenue_yoy": 0, - "net_profit_yoy": 0.0037, - "bps": 5.149, - "ocfps": 0.06, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 72.0, - "max": 97.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "601966": { - "data": { - "stock_info": { - "code": "601966", - "name": "玲珑轮胎", - "market_value": 229.33, - "pe_ratio": 13.09, - "pb_ratio": 0.99, - "ps_ratio": 1.04, - "dividend_yield": 0.0, - "price": 15.67, - "change_percent": 0.0, - "roe": 0.0519, - "gross_profit_margin": 0.1638, - "net_profit_margin": 0.0643, - "debt_to_assets": 0.5118, - "revenue_yoy": 0, - "net_profit_yoy": -0.3181, - "bps": 15.898, - "ocfps": 0.936, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 220.0, - "max": 280.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "603195": { - "data": { - "stock_info": { - "code": "603195", - "name": "公牛集团", - "market_value": 800.12, - "pe_ratio": 18.73, - "pb_ratio": 5.11, - "ps_ratio": 4.75, - "dividend_yield": 0.0, - "price": 44.24, - "change_percent": 0.01, - "roe": 0.189, - "gross_profit_margin": 0.4211, - "net_profit_margin": 0.2445, - "debt_to_assets": 0.2634, - "revenue_yoy": 0, - "net_profit_yoy": -0.0872, - "bps": 8.659, - "ocfps": 2.002, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 730.0, - "max": 950.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "000001": { - "data": { - "stock_info": { - "code": "000001", - "name": "平安银行", - "market_value": 2270.49, - "pe_ratio": 5.1, - "pb_ratio": 0.51, - "ps_ratio": 1.55, - "dividend_yield": 0.0, - "price": 11.7, - "change_percent": 0.0017, - "roe": 0.0757, - "gross_profit_margin": 0, - "net_profit_margin": 0.3808, - "debt_to_assets": 0.9102, - "revenue_yoy": 0, - "net_profit_yoy": -0.035, - "bps": 23.085, - "ocfps": 3.7, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 1800.0, - "max": 2500.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "600867": { - "data": { - "stock_info": { - "code": "600867", - "name": "通化东宝", - "market_value": 180.97, - "pe_ratio": 0, - "pb_ratio": 2.54, - "ps_ratio": 9.01, - "dividend_yield": 0.0, - "price": 9.24, - "change_percent": 0.0, - "roe": 0.178, - "gross_profit_margin": 0.7189, - "net_profit_margin": 0.545, - "debt_to_assets": 0.1246, - "revenue_yoy": 0, - "net_profit_yoy": 19.1135, - "bps": 3.64, - "ocfps": 0.194, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 120.0, - "max": 160.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "603087": { - "data": { - "stock_info": { - "code": "603087", - "name": "甘李药业", - "market_value": 393.8, - "pe_ratio": 64.07, - "pb_ratio": 3.49, - "ps_ratio": 12.93, - "dividend_yield": 0.0, - "price": 65.93, - "change_percent": 0.0056, - "roe": 0.0733, - "gross_profit_margin": 0.7618, - "net_profit_margin": 0.2686, - "debt_to_assets": 0.0709, - "revenue_yoy": 0, - "net_profit_yoy": 0.6132, - "bps": 18.882, - "ocfps": 0.807, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 170.0, - "max": 283.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "603290": { - "data": { - "stock_info": { - "code": "603290", - "name": "斯达半导", - "market_value": 236.24, - "pe_ratio": 46.53, - "pb_ratio": 3.42, - "ps_ratio": 6.97, - "dividend_yield": 0.0, - "price": 98.65, - "change_percent": 0.0156, - "roe": 0.0562, - "gross_profit_margin": 0.2791, - "net_profit_margin": 0.1293, - "debt_to_assets": 0.3394, - "revenue_yoy": 0, - "net_profit_yoy": -0.098, - "bps": 28.877, - "ocfps": 1.599, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 150.0, - "max": 200.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "600332": { - "data": { - "stock_info": { - "code": "600332", - "name": "白云山", - "market_value": 443.03, - "pe_ratio": 15.62, - "pb_ratio": 1.17, - "ps_ratio": 0.59, - "dividend_yield": 0.0, - "price": 27.25, - "change_percent": 0.0022, - "roe": 0.0897, - "gross_profit_margin": 0.176, - "net_profit_margin": 0.0552, - "debt_to_assets": 0.5192, - "revenue_yoy": 0, - "net_profit_yoy": 0.0478, - "bps": 23.316, - "ocfps": -1.213, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 380.0, - "max": 460.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "300124": { - "data": { - "stock_info": { - "code": "300124", - "name": "汇川技术", - "market_value": 1959.62, - "pe_ratio": 45.73, - "pb_ratio": 5.7, - "ps_ratio": 5.29, - "dividend_yield": 0.0, - "price": 72.4, - "change_percent": 0.0126, - "roe": 0.1364, - "gross_profit_margin": 0.2927, - "net_profit_margin": 0.1364, - "debt_to_assets": 0.4676, - "revenue_yoy": 0, - "net_profit_yoy": 0.2684, - "bps": 12.704, - "ocfps": 1.453, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 1200.0, - "max": 1400.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "300146": { - "data": { - "stock_info": { - "code": "300146", - "name": "汤臣倍健", - "market_value": 220.76, - "pe_ratio": 33.82, - "pb_ratio": 1.95, - "ps_ratio": 3.23, - "dividend_yield": 0.0, - "price": 13.05, - "change_percent": 0.0108, - "roe": 0.0811, - "gross_profit_margin": 0.6855, - "net_profit_margin": 0.1921, - "debt_to_assets": 0.186, - "revenue_yoy": 0, - "net_profit_yoy": 0.0445, - "bps": 6.689, - "ocfps": 0.545, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 150.0, - "max": 200.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "000999": { - "data": { - "stock_info": { - "code": "000999", - "name": "华润三九", - "market_value": 500.26, - "pe_ratio": 14.85, - "pb_ratio": 2.46, - "ps_ratio": 1.81, - "dividend_yield": 0.0, - "price": 30.06, - "change_percent": -0.0046, - "roe": 0.1147, - "gross_profit_margin": 0.5352, - "net_profit_margin": 0.1319, - "debt_to_assets": 0.3392, - "revenue_yoy": 0, - "net_profit_yoy": -0.2051, - "bps": 12.688, - "ocfps": 1.758, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 450.0, - "max": 580.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "000516": { - "data": { - "stock_info": { - "code": "000516", - "name": "国际医学", - "market_value": 109.4, - "pe_ratio": 0, - "pb_ratio": 3.0, - "ps_ratio": 2.37, - "dividend_yield": 0.0, - "price": 4.84, - "change_percent": -0.032, - "roe": -0.0561, - "gross_profit_margin": 0.0919, - "net_profit_margin": -0.0816, - "debt_to_assets": 0.6704, - "revenue_yoy": 0, - "net_profit_yoy": 0.0459, - "bps": 1.616, - "ocfps": 0.276, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 100.0, - "max": 120.0 - } - } - }, - "timestamp": "2025-01-10" - }, - "002466": { - "data": { - "stock_info": { - "code": "002466", - "name": "天齐锂业", - "market_value": 976.51, - "pe_ratio": 0, - "pb_ratio": 2.31, - "ps_ratio": 7.48, - "dividend_yield": 0.0, - "price": 59.5, - "change_percent": 0.0998, - "roe": 0.0043, - "gross_profit_margin": 0.3898, - "net_profit_margin": 0.2954, - "debt_to_assets": 0.305, - "revenue_yoy": 0, - "net_profit_yoy": 1.0316, - "bps": 25.837, - "ocfps": 1.336, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 330.0, - "max": 415.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "index_data": { - "data": { - "data": [ - { - "code": "000001.SH", - "name": "上证指数", - "price": 3168.5238, - "change": -1.3349, - "kline_data": [ - { - "date": "20250110", - "open": 3211.7068, - "close": 3168.5238, - "high": 3220.1073, - "low": 3168.5238, - "vol": 403663348.0 - }, - { - "date": "20250109", - "open": 3220.7205, - "close": 3211.3933, - "high": 3228.9725, - "low": 3205.9103, - "vol": 382943557.0 - }, - { - "date": "20250108", - "open": 3218.8577, - "close": 3230.1679, - "high": 3246.2908, - "low": 3175.7247, - "vol": 472864390.0 - }, - { - "date": "20250107", - "open": 3203.3068, - "close": 3229.6439, - "high": 3230.8529, - "low": 3190.4612, - "vol": 409660529.0 - }, - { - "date": "20250106", - "open": 3209.7832, - "close": 3206.9228, - "high": 3219.4877, - "low": 3185.4631, - "vol": 430978403.0 - }, - { - "date": "20250103", - "open": 3267.0766, - "close": 3211.4299, - "high": 3273.5656, - "low": 3205.7755, - "vol": 517592014.0 - }, - { - "date": "20250102", - "open": 3347.9392, - "close": 3262.5607, - "high": 3351.722, - "low": 3242.0865, - "vol": 561375199.0 - }, - { - "date": "20241231", - "open": 3406.9652, - "close": 3351.763, - "high": 3413.4545, - "low": 3351.763, - "vol": 502731062.0 - }, - { - "date": "20241230", - "open": 3395.3962, - "close": 3407.3259, - "high": 3412.8414, - "low": 3394.9648, - "vol": 455262383.0 - }, - { - "date": "20241227", - "open": 3397.2939, - "close": 3400.142, - "high": 3418.952, - "low": 3388.3215, - "vol": 500488130.0 - }, - { - "date": "20241226", - "open": 3389.3383, - "close": 3398.0765, - "high": 3401.0951, - "low": 3380.255, - "vol": 422177271.0 - }, - { - "date": "20241225", - "open": 3395.1072, - "close": 3393.3501, - "high": 3406.2125, - "low": 3374.0113, - "vol": 471315592.0 - }, - { - "date": "20241224", - "open": 3353.5354, - "close": 3393.5281, - "high": 3394.9044, - "low": 3352.9474, - "vol": 471992673.0 - }, - { - "date": "20241223", - "open": 3367.9037, - "close": 3351.2571, - "high": 3384.9918, - "low": 3348.277, - "vol": 556747248.0 - }, - { - "date": "20241220", - "open": 3364.481, - "close": 3368.0693, - "high": 3390.6182, - "low": 3362.8193, - "vol": 490964621.0 - }, - { - "date": "20241219", - "open": 3355.9041, - "close": 3370.0331, - "high": 3377.5321, - "low": 3346.469, - "vol": 513909063.0 - }, - { - "date": "20241218", - "open": 3371.2955, - "close": 3382.2081, - "high": 3396.4603, - "low": 3371.2955, - "vol": 512524979.0 - }, - { - "date": "20241217", - "open": 3381.8141, - "close": 3361.485, - "high": 3396.2074, - "low": 3357.7694, - "vol": 568941505.0 - }, - { - "date": "20241216", - "open": 3390.083, - "close": 3386.3312, - "high": 3401.9263, - "low": 3376.5358, - "vol": 625578996.0 - }, - { - "date": "20241213", - "open": 3442.9259, - "close": 3391.8782, - "high": 3442.9259, - "low": 3390.7537, - "vol": 777464144.0 - } - ] - }, - { - "code": "399001.SZ", - "name": "深证成指", - "price": 9795.9438, - "change": -1.8049, - "kline_data": [ - { - "date": "20250110", - "open": 9957.8343, - "close": 9795.9438, - "high": 10004.0607, - "low": 9795.9438, - "vol": 616360985.0 - }, - { - "date": "20250109", - "open": 9906.9088, - "close": 9976.0038, - "high": 10021.9397, - "low": 9901.6345, - "vol": 605202920.0 - }, - { - "date": "20250108", - "open": 9940.1968, - "close": 9944.6404, - "high": 10016.2192, - "low": 9731.4379, - "vol": 683561380.0 - }, - { - "date": "20250107", - "open": 9860.578, - "close": 9998.7565, - "high": 10000.3408, - "low": 9822.0008, - "vol": 588919068.0 - }, - { - "date": "20250106", - "open": 9890.9841, - "close": 9885.6545, - "high": 9975.9921, - "low": 9817.7509, - "vol": 586235482.0 - }, - { - "date": "20250103", - "open": 10097.9312, - "close": 9897.1203, - "high": 10139.9895, - "low": 9891.8398, - "vol": 685235256.0 - }, - { - "date": "20250102", - "open": 10400.6644, - "close": 10088.0589, - "high": 10409.6132, - "low": 10006.5558, - "vol": 681116709.0 - }, - { - "date": "20241231", - "open": 10668.168, - "close": 10414.6113, - "high": 10678.7711, - "low": 10414.6113, - "vol": 646653008.0 - }, - { - "date": "20241230", - "open": 10644.1537, - "close": 10671.155, - "high": 10726.6603, - "low": 10632.7442, - "vol": 609052769.0 - }, - { - "date": "20241227", - "open": 10679.3433, - "close": 10659.9751, - "high": 10780.5327, - "low": 10632.5252, - "vol": 679934587.0 - }, - { - "date": "20241226", - "open": 10586.957, - "close": 10673.9684, - "high": 10692.8864, - "low": 10570.0857, - "vol": 593997426.0 - }, - { - "date": "20241225", - "open": 10675.729, - "close": 10603.0998, - "high": 10677.6475, - "low": 10544.8803, - "vol": 667963062.0 - }, - { - "date": "20241224", - "open": 10548.1453, - "close": 10671.4274, - "high": 10673.3372, - "low": 10548.1453, - "vol": 664977041.0 - }, - { - "date": "20241223", - "open": 10650.575, - "close": 10537.4021, - "high": 10708.8229, - "low": 10527.1463, - "vol": 795687830.0 - }, - { - "date": "20241220", - "open": 10631.4447, - "close": 10646.6176, - "high": 10731.0762, - "low": 10618.7944, - "vol": 732375378.0 - }, - { - "date": "20241219", - "open": 10483.5035, - "close": 10649.0339, - "high": 10673.9616, - "low": 10460.1961, - "vol": 760693343.0 - }, - { - "date": "20241218", - "open": 10561.9354, - "close": 10584.2695, - "high": 10639.7699, - "low": 10535.7611, - "vol": 740846621.0 - }, - { - "date": "20241217", - "open": 10557.2316, - "close": 10537.4285, - "high": 10677.7429, - "low": 10526.2556, - "vol": 858140531.0 - }, - { - "date": "20241216", - "open": 10705.5166, - "close": 10573.9194, - "high": 10721.7095, - "low": 10535.5265, - "vol": 922676962.0 - }, - { - "date": "20241213", - "open": 10876.1903, - "close": 10713.072, - "high": 10876.1903, - "low": 10709.1393, - "vol": 1075547182.0 - } - ] - }, - { - "code": "399006.SZ", - "name": "创业板指", - "price": 1975.3048, - "change": -1.7584, - "kline_data": [ - { - "date": "20250110", - "open": 2006.5527, - "close": 1975.3048, - "high": 2024.4268, - "low": 1975.3048, - "vol": 178384005.0 - }, - { - "date": "20250109", - "open": 1994.81, - "close": 2010.6601, - "high": 2025.306, - "low": 1994.7047, - "vol": 174244807.0 - }, - { - "date": "20250108", - "open": 2011.7489, - "close": 2008.4441, - "high": 2029.0233, - "low": 1963.8057, - "vol": 195527838.0 - }, - { - "date": "20250107", - "open": 1996.0003, - "close": 2028.3582, - "high": 2028.7513, - "low": 1988.418, - "vol": 168570528.0 - }, - { - "date": "20250106", - "open": 2018.2457, - "close": 2014.1899, - "high": 2033.2681, - "low": 2000.8615, - "vol": 169375612.0 - }, - { - "date": "20250103", - "open": 2062.4025, - "close": 2015.9671, - "high": 2067.4089, - "low": 2015.9633, - "vol": 206064912.0 - }, - { - "date": "20250102", - "open": 2136.9221, - "close": 2060.4422, - "high": 2137.6549, - "low": 2040.3792, - "vol": 196587394.0 - }, - { - "date": "20241231", - "open": 2202.1811, - "close": 2141.5958, - "high": 2207.3112, - "low": 2141.5958, - "vol": 199428413.0 - }, - { - "date": "20241230", - "open": 2198.8648, - "close": 2206.2852, - "high": 2218.7352, - "low": 2198.1247, - "vol": 187201460.0 - }, - { - "date": "20241227", - "open": 2209.7101, - "close": 2204.8965, - "high": 2235.5559, - "low": 2196.2938, - "vol": 207513529.0 - }, - { - "date": "20241226", - "open": 2200.3188, - "close": 2209.8464, - "high": 2219.6932, - "low": 2193.5882, - "vol": 184436466.0 - }, - { - "date": "20241225", - "open": 2215.6123, - "close": 2201.296, - "high": 2216.3572, - "low": 2186.9997, - "vol": 203868100.0 - }, - { - "date": "20241224", - "open": 2190.4696, - "close": 2213.548, - "high": 2215.1924, - "low": 2187.7803, - "vol": 200966406.0 - }, - { - "date": "20241223", - "open": 2205.6903, - "close": 2187.9448, - "high": 2223.6367, - "low": 2185.3528, - "vol": 245538770.0 - }, - { - "date": "20241220", - "open": 2209.3755, - "close": 2209.6616, - "high": 2234.1315, - "low": 2201.9409, - "vol": 230049274.0 - }, - { - "date": "20241219", - "open": 2178.463, - "close": 2213.5429, - "high": 2220.4332, - "low": 2174.9434, - "vol": 221719261.0 - }, - { - "date": "20241218", - "open": 2207.1823, - "close": 2202.1373, - "high": 2216.0033, - "low": 2192.5973, - "vol": 216481475.0 - }, - { - "date": "20241217", - "open": 2197.1259, - "close": 2201.181, - "high": 2233.172, - "low": 2197.1259, - "vol": 248753480.0 - }, - { - "date": "20241216", - "open": 2232.6249, - "close": 2201.5324, - "high": 2234.1863, - "low": 2192.2524, - "vol": 273787933.0 - }, - { - "date": "20241213", - "open": 2272.2664, - "close": 2235.2586, - "high": 2276.1275, - "low": 2234.1701, - "vol": 328537971.0 - } - ] - }, - { - "code": "000016.SH", - "name": "上证50", - "price": 2560.2497, - "change": -0.7492, - "kline_data": [ - { - "date": "20250110", - "open": 2585.1998, - "close": 2560.2497, - "high": 2593.0872, - "low": 2560.2497, - "vol": 36166878.0 - }, - { - "date": "20250109", - "open": 2595.6918, - "close": 2579.5751, - "high": 2597.0557, - "low": 2574.603, - "vol": 34356506.0 - }, - { - "date": "20250108", - "open": 2587.5267, - "close": 2597.4459, - "high": 2611.1528, - "low": 2565.5989, - "vol": 46257463.0 - }, - { - "date": "20250107", - "open": 2576.6772, - "close": 2593.9654, - "high": 2595.6633, - "low": 2566.6785, - "vol": 39218598.0 - }, - { - "date": "20250106", - "open": 2589.3354, - "close": 2579.9632, - "high": 2591.8944, - "low": 2555.9276, - "vol": 44486402.0 - }, - { - "date": "20250103", - "open": 2615.3507, - "close": 2587.1316, - "high": 2620.6463, - "low": 2579.439, - "vol": 50804858.0 - }, - { - "date": "20250102", - "open": 2682.4157, - "close": 2610.342, - "high": 2684.2467, - "low": 2595.3991, - "vol": 61160984.0 - }, - { - "date": "20241231", - "open": 2715.2066, - "close": 2684.7706, - "high": 2725.7876, - "low": 2684.7706, - "vol": 50745554.0 - }, - { - "date": "20241230", - "open": 2700.2245, - "close": 2718.7486, - "high": 2724.7263, - "low": 2700.2245, - "vol": 44994763.0 - }, - { - "date": "20241227", - "open": 2701.2086, - "close": 2701.7031, - "high": 2713.8405, - "low": 2686.5011, - "vol": 44894855.0 - }, - { - "date": "20241226", - "open": 2709.5915, - "close": 2702.5952, - "high": 2709.5915, - "low": 2692.8677, - "vol": 41770736.0 - }, - { - "date": "20241225", - "open": 2706.5666, - "close": 2710.1649, - "high": 2726.1403, - "low": 2700.6812, - "vol": 51010381.0 - }, - { - "date": "20241224", - "open": 2671.9088, - "close": 2702.282, - "high": 2704.5775, - "low": 2666.3412, - "vol": 46469198.0 - }, - { - "date": "20241223", - "open": 2648.9188, - "close": 2671.2025, - "high": 2688.8864, - "low": 2648.9188, - "vol": 56629099.0 - }, - { - "date": "20241220", - "open": 2654.8486, - "close": 2648.4609, - "high": 2670.3177, - "low": 2644.8364, - "vol": 42493510.0 - }, - { - "date": "20241219", - "open": 2652.1949, - "close": 2661.7224, - "high": 2672.5807, - "low": 2642.6831, - "vol": 40214140.0 - }, - { - "date": "20241218", - "open": 2662.0322, - "close": 2671.3461, - "high": 2681.1212, - "low": 2661.8983, - "vol": 44259056.0 - }, - { - "date": "20241217", - "open": 2637.3498, - "close": 2652.3455, - "high": 2672.9552, - "low": 2637.3498, - "vol": 44279234.0 - }, - { - "date": "20241216", - "open": 2637.7401, - "close": 2641.5604, - "high": 2647.6977, - "low": 2635.2467, - "vol": 54098120.0 - }, - { - "date": "20241213", - "open": 2684.2932, - "close": 2638.0266, - "high": 2684.4109, - "low": 2636.9729, - "vol": 61597705.0 - } - ] - }, - { - "code": "000300.SH", - "name": "沪深300", - "price": 3732.4806, - "change": -1.2539, - "kline_data": [ - { - "date": "20250110", - "open": 3778.8928, - "close": 3732.4806, - "high": 3786.8069, - "low": 3732.4806, - "vol": 137523171.0 - }, - { - "date": "20250109", - "open": 3780.7341, - "close": 3779.8773, - "high": 3795.2608, - "low": 3768.681, - "vol": 129794002.0 - }, - { - "date": "20250108", - "open": 3781.261, - "close": 3789.2153, - "high": 3810.7355, - "low": 3731.1887, - "vol": 169166968.0 - }, - { - "date": "20250107", - "open": 3760.866, - "close": 3796.1055, - "high": 3797.602, - "low": 3749.0581, - "vol": 146455779.0 - }, - { - "date": "20250106", - "open": 3775.9902, - "close": 3768.9697, - "high": 3788.8489, - "low": 3743.0727, - "vol": 150946670.0 - }, - { - "date": "20250103", - "open": 3825.2426, - "close": 3775.1648, - "high": 3835.9353, - "low": 3767.6653, - "vol": 175342676.0 - }, - { - "date": "20250102", - "open": 3931.8155, - "close": 3820.3952, - "high": 3934.2034, - "low": 3796.3389, - "vol": 217898724.0 - }, - { - "date": "20241231", - "open": 3995.8705, - "close": 3934.9109, - "high": 4004.3462, - "low": 3934.9109, - "vol": 183928397.0 - }, - { - "date": "20241230", - "open": 3976.7004, - "close": 3999.0549, - "high": 4005.778, - "low": 3976.7004, - "vol": 164612485.0 - }, - { - "date": "20241227", - "open": 3987.0189, - "close": 3981.0307, - "high": 4007.1314, - "low": 3970.9365, - "vol": 169268666.0 - }, - { - "date": "20241226", - "open": 3981.7317, - "close": 3987.4801, - "high": 3991.5331, - "low": 3965.5461, - "vol": 143425286.0 - }, - { - "date": "20241225", - "open": 3987.9557, - "close": 3985.6291, - "high": 4007.7626, - "low": 3969.4766, - "vol": 159390747.0 - }, - { - "date": "20241224", - "open": 3934.9422, - "close": 3983.6882, - "high": 3985.8588, - "low": 3934.7338, - "vol": 161494589.0 - }, - { - "date": "20241223", - "open": 3928.1493, - "close": 3933.5718, - "high": 3965.8502, - "low": 3928.1493, - "vol": 190067362.0 - }, - { - "date": "20241220", - "open": 3937.4722, - "close": 3927.7441, - "high": 3958.6336, - "low": 3923.2562, - "vol": 154986496.0 - }, - { - "date": "20241219", - "open": 3911.8901, - "close": 3945.4635, - "high": 3954.3491, - "low": 3899.7479, - "vol": 161081040.0 - }, - { - "date": "20241218", - "open": 3934.5425, - "close": 3941.89, - "high": 3956.1441, - "low": 3933.9693, - "vol": 157738321.0 - }, - { - "date": "20241217", - "open": 3908.1263, - "close": 3922.0334, - "high": 3955.945, - "low": 3908.1263, - "vol": 160939090.0 - }, - { - "date": "20241216", - "open": 3930.7807, - "close": 3911.8416, - "high": 3938.7595, - "low": 3901.6465, - "vol": 178511251.0 - }, - { - "date": "20241213", - "open": 4000.9854, - "close": 3933.1808, - "high": 4000.9854, - "low": 3931.7392, - "vol": 230353079.0 - } - ] - }, - { - "code": "000905.SH", - "name": "中证500", - "price": 5369.2811, - "change": -1.6, - "kline_data": [ - { - "date": "20250110", - "open": 5449.0014, - "close": 5369.2811, - "high": 5496.3833, - "low": 5369.2811, - "vol": 130179553.0 - }, - { - "date": "20250109", - "open": 5437.8128, - "close": 5456.5863, - "high": 5493.6799, - "low": 5436.7036, - "vol": 129832627.0 - }, - { - "date": "20250108", - "open": 5462.324, - "close": 5461.5861, - "high": 5501.6555, - "low": 5326.9259, - "vol": 163849915.0 - }, - { - "date": "20250107", - "open": 5429.1729, - "close": 5484.6847, - "high": 5485.5848, - "low": 5405.1327, - "vol": 145065278.0 - }, - { - "date": "20250106", - "open": 5421.0653, - "close": 5427.1007, - "high": 5482.6253, - "low": 5384.3326, - "vol": 146265658.0 - }, - { - "date": "20250103", - "open": 5552.75, - "close": 5427.8041, - "high": 5581.3978, - "low": 5419.7429, - "vol": 174077310.0 - }, - { - "date": "20250102", - "open": 5717.9157, - "close": 5545.8174, - "high": 5722.1107, - "low": 5498.8216, - "vol": 193763157.0 - }, - { - "date": "20241231", - "open": 5902.033, - "close": 5725.7324, - "high": 5905.463, - "low": 5725.7324, - "vol": 182158855.0 - }, - { - "date": "20241230", - "open": 5890.4633, - "close": 5898.884, - "high": 5928.5718, - "low": 5867.8538, - "vol": 155565586.0 - }, - { - "date": "20241227", - "open": 5892.6943, - "close": 5899.1665, - "high": 5975.7919, - "low": 5881.7173, - "vol": 179085498.0 - }, - { - "date": "20241226", - "open": 5830.7212, - "close": 5887.0605, - "high": 5897.4827, - "low": 5830.7212, - "vol": 145319444.0 - }, - { - "date": "20241225", - "open": 5896.4295, - "close": 5840.2889, - "high": 5897.5101, - "low": 5796.6475, - "vol": 157279132.0 - }, - { - "date": "20241224", - "open": 5827.1597, - "close": 5895.2595, - "high": 5897.1946, - "low": 5827.1597, - "vol": 167365824.0 - }, - { - "date": "20241223", - "open": 5917.6939, - "close": 5818.551, - "high": 5935.1523, - "low": 5815.3415, - "vol": 177315486.0 - }, - { - "date": "20241220", - "open": 5889.946, - "close": 5917.1751, - "high": 5962.4154, - "low": 5889.946, - "vol": 178090399.0 - }, - { - "date": "20241219", - "open": 5829.2631, - "close": 5901.9155, - "high": 5918.4895, - "low": 5816.4729, - "vol": 196531194.0 - }, - { - "date": "20241218", - "open": 5861.2624, - "close": 5888.6162, - "high": 5921.9798, - "low": 5853.1628, - "vol": 187996290.0 - }, - { - "date": "20241217", - "open": 5896.9676, - "close": 5848.1628, - "high": 5924.6244, - "low": 5837.2514, - "vol": 193108547.0 - }, - { - "date": "20241216", - "open": 5983.1598, - "close": 5912.6014, - "high": 5991.5772, - "low": 5888.0426, - "vol": 214647441.0 - }, - { - "date": "20241213", - "open": 6065.9542, - "close": 5988.292, - "high": 6065.9542, - "low": 5988.292, - "vol": 286328198.0 - } - ] - }, - { - "code": "000852.SH", - "name": "中证1000", - "price": 5544.9158, - "change": -2.254, - "kline_data": [ - { - "date": "20250110", - "open": 5662.7667, - "close": 5544.9158, - "high": 5718.2122, - "low": 5544.9158, - "vol": 198014877.0 - }, - { - "date": "20250109", - "open": 5629.8952, - "close": 5672.7796, - "high": 5714.4606, - "low": 5629.8239, - "vol": 193320421.0 - }, - { - "date": "20250108", - "open": 5664.3121, - "close": 5661.7793, - "high": 5706.6593, - "low": 5501.4918, - "vol": 225800830.0 - }, - { - "date": "20250107", - "open": 5610.9462, - "close": 5692.041, - "high": 5694.0046, - "low": 5580.8851, - "vol": 193912450.0 - }, - { - "date": "20250106", - "open": 5614.0281, - "close": 5608.0344, - "high": 5688.9223, - "low": 5561.898, - "vol": 203047676.0 - }, - { - "date": "20250103", - "open": 5808.5424, - "close": 5625.209, - "high": 5826.0833, - "low": 5609.7663, - "vol": 237093211.0 - }, - { - "date": "20250102", - "open": 5948.6802, - "close": 5797.089, - "high": 5982.5535, - "low": 5743.1785, - "vol": 244125606.0 - }, - { - "date": "20241231", - "open": 6151.0339, - "close": 5957.7172, - "high": 6160.7327, - "low": 5957.7172, - "vol": 237990032.0 - }, - { - "date": "20241230", - "open": 6164.908, - "close": 6145.928, - "high": 6192.5845, - "low": 6097.1031, - "vol": 207712448.0 - }, - { - "date": "20241227", - "open": 6164.67, - "close": 6171.5653, - "high": 6250.2013, - "low": 6135.4787, - "vol": 237841554.0 - }, - { - "date": "20241226", - "open": 6083.8873, - "close": 6160.4518, - "high": 6185.8068, - "low": 6083.8873, - "vol": 206916739.0 - }, - { - "date": "20241225", - "open": 6158.6491, - "close": 6094.6866, - "high": 6159.5964, - "low": 6037.7355, - "vol": 219027032.0 - }, - { - "date": "20241224", - "open": 6110.7669, - "close": 6164.3605, - "high": 6165.2022, - "low": 6066.226, - "vol": 216010918.0 - }, - { - "date": "20241223", - "open": 6271.7634, - "close": 6096.3813, - "high": 6276.9712, - "low": 6086.912, - "vol": 258474975.0 - }, - { - "date": "20241220", - "open": 6195.6436, - "close": 6271.7532, - "high": 6320.9626, - "low": 6191.0814, - "vol": 243659085.0 - }, - { - "date": "20241219", - "open": 6127.2202, - "close": 6207.1862, - "high": 6223.8046, - "low": 6108.3942, - "vol": 251487924.0 - }, - { - "date": "20241218", - "open": 6156.6921, - "close": 6195.8677, - "high": 6242.5165, - "low": 6121.0537, - "vol": 248024122.0 - }, - { - "date": "20241217", - "open": 6262.4928, - "close": 6143.9436, - "high": 6269.1401, - "low": 6133.1291, - "vol": 274793959.0 - }, - { - "date": "20241216", - "open": 6355.6283, - "close": 6277.5162, - "high": 6367.8085, - "low": 6248.1579, - "vol": 310215173.0 - }, - { - "date": "20241213", - "open": 6447.2029, - "close": 6354.9883, - "high": 6460.9385, - "low": 6351.3009, - "vol": 361985513.0 - } - ] - }, - { - "code": "899050.BJ", - "name": "北证50", - "price": 1024.259, - "change": -2.7783, - "kline_data": [ - { - "date": "20250110", - "open": 1050.5655, - "close": 1024.259, - "high": 1068.3807, - "low": 1024.259, - "vol": 8430121.72 - }, - { - "date": "20250109", - "open": 1033.4444, - "close": 1053.529, - "high": 1063.6647, - "low": 1028.7234, - "vol": 10081530.53 - }, - { - "date": "20250108", - "open": 1018.3275, - "close": 1037.3981, - "high": 1047.0022, - "low": 999.111, - "vol": 8772397.56 - }, - { - "date": "20250107", - "open": 1006.3205, - "close": 1024.4156, - "high": 1024.4156, - "low": 988.2998, - "vol": 7687642.52 - }, - { - "date": "20250106", - "open": 1017.0684, - "close": 1003.6427, - "high": 1030.2143, - "low": 995.401, - "vol": 7686193.04 - }, - { - "date": "20250103", - "open": 1026.3682, - "close": 1018.1664, - "high": 1049.3344, - "low": 1011.6511, - "vol": 8352396.17 - }, - { - "date": "20250102", - "open": 1034.7079, - "close": 1019.8776, - "high": 1047.6465, - "low": 1009.8723, - "vol": 7543876.8 - }, - { - "date": "20241231", - "open": 1049.6903, - "close": 1037.8089, - "high": 1072.333, - "low": 1037.2628, - "vol": 7807303.05 - }, - { - "date": "20241230", - "open": 1092.0436, - "close": 1046.1662, - "high": 1092.0436, - "low": 1044.7641, - "vol": 7390755.29 - }, - { - "date": "20241227", - "open": 1108.9413, - "close": 1094.2393, - "high": 1115.3085, - "low": 1090.0353, - "vol": 7700006.02 - }, - { - "date": "20241226", - "open": 1105.5626, - "close": 1107.7839, - "high": 1130.2294, - "low": 1104.3358, - "vol": 7499455.6 - }, - { - "date": "20241225", - "open": 1148.0414, - "close": 1106.1983, - "high": 1148.4815, - "low": 1106.1983, - "vol": 7681377.4 - }, - { - "date": "20241224", - "open": 1148.274, - "close": 1149.776, - "high": 1161.8042, - "low": 1125.8246, - "vol": 7348674.51 - }, - { - "date": "20241223", - "open": 1169.7814, - "close": 1140.8764, - "high": 1185.7544, - "low": 1140.846, - "vol": 8063638.57 - }, - { - "date": "20241220", - "open": 1156.3654, - "close": 1171.7736, - "high": 1197.9436, - "low": 1153.6311, - "vol": 9409865.85 - }, - { - "date": "20241219", - "open": 1116.1291, - "close": 1169.7086, - "high": 1174.2728, - "low": 1091.6927, - "vol": 11979102.42 - }, - { - "date": "20241218", - "open": 1144.941, - "close": 1132.6112, - "high": 1164.2948, - "low": 1127.6974, - "vol": 8281584.21 - }, - { - "date": "20241217", - "open": 1178.1043, - "close": 1139.4107, - "high": 1182.6161, - "low": 1138.1503, - "vol": 8478059.38 - }, - { - "date": "20241216", - "open": 1215.7327, - "close": 1177.3635, - "high": 1222.3319, - "low": 1169.0697, - "vol": 9263746.01 - }, - { - "date": "20241213", - "open": 1222.1633, - "close": 1219.2972, - "high": 1248.5568, - "low": 1211.8177, - "vol": 11252895.68 - } - ] - } - ] - }, - "timestamp": "2025-01-11" - }, - "300009": { - "data": { - "stock_info": { - "code": "300009", - "name": "安科生物", - "market_value": 184.31, - "pe_ratio": 26.07, - "pb_ratio": 4.42, - "ps_ratio": 7.27, - "dividend_yield": 0.0, - "price": 11.02, - "change_percent": -0.009, - "roe": 0.133, - "gross_profit_margin": 0.7597, - "net_profit_margin": 0.2867, - "debt_to_assets": 0.1481, - "revenue_yoy": 0, - "net_profit_yoy": -0.0648, - "bps": 2.493, - "ocfps": 0.332, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 120.0, - "max": 160.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "300743": { - "data": { - "stock_info": { - "code": "300743", - "name": "天地数码", - "market_value": 28.92, - "pe_ratio": 31.31, - "pb_ratio": 4.66, - "ps_ratio": 3.79, - "dividend_yield": 0.0, - "price": 19.12, - "change_percent": -0.0124, - "roe": 0.1456, - "gross_profit_margin": 0.3269, - "net_profit_margin": 0.1376, - "debt_to_assets": 0.4288, - "revenue_yoy": 0, - "net_profit_yoy": 0.2294, - "bps": 4.105, - "ocfps": 0.49, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": null, - "max": null - } - } - }, - "timestamp": "2025-11-13" - }, - "603511": { - "data": { - "stock_info": { - "code": "603511", - "name": "爱慕股份", - "market_value": 67.44, - "pe_ratio": 41.28, - "pb_ratio": 1.55, - "ps_ratio": 2.13, - "dividend_yield": 0.0, - "price": 16.69, - "change_percent": -0.0113, - "roe": 0.0231, - "gross_profit_margin": 0.6656, - "net_profit_margin": 0.0452, - "debt_to_assets": 0.1433, - "revenue_yoy": 0, - "net_profit_yoy": -0.284, - "bps": 10.775, - "ocfps": 1.035, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": null, - "max": null - } - } - }, - "timestamp": "2025-11-13" - }, - "688553": { - "data": { - "stock_info": { - "code": "688553", - "name": "汇宇制药-W", - "market_value": 91.71, - "pe_ratio": 28.18, - "pb_ratio": 2.46, - "ps_ratio": 8.38, - "dividend_yield": 0.0, - "price": 21.65, - "change_percent": 0.0285, - "roe": -0.0132, - "gross_profit_margin": 0.8049, - "net_profit_margin": -0.0742, - "debt_to_assets": 0.2369, - "revenue_yoy": 0, - "net_profit_yoy": -1.2235, - "bps": 8.808, - "ocfps": 0.074, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": null, - "max": null - } - } - }, - "timestamp": "2025-11-13" - }, - "600030": { - "data": { - "stock_info": { - "code": "600030", - "name": "中信证券", - "market_value": 4309.82, - "pe_ratio": 19.86, - "pb_ratio": 1.54, - "ps_ratio": 6.76, - "dividend_yield": 0.0, - "price": 29.08, - "change_percent": 0.0062, - "roe": 0.0762, - "gross_profit_margin": 0, - "net_profit_margin": 0.4285, - "debt_to_assets": 0.8417, - "revenue_yoy": 0, - "net_profit_yoy": 0.3786, - "bps": 18.91, - "ocfps": 3.792, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": 3200.0, - "max": 3600.0 - } - } - }, - "timestamp": "2025-11-13" - }, - "600179": { - "data": { - "stock_info": { - "code": "600179", - "name": "安通控股", - "market_value": 206.5, - "pe_ratio": 33.83, - "pb_ratio": 1.81, - "ps_ratio": 2.74, - "dividend_yield": 0.0, - "price": 4.88, - "change_percent": 0.0942, - "roe": 0.0598, - "gross_profit_margin": 0.1529, - "net_profit_margin": 0.1016, - "debt_to_assets": 0.2239, - "revenue_yoy": 0, - "net_profit_yoy": 3.1177, - "bps": 2.703, - "ocfps": 0.363, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": null, - "max": null - } - } - }, - "timestamp": "2025-11-17" - }, - "600589": { - "data": { - "stock_info": { - "code": "600589", - "name": "大位科技", - "market_value": 109.27, - "pe_ratio": 0, - "pb_ratio": 15.37, - "ps_ratio": 26.96, - "dividend_yield": 0.0, - "price": 7.36, - "change_percent": -0.0252, - "roe": 0.0552, - "gross_profit_margin": 0.206, - "net_profit_margin": 0.1242, - "debt_to_assets": 0.7641, - "revenue_yoy": 0, - "net_profit_yoy": 2.3678, - "bps": 0.479, - "ocfps": 0.033, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": null, - "max": null - } - } - }, - "timestamp": "2025-11-17" - }, - "002065": { - "data": { - "stock_info": { - "code": "002065", - "name": "东华软件", - "market_value": 324.39, - "pe_ratio": 64.92, - "pb_ratio": 2.66, - "ps_ratio": 2.43, - "dividend_yield": 0.0049, - "price": 10.12, - "change_percent": 0.011, - "roe": 0.029, - "gross_profit_margin": 0.2088, - "net_profit_margin": 0.0466, - "debt_to_assets": 0.5099, - "revenue_yoy": 0, - "net_profit_yoy": -0.2845, - "bps": 3.806, - "ocfps": 0.192, - "from_cache": false - }, - "targets": { - "target_market_value": { - "min": null, - "max": null - } - } - }, - "timestamp": "2025-11-17" - } -} \ No newline at end of file diff --git a/项目文档.txt b/项目文档.txt deleted file mode 100644 index c534033..0000000 --- a/项目文档.txt +++ /dev/null @@ -1,193 +0,0 @@ -# 价值投资盯盘系统项目文档 - -## 项目概述 - -**项目名称**:价值投资盯盘系统 -**开发者**:张艺杰 -**项目类型**:A股智能股票分析与监控平台 -**技术栈**:Python + FastAPI + Bootstrap + ECharts - -## 系统功能 - -### 1. 核心功能 - -1. **股票监控** - - 实时股票行情监控 - - 自定义市值目标区间 - - 多维度指标展示 - - 涨跌幅实时更新 - -2. **指数行情** - - 主要指数实时展示 - - K线图可视化 - - 涨跌幅实时更新 - -3. **公司详情分析** - - 公司基本信息 - - 财务指标分析 - - 股东结构分析 - - AI智能分析 - -### 2. 具体指标监控 - -#### 2.1 基础指标 -- 股票代码和名称 -- 现价和涨跌幅 -- 市值监控 -- 目标区间对比 - -#### 2.2 估值指标 -- 市盈率(PE) -- 市净率(PB) -- 市销率(PS) -- 股息率 - -#### 2.3 财务指标 -- ROE(净资产收益率) -- 毛利率 -- 净利率 -- 资产负债率 -- 净利润增长率 -- 每股净资产 -- 每股经营现金流 - -### 3. AI分析功能 - -1. **投资建议** - - 总体建议 - - 建议操作 - - 关注重点 - -2. **价格分析** - - 合理价格区间 - - 目标市值区间 - -3. **多维度分析** - - 估值分析 - - 财务健康状况 - - 成长潜力 - - 风险评估 - -## 技术实现 - -### 1. 后端架构 - -1. **Web框架** - - FastAPI作为主要Web框架 - - Uvicorn作为ASGI服务器 - -2. **数据源集成** - - Tushare API接口 - -3. **数据处理** - - Pandas进行数据分析 - - NumPy进行数值计算 - -### 2. 前端实现 - -1. **UI框架** - - Bootstrap 5.1.3 - - 响应式设计 - -2. **数据可视化** - - ECharts 5.4.3 - - 动态K线图表 - -3. **交互设计** - - AJAX异步数据更新 - - 实时数据刷新 - - 模态框展示详情 - -### 3. 数据存储 - -1. **配置存储** - - JSON文件存储监控列表 - - 配置文件自动管理 - -2. **缓存机制** - - 行情数据缓存 - - 智能更新策略 - -## 部署要求 - -### 1. 系统要求 -- Python 3.8+ -- 8GB+ RAM -- 现代浏览器支持 - -### 2. 依赖安装 -```bash -pip install -r requirements.txt -``` - -### 3. 配置说明 -- 需配置Tushare API Token -- 配置端口默认为8000 -- 支持热重载 - -## 使用说明 - -### 1. 启动系统 -```bash -python run.py -``` - -### 2. 访问系统 -- 浏览器访问:`http://localhost:8000` - -### 3. 基本操作 -1. 添加监控股票 - - 输入6位股票代码 - - 设置目标市值区间 - -2. 查看股票详情 - - 点击股票名称查看详细信息 - - 查看AI分析报告 - -3. 管理监控列表 - - 删除不需要的股票 - - 强制刷新数据 - -## 安全性考虑 - -1. **数据安全** - - API Token安全存储 - - 敏感信息加密 - -2. **访问控制** - - 请求频率限制 - - 错误处理机制 - -## 后续优化方向 - -1. **功能扩展** - - 增加更多技术指标 - - 添加自定义告警功能 - - 支持多维度筛选 - -2. **性能优化** - - 优化数据缓存机制 - - 提升响应速度 - - 减少资源占用 - -3. **用户体验** - - 增加自定义主题 - - 优化移动端显示 - - 添加更多图表类型 - -## 维护说明 - -1. **日常维护** - - 定期更新依赖 - - 检查API可用性 - - 优化数据缓存 - -2. **问题处理** - - 日志监控 - - 异常处理 - - 性能监控 - -## 版权信息 - -版权所有 © 2024 张艺杰 -保留所有权利 \ No newline at end of file