From f3f43a5a5dfb1bc0d2e1d6e89c99f51b2389f2c4 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Wed, 22 Apr 2026 11:56:23 +0800 Subject: [PATCH] 1 --- .../app/__pycache__/config.cpython-313.pyc | Bin 4502 -> 5561 bytes backend/app/analysis/sector_realtime.py | 93 ++++++++++++++ .../api/__pycache__/market.cpython-313.pyc | Bin 11547 -> 11653 bytes .../recommendations.cpython-313.pyc | Bin 8709 -> 8872 bytes .../api/__pycache__/sectors.cpython-313.pyc | Bin 9417 -> 6911 bytes .../api/__pycache__/stocks.cpython-313.pyc | Bin 32329 -> 32872 bytes backend/app/api/market.py | 5 +- backend/app/api/recommendations.py | 9 +- backend/app/api/sectors.py | 116 +++--------------- backend/app/api/stocks.py | 21 +++- backend/app/config.py | 24 ++++ .../data/__pycache__/models.cpython-313.pyc | Bin 8465 -> 9181 bytes backend/app/data/models.py | 15 +++ .../__pycache__/recommender.cpython-313.pyc | Bin 38175 -> 38219 bytes .../__pycache__/screener.cpython-313.pyc | Bin 58246 -> 60096 bytes backend/app/engine/recommender.py | 1 + backend/app/engine/screener.py | 73 ++++++++--- .../llm/__pycache__/prompts.cpython-313.pyc | Bin 6690 -> 7090 bytes backend/app/llm/batch_screener.py | 112 +++++++++++++---- backend/app/llm/daily_review.py | 8 +- backend/app/llm/prompts.py | 55 +++++---- backend/app/llm/strategy_board.py | 109 ++++++++++++++-- backend/astock.db | Bin 2797568 -> 2797568 bytes frontend/.next/app-build-manifest.json | 5 - frontend/.next/server/app-paths-manifest.json | 3 +- frontend/src/app/(auth)/dashboard/page.tsx | 90 ++++++++++---- frontend/src/app/(auth)/diagnose/page.tsx | 46 ++++++- .../src/app/(auth)/recommendations/page.tsx | 55 +++++++-- frontend/src/app/(auth)/sectors/page.tsx | 53 +++++--- frontend/src/app/(auth)/stock/[code]/page.tsx | 106 +++++++++++----- frontend/src/components/sector-heatmap.tsx | 15 ++- frontend/src/components/stock-card.tsx | 82 ++++++------- frontend/src/lib/api.ts | 12 ++ 33 files changed, 787 insertions(+), 321 deletions(-) create mode 100644 backend/app/analysis/sector_realtime.py diff --git a/backend/app/__pycache__/config.cpython-313.pyc b/backend/app/__pycache__/config.cpython-313.pyc index 08737201510cc1acba09a8324d06eab61ffb15b2..de9c77422d339dee09620a383c774f01d75336ae 100644 GIT binary patch delta 1089 zcmZ`%Z%k5A6u%Ealm~*d53C8jrmk2;Qdyg6TT7VLvOn0CX|cD!(qXmxaIIE?%{DdB z8k&@*m8j*1w0}lG@wL9;_@Ha85Bc7M`l3zO=gx(4w(4y6oO|y%=XcJ%zjLSH&8D0s zl}dp?K9naLM9u9SE%o5`hD%1PypG->vC?Z%qZBKxIaXyIZIx`1BLp7#4yjc|JdG4q zos}5W&mQfOQ>qv0tZ0_@3_<%XGT3CmTCyYGw`_~#Shnw|dX2k;N(U4ogRHp(_Q`0k zFfhaiJH=P8#r|obE5rwHChoQI;Z7m&MC>0G2Zt9T?KL2W4pmevL^|d@*RcdLaV1d) zKwZf!swrwJvKwUhFuB`L6e*66fclulxp2L!p~-zh5>*Jwg9PdNPjp^SS!dbZtOx7jT2q|fHcRiGp?3!>*O4YZAQj-O{I0uds@Zb>BN9vxYZ3D3uD*Cp+Tm)nLEc~2O|!8#5+@I2tY_i z{d~|1ouxZih;%`zbaUxG6x)rA;C;cri~bKnc>O#P?pc9w6O5jTTu<+S|MFiXTg+GW%;r;}O C*>MQ~ delta 126 zcmdm~Jx!VKGcPX}0}#l3yO-I`JCRR bool: + """东方财富板块名与 Tushare 板块名模糊匹配。""" + em_clean = em_name.rstrip("行业").rstrip("板块").rstrip("概念").strip() + ts_clean = ts_name.rstrip("行业").rstrip("板块").rstrip("概念").strip() + if em_clean == ts_clean: + return True + short, long = (em_clean, ts_clean) if len(em_clean) <= len(ts_clean) else (ts_clean, em_clean) + return short in long + + +def _apply_empty_overlay(sector: SectorInfo) -> SectorInfo: + sector.realtime_pct_change = None + sector.realtime_limit_up_count = None + sector.realtime_amount = None + sector.realtime_turnover_rate = None + sector.realtime_up_count = None + sector.realtime_down_count = None + sector.leading_stocks_realtime = [] + sector.is_realtime = False + sector.data_mode = "daily_snapshot" + return sector + + +async def enrich_sectors_with_realtime(sectors: list[SectorInfo]) -> list[SectorInfo]: + """按需为板块快照追加实时字段并重排。""" + if not sectors: + return sectors + + latest_trade_date = sectors[0].trade_date or tushare_client.get_latest_trade_date() + if not should_prefer_realtime_today(latest_trade_date): + return [_apply_empty_overlay(sector) for sector in sectors] + + try: + em_sectors = await get_sector_realtime_ranking() + except Exception: + logger.warning("东方财富板块实时数据获取失败,回退到日级快照") + return [_apply_empty_overlay(sector) for sector in sectors] + + if not em_sectors: + return [_apply_empty_overlay(sector) for sector in sectors] + + em_name_map = {item["sector_name"]: item for item in em_sectors} + matched = 0 + + for sector in sectors: + em_data = em_name_map.get(sector.sector_name) + if not em_data: + for item in em_sectors: + if _match_sector_name(item["sector_name"], sector.sector_name): + em_data = item + break + + if not em_data: + _apply_empty_overlay(sector) + continue + + matched += 1 + sector.realtime_pct_change = float(em_data.get("pct_change", 0) or 0) + sector.realtime_limit_up_count = None + sector.realtime_amount = round(float(em_data.get("amount", 0) or 0) / 10000, 2) + sector.realtime_turnover_rate = float(em_data.get("turnover_rate", 0) or 0) + sector.realtime_up_count = int(em_data.get("up_count", 0) or 0) + sector.realtime_down_count = int(em_data.get("down_count", 0) or 0) + sector.leading_stocks_realtime = [] + if em_data.get("leading_stock_name"): + sector.leading_stocks_realtime = [{ + "ts_code": em_data.get("leading_stock_code", ""), + "name": em_data.get("leading_stock_name", ""), + "pct_chg": float(em_data.get("leading_stock_pct", 0) or 0), + "amount": 0, + }] + sector.is_realtime = True + sector.data_mode = "realtime_overlay" + + logger.info("板块实时覆盖: %s/%s 匹配成功", matched, len(sectors)) + sectors.sort(key=lambda s: (s.realtime_pct_change if s.realtime_pct_change is not None else s.pct_change), reverse=True) + return sectors diff --git a/backend/app/api/__pycache__/market.cpython-313.pyc b/backend/app/api/__pycache__/market.cpython-313.pyc index cd8d0984bcea38a22af608c8f17b261c7406741a..40b70f23224a83cdfe329b8255424e9b05b2b6f9 100644 GIT binary patch delta 1744 zcmZWpYitx%6rMXfv%7umqmS*hyR`dgX}5*80*ew$u~CVF7Qz-Gw6dA*+`__kr=FQs z_<@fqC=Z2l6BA?fk5MCuF|tbVhlw%K-=HR#iP8AWHj#t`;vWC ztsP|9x-BwNKD-d6M?tKLx-XlvahXItB) zBgzq$p*`RO+A8A%I#7E6gMMJl!2m%sOn&6^-01 z@M#V?;Cj(6%h8QR5=w}*wrDQ}7;#U<}8`GO=OsiCft zG@(|CmZZH>wdCHFklrXs6F%{!C$4l!6E$MV_g<$8UL18=4sknI)NO9rIU96?=L~e9 zPMr4lKAUDv_Z2IgT|O9SX5Q^Yctjiv zrV>x0nU=wgkZs8b@fD>axQe*m-sFX+F}hE@o6yQPgGsNq4_#PSZi#G=V^n9F!GrF{ zQRnsI9A8~HudQb%#iG_$e;dc8iUKilMeAXMBzW_Zcq0K18?5yA0Kb31?7EOB>r$cPiscxY9)3h^QFNpyr|#UIgemJp+{ zX7vQ79(?~mOxZ{pjl(v?F&ITeAzr`_kbw6J0Y@oHa7nxo?_!UM%kj%woho+ zLY`ZdexSPHq8Ly22B|4vZ54hJXVZTM_M$t6H}i4)rW4|P-!Ar?__eR&MX1Aw(?HiN zZvr1M_^bu>6!W4)_#**Vv?NB{-vdx@0rijLDPU()GXUKpN0~J(8*41&?aCc-51Ed} zmCD`G#}d#^D*qn;@QfKU=8Aj-u40hXu+E7y{V`?#b!or&sDC8rXKQYIB)RRDjMlA4 L`A&a|DKx{s&GVkL delta 1709 zcmZ8hTTC2f6rO)(XLng(mzxWFp$xm+mdh@-U<0jG+j_$ak)^E>bTjM>xOCaUGXuSN zDXCVi@lx`eXf>%OJ{XJ->c%uaHPM*(XyXIPsE?-MjY{K-iF(dV*e3kRd~?qE&pH2p zZvW+(Po53#)Yb+BT$A72E_{(#54NyZ*Sk+A6d}v9{wIUuAqXkLtdtP4RoOs>WvfR- zsGF^uN^<6DT)z>P8!F77VYzXa;f*XTH&wWYk-M+L87vqH4~g04sIcf+WW#hmE@-Z3 zY@mJ%atG)XzR7~!p<(7~4Y6!&Hj38qJ=U>`ew$d=cTZo;hG)Z$jVHTrk4?N{vm^#H z_iT30Q1lGAB(P z%#5mnQOcOc3gq=%-q7@nYFbAA%z(O}m#oa3nm?nLv<$!CO|o`=-McMn0>33igFSB0 zf{JcuB>tPEcshg`nfv|MB?$ujCx6eu4Pl40KtZr;7IZ6Ttm?2@(9c3QiU#pBdl9yT zHQ{cJ;E!zjqPKieK3)|M)u8-R>md7-r9Gd82FvHFPKf;!g6+#2B?~VHG2#Mw&{a#< za;lY*fD~YWAkDX{W6k8<4ynt_X>Bg8sg^pYV%YsWQ8PF|d(ck`qBaNnh|3TsfpOMm zVET|z+yx51SQC~G9`i|A=(rX6m-KQb#eIwGN;O;b)#Ny3Q6cIUkL9P;}ma-!2{0E zmRHr{ioSb1|G8zL>vh~Q>k-z3-$M;ohd(^@(a~Q+ZNK>2pbMpLc49mh>1OHjvB-$X zqWqOuTOAbwoOIkzR6qYJ79XD^azBFYpVzJY3r0zIj)o!Hs3MSPS1Mt;stTqtZ@>~3 zq1N?8i3P{ULEaD_V^jR;_;geui=fj9%O<8uwN^}c35CynelzYlKnb0OF~kXY2oY2C zGX8+FT}crM3cIku|57^GG2Yz%?Z{&&oQ5Y5Ctw^A`@u=DEyf^Vf+nQ_Vm=Nvsg;}xV)ybHA3FSA+eaTZ|%iBoW9-13l&KC{rt#`@jJ$|h- zm5CEcZ-^~}u~O1fvK=UtH2qwTI??fL`%CJQo?B9v;VQq=*@RCT+9Y@3X1S^Bws`tY z;@={;f`DOBq2V&Y8wgu6Ntx}@)bplOx zV3TjB{ti;tO<>C&$A3!UcY7x<8l>Szumd!fILq^e1-G37B&~I&pr>j(sqLon^ZiNYA?yDE3y_Ly diff --git a/backend/app/api/__pycache__/recommendations.cpython-313.pyc b/backend/app/api/__pycache__/recommendations.cpython-313.pyc index c6cbb806faf59927bd81ea6165c2f49eb987ad06..fdbc92db2591930e4d790d952c5c3dbf9d017ddf 100644 GIT binary patch delta 1711 zcmZ`(T})eL82-M~(gLL|rS^wXpui&ZVkivaPZ)xNTfm8OMB}JCtL>o_N>B5i(=EFh z^9Qr+!brZD;f0Ga8vhbaY+_9GMiXyLmXL9w9vwFWqTX18iSb5#-_sRghA%nKdH>J* z_nnoo_s2cEZg)Myvw8gIY+duJC(OgE-OBQW7rZehbJ;oBIN<}IMb}O?P58kdV+m=x zhnxD^@`?*V@-xa@jBf>0tf-FMKQIJu$fA-f}N@U{UgH=}u#+vg>F z_RPHrCw|ah+!SF8_61%I0Ij4$3qQ%B?U2}aRbwTb%LhVpE3703 z?g-m{NKL_NAUU+Jrr06Y;XUhCxjE5+X+|QW2lMYd#I66AjFS?6JMq+qZ(qS;kpFJMZ3F)N5naIcg;yug-dU{FWUG} zsl#`JyBe5tJ4ECJ!JbC9BOAj5Cy+3mm?NnAJUoNkOzEuuw5^N09tSIv zCbDtqsbpR$7Sc&Wt>pHQxEYK(0;q&x@lWu~^emAHf+q;hAjF$MCfb2u`jUl0Z?cf> zJ+D5mYpF%mfM-c=%c*&In9QtTHcOV*&ch?(lh84)h+Cn~lU5ZJ#`1wNGO7bLrKGfC z-hgr9IW`*KdMyG~N5`rVo<@lUi=|WHnCIf*MxUJGUlA1NT zku-Fj60Alxy_J^G^izvCemIBPE8=>~N5NN->S8hWI-|~1&a@=n3`9rrY__H?B%dhNQdu{8> zwe6M5VygA%(vxUl&B}D8RXqjSf|1qoSZD((%mD6_lEZQ8hPAt@V$Ie?PV}?Fwf6l_ zZF=umXdSb{ZomogR%>AHA~I|E=+xj}*d_LdgEe=57aqD19@+>GuiFDZ1^d^Jjco+S zFHLO)@7@UZYigYkZ*Y{Ey~a^?ut^U5Kfzx*P5;GF+h~n&wXJn7 z(`V>EczcOmRL*=>H#E2aFQEJ#@lD&O?H{mRq*-OrT?G~9G+0XJQ!1>W_(SnV`+bok zrc+VUT1rviDHNK{oR-PZEEmMD?NKfZXY9!R)DocPm?7F@>{YV&;2o~C)lJ%z25N6w zE$HADBeCY?4EZ@gE8^v;tUBFW(_YYY19BFb&ZZ3O0#=rl)(EJbrY(#6O$AvQ!%IZH zOkmyJ+My*aUCgP&AW%mA&>uuB+c@Vxu;4A``H^|V_c4FnpAC#Z%5SoMQI37>t>ung T8Oq|Jr{R~ delta 1545 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 literal 9417 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 bool: - """东方财富板块名与 Tushare 板块名模糊匹配 - - 东方财富用"酿酒行业",Tushare 可能叫"白酒"; - 东方财富用"汽车整车",Tushare 可能叫"汽车"。 - 用包含匹配(短名在长名中)或尾部去掉"行业"后完全匹配。 - """ - # 去掉常见后缀再做比较 - em_clean = em_name.rstrip("行业").rstrip("板块").rstrip("概念").strip() - ts_clean = ts_name.rstrip("行业").rstrip("板块").rstrip("概念").strip() - if em_clean == ts_clean: - return True - # 短名包含在长名中 - short, long = (em_clean, ts_clean) if len(em_clean) <= len(ts_clean) else (ts_clean, em_clean) - return short in long - - -async def _enrich_sectors_realtime(sectors_data: list[dict]) -> list[dict]: - """盘中时,用东方财富实时板块数据补充涨幅和涨停数 - - 一次请求替代之前腾讯批量获取数千只成分股的方式。 - """ - if not is_market_session(): - for s in sectors_data: - s["realtime_pct_change"] = None - s["realtime_limit_up_count"] = None - s["is_realtime"] = False - return sectors_data - - # 从东方财富获取实时板块排名(1次 HTTP 请求) - try: - em_sectors = await get_sector_realtime_ranking() - except Exception: - logger.warning("东方财富板块实时数据获取失败,回退到日级数据") - for s in sectors_data: - s["realtime_pct_change"] = None - s["realtime_limit_up_count"] = None - s["is_realtime"] = False - return sectors_data - - if not em_sectors: - for s in sectors_data: - s["realtime_pct_change"] = None - s["realtime_limit_up_count"] = None - s["is_realtime"] = False - return sectors_data - - # 构建东方财富板块名查找表(用于匹配) - em_name_map = {s["sector_name"]: s for s in em_sectors} - - matched = 0 - for s in sectors_data: - ts_name = s["sector_name"] - # 尝试匹配:先精确,再模糊 - em_data = em_name_map.get(ts_name) - if not em_data: - # 模糊匹配 - for em_s in em_sectors: - if _match_sector_name(em_s["sector_name"], ts_name): - em_data = em_s - break - - if em_data: - matched += 1 - s["realtime_pct_change"] = em_data["pct_change"] - s["is_realtime"] = True - # 涨停家数仍保留 Tushare 数据(东方财富此字段不可用) - s["realtime_limit_up_count"] = None - # 更新领涨股(东方财富直接提供) - if em_data.get("leading_stock_name"): - s["leading_stocks_realtime"] = [ - { - "ts_code": em_data.get("leading_stock_code", ""), - "name": em_data.get("leading_stock_name", ""), - "pct_chg": em_data.get("leading_stock_pct", 0), - "amount": 0, - } - ] - else: - s["realtime_pct_change"] = None - s["realtime_limit_up_count"] = None - s["is_realtime"] = False - - logger.info(f"板块实时数据: {matched}/{len(sectors_data)} 匹配成功") - - # 盘中按实时涨幅重新排序 - sectors_data.sort(key=lambda s: s.get("realtime_pct_change") or s.get("pct_change") or 0, reverse=True) - - return sectors_data - - @router.get("/hot") async def get_hot_sectors(limit: int = 10): """获取热门板块排名(盘中自动补充实时数据)""" sectors = await get_latest_sectors() + sectors = await enrich_sectors_with_realtime(sectors) + trade_date = sectors[0].trade_date if sectors else "" + sectors_data = [ { "sector_code": s.sector_code, @@ -125,11 +33,25 @@ async def get_hot_sectors(limit: int = 10): "pct_trend": s.pct_trend, "turnover_avg": s.turnover_avg, "main_force_ratio": s.main_force_ratio, + "trade_date": trade_date, + "realtime_pct_change": s.realtime_pct_change, + "realtime_limit_up_count": s.realtime_limit_up_count, + "realtime_amount": s.realtime_amount, + "realtime_turnover_rate": s.realtime_turnover_rate, + "realtime_up_count": s.realtime_up_count, + "realtime_down_count": s.realtime_down_count, + "leading_stocks_realtime": s.leading_stocks_realtime, + "is_realtime": s.is_realtime, + "data_mode": s.data_mode, } for s in sectors[:limit] ] - sectors_data = await _enrich_sectors_realtime(sectors_data) + realtime_enabled = any(s.get("is_realtime") for s in sectors_data) + mode = "realtime_overlay" if realtime_enabled else "daily_snapshot" + for s in sectors_data: + s["data_mode"] = mode + s["structure_trade_date"] = trade_date return sectors_data diff --git a/backend/app/api/stocks.py b/backend/app/api/stocks.py index 24f68413..54a15e28 100644 --- a/backend/app/api/stocks.py +++ b/backend/app/api/stocks.py @@ -513,10 +513,10 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")): pass mode_instruction_map = { - "entry": "这是建仓前诊断。重点判断是否值得纳入操作或重点关注,强调触发条件和失效条件。", - "holding": "这是持仓复核。重点判断原有逻辑是否仍成立,是否该继续持有、减仓或退出。", - "review": "这是回撤复盘。重点分析问题出在个股、板块还是市场环境,并给出修正建议。", - "tracking": "这是继续跟踪。重点判断是否保留在观察池、何时升级为可操作或何时移除。", + "entry": "这是建仓前诊断。必须明确当前是可操作、重点关注、观察还是回避,并给出触发与失效边界。", + "holding": "这是持仓复核。必须回答逻辑是否还成立,当前更适合持有、减仓、退出还是继续观察。", + "review": "这是回撤复盘。重点拆清问题来自市场、板块还是个股执行,并提出下一轮修正动作。", + "tracking": "这是继续跟踪。必须说明保留理由、升级条件和移除条件,避免空泛表述。", } mode_label_map = { "entry": "建仓前诊断", @@ -551,6 +551,8 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")): 3. 趋势评分是推荐体系的技术面核心分数(均线排列40+高低点结构35+MA20方向25=满分100),辅助信号计数仅供参考不参与主评分。 4. 位置安全评分高(>80)表示股价处于相对低位,低(<40)表示可能追高。 5. 板块信息和推荐体系信息优先级高于单一技术指标。 +6. 先给结论和动作,再解释原因;不要先铺陈背景再拖到最后才下结论。 +7. 如果证据不足,也要明确给出“观察”或“回避”,不能写成模糊建议。 {freshness_note} 请严格按以下 Markdown 结构输出,不要写成泛泛长文: @@ -558,18 +560,21 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")): ## 当前结论 - 结论: 只能从「可操作 / 重点关注 / 观察 / 回避」中选一个 - 一句话判断: 用一句话解释为什么 +- 当前动作: 只能从「执行 / 等确认 / 继续跟踪 / 暂不参与」中选一个 - 适配模式: 说明更适合启动试错、分歧回流、趋势跟随还是只观察 ## 核心逻辑 - 市场环境: 当前大盘和风格是否支持这只票 - 板块位置: 所属板块是主线、次主线还是观察线 - 个股角色: 龙头 / 跟风 / 独立逻辑 / 非核心 +- 关键证据: 只提最重要的两到三条证据,不要抄原始数据 ## 执行动作 - 触发条件: 什么情况下才可以行动 - 失效条件: 什么情况下放弃 - 仓位建议: 用低 / 中 / 高 或百分比表达 - 适合谁: 适合激进试错、低吸等待、还是不适合参与 +- 跟踪重点: 下一交易时段最该盯住什么 ## 风险清单 - 风险1: @@ -579,11 +584,15 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")): ## 复盘问题 - 如果后续走势不符合预期,优先检查哪两个问题 +## 会诊纪要 +- 用两到三句话总结本次会诊,不要写成长文,不要复制前面的条目 + 要求: - 结论必须明确,不能模糊两可 - 少写形容词,多写交易判断 - 不要重复原始数据 -- 文字保持简洁,避免旧式研报语气""" +- 文字保持简洁,避免旧式研报语气 +- 每个条目尽量一句话说清,不要堆砌长段落""" # ── SSE 流式返回 ── async def _stream_diagnosis(): @@ -593,7 +602,7 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")): stream = await client.chat.completions.create( model=settings.deepseek_model, messages=[ - {"role": "system", "content": "你是A股AI投研作战台中的个股会诊模块。你的职责不是写传统长文研报,而是基于市场环境、板块地位、推荐体系评分和跟踪结果,输出可执行、结构化的交易会诊意见。回复必须使用Markdown,结论明确,强调触发条件、失效条件、仓位和风险。"}, + {"role": "system", "content": "你是A股AI投研作战台中的个股会诊模块。你输出的是交易会诊单,不是传统研报。必须先给明确结论,再给执行动作、风险边界和跟踪重点。回复必须使用Markdown,结构严格、结论清晰、语言简短,禁止空泛抒情。"}, {"role": "user", "content": user_msg}, ], max_tokens=1500, diff --git a/backend/app/config.py b/backend/app/config.py index 5d89e409..69c1fe05 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -105,3 +105,27 @@ def is_pre_close() -> bool: now = datetime.now(ZoneInfo("Asia/Shanghai")) t = now.hour * 100 + now.minute return 1500 <= t <= 1530 + + +def today_trade_date() -> str: + """返回上海时区下的今天日期(YYYYMMDD)。""" + from zoneinfo import ZoneInfo + return datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d") + + +def should_prefer_realtime_today(latest_trade_date: str | None = None) -> bool: + """是否应该优先使用“今天”的实时数据。 + + 规则: + 1. 非交易日直接 False + 2. 交易日内(含午休、收盘后)如果 Tushare 最新交易日还不是今天,则优先实时 + 3. 即便 Tushare 最新交易日已经是今天,只要仍处于 15:30 前的延迟窗口,也优先实时 + """ + if not is_market_session() and not is_pre_close(): + return False + + today = today_trade_date() + if latest_trade_date and latest_trade_date != today: + return True + + return is_market_session() or is_pre_close() diff --git a/backend/app/data/__pycache__/models.cpython-313.pyc b/backend/app/data/__pycache__/models.cpython-313.pyc index 60b466d92c7018c4a44cc53d0dda3c287ba40101..8adf4b9edf7d0138a3601c0eae2e3a07f9830217 100644 GIT binary patch delta 2164 zcmZ9NZERCj7{`0zcHO$ZuCHs?t-#(|CsT(3O*A-QumJ<R;?KAHk9?u)p~E3dq52*Rgj+tY+|mYJK13twun`B8lp#t zt%m3_H17t@ZG`gCG#!TLC(lEkmj#lo3GdSu3hXnqV6uHp^BY>{dcSfaV2Gi0F%dMh z@P@&ihSs$~3qccK6xr}aVd~e#x|2QP`y%9Zvnb`GhrB4;lJmy4=+;Cw&6Bo%i*8~u z5s1HI`?@@-dstQ}X!)6J>Reh&rOznETviXPiG}=3UQ5lMOQlP*MNN0EH7GMA_}2t& zmK96qvn<6FEvviN^tG|;Gi6E_iWG1{(*uR9lF1iyDOD?_&#I}_abtRWe%;d@86{ts zOQ}WWoO-6D>1`QBQ&KafOqPYk$Hcbse>-~0_%5~w*b3|g_6g{l^F@t~!QBsx0~5eO z0bMTSRZW*O`LxCkK|Ksi0!M(Oz^lM9AOTDP)4*%MaRJ>TpDvXO>?D_5!T8INfnDNX z4NE4|byL&rZOC4O{$ORIy15|-`S133%MH1wIXJW=2Wq}W??P8Y9%{z7FUjFrVsXd9 zsfN70**jP{Qk||%-AXp(!RAxLOLC~TZ*lL!p@uvx8kb~WRTZrl(b^wJBu`CS2sPw* zGcE#$Yg3E8h&0p;Mk)uZVkMELudgybuRfOhEUcH-%PZbaKJWNan&u2i11iVet=V1a0|JalJHR3!)1oiK1TNJJoku0X(8d<%RBB!TaNAAlc`m~aTll2<+*ZXJ(>Jw&kgAT4@E~p zR1fkPLZ_VOqp2S7Tr?v6#IHto*g{f&wR71dewJV4KS!SpTP05|wrmnV%ORH(t&aa~ J57fK6vsWVNt}(pKZ1?1HXpQ+Q&lb^hafc(H6b)4m{6Bcz@l;NjboB{#q6e4 z1vGp_fCQ*SqlgHpilUzQXi+ei9(q8c7cLwsRnHPEh!@)_+*+ z6h;u9H%r)!re3rR&ZorVnAuo`Ziv440XAN{8vl~@orZFTWZ*0?51a$e0~df%;3BX9 zTmr6$H(O(Y>o9)+Xt#@EOCl)>iMVP(15VWz5~GY+*ra&9Z8uvI>EsvcGRA==aW$D> z%NS;pvc$*qX6~V)RjHar<14~rPeGp)zRp9QfrZ#;%L;TQbP}KXwld$bN&P@tw)@jrqck_8_w$G1P-l-Pz}jxa*In|l{-Idx+>vNx;pNo2 ROFq^^emOYw&LtnT!oO4}F_HiP diff --git a/backend/app/data/models.py b/backend/app/data/models.py index ea4c85a0..30102021 100644 --- a/backend/app/data/models.py +++ b/backend/app/data/models.py @@ -56,6 +56,7 @@ class CapitalFlow(BaseModel): class SectorInfo(BaseModel): sector_code: str sector_name: str + trade_date: str = "" pct_change: float = 0 # 涨跌幅 % capital_inflow: float = 0 # 主力净流入(万元,原始数据来自Tushare亿元×10000) limit_up_count: int = 0 # 涨停数 @@ -70,6 +71,15 @@ class SectorInfo(BaseModel): pct_trend: list[float] = [] # 近5日涨跌幅趋势 turnover_avg: float = 0 # 板块平均换手率 main_force_ratio: float = 0 # 主力资金占比(主力净流入/总成交额) + realtime_pct_change: float | None = None + realtime_limit_up_count: int | None = None + realtime_amount: float | None = None + realtime_turnover_rate: float | None = None + realtime_up_count: int | None = None + realtime_down_count: int | None = None + leading_stocks_realtime: list[dict] = [] + is_realtime: bool = False + data_mode: str = "daily_snapshot" class MarketTemperature(BaseModel): @@ -157,11 +167,16 @@ class StrategySectorFocus(BaseModel): heat_score: float = 0 pct_change: float = 0 limit_up_count: int = 0 + turnover_rate: float = 0 + up_count: int = 0 + down_count: int = 0 + data_mode: str = "daily_snapshot" view: str = "" class StrategyBoard(BaseModel): trade_date: str + data_mode: str = "daily_snapshot" market_regime: str risk_level: str action_bias: str diff --git a/backend/app/engine/__pycache__/recommender.cpython-313.pyc b/backend/app/engine/__pycache__/recommender.cpython-313.pyc index 1820ec7a44b420706a078b91597975369495f50f..3c5a1ee20647bb939636dd7b1a83142731b27376 100644 GIT binary patch delta 526 zcmbQgis|$!Cf?7yyj%=GVCV26^Z!QPC^kmL&3$aE8o9-Qg3Unuxrt>mf2{uGqK-1g z@Xaqf*cc}_cZxD{P5xLZ$;drfu}fz10vGYg3SBId3%Wc&G^4=e!Y{h7T>F>de3?eeqEv8s}V&NC#`_2mH zO}Cg}@%u~2WZsF9_4bS{eC~=2lNjCX*(NgsS<@8V*_pR9x-+rua5V)okLmINMUPoB zy0fw!vtj|U?HE1m8IRdB0@=rz7=dbzvx3>|Z0>UG$9Wmu1$d70@qpM;AQO+vg47?E z1G5#mJZzYcYukI6GoR36^pMwOJE6@6;utalB~BQz0+pUHX9clsm_6(_vrJ-V7BOTL US`e~A>!PaZR|X)vXcjQQ0Q1w1U;qFB delta 506 zcmX@TifR5TCf?7yyj%=GaCznP%%dB5quA8l^+StOi;DFV6N~cm^o#RLi;`30lk-zj z^%ILr@{_Z56Vp@kO7u5pu&rvGY|X*R7%*9;V;Q5}=EEIqj7-5?lMTBhn1Z<>6c3Qn zW#paASS2}Gr^|;?VDkSC@yV;Yq$Ypsl3^5rh<8g&zSAWw6f9!NYsv_63Xl5+@{Cfl5!vv4Ysj%pPi+4JNTOix@BpEeKhmby3yyD+7>SGz%EE0Dx_X ATL1t6 diff --git a/backend/app/engine/__pycache__/screener.cpython-313.pyc b/backend/app/engine/__pycache__/screener.cpython-313.pyc index 4d84deb92c9f54d15962e52129aa2ef6c75b295c..501d9be6e4c4bf1f707c548ad373d489772979a6 100644 GIT binary patch delta 11017 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 H1)BVS({{sG delta 9391 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 JD^a1n{|CW06M6su diff --git a/backend/app/engine/recommender.py b/backend/app/engine/recommender.py index db61dd8f..86c3b0f0 100644 --- a/backend/app/engine/recommender.py +++ b/backend/app/engine/recommender.py @@ -817,6 +817,7 @@ async def _load_sectors_from_db() -> list[SectorInfo]: sectors.append(SectorInfo( sector_code=r["sector_code"], sector_name=r["sector_name"], + trade_date=r.get("trade_date") or "", pct_change=r["pct_change"] or 0, capital_inflow=r["capital_inflow"] or 0, limit_up_count=r["limit_up_count"] or 0, diff --git a/backend/app/engine/screener.py b/backend/app/engine/screener.py index 6ee74363..46a56749 100644 --- a/backend/app/engine/screener.py +++ b/backend/app/engine/screener.py @@ -29,7 +29,8 @@ from app.analysis.trend_scanner import scan_trend_breakout from app.analysis.signals import generate_signals from app.analysis.intraday import intraday_market_temperature, intraday_filter_stocks, intraday_sector_scan from app.data.models import MarketTemperature, SectorInfo, TechnicalSignal, Recommendation -from app.config import settings, is_trading_hours, is_market_session +from app.config import settings, is_trading_hours, is_market_session, should_prefer_realtime_today +from app.data.tushare_client import tushare_client from app.llm.strategy_selector import select_strategy_profile logger = logging.getLogger(__name__) @@ -45,7 +46,8 @@ async def run_screening(trade_date: str = None) -> dict: "scan_mode": "intraday" | "post_market", } """ - intraday = is_market_session() + latest_trade_date = tushare_client.get_latest_trade_date() + intraday = should_prefer_realtime_today(latest_trade_date) scan_mode = "intraday" if intraday else "post_market" logger.info(f"=== 筛选模式: {'盘中实时' if intraday else '盘后'} ===") @@ -695,32 +697,60 @@ async def _build_recommendations( ) llm_results = await analyze_candidates_individually(llm_top, market_summary) - # 综合量化 + LLM 判断 + # 综合规则边界 + LLM 最终裁决 for rec in recommendations: llm_data = llm_results.get(rec.ts_code) if llm_data: rec.llm_analysis = llm_data.get("analysis", "") + rec.llm_score = float(llm_data.get("conviction", 0) or 0) - # LLM 信号强度转换为分数调整 - # 调整幅度温和,保底不低于 60,避免推荐被过滤掉 - strength = llm_data.get("strength", "中") - llm_signal = llm_data.get("signal", "HOLD") + verdict = llm_data.get("verdict", "watch") + action_plan = llm_data.get("action_plan", "") + conviction = float(llm_data.get("conviction", 6) or 6) + ai_score = conviction * 10 - if llm_signal == "SKIP": - # 降分但保底 60,排序靠后但不消失 - rec.score = max(60, round(rec.score - 15, 1)) - elif llm_signal == "HOLD": - rec.score = max(60, round(rec.score - 5, 1)) - elif llm_signal == "BUY" and strength == "强": - rec.score = round(rec.score + 10, 1) - elif llm_signal == "BUY" and strength == "中": - rec.score = round(rec.score + 5, 1) - else: # BUY + 弱 - pass # 不调整 + if verdict == "execute": + rec.score = round(rec.score * 0.4 + ai_score * 0.6 + 4, 1) + elif verdict == "watch": + rec.score = round(rec.score * 0.5 + ai_score * 0.5 - 2, 1) + else: # skip + rec.score = round(rec.score * 0.45 + ai_score * 0.35 - 18, 1) + + if verdict == "skip": + rec.signal = "HOLD" + rec.action_plan = "观察" + rec.lifecycle_status = "candidate" + if not rec.risk_note: + rec.risk_note = llm_data.get("risk_flag", "") or rec.risk_note + else: + if action_plan in {"可操作", "重点关注", "观察"}: + rec.action_plan = action_plan + elif verdict == "execute": + rec.action_plan = "可操作" + else: + rec.action_plan = "重点关注" + + rec.signal = "BUY" if verdict == "execute" else "HOLD" + if rec.action_plan == "可操作": + rec.lifecycle_status = "actionable" + elif rec.action_plan == "重点关注": + rec.lifecycle_status = "candidate" + + if llm_data.get("timing"): + rec.entry_timing = llm_data["timing"] + + if llm_data.get("trigger_condition"): + rec.trigger_condition = llm_data["trigger_condition"] + if llm_data.get("invalidation_condition"): + rec.invalidation_condition = llm_data["invalidation_condition"] + if llm_data.get("position_pct") is not None: + rec.suggested_position_pct = float(llm_data["position_pct"] or 0) + if llm_data.get("risk_flag"): + rec.risk_note = llm_data["risk_flag"] rec.level = _score_to_level(rec.score) - # 用 LLM 给出的价格替代硬编码价格 + # 用 LLM 给出的价格替代结构化规则价格 if llm_data.get("entry_price"): rec.entry_price = llm_data["entry_price"] if llm_data.get("target_price"): @@ -728,6 +758,11 @@ 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) + ] recommendations.sort(key=lambda r: r.score, reverse=True) recommendations = recommendations[:settings.top_stock_count] logger.info(f"LLM 逐股分析完成, 综合评分后保留 {len(recommendations)} 只") diff --git a/backend/app/llm/__pycache__/prompts.cpython-313.pyc b/backend/app/llm/__pycache__/prompts.cpython-313.pyc index a13b2037f39a41d29de840cf1e2b30454c266c20..8d726eca35dea62bca03b005be6b7d9afaaa4726 100644 GIT binary patch delta 1667 zcmaJ?O>Y`k6m>RTjFN1+snjk;tZOSyTvhU-QWsscQrktVNL?X>sXMX@2&mJjt!6w4 z3{YU~;195|4K)~?$Y95AJj{UUKCAu)zx(FR%8RP|+{eR?+f-#?%y8ei=iGbFGqO-_oohM*xf_q!Vve2cVj1k%#4fl_8QL58 z94s9w_PMr5w+67aj{kUuY7*xZp5(Bz+i$y0k+p4X!~3;2*q{vNQzYazTExjheIM)< zWPpEJr@;oBkZjOGi{+Q-Q57P6N!WsDb_ytRap@x@pL^NWbFRO-MgOu9C#r zsz|G_1L^WbZ$p}#kcBdoMOP28Hy2^*OnY=ComMp0NxKItw3e3cPROvQ)9s2-1eHV7 zG1SFnv6B|U;YT5krq}5D3tF+cf?U^2<`Gl{vx9`)%@bN^z|tNH_nwvbSoNB8?-E{& z0FtLrH)yHg*4nJO2j()d3Q#|yt7&%DbystIaQS1>A6%&r>q5>5$|ZDnL_caSLEa#} zk_yQ*jzpT8kl9%g3>){vy>NZ;`PD1+Zgqub8zvCE*^($Y(Pm(PA7DBNZC7AF{%f8T$ zq2LdRko>d!Adm=thwt$@CF*;hmj!|eB@*^W=L2D0irQ!~k4wfP&&a!a7m~;HxP>2C zQ-{uuI2DY97Z4mz`leNRa(wd65G}OvDk3jh$CUV-|J(V%KS#ql{3IVrz3J^1yz;(J`UQIWCX( zH!Y76EKHnfvRaOA)l}lVz+iY=+hAE30XA5sm*3%6$Wa*fde}wM5qfBdY1z{pcDBq0 zC8)Hx+4?E3qM;AngAAM{d05(YVM4qVMAFbBDB9awQ#~y+YFIUJvxv&=H7(=Pu@B}4 zH;#LMvp=WSUPN}@3J%S{Xkjt2(Bdt;GBBAqj9LSdNn}EgbTN$A){|k7^lFZZVH;Xd z=D}M%!D|O8TwkKh1}m_+E#Dcx_rdF;i{7TVd9E0WrkvH`(d7|@qR(rzlA+lwHEVeN zVB~q?o$@G6-{A0vim~(!AIf+!%m3}X5q+Mx;kv|p*`q$B%I?06**X5>o%=U#`sA-~ N{5kes>?Z!|yaEBH$s_;( delta 1217 zcmZ8hOHUI~6o%~taN))fO(rC`YYAebsVj{O2}Vhb27iKyEB`>J<7tfrfZ<*S-rwS zkut3u!D<_{C`oLwixt;ugJL5)tG8WeA4(=4z^b@Lk|Y#TtcwY$r83>bGMfBBPm>VS z$aW1@D^N7a&Lt_D{Yemj<`L3@M7d|@rCGmBEgP~iA-SuB!Z<8ac9pO|C#fjekHXiK z>!hipBf$XLQ6ZitxjJO4o)SR_*GeLv^vEwmHA41^!>4>MYZsy>Dsvr`&v4biYGguO zPF6dh1%wE-kqZBibP4aIIls*6E3VyeJ7vhU!8XCHN)oz2Rx(_GPv(+}sJDTjIL@tI z*RK}PB#zkMl+c28|Emqroudd{3vbS%|uQ5t!xzhQlQ1RIK%)X&SkL)fNiHOktF8Xm-)S zRH|cl>JQo_7saHk&-0hK0^} z-`Bm}V@VP%A|AM%pv(hOakw@ixOjB43~zUZbv6ds^~rpZL75#!NU?zRv=)c!RZ=*| zcAl^Pndpi`{DKuk>m5-=ql0<@J%Ita>%aH|d lnu8mTSl01lK$Jtg_fB)I#mNt&V-Mu{(Vrvtzl`BG>krI63tRvI diff --git a/backend/app/llm/batch_screener.py b/backend/app/llm/batch_screener.py index 61fe124b..e9363d64 100644 --- a/backend/app/llm/batch_screener.py +++ b/backend/app/llm/batch_screener.py @@ -5,6 +5,7 @@ """ import asyncio +import json import logging import re @@ -21,11 +22,17 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict: market_summary: 市场环境摘要 返回: { - "signal": "BUY"/"HOLD"/"SKIP", - "strength": "强"/"中"/"弱", + "verdict": "execute"/"watch"/"skip", + "action_plan": "可操作"/"重点关注"/"观察", + "conviction": int, + "timing": str, "entry_price": float or None, "target_price": float or None, "stop_loss": float or None, + "trigger_condition": str, + "invalidation_condition": str, + "position_pct": int, + "risk_flag": str, "analysis": str, } """ @@ -36,7 +43,7 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict: stock_text = f"""\ 股票: {candidate['name']}({candidate['ts_code']}) 板块: {candidate.get('sector', '未知')} -量化评分: {candidate.get('quant_score', 0)}/100 +规则参考分: {candidate.get('quant_score', 0)}/100 位置安全: {candidate.get('position_score', 50)}/100 当前价: {candidate.get('current_price', '未知')}""" @@ -56,9 +63,10 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict: { "role": "system", "content": ( - "你是一位专业的A股趋势交易分析师,专注于中短线(1-5日)交易。" - "你根据技术分析结论独立判断入场时机,给出具体的买卖价格建议。" - "不要被量化评分束缚,给出你真实的判断。" + "你是一位A股短线交易裁决员。" + "你的任务是决定这只股票今天是否该进入推荐前列,以及应该归入可操作、重点关注还是观察。" + "不要复述数据,不要写成长文,不要被规则参考分绑架。" + "必须返回合法JSON。" ), }, {"role": "user", "content": user_msg}, @@ -72,69 +80,131 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict: except Exception as e: logger.error(f"LLM 分析 {candidate.get('ts_code')} 失败: {e}") return { - "signal": "HOLD", - "strength": "弱", + "verdict": "watch", + "action_plan": "重点关注", + "conviction": 4, + "timing": "", "entry_price": None, "target_price": None, "stop_loss": None, + "trigger_condition": "", + "invalidation_condition": "", + "position_pct": 0, + "risk_flag": "AI 裁决暂不可用", "analysis": "AI分析暂不可用", } def _parse_single_response(text: str) -> dict: """解析单只股票的 LLM 返回""" - # 提取信号 + data = _extract_json_object(text) + if data: + verdict = str(data.get("verdict", "watch")).strip().lower() + if verdict not in {"execute", "watch", "skip"}: + verdict = "watch" + + action_plan = str(data.get("action_plan", "")).strip() + if action_plan not in {"可操作", "重点关注", "观察"}: + action_plan = {"execute": "可操作", "watch": "重点关注", "skip": "观察"}[verdict] + + conviction = _clamp_int(data.get("conviction"), minimum=1, maximum=10, default=6) + position_pct = _clamp_int(data.get("position_pct"), minimum=0, maximum=35, default=0) + + return { + "verdict": verdict, + "action_plan": action_plan, + "conviction": conviction, + "timing": str(data.get("timing", "")).strip(), + "entry_price": _parse_float(data.get("entry_price")), + "target_price": _parse_float(data.get("target_price")), + "stop_loss": _parse_float(data.get("stop_loss")), + "trigger_condition": str(data.get("trigger_condition", "")).strip(), + "invalidation_condition": str(data.get("invalidation_condition", "")).strip(), + "position_pct": position_pct, + "analysis": str(data.get("analysis", "")).strip() or "暂无分析", + "risk_flag": str(data.get("risk_flag", "")).strip(), + } + + # 兼容旧格式 signal = "HOLD" signal_match = re.search(r"信号[:\s]*(BUY|HOLD|SKIP)", text) if signal_match: signal = signal_match.group(1) - # 提取信号强度 + verdict = "execute" if signal == "BUY" else "skip" if signal == "SKIP" else "watch" strength = "中" strength_match = re.search(r"信号强度[:\s]*(强|中|弱)", text) if strength_match: strength = strength_match.group(1) + conviction = {"强": 8, "中": 6, "弱": 4}.get(strength, 6) - # 提取买入价 entry_price = None entry_match = re.search(r"买入价[:\s]*(\d+(?:\.\d+)?)", text) if entry_match: entry_price = float(entry_match.group(1)) - - # 提取止盈价 target_price = None target_match = re.search(r"止盈价[:\s]*(\d+(?:\.\d+)?)", text) if target_match: target_price = float(target_match.group(1)) - - # 提取止损价 stop_loss = None stop_match = re.search(r"止损价[:\s]*(\d+(?:\.\d+)?)", text) if stop_match: stop_loss = float(stop_match.group(1)) - - # 提取分析 analysis = "" analysis_match = re.search(r"分析[:\s]*(.+)", text, re.DOTALL) if analysis_match: analysis = analysis_match.group(1).strip() return { - "signal": signal, - "strength": strength, + "verdict": verdict, + "action_plan": {"execute": "可操作", "watch": "重点关注", "skip": "观察"}[verdict], + "conviction": conviction, + "timing": "", "entry_price": entry_price, "target_price": target_price, "stop_loss": stop_loss, + "trigger_condition": "", + "invalidation_condition": "", + "position_pct": 20 if verdict == "execute" else 0, "analysis": analysis or "暂无分析", + "risk_flag": "", } +def _extract_json_object(text: str) -> dict | None: + match = re.search(r"\{[\s\S]*\}", text) + if not match: + return None + try: + parsed = json.loads(match.group(0)) + return parsed if isinstance(parsed, dict) else None + except Exception: + return None + + +def _parse_float(value) -> float | None: + try: + if value in (None, "", 0, "0"): + return None + return float(value) + except Exception: + return None + + +def _clamp_int(value, minimum: int, maximum: int, default: int) -> int: + try: + parsed = int(round(float(value))) + except Exception: + return default + return max(minimum, min(maximum, parsed)) + + async def analyze_candidates_individually( candidates: list[dict], market_summary: str, max_concurrent: int = 3 ) -> dict[str, dict]: """对候选股票逐个做 LLM 分析(控制并发数) - 返回: {ts_code: {"signal", "strength", "entry_price", ...}} + 返回: {ts_code: {"verdict", "action_plan", "conviction", "entry_price", ...}} """ if not settings.deepseek_api_key or not candidates: return {} @@ -149,7 +219,7 @@ async def analyze_candidates_individually( result = await analyze_single_stock(c, market_summary) logger.info( f"LLM 结果: {c.get('name', ts_code)} → " - f"信号={result['signal']} 强度={result['strength']} " + f"裁决={result['verdict']} 计划={result['action_plan']} 置信度={result['conviction']} " f"买入={result.get('entry_price')} 止盈={result.get('target_price')} " f"止损={result.get('stop_loss')}" ) diff --git a/backend/app/llm/daily_review.py b/backend/app/llm/daily_review.py index b927b58e..7172001d 100644 --- a/backend/app/llm/daily_review.py +++ b/backend/app/llm/daily_review.py @@ -2,7 +2,7 @@ import logging -from app.config import settings +from app.config import settings, today_trade_date logger = logging.getLogger(__name__) @@ -13,7 +13,8 @@ async def generate_review() -> dict: from app.data import tencent_client from app.engine.recommender import get_latest_recommendations, get_latest_sectors - trade_date = tushare_client.get_latest_trade_date() + latest_trade_date = tushare_client.get_latest_trade_date() + trade_date = today_trade_date() # 收集市场数据 result = await get_latest_recommendations() @@ -45,7 +46,7 @@ async def generate_review() -> dict: index_summary += f"{name_map[code]}: {d.get('price', 0):.2f} ({d.get('pct_chg', 0):+.2f}%), " sector_summary = "热门板块: " + "、".join( - f"{s.sector_name}({s.pct_change:+.1f}%)" for s in sectors[:5] + f"{s.sector_name}({(getattr(s, 'realtime_pct_change', None) or s.pct_change):+.1f}%)" for s in sectors[:5] ) rec_summary = "" @@ -58,6 +59,7 @@ async def generate_review() -> dict: user_msg = f"""请根据以下数据生成今日A股市场复盘报告(中文): 日期: {trade_date} +Tushare 最新交易日: {latest_trade_date} {market_summary} {index_summary} {sector_summary} diff --git a/backend/app/llm/prompts.py b/backend/app/llm/prompts.py index 38c16cea..700934d4 100644 --- a/backend/app/llm/prompts.py +++ b/backend/app/llm/prompts.py @@ -122,32 +122,37 @@ ANALYSIS_USER_TEMPLATE = """\ # ── AI 逐股筛选 Prompt ── SINGLE_STOCK_ANALYSIS_PROMPT = """\ -你是一位专业的A股趋势交易分析师,专注于中短线交易(持仓1-5个交易日)。 +你是一位A股中短线交易裁决员,不是量化打分解释器。你的职责是基于给定的市场、板块、量价和位置结论,决定这只股票今天应不应该进入推荐池前列,以及应该归入什么动作级别。 -量化系统已通过多轮筛选认定该股票具备投资价值,请你基于以下技术分析结论,独立判断入场时机。 +你的原则: +1. 量化分数只是参考,不是最终答案 +2. 如果板块地位、量价质量、位置或时机不匹配,可以直接否决高分股 +3. 如果股票具备明确触发与失效边界,即使量化分不是最高,也可以提升优先级 +4. 输出的是交易裁决单,不是研报 -你的任务: -1. 综合趋势、量价、技术指标和位置,判断当前是否适合介入 -2. 如果适合介入,给出具体的买入价位、止盈价和止损价 -3. 评估信号强度 +请严格输出 JSON,不要输出 Markdown,不要添加多余解释。字段如下: +{ + "verdict": "execute | watch | skip", + "action_plan": "可操作 | 重点关注 | 观察", + "conviction": 1-10, + "timing": "一句话描述当下最合适的处理方式", + "entry_price": 0, + "target_price": 0, + "stop_loss": 0, + "trigger_condition": "一句话", + "invalidation_condition": "一句话", + "position_pct": 0, + "analysis": "2-4句话,说明为什么给这个裁决", + "risk_flag": "一句话说明最大风险" +} -注意: -- 你看到的是量化系统对K线和技术指标的分析结论,不是原始数据 -- 请独立判断,不要被量化评分影响太多 -- 止盈空间通常3-8%,止损空间通常3-5% -- 中短线交易,重点关注1-5日的走势 +裁决标准: +- execute: 今天具备执行条件或非常接近执行条件,可以进入推荐前列 +- watch: 逻辑还在,但需要等待确认,不应直接作为首选执行标的 +- skip: 当前不适合进入推荐前列,宁可错过也不主动参与 -请严格按以下格式输出: - -信号: BUY/HOLD/SKIP -信号强度: 强/中/弱 -买入价: XX.XX(建议入场价位,基于当前价微调) -止盈价: XX.XX(目标价位,给出合理空间) -止损价: XX.XX(跌破此价离场) -分析: 3-5句话,说明核心逻辑、入场理由和主要风险 - -说明: -- BUY: 看好,建议在买入价附近介入 -- HOLD: 观望,等待更好的时机 -- SKIP: 不看好,风险大于收益 -- 信号强度"强"表示把握较大,"弱"表示不确定性较高""" +补充要求: +- conviction 必须是 1-10 的整数 +- position_pct 返回 0-35 的整数;如果不适合参与,就返回 0 +- 没有把握时优先给 watch 或 skip +- trigger_condition 和 invalidation_condition 必须可执行,不能写空话""" diff --git a/backend/app/llm/strategy_board.py b/backend/app/llm/strategy_board.py index 3f9eb0bb..532cf6e2 100644 --- a/backend/app/llm/strategy_board.py +++ b/backend/app/llm/strategy_board.py @@ -6,7 +6,8 @@ import logging -from app.config import settings +from app.analysis.sector_realtime import enrich_sectors_with_realtime +from app.config import settings, should_prefer_realtime_today, today_trade_date from app.data.models import ( MarketTemperature, Recommendation, @@ -19,6 +20,35 @@ from app.data.models import ( logger = logging.getLogger(__name__) +def _sector_pct(sector: SectorInfo) -> float: + return float(sector.realtime_pct_change if sector.realtime_pct_change is not None else sector.pct_change) + + +def _sector_turnover(sector: SectorInfo) -> float: + return float(sector.realtime_turnover_rate if sector.realtime_turnover_rate is not None else sector.turnover_avg) + + +def _sector_up_count(sector: SectorInfo) -> int: + return int(sector.realtime_up_count if sector.realtime_up_count is not None else 0) + + +def _sector_down_count(sector: SectorInfo) -> int: + return int(sector.realtime_down_count if sector.realtime_down_count is not None else 0) + + +def _sector_breadth(sector: SectorInfo) -> int: + return _sector_up_count(sector) - _sector_down_count(sector) + + +def _sector_strength_score(sector: SectorInfo) -> float: + strength = _sector_pct(sector) * 12 + strength += min(max(_sector_breadth(sector), -30), 30) * 0.6 + strength += min(_sector_turnover(sector), 12) * 1.5 + strength += min(sector.limit_up_count, 8) * 2.5 + strength += min(sector.days_continuous, 5) * 1.5 + return round(strength, 1) + + async def build_strategy_board(include_llm: bool = False) -> dict: """生成今日市场作战面板。""" from app.engine.recommender import ( @@ -31,6 +61,7 @@ async def build_strategy_board(include_llm: bool = False) -> dict: market_temp = latest.get("market_temp") recommendations = latest.get("recommendations", []) sectors = await get_latest_sectors() + sectors = await enrich_sectors_with_realtime(sectors) performance = await get_performance_stats() from app.llm.strategy_iteration import build_strategy_iteration_report iteration_report = await build_strategy_iteration_report(limit=50, include_llm=include_llm) @@ -59,11 +90,18 @@ def _build_rule_board( performance: dict, ) -> StrategyBoard: temp = market_temp.temperature if market_temp else 0 - trade_date = market_temp.trade_date if market_temp else "" + raw_trade_date = market_temp.trade_date if market_temp else "" + prefer_realtime = should_prefer_realtime_today(raw_trade_date) + trade_date = today_trade_date() if prefer_realtime else raw_trade_date + data_mode = "realtime_today" if prefer_realtime else "daily_snapshot" market_regime, risk_level, action_bias, position_suggestion = _classify_market(temp, market_temp) actionable = [r for r in recommendations if r.action_plan == "可操作"] watch = [r for r in recommendations if r.action_plan == "重点关注"] + strong_sectors = [ + s for s in sectors[:5] + if _sector_pct(s) >= 1.5 and (_sector_breadth(s) > 0 or s.limit_up_count >= 2) + ] avg_score = ( round(sum(r.score for r in recommendations) / len(recommendations), 1) if recommendations else 0 @@ -74,11 +112,13 @@ def _build_rule_board( watch_sectors = [_sector_focus(s) for s in sectors[:5]] avoid_rules = _build_avoid_rules(temp, sectors, recommendations) iteration_notes = _build_iteration_notes(performance, recommendations) + mode_prefix = "今日实时视角:" if prefer_realtime else "" summary = ( - f"{market_regime},风险等级{risk_level}。" + f"{mode_prefix}{market_regime},风险等级{risk_level}。" f"当前 {len(recommendations)} 只入选,其中 {len(actionable)} 只可操作、" f"{len(watch)} 只重点关注,平均分 {avg_score}。" + f"{'主线活跃板块 ' + ' / '.join(s.sector_name for s in strong_sectors[:3]) + '。' if strong_sectors else '板块尚未形成强共振,优先等确认。'}" ) metrics = { @@ -94,6 +134,7 @@ def _build_rule_board( return StrategyBoard( trade_date=trade_date, + data_mode=data_mode, market_regime=market_regime, risk_level=risk_level, action_bias=action_bias, @@ -125,11 +166,17 @@ def _classify_market( def _choose_strategy_mode( temp: float, sectors: list[SectorInfo], recommendations: list[Recommendation] ) -> str: - early_mid = [s for s in sectors[:5] if s.stage in ("early", "mid")] - if temp >= 60 and early_mid: - return "主线突破 + 回踩确认" - if temp >= 45: - return "精选回踩,降低追高" + top = sectors[:5] + strong = [s for s in top if _sector_pct(s) >= 2 and _sector_breadth(s) > 0] + active = [s for s in top if _sector_pct(s) >= 1 and (_sector_turnover(s) >= 2 or s.limit_up_count >= 2)] + end_stage = [s for s in top if s.stage == "end" and _sector_pct(s) > 0] + + if temp >= 65 and len(strong) >= 2: + return "顺主线做强势确认" + if temp >= 55 and active: + return "围绕强板块做回踩确认" + if temp < 45 or end_stage: + return "缩仓观察,回避后排追高" if recommendations: return "观察池跟踪,等待触发" return "防守观察" @@ -159,16 +206,37 @@ def _build_strategy_focus( if sectors: main = sectors[0] + sector_pct = _sector_pct(main) + breadth = _sector_breadth(main) + turnover = _sector_turnover(main) focus.append(StrategyFocus( label=f"{main.sector_name} 主线跟踪", - description=f"热度 {main.heat_score},阶段 {main.stage},优先确认资金是否延续。", + description=( + f"当前涨幅 {sector_pct:+.2f}% ,广度 {breadth:+d},换手 {turnover:.1f}% ," + f"阶段 {main.stage},优先确认资金是否延续。" + ), )) + if len(sectors) > 1: + runner_up = sectors[1] + focus.append(StrategyFocus( + label=f"{runner_up.sector_name} 轮动监控", + description=( + f"强度分 {_sector_strength_score(runner_up)}," + f"若涨幅继续扩大且广度转强,可切入今日第二梯队。" + ), + )) + if temp < 45: focus.append(StrategyFocus( label="防守优先", description="市场温度不足,推荐只作为观察池,不宜扩大仓位。", )) + elif sectors and _sector_pct(sectors[0]) < 1: + focus.append(StrategyFocus( + label="等一致性强化", + description="板块领涨幅度仍有限,优先等主线扩散和个股触发后再出手。", + )) return focus @@ -185,9 +253,19 @@ def _sector_focus(sector: SectorInfo) -> StrategySectorFocus: sector_name=sector.sector_name, stage=sector.stage, heat_score=sector.heat_score, - pct_change=sector.pct_change, + pct_change=_sector_pct(sector), limit_up_count=sector.limit_up_count, - view=stage_view, + turnover_rate=_sector_turnover(sector), + up_count=_sector_up_count(sector), + down_count=_sector_down_count(sector), + data_mode=sector.data_mode, + view=( + f"{stage_view};当前涨幅 {_sector_pct(sector):+.2f}%" + + ( + f",上涨/下跌 {_sector_up_count(sector)}/{_sector_down_count(sector)}" + if sector.is_realtime else "" + ) + ), ) @@ -195,10 +273,17 @@ def _build_avoid_rules( temp: float, sectors: list[SectorInfo], recommendations: list[Recommendation] ) -> list[str]: rules = [] + top = sectors[:5] if temp < 45: rules.append("市场温度低于45时,不追突破首日,只等次日确认或回踩。") - if any(s.stage == "end" for s in sectors[:5]): + if any(s.stage == "end" for s in top): rules.append("板块进入末期时,降低同板块追高标的权重。") + if top and _sector_pct(top[0]) < 1: + rules.append("主线板块涨幅不足1%时,不把局部异动当成全面进攻信号。") + if any(_sector_breadth(s) < 0 for s in top[:3] if s.is_realtime): + rules.append("板块涨幅与广度背离时,优先回避后排补涨和冲高追单。") + if any(_sector_turnover(s) > 8 and s.stage in ("late", "end") for s in top): + rules.append("高换手叠加板块后期阶段时,防止情绪过热后的快速分歧。") if any(r.position_score < 35 for r in recommendations): rules.append("位置安全分低于35的标的,只观察不主动追入。") if not rules: diff --git a/backend/astock.db b/backend/astock.db index 31ffc2d15593d63624abff6a3595d7544766d392..ccf34c11fdc21489c4b8c6a0f39849e420a2d53d 100644 GIT binary patch delta 6361 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 OwWcH0?4|0QlK%x8@EuM7 delta 3811 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 UZ4_r^&2*?$)ti~)n=OU^1JT?iL;wH) diff --git a/frontend/.next/app-build-manifest.json b/frontend/.next/app-build-manifest.json index bce6a549..ef123f7d 100644 --- a/frontend/.next/app-build-manifest.json +++ b/frontend/.next/app-build-manifest.json @@ -70,11 +70,6 @@ "static/chunks/webpack.js", "static/chunks/main-app.js", "static/chunks/app/(public)/page.js" - ], - "/_not-found/page": [ - "static/chunks/webpack.js", - "static/chunks/main-app.js", - "static/chunks/app/_not-found/page.js" ] } } \ No newline at end of file diff --git a/frontend/.next/server/app-paths-manifest.json b/frontend/.next/server/app-paths-manifest.json index 32c12767..21ce9557 100644 --- a/frontend/.next/server/app-paths-manifest.json +++ b/frontend/.next/server/app-paths-manifest.json @@ -1,7 +1,6 @@ { - "/_not-found/page": "app/_not-found/page.js", - "/(auth)/dashboard/page": "app/(auth)/dashboard/page.js", "/(auth)/stock/[code]/page": "app/(auth)/stock/[code]/page.js", + "/(auth)/dashboard/page": "app/(auth)/dashboard/page.js", "/(auth)/chat/page": "app/(auth)/chat/page.js", "/(public)/login/page": "app/(public)/login/page.js", "/(public)/page": "app/(public)/page.js" diff --git a/frontend/src/app/(auth)/dashboard/page.tsx b/frontend/src/app/(auth)/dashboard/page.tsx index 6c39b3a5..548bc231 100644 --- a/frontend/src/app/(auth)/dashboard/page.tsx +++ b/frontend/src/app/(auth)/dashboard/page.tsx @@ -350,18 +350,34 @@ function MissionControl({ const risks = board?.avoid_rules?.length ? board.avoid_rules : ["等待市场状态和推荐结果更新后生成风险约束。"]; const primaryQueue = (actionable.length ? actionable : watch).slice(0, 3); const laneTitle = actionable.length ? "优先执行" : watch.length ? "候选观察" : "等待信号"; - const strategyName = strategyProfile?.name ?? board?.recommended_mode ?? "待定"; - const strategyHint = strategyProfile?.description ?? "系统判断今天更适合采用的出手方式"; - const riskText = risks.slice(0, 2).join(" / "); + const strategyName = strategyProfile?.name && strategyProfile.name !== "当前推荐策略" + ? strategyProfile.name + : board?.recommended_mode ?? "待定"; + const strategyHint = strategyProfile?.description ?? "结合市场状态与主线强弱得出的出手方式"; + const isRealtimeBoard = board?.data_mode === "realtime_today"; + const topSectorEvidence = topSectors.map((sector) => ({ + key: sector.sector_code, + name: sector.sector_name, + pct: sector.realtime_pct_change ?? sector.pct_change, + breadth: sector.realtime_up_count != null && sector.realtime_down_count != null + ? `${sector.realtime_up_count}/${sector.realtime_down_count}` + : null, + turnover: sector.realtime_turnover_rate, + })); return ( -
+
-
-
+
+
今日结论 + {isRealtimeBoard && ( + + 今日实时 + + )} {board?.generated_by === "rules+llm" && ( AI增强 )} @@ -371,14 +387,19 @@ function MissionControl({
-
+

{board?.market_regime ?? "等待市场状态"}

-

+

{board?.summary ?? "系统尚未生成今日作战结论。触发扫描后,将基于市场温度、板块主线、推荐生命周期和策略复盘生成操作框架。"}

+ {board?.trade_date && ( +
+ {isRealtimeBoard ? `分析日期 ${board.trade_date} · 今日实时优先` : `数据日期 ${board.trade_date}`} +
+ )}
@@ -387,23 +408,32 @@ function MissionControl({
-
+
- sector.sector_name).join(" / ") : "暂无"} extra={riskText || "暂无风险约束"} /> +
-
- {topSectors.length ? ( - topSectors.map((sector) => ( - - {sector.sector_name} - = 0 ? "text-red-400" : "text-emerald-400"}`}> - {sector.pct_change > 0 ? "+" : ""}{sector.pct_change.toFixed(2)}% - - - )) +
+
主线证据
+ {topSectorEvidence.length ? ( +
+ {topSectorEvidence.map((sector) => ( +
+
+ {sector.name} + = 0 ? "text-red-400" : "text-emerald-400"}`}> + {sector.pct > 0 ? "+" : ""}{sector.pct.toFixed(2)}% + +
+
+ {sector.breadth ? 涨跌 {sector.breadth} : null} + {sector.turnover != null ? 换手 {sector.turnover.toFixed(1)}% : null} +
+
+ ))} +
) : ( 暂无主线板块 )} @@ -443,8 +473,8 @@ function CompactDecision({ label, value, extra }: { label: string; value: string return (
{label}
-
{value}
- {extra ?
{extra}
: null} +
{value}
+ {extra ?
{extra}
: null}
); } @@ -455,6 +485,7 @@ function CompactMissionStock({ rec }: { rec: LatestResult["recommendations"][num "重点关注": "bg-amber-500/15 text-amber-400 border-amber-500/20", "观察": "bg-surface-3 text-text-muted border-border-default", }; + const aiConviction = rec.llm_score != null ? Math.round(rec.llm_score) : null; return (
-
{rec.score}
-
参考
+ {aiConviction != null ? ( + <> +
AI {aiConviction}/10
+
{rec.action_plan ?? "观察"}
+ + ) : ( + <> +
{rec.action_plan ?? "观察"}
+
参考分 {Math.round(rec.score)}
+ + )}
- {rec.trigger_condition ?? rec.reasons?.[0] ?? "等待触发条件确认"} + {rec.trigger_condition ?? rec.entry_timing ?? rec.reasons?.[0] ?? "等待触发条件确认"}
); diff --git a/frontend/src/app/(auth)/diagnose/page.tsx b/frontend/src/app/(auth)/diagnose/page.tsx index ee79a8e1..78ec2259 100644 --- a/frontend/src/app/(auth)/diagnose/page.tsx +++ b/frontend/src/app/(auth)/diagnose/page.tsx @@ -301,7 +301,7 @@ export default function DiagnosePage() {
正在分析中...
-
收集行情数据、技术指标、资金流向并生成分析报告
+
收集行情、板块和推荐归档,生成本次会诊结论
)} @@ -345,6 +345,15 @@ export default function DiagnosePage() {
+
+
会诊摘要
+
+ + + + +
+
@@ -506,3 +515,38 @@ function buildDiagnosisRisk(thesis: StockThesisResponse | null, loading: boolean } return "当前没有明确风险备注"; } + +function buildDiagnosisAction( + thesis: StockThesisResponse | null, + mode: "entry" | "holding" | "review" | "tracking", +) { + if (thesis?.recommendation?.action_plan === "可操作") { + return "仅在触发条件成立时执行,不提前交易。"; + } + if (thesis?.recommendation?.action_plan === "重点关注") { + return "继续跟踪板块和个股强度,等确认后再做动作。"; + } + if (mode === "holding") { + return "优先核对持仓逻辑、失效条件和风险暴露。"; + } + if (mode === "review") { + return "先定位问题出在市场环境、板块节奏还是个股执行。"; + } + return "当前以观察和补充证据为主。"; +} + +function buildDiagnosisNextStep( + thesis: StockThesisResponse | null, + mode: "entry" | "holding" | "review" | "tracking", +) { + if (thesis?.recommendation?.trigger_condition) { + return `重点盯住:${thesis.recommendation.trigger_condition}`; + } + if (mode === "tracking") { + return "继续观察是否进入可操作或重点关注状态。"; + } + if (mode === "holding") { + return "检查是否需要减仓、止损或继续持有。"; + } + return "结合下一个交易时段的量价和板块变化再判断。"; +} diff --git a/frontend/src/app/(auth)/recommendations/page.tsx b/frontend/src/app/(auth)/recommendations/page.tsx index 2cd2d374..af10af7d 100644 --- a/frontend/src/app/(auth)/recommendations/page.tsx +++ b/frontend/src/app/(auth)/recommendations/page.tsx @@ -158,6 +158,27 @@ export default function RecommendationsPage() {
+
+
Recommendation Logic
+
+ + + +
+
+ {opsStatus && (
@@ -333,8 +354,6 @@ export default function RecommendationsPage() { {/* Mobile stats inline after date */}
{filter === "all" ? group.count : filtered.length}只 - · - 参考{group.avg_score} {group.buy_count > 0 && ( <> · @@ -346,12 +365,6 @@ export default function RecommendationsPage() { {/* Desktop stats */}
-
- 参考均值 - - {group.avg_score} - -
买入 @@ -505,9 +518,9 @@ function RecommendationCommandCenter({
Recommendation Ops
-

推荐不是排行榜,是执行管线

+

推荐闭环

- 这里把 AI 选股结果拆成四个动作池:能操作的先盯触发,未确认的继续等待,已推荐的持续跟踪,结束后的样本进入策略迭代。 + 先看可操作和重点关注,再看跟踪结果,结束样本自动回流到策略迭代。

@@ -539,6 +552,28 @@ function RecommendationCommandCenter({ ); } +function GlossaryCard({ + label, + value, + description, +}: { + label: string; + value: string; + description: string; +}) { + return ( +
+
+
{label}
+
+ {value} +
+
+
{description}
+
+ ); +} + function PipelineMetric({ label, value, tone }: { label: string; value: number; tone?: "red" | "amber" | "cyan" }) { const color = tone === "red" ? "text-red-400" : tone === "amber" ? "text-amber-400" : tone === "cyan" ? "text-cyan-400" : "text-text-primary"; return ( diff --git a/frontend/src/app/(auth)/sectors/page.tsx b/frontend/src/app/(auth)/sectors/page.tsx index 3d370dcd..15cdc91c 100644 --- a/frontend/src/app/(auth)/sectors/page.tsx +++ b/frontend/src/app/(auth)/sectors/page.tsx @@ -110,6 +110,10 @@ function SectorDetailCard({ sector, index, factorScores }: { const displayPct = sector.realtime_pct_change ?? sector.pct_change; const isUp = displayPct > 0; const displayLimitUp = sector.realtime_limit_up_count ?? sector.limit_up_count; + const displayAmount = sector.realtime_amount ?? sector.capital_inflow; + const displayTurnover = sector.realtime_turnover_rate ?? sector.turnover_avg ?? 0; + const displayUpCount = sector.realtime_up_count; + const displayDownCount = sector.realtime_down_count; const leaders = sector.is_realtime ? (sector.leading_stocks_realtime?.length ? sector.leading_stocks_realtime : sector.leading_stocks) : sector.leading_stocks; @@ -186,24 +190,36 @@ function SectorDetailCard({ sector, index, factorScores }: { {/* Metrics row - 4 columns */}
-
资金净流入
-
0 ? "text-red-400" : "text-emerald-400"}`}> - {sector.capital_inflow > 0 ? "+" : ""}{formatNumber(sector.capital_inflow)} +
{sector.is_realtime ? "实时成交额" : "资金净流入"}
+
0 ? "text-red-400" : "text-emerald-400"}`}> + {displayAmount > 0 ? "+" : ""}{formatNumber(displayAmount)}
-
涨停股
-
- {displayLimitUp} -
+
{sector.is_realtime ? "上涨/下跌" : "涨停股"}
+ {sector.is_realtime && displayUpCount != null && displayDownCount != null ? ( +
+ {displayUpCount} / {displayDownCount} +
+ ) : ( +
+ {displayLimitUp} +
+ )}
-
主力占比
-
30 ? "text-amber-400" : mainForceRatio < 0 ? "text-red-400" : "text-text-secondary" - }`}> - {mainForceRatio.toFixed(1)}% -
+
{sector.is_realtime ? "实时换手" : "主力占比"}
+ {sector.is_realtime ? ( +
+ {displayTurnover.toFixed(1)}% +
+ ) : ( +
30 ? "text-amber-400" : mainForceRatio < 0 ? "text-red-400" : "text-text-secondary" + }`}> + {mainForceRatio.toFixed(1)}% +
+ )}
热度评分
@@ -344,6 +360,8 @@ export default function SectorsPage() { ); const hasRealtime = sectors.some((s) => s.is_realtime); + const structureTradeDate = sectors[0]?.structure_trade_date || sectors[0]?.trade_date || ""; + const dataMode = sectors[0]?.data_mode || "daily_snapshot"; const loadRotation = useCallback(async () => { try { @@ -405,8 +423,15 @@ export default function SectorsPage() {

板块主线

判断当前主线、板块阶段、资金持续性和领涨股强度 - {hasRealtime && · 实时数据} + {hasRealtime && · 盘中实时优先}

+ {hasRealtime && dataMode === "realtime_overlay" && ( +

+ 盘中模式下,涨幅、成交额、上涨/下跌家数与领涨股为实时覆盖;阶段、资金连续性等结构字段仍基于 + {structureTradeDate || "最近交易日"} + 的板块快照。 +

+ )}
{!sectors.length ? ( diff --git a/frontend/src/app/(auth)/stock/[code]/page.tsx b/frontend/src/app/(auth)/stock/[code]/page.tsx index 6f598793..60f6fbf6 100644 --- a/frontend/src/app/(auth)/stock/[code]/page.tsx +++ b/frontend/src/app/(auth)/stock/[code]/page.tsx @@ -162,6 +162,7 @@ export default function StockDetailPage() { const latestTracking = thesis?.latest_tracking; const latestFlow = capitalFlow.length > 0 ? capitalFlow[capitalFlow.length - 1] : null; const pageName = recommendation?.name || thesis?.name || quote?.name || code; + const aiConviction = recommendation?.llm_score != null ? Math.round(recommendation.llm_score) : null; return ( @@ -181,7 +182,7 @@ export default function StockDetailPage() {
- Stock Thesis + 个股作战卡 {recommendation?.action_plan ? ( {recommendation.action_plan} @@ -215,6 +216,12 @@ export default function StockDetailPage() {
{thesis?.data_freshness.message ?? "加载中"}
+
+ + + + +
推荐 {formatDateTime(thesis?.data_freshness.recommendation_created_at)} 跟踪 {thesis?.data_freshness.tracking_date || "暂无"} @@ -237,7 +244,7 @@ export default function StockDetailPage() {
- +
@@ -289,28 +296,54 @@ function PlanCard({ }) { return (
-
- - {recommendation?.llm_score != null ? ( - AI {recommendation.llm_score}/10 - ) : null} +
+ + {recommendation?.llm_score != null ? ( + AI 置信 {Math.round(recommendation.llm_score)}/10 + ) : null}
+ {recommendation ? : null} {trackingNote ? : null}
); } -function EvidenceCard({ recommendation }: { recommendation: RecommendationData | null | undefined }) { +function EvidenceCard({ + recommendation, + quote, + signals, +}: { + recommendation: RecommendationData | null | undefined; + quote: QuoteData | null; + signals: StockSignals | null; +}) { const reasons = recommendation?.reasons ?? []; + const evidenceChips = [ + recommendation?.entry_signal_type ? `信号 ${signalTypeLabel(recommendation.entry_signal_type)}` : null, + quote?.pct_chg != null ? `涨幅 ${quote.pct_chg > 0 ? "+" : ""}${quote.pct_chg.toFixed(2)}%` : null, + quote?.turnover_rate != null ? `换手 ${quote.turnover_rate.toFixed(2)}%` : null, + signals?.ma_bullish ? "均线多头" : null, + signals?.volume_breakout ? "放量突破" : null, + signals?.pullback_support ? "回踩支撑" : null, + ].filter(Boolean) as string[]; return (
- + + {evidenceChips.length ? ( +
+ {evidenceChips.slice(0, 6).map((item) => ( + + {item} + + ))} +
+ ) : null}
{reasons.length ? reasons.map((reason, index) => (
@@ -381,7 +414,7 @@ function TrackingCard({ tracking }: { tracking: StockThesisResponse["latest_trac function QuoteSnapshot({ quote, evidenceLoaded }: { quote: QuoteData | null; evidenceLoaded: boolean }) { return (
- + {quote ? ( <>
@@ -419,14 +452,14 @@ function SignalSnapshot({ }) { return (
- + {signals ? ( <> -
- - - - +
+ + + +
@@ -515,22 +548,6 @@ function TrackingMetric({ label, value }: { label: string; value: number | null ); } -function DimensionScore({ label, value }: { label: string; value: number }) { - const width = Math.max(0, Math.min(value, 100)); - const gradientClass = value >= 70 ? "score-bar-gradient-high" : value >= 50 ? "score-bar-gradient-mid" : "score-bar-gradient-low"; - return ( -
-
- {label} - {value.toFixed(0)} -
-
-
-
-
- ); -} - function SignalFlag({ label, active }: { label: string; active: boolean }) { return (
@@ -606,3 +623,28 @@ function formatFlowAmount(val: number): string { if (absVal >= 10000) return (val / 10000).toFixed(1) + "亿"; return val.toFixed(0) + "万"; } + +function signalTypeLabel(signalType?: string) { + const map: Record = { + breakout: "突破", + pullback: "回踩", + launch: "启动", + reversal: "反转", + breakout_confirm: "突破确认", + }; + return map[signalType || ""] ?? "观察"; +} + +function signalBias(signals: StockSignals, recScore: RecScore | null) { + const trend = recScore?.technical_score ?? signals.trend_score; + if (trend >= 75 && signals.volume_breakout) return "偏强,可等确认"; + if (trend >= 60) return "中性偏强"; + if (signals.pullback_support) return "等待支撑确认"; + return "仍需观察"; +} + +function positionComment(positionScore: number) { + if (positionScore >= 75) return "位置相对安全"; + if (positionScore >= 50) return "位置中性"; + return "位置偏高,防追高"; +} diff --git a/frontend/src/components/sector-heatmap.tsx b/frontend/src/components/sector-heatmap.tsx index 78bec50b..fec95c76 100644 --- a/frontend/src/components/sector-heatmap.tsx +++ b/frontend/src/components/sector-heatmap.tsx @@ -38,6 +38,10 @@ export default function SectorHeatmap({ sectors }: { sectors: SectorData[] }) { const isTop3 = index < 3; // 盘中使用实时涨停数 const displayLimitUp = s.realtime_limit_up_count ?? s.limit_up_count; + const displayAmount = s.realtime_amount ?? s.capital_inflow; + const displayBreadth = s.realtime_up_count != null && s.realtime_down_count != null + ? `${s.realtime_up_count}/${s.realtime_down_count}` + : null; // Bar width based on score relative to max const barWidth = `${Math.max(intensity * 100, 15)}%`; @@ -85,14 +89,19 @@ export default function SectorHeatmap({ sectors }: { sectors: SectorData[] }) {
- 0 ? "text-red-400" : "text-emerald-400"}`}> - {s.capital_inflow > 0 ? "+" : ""} - {formatNumber(s.capital_inflow)} + 0 ? "text-red-400" : "text-emerald-400"}`}> + {displayAmount > 0 ? "+" : ""} + {formatNumber(displayAmount)} {displayPct > 0 ? "+" : ""} {displayPct.toFixed(2)}% + {displayBreadth && ( + + {displayBreadth} + + )} {/* Heat score pill */} = { @@ -39,6 +40,9 @@ 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[]; return (
@@ -70,12 +74,13 @@ export default function StockCard({ rec }: { rec: RecommendationData }) {
-
- AI 决策 -
-
- {rec.action_plan ?? "观察"} -
+
结论
+
{rec.action_plan ?? "观察"}
+ {aiConviction != null ? ( +
+ AI {aiConviction}/10 +
+ ) : null}
@@ -108,6 +113,15 @@ export default function StockCard({ rec }: { rec: RecommendationData }) { {rec.review_after_days}日复盘 ) : null} + {aiConviction != null ? ( + + AI置信 {aiConviction}/10 + + ) : rec.score ? ( + + 参考分 {rec.score.toFixed(0)} + + ) : null} {rec.level} @@ -115,20 +129,25 @@ export default function StockCard({ rec }: { rec: RecommendationData }) {
)} -
-
-
证据维度
- - 参考 {rec.score.toFixed(0)} - + {evidence.length > 0 && ( +
+
核心证据
+
+ {evidence.map((item, index) => ( +
+ + {item} +
+ ))} +
+
+ 规则供需 {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)} +
-
- - - - -
-
+ )} {/* Price reference */} {rec.entry_price && ( @@ -193,10 +212,10 @@ export default function StockCard({ rec }: { rec: RecommendationData }) {
- 推演记录在详情页归档 - {rec.llm_score != null && ( + 详细推演在详情页归档 + {aiConviction != null && ( - AI {rec.llm_score}/10 + AI {aiConviction}/10 )}
@@ -215,25 +234,6 @@ export default function StockCard({ rec }: { rec: RecommendationData }) { ); } -function ScoreBar({ label, value, weight }: { label: string; value: number; weight?: string }) { - const width = Math.min(value, 100); - const gradientClass = value >= 70 ? "score-bar-gradient-high" : value >= 50 ? "score-bar-gradient-mid" : "score-bar-gradient-low"; - return ( -
-
- {label}{weight ? {weight} : null} - {value.toFixed(0)} -
-
-
-
-
- ); -} - function TrackingMetric({ label, value }: { label: string; value: number | null }) { const num = value ?? 0; const color = num > 0 ? "text-red-400" : num < 0 ? "text-emerald-400" : "text-text-secondary"; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a385523a..2c88df1f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -174,6 +174,7 @@ export interface SectorData { sector_code: string; sector_name: string; pct_change: number; + trade_date?: string; capital_inflow: number; limit_up_count: number; days_continuous: number; @@ -187,7 +188,13 @@ export interface SectorData { main_force_ratio?: number; realtime_pct_change?: number | null; realtime_limit_up_count?: number | null; + realtime_amount?: number | null; + realtime_turnover_rate?: number | null; + realtime_up_count?: number | null; + realtime_down_count?: number | null; is_realtime?: boolean; + data_mode?: "realtime_overlay" | "daily_snapshot"; + structure_trade_date?: string; } export interface LatestResult { @@ -272,6 +279,10 @@ export interface StrategySectorFocus { heat_score: number; pct_change: number; limit_up_count: number; + turnover_rate?: number; + up_count?: number; + down_count?: number; + data_mode?: string; view: string; } @@ -307,6 +318,7 @@ export interface StrategyIterationReport { export interface StrategyBoard { trade_date: string; + data_mode?: string; market_regime: string; risk_level: string; action_bias: string;