From eeddc58327e2a201b14d021571446f53d167085a Mon Sep 17 00:00:00 2001 From: aaron <> Date: Wed, 22 Apr 2026 22:19:29 +0800 Subject: [PATCH] update --- .../app/__pycache__/config.cpython-313.pyc | Bin 5561 -> 5800 bytes .../recommendations.cpython-313.pyc | Bin 8872 -> 9117 bytes .../api/__pycache__/stocks.cpython-313.pyc | Bin 32872 -> 33729 bytes backend/app/api/recommendations.py | 4 + backend/app/api/stocks.py | 9 +- backend/app/config.py | 4 + .../data/__pycache__/models.cpython-313.pyc | Bin 9181 -> 9384 bytes .../tushare_client.cpython-313.pyc | Bin 15908 -> 15808 bytes backend/app/data/models.py | 4 + backend/app/data/tushare_client.py | 4 +- .../db/__pycache__/database.cpython-313.pyc | Bin 5870 -> 6122 bytes .../app/db/__pycache__/tables.cpython-313.pyc | Bin 6552 -> 6733 bytes backend/app/db/database.py | 4 + backend/app/db/tables.py | 4 + .../__pycache__/recommender.cpython-313.pyc | Bin 38219 -> 42832 bytes .../__pycache__/screener.cpython-313.pyc | Bin 60096 -> 71016 bytes backend/app/engine/recommender.py | 84 ++- backend/app/engine/screener.py | 502 ++++++++++++------ .../llm/__pycache__/prompts.cpython-313.pyc | Bin 7090 -> 8241 bytes backend/app/llm/batch_screener.py | 123 ++++- backend/app/llm/prompts.py | 28 + backend/astock.db | Bin 2797568 -> 2797568 bytes .../src/app/(auth)/recommendations/page.tsx | 99 +++- frontend/src/app/(auth)/stock/[code]/page.tsx | 31 +- frontend/src/components/stock-card.tsx | 55 +- frontend/src/lib/api.ts | 8 + 26 files changed, 756 insertions(+), 207 deletions(-) diff --git a/backend/app/__pycache__/config.cpython-313.pyc b/backend/app/__pycache__/config.cpython-313.pyc index de9c77422d339dee09620a383c774f01d75336ae..57859c909d2e32c4a247606acc6bf6b8f78215e7 100644 GIT binary patch delta 1230 zcmZvb%TE(=5Xbu~ZD}na~R+ri?WTo4h*(KtWqn^C{ z4sz3@deg=j6XTzN7!HyijENUXycqT3MP~-$Bk`B)cjq%RzxmDnrhVD^C{p&dv~-;f z*Nvr5`g`BgvQi=XWk4*crtJ`7>SEdf zjZEE4b_ge&SaT;+4>U3DV%iPOOnaDmp#{$?b;b8GR)UhKkl*c1!?2C}IK=J&i1404 zrXgr!8fH2SQKk{5Bk@>#w9O9f@uSc&bxaw{w-z;uxs_SG+d@?;nN92Iq@}4>a=DC} z(GA^NX{1nPCS#~qK%3MvmIiAP7exa9MvUaFn#yHUGXPrFS}CKH^^`KHXOn9sr_f2O zui!KEtZL2VCme=dbjk(S1Me0LKn+Qqyi;5w9~am5LB~#N{HXW5-cR;+7E$;bXFtq5{I3HSagqG{-7abesE0-Mg z%Pq>HBlr-#O8&XqEkrE;+Ve(;OYE(;Ur zKQC{WP5n9HC;nSjK zv8o2Ajxa0Ds)HgXUsUxE4$~$Y5#Qo!jR8VFgR-HIgl4zej=R^$rDrnQKFFev`ZO)M ZzuI5kFT??H_vSYn8(&6bs`>=q#V@>!9HjsN delta 1002 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 MG2L~3R|Sdw1uFWZ{{R30 diff --git a/backend/app/api/__pycache__/stocks.cpython-313.pyc b/backend/app/api/__pycache__/stocks.cpython-313.pyc index 79fcd35eabfce6cf0b8abe146585ade5e994011e..12c28f24e9be708734cfac8c28d9ec32821fcc63 100644 GIT binary patch delta 3471 zcma)83s6&M7QTPNO@QPIgqH~r5+H=gLt=OY6(J}n)`BDrz8fPiQB2?hqqPsvZl_zj zRq(G%)LNn4R@`k@I+>kCcYSxJ?bz7{vl^T2Y};Ky-JRXJK6blpXJ_`D3)FRHyStgm zchC2o^Pl_w=Rfzq_jbSJ;){|w_c<<_f#1#Rx7$D2_THRtWr`S}&%^(IrNW;e*F;1( zjP_*OP#9mR%wU`fyBgyk{W)@q$+Yswm71dxvP=#`fy6QIdj{r;129mLCkEy_6^;U0 z7?nt0R_F^v$pTUl3;DN1GBAN@dk*6$wn*xUqeP4;1>K6nEM?$XXpx{gU+#%@Eb_!T z7JF3An4Fn8H$y5%IjBoKs(P(6#;&HnSD30RoH33hyvrQMB7t~?qY@chZFqH56*3MP zFc~rzaa7JmfNh(NqFOGEDCuG@EsB6%4SF>lP^#%BP9rMIL0OIpy8^5gqE!o4t!S+T zYo%yC2G(P^o~Tuj=W$?#1B%~ojk3i0b<-s{En zZ2-0r*hZ1znw|vqowdG_Gc$eX?JATYMtJ8%5p*1Jc;s!qP8wY{UKg?BZ#G_`lPdp)z^Q!kykJ@O-e zOVV*kdhEk6zQx_`^@NqJ-lpDeSEsk#)6-3*>eZZt-wB!d0ss?7N7RjM#5VBKzpHO6 z_Jf!O|L!8W|Bhyh#9Dl6;mL*Xc`vv>Xt~((;p4%Y^}&)2!Q73Z-0gwQ+b1GBZm3d+ zyH50u^ak>6L1R_O*l+=4J6)dE85t9qEbFfq66pD~C-My`(KnQ_1NwnwgGNCL>8~;6J!h*KVTbZ9jq1P23o02$U`-n;SvTK26hgvA94+w1qEtKhSLql1u{z} z^ra#F;&HRU8EC&YaU(io87|e=;Om=6n;%Lm8Z!%VsHzxF>+1^W7fhsELh07AE+HP} z1cu8PHU|s~Co&g>GAqZsghZ5+7*0Q|4`f*OY{_Dt?RNFGWl9o;1uQOdHd8igCM8j=m*u+r#yvwo}z7 ztH%n0dA49`l~946O2Tl-KH{5qmNSK((~7$oO2w6Zrh)YjD-U}^T3|s<(Y;zRZWS%ly{z0$7Aa){vxt@5TSEgnW8Ye- zJSqC??pNWN+MC#@B}2qHPv(#2%L{;9%O^18TA_#*;!FopLeNnr zYmAd$t00XM<#k30_I0JIQR_dXw-Od-ch!G2qfJ5%(S-(I^dS(!lIB+a82!v(X5R+- zJHIM>D*H0hR<>q^iqD#(gUOtiCMMZzYytN6mm)7?CG^v-Mj<@^@0% z9%-}YUPd_r`p7Hpp15n_cz0)KLGzXZ>?Gbbv!CD4f3{gm{voDm+S=>c&VP-0_aJSkcM#7FWaFZq?zhHHMV|Keua<D#R7Df1KqrtzSn7?Uv_HQwV;*wBf1LZRt?L2 z7Ox(%zH0UHH`4XTcOTyUX>5WZVG~R5OXYDYcq6Ok`jP{&`up+Gx;bAl(V96wTqz{c zj$Je|ok>U?vIgl`!3^Cc=mDVhqb& zu4ZqO%O&dYgpEhL_%F@Ood$Nc4gPO<_IV-1kM!8kX3}aHhbB U#;D`Lw1I(HcO|08w?o1H1?-!|NdN!< delta 2783 zcmZuzYj6|S72Ydp<%c9o7M5*UvMgB!TMjl7Hnt4-A#fnP4Ax>pLo8^Z9qV2OvjihIL~tZAJtlY^_L`Cj*lUGHRlr8ee*d>%g2Z)9+sle2TL zv7)L|;`y|FN>!UiC}r*plcr3VU#SpWpvzG^ACvV$&=ow4-JZ^Ib3BU^DytM53A?Q+ zc7v#@Qeg5TK4cK8L)pTTkg-i)27~bGgwa9`xV0hUMq8VnxivZLPs=j5%_Vfe%!0Q~ zFXZAnSzMI@hHHd6RB&I4cVU^E6V#y8r~*W&o67*cbS?|{a^TB(_PIWvuLoMsF6+#E z1F!~}H3DmtSrf1(nfZYEWVQm>3Yo10wo+!zz?!ij_!M7>_MOi5rj$rmzEv0w6m1uQ_V{tOW+eRs>Khi+ZC**s4rck&QlL> zlnm@<(dOL_OXif;5OqYGM@uCosM}gov_EDZJs|O*BsHg-7xhJVj;@i?P^WV`Q?w(N zKH8hiEgkbq8E9s5x}0chbnobs#{**?NrRr2)7i(eEg$$`3<)8&p1jrE^1pE>YhUbW;zvxFe~J2vHwXBnRs-3SdBk6;n#{*JCM$eKfj3-j zTvrGxZUvQTNK{sWxNajbRaBCj_Jdhuk5m+(hn zcOQt!`CSSUWsPOA%qSQsMdwZ$V}C32@Ml2Y5c6ES2ziAKmoMbcg7K;NPWgu_^{YTB zV=pf{oyTyLes@QAC=dz`1iC{z`@(za+pNL8s__`c(z^$Ol!o_4=zbja40=2b4~9DF zN#L$DYC<1Zr>7Cj04WlnhuNgt!@mpaAL2vzBA&d*Ts4b^1~3OtWvWFOMA(P07XhcA zS;&TL1wcyO7w+nUJ&b^GWy~xmPjEnv(qLq;Zy=?@J*Lujbw>um^ci{>hrfUcBp87o zB+fo_v1E;np9HNJ|5fvILN1Cw^>!J_h-h4AA>^u9xn4oY3(T`&Jz38VZaABbwYrbL zb+iIt6=h;o%bQBlCZ<|96Vnz9yovA=gmVaQv6aG6#j{sfQaGlG>6Fa6Nq*tsH=|CGPomPd|VE#aCPl?RPG|ad-H+dfM@I zwEB_gz4Ie?UcOXM1FWOXS-Kr_W+uIR`qG`#$Lr~n|Fb^1bmq>{U)Iwfi*L78E6GK6 zd+Sbk_SN4yVkr3?q^0f zX?r_ps5{g}u~PK=2pR(}TIKw#ToNsd2xedIy=Lj^(B{4koCQ_QV#pgewJRyd(^ zQg(+1=oyq}5%6T7_)Scy_JrXXapP}s*cu!yK|6i= 60 " + "WHERE ts_code = :code " + "AND (action_plan IN ('可操作', '重点关注') OR COALESCE(llm_score, 0) >= 6 OR score >= 56) " "ORDER BY created_at DESC LIMIT 1" ), {"code": ts_code}, diff --git a/backend/app/config.py b/backend/app/config.py index 69c1fe05..e730b439 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -30,6 +30,10 @@ class Settings(BaseSettings): # 筛选参数 top_sector_count: int = 5 # 关注板块数量 top_stock_count: int = 20 # 进入技术面筛选的个股数 + candidate_pool_limit: int = 120 # 多路召回后的候选池上限 + llm_prefilter_limit: int = 36 # LLM 初筛保留数量 + llm_prefilter_max_concurrent: int = 6 + llm_final_limit: int = 14 # LLM 深裁决池上限 min_turnover_rate: float = 2.0 # 最小换手率 % max_turnover_rate: float = 30.0 # 最大换手率 % min_circ_mv: float = 50.0 # 最小流通市值(亿) diff --git a/backend/app/data/__pycache__/models.cpython-313.pyc b/backend/app/data/__pycache__/models.cpython-313.pyc index 8adf4b9edf7d0138a3601c0eae2e3a07f9830217..af2094340d363297d6effaa77710ef6c0615b982 100644 GIT binary patch delta 545 zcmccXzQU9DGcPX}0}y0idy)BeBkvM1CJm;^A7s=v-xQm{Sg+4eBp<^c!@>$w5yKj+ zr4FQn#flVSSb#ijC{Gd0(*f~->Xnex>q1p1gH`B(d4feMF{;6Wy6j*R^}%8WMQZ7S znufRdf>M+7b8}PkQW8rt^YdPAUL*0I(Sf@tH90XSC%z;xz4(?;K~ZX2W==_JQG7~j za%M45*)0KtSW#+XF;IjjEkC)mIKCi1Gq0q0@+>I@M)k>lvhtH}Nwo_s1iG_m5r|j} zB9=^!mDXk~+1x3u$7m=k-{3t#WJ2;p$;&MAH{=v9u!zlIzRaRWfUGBR%6d|5%5(PBGDG8#mH&DsG|TeK6z+65wZgNPUq zu?Ix#1rhtfCa(uELB!U{4oXWI*H3`eG zDa0rS3+l2nFaX80!D2c^O6h`{x{L2iFilpFmS>#~t@-Jz1MyJh6GJ1@Yn`Fxw z=TH768>F@fXhso;m=DCoyg;Iz;RZKfv)2tlk!vg>MJizV$qR%eCwI&3Vq7xWO@6WR zN|2y3h>!*mU^S{B)^ZrJWHOIJKI3wbzRgn=I2ai>Zl0$o%xJM0q#znZfGyqvQ(v?d z#M%ZTwu6Wm5U~S9>;w_JC%=+cWZXEJTX`wt>d70Gow&i;!Pc#w{7+eyao=Pal?Wb1 SM)e7iUl@SY7mvveDjEQ_%vc-% diff --git a/backend/app/data/__pycache__/tushare_client.cpython-313.pyc b/backend/app/data/__pycache__/tushare_client.cpython-313.pyc index 12bd2af2920b8338c3c8d687d08fa7710a7123ef..6c2da9d67ec13f57f93d5ba997b0cc18711702d0 100644 GIT binary patch delta 1685 zcmZ{ke@t6d6vw&!@%ULOMb<&rLI(S&GBwESgejQhiXod2TU@9k5{nD38IZ_hdR zoO91P_uhEv^I69|hohuGe5Zc+E%p0N$EuC(B%6J)@RpEWEI3Zr_f*lUz1T8XKoyEx zET6X>FG$;IREaQ!K2<#Qvog$jX?LOD`HQ>yt;~hYwT5>auQz`0ZZ?vIw{5J39xbb9 zei|<8P`tt`uQe9RtX8E1>mZ#kZ`=7emYqNd*aNf*WR+MnmQ2KXyYXvz%v!o1iV9$W zMk?poE?TP$cK1VIhq+UltGb?6H9e}QVmt_4FLa7NnNDeX&~7?_V!zS*@D*!lNUZPv zExxfaS)+_>ZbKE6<_y}y8>1B_H{;cU^{j}HPSEQ4O5%GM_O-wfb>0JxcxTEu0ukQwdpfc ziA~uQ>q$+O8HxNBsjt3Dk>zS7)=)>Y^-t~Ch;&N02wHQI7G)4)t^PB%L-rF>ovTL) zT;^5dIdTOCLi5;cmOuuT7JNa7??0~)iFUDuZFDto*m?iKX+7X!=Zxw{LKa1y_b43- z`dnCU?z$+On=S>1To)1A%v+wp2$tWNhmgQz!-?TBX58_B*p-HqFh(w`lf+cZ&_o3 z53nC4;2#1HK5ZKHwcor3MzZyN)|6kkQrSoKbW{`9f2$MOge8wc&$bWz>*JmF zTjnK-A#&~#avqCZ({ zYC@M?$=VP*_m;^X&cg6j;7#BXungP)-UB`YJ^^k6Ujg3=WUEy3fmNf%Gu=Ni=A}?i zJ!_KLl`~&|? BqtO5W delta 1669 zcmZvdZA@EL7{`0)?PaAD5M)5hOL?)g>qdcYBM_!Umu*_6A|cL=TATJx8cK`jUV^-s zvBlSI%Hm_3XyP{FOO_>W=4M}}aTqo6gYhNvgBGHRG0|mTru$+{d^!KqE)?N@`1SOh z=jnN#|M{QW#p$h57P?aDrd~&pGH6Mv zRH6>CLK;!sMRr{?B1U>dOVP9HKDL*xs*N?v#U*on$NFBs{j_V|^<8nxa#hW#saK|G z##9QgD9lZtIht7${pQ%A){APn$80IKTGdXBH`CVAU{e={-9S6g0R#myYAhNX*5Z7- zd9*ZUb?m{yZNP52QT`nB(nN)i?V__4?uN~n?iI+WMmm|$4WBI+iiJJqQpKCrwmvbw z_OE!Zm0~2z*@V$QV$N5{BZ##Ej{*nGcdL?&_0W^0|WGRgR41D zH%uZ%R|ICobF0vjt}43jaZsx#)SYb}p@`zU02?qRa8AuOi(la(APmSocAKV0Dd>Gj zOk@;ea!iwA`Ep{}jbS|^>lQO}NP=ps1w&>Bd3+nm+5AB5%FYMHg3p@WS*C`~iRO=4 ztvpfOf~+h>*dkAq1^@F%%r8K$@)yPQ8V$x=LIbaz?(&Ap{;z>BStY&iRoMyilDDlu zmF*AE^*ig!FFaH8_ zO9Q-t326YkywH)+L)p_GqKek}HZ&{DD6MH9O{cWUL&@~Sy3LBnOpwlYRnv07PJg%F zH;~3}!~B^Iy1~bc#Hf}%{{vW9DGDYKcSSm0_5>3kC(4aT+*oCY`M?tMUO>J9Tm;tI z9{EA?cQn$6?f2&VZz>(sW?Vd>mNU3ME~2)Z1yJ%RBB-k0a`YSaM+EMCcqDD0q|YpBQ&N*Nizgd$icH?i zr6G?HFG@`;&d)v|v^ce>SU)kbC_hiXIKQ+gIW;~xKP6Q^ zvA85ZIa@a|JvFaHe={fZJ!WS8D$mImIn*}`vJ0~^>Q3IstGqdrSCWI>K!U5o{X^2^ zPEkGGR7N20J3E7bTz_3x-AxXIj~R?if4xQ06oSp@JHa;R*qh_pX zGHDf}l`;vqVpws`5;Ntyr4Fb&SE-x(OY@LzOWNZdtXJHi79>ln5<6Yw^pYeR&J*1E zvhIZCuW<3#BvCRHSmhF~bNU9?V2#u32K#L6J{UXdZmuKW!v?>JgKsxu#pJCxVdidg z=I(HsANDSHeoxnxmn0So3!z8@z#*de@-d17qy;8c0*1OvU z^`Kp84;+YX#p>GO8kvJMzGZf)c5SUL)+K{o{QRZvLtWjy?$Dy;x>@c4=2nKSL&G0J ze74>BBZEgh+oomrvFwjATgX+uvrWW5G4wkGb=0nQELoD1ZT0_VY&{(h>+~sha^~Rf z^uO3V_H`DZ(@48^vGNOed1&zRnL&LHUiAC_0?xi{XX|tJYIAy7_ea=0{uRXP-5-Pc zx}Eur;j9FT+dcjding|Q?qG7_+mpZSKbhAY`OMyKF1J(MJt#afZ{L66KF(+Mv$ zzmv}FWeeG(!xz0e%V%~AK)Uvi_8t~?j*qg3#lp$sE~h9ARGzs4jvv9e0N;YuR{6sf zE`DNf zzkQWNX#&}@mfK8SL3B+(1`AP&SZ-B7c%4BUn5g6`y}{rb!n(t+c&d50rZin=_+}HM z@lBOX(OZn(W);9@)q~FOFnG5G@p|qt^eRJ3@<1*4eEiw?$t9H}{&@|L>L%~$=1JK@ KMDhc?`u_lhsN=~1 delta 1959 zcma)+Npl)U6vr9pVUY|1WVE5p0xa+%ED4ZC62g)#J8`NK#UrwL90yBSUPS`f&T`=D zBMFzO@@0;cPuQxm{Uz+u6c=A}%dwSwfxOoYL#BPnR839&e{X-?@Ghk;`u_2H`)u%p zWVarP^mzRLCXiB;P>r^4DhVYD~E+WQpS z8@Z=!;{}P;KDQ>5zu@#Xr@!R%bw!i~IcS`^thnJ0bk&YU<)QAlY5S_9hK#_k zL4IQzKi;>W4fHJzwE7(zpttWEOm{Dy0_vuj>3e8FG!@J1fpcW%bPF#tTbsvNTYy#h z0jo^$r&@r#z4m(@I*r&Mi@lApjhn9ViCysDGbFmq zy9esNneBn%l;=$K|7Pr7jgzd>L##Bg4~N(L$v(om0|!uP(#&0II6VpJM@uh1SyUN% z>0N*d*s5l9Z%)54XNXmQjMb$l;M=SI6x7dVMe+ihchiea}+$VQueiR3|wqg6Y_46Z@Y@^*t@Gle5%?$c-pd zPiZlo#Hj~SAEM>@PMeI;egr2FEY-IVcoFyj9Q99XVw;?%1Ci01{n(E_>uS19PSF7R z1QFy^2%4dT2u`w~akbbcQ5r%r#K^Q3YXx+&NQaS)FgB%yT7pg%=qR!=#=>g8O(Ham zWcAlWBSi!3+YU#R!5Zf>jl!n4~cTrxCz@+9XV85yU$l zM7yn%1Wh11gD9&;n{J)V)3b=q)qvqXNE7_#>3M{6j77Aa)`CtlG>Pnj!SbyQWb?=t zj96D^35&>5j7_N3<_(=lG>t67SXkX@LcTe=glriYOq8lMgE~1!C1fkeR_fKGSQ=}r zB3omz*xwzUq-hq}x)Iw6%^|udAcKWyRZO=bAlzgSir^kEF?5-sHFcyFeG`8bKRn~p XN%rq8v>n+($JY+GLPR8WxS#(5Yc{7h diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 5af2fe78..ce5aed29 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -55,6 +55,10 @@ async def init_db(): "ALTER TABLE market_temperature ADD COLUMN broken_rate REAL", "ALTER TABLE recommendations ADD COLUMN entry_signal_type TEXT DEFAULT 'none'", "ALTER TABLE recommendations ADD COLUMN entry_timing TEXT DEFAULT ''", + "ALTER TABLE recommendations ADD COLUMN recall_tags TEXT DEFAULT '[]'", + "ALTER TABLE recommendations ADD COLUMN prefilter_decision TEXT DEFAULT ''", + "ALTER TABLE recommendations ADD COLUMN prefilter_reason TEXT DEFAULT ''", + "ALTER TABLE recommendations ADD COLUMN focus_points TEXT DEFAULT '[]'", "ALTER TABLE sector_heat ADD COLUMN stage TEXT", "ALTER TABLE sector_heat ADD COLUMN days_continuous INTEGER", "ALTER TABLE sector_heat ADD COLUMN member_count INTEGER", diff --git a/backend/app/db/tables.py b/backend/app/db/tables.py index f3f3aab8..18a3140c 100644 --- a/backend/app/db/tables.py +++ b/backend/app/db/tables.py @@ -40,6 +40,10 @@ recommendations_table = Table( Column("entry_signal_type", Text, default="none"), Column("entry_timing", Text, default=""), Column("llm_score", Float, default=None), + Column("recall_tags", Text, default="[]"), + Column("prefilter_decision", Text, default=""), + Column("prefilter_reason", Text, default=""), + Column("focus_points", Text, default="[]"), Column("scan_session", Text), Column("created_at", DateTime, server_default=func.now()), ) diff --git a/backend/app/engine/__pycache__/recommender.cpython-313.pyc b/backend/app/engine/__pycache__/recommender.cpython-313.pyc index 3c5a1ee20647bb939636dd7b1a83142731b27376..e27dd600a8cad057a41f1ab4a4d5afd68b7c5f60 100644 GIT binary patch delta 9934 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 diff --git a/backend/app/engine/__pycache__/screener.cpython-313.pyc b/backend/app/engine/__pycache__/screener.cpython-313.pyc index 501d9be6e4c4bf1f707c548ad373d489772979a6..19e80bcf3bceb69f07391401cef88b50e3e72d22 100644 GIT binary patch delta 30240 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 L{zrxktNZ@}mB^V) delta 20710 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 diff --git a/backend/app/engine/recommender.py b/backend/app/engine/recommender.py index 86c3b0f0..d8a276c5 100644 --- a/backend/app/engine/recommender.py +++ b/backend/app/engine/recommender.py @@ -365,7 +365,7 @@ async def get_performance_stats() -> dict: text( "SELECT r.ts_code, r.name, r.signal, r.entry_price, " " r.target_price, r.stop_loss, r.entry_signal_type, r.score, " - " r.action_plan, r.lifecycle_status, " + " r.action_plan, r.lifecycle_status, r.recall_tags, r.prefilter_decision, " " t.pct_from_entry, t.current_price, t.track_date, t.hit_target, t.hit_stop_loss, " " t.max_return_pct, t.max_drawdown_pct, t.days_since_recommendation, " " t.close_reason, t.review_note, r.created_at " @@ -388,6 +388,8 @@ async def get_performance_stats() -> dict: "entry_signal_type": r["entry_signal_type"], "action_plan": r["action_plan"], "lifecycle_status": r["lifecycle_status"], + "recall_tags": json.loads(r["recall_tags"]) if r["recall_tags"] else [], + "prefilter_decision": r["prefilter_decision"] or "", "score": r["score"], "entry_price": r["entry_price"], "target_price": r["target_price"], @@ -418,6 +420,8 @@ async def get_performance_stats() -> dict: "hit_target_count": hit_target_count, "hit_stop_count": hit_stop_count, "lifecycle_counts": lifecycle_counts, + "route_breakdown": _build_route_breakdown(details), + "prefilter_breakdown": _build_prefilter_breakdown(details), "details": details, } except Exception as e: @@ -428,7 +432,7 @@ async def get_performance_stats() -> dict: "total_recommendations": 0, "tracked": 0, "winning": 0, "win_rate": 0, "avg_return": 0, "avg_max_return": 0, "avg_max_drawdown": 0, "hit_target_count": 0, - "hit_stop_count": 0, "lifecycle_counts": {}, "details": [], + "hit_stop_count": 0, "lifecycle_counts": {}, "route_breakdown": [], "prefilter_breakdown": [], "details": [], } @@ -470,7 +474,7 @@ async def get_recommendation_history(days: int = 7) -> list[dict]: " ) lt ON t.id = lt.max_id" ") latest_t ON latest_t.recommendation_id = r.id " "WHERE r.created_at >= :start " - "AND r.score >= 60 " + "AND (r.action_plan IN ('可操作', '重点关注') OR COALESCE(r.llm_score, 0) >= 6 OR r.score >= 56) " "AND r.id IN (" " SELECT MAX(id) FROM recommendations " " WHERE created_at >= :start " @@ -526,6 +530,10 @@ async def get_recommendation_history(days: int = 7) -> list[dict]: "entry_signal_type": r.get("entry_signal_type") or "none", "llm_analysis": r.get("llm_analysis") or "", "llm_score": r.get("llm_score"), + "recall_tags": json.loads(r.get("recall_tags") or "[]"), + "prefilter_decision": r.get("prefilter_decision") or "", + "prefilter_reason": r.get("prefilter_reason") or "", + "focus_points": json.loads(r.get("focus_points") or "[]"), "tracking": { "current_price": r.get("latest_current_price"), "pct_from_entry": r.get("latest_pct_from_entry"), @@ -573,6 +581,50 @@ def _score_to_level_static(score: float) -> str: return "观望" +def _build_route_breakdown(details: list[dict]) -> list[dict]: + stats: dict[str, dict] = {} + for item in details: + for tag in item.get("recall_tags", []) or []: + bucket = stats.setdefault(tag, {"route": tag, "count": 0, "wins": 0, "avg_return_sum": 0.0}) + bucket["count"] += 1 + pct = float(item.get("pct_from_entry") or 0) + bucket["avg_return_sum"] += pct + if pct > 0: + bucket["wins"] += 1 + result = [] + for bucket in stats.values(): + count = bucket["count"] or 1 + result.append({ + "route": bucket["route"], + "count": bucket["count"], + "win_rate": round(bucket["wins"] / count * 100, 1), + "avg_return": round(bucket["avg_return_sum"] / count, 2), + }) + return sorted(result, key=lambda item: item["count"], reverse=True) + + +def _build_prefilter_breakdown(details: list[dict]) -> list[dict]: + stats: dict[str, dict] = {} + for item in details: + key = item.get("prefilter_decision") or "unknown" + bucket = stats.setdefault(key, {"decision": key, "count": 0, "wins": 0, "avg_return_sum": 0.0}) + bucket["count"] += 1 + pct = float(item.get("pct_from_entry") or 0) + bucket["avg_return_sum"] += pct + if pct > 0: + bucket["wins"] += 1 + result = [] + for bucket in stats.values(): + count = bucket["count"] or 1 + result.append({ + "decision": bucket["decision"], + "count": bucket["count"], + "win_rate": round(bucket["wins"] / count * 100, 1), + "avg_return": round(bucket["avg_return_sum"] / count, 2), + }) + return sorted(result, key=lambda item: item["count"], reverse=True) + + async def _save_to_db(result: dict): """将推荐结果保存到数据库""" try: @@ -631,7 +683,14 @@ async def _save_to_db(result: dict): # 保存推荐:先批量清除当日旧记录,再批量插入 today_str = datetime.now().strftime("%Y-%m-%d") now_dt = datetime.now() - qualified_recs = [rec for rec in result.get("recommendations", []) if rec.score >= 60] + qualified_recs = [ + rec for rec in result.get("recommendations", []) + if ( + rec.action_plan in {"可操作", "重点关注"} + or (rec.llm_score is not None and rec.llm_score >= 6) + or rec.score >= 56 + ) + ] if qualified_recs: # 批量删除当日同一 ts_code 的旧记录 codes = [rec.ts_code for rec in qualified_recs] @@ -676,6 +735,10 @@ async def _save_to_db(result: dict): "entry_signal_type": rec.entry_signal_type, "entry_timing": rec.entry_timing, "llm_score": rec.llm_score, + "recall_tags": json.dumps(rec.recall_tags, ensure_ascii=False), + "prefilter_decision": rec.prefilter_decision, + "prefilter_reason": rec.prefilter_reason, + "focus_points": json.dumps(rec.focus_points, ensure_ascii=False), "scan_session": rec.scan_session, "created_at": now_dt, } @@ -684,7 +747,10 @@ async def _save_to_db(result: dict): await db.execute(tables.recommendations_table.insert(), rec_values) await db.commit() - logger.info(f"已保存 {len(qualified_recs)} 条推荐到数据库(共 {len(result.get('recommendations', []))} 条,过滤掉 <60 分)") + logger.info( + f"已保存 {len(qualified_recs)} 条推荐到数据库" + f"(共 {len(result.get('recommendations', []))} 条,过滤掉低优先级候选)" + ) except Exception as e: logger.error(f"保存推荐到数据库失败: {e}") from app.db.error_logger import log_error @@ -721,11 +787,11 @@ async def _load_today_from_db() -> dict: temperature=m["temperature"], ) - # 加载推荐(取最近一个有数据的日期,按 ts_code 去重,只取 >= 60 分) + # 加载推荐(取最近一个有数据的日期,按 ts_code 去重,优先保留行动级别更高的结果) result = await db.execute( text("SELECT * FROM recommendations " "WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) " - "AND score >= 60 " + "AND (action_plan IN ('可操作', '重点关注') OR COALESCE(llm_score, 0) >= 6 OR score >= 56) " "AND id IN (SELECT MAX(id) FROM recommendations " " WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) " " GROUP BY ts_code) " @@ -766,6 +832,10 @@ async def _load_today_from_db() -> dict: strategy=r.get("strategy") or "trend_breakout", entry_signal_type=r.get("entry_signal_type") or "none", llm_score=r.get("llm_score"), + recall_tags=json.loads(r.get("recall_tags") or "[]"), + prefilter_decision=r.get("prefilter_decision") or "", + prefilter_reason=r.get("prefilter_reason") or "", + focus_points=json.loads(r.get("focus_points") or "[]"), scan_session=r["scan_session"] or "", )) diff --git a/backend/app/engine/screener.py b/backend/app/engine/screener.py index 46a56749..9dd64a36 100644 --- a/backend/app/engine/screener.py +++ b/backend/app/engine/screener.py @@ -91,21 +91,14 @@ async def run_screening(trade_date: str = None) -> dict: f"threshold={strategy_profile.buy_threshold} min_score={strategy_profile.min_score} ===" ) - # ── Step 2: 板块内选股 ── - logger.info("=== Step 2: 板块内选股 ===") - if intraday: - candidates = await intraday_filter_stocks(hot_sectors) - else: - candidates = await _select_from_hot_sectors(hot_sectors, trade_date, intraday) - - if not candidates: - logger.info("=== Step 2 无候选,回退到全市场扫描 ===") - candidates = await scan_trend_breakout( - trade_date=trade_date, - market_temp=market_temp, - hot_sectors=hot_sectors, - intraday=intraday, - ) + # ── Step 2: 多路召回构建候选池 ── + logger.info("=== Step 2: 多路召回候选池 ===") + candidates = await _build_candidate_pool( + hot_sectors=hot_sectors, + trade_date=trade_date, + intraday=intraday, + market_temp=market_temp, + ) if not candidates: logger.info("=== 筛选完成: 0 只股票 ===") @@ -129,14 +122,23 @@ async def run_screening(trade_date: str = None) -> dict: except Exception as e: logger.warning(f"注入实时价格失败,使用 Tushare 收盘价: {e}") - # ── Step 3: 供需 + 价格行为 + 趋势评分 ── - logger.info("=== Step 3: 深度分析 ===") + # ── Step 3: 规则边界 + LLM 两阶段裁决 ── + logger.info("=== Step 3: 规则边界 + LLM 两阶段裁决 ===") recommendations = await _build_recommendations( candidates, market_temp, hot_sectors, market_temp_score, intraday, strategy_profile, ) - # 过滤低质量推荐(低于60分不推荐) - recommendations = [r for r in recommendations if r.score >= strategy_profile.min_score] + if settings.deepseek_api_key: + recommendations = [ + r for r in recommendations + if ( + r.action_plan in {"可操作", "重点关注"} + or (r.llm_score is not None and r.llm_score >= 6) + or r.score >= max(strategy_profile.min_score - 4, 56) + ) + ] + else: + recommendations = [r for r in recommendations if r.score >= strategy_profile.min_score] logger.info(f"=== 筛选完成: {len(recommendations)} 只股票 ({scan_mode}) ===") for r in recommendations[:5]: @@ -158,25 +160,28 @@ async def _select_from_hot_sectors( trade_date: str, intraday: bool, ) -> list[dict]: - """Step 2: 从热门板块成分股中选出有资金流入的候选 + """热点板块轻召回。 - 流程: - 1. 收集所有热门板块的成分股代码 - 2. 用 get_daily_all + get_daily_basic 过滤市值/换手率 - 3. 用 get_moneyflow_batch 过滤主力净流入 > 0 - 4. 对候选做入场信号初筛(只需满足任一信号类型) + 这里只做基础清洗和活跃度排序,不再用“主力净流入必须为正”之类的硬门槛直接淘汰。 """ - from app.data.tushare_client import tushare_client from datetime import datetime, timedelta - import pandas as pd if not trade_date: trade_date = tushare_client.get_latest_trade_date() - # 收集热门板块成分股代码 sector_member_codes: set[str] = set() - sector_code_map: dict[str, str] = {} # ts_code -> sector_name - for s in hot_sectors: + sector_code_map: dict[str, str] = {} + sector_stage_map: dict[str, str] = {} + sector_rank_map: dict[str, int] = {} + leader_codes: set[str] = set() + + for idx, s in enumerate(hot_sectors): + sector_rank_map[s.sector_name] = idx + 1 + sector_stage_map[s.sector_name] = s.stage + for leader in s.leading_stocks or []: + leader_code = str(leader.get("ts_code", "")).strip() + if leader_code: + leader_codes.add(leader_code) try: members_df = tushare_client.get_ths_members(s.sector_code) if not members_df.empty and "con_code" in members_df.columns: @@ -188,47 +193,44 @@ async def _select_from_hot_sectors( logger.warning(f"获取板块 {s.sector_name} 成分股失败: {e}") if not sector_member_codes: - logger.info("Step 2: 无板块成分股数据") + logger.info("Step 2: 热点板块轻召回无成分股数据") return [] - logger.info(f"Step 2: 热门板块共 {len(sector_member_codes)} 只成分股") + logger.info(f"Step 2: 热点板块共 {len(sector_member_codes)} 只成分股") - # 过滤市值/换手率/ST/次新 stock_basic = tushare_client.get_stock_basic() exclude_codes = set() + name_map = {} + industry_map = {} if not stock_basic.empty: st_codes = set(stock_basic[stock_basic["name"].str.contains("ST", na=False)]["ts_code"]) exclude_codes.update(st_codes) cutoff = (datetime.now() - timedelta(days=settings.min_list_days)).strftime("%Y%m%d") new_codes = set(stock_basic[stock_basic["list_date"] > cutoff]["ts_code"]) exclude_codes.update(new_codes) - - # 行业映射 - industry_map = {} - if not stock_basic.empty: for _, row in stock_basic.iterrows(): + name_map[row["ts_code"]] = row["name"] industry_map[row["ts_code"]] = row.get("industry", "") - # 用 daily_basic 过滤 basic = tushare_client.get_daily_basic(trade_date) if basic.empty: logger.info("Step 2: daily_basic 无数据") return [] - basic["circ_mv"] = basic["circ_mv"] / 10000 # 万元 → 亿元 + basic = basic.copy() + basic["circ_mv"] = basic["circ_mv"] / 10000 filtered_basic = basic[ (basic["ts_code"].isin(sector_member_codes)) & (~basic["ts_code"].isin(exclude_codes)) & (basic["circ_mv"] >= settings.min_circ_mv) & (basic["circ_mv"] <= settings.max_circ_mv) & - (basic["turnover_rate"] >= settings.min_turnover_rate) & - (basic["turnover_rate"] <= settings.max_turnover_rate) + (basic["turnover_rate"] >= max(settings.min_turnover_rate * 0.5, 1.0)) & + (basic["turnover_rate"] <= settings.max_turnover_rate * 1.2) ].copy() - # 严格过滤为空时,放宽换手率条件重试 if filtered_basic.empty: - logger.info("Step 2 严格过滤无结果,放宽换手率重试") + logger.info("Step 2 热点板块轻召回严格过滤无结果,放宽换手率重试") filtered_basic = basic[ (basic["ts_code"].isin(sector_member_codes)) & (~basic["ts_code"].isin(exclude_codes)) & @@ -241,12 +243,9 @@ async def _select_from_hot_sectors( if filtered_basic.empty: return [] - # 资金流过滤:主力净流入 > 0 mf = tushare_client.get_moneyflow_batch(trade_date) - if mf.empty: - logger.info("Step 2: 资金流数据为空,跳过资金过滤") - candidate_codes = set(filtered_basic["ts_code"].tolist()) - else: + mf_lookup = {} + if not mf.empty: mf["main_net_inflow"] = ( (mf["buy_elg_amount"] - mf["sell_elg_amount"]) + (mf["buy_lg_amount"] - mf["sell_lg_amount"]) @@ -258,65 +257,177 @@ async def _select_from_hot_sectors( mf["buy_sm_amount"] + mf["sell_sm_amount"] ) mf["inflow_ratio"] = (mf["main_net_inflow"] / total.replace(0, float("nan")) * 100).fillna(0) - - mf_positive = mf[ - (mf["ts_code"].isin(set(filtered_basic["ts_code"]))) & - (mf["main_net_inflow"] > 0) - ].sort_values("main_net_inflow", ascending=False) - - candidate_codes = set(mf_positive["ts_code"].tolist()) - - # 构建资金流查找表 - mf_lookup = {} - for _, row in mf_positive.iterrows(): + for _, row in mf.iterrows(): mf_lookup[row["ts_code"]] = { "main_net_inflow": float(row["main_net_inflow"]), "inflow_ratio": float(row.get("inflow_ratio", 0)), } - - logger.info(f"Step 2 资金流过滤: → {len(candidate_codes)} 只主力净流入 > 0") - - if not candidate_codes: - return [] - - # 构建候选列表 - import numpy as np candidates = [] - for ts_code in candidate_codes: - name = "" - if not stock_basic.empty: - row = stock_basic[stock_basic["ts_code"] == ts_code] - if not row.empty: - name = row.iloc[0]["name"] - + for _, base_row in filtered_basic.iterrows(): + ts_code = base_row["ts_code"] + name = name_map.get(ts_code, ts_code) sector_name = sector_code_map.get(ts_code, industry_map.get(ts_code, "")) - b_row = filtered_basic[filtered_basic["ts_code"] == ts_code] - turnover_rate = float(b_row.iloc[0]["turnover_rate"]) if not b_row.empty else 0 - circ_mv = float(b_row.iloc[0]["circ_mv"]) if not b_row.empty else 0 - pe = float(b_row.iloc[0]["pe"]) if not b_row.empty and pd.notna(b_row.iloc[0].get("pe")) else None - pb = float(b_row.iloc[0]["pb"]) if not b_row.empty and pd.notna(b_row.iloc[0].get("pb")) else None - volume_ratio = float(b_row.iloc[0]["volume_ratio"]) if not b_row.empty and pd.notna(b_row.iloc[0].get("volume_ratio")) else None - - try: - mf_info = mf_lookup.get(ts_code, {}) - except NameError: - mf_info = {} + mf_info = mf_lookup.get(ts_code, {}) + turnover_rate = float(base_row["turnover_rate"]) if pd.notna(base_row.get("turnover_rate")) else 0 + circ_mv = float(base_row["circ_mv"]) if pd.notna(base_row.get("circ_mv")) else 0 + pe = float(base_row["pe"]) if pd.notna(base_row.get("pe")) else None + pb = float(base_row["pb"]) if pd.notna(base_row.get("pb")) else None + volume_ratio = float(base_row["volume_ratio"]) if pd.notna(base_row.get("volume_ratio")) else None + main_net_inflow = float(mf_info.get("main_net_inflow", 0)) + inflow_ratio = float(mf_info.get("inflow_ratio", 0)) + sector_rank = sector_rank_map.get(sector_name, 99) + recall_score = 30 + if sector_rank <= 2: + recall_score += 14 + elif sector_rank <= 5: + recall_score += 8 + if ts_code in leader_codes: + recall_score += 14 + if turnover_rate >= settings.min_turnover_rate: + recall_score += 8 + if volume_ratio and volume_ratio >= 1.2: + recall_score += 8 + if main_net_inflow > 0: + recall_score += 8 + elif main_net_inflow < 0: + recall_score -= 4 + recall_tags = ["hot_sector_core"] + if ts_code in leader_codes: + recall_tags.append("sector_leader") + if main_net_inflow > 0: + recall_tags.append("moneyflow_support") + if volume_ratio and volume_ratio >= 1.5: + recall_tags.append("volume_active") candidates.append({ "ts_code": ts_code, "name": name, "sector": sector_name, + "sector_stage": sector_stage_map.get(sector_name, "mid"), "turnover_rate": turnover_rate, "circ_mv": circ_mv, "pe": pe, "pb": pb, "volume_ratio": volume_ratio, - "main_net_inflow": mf_info.get("main_net_inflow", 0), - "inflow_ratio": mf_info.get("inflow_ratio", 0), + "main_net_inflow": main_net_inflow, + "inflow_ratio": inflow_ratio, + "recall_score": round(recall_score, 1), + "recall_tags": recall_tags, + "stock_role_hint": "板块领涨前排" if ts_code in leader_codes else "板块活跃成分", }) - logger.info(f"Step 2 候选: {len(candidates)} 只") - return candidates + candidates.sort(key=lambda item: ( + item.get("recall_score", 0), + item.get("main_net_inflow", 0), + item.get("turnover_rate", 0), + ), reverse=True) + top = candidates[: settings.candidate_pool_limit] + logger.info(f"Step 2 热点板块轻召回: {len(top)} 只") + return top + + +async def _build_candidate_pool( + hot_sectors: list[SectorInfo], + trade_date: str | None, + intraday: bool, + market_temp: MarketTemperature, +) -> list[dict]: + """多路召回候选池。 + + 目标是提高召回率,再交给 LLM 做资源分配与最终裁决。 + """ + merged: dict[str, dict] = {} + + sector_candidates = await _select_from_hot_sectors(hot_sectors, trade_date, intraday) + _merge_candidate_batch(merged, sector_candidates, route="sector_recall") + + try: + trend_candidates = await scan_trend_breakout( + trade_date=trade_date, + market_temp=market_temp, + hot_sectors=hot_sectors, + intraday=intraday, + ) + except Exception as e: + logger.warning(f"趋势扫描召回失败: {e}") + trend_candidates = [] + _merge_candidate_batch(merged, trend_candidates, route="trend_scan") + + if intraday: + try: + intraday_candidates = await intraday_filter_stocks(hot_sectors) + except Exception as e: + logger.warning(f"盘中异动召回失败: {e}") + intraday_candidates = [] + _merge_candidate_batch(merged, intraday_candidates, route="intraday_active") + + candidates = list(merged.values()) + candidates.sort(key=lambda item: ( + item.get("recall_score", 0), + item.get("main_net_inflow", 0), + item.get("turnover_rate", 0), + item.get("volume_ratio", 0) or 0, + ), reverse=True) + top = candidates[: settings.candidate_pool_limit] + logger.info( + f"Step 2 多路召回完成: sector={len(sector_candidates)} " + f"trend={len(trend_candidates)} " + f"{'intraday=' + str(len(intraday_candidates)) if intraday else ''} " + f"→ merged={len(top)}" + ) + return top + + +def _merge_candidate_batch(merged: dict[str, dict], items: list[dict], route: str) -> None: + for item in items or []: + ts_code = str(item.get("ts_code", "")).strip() + if not ts_code: + continue + + normalized = dict(item) + normalized.setdefault("ts_code", ts_code) + normalized.setdefault("name", ts_code) + normalized.setdefault("sector", item.get("sector", "")) + normalized.setdefault("sector_stage", item.get("sector_stage", "mid")) + normalized.setdefault("recall_tags", []) + normalized.setdefault("stock_role_hint", "待判断") + normalized["recall_tags"] = list({*normalized.get("recall_tags", []), route}) + normalized["recall_score"] = round( + float(normalized.get("recall_score", 0) or 0) + _route_recall_weight(route, normalized), + 1, + ) + + existing = merged.get(ts_code) + if not existing: + merged[ts_code] = normalized + continue + + existing["recall_tags"] = list({*existing.get("recall_tags", []), *normalized.get("recall_tags", [])}) + existing["recall_score"] = round( + min( + 100, + max(float(existing.get("recall_score", 0) or 0), float(normalized.get("recall_score", 0) or 0)) + + min(float(normalized.get("recall_score", 0) or 0) * 0.2, 10), + ), + 1, + ) + for key, value in normalized.items(): + if key in {"recall_tags", "recall_score"}: + continue + if existing.get(key) in (None, "", 0) and value not in (None, "", 0): + existing[key] = value + if len(existing.get("sector", "")) < len(normalized.get("sector", "")): + existing["sector"] = normalized.get("sector", existing.get("sector", "")) + + +def _route_recall_weight(route: str, item: dict) -> float: + if route == "sector_recall": + return 8 + if route == "trend_scan": + return min(float(item.get("entry_signal_score", 0) or 0) * 0.12, 12) + if route == "intraday_active": + return 12 + return 0 async def _build_recommendations( @@ -327,11 +438,7 @@ async def _build_recommendations( intraday: bool = False, strategy_profile=None, ) -> list[Recommendation]: - """Step 3: 对候选做供需 + 价格行为 + 趋势深度分析 - - 评分公式:供需关系 40% + 价格行为 35% + 趋势 25% - 板块和资金流已在前置过滤中处理。 - """ + """Step 3: 规则边界建模 + LLM 两阶段裁决。""" from app.data.tushare_client import tushare_client from app.analysis.technical import add_all_indicators from app.analysis.breakout_signals import ( @@ -342,6 +449,10 @@ async def _build_recommendations( ) from app.analysis.signals import generate_signals from app.analysis.capital_flow import _score_valuation + from app.llm.batch_screener import ( + analyze_candidates_individually, + prefilter_candidates_individually, + ) # 名称和行业映射 stock_basic = tushare_client.get_stock_basic() @@ -353,7 +464,7 @@ async def _build_recommendations( industry_map[row["ts_code"]] = row.get("industry", "") recommendations = [] - llm_candidates = [] # 收集候选摘要供 LLM 分析 + llm_candidates = [] total = len(candidates) signal_counts = {"breakout": 0, "breakout_confirm": 0, "pullback": 0, "launch": 0, "reversal": 0, "none": 0} score_weights = strategy_profile.score_weights if strategy_profile else { @@ -394,42 +505,31 @@ async def _build_recommendations( signal_type = entry_signal["signal_type"] if signal_type == EntrySignal.NONE: signal_counts["none"] += 1 - continue - if signal_priority and signal_type.value not in signal_priority[:4]: - signal_counts["none"] += 1 - continue - signal_counts[signal_type.value] += 1 + signal_name = "none" + else: + signal_name = signal_type.value + signal_counts[signal_name] += 1 # ── 三维度评分 ── - - # 1. 供需关系评分 (50%) — 短线核心 supply_demand_score = score_supply_demand(df) - - # 2. 价格行为评分 (40%) — 形态质量 price_action_score = _score_price_action(df, entry_signal) - - # 3. 趋势评分 (10%) — 短线趋势权重低,偏空直接过滤 trend_score = _score_trend(df) - # 趋势偏空门槛过滤:MA5 20: @@ -448,22 +548,20 @@ async def _build_recommendations( elif market_temp_score < 50: penalties.append(0.88) - # 取最大惩罚(1.0 = 无惩罚) if penalties: final_score *= min(penalties) - # 奖励可叠加(奖励之间互不矛盾) sector_limit_up = _get_sector_limit_up(sector, hot_sectors) - sector_member_count = _get_sector_member_count(sector, hot_sectors) if sector_limit_up >= 5: - final_score *= 1.20 # 板块5+涨停,情绪极强 + final_score *= 1.20 elif sector_limit_up >= 3: - final_score *= 1.10 # 板块3涨停,情绪较强 + final_score *= 1.10 if entry_signal.get("signal_score", 0) >= 80: final_score *= 1.10 - if signal_priority: + signal_matches_profile = bool(signal_priority and signal_name in signal_priority[:4]) + if signal_type != EntrySignal.NONE and signal_priority: priority_rank = signal_priority.index(signal_type.value) if priority_rank == 0: final_score *= 1.08 @@ -472,22 +570,21 @@ async def _build_recommendations( elif priority_rank >= 3: final_score *= 0.94 - # 估值评分(辅助参考,不参与主评分) pe = stock.get("pe") pb = stock.get("pb") valuation_score = _score_valuation(pe, pb) - # 确定信号和等级 level = _score_to_level(final_score) signal = "HOLD" position_score = tech_signal.position_score if tech_signal else 50 - if (signal_type != EntrySignal.NONE - and entry_signal.get("signal_score", 0) >= 50 - and position_score >= 30 - and final_score >= buy_threshold): + if ( + signal_type != EntrySignal.NONE + and entry_signal.get("signal_score", 0) >= 50 + and position_score >= 30 + and final_score >= buy_threshold + ): signal = "BUY" - # 价格参考 — 结构化止损止盈(基于市场结构而非固定百分比) entry_price = None target_price = None stop_loss = None @@ -496,21 +593,16 @@ async def _build_recommendations( st = signal_type.value details = entry_signal.get("details", {}) - # ── 入场价:统一为当前价(短线看盘即时进场) ── entry_price = round(current_close, 2) - # ── 止损价:基于市场结构 ── if st == "breakout": - # 突破型:止损在突破点(被突破的阻力位)下方1% resistance = details.get("resistance_price", 0) if resistance and resistance > 0: stop_loss = round(resistance * 0.99, 2) else: - # fallback: 近20日低点下方1% low_20 = float(df.tail(20)["low"].min()) stop_loss = round(low_20 * 0.99, 2) elif st == "pullback": - # 回踩型:止损在支撑均线下方1.5% support_ma = details.get("support_ma", "MA20") support_price = 0 if support_ma == "MA20" and not pd.isna(last.get("ma20")): @@ -522,71 +614,51 @@ async def _build_recommendations( else: stop_loss = round(current_close * 0.97, 2) elif st == "reversal": - # 反转型:止损在近5日最低点下方1% low_5 = float(df.tail(5)["low"].min()) stop_loss = round(low_5 * 0.99, 2) elif st == "launch": - # 启动型:止损在MA20下方2% if not pd.isna(last.get("ma20")) and last["ma20"] > 0: stop_loss = round(last["ma20"] * 0.98, 2) else: stop_loss = round(current_close * 0.97, 2) else: - # breakout_confirm / 其他:近20日低点下方1% low_20 = float(df.tail(20)["low"].min()) stop_loss = round(min(low_20 * 0.99, current_close * 0.97), 2) - # ── 止盈价:基于下一个阻力位 ── - # 近20日高点作为第一阻力 high_20 = float(df.tail(20)["high"].max()) - # 近60日高点作为第二阻力 high_60 = float(df.tail(60)["high"].max()) if len(df) >= 60 else high_20 if st == "breakout": - # 突破型:刚突破20日高点,目标看60日高点附近 if high_60 > current_close: target_price = round(min(high_60 * 0.98, entry_price * 1.08), 2) else: target_price = round(entry_price * 1.05, 2) elif st == "launch": - # 启动型:整理后启动,目标看整理区间上方+8% target_price = round(min(high_20 * 1.03, entry_price * 1.08), 2) elif st == "reversal": - # 反转型:从低位反转,目标看近20日高点 target_price = round(min(high_20 * 0.98, entry_price * 1.08), 2) elif st == "pullback": - # 回踩型:目标看前高附近 target_price = round(min(high_20 * 0.98, entry_price * 1.05), 2) else: - # breakout_confirm / 其他 target_price = round(min(high_20 * 0.98, entry_price * 1.05), 2) - # 保底:止损不超过入场价-8%(防止结构化止损太远) max_stop_pct = 0.08 if stop_loss < entry_price * (1 - max_stop_pct): stop_loss = round(entry_price * (1 - max_stop_pct), 2) - # 止损不低于入场价-2%(止损太近没有意义) min_stop_pct = 0.02 if stop_loss > entry_price * (1 - min_stop_pct): stop_loss = round(entry_price * (1 - min_stop_pct), 2) - - # 保底:止盈不低于入场价+3%(空间太小不值得做) min_target_pct = 0.03 if target_price < entry_price * (1 + min_target_pct): target_price = round(entry_price * (1 + min_target_pct), 2) - # 生成推荐理由 reasons = _generate_reasons(stock, entry_signal, tech_signal, df, intraday) - stock["entry_signal_type"] = signal_type.value + stock["entry_signal_type"] = signal_name risk_note = _generate_risk_note(market_temp, tech_signal, stock) - - # 量价模式 vol_pattern = analyze_volume_pattern(df) - - # 进场时机建议(盘中适用) - entry_timing = _generate_entry_timing(signal_type.value, intraday) + entry_timing = _generate_entry_timing(signal_name, intraday) trade_plan = _build_trade_plan( - signal_type=signal_type.value, + signal_type=signal_name, score=final_score, market_temp=market_temp, sector_stage=sector_stage, @@ -618,7 +690,7 @@ async def _build_recommendations( risk_note=risk_note, level=level, strategy=strategy_profile.strategy_id if strategy_profile else "trend_breakout", - entry_signal_type=signal_type.value, + entry_signal_type=signal_name, entry_timing=entry_timing, action_plan=trade_plan["action_plan"], trigger_condition=trade_plan["trigger_condition"], @@ -627,6 +699,10 @@ async def _build_recommendations( review_after_days=trade_plan["review_after_days"], lifecycle_status=trade_plan["lifecycle_status"], data_freshness=trade_plan["data_freshness"], + recall_tags=stock.get("recall_tags", []), + prefilter_decision="", + prefilter_reason="", + focus_points=[], ) recommendations.append(rec) @@ -643,9 +719,16 @@ async def _build_recommendations( f"主力净流入{stock.get('main_net_inflow', 0):.0f}万, " f"占比{stock.get('inflow_ratio', 0):.1f}%" ), + "recall_tags": stock.get("recall_tags", []), + "sector_stage": sector_stage, + "stock_role_hint": stock.get("stock_role_hint", "待判断"), + "entry_signal_type": signal_name, + "entry_signal_score": round(entry_signal.get("signal_score", 0), 1), + "signal_matches_profile": signal_matches_profile, + "risk_tags": _build_risk_tags(market_temp, tech_signal, sector_stage, trend_penalty), + "focus_points": _build_focus_points(stock, entry_signal, tech_signal, vol_pattern, sector_stage), } - # 盘中模式:补充分时量能分布数据 if intraday: try: from app.data.eastmoney_client import get_min_kline, analyze_intraday_volume_distribution @@ -670,9 +753,6 @@ async def _build_recommendations( logger.debug(f"深度分析 {ts_code} 失败: {e}") continue - # 让出控制权(同步函数中无法 await,跳过) - # idx % 10 == 0 的让步在 _select_from_hot_sectors 的上层 async 函数中处理 - logger.info( f"Step 3 入场信号分布: " f"突破={signal_counts['breakout']} 确认={signal_counts['breakout_confirm']} " @@ -681,24 +761,55 @@ async def _build_recommendations( f"(共分析{total}只)" ) - # ── LLM 逐股深度分析 ── + recommendations.sort(key=lambda rec: rec.score, reverse=True) + if settings.deepseek_api_key and llm_candidates: try: - from app.llm.batch_screener import analyze_candidates_individually - - # 只对量化评分 Top N 做LLM分析,减少API调用 - llm_candidates.sort(key=lambda c: c["quant_score"], reverse=True) - llm_top = llm_candidates[:settings.top_stock_count] - market_summary = ( f"市场温度: {market_temp.temperature}/100, " f"涨跌比: {market_temp.up_count}涨/{market_temp.down_count}跌, " f"涨停: {market_temp.limit_up_count}家" ) + + llm_candidates.sort(key=lambda c: c["quant_score"], reverse=True) + prefilter_pool = llm_candidates[: settings.llm_prefilter_limit] + prefilter_results = await prefilter_candidates_individually( + prefilter_pool, + market_summary, + max_concurrent=settings.llm_prefilter_max_concurrent, + ) + + prioritized = [] + for item in prefilter_pool: + pre = prefilter_results.get(item["ts_code"], {}) + item["prefilter_decision"] = pre.get("decision", "watch") + item["prefilter_confidence"] = pre.get("confidence", 5) + item["prefilter_reason"] = pre.get("reason", "") + item["prefilter_focus_points"] = pre.get("focus_points", []) + if item["prefilter_decision"] == "priority": + rank_bonus = 16 + elif item["prefilter_decision"] == "watch": + rank_bonus = 6 + else: + rank_bonus = -12 + item["deep_rank"] = round(item["quant_score"] + rank_bonus + item["prefilter_confidence"] * 1.5, 1) + if item["prefilter_decision"] != "ignore": + prioritized.append(item) + + if not prioritized: + prioritized = prefilter_pool[: min(8, len(prefilter_pool))] + + prioritized.sort(key=lambda c: c.get("deep_rank", c["quant_score"]), reverse=True) + llm_top = prioritized[: settings.llm_final_limit] llm_results = await analyze_candidates_individually(llm_top, market_summary) - # 综合规则边界 + LLM 最终裁决 for rec in recommendations: + pre_item = next((item for item in prefilter_pool if item["ts_code"] == rec.ts_code), None) + if pre_item: + rec.prefilter_decision = pre_item.get("prefilter_decision", "") + rec.prefilter_reason = pre_item.get("prefilter_reason", "") + rec.focus_points = pre_item.get("prefilter_focus_points", []) + llm_data = llm_results.get(rec.ts_code) if llm_data: rec.llm_analysis = llm_data.get("analysis", "") @@ -758,18 +869,22 @@ async def _build_recommendations( if llm_data.get("stop_loss"): rec.stop_loss = llm_data["stop_loss"] - # LLM 明确 skip 的标的,从推荐前列剔除 recommendations = [ rec for rec in recommendations - if not (rec.llm_score is not None and rec.llm_score <= 4 and rec.action_plan == "观察" and rec.score < strategy_profile.min_score) + if not ( + rec.llm_score is not None + and rec.llm_score <= 4 + and rec.action_plan == "观察" + and rec.score < max(strategy_profile.min_score - 6, 54) + ) ] recommendations.sort(key=lambda r: r.score, reverse=True) recommendations = recommendations[:settings.top_stock_count] - logger.info(f"LLM 逐股分析完成, 综合评分后保留 {len(recommendations)} 只") + logger.info(f"LLM 两阶段分析完成, 综合评分后保留 {len(recommendations)} 只") except Exception as e: - logger.error(f"LLM 逐股分析失败, 仅使用量化评分: {e}") + logger.error(f"LLM 两阶段分析失败, 仅使用规则边界: {e}") from app.db.error_logger import log_error - await log_error("screener", f"LLM 逐股分析失败, 仅使用量化评分: {e}", detail=traceback.format_exc()) + await log_error("screener", f"LLM 两阶段分析失败, 仅使用规则边界: {e}", detail=traceback.format_exc()) return recommendations @@ -1238,6 +1353,49 @@ def _generate_risk_note( return ";".join(notes) +def _build_risk_tags( + market: MarketTemperature, + tech: TechnicalSignal | None, + sector_stage: str, + trend_penalty: float, +) -> list[str]: + tags: list[str] = [] + if market.temperature < 45: + tags.append("market_weak") + if sector_stage in ("late", "end"): + tags.append(f"sector_{sector_stage}") + if trend_penalty < 0.9: + tags.append("trend_under_pressure") + if tech: + if tech.position_score < 35: + tags.append("position_high") + if tech.rally_pct_10d > 20: + tags.append("short_term_overheat") + return tags + + +def _build_focus_points( + stock: dict, + entry_signal: dict, + tech: TechnicalSignal | None, + vol_pattern: dict, + sector_stage: str, +) -> list[str]: + points: list[str] = [] + signal_type = entry_signal.get("signal_type") + if signal_type and getattr(signal_type, "value", "none") != "none": + points.append(f"确认{signal_type.value}信号是否延续") + if stock.get("main_net_inflow", 0) > 0: + points.append("观察主力流入是否继续放大") + if vol_pattern.get("volume_trend"): + points.append(f"量能状态: {vol_pattern['volume_trend']}") + if tech and tech.support_price: + points.append(f"关键支撑 {tech.support_price}") + if sector_stage in ("late", "end"): + points.append("板块已偏后段,注意是否还有前排承接") + return points[:4] + + def _summarize_for_llm(df, entry_signal: dict, tech_signal: TechnicalSignal | None) -> str: """生成 K 线分析结论供 LLM 判断(输出结论而非原始数据)""" import pandas as pd diff --git a/backend/app/llm/__pycache__/prompts.cpython-313.pyc b/backend/app/llm/__pycache__/prompts.cpython-313.pyc index 8d726eca35dea62bca03b005be6b7d9afaaa4726..913e5a56a4aca6dc4ba4d778f5584e75245e8c4c 100644 GIT binary patch delta 1251 zcmZ8h%WoS+7*8amwkxHm2P*nFtb9R{nw0PmQY0j*Vnho~Q5+5kAuDp+Vj=9xPE}Qa zymtLe>PK+h__4-z$R;5ub~dE=vAf=X0mLoGcD~v1sYqNo@Qt^$2uqedv)}&a_u4&p zM}6<)UlS9;7=F@!FyH&)+R0bPFRZ;bdE9j@c;e>q$rI|r-)F+NKYeLzj2I1CYF}c} zCP?L>l%{IKs>FsHF=#cRReGx5x2i4LNa7I4JEWVTdX8q()R1vWcX4TToln1X!XF% zaJG#kBokau*w~;_1hI*cc*bfpL`zv(8o(x+ROeu$B{PlT&!y)}deEeDn)EkmslWz3 zt9Ty~(P`l=^kz_iWFO4;4pL>oHj zIqvQ^f+#wcydGc37w|h~MaQk6FA(%C-xa?V?<_8RzQkYOO@AQhbzI=1o`C;`Z^`TT zcyV}UYUcIPZqU0JvTsL&A*Zl*jkOO^3+eWWCXrr>h0QW;bkK89qc+ryfai9|eJkMe zFNYB9a|h-Mn-#1Atu=WFBLv82Ep3mrm0>btMSB>F4e0nn_zvZa@NAd4oGI~N{<9)T zItc3Hdv$bF4WLKo+hnCu>xo~^dCT`VLjNv%0E90@|odAO_&Nlvk`e21$=;r z*@PUHNX3-xia}@8vQfUgI^8&uutT>w!61cg6$I8)ARfnfsT#FKzeq)V(FC53NQ;M9 za-<*ruifJRtcHC9OAYZ3F$y>fTT%LhOjk`VDw*IiM~||lm2DgN&ZuBiQ~0Lckhw|W z=$PBR6!5s+N3S^NKD;z{+39wD=2~!Ga~HlotDibL?OM2g`6Kry^Ue>he7xYCFF4M< kExe3xu5tW^<_nAGuAKU9?EIfs$IqO7_xG{W!3o^|2l>|78~^|S delta 72 zcmdn!u*sb7GcPX}0}$A`zQ{Z+J&{j>QDdUICQCZICdbBzKa$LTnw*n2%3Yg0UtW)k Y52%h2h>JZZUzhjfxX2(=!~^640NDK!kN^Mx diff --git a/backend/app/llm/batch_screener.py b/backend/app/llm/batch_screener.py index e9363d64..b7aeac70 100644 --- a/backend/app/llm/batch_screener.py +++ b/backend/app/llm/batch_screener.py @@ -1,7 +1,7 @@ -"""LLM 逐股深度分析 +"""LLM 候选预筛 + 逐股深度分析 -量化筛选完成后,对每只候选股票单独调用 LLM 做深度分析, -让 AI 独立判断入场时机并给出具体买卖价格。 +先做轻量预筛,控制深度裁决成本; +再对重点股票单独调用 LLM 做深度分析。 """ import asyncio @@ -14,6 +14,62 @@ from app.config import settings logger = logging.getLogger(__name__) +async def prefilter_single_stock(candidate: dict, market_summary: str) -> dict: + """对单只候选股票做轻量 LLM 预筛。""" + from app.llm.prompts import STOCK_PREFILTER_PROMPT + from app.llm.client import get_client + + stock_text = f"""\ +股票: {candidate['name']}({candidate['ts_code']}) +板块: {candidate.get('sector', '未知')} +召回来源: {', '.join(candidate.get('recall_tags', []) or ['未标注'])} +规则参考分: {candidate.get('quant_score', 0)}/100 +位置安全: {candidate.get('position_score', 50)}/100 +当前价: {candidate.get('current_price', '未知')} +板块阶段: {candidate.get('sector_stage', '未知')} +个股角色线索: {candidate.get('stock_role_hint', '待判断')}""" + + if candidate.get("kline_summary"): + stock_text += f"\n\n## 技术结构摘要\n{candidate['kline_summary']}" + + if candidate.get("capital_flow_summary"): + stock_text += f"\n\n## 资金与活跃度摘要\n{candidate['capital_flow_summary']}" + + if candidate.get("intraday_volume"): + stock_text += f"\n\n## 分时量能摘要\n{candidate['intraday_volume']}" + + user_msg = f"{STOCK_PREFILTER_PROMPT}\n\n## 市场环境\n{market_summary}\n\n{stock_text}\n\n请输出 JSON。" + + try: + client = get_client() + response = await client.chat.completions.create( + model=settings.deepseek_model, + messages=[ + { + "role": "system", + "content": ( + "你是A股候选池预审官。" + "你只负责决定资源分配优先级,不直接下最终交易结论。" + "必须返回合法JSON。" + ), + }, + {"role": "user", "content": user_msg}, + ], + max_tokens=400, + temperature=0.2, + ) + content = response.choices[0].message.content.strip() + return _parse_prefilter_response(content) + except Exception as e: + logger.error(f"LLM 预筛 {candidate.get('ts_code')} 失败: {e}") + return { + "decision": "watch", + "confidence": 5, + "reason": "AI 预筛暂不可用,保留观察", + "focus_points": [], + } + + async def analyze_single_stock(candidate: dict, market_summary: str) -> dict: """对单只股票做 LLM 深度分析 @@ -95,6 +151,32 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict: } +def _parse_prefilter_response(text: str) -> dict: + data = _extract_json_object(text) + if not data: + return { + "decision": "watch", + "confidence": 5, + "reason": "预筛输出不可解析,默认保留观察", + "focus_points": [], + } + + decision = str(data.get("decision", "watch")).strip().lower() + if decision not in {"priority", "watch", "ignore"}: + decision = "watch" + + focus_points = data.get("focus_points") or [] + if not isinstance(focus_points, list): + focus_points = [] + + return { + "decision": decision, + "confidence": _clamp_int(data.get("confidence"), minimum=1, maximum=10, default=5), + "reason": str(data.get("reason", "")).strip() or "暂无说明", + "focus_points": [str(item).strip() for item in focus_points[:3] if str(item).strip()], + } + + def _parse_single_response(text: str) -> dict: """解析单只股票的 LLM 返回""" data = _extract_json_object(text) @@ -238,3 +320,38 @@ async def analyze_candidates_individually( logger.info(f"LLM 逐股分析完成: {len(results)}/{len(candidates)} 只") return results + + +async def prefilter_candidates_individually( + candidates: list[dict], market_summary: str, max_concurrent: int = 6 +) -> dict[str, dict]: + """对候选股票逐个做 LLM 预筛。""" + if not settings.deepseek_api_key or not candidates: + return {} + + results = {} + semaphore = asyncio.Semaphore(max_concurrent) + + async def _prefilter_with_semaphore(c: dict): + async with semaphore: + ts_code = c["ts_code"] + logger.info(f"LLM 预筛: {c.get('name', ts_code)}") + result = await prefilter_single_stock(c, market_summary) + logger.info( + f"LLM 预筛结果: {c.get('name', ts_code)} → " + f"decision={result['decision']} confidence={result['confidence']}" + ) + return ts_code, result + + tasks = [_prefilter_with_semaphore(c) for c in candidates] + completed = await asyncio.gather(*tasks, return_exceptions=True) + + for item in completed: + if isinstance(item, Exception): + logger.error(f"LLM 预筛任务异常: {item}") + continue + ts_code, result = item + results[ts_code] = result + + logger.info(f"LLM 预筛完成: {len(results)}/{len(candidates)} 只") + return results diff --git a/backend/app/llm/prompts.py b/backend/app/llm/prompts.py index 700934d4..b448e3c9 100644 --- a/backend/app/llm/prompts.py +++ b/backend/app/llm/prompts.py @@ -156,3 +156,31 @@ SINGLE_STOCK_ANALYSIS_PROMPT = """\ - position_pct 返回 0-35 的整数;如果不适合参与,就返回 0 - 没有把握时优先给 watch 或 skip - trigger_condition 和 invalidation_condition 必须可执行,不能写空话""" + + +STOCK_PREFILTER_PROMPT = """\ +你是A股候选池的预审官,目标是在不漏掉潜在机会的前提下,先把候选股票分成“优先深看 / 保留观察 / 可忽略”三类。 + +你的原则: +1. 这一步不是最终买卖结论,只做资源分配 +2. 不能因为某一个规则分数低就直接忽略,要看题材位置、角色、量价异常、时机感 +3. 可以容忍不标准的形态,但不能容忍明显失真、明显追高、明显没有交易边界的票 +4. 输出必须是 JSON,不要输出 Markdown + +字段格式: +{ + "decision": "priority | watch | ignore", + "confidence": 1-10, + "reason": "一句话说明为什么这样分层", + "focus_points": ["最多三条,说明后续该重点看什么"] +} + +分层标准: +- priority: 值得进入深度裁决池,今天存在较强观察价值或操作潜力 +- watch: 逻辑未坏,但暂时不应占用深度裁决名额 +- ignore: 当前信号弱、位置差、边界不清或交易价值很低 + +补充要求: +- confidence 必须是 1-10 整数 +- focus_points 最多三条,尽量具体 +- 如果拿不准,优先给 watch,不要滥给 ignore""" diff --git a/backend/astock.db b/backend/astock.db index ccf34c11fdc21489c4b8c6a0f39849e420a2d53d..6c8e9bcaa5691318c4a9886a0ab332d86937d0a5 100644 GIT binary patch delta 2301 zcmaKse@t7~702ImjDH&&VyC8r7zmD^X`%KrCe&+MBciQUmttU1vt}xx2}!kPE$UKL zo4VEJmra7PjUg8>36St3Y6w4Sf{C$l{+PB-TBQ9^DVw%wjrQ*Ivl=x>ks>Wx)t%=T zM{PRoN*`ap^WHt@+;hL@dcOFC^Z=n!bw5Fft`Y>HfVnqJ?I-(JkIia!s5R-BRJ*AW z4R=ODQUg4&gQh!@r(c2H$m!X+Dg~gSnFzN zy4?EcRqW0)>ttk4)!s;=rZn=iDs_HMq^s%#T2NVzOx!)Jm54w8&gSApA<0eg|46UL|G?*eKmsCQ2OtIt00|%k zWPlt{07^gwsDVO2189LFpcvQ*=m0&i3wR1J042cFKq;^rCQ-C#euHb^fvnY zFU*DthtqCz+UoM={G8r?{8`OkC?m4VO}eHUTdlRubT2u`PI~a{0*+o}hi3(&_t}%* z4|?)(P`&QgcYd^_yGbDTAyO9!wTmIJ)#jw_u1Bx(^G%}V%f3Aut?;R4m@<~o-wDZH zU&=)NHM|j3{EgbV1M-O@q!blx>lWkXZspgKuv`AI@1s)|!e}bt4~^8rZ@10VNRmCTKSP$0q_@-L z?erRDa-+AzYq7$pd%m{q4ZIcSu17N?*V)x+;n&rA-_8$LYnw2?%AVxM;g_3#e9(2^ zYsgM_W2{AuE?=##M-O`~T2%Wor9pq*wkT248Kv$h z+zrr~xoe}=>ZrHd>g#FttcRUk#;HrJ$BTytv%Vm+8OMFGOl*@$j%5}+AQ?P6foIp) zp<(Pt>_#6uzVa_mHdBKjpTz_S2}b#%w^%5sM1F_zk5%OD9MPD*mgf1f>?M zHF7+34xf#}D&)bIJ?QwG1%@(sdh7kpj(6Vs;7ohRnKK>7-;v~>o7&NxUbDd}B*zZ= z*l8b^_GNmmK{i}EmAyK~jzzdjSMeMI3$rnjNhX*ZLDceYso~?yL~mwhiCdb52w7hs z(|rT>!^W;~-81~&q8GW99?ln|8)_SHU(daypB~+4Vn>$Q;V_d5KvW?Bdm)W4Aa-<(NkzEpD>%8zSIpZ?xs}%j@KzsW&rT;% z%d6_0sAHsB|HH>w^anUk4>ulWHqYU{IauXyJo$tg^+r%*?=MYc2}yQqO1!Uo%`!5l z#)vkp{{g5EHtKp!P@BvB{5XsWSBo(3%BfEe%?%yydW!E6P~px$@#7XMYOB;CZPemM z?Tb`7s_L^8HSk)qJ89_LhFZG5&f#ciq=gvY-X)wUPRuhK9?rkWAMNs71_?k-a4Lca0&H|G3;#oYCN;@yB)D}@W|~{*#>-QnGSCj6hi5Q*;bLa$ zS|*Xkz6ADdX6MgmJp;3oM>MRf*nhpzhDthjpmQvIuHW#2!z`Ps% zAx)}(p3@~xCZM>udl3(w2lsgC2FN!%4t+MqUil;w9RqaDQ{P$O*xrDhWI zj#`@_qk-&rFUXcjdNWsI_{KW=>W^x}+n`)NLU36S1ee-mHY42fD3tK~l62Hsd0(J7 zc60?)iG!D6F(h-SvFTve7sf#-{VKOM2W`ZjUt!OM;2yxCFbE%cnyD6a$z*;SML&hJ en0|$lqWRMmMa4qz33cT+o|x}HW|==$?fx${uMso= delta 1445 zcmcK4UrbY190%~;+tS|B)?1`Sut3X%-cuH!6dcoRvza{1O$BsjGIcSFnOU+;Of)D% z<)5|)r7aiuQT&U_K$J07;C3y@9(>RkO|~pEds$r8ds`CYpRkz?U9uk(5+C-ulYDNI zo_o$czwbHsHar-n8j#PVJ0X+N*JLtTGX6IENf~uwZ0{9Tu4QiqRr&}^x9#R{n%^)a z;Vl&V9Iv#Wea@^`x)hnb!E!-avc9BveX(%vh4l<&a5(Ia*9z>V1rCSBp6FQ{{O#3A ziqFnn!_35SI5vJ$u0^%f%h%-Y_s^M4h7%uf}S2VK@f>J+?D&*<<?f29ueLD zQFOEGnalNzLNt;i1xi9nq(Tf*qhzE(TBJiO5R3FE1+7G>$bgI}4Xr}yCuaA%M!2K5p0HuPR6T4uLH zoaV(=4;*}-OI1FeYLAAxpvupsrkpt+|E4(_8o~dh-W#ziVQ_9W8=)+~6~f^?Ha#=Z zD4ra^Uf!RP59V#0(kS(I#QMLE^|gpTe>8M@_Uf2;dI)MZk@T{7Q-3rfh-ZgN3ybZu zftL7;KRVSI8wyJ99;xfbtjil4Zj4$r5#9_F-QN#fMm zQuE1JWLRqJiH(m&L&G?$7@3SkM#RO{0(+dSf?to4T52sE{5dZTKC2@~sWQINlqD}M zEQJTd+=#rD+PRe69=Q7@DN-#Z2l9H-xtI#{*OLNB*@BOj>WPuRm|=#FEyM;JkCBz@ zq|jBdv;FsmCafkl6^?%AjZd`9y8MZ9u}chLS6DnbCbf@Z7`SHf)I>DY*5fvsl%f}A z13tKBwB-~e3KxB&7*lknU-Sx?Mq+fLP%KO8>%knvQy%z7OJ{;UgD7F z<
Recommendation Logic
-
+
+ + -
@@ -257,6 +262,30 @@ export default function RecommendationsPage() { ))} + {((performance.route_breakdown?.length ?? 0) > 0 || (performance.prefilter_breakdown?.length ?? 0) > 0) && ( +
+ {(performance.route_breakdown?.length ?? 0) > 0 ? ( + ({ + label: formatRouteLabel(item.route), + value: `${item.count}只`, + detail: `胜率 ${item.win_rate.toFixed(1)}% · 平均 ${item.avg_return > 0 ? "+" : ""}${item.avg_return.toFixed(2)}%`, + }))} + /> + ) : null} + {(performance.prefilter_breakdown?.length ?? 0) > 0 ? ( + ({ + label: formatPrefilterLabel(item.decision), + value: `${item.count}只`, + detail: `胜率 ${item.win_rate.toFixed(1)}% · 平均 ${item.avg_return > 0 ? "+" : ""}${item.avg_return.toFixed(2)}%`, + }))} + /> + ) : null} +
+ )} )} @@ -404,6 +433,54 @@ export default function RecommendationsPage() { ); } +function CompactInsightCard({ + title, + items, +}: { + title: string; + items: Array<{ label: string; value: string; detail: string }>; +}) { + return ( +
+
{title}
+
+ {items.map((item) => ( +
+
+
{item.label}
+
{item.detail}
+
+
{item.value}
+
+ ))} +
+
+ ); +} + +function formatRouteLabel(route: string): string { + const labels: Record = { + sector_recall: "主线召回", + trend_scan: "趋势召回", + intraday_active: "盘中异动", + hot_sector_core: "板块核心", + sector_leader: "前排线索", + moneyflow_support: "资金支撑", + volume_active: "量能活跃", + }; + return labels[route] ?? route; +} + +function formatPrefilterLabel(decision: string): string { + const labels: Record = { + priority: "AI优先深看", + watch: "AI保留观察", + ignore: "AI建议忽略", + unknown: "未记录", + }; + return labels[decision] ?? decision; +} + function FunnelWorkspace({ groups, activeKey, diff --git a/frontend/src/app/(auth)/stock/[code]/page.tsx b/frontend/src/app/(auth)/stock/[code]/page.tsx index 60f6fbf6..b5a3ad90 100644 --- a/frontend/src/app/(auth)/stock/[code]/page.tsx +++ b/frontend/src/app/(auth)/stock/[code]/page.tsx @@ -219,9 +219,18 @@ export default function StockDetailPage() {
- +
+ {(recommendation?.recall_tags?.length ?? 0) > 0 ? ( +
+ {(recommendation?.recall_tags ?? []).slice(0, 4).map((tag) => ( + + {formatRecallTag(tag)} + + ))} +
+ ) : null}
推荐 {formatDateTime(thesis?.data_freshness.recommendation_created_at)} 跟踪 {thesis?.data_freshness.tracking_date || "暂无"} @@ -303,17 +312,35 @@ function PlanCard({ ) : null}
+ {recommendation?.prefilter_reason ? : null} + {(recommendation?.focus_points?.length ?? 0) > 0 ? ( + + ) : null} - {recommendation ? : null} + {recommendation ? : null} + {recommendation ? : null} {trackingNote ? : null}
); } +function formatRecallTag(tag: string): string { + const labels: Record = { + sector_recall: "主线召回", + trend_scan: "趋势召回", + intraday_active: "盘中异动", + hot_sector_core: "板块核心", + sector_leader: "前排线索", + moneyflow_support: "资金支撑", + volume_active: "量能活跃", + }; + return labels[tag] ?? tag; +} + function EvidenceCard({ recommendation, quote, diff --git a/frontend/src/components/stock-card.tsx b/frontend/src/components/stock-card.tsx index fbdbcc4b..5256bb2f 100644 --- a/frontend/src/components/stock-card.tsx +++ b/frontend/src/components/stock-card.tsx @@ -6,6 +6,21 @@ import type { RecommendationData } from "@/lib/api"; export default function StockCard({ rec }: { rec: RecommendationData }) { const badge = getLevelBadge(rec.level); const aiConviction = rec.llm_score != null ? Math.round(rec.llm_score) : null; + const recallLabels: Record = { + sector_recall: "主线召回", + trend_scan: "趋势召回", + intraday_active: "盘中异动", + hot_sector_core: "板块核心", + sector_leader: "前排线索", + moneyflow_support: "资金支撑", + volume_active: "量能活跃", + }; + const prefilterLabel: Record = { + priority: "AI优先深看", + watch: "AI保留观察", + ignore: "AI建议忽略", + "": "待AI预筛", + }; // 入场信号标签 const signalTypeMap: Record = { @@ -40,9 +55,13 @@ export default function StockCard({ rec }: { rec: RecommendationData }) { "重点关注": "等待确认,不提前交易", "观察": "只记录,不主动出手", }; - const evidence = [rec.reasons?.[0], rec.entry_timing, rec.data_freshness] - .filter(Boolean) - .slice(0, 2) as string[]; + const evidence = [ + rec.prefilter_reason, + rec.focus_points?.[0], + rec.reasons?.[0], + rec.entry_timing, + rec.data_freshness, + ].filter(Boolean).slice(0, 3) as string[]; return (
@@ -131,7 +150,7 @@ export default function StockCard({ rec }: { rec: RecommendationData }) { {evidence.length > 0 && (
-
核心证据
+
AI 关注点
{evidence.map((item, index) => (
@@ -141,10 +160,28 @@ export default function StockCard({ rec }: { rec: RecommendationData }) { ))}
- 规则供需 {Math.round(rec.supply_demand_score ?? 0)} - 规则形态 {Math.round(rec.price_action_score ?? 0)} - 规则趋势 {Math.round(rec.technical_score ?? 0)} - 规则位置 {Math.round(rec.position_score ?? 50)} + {(rec.recall_tags ?? []).slice(0, 3).map((tag) => ( + + {recallLabels[tag] ?? tag} + + ))} + + {prefilterLabel[rec.prefilter_decision ?? ""] ?? "AI预筛"} + +
+
+ )} + + {(rec.focus_points?.length ?? 0) > 0 && ( +
+
深裁决前重点观察
+
+ {(rec.focus_points ?? []).slice(0, 3).map((item, index) => ( +
+ + {item} +
+ ))}
)} @@ -212,7 +249,7 @@ export default function StockCard({ rec }: { rec: RecommendationData }) {
- 详细推演在详情页归档 + 召回、预筛与推演链路已归档 {aiConviction != null && ( AI {aiConviction}/10 diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2c88df1f..671ffb1d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -138,6 +138,10 @@ export interface RecommendationData { entry_signal_type?: "breakout" | "pullback" | "launch" | "none"; llm_analysis?: string; llm_score?: number | null; + recall_tags?: string[]; + prefilter_decision?: "priority" | "watch" | "ignore" | ""; + prefilter_reason?: string; + focus_points?: string[]; scan_session: string; created_at: string | null; entry_timing?: string; @@ -231,6 +235,8 @@ export interface PerformanceStats { hit_target_count: number; hit_stop_count: number; lifecycle_counts: Record; + route_breakdown?: Array<{ route: string; count: number; win_rate: number; avg_return: number }>; + prefilter_breakdown?: Array<{ decision: string; count: number; win_rate: number; avg_return: number }>; details: TrackedRecommendation[]; } @@ -246,6 +252,8 @@ export interface TrackedRecommendation { status: string; action_plan?: string; lifecycle_status?: string; + recall_tags?: string[]; + prefilter_decision?: string; max_return_pct?: number; max_drawdown_pct?: number; days_since_recommendation?: number;