From bb9b3922c901a7cf215e3b062c1b96f4cae2e5b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=A5=E5=BE=AE?= Date: Fri, 3 Jul 2026 13:44:10 +0800 Subject: [PATCH] =?UTF-8?q?migrate:=20last=204=20JSON=20files=20=E2=80=94?= =?UTF-8?q?=20live=5Fprices,=20market,=20mtf=5Fcache,=20capital=5Fflow=20?= =?UTF-8?q?=E2=86=92=20DB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __pycache__/multi_timeframe.cpython-312.pyc | Bin 26437 -> 25827 bytes __pycache__/price_monitor.cpython-312.pyc | Bin 32504 -> 0 bytes .../strategy_lifecycle.cpython-312.pyc | Bin 108025 -> 107475 bytes _watchdog_report.py | 35 + data/candidate_pool.json | 196 ++- data/mofin.db-shm | Bin 32768 -> 0 bytes data/mofin.db-wal | Bin 8272 -> 0 bytes data/multi_tf_cache.json | 300 +++- data/portfolio.json | 146 +- data/price_events.json | 20 + data/price_history.json | 32 +- data_freshness.py | 144 +- market_watch.py | 444 +++--- mofin_db.py | 67 + multi_timeframe.py | 1282 +++++++++-------- price_monitor.py | 14 +- scripts/capital_flow_collector.py | 368 ++--- scripts/check_imports.py | 33 + scripts/check_prices.py | 15 + scripts/clean_watchlist.py | 6 +- scripts/diagnose_assets.py | 35 + scripts/quick_verify.py | 25 + scripts/test_api.py | 36 + scripts/test_interval.py | 22 + scripts/test_ratelimit.py | 49 + scripts/verify_pnl.py | 38 + scripts/xiaoguo_signal_consumer.py | 13 +- 27 files changed, 2064 insertions(+), 1256 deletions(-) delete mode 100644 __pycache__/price_monitor.cpython-312.pyc create mode 100644 _watchdog_report.py delete mode 100644 data/mofin.db-shm delete mode 100644 data/mofin.db-wal create mode 100644 scripts/check_imports.py create mode 100644 scripts/check_prices.py create mode 100644 scripts/diagnose_assets.py create mode 100644 scripts/quick_verify.py create mode 100644 scripts/test_api.py create mode 100644 scripts/test_interval.py create mode 100644 scripts/test_ratelimit.py create mode 100644 scripts/verify_pnl.py diff --git a/__pycache__/multi_timeframe.cpython-312.pyc b/__pycache__/multi_timeframe.cpython-312.pyc index 18d42c63b085a99722794190ffca185af6c640ac..6b983036aaef052296596ad1288ec3c92a6b886a 100644 GIT binary patch delta 3781 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 delta 4504 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 ak<@+eF#$e(1^jbj&P{BMC9>sW&;J2Hjzt0h diff --git a/__pycache__/price_monitor.cpython-312.pyc b/__pycache__/price_monitor.cpython-312.pyc deleted file mode 100644 index 8926ee0f2786a4a05e47262d8bd2e40e33c8b746..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32504 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

oz;&Q=iysZ&z6MTukw&SlR!4ACb5Il;vU4jn0 zbqe}0$}T}a{(4j}fH&iQ>;&BbK~S4Br7iNKZQ!J~49%2NU4y5FP7J{*(R3hZfnTuz z4mNOA`!&X3wvijcL0F{34r)Z6mSjN31tAPH#-c7#;6jD+P%)o@Xp z?Y2P9c%%b+V6`u+VWR4z>i4V9B%LvTuw{+P4|Hw=J;lQQz8KNc1u~ zCzVtr(E{(H_v+uSpJ)#(+~8ZV(U-AlQu*+V9BrDwoP6iV&f_*;=9WN8Ye2QtA-JT2 zkM~GrO(eVWIk%71!}$y;Y2ydS4!ZVw1Yn*tl~U zp1$*0i>H(F!^zbXf>@* z$-(A_o#H925lEmr4O#%caHH{SmjO8(Q(e?$2bZpQv`i^8{mM+&PH0VKsb`a4*%-_; zIyU@1L466$PvH|cjwY}ycji97n*HDn&?95W|jKNm-;f7L5!60TZAwr(J2JG zb~tVx=3aR4TfI#_ZP6qcxXKBsVOpB#tiB*kn-Ri~uWok&&6{nMf9H={fIFb*EXx7Y zD1lf5*6~iEv&C8KORx5+Yd&oKsO|l>3)0mWrRqO@u6pdN+r2^pj|w4yKxx^6eYtfL zx(T5#r*TTV|znP{)T2*hgfi-rfFWse6XT#n%|j0TuiJ6_g@vsou#6G%@d(px?I>fpSZL{ zW|j+oFC)=aiOneqzfZ&*f1jRfULyQlEbAy2f38mIC=q{NB1ZpwS!b&7^ZKMtz3>aY z82n#k$T~}eUlfz*LTID#i^U|koly12g^WYowB15hOgtrWP--Skf*)L!bYM{mZ~Jj; z#P$X@JnD%<(_*Jad+d(15n67r*YXgJ2J`Tru+redh$qP}MM6?^OG=Q+-x7x360+YC z^4}7Qe<8B|C?ViT&kIE7?}&pJh=ac)mivk2mvj#etAkNTyL0jD}74(tmY|=ll1A!CJE3)6p?BFB3cAroETm|BNdYRi|Vu^ zeQ+ou@rq>&WH9>9Sub!UpBJWG6iJ_13A9a={?*J510jXI7D=WB^{L-k zk8PI*x{ZWXhY$5Zb>eqc;Y0n`Gg|hc5MBmOCt4j66-B3LbNOZGQE6RnRmXYB856d1w@&Et; diff --git a/__pycache__/strategy_lifecycle.cpython-312.pyc b/__pycache__/strategy_lifecycle.cpython-312.pyc index bda4403a56266fb355310a73eb6e50123b5f39a0..bc5d3dbefe57d5484d1017b49ba643855d59189a 100644 GIT binary patch delta 893 zcmYL`T}V@57{}jd=bX9eHs&_B)M4!(S~uCs9Zg0lV*UvF8zZ=|ekq-9`ux{(tbInX+l-42AJA(1~10R4=_^k0Bb26U5ZKX{>+Jo3Z!q^ilF5FGTy7Vl@(YXq&J z>v=ZEOil=BG|itSuOOeE=Yn+|=G z`sl`^ep+q{v9*Vpv9I5*5_=RZ$w3C8{Uqx9#MUTGnH7JM$+-o10#5Pg0we*L$?wO| z67H~}4JgFC6>Zqen76PSO)430lnPmHm0%6#3l(qAa5A>hP?5}oViWGHMu$c-cB$Ay zhf&56Lz9+0%1DdjG?iNRI6ob`49eq1R)&OA=|2`$!bNmS89v*D9oS_>lGCn8u$W7* zyg4`Cw3?b5u$w_ro5hUX4$9KhINMQqf|Vxh$wg9!&5n5!Bk@K^&_Z{nH}h5&^ZC>l zs&QupC-G%60EF@@EV>hC?K#yJZl=WOZyBX?jjO5nnu{)14 zk9s*f)Yy|vS&@T1>5Sw_=i=FuOMNNLB_MNG<@Nfm3=Dg{cJSEYyND2P5Esr}& UA6U~KgV>Dyy=%>6`zaLv1=Mf%7XSbN delta 1282 zcmaiye@I(b6vyv*zf4jS5`RR^T03W}#vhw?{;F8zD%08xJCm49Iu=r2)+K5-UY?ff z%bwlnx75@NICuj%s56y^%D9Di&^g7Rlj^Dr)5jd5|@^ktSv5!ZOb>8 zxU2#s@2Qp;8Y=8F>^X1!|MQ|LW>c(kaV}G_E&;X4WElDj#E<6L*tvhVaGrbALb3hF z)m|aLN#KVuay$xSdF`=jWjY*9-2Qrhr;@Lj6^pn^%g-oCDwYrA`iq|^<~bymDEV~% zRI$+gvxU6OooEUmb%qqk;A4y8(_~zRgYXTB%aC9T;qaZW)&^~dTnIrX(1F5gAv)uN;l|FlC1Rmr7cqMOfV9Z0&-CF2V`PbVFo%<@2X%0 zi?mBSq&abN;x(xPNOBeiAVz+lrL)qS>NyXqY=uP(j)tP4S0hos7?1;L&Oaf)EKShT zl1#T~jHvB!D6#v?De}ezC^4xlLVxGcz^|#r3ovU}?-~$qyf|=v;O(*b<_|0@?ABw= zYb~2Yu5He}!x=J>(^n2%c|953DyaM7@b|FIowy?)doRom?iQEt*orQXB+7RjPh72k zuYT^ypB+bTJC1BN^=vtM|IFj;!tCH(Q?5m1UQBkaJiS%kwiev<3~zH@<}U*|JP%0- zq`sSnJb*^>(*pFVE)!aZD(vb;>(zGb>fqM93_89Q7~Q$ifb+52$o$a3>-Z6k*XX=g zUxYnn=zgIcd+NCRwS_t!uv9g24;;lAG*V|H_pq`Ids>8t$2c7tw$YppPQx`cN~u26 zg=?{=8)^McCd+z0%ch=+jB%okd!ZKJ2MsHBU8U*=@wbm8>>w zu;Srz%5pxdtBlxdqWUQ-_VTRe<@t>EnvK{8RNEiN-cHI+KBIjc_1jp@X9Y^4T!r`d zbbMW{l+Np%$MLk--lpBIy7aUCb@|vFhvv)ou{#obpGDro;%JLr zOMn0Y0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkL{*978!O(8&l009C7 v2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ;J*c4VoW8u diff --git a/data/mofin.db-wal b/data/mofin.db-wal deleted file mode 100644 index 382dc1a78d37be7c3644063bdd920d210981adf9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8272 zcmeHLZ%`Cv7+?8!aKN3TGzX3-TK*wBdw1_{Z|_Qc(E^-N3*jWq9Llnp{y~1xY&1m? zK>@+DI1C&_K{GfI2|VK{n`v~a4^1CvnrSBWcJEG``jSl_G<){l+dH^hoZ?gE?l7>o z$NPJp_xJps-@Efz-%F~ZjMp!B%+ebc zOib2n<$S{}!RF)s?k1sbj}mmI2)yiVoDINphS!PEV^mHAHTJGxdUPC?V{O4>qruL0 zzPi@$Ii(b5YY_>6U(2}-l;lF3#90&qYz6d0n@K~{tU)!sm_TWIJ8QkBx# z-uE?J-{2iq*nQlu~-QS?AYK97fYE6Ueq08%=~U_F&TlWZ$4+yW+qEM|_cZ{-h8 zE-UH=u6R|>LGv&*KfEEuo8_5umeWFg#sF8o;Tq7b@6)}$;Z`N;sKrD|age6w^i;J} zppip|TO4sAg2b}dHzGpS+6FMfR4rR7RD>#F*$1Dt~c+@2yorEfCG( z_7@`U`4x6abYKLfXm8Y=70=;^x8aq6aotEAu<_FYh zyQu|}(-Hb+EhC4H3#k;*CS>EiLqbaffAFkQ=tIR)=(;Zn2(EjcY$SdO1jTd=88(g! zwu}aA4)a47;76H^(jBseQq=PKvN%G7LZwVdR2uk=@IenWX*ew=2ifclV`o_0Y!Ef^ zt|*GaY#AX`Yw#^IoJxa3(Bs0Mw($t#PPocUskRjomw=Rdo|<=G4oe=@XF>7f!li>q z9Abp?M}*^D(&1-xgtLvriVNTaSICxejf*G=AJ`+iWl3%n$GflPQPc=f98-f zXanA_fo{9pi1!aj&AjhB+4b@k#G>c~&E$b7*)KL3V`V-oRCNq$6hE8cB;)tG9|5l&p-4>T?$G zCh;jIhoDSw{khz-$ySm?3Miih6H86Fk=`8T~1sU@9vkZ zZ`T^~7l2F5`mF4UhJ~y&T~g}vd>lC17N#Mhcl;31p%!O|Gy~7!&(d=MeFEtXq6>nK z= 5: # 周六日 - return False, "weekend" - t = now.hour * 60 + now.minute - if 9*60+30 <= t <= 15*60: - return True, "trading" - return False, "closed" - - -def check_fresh(): - """返回 (ok: bool, msg: str)""" - now = datetime.now() - - # 先看是不是交易日 - in_market, period = is_market_hours() - - max_age_min = 5 if in_market else 120 - - # 主指标:live_prices.json - if os.path.exists(LIVE_PRICES_PATH): - try: - lp = json.load(open(LIVE_PRICES_PATH)) - lp_time = lp.get("updated_at", "") - if not lp_time: - return False, "live_prices.json updated_at 为空" - lp_dt = datetime.fromisoformat(lp_time) - age = (now - lp_dt).total_seconds() / 60 - if age > max_age_min: - return False, f"live_prices.json 已 {age:.0f} 分钟未更新(阈值 {max_age_min} 分钟)" - return True, f"数据新鲜({age:.0f} 分钟前)" - except Exception as e: - return False, f"live_prices.json 读取失败: {e}" - else: - # fallback: portfolio.json - if os.path.exists(PORTFOLIO_PATH): - try: - pf = mo_data.read_portfolio() - pf_time = pf.get("updated_at", "") - if not pf_time: - return False, "portfolio.json updated_at 为空" - pf_dt = datetime.fromisoformat(pf_time) - age = (now - pf_dt).total_seconds() / 60 - if age > max_age_min: - return False, f"portfolio.json 已 {age:.0f} 分钟未更新(阈值 {max_age_min} 分钟)" - return True, f"数据新鲜(portfolio.json {age:.0f} 分钟前)" - except Exception as e: - return False, f"portfolio.json 读取失败: {e}" - return False, "live_prices.json 和 portfolio.json 均不存在" - - -if __name__ == "__main__": - ok, msg = check_fresh() - print(f"{'✅' if ok else '❌'} {msg}") +#!/usr/bin/env python3 +"""data_freshness.py — 数据新鲜度校验 + +所有报告管道在生成输出前必须调用 check_fresh()。 +返回 (pass: bool, details: str),如果数据过期则阻止生成操作建议。 + +用法: + from data_freshness import check_fresh + ok, msg = check_fresh() + if not ok: + print(f"⚠️ 数据过期: {msg}") + sys.exit(0) # 不生成报告 + +校验规则: +- 盘中 (9:30~15:00):price/live_prices.json 必须在 5 分钟内刷新 +- 盘后 (9:30以前/15:00以后):允许最长 120 分钟 +- 周末/节假日:跳过校验 +""" + +import json, os +from datetime import datetime, timedelta +from mo_data import read_portfolio, read_decisions, read_watchlist + + +# live_prices.json 已废弃,所有数据走 DB +LIVE_PRICES_PATH = "/home/hmo/web-dashboard/data/live_prices.json" + + +def is_market_hours(): + now = datetime.now() + if now.weekday() >= 5: + return False, "weekend" + t = now.hour * 60 + now.minute + if 9*60+30 <= t <= 15*60: + return True, "trading" + return False, "closed" + + +def check_fresh(): + """返回 (ok: bool, msg: str)""" + now = datetime.now() + in_market, period = is_market_hours() + max_age_min = 5 if in_market else 120 + + # 主指标:DB portfolio_summary.updated_at + try: + pf = read_portfolio() + pf_time = pf.get("updated_at", "") + if pf_time: + pf_dt = datetime.fromisoformat(pf_time) + age = (now - pf_dt).total_seconds() / 60 + if age > max_age_min: + return False, f"数据已 {age:.0f} 分钟未更新(阈值 {max_age_min} 分钟)" + return True, f"数据新鲜({age:.0f} 分钟前)" + except Exception as e: + return False, f"DB 读取失败: {e}" + + return False, "无法获取数据新鲜度" + + +if __name__ == "__main__": + ok, msg = check_fresh() + print(f"{'✅' if ok else '❌'} {msg}") diff --git a/market_watch.py b/market_watch.py index 1b705ee..7c80389 100644 --- a/market_watch.py +++ b/market_watch.py @@ -1,223 +1,221 @@ -#!/usr/bin/env python3 -"""market_watch.py — 行業熱點數據採集,寫入 dashboard data/market.json - -數據源優先級: - 後端A:東方財富 push2 API(首選,有板塊代碼+實時指數) - 後端B:同花順 THS / akshare(降級,有漲跌家數+資金流向) - -注意:當前服務器無法連通東方財富API(已被封禁/域名不可達), -實際運行時自動降級到同花順 THS 後端。THS 提供90+行業板塊的 -實時漲跌、上漲/下跌家數、淨流入資金等數據,足以滿足需求。 - -輸出:data/market.json → MoFin Dashboard 市場數據展示 -""" - -import json -from datetime import datetime -from pathlib import Path - -from mofin_db import get_conn, init_all_tables, write_market_snapshot - -DATA_DIR = Path(__file__).parent / "data" - - -# ── 後端A:東方財富 push2 API(首選,有板塊代碼+實時指數) ── - -def _fetch_em(url): - """通用 EM API 請求""" - import urllib.request - req = urllib.request.Request( - url, - headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} - ) - resp = urllib.request.urlopen(req, timeout=10) - return json.loads(resp.read().decode("utf-8")) - - -def fetch_sector_em(): - """東方財富行業板塊""" - try: - data = _fetch_em( - "https://push2.eastmoney.com/api/qt/clist/get?" - "pn=1&pz=60&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:2" - ) - return [{ - "name": i["f14"], - "code": i["f12"], - "price": i.get("f2", 0), - "change": i.get("f3", 0), - } for i in data.get("data", {}).get("diff", [])] - except Exception: - return None - - -def fetch_concept_em(): - """東方財富概念板塊""" - try: - data = _fetch_em( - "https://push2.eastmoney.com/api/qt/clist/get?" - "pn=1&pz=30&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:3" - ) - return [{ - "name": i["f14"], - "code": i["f12"], - "change": i.get("f3", 0), - } for i in data.get("data", {}).get("diff", [])] - except Exception: - return None - - -# ── 後端B:同花順 THS / akshare(降級) ── - -def fetch_sector_ths(): - """THS 行業板塊(含漲跌家數、資金流向、領漲股)""" - try: - import akshare as ak - df = ak.stock_board_industry_summary_ths() - return [{ - "name": r["板块"], - "code": "", - "price": 0, - "change": float(r.get("涨跌幅", 0)), - "volume": float(r.get("总成交量", 0)), - "turnover": float(r.get("总成交额", 0)), - "net_inflow": float(r.get("净流入", 0)), - "up_count": int(r.get("上涨家数", 0)), - "down_count": int(r.get("下跌家数", 0)), - "avg_price": float(r.get("均价", 0)), - "lead_stock": r.get("领涨股", ""), - "lead_stock_change": float(r.get("领涨股-涨跌幅", 0)), - } for _, r in df.iterrows()] - except Exception as e: - print(f"THS行業失敗: {e}", flush=True) - return [] - - -def fetch_concept_ths(): - """THS 概念板塊(僅名稱,無實時漲跌)""" - try: - import akshare as ak - df = ak.stock_board_concept_name_ths() - return [{ - "name": r["name"], - "code": str(r.get("code", "")), - "change": 0, - } for _, r in df.iterrows()] - except Exception as e: - print(f"THS概念失敗: {e}", flush=True) - return [] - - -# ── 輔助函數 ── - -def get_market_mood(sectors): - if not sectors: - return "unknown" - ratio = sum(1 for s in sectors if s.get("change", 0) > 0) / len(sectors) - return "bullish" if ratio > 0.7 else "neutral" if ratio > 0.4 else "bearish" - - -def get_market_verdict(up_ratio, mood, sectors): - """Return (verdict, reason) based on sector data.""" - if not sectors: - return "unknown", "数据不足" - if up_ratio < 25: - return "弱势", f"仅{up_ratio}%板块上涨,{mood}" - elif up_ratio < 40: - return "偏弱", f"{up_ratio}%板块上涨,结构分化" - elif up_ratio < 60: - return "均衡", f"{up_ratio}%板块上涨,涨跌均衡" - else: - return "强势", f"{up_ratio}%板块上涨,整体走强" - - -def get_hot_sectors(sectors, top_n=3): - """Return sectors with highest positive change as hot sectors.""" - hot = [s for s in sectors if s.get("change", 0) > 1.0] - hot.sort(key=lambda s: s.get("change", 0), reverse=True) - return [{ - "name": s["name"], - "change": s.get("change", 0), - "reason": f"板块涨{s.get('change',0):.1f}%" - } for s in hot[:top_n]] - - -def get_danger_sectors(sectors, top_n=3): - """Return sectors with lowest (negative) change as danger sectors.""" - danger = [s for s in sectors if s.get("change", 0) < -1.0] - danger.sort(key=lambda s: s.get("change", 0)) - return [{ - "name": s["name"], - "change": s.get("change", 0), - "reason": f"板块跌{s.get('change',0):.1f}%" - } for s in danger[:top_n]] - - -# ── 主流程 ── - -def main(): - # 行業板塊:EM → THS → 兜底 - sectors = fetch_sector_em() - source = "eastmoney" - if sectors is None: - sectors = fetch_sector_ths() - source = "ths" - - # 概念板塊:EM → THS → 空 - concepts = fetch_concept_em() - concept_source = "eastmoney" - if concepts is None: - concepts = fetch_concept_ths() - concept_source = "ths" - if not concepts: - concepts = [] - concept_source = "unavailable" - - # 排序 - sorted_sectors = sorted(sectors, key=lambda s: s.get("change", 0), reverse=True) - top_gainers = [s for s in sorted_sectors if s.get("change", 0) > 0][:5] - top_losers = [s for s in reversed(sorted_sectors) if s.get("change", 0) < 0][:3] - - # 计算大盘数据 - up_ratio = round( - sum(1 for s in sectors if s.get("change", 0) > 0) / max(len(sectors), 1) * 100, 1 - ) - mood = get_market_mood(sectors) - verdict, verdict_reason = get_market_verdict(up_ratio, mood, sectors) - - market_data = { - "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), - "source": source, - "concept_source": concept_source, - "total_sectors": len(sectors), - "up_ratio": up_ratio, - "mood": mood, - "market_verdict": verdict, - "verdict_reason": verdict_reason, - "hot_sectors": get_hot_sectors(sectors), - "danger_sectors": get_danger_sectors(sectors), - "top_gainers": top_gainers, - "top_losers": top_losers, - "sectors": sectors, - "concepts": concepts, - } - - DATA_DIR.mkdir(parents=True, exist_ok=True) - with open(DATA_DIR / "market.json", "w", encoding="utf-8") as f: - json.dump(market_data, f, ensure_ascii=False, indent=2) - - # ── SQLite 双写 ── - conn = get_conn() - init_all_tables(conn) - ok, msg, sid = write_market_snapshot(conn, market_data) - if ok: - print(f"[DB] {msg}", flush=True) - else: - print(f"[DB] 写入失败(JSON 不受影响): {msg}", flush=True) - conn.close() - - # 靜默:只寫文件,不輸出到stdout,避免cron推送 - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +"""market_watch.py — 行業熱點數據採集,寫入 dashboard data/market.json + +數據源優先級: + 後端A:東方財富 push2 API(首選,有板塊代碼+實時指數) + 後端B:同花順 THS / akshare(降級,有漲跌家數+資金流向) + +注意:當前服務器無法連通東方財富API(已被封禁/域名不可達), +實際運行時自動降級到同花順 THS 後端。THS 提供90+行業板塊的 +實時漲跌、上漲/下跌家數、淨流入資金等數據,足以滿足需求。 + +輸出:data/market.json → MoFin Dashboard 市場數據展示 +""" + +import json +from datetime import datetime +from pathlib import Path + +from mofin_db import get_conn, init_all_tables, write_market_snapshot + +DATA_DIR = Path(__file__).parent / "data" + + +# ── 後端A:東方財富 push2 API(首選,有板塊代碼+實時指數) ── + +def _fetch_em(url): + """通用 EM API 請求""" + import urllib.request + req = urllib.request.Request( + url, + headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} + ) + resp = urllib.request.urlopen(req, timeout=10) + return json.loads(resp.read().decode("utf-8")) + + +def fetch_sector_em(): + """東方財富行業板塊""" + try: + data = _fetch_em( + "https://push2.eastmoney.com/api/qt/clist/get?" + "pn=1&pz=60&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:2" + ) + return [{ + "name": i["f14"], + "code": i["f12"], + "price": i.get("f2", 0), + "change": i.get("f3", 0), + } for i in data.get("data", {}).get("diff", [])] + except Exception: + return None + + +def fetch_concept_em(): + """東方財富概念板塊""" + try: + data = _fetch_em( + "https://push2.eastmoney.com/api/qt/clist/get?" + "pn=1&pz=30&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:3" + ) + return [{ + "name": i["f14"], + "code": i["f12"], + "change": i.get("f3", 0), + } for i in data.get("data", {}).get("diff", [])] + except Exception: + return None + + +# ── 後端B:同花順 THS / akshare(降級) ── + +def fetch_sector_ths(): + """THS 行業板塊(含漲跌家數、資金流向、領漲股)""" + try: + import akshare as ak + df = ak.stock_board_industry_summary_ths() + return [{ + "name": r["板块"], + "code": "", + "price": 0, + "change": float(r.get("涨跌幅", 0)), + "volume": float(r.get("总成交量", 0)), + "turnover": float(r.get("总成交额", 0)), + "net_inflow": float(r.get("净流入", 0)), + "up_count": int(r.get("上涨家数", 0)), + "down_count": int(r.get("下跌家数", 0)), + "avg_price": float(r.get("均价", 0)), + "lead_stock": r.get("领涨股", ""), + "lead_stock_change": float(r.get("领涨股-涨跌幅", 0)), + } for _, r in df.iterrows()] + except Exception as e: + print(f"THS行業失敗: {e}", flush=True) + return [] + + +def fetch_concept_ths(): + """THS 概念板塊(僅名稱,無實時漲跌)""" + try: + import akshare as ak + df = ak.stock_board_concept_name_ths() + return [{ + "name": r["name"], + "code": str(r.get("code", "")), + "change": 0, + } for _, r in df.iterrows()] + except Exception as e: + print(f"THS概念失敗: {e}", flush=True) + return [] + + +# ── 輔助函數 ── + +def get_market_mood(sectors): + if not sectors: + return "unknown" + ratio = sum(1 for s in sectors if s.get("change", 0) > 0) / len(sectors) + return "bullish" if ratio > 0.7 else "neutral" if ratio > 0.4 else "bearish" + + +def get_market_verdict(up_ratio, mood, sectors): + """Return (verdict, reason) based on sector data.""" + if not sectors: + return "unknown", "数据不足" + if up_ratio < 25: + return "弱势", f"仅{up_ratio}%板块上涨,{mood}" + elif up_ratio < 40: + return "偏弱", f"{up_ratio}%板块上涨,结构分化" + elif up_ratio < 60: + return "均衡", f"{up_ratio}%板块上涨,涨跌均衡" + else: + return "强势", f"{up_ratio}%板块上涨,整体走强" + + +def get_hot_sectors(sectors, top_n=3): + """Return sectors with highest positive change as hot sectors.""" + hot = [s for s in sectors if s.get("change", 0) > 1.0] + hot.sort(key=lambda s: s.get("change", 0), reverse=True) + return [{ + "name": s["name"], + "change": s.get("change", 0), + "reason": f"板块涨{s.get('change',0):.1f}%" + } for s in hot[:top_n]] + + +def get_danger_sectors(sectors, top_n=3): + """Return sectors with lowest (negative) change as danger sectors.""" + danger = [s for s in sectors if s.get("change", 0) < -1.0] + danger.sort(key=lambda s: s.get("change", 0)) + return [{ + "name": s["name"], + "change": s.get("change", 0), + "reason": f"板块跌{s.get('change',0):.1f}%" + } for s in danger[:top_n]] + + +# ── 主流程 ── + +def main(): + # 行業板塊:EM → THS → 兜底 + sectors = fetch_sector_em() + source = "eastmoney" + if sectors is None: + sectors = fetch_sector_ths() + source = "ths" + + # 概念板塊:EM → THS → 空 + concepts = fetch_concept_em() + concept_source = "eastmoney" + if concepts is None: + concepts = fetch_concept_ths() + concept_source = "ths" + if not concepts: + concepts = [] + concept_source = "unavailable" + + # 排序 + sorted_sectors = sorted(sectors, key=lambda s: s.get("change", 0), reverse=True) + top_gainers = [s for s in sorted_sectors if s.get("change", 0) > 0][:5] + top_losers = [s for s in reversed(sorted_sectors) if s.get("change", 0) < 0][:3] + + # 计算大盘数据 + up_ratio = round( + sum(1 for s in sectors if s.get("change", 0) > 0) / max(len(sectors), 1) * 100, 1 + ) + mood = get_market_mood(sectors) + verdict, verdict_reason = get_market_verdict(up_ratio, mood, sectors) + + market_data = { + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), + "source": source, + "concept_source": concept_source, + "total_sectors": len(sectors), + "up_ratio": up_ratio, + "mood": mood, + "market_verdict": verdict, + "verdict_reason": verdict_reason, + "hot_sectors": get_hot_sectors(sectors), + "danger_sectors": get_danger_sectors(sectors), + "top_gainers": top_gainers, + "top_losers": top_losers, + "sectors": sectors, + "concepts": concepts, + } + + DATA_DIR.mkdir(parents=True, exist_ok=True) + + # ── SQLite 写入(替代 market.json)── + conn = get_conn() + init_all_tables(conn) + ok, msg, sid = write_market_snapshot(conn, market_data) + if ok: + print(f"[DB] {msg}", flush=True) + else: + print(f"[DB] 写入失败(JSON 不受影响): {msg}", flush=True) + conn.close() + + # 靜默:只寫文件,不輸出到stdout,避免cron推送 + + +if __name__ == "__main__": + main() diff --git a/mofin_db.py b/mofin_db.py index f746234..b298e66 100644 --- a/mofin_db.py +++ b/mofin_db.py @@ -387,6 +387,28 @@ def init_all_tables(conn: sqlite3.Connection): last_scanned_at TEXT, found_count INTEGER DEFAULT 0 ); + + -- 实时价格快照(替代 live_prices.json) + CREATE TABLE IF NOT EXISTS live_prices ( + code TEXT PRIMARY KEY, + price REAL, + change_pct REAL, + updated_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- 多周期缓存(替代 multi_tf_cache.json) + CREATE TABLE IF NOT EXISTS mtf_cache ( + code TEXT PRIMARY KEY, + cache_json TEXT, + updated_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- 资金流缓存(替代 capital_flow_cache.json) + CREATE TABLE IF NOT EXISTS capital_flow_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cache_json TEXT, + updated_at TEXT DEFAULT (datetime('now','localtime')) + ); """) conn.commit() @@ -1120,3 +1142,48 @@ def query_cash_log(conn, limit: int = 20) -> list[dict]: "SELECT * FROM cash_log ORDER BY id DESC LIMIT ?", (limit,) ).fetchall() return [dict(r) for r in rows] + + +# ═══ live_prices / mtf_cache / capital_flow_cache 写函数 ═══ + +def write_live_prices(conn, prices: dict): + """写入实时价格快照(替代 live_prices.json)""" + import json + for code, info in prices.items(): + conn.execute( + "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) VALUES (?,?,?,datetime('now','localtime'))", + (code, info.get('price'), info.get('change_pct')) + ) + +def read_live_prices(conn) -> dict: + rows = conn.execute("SELECT code, price, change_pct FROM live_prices").fetchall() + return {r['code']: {'price': r['price'], 'change_pct': r['change_pct']} for r in rows} + + +def write_mtf_cache(conn, code: str, data: dict): + """写入多周期缓存(替代 multi_tf_cache.json 单条)""" + import json + conn.execute( + "INSERT OR REPLACE INTO mtf_cache (code, cache_json, updated_at) VALUES (?,?,datetime('now','localtime'))", + (code, json.dumps(data, ensure_ascii=False)) + ) + +def read_mtf_cache(conn, code: str) -> dict: + import json + r = conn.execute("SELECT cache_json FROM mtf_cache WHERE code=?", (code,)).fetchone() + return json.loads(r['cache_json']) if r else {} + + +def write_capital_flow_cache(conn, data: dict): + """写入资金流缓存(替代 capital_flow_cache.json)""" + import json + conn.execute("DELETE FROM capital_flow_cache") + conn.execute( + "INSERT INTO capital_flow_cache (cache_json, updated_at) VALUES (?,datetime('now','localtime'))", + (json.dumps(data, ensure_ascii=False),) + ) + +def read_capital_flow_cache(conn) -> dict: + import json + r = conn.execute("SELECT cache_json FROM capital_flow_cache ORDER BY id DESC LIMIT 1").fetchone() + return json.loads(r['cache_json']) if r else {} diff --git a/multi_timeframe.py b/multi_timeframe.py index d619adb..69f4291 100644 --- a/multi_timeframe.py +++ b/multi_timeframe.py @@ -1,640 +1,642 @@ -#!/usr/bin/env python3 -"""multi_timeframe.py — 多周期技术分析模块 - -从腾讯API获取日/周/月K线数据,计算: -- 多周期支撑压力位(日线/周线/月线) -- 移动均线(MA5/10/20/60) -- 趋势方向判断(上升/下降/震荡) -- 综合策略调整建议 - -集成到 strategy_lifecycle.py 中使用。 -""" - -import json -import os -import urllib.request -import urllib.error -from datetime import datetime, date, timedelta -from typing import Optional - -DATA_DIR = "/home/hmo/web-dashboard/data" -HISTORY_PATH = os.path.join(DATA_DIR, "price_history.json") -MTF_CACHE_PATH = os.path.join(DATA_DIR, "multi_tf_cache.json") # 多周期缓存独立存储 - -# 腾讯API K线端点 -KLINE_URL = "http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={market}{code},{period},,,{count},qfq" - -# 腾讯实时行情端点(用于市场前缀判断) -QUOTE_URL = "http://qt.gtimg.cn/q={market}{code}" - - -def _write_klines_to_db(code: str, daily: list, weekly: list, monthly: list, fundamentals: dict = None): - """K线数据双写 SQLite(失败不影响缓存写入)""" - try: - from mofin_db import get_conn, init_all_tables, write_klines - conn = get_conn() - init_all_tables(conn) - # 从 stock_profiles.json 获取名称 - name = code - try: - import json - profiles_path = os.path.join(DATA_DIR, "stock_profiles.json") - if os.path.exists(profiles_path): - with open(profiles_path, encoding="utf-8") as f: - profiles = json.load(f) - for p in profiles.get("profiles", []): - if p.get("code") == code: - name = p.get("name", code) - break - except Exception: - pass - write_klines(conn, code, name, daily, weekly, monthly, fundamentals) - conn.close() - except Exception: - pass # SQLite 写入失败不影响主流程 - - -def _market_prefix(code: str) -> str: - """根据代码确定市场前缀""" - raw = str(code).split("_")[0] - # 指数代码:sh/sz/hk开头 - if raw.startswith("sh"): - return "sh" - if raw.startswith("sz"): - return "sz" - if raw.startswith("hk"): - return "hk" - if len(raw) == 5 and raw.isdigit(): - return "hk" - if raw.startswith("6") or raw.startswith("5"): - return "sh" - return "sz" - - -def _user_agent() -> dict: - return { - "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" - } - - -# 多周期缓存TTL(秒):日K线1小时,周/月K线1天 -_KLINE_CACHE_TTL = {"day": 3600, "week": 86400, "month": 86400} - -# 模块级缓存:避免每次fetch_kline都重新读/写大文件 -_MTF_CACHE_DATA = None # {code: {daily:[], weekly:[], monthly:[], updated_at: float, fundamentals:{}}} -_MTF_CACHE_MTIME = 0 # 文件最后修改时间 - - -def _load_mtf_cache(): - """加载多周期缓存(带模块级缓存,避免频繁读盘)""" - global _MTF_CACHE_DATA, _MTF_CACHE_MTIME - import time - try: - current_mtime = os.path.getmtime(MTF_CACHE_PATH) - if _MTF_CACHE_DATA is not None and current_mtime == _MTF_CACHE_MTIME: - return _MTF_CACHE_DATA - with open(MTF_CACHE_PATH) as f: - _MTF_CACHE_DATA = json.load(f) - _MTF_CACHE_MTIME = current_mtime - return _MTF_CACHE_DATA - except (FileNotFoundError, json.JSONDecodeError, OSError): - _MTF_CACHE_DATA = {} - _MTF_CACHE_MTIME = 0 - return {} - - -def _save_mtf_cache(): - """将模块级缓存写回磁盘""" - global _MTF_CACHE_DATA, _MTF_CACHE_MTIME - if _MTF_CACHE_DATA is None: - return - try: - os.makedirs(os.path.dirname(MTF_CACHE_PATH), exist_ok=True) - with open(MTF_CACHE_PATH, "w") as f: - json.dump(_MTF_CACHE_DATA, f, ensure_ascii=False, indent=2) - import time - _MTF_CACHE_MTIME = os.path.getmtime(MTF_CACHE_PATH) if os.path.exists(MTF_CACHE_PATH) else time.time() - except Exception: - pass - - -def fetch_kline(code: str, period: str = "day", count: int = 120) -> list: - """从腾讯API获取K线数据,优先使用本地缓存 - - Args: - code: 股票代码 (如 "300548") - period: "day" / "week" / "month" - count: 需要多少条 - - Returns: - list of dict: [{"date":str, "open":float, "close":float, - "high":float, "low":float, "volume":float}, ...] - """ - import time - now = time.time() - - # 优先检查本地缓存(模块级,避免重复读盘) - # 注意:缓存中存储的key是'daily'/'weekly'/'monthly',参数period是'day'/'week'/'month' - _PERIOD_MAP = {"day": "daily", "week": "weekly", "month": "monthly"} - cache_data = _load_mtf_cache() - cached = cache_data.get(code, {}) - cache_key = _PERIOD_MAP.get(period, period) - cached_klines = cached.get(cache_key, cached.get(period, [])) - updated_at = cached.get("updated_at", 0) - if cached_klines and updated_at and (now - updated_at) < _KLINE_CACHE_TTL.get(period, 3600): - return cached_klines - - market = _market_prefix(code) - is_index = any(code.startswith(p) for p in ["sh", "sz", "hk"]) - - # 指数代码已经自带前缀,API直接用code;普通股票需要加market前缀 - api_code = code if is_index else f"{market}{code}" - url = f"http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={api_code},{period},,,{count},qfq" - - try: - req = urllib.request.Request(url, headers=_user_agent()) - with urllib.request.urlopen(req, timeout=10) as resp: - raw = json.loads(resp.read().decode("utf-8")) - except Exception as e: - return {"error": str(e), "code": code, "period": period} - - if not isinstance(raw, dict): - return {"error": f"API returned {type(raw).__name__}", "raw": str(raw)[:200]} - - api_data = raw.get("data", {}) - if not isinstance(api_data, dict): - return {"error": f"data field is {type(api_data).__name__}", "raw": str(api_data)[:200]} - - # 指数代码已经自带前缀(sh000001/sz399001),直接用 - # 普通股票代码需要加market前缀(sh600036/sz300750) - is_index = any(code.startswith(p) for p in ["sh", "sz", "hk"]) - stock_key = code if is_index else f"{market}{code}" - stock_data = api_data.get(stock_key, {}) - - # 腾讯API的K线字段名: qfqday, qfqweek, qfqmonth - period_key = f"qfq{period}" - klines = stock_data.get(period_key, []) - - if not klines: - # 尝试其他字段名 - for k in stock_data: - if isinstance(stock_data[k], list) and len(stock_data[k]) > 0: - if isinstance(stock_data[k][0], list) and len(stock_data[k][0]) >= 6: - klines = stock_data[k] - break - - result = [] - for k in klines: - if len(k) >= 6: - try: - result.append({ - "date": str(k[0]), - "open": float(k[1]), - "close": float(k[2]), - "high": float(k[3]), - "low": float(k[4]), - "volume": float(k[5]), - }) - except (ValueError, IndexError): - continue - - return result - - -def calc_moving_averages(klines: list, windows: list = [5, 10, 20, 60]) -> dict: - """计算移动均线 - - Args: - klines: K线数据(按时间正序或倒序均可,自动处理) - windows: 均线周期列表 - - Returns: - dict: {ma5: float|None, ma10: float|None, ...} - """ - if not klines: - return {f"ma{w}": None for w in windows} - - # 确保按时间正序(旧的在前) - closes = [k["close"] for k in klines] - # 检查是否倒序(最新的在前) - if len(closes) >= 2 and closes[0] > closes[-1]: - closes = list(reversed(closes)) - - result = {} - for w in windows: - if len(closes) >= w: - result[f"ma{w}"] = round(sum(closes[-w:]) / w, 2) - else: - result[f"ma{w}"] = None - return result - - -def calc_multi_tf_support_resistance(klines: list, lookback: int = 0) -> dict: - """基于K线数据计算多周期支撑压力位 - - 使用近期高点和低点作为关键位: - - 强阻力 = 近期最高(或倒数第二高) - - 弱阻力 = 近期中枢上沿 - - 弱支撑 = 近期中枢下沿 - - 强支撑 = 近期最低(或倒数第二低) - - Args: - klines: K线数据 - lookback: 取最近多少条(0=全部) - - Returns: - dict: {strong_resist, weak_resist, weak_support, strong_support, - high_52w, low_52w, range_pct} - """ - if not klines or len(klines) < 3: - return {} - - # 取最近N条(日线看近期,周线/月线看全部) - if lookback <= 0: - lookback = min(len(klines), 20) # 日线默认20天 - n = min(len(klines), lookback) - recent = klines[-n:] - - # 全量数据(用于52周高低) - all_highs = [k["high"] for k in klines] - all_lows = [k["low"] for k in klines] - - highs = [k["high"] for k in recent] - lows = [k["low"] for k in recent] - - max_h = max(highs) - min_l = min(lows) - mid = (max_h + min_l) / 2 - - # 找第二高和第二低作为更稳健的边界 - sorted_h = sorted(set(highs), reverse=True) - sorted_l = sorted(set(lows)) - - strong_resist = sorted_h[0] if sorted_h else max_h - strong_support = sorted_l[0] if sorted_l else min_l - - weak_resist = sorted_h[1] if len(sorted_h) > 1 else (max_h + mid) / 2 - weak_support = sorted_l[1] if len(sorted_l) > 1 else (min_l + mid) / 2 - - # 最近20日的振幅比例(判断波动率) - if len(closes := [k["close"] for k in recent]) >= 2: - recent_range = (max_h - min_l) / min_l * 100 if min_l > 0 else 0 - else: - recent_range = 0 - - return { - "strong_resist": round(strong_resist, 2), - "weak_resist": round(weak_resist, 2), - "weak_support": round(weak_support, 2), - "strong_support": round(strong_support, 2), - "high_52w": round(max(all_highs), 2), - "low_52w": round(min(all_lows), 2), - "range_pct": round(recent_range, 1), - } - - -def assess_trend(klines: list) -> dict: - """判断趋势方向 - - Args: - klines: K线数据 - - Returns: - dict: {trend (up/down/sideways), strength (0~1), - description, ma_trend} - """ - if not klines or len(klines) < 10: - return {"trend": "unknown", "strength": 0, "description": "数据不足"} - - closes = [k["close"] for k in klines] - # 确保正序 - if len(closes) >= 2 and closes[0] > closes[-1]: - closes = list(reversed(closes)) - - n = len(closes) - ma20 = sum(closes[-20:]) / 20 if n >= 20 else sum(closes) / n - ma60 = sum(closes[-60:]) / 60 if n >= 60 else None - current = closes[-1] - - # 均线多头/空头排列判断 - ma5 = sum(closes[-5:]) / 5 if n >= 5 else None - ma10 = sum(closes[-10:]) / 10 if n >= 10 else None - - # 趋势判断 - up_count = sum(1 for i in range(1, len(closes)) if closes[i] > closes[i-1]) - up_ratio = up_count / (len(closes) - 1) - - # 价格相对均线位置 - above_ma20 = current > ma20 if ma20 else True - - if up_ratio > 0.6 and above_ma20: - if ma60 and current > ma60 * 1.2: - trend = "strong_up" - strength = min(1.0, up_ratio + 0.2) - desc = "强势上升" - else: - trend = "up" - strength = up_ratio - desc = "震荡上升" - elif up_ratio < 0.4 and not above_ma20: - if ma60 and current < ma60 * 0.8: - trend = "strong_down" - strength = min(1.0, (1 - up_ratio) + 0.2) - desc = "强势下跌" - else: - trend = "down" - strength = 1 - up_ratio - desc = "震荡下跌" - else: - trend = "sideways" - strength = 0.3 - desc = "横盘震荡" - - # 均线排列 - ma_trend = "unknown" - if ma5 and ma10 and ma20: - if ma5 > ma10 > ma20: - ma_trend = "多头排列" - elif ma5 < ma10 < ma20: - ma_trend = "空头排列" - else: - ma_trend = "粘合/交叉" - - return { - "trend": trend, - "strength": round(strength, 2), - "description": desc, - "ma_trend": ma_trend, - "ma5": round(ma5, 2) if ma5 else None, - "ma10": round(ma10, 2) if ma10 else None, - "ma20": round(ma20, 2), - "ma60": round(ma60, 2) if ma60 else None, - "current_above_ma20": current > ma20 if ma20 else None, - } - - -def full_multi_tf_analysis(code: str) -> dict: - """完整多周期分析入口 - - 同时获取日/周/月K线,计算: - - 各周期支撑压力位 - - 均线系统 - - 趋势方向 - - 综合策略建议 - - Args: - code: 股票代码 (如 "300548") - - Returns: - dict: 完整分析结果 - """ - # 获取三个周期的数据 - daily = fetch_kline(code, "day", 120) - weekly = fetch_kline(code, "week", 24) - monthly = fetch_kline(code, "month", 12) - - # 如果API失败,检查是否有本地缓存 - if isinstance(daily, dict) and "error" in daily: - daily = _load_local_history(code, "daily") - if isinstance(weekly, dict) and "error" in weekly: - weekly = _load_local_history(code, "weekly") - if isinstance(monthly, dict) and "error" in monthly: - monthly = _load_local_history(code, "monthly") - - result = { - "code": code, - "analyzed_at": datetime.now().strftime("%Y-%m-%d %H:%M"), - } - - # 日线分析 - if daily and not (isinstance(daily, dict) and "error" in daily): - result["daily"] = { - "count": len(daily), - "latest": daily[-1] if daily else None, - "support_resistance": calc_multi_tf_support_resistance(daily, lookback=20), - "mas": calc_moving_averages(daily, [5, 10, 20, 60]), - "trend": assess_trend(daily), - } - - # 周线分析 - if weekly and not (isinstance(weekly, dict) and "error" in weekly): - result["weekly"] = { - "count": len(weekly), - "latest": weekly[-1] if weekly else None, - "support_resistance": calc_multi_tf_support_resistance(weekly, lookback=12), - "mas": calc_moving_averages(weekly, [5, 10]), - "trend": assess_trend(weekly), - } - - # 月线分析 - if monthly and not (isinstance(monthly, dict) and "error" in monthly): - result["monthly"] = { - "count": len(monthly), - "latest": monthly[-1] if monthly else None, - "support_resistance": calc_multi_tf_support_resistance(monthly, lookback=6), - "mas": calc_moving_averages(monthly, [5]), - "trend": assess_trend(monthly), - } - - # 综合策略建议 - result["strategy_adjustment"] = _generate_strategy_adjustment(result) - - # 写入本地缓存(供离线使用) - _save_local_history(code, daily, weekly, monthly) - - return result - - -def flush_mtf_cache(): - """将模块级缓存显式刷回磁盘(供批量处理后调用)""" - _save_mtf_cache() - - -def _generate_strategy_adjustment(analysis: dict) -> dict: - """基于多周期分析生成策略调整建议""" - adj = { - "stop_loss_reference": None, - "take_profit_reference": None, - "trend_alignment": "unknown", - "multi_tf_summary": {}, - "cautions": [], - } - - daily_trend = analysis.get("daily", {}).get("trend", {}) - weekly_trend = analysis.get("weekly", {}).get("trend", {}) - monthly_trend = analysis.get("monthly", {}).get("trend", {}) - - # 均线数据 - daily_mas = analysis.get("daily", {}).get("mas", {}) - daily_sr = analysis.get("daily", {}).get("support_resistance", {}) - weekly_sr = analysis.get("weekly", {}).get("support_resistance", {}) - monthly_sr = analysis.get("monthly", {}).get("support_resistance", {}) - - current = analysis.get("daily", {}).get("latest", {}).get("close", 0) - - # 多周期趋势一致性 - up_tfs, down_tfs = 0, 0 - tf_details = [] - for tf_name, tf_data in [("daily", daily_trend), ("weekly", weekly_trend), - ("monthly", monthly_trend)]: - t = tf_data.get("trend", "unknown") - desc = tf_data.get("description", "") - ma_t = tf_data.get("ma_trend", "") - tf_details.append(f"{tf_name}:{desc}({ma_t})") - if "up" in t or "strong_up" in t: - up_tfs += 1 - elif "down" in t or "strong_down" in t: - down_tfs += 1 - - adj["multi_tf_summary"] = { - "daily_trend": daily_trend.get("description", "未知"), - "weekly_trend": weekly_trend.get("description", "未知"), - "monthly_trend": monthly_trend.get("description", "未知"), - "daily_ma_trend": daily_trend.get("ma_trend", "未知"), - } - - if up_tfs >= 2: - adj["trend_alignment"] = "多周期看多" - elif down_tfs >= 2: - adj["trend_alignment"] = "多周期看空" - elif up_tfs >= 1 and down_tfs >= 1: - adj["trend_alignment"] = "多周期分化" - else: - adj["trend_alignment"] = "震荡/无明显方向" - - if not current: - return adj - - # ===== 参考止损位(三级递进)===== - # 第一级:MA20(短线交易的生命线) - ma20 = daily_mas.get("ma20") - # 第二级:日线弱支撑(近20天次低点) - daily_ws = daily_sr.get("weak_support") - # 第三级:日线强支撑 / MA60 - ma60 = daily_mas.get("ma60") - daily_ss = daily_sr.get("strong_support") - - stop_candidates = [] - if ma20: - stop_candidates.append(("MA20", ma20, abs(current - ma20) / current * 100)) - if daily_ws: - stop_candidates.append(("日弱支撑", daily_ws, abs(current - daily_ws) / current * 100)) - if ma60: - stop_candidates.append(("MA60", ma60, abs(current - ma60) / current * 100)) - if daily_ss: - stop_candidates.append(("日强支撑", daily_ss, abs(current - daily_ss) / current * 100)) - - if stop_candidates: - # 选一个合理的止损参考:MA20优先(如果距现价不太近),否则选日弱支撑 - best_stop = None - for name, level, dist in stop_candidates: - if level < current: # 止损必须在现价下方 - if 2 <= dist <= 15: # 距现价2~15%之间比较合理 - best_stop = {"source": name, "level": level, - "distance_pct": round(dist, 2)} - break - if not best_stop: - # 没有2~15%内的,选最近的一个 - below = [(n, l, d) for n, l, d in stop_candidates if l < current] - if below: - nearest = min(below, key=lambda x: x[2]) - best_stop = {"source": nearest[0], "level": nearest[1], - "distance_pct": round(nearest[2], 2)} - if best_stop: - adj["stop_loss_reference"] = best_stop - - # ===== 参考止盈位 ===== - take_candidates = [] - # 日线阻力 - for name, level in [("日弱阻", daily_sr.get("weak_resist")), - ("日强阻", daily_sr.get("strong_resist")), - ("周强阻", weekly_sr.get("strong_resist")), - ("月强阻", monthly_sr.get("strong_resist"))]: - if level and level > current: - dist = (level - current) / current * 100 - take_candidates.append((name, level, dist)) - - if take_candidates: - # 选距现价5~30%内的最高阻力位 - best_take = None - for name, level, dist in sorted(take_candidates, key=lambda x: x[1], reverse=True): - if 3 <= dist <= 40: - best_take = {"source": name, "level": level, - "distance_pct": round(dist, 2)} - break - if not best_take: - farthest = max(take_candidates, key=lambda x: x[2]) - best_take = {"source": farthest[0], "level": farthest[1], - "distance_pct": round(farthest[2], 2)} - if best_take: - adj["take_profit_reference"] = best_take - - # ===== 风险提示 ===== - if ma20 and current < ma20: - adj["cautions"].append(f"价格{current} list: - """从本地多周期缓存读取历史数据,不修改 price_history.json""" - try: - with open(MTF_CACHE_PATH) as f: - data = json.load(f) - except (FileNotFoundError, json.JSONDecodeError): - return [] - - stock = data.get(code, {}) - return stock.get(period, []) - - -def _save_local_history(code: str, daily: list, weekly: list, monthly: list): - """将多周期数据写入模块级缓存(含时间戳),不直接写磁盘""" - import time - global _MTF_CACHE_DATA - cache_data = _load_mtf_cache() - stock = cache_data.get(code, {}) - - if daily and not (isinstance(daily, dict) and "error" in daily): - stock["daily"] = daily - if weekly and not (isinstance(weekly, dict) and "error" in weekly): - stock["weekly"] = weekly - if monthly and not (isinstance(monthly, dict) and "error" in monthly): - stock["monthly"] = monthly - stock["updated_at"] = time.time() # 缓存时间戳 - - cache_data[code] = stock - _MTF_CACHE_DATA = cache_data # 更新模块级缓存 - - # ── SQLite 双写 ── - _write_klines_to_db(code, daily, weekly, monthly, stock.get("fundamentals")) - - -def batch_update_all(codes: list): - """批量更新多只股票的多周期数据""" - results = {} - for code in codes: - try: - r = full_multi_tf_analysis(code) - results[code] = { - "status": "ok", - "periods": [k for k in ["daily", "weekly", "monthly"] - if k in r] - } - except Exception as e: - results[code] = {"status": "error", "error": str(e)} - return results - - -if __name__ == "__main__": - import sys - codes = sys.argv[1:] or ["300548", "600110"] - for code in codes: - r = full_multi_tf_analysis(code) - print(json.dumps(r, ensure_ascii=False, indent=2)) - print("-" * 60) +#!/usr/bin/env python3 +"""multi_timeframe.py — 多周期技术分析模块 + +从腾讯API获取日/周/月K线数据,计算: +- 多周期支撑压力位(日线/周线/月线) +- 移动均线(MA5/10/20/60) +- 趋势方向判断(上升/下降/震荡) +- 综合策略调整建议 + +集成到 strategy_lifecycle.py 中使用。 +""" + +import json +import os +import urllib.request +import urllib.error +from datetime import datetime, date, timedelta +from typing import Optional + +DATA_DIR = "/home/hmo/web-dashboard/data" +HISTORY_PATH = os.path.join(DATA_DIR, "price_history.json") +# multi_tf_cache.json 已迁移到 DB (mtf_cache 表) + +# 腾讯API K线端点 +KLINE_URL = "http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={market}{code},{period},,,{count},qfq" + +# 腾讯实时行情端点(用于市场前缀判断) +QUOTE_URL = "http://qt.gtimg.cn/q={market}{code}" + + +def _write_klines_to_db(code: str, daily: list, weekly: list, monthly: list, fundamentals: dict = None): + """K线数据双写 SQLite(失败不影响缓存写入)""" + try: + from mofin_db import get_conn, init_all_tables, write_klines + conn = get_conn() + init_all_tables(conn) + # 从 stock_profiles.json 获取名称 + name = code + try: + import json + profiles_path = os.path.join(DATA_DIR, "stock_profiles.json") + if os.path.exists(profiles_path): + with open(profiles_path, encoding="utf-8") as f: + profiles = json.load(f) + for p in profiles.get("profiles", []): + if p.get("code") == code: + name = p.get("name", code) + break + except Exception: + pass + write_klines(conn, code, name, daily, weekly, monthly, fundamentals) + conn.close() + except Exception: + pass # SQLite 写入失败不影响主流程 + + +def _market_prefix(code: str) -> str: + """根据代码确定市场前缀""" + raw = str(code).split("_")[0] + # 指数代码:sh/sz/hk开头 + if raw.startswith("sh"): + return "sh" + if raw.startswith("sz"): + return "sz" + if raw.startswith("hk"): + return "hk" + if len(raw) == 5 and raw.isdigit(): + return "hk" + if raw.startswith("6") or raw.startswith("5"): + return "sh" + return "sz" + + +def _user_agent() -> dict: + return { + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" + } + + +# 多周期缓存TTL(秒):日K线1小时,周/月K线1天 +_KLINE_CACHE_TTL = {"day": 3600, "week": 86400, "month": 86400} + +# 模块级缓存:避免每次 fetch_kline 都重新读 DB +_MTF_CACHE_DATA = None +_MTF_CACHE_DIRTY = False + + +def _load_mtf_cache(): + """从 DB 加载多周期缓存""" + global _MTF_CACHE_DATA + if _MTF_CACHE_DATA is not None: + return _MTF_CACHE_DATA + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + rows = db.execute("SELECT code, cache_json FROM mtf_cache").fetchall() + _MTF_CACHE_DATA = {} + for code, json_str in rows: + try: + _MTF_CACHE_DATA[code] = json.loads(json_str) + except: + pass + db.close() + except Exception: + _MTF_CACHE_DATA = {} + return _MTF_CACHE_DATA + + +def _save_mtf_cache(): + """将模块级缓存写回 DB""" + global _MTF_CACHE_DATA + if _MTF_CACHE_DATA is None: + return + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + for code, data in _MTF_CACHE_DATA.items(): + db.execute( + "INSERT OR REPLACE INTO mtf_cache (code, cache_json, updated_at) VALUES (?,?,datetime('now','localtime'))", + (code, json.dumps(data, ensure_ascii=False)) + ) + db.commit() + db.close() + except Exception: + pass + + +def fetch_kline(code: str, period: str = "day", count: int = 120) -> list: + """从腾讯API获取K线数据,优先使用本地缓存 + + Args: + code: 股票代码 (如 "300548") + period: "day" / "week" / "month" + count: 需要多少条 + + Returns: + list of dict: [{"date":str, "open":float, "close":float, + "high":float, "low":float, "volume":float}, ...] + """ + import time + now = time.time() + + # 优先检查本地缓存(模块级,避免重复读盘) + # 注意:缓存中存储的key是'daily'/'weekly'/'monthly',参数period是'day'/'week'/'month' + _PERIOD_MAP = {"day": "daily", "week": "weekly", "month": "monthly"} + cache_data = _load_mtf_cache() + cached = cache_data.get(code, {}) + cache_key = _PERIOD_MAP.get(period, period) + cached_klines = cached.get(cache_key, cached.get(period, [])) + updated_at = cached.get("updated_at", 0) + if cached_klines and updated_at and (now - updated_at) < _KLINE_CACHE_TTL.get(period, 3600): + return cached_klines + + market = _market_prefix(code) + is_index = any(code.startswith(p) for p in ["sh", "sz", "hk"]) + + # 指数代码已经自带前缀,API直接用code;普通股票需要加market前缀 + api_code = code if is_index else f"{market}{code}" + url = f"http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={api_code},{period},,,{count},qfq" + + try: + req = urllib.request.Request(url, headers=_user_agent()) + with urllib.request.urlopen(req, timeout=10) as resp: + raw = json.loads(resp.read().decode("utf-8")) + except Exception as e: + return {"error": str(e), "code": code, "period": period} + + if not isinstance(raw, dict): + return {"error": f"API returned {type(raw).__name__}", "raw": str(raw)[:200]} + + api_data = raw.get("data", {}) + if not isinstance(api_data, dict): + return {"error": f"data field is {type(api_data).__name__}", "raw": str(api_data)[:200]} + + # 指数代码已经自带前缀(sh000001/sz399001),直接用 + # 普通股票代码需要加market前缀(sh600036/sz300750) + is_index = any(code.startswith(p) for p in ["sh", "sz", "hk"]) + stock_key = code if is_index else f"{market}{code}" + stock_data = api_data.get(stock_key, {}) + + # 腾讯API的K线字段名: qfqday, qfqweek, qfqmonth + period_key = f"qfq{period}" + klines = stock_data.get(period_key, []) + + if not klines: + # 尝试其他字段名 + for k in stock_data: + if isinstance(stock_data[k], list) and len(stock_data[k]) > 0: + if isinstance(stock_data[k][0], list) and len(stock_data[k][0]) >= 6: + klines = stock_data[k] + break + + result = [] + for k in klines: + if len(k) >= 6: + try: + result.append({ + "date": str(k[0]), + "open": float(k[1]), + "close": float(k[2]), + "high": float(k[3]), + "low": float(k[4]), + "volume": float(k[5]), + }) + except (ValueError, IndexError): + continue + + return result + + +def calc_moving_averages(klines: list, windows: list = [5, 10, 20, 60]) -> dict: + """计算移动均线 + + Args: + klines: K线数据(按时间正序或倒序均可,自动处理) + windows: 均线周期列表 + + Returns: + dict: {ma5: float|None, ma10: float|None, ...} + """ + if not klines: + return {f"ma{w}": None for w in windows} + + # 确保按时间正序(旧的在前) + closes = [k["close"] for k in klines] + # 检查是否倒序(最新的在前) + if len(closes) >= 2 and closes[0] > closes[-1]: + closes = list(reversed(closes)) + + result = {} + for w in windows: + if len(closes) >= w: + result[f"ma{w}"] = round(sum(closes[-w:]) / w, 2) + else: + result[f"ma{w}"] = None + return result + + +def calc_multi_tf_support_resistance(klines: list, lookback: int = 0) -> dict: + """基于K线数据计算多周期支撑压力位 + + 使用近期高点和低点作为关键位: + - 强阻力 = 近期最高(或倒数第二高) + - 弱阻力 = 近期中枢上沿 + - 弱支撑 = 近期中枢下沿 + - 强支撑 = 近期最低(或倒数第二低) + + Args: + klines: K线数据 + lookback: 取最近多少条(0=全部) + + Returns: + dict: {strong_resist, weak_resist, weak_support, strong_support, + high_52w, low_52w, range_pct} + """ + if not klines or len(klines) < 3: + return {} + + # 取最近N条(日线看近期,周线/月线看全部) + if lookback <= 0: + lookback = min(len(klines), 20) # 日线默认20天 + n = min(len(klines), lookback) + recent = klines[-n:] + + # 全量数据(用于52周高低) + all_highs = [k["high"] for k in klines] + all_lows = [k["low"] for k in klines] + + highs = [k["high"] for k in recent] + lows = [k["low"] for k in recent] + + max_h = max(highs) + min_l = min(lows) + mid = (max_h + min_l) / 2 + + # 找第二高和第二低作为更稳健的边界 + sorted_h = sorted(set(highs), reverse=True) + sorted_l = sorted(set(lows)) + + strong_resist = sorted_h[0] if sorted_h else max_h + strong_support = sorted_l[0] if sorted_l else min_l + + weak_resist = sorted_h[1] if len(sorted_h) > 1 else (max_h + mid) / 2 + weak_support = sorted_l[1] if len(sorted_l) > 1 else (min_l + mid) / 2 + + # 最近20日的振幅比例(判断波动率) + if len(closes := [k["close"] for k in recent]) >= 2: + recent_range = (max_h - min_l) / min_l * 100 if min_l > 0 else 0 + else: + recent_range = 0 + + return { + "strong_resist": round(strong_resist, 2), + "weak_resist": round(weak_resist, 2), + "weak_support": round(weak_support, 2), + "strong_support": round(strong_support, 2), + "high_52w": round(max(all_highs), 2), + "low_52w": round(min(all_lows), 2), + "range_pct": round(recent_range, 1), + } + + +def assess_trend(klines: list) -> dict: + """判断趋势方向 + + Args: + klines: K线数据 + + Returns: + dict: {trend (up/down/sideways), strength (0~1), + description, ma_trend} + """ + if not klines or len(klines) < 10: + return {"trend": "unknown", "strength": 0, "description": "数据不足"} + + closes = [k["close"] for k in klines] + # 确保正序 + if len(closes) >= 2 and closes[0] > closes[-1]: + closes = list(reversed(closes)) + + n = len(closes) + ma20 = sum(closes[-20:]) / 20 if n >= 20 else sum(closes) / n + ma60 = sum(closes[-60:]) / 60 if n >= 60 else None + current = closes[-1] + + # 均线多头/空头排列判断 + ma5 = sum(closes[-5:]) / 5 if n >= 5 else None + ma10 = sum(closes[-10:]) / 10 if n >= 10 else None + + # 趋势判断 + up_count = sum(1 for i in range(1, len(closes)) if closes[i] > closes[i-1]) + up_ratio = up_count / (len(closes) - 1) + + # 价格相对均线位置 + above_ma20 = current > ma20 if ma20 else True + + if up_ratio > 0.6 and above_ma20: + if ma60 and current > ma60 * 1.2: + trend = "strong_up" + strength = min(1.0, up_ratio + 0.2) + desc = "强势上升" + else: + trend = "up" + strength = up_ratio + desc = "震荡上升" + elif up_ratio < 0.4 and not above_ma20: + if ma60 and current < ma60 * 0.8: + trend = "strong_down" + strength = min(1.0, (1 - up_ratio) + 0.2) + desc = "强势下跌" + else: + trend = "down" + strength = 1 - up_ratio + desc = "震荡下跌" + else: + trend = "sideways" + strength = 0.3 + desc = "横盘震荡" + + # 均线排列 + ma_trend = "unknown" + if ma5 and ma10 and ma20: + if ma5 > ma10 > ma20: + ma_trend = "多头排列" + elif ma5 < ma10 < ma20: + ma_trend = "空头排列" + else: + ma_trend = "粘合/交叉" + + return { + "trend": trend, + "strength": round(strength, 2), + "description": desc, + "ma_trend": ma_trend, + "ma5": round(ma5, 2) if ma5 else None, + "ma10": round(ma10, 2) if ma10 else None, + "ma20": round(ma20, 2), + "ma60": round(ma60, 2) if ma60 else None, + "current_above_ma20": current > ma20 if ma20 else None, + } + + +def full_multi_tf_analysis(code: str) -> dict: + """完整多周期分析入口 + + 同时获取日/周/月K线,计算: + - 各周期支撑压力位 + - 均线系统 + - 趋势方向 + - 综合策略建议 + + Args: + code: 股票代码 (如 "300548") + + Returns: + dict: 完整分析结果 + """ + # 获取三个周期的数据 + daily = fetch_kline(code, "day", 120) + weekly = fetch_kline(code, "week", 24) + monthly = fetch_kline(code, "month", 12) + + # 如果API失败,检查是否有本地缓存 + if isinstance(daily, dict) and "error" in daily: + daily = _load_local_history(code, "daily") + if isinstance(weekly, dict) and "error" in weekly: + weekly = _load_local_history(code, "weekly") + if isinstance(monthly, dict) and "error" in monthly: + monthly = _load_local_history(code, "monthly") + + result = { + "code": code, + "analyzed_at": datetime.now().strftime("%Y-%m-%d %H:%M"), + } + + # 日线分析 + if daily and not (isinstance(daily, dict) and "error" in daily): + result["daily"] = { + "count": len(daily), + "latest": daily[-1] if daily else None, + "support_resistance": calc_multi_tf_support_resistance(daily, lookback=20), + "mas": calc_moving_averages(daily, [5, 10, 20, 60]), + "trend": assess_trend(daily), + } + + # 周线分析 + if weekly and not (isinstance(weekly, dict) and "error" in weekly): + result["weekly"] = { + "count": len(weekly), + "latest": weekly[-1] if weekly else None, + "support_resistance": calc_multi_tf_support_resistance(weekly, lookback=12), + "mas": calc_moving_averages(weekly, [5, 10]), + "trend": assess_trend(weekly), + } + + # 月线分析 + if monthly and not (isinstance(monthly, dict) and "error" in monthly): + result["monthly"] = { + "count": len(monthly), + "latest": monthly[-1] if monthly else None, + "support_resistance": calc_multi_tf_support_resistance(monthly, lookback=6), + "mas": calc_moving_averages(monthly, [5]), + "trend": assess_trend(monthly), + } + + # 综合策略建议 + result["strategy_adjustment"] = _generate_strategy_adjustment(result) + + # 写入本地缓存(供离线使用) + _save_local_history(code, daily, weekly, monthly) + + return result + + +def flush_mtf_cache(): + """将模块级缓存显式刷回磁盘(供批量处理后调用)""" + _save_mtf_cache() + + +def _generate_strategy_adjustment(analysis: dict) -> dict: + """基于多周期分析生成策略调整建议""" + adj = { + "stop_loss_reference": None, + "take_profit_reference": None, + "trend_alignment": "unknown", + "multi_tf_summary": {}, + "cautions": [], + } + + daily_trend = analysis.get("daily", {}).get("trend", {}) + weekly_trend = analysis.get("weekly", {}).get("trend", {}) + monthly_trend = analysis.get("monthly", {}).get("trend", {}) + + # 均线数据 + daily_mas = analysis.get("daily", {}).get("mas", {}) + daily_sr = analysis.get("daily", {}).get("support_resistance", {}) + weekly_sr = analysis.get("weekly", {}).get("support_resistance", {}) + monthly_sr = analysis.get("monthly", {}).get("support_resistance", {}) + + current = analysis.get("daily", {}).get("latest", {}).get("close", 0) + + # 多周期趋势一致性 + up_tfs, down_tfs = 0, 0 + tf_details = [] + for tf_name, tf_data in [("daily", daily_trend), ("weekly", weekly_trend), + ("monthly", monthly_trend)]: + t = tf_data.get("trend", "unknown") + desc = tf_data.get("description", "") + ma_t = tf_data.get("ma_trend", "") + tf_details.append(f"{tf_name}:{desc}({ma_t})") + if "up" in t or "strong_up" in t: + up_tfs += 1 + elif "down" in t or "strong_down" in t: + down_tfs += 1 + + adj["multi_tf_summary"] = { + "daily_trend": daily_trend.get("description", "未知"), + "weekly_trend": weekly_trend.get("description", "未知"), + "monthly_trend": monthly_trend.get("description", "未知"), + "daily_ma_trend": daily_trend.get("ma_trend", "未知"), + } + + if up_tfs >= 2: + adj["trend_alignment"] = "多周期看多" + elif down_tfs >= 2: + adj["trend_alignment"] = "多周期看空" + elif up_tfs >= 1 and down_tfs >= 1: + adj["trend_alignment"] = "多周期分化" + else: + adj["trend_alignment"] = "震荡/无明显方向" + + if not current: + return adj + + # ===== 参考止损位(三级递进)===== + # 第一级:MA20(短线交易的生命线) + ma20 = daily_mas.get("ma20") + # 第二级:日线弱支撑(近20天次低点) + daily_ws = daily_sr.get("weak_support") + # 第三级:日线强支撑 / MA60 + ma60 = daily_mas.get("ma60") + daily_ss = daily_sr.get("strong_support") + + stop_candidates = [] + if ma20: + stop_candidates.append(("MA20", ma20, abs(current - ma20) / current * 100)) + if daily_ws: + stop_candidates.append(("日弱支撑", daily_ws, abs(current - daily_ws) / current * 100)) + if ma60: + stop_candidates.append(("MA60", ma60, abs(current - ma60) / current * 100)) + if daily_ss: + stop_candidates.append(("日强支撑", daily_ss, abs(current - daily_ss) / current * 100)) + + if stop_candidates: + # 选一个合理的止损参考:MA20优先(如果距现价不太近),否则选日弱支撑 + best_stop = None + for name, level, dist in stop_candidates: + if level < current: # 止损必须在现价下方 + if 2 <= dist <= 15: # 距现价2~15%之间比较合理 + best_stop = {"source": name, "level": level, + "distance_pct": round(dist, 2)} + break + if not best_stop: + # 没有2~15%内的,选最近的一个 + below = [(n, l, d) for n, l, d in stop_candidates if l < current] + if below: + nearest = min(below, key=lambda x: x[2]) + best_stop = {"source": nearest[0], "level": nearest[1], + "distance_pct": round(nearest[2], 2)} + if best_stop: + adj["stop_loss_reference"] = best_stop + + # ===== 参考止盈位 ===== + take_candidates = [] + # 日线阻力 + for name, level in [("日弱阻", daily_sr.get("weak_resist")), + ("日强阻", daily_sr.get("strong_resist")), + ("周强阻", weekly_sr.get("strong_resist")), + ("月强阻", monthly_sr.get("strong_resist"))]: + if level and level > current: + dist = (level - current) / current * 100 + take_candidates.append((name, level, dist)) + + if take_candidates: + # 选距现价5~30%内的最高阻力位 + best_take = None + for name, level, dist in sorted(take_candidates, key=lambda x: x[1], reverse=True): + if 3 <= dist <= 40: + best_take = {"source": name, "level": level, + "distance_pct": round(dist, 2)} + break + if not best_take: + farthest = max(take_candidates, key=lambda x: x[2]) + best_take = {"source": farthest[0], "level": farthest[1], + "distance_pct": round(farthest[2], 2)} + if best_take: + adj["take_profit_reference"] = best_take + + # ===== 风险提示 ===== + if ma20 and current < ma20: + adj["cautions"].append(f"价格{current} list: + """从 DB 多周期缓存读取历史数据""" + data = _load_mtf_cache() + stock = data.get(code, {}) + return stock.get(period, []) + + +def _save_local_history(code: str, daily: list, weekly: list, monthly: list): + """将多周期数据写入模块级缓存(含时间戳),不直接写磁盘""" + import time + global _MTF_CACHE_DATA + cache_data = _load_mtf_cache() + stock = cache_data.get(code, {}) + + if daily and not (isinstance(daily, dict) and "error" in daily): + stock["daily"] = daily + if weekly and not (isinstance(weekly, dict) and "error" in weekly): + stock["weekly"] = weekly + if monthly and not (isinstance(monthly, dict) and "error" in monthly): + stock["monthly"] = monthly + stock["updated_at"] = time.time() # 缓存时间戳 + + cache_data[code] = stock + _MTF_CACHE_DATA = cache_data # 更新模块级缓存 + + # ── SQLite 双写 ── + _write_klines_to_db(code, daily, weekly, monthly, stock.get("fundamentals")) + + +def batch_update_all(codes: list): + """批量更新多只股票的多周期数据""" + results = {} + for code in codes: + try: + r = full_multi_tf_analysis(code) + results[code] = { + "status": "ok", + "periods": [k for k in ["daily", "weekly", "monthly"] + if k in r] + } + except Exception as e: + results[code] = {"status": "error", "error": str(e)} + return results + + +if __name__ == "__main__": + import sys + codes = sys.argv[1:] or ["300548", "600110"] + for code in codes: + r = full_multi_tf_analysis(code) + print(json.dumps(r, ensure_ascii=False, indent=2)) + print("-" * 60) diff --git a/price_monitor.py b/price_monitor.py index f29f4d9..0df65d9 100644 --- a/price_monitor.py +++ b/price_monitor.py @@ -12,7 +12,7 @@ from datetime import datetime # ── MoFin unified model ────────────────────────────────────────────── from mo_models import is_hk_stock, get_hk_rate, calc_total_assets, calc_total_mv, calc_position_pct -from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_price_event, write_watchlist_stock, write_holding_strategy +from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_price_event, write_watchlist_stock, write_live_prices, read_capital_flow_cache, write_holding_strategy from mo_data import read_portfolio, read_decisions, read_watchlist BREACH_PATH = "/home/hmo/.hermes/zone_breach.json" @@ -231,14 +231,16 @@ def refresh_data_prices(): prices.update(hk_prices) updated = 0 - # 保存全量实时价快照(供报告管道消费,确保分析用最新数据) + # 保存全量实时价快照到 DB(替代 live_prices.json) try: - live = {"updated_at": datetime.now().isoformat(), "prices": {}} + live = {} for code in all_codes: if code in prices: p, c, chg = prices[code] - live["prices"][code] = {"price": p, "change_pct": chg} - json.dump(live, open("/home/hmo/web-dashboard/data/live_prices.json", "w"), indent=2) + live[code] = {"price": p, "change_pct": chg} + conn = get_conn() + write_live_prices(conn, live) + conn.close() except Exception: pass @@ -663,7 +665,7 @@ def run_once(round_label=""): # === 3.5 资金流异常检测(2026-06-27 新增)=== try: - cf = json.load(open("/home/hmo/web-dashboard/data/capital_flow_cache.json")) + cf = read_capital_flow_cache(get_conn()) # 检查所有 active decision 中的资金流异常 for d in active: code = d["code"] diff --git a/scripts/capital_flow_collector.py b/scripts/capital_flow_collector.py index 7bf450a..06a2a4a 100644 --- a/scripts/capital_flow_collector.py +++ b/scripts/capital_flow_collector.py @@ -1,182 +1,186 @@ -#!/usr/bin/env python3 -"""capital_flow_collector.py — 个股资金流数据采集器 - -每30分钟拉一次持仓+自选的超大单/大单/中单/小单资金流向。 -输出到 capital_flow_cache.json 供 price_monitor 和报告使用。 - -API: push2his.eastmoney.com 个股资金流日线 -""" -import json, os, sys, time, urllib.request -from datetime import datetime -from urllib.request import urlopen, Request -from concurrent.futures import ThreadPoolExecutor, as_completed -from threading import Semaphore -from mo_data import read_portfolio, read_decisions, read_watchlist - -DATA_DIR = "/home/hmo/web-dashboard/data" -DECISIONS_PATH = f"{DATA_DIR}/decisions.json" -CACHE_PATH = f"{DATA_DIR}/capital_flow_cache.json" - -UA = "Mozilla/5.0" -# 限速器:最多5个并发,每请求后强制间隔0.3s -RATE_LIMIT = Semaphore(5) -MIN_INTERVAL = 0.3 -_last_req = 0 - -def _rate_limited_request(url): - """带速率限制的HTTP GET,用Semaphore控制并发数""" - global _last_req - with RATE_LIMIT: - elapsed = time.time() - _last_req - if elapsed < MIN_INTERVAL: - time.sleep(MIN_INTERVAL - elapsed) - proxy_handler = urllib.request.ProxyHandler({}) - opener = urllib.request.build_opener(proxy_handler) - req = Request(url, headers={"User-Agent": UA, "Referer": "https://data.eastmoney.com/"}) - try: - resp = opener.open(req, timeout=8) - _last_req = time.time() - return json.loads(resp.read().decode("utf-8")) - except Exception: - return None - -# eastmoney secid: 1=上海 0=深圳 -def secid(code): - code = str(code).strip() - if code.startswith(("6", "9")): - return f"1.{code}" - return f"0.{code}" - -def fetch_flow(code, days=5): - """拉取个股近N日资金流(带限速+代理绕过)""" - sid = secid(code) - url = f"http://push2his.eastmoney.com/api/qt/stock/fflow/daykline/get?secid={sid}&fields1=f1,f2,f3,f7&fields2=f51,f52,f53,f54,f55,f56,f57&lmt={days}" - data = _rate_limited_request(url) - if not data: - return None - klines = data.get("data", {}).get("klines", []) - if not klines: - return None - result = [] - for k in klines: - p = k.split(",") - if len(p) >= 7: - result.append({ - "date": p[0], - "main_net": float(p[1]), # 主力净流入(元) - "super_large": float(p[2]), # 超大单净流入(元) - "large": float(p[3]), # 大单净流入(元) - "medium": float(p[4]), # 中单净流入(元) - "small": float(p[5]), # 小单净流入(元) - }) - return result - -def fetch_flow_intraday(code): - """拉取当日分时资金流(用于盘中判断)""" - sid = secid(code) - url = f"http://push2.eastmoney.com/api/qt/stock/fflow/kline/get?secid={sid}&fields1=f1,f2,f3,f7&fields2=f51,f52,f53,f54,f55,f56,f57&klt=1&lmt=120" - try: - resp = urlopen(url, timeout=5) - data = json.loads(resp.read().decode("utf-8")) - klines = data.get("data", {}).get("klines", []) - if not klines: - return None - latest = klines[-1].split(",") - return { - "main_net": float(latest[1]), - "super_large": float(latest[2]), - "large": float(latest[3]), - } - except: - return None - -def analyze_flow(flow_data): - """分析资金流模式""" - if not flow_data or len(flow_data) < 2: - return {} - - result = {"alerts": [], "pattern": ""} - - # 最近两日对比 - d1 = flow_data[-1] # 最新日 - d2 = flow_data[-2] # 前一日 - - # 超大单信号 - sl1 = d1["super_large"] - sl2 = d2["super_large"] - - # 连续形态判断 - main_trend = sum(d["main_net"] for d in flow_data[-3:]) - sl_trend = sum(d["super_large"] for d in flow_data[-3:]) - - # 1. 主力连续流入 - if main_trend > 50000000 and sl1 > 0 and sl2 > 0: - result["pattern"] = "主力持续流入" - result["alerts"].append("主力连续3日净流入") - - # 2. 超大单突然转向(连续流入→流出 或 流出→流入) - if sl1 * sl2 < 0: # 方向反转 - if sl1 > 0 and sl2 < 0: - result["pattern"] = "超大单由出转入" - result["alerts"].append("超大单转为净买入(暗示消息即将落地)") - elif sl1 < 0 and sl2 > 0: - result["pattern"] = "超大单由入转出" - result["alerts"].append("超大单转为净卖出(利好出货嫌疑)") - - # 3. 价格与资金流背离(缺当前价格作比较,在主脚本中完成) - # 4. 单日暴量 - max_sl = max(abs(d["super_large"]) for d in flow_data) - if max_sl == abs(sl1) and abs(sl1) > 100000000: - result["pattern"] = "单日资金暴量" - result["alerts"].append(f"今日超大单异常: {sl1/100000000:.2f}亿") - - return result - -def main(): - codes = set() - # 读取持仓+自选 - try: - dec = mo_data.read_decisions() - for d in dec.get("decisions", []): - c = d.get("code", "") - if c: - codes.add(c) - except: - pass - - all_flows = {} - - # 并行抓取:ThreadPoolExecutor + 内置限速器(Semaphore 5 + 0.3s间隔) - code_list = sorted(codes) - if not code_list: - print("[capital_flow] 无代码需要采集") - return - - def fetch_one(code): - flow = fetch_flow(code, days=5) - if flow: - analysis = analyze_flow(flow) - return (code, { - "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), - "flow": flow, - "analysis": analysis, - }) - return (code, None) - - with ThreadPoolExecutor(max_workers=5) as pool: - futures = {pool.submit(fetch_one, c): c for c in code_list} - for f in as_completed(futures): - code, result = f.result() - if result: - all_flows[code] = result - - # 写缓存 - cache = { - "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), - "stocks": all_flows, - } - json.dump(cache, open(CACHE_PATH, "w"), indent=2, ensure_ascii=False) - print(f"[capital_flow] {len(all_flows)}/{len(code_list)}只更新完成") - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +"""capital_flow_collector.py — 个股资金流数据采集器 + +每30分钟拉一次持仓+自选的超大单/大单/中单/小单资金流向。 +输出到 capital_flow_cache.json 供 price_monitor 和报告使用。 + +API: push2his.eastmoney.com 个股资金流日线 +""" +import json, os, sys, time, urllib.request +from datetime import datetime +from urllib.request import urlopen, Request +from concurrent.futures import ThreadPoolExecutor, as_completed +from threading import Semaphore +from mo_data import read_portfolio, read_decisions, read_watchlist +from mofin_db import get_conn, write_capital_flow_cache + +DATA_DIR = "/home/hmo/web-dashboard/data" +DECISIONS_PATH = f"{DATA_DIR}/decisions.json" +CACHE_PATH = f"{DATA_DIR}/capital_flow_cache.json" + +UA = "Mozilla/5.0" +# 限速器:最多5个并发,每请求后强制间隔0.3s +RATE_LIMIT = Semaphore(5) +MIN_INTERVAL = 0.3 +_last_req = 0 + +def _rate_limited_request(url): + """带速率限制的HTTP GET,用Semaphore控制并发数""" + global _last_req + with RATE_LIMIT: + elapsed = time.time() - _last_req + if elapsed < MIN_INTERVAL: + time.sleep(MIN_INTERVAL - elapsed) + proxy_handler = urllib.request.ProxyHandler({}) + opener = urllib.request.build_opener(proxy_handler) + req = Request(url, headers={"User-Agent": UA, "Referer": "https://data.eastmoney.com/"}) + try: + resp = opener.open(req, timeout=8) + _last_req = time.time() + return json.loads(resp.read().decode("utf-8")) + except Exception: + return None + +# eastmoney secid: 1=上海 0=深圳 +def secid(code): + code = str(code).strip() + if code.startswith(("6", "9")): + return f"1.{code}" + return f"0.{code}" + +def fetch_flow(code, days=5): + """拉取个股近N日资金流(带限速+代理绕过)""" + sid = secid(code) + url = f"http://push2his.eastmoney.com/api/qt/stock/fflow/daykline/get?secid={sid}&fields1=f1,f2,f3,f7&fields2=f51,f52,f53,f54,f55,f56,f57&lmt={days}" + data = _rate_limited_request(url) + if not data: + return None + klines = data.get("data", {}).get("klines", []) + if not klines: + return None + result = [] + for k in klines: + p = k.split(",") + if len(p) >= 7: + result.append({ + "date": p[0], + "main_net": float(p[1]), # 主力净流入(元) + "super_large": float(p[2]), # 超大单净流入(元) + "large": float(p[3]), # 大单净流入(元) + "medium": float(p[4]), # 中单净流入(元) + "small": float(p[5]), # 小单净流入(元) + }) + return result + +def fetch_flow_intraday(code): + """拉取当日分时资金流(用于盘中判断)""" + sid = secid(code) + url = f"http://push2.eastmoney.com/api/qt/stock/fflow/kline/get?secid={sid}&fields1=f1,f2,f3,f7&fields2=f51,f52,f53,f54,f55,f56,f57&klt=1&lmt=120" + try: + resp = urlopen(url, timeout=5) + data = json.loads(resp.read().decode("utf-8")) + klines = data.get("data", {}).get("klines", []) + if not klines: + return None + latest = klines[-1].split(",") + return { + "main_net": float(latest[1]), + "super_large": float(latest[2]), + "large": float(latest[3]), + } + except: + return None + +def analyze_flow(flow_data): + """分析资金流模式""" + if not flow_data or len(flow_data) < 2: + return {} + + result = {"alerts": [], "pattern": ""} + + # 最近两日对比 + d1 = flow_data[-1] # 最新日 + d2 = flow_data[-2] # 前一日 + + # 超大单信号 + sl1 = d1["super_large"] + sl2 = d2["super_large"] + + # 连续形态判断 + main_trend = sum(d["main_net"] for d in flow_data[-3:]) + sl_trend = sum(d["super_large"] for d in flow_data[-3:]) + + # 1. 主力连续流入 + if main_trend > 50000000 and sl1 > 0 and sl2 > 0: + result["pattern"] = "主力持续流入" + result["alerts"].append("主力连续3日净流入") + + # 2. 超大单突然转向(连续流入→流出 或 流出→流入) + if sl1 * sl2 < 0: # 方向反转 + if sl1 > 0 and sl2 < 0: + result["pattern"] = "超大单由出转入" + result["alerts"].append("超大单转为净买入(暗示消息即将落地)") + elif sl1 < 0 and sl2 > 0: + result["pattern"] = "超大单由入转出" + result["alerts"].append("超大单转为净卖出(利好出货嫌疑)") + + # 3. 价格与资金流背离(缺当前价格作比较,在主脚本中完成) + # 4. 单日暴量 + max_sl = max(abs(d["super_large"]) for d in flow_data) + if max_sl == abs(sl1) and abs(sl1) > 100000000: + result["pattern"] = "单日资金暴量" + result["alerts"].append(f"今日超大单异常: {sl1/100000000:.2f}亿") + + return result + +def main(): + codes = set() + # 读取持仓+自选 + try: + dec = mo_data.read_decisions() + for d in dec.get("decisions", []): + c = d.get("code", "") + if c: + codes.add(c) + except: + pass + + all_flows = {} + + # 并行抓取:ThreadPoolExecutor + 内置限速器(Semaphore 5 + 0.3s间隔) + code_list = sorted(codes) + if not code_list: + print("[capital_flow] 无代码需要采集") + return + + def fetch_one(code): + flow = fetch_flow(code, days=5) + if flow: + analysis = analyze_flow(flow) + return (code, { + "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), + "flow": flow, + "analysis": analysis, + }) + return (code, None) + + with ThreadPoolExecutor(max_workers=5) as pool: + futures = {pool.submit(fetch_one, c): c for c in code_list} + for f in as_completed(futures): + code, result = f.result() + if result: + all_flows[code] = result + + # 写缓存 + cache = { + "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), + "stocks": all_flows, + } + # 写 DB(替代 capital_flow_cache.json) + conn = get_conn() + write_capital_flow_cache(conn, cache) + conn.close() + print(f"[capital_flow] {len(all_flows)}/{len(code_list)}只更新完成") + +if __name__ == "__main__": + main() diff --git a/scripts/check_imports.py b/scripts/check_imports.py new file mode 100644 index 0000000..1bf4bdc --- /dev/null +++ b/scripts/check_imports.py @@ -0,0 +1,33 @@ +import sys, traceback +sys.path.insert(0, '/home/hmo/MoFin') + +errors = [] +for mod, name in [ + ('mo_data', 'read_portfolio'), + ('mo_data', 'read_decisions'), + ('mo_data', 'read_watchlist'), + ('mofin_db', 'get_conn'), + ('mofin_db', 'write_holdings_batch'), + ('mofin_db', 'write_portfolio_summary'), + ('mofin_db', 'write_watchlist_stock'), + ('mofin_db', 'write_holding_strategy'), + ('mo_models', 'is_hk_stock'), + ('mo_models', 'get_hk_rate'), +]: + try: + m = __import__(mod, fromlist=[name]) + getattr(m, name) + print(f"OK: {mod}.{name}") + except Exception as e: + print(f"FAIL: {mod}.{name} -> {e}") + errors.append(str(e)) + +print(f"\n=== price_monitor.py import test ===") +try: + import price_monitor + print("price_monitor imported OK") +except Exception as e: + print(f"FAIL: {traceback.format_exc()}") + +if errors: + print(f"\n{len(errors)} import errors!") diff --git a/scripts/check_prices.py b/scripts/check_prices.py new file mode 100644 index 0000000..d4a2f08 --- /dev/null +++ b/scripts/check_prices.py @@ -0,0 +1,15 @@ +import sqlite3 +db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + +# Check when portfolio was last updated +r = db.execute("SELECT updated_at, total_assets, total_mv, cash FROM portfolio_summary WHERE id=1").fetchone() +print(f"Portfolio last updated: {r[0]}") +print(f"total_assets={r[1]} total_mv={r[2]} cash={r[3]}") + +# Check hold prices +print("\nAll holdings:") +for r in db.execute("SELECT code, name, price, change_pct, cost, shares FROM holdings WHERE is_active=1 ORDER BY code"): + mv = (r[2] or 0) * (r[5] or 0) + print(f" {r[0]} {r[1]}: price={r[2]} chg={r[3]} cost={r[4]} shares={r[5]} mv={mv}") + +db.close() diff --git a/scripts/clean_watchlist.py b/scripts/clean_watchlist.py index ab89f13..be8d04e 100644 --- a/scripts/clean_watchlist.py +++ b/scripts/clean_watchlist.py @@ -1,7 +1,11 @@ #!/usr/bin/env python3 """Remove held stocks from watchlist""" -import json, os +import json, os, sys + +# 确保 MoFin 根目录在模块搜索路径中(兼容 cron 环境) +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + from mo_data import read_portfolio, read_decisions, read_watchlist from mofin_db import get_conn, write_watchlist_stock, write_holding_strategy diff --git a/scripts/diagnose_assets.py b/scripts/diagnose_assets.py new file mode 100644 index 0000000..e15941e --- /dev/null +++ b/scripts/diagnose_assets.py @@ -0,0 +1,35 @@ +"""Diagnose: check HK stock costs, prices, total assets calculation""" +import sys +sys.path.insert(0, '/home/hmo/MoFin') +from mo_models import calc_total_assets, calc_total_mv, is_hk_stock, to_cny, get_hk_rate +from mo_data import read_portfolio +import json + +pf = read_portfolio() +holdings = pf.get('holdings', []) +rate = get_hk_rate() + +print(f"HK_RATE: {rate}") +print(f"total_assets (from DB): {pf.get('total_assets')}") +print(f"total_mv (from DB): {pf.get('total_mv')}") +print(f"cash: {pf.get('cash')}") +print(f"frozen_cash: {pf.get('frozen_cash')}") +print(f"position_pct: {pf.get('position_pct')}") + +total_mv_calc = calc_total_mv(holdings) +total_assets_calc = calc_total_assets(pf) +print(f"\ncalc_total_mv: {total_mv_calc}") +print(f"calc_total_assets: {total_assets_calc}") + +print(f"\n=== HK stocks ===") +for h in holdings: + code = h.get('code', '') + if is_hk_stock(str(code)): + cost = h.get('cost', 0) or 0 + price = h.get('price', 0) or 0 + shares = h.get('shares', 0) or 0 + mv = price * shares + cost_calc = cost * shares + pnl = (price - cost) * shares if cost > 0 else 0 + pnl_pct = (price - cost) / cost * 100 if cost > 0 else 0 + print(f" {code} {h.get('name')}: cost={cost} price={price} shares={shares} mv={mv} pnl={pnl:.1f} ({pnl_pct:+.1f}%)") diff --git a/scripts/quick_verify.py b/scripts/quick_verify.py new file mode 100644 index 0000000..de52a94 --- /dev/null +++ b/scripts/quick_verify.py @@ -0,0 +1,25 @@ +"""Quick verification after JSON→DB migration""" +import sys +sys.path.insert(0, '/home/hmo/MoFin') + +from mo_data import read_portfolio, read_decisions, read_watchlist + +ok = 0 +err = 0 + +for name, fn in [("portfolio", read_portfolio), ("decisions", read_decisions), ("watchlist", read_watchlist)]: + try: + r = fn() + if name == "portfolio": + n = len(r.get('holdings', [])) + elif name == "decisions": + n = len(r.get('decisions', [])) + else: + n = len(r.get('stocks', [])) + print(f" {name}: {n} records OK") + ok += 1 + except Exception as e: + print(f" {name}: ERROR -> {e}") + err += 1 + +print(f"\n{ok}/3 passed, {err} errors") diff --git a/scripts/test_api.py b/scripts/test_api.py new file mode 100644 index 0000000..b18e7a9 --- /dev/null +++ b/scripts/test_api.py @@ -0,0 +1,36 @@ +"""Test Eastmoney API response time for HK stocks""" +import urllib.request, json, time + +codes = ['00700', '01888', '00981'] +UA = 'Mozilla/5.0' + +for code in codes: + url = f"https://push2.eastmoney.com/api/qt/stock/get?secid=116.{code}&fields=f43,f170&fltt=2" + start = time.time() + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=30) as r: + resp = json.loads(r.read().decode("utf-8")) + elapsed = time.time() - start + price = resp.get('data', {}).get('f43', '?') + print(f"{code}: {elapsed:.1f}s, price={price}, rc={resp.get('rc')}") + except Exception as e: + elapsed = time.time() - start + print(f"{code}: {elapsed:.1f}s, ERROR: {type(e).__name__}: {e}") + +# Also test Tencent fallback +print("\nTencent fallback:") +url = "http://qt.gtimg.cn/q=hk00700,hk01888,hk00981" +start = time.time() +try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=10) as r: + text = r.read().decode("gbk") + elapsed = time.time() - start + print(f"Tencent: {elapsed:.1f}s, {len(text)} bytes") + # Parse first line + line = text.strip().split('\n')[0] + print(f" sample: {line[:80]}...") +except Exception as e: + elapsed = time.time() - start + print(f"Tencent: {elapsed:.1f}s, ERROR: {e}") diff --git a/scripts/test_interval.py b/scripts/test_interval.py new file mode 100644 index 0000000..15b7b9d --- /dev/null +++ b/scripts/test_interval.py @@ -0,0 +1,22 @@ +"""Test if steady 5s interval avoids rate limit""" +import urllib.request, json, time + +UA = 'Mozilla/5.0' +codes = ['00700', '01888', '00981'] + +for i, code in enumerate(codes): + url = f"https://push2.eastmoney.com/api/qt/stock/get?secid=116.{code}&fields=f43,f170&fltt=2" + start = time.time() + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=5) as r: + resp = json.loads(r.read().decode("utf-8")) + elapsed = time.time() - start + price = resp.get('data', {}).get('f43', '?') + print(f"#{i+1} {code}: OK in {elapsed:.1f}s, price={price}") + except Exception as e: + elapsed = time.time() - start + print(f"#{i+1} {code}: FAIL in {elapsed:.1f}s — {type(e).__name__}") + break + if i < len(codes) - 1: + time.sleep(5) diff --git a/scripts/test_ratelimit.py b/scripts/test_ratelimit.py new file mode 100644 index 0000000..c06f083 --- /dev/null +++ b/scripts/test_ratelimit.py @@ -0,0 +1,49 @@ +"""Test Eastmoney rate limit — find safe interval between requests""" +import urllib.request, json, time + +UA = 'Mozilla/5.0' +CODE = '00700' +url = f"https://push2.eastmoney.com/api/qt/stock/get?secid=116.{CODE}&fields=f43,f170&fltt=2" + +def try_fetch(): + start = time.time() + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=5) as r: + resp = json.loads(r.read().decode("utf-8")) + elapsed = time.time() - start + return True, elapsed, resp.get('data', {}).get('f43', '?'), '' + except Exception as e: + elapsed = time.time() - start + return False, elapsed, 0, str(e)[:50] + +# Test 1: single request +ok, t, price, err = try_fetch() +print(f"Single: {'OK' if ok else 'FAIL'} in {t:.2f}s price={price} {err}") + +if ok: + # If it works, test minimal interval + for delay in [0.5, 1, 2, 3, 5]: + time.sleep(delay) + ok2, t2, p2, e2 = try_fetch() + print(f"After {delay:.1f}s: {'OK' if ok2 else 'FAIL'} in {t2:.2f}s price={p2} {e2}") + if not ok2: + print(f" -> Rate limited! Need > {delay}s between requests") +else: + # Currently blocked, wait and retry + print("Currently blocked. Waiting 10s then retry...") + time.sleep(10) + ok, t, price, err = try_fetch() + print(f"After 10s: {'OK' if ok else 'FAIL'} in {t:.2f}s price={price} {err}") + if ok: + time.sleep(2) + ok2, t2, p2, e2 = try_fetch() + print(f"After +2s: {'OK' if ok2 else 'FAIL'} in {t2:.2f}s price={p2} {e2}") + else: + for w in [20, 30, 60]: + print(f"Waiting {w}s...") + time.sleep(w) + ok, t, price, err = try_fetch() + print(f"After {w}s: {'OK' if ok else 'FAIL'} in {t:.2f}s price={price} {err}") + if ok: + break diff --git a/scripts/verify_pnl.py b/scripts/verify_pnl.py new file mode 100644 index 0000000..d377d5a --- /dev/null +++ b/scripts/verify_pnl.py @@ -0,0 +1,38 @@ +"""Verify total assets and P&L calculation""" +import sqlite3, sys +sys.path.insert(0, '/home/hmo/MoFin') +from mo_models import calc_total_assets, calc_total_mv + +db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') +r = db.execute("SELECT * FROM portfolio_summary WHERE id=1").fetchone() +keys = [d[0] for d in db.execute("SELECT * FROM portfolio_summary LIMIT 0").description] +summary = dict(zip(keys, r)) + +print(f"total_assets (stored): {summary.get('total_assets')}") +print(f"total_mv (stored): {summary.get('total_mv')}") +print(f"total_pnl (stored): {summary.get('total_pnl')}") +print(f"cash: {summary.get('cash')}") +print(f"frozen_cash: {summary.get('frozen_cash')}") + +holdings = [] +for r in db.execute("SELECT code, name, cost, price, shares FROM holdings WHERE is_active=1"): + holdings.append({'code': r[0], 'name': r[1], 'cost': r[2] or 0, 'price': r[3] or 0, 'shares': r[4] or 0}) + +mv = sum(h['price'] * h['shares'] for h in holdings) +total_cost = sum(h['cost'] * h['shares'] for h in holdings) +pnl = mv - total_cost +ta = mv + (summary.get('cash') or 0) + (summary.get('frozen_cash') or 0) + +print(f"\nCalculated:") +print(f"total_mv = {mv:.2f}") +print(f"total_cost = {total_cost:.2f}") +print(f"total_pnl = {pnl:.2f}") +print(f"total_assets = {ta:.2f}") + +# Check for HK stocks with cost=0 (never converted) +print("\nStocks with cost=0 or None:") +for h in holdings: + if h['cost'] <= 0 and h['shares'] > 0: + print(f" {h['code']} {h['name']}: cost={h['cost']} shares={h['shares']} price={h['price']}") + +db.close() diff --git a/scripts/xiaoguo_signal_consumer.py b/scripts/xiaoguo_signal_consumer.py index dd5a898..47bd07f 100644 --- a/scripts/xiaoguo_signal_consumer.py +++ b/scripts/xiaoguo_signal_consumer.py @@ -13,6 +13,10 @@ no_agent模式:有发现→输出,无→静默 import json, os, sqlite3, sys, time, urllib.request from pathlib import Path from datetime import datetime + +# 确保 MoFin 根目录在模块搜索路径中(兼容 cron 环境) +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + from mo_data import read_watchlist from mofin_db import write_watchlist_stock @@ -34,8 +38,13 @@ def clean_proxy(): def fetch_quote(code): """拉行情。DB 优先,腾讯 fallback""" # DB 优先 - try: from mofin_db import get_price_from_db; p, chg = get_price_from_db(code); return {"name":"", "code":code, "price":p, "change_pct":chg or 0} if p else None - except: pass + try: + from mofin_db import get_price_from_db + p, chg = get_price_from_db(code) + if p: + return {"name":"", "code":code, "price":p, "change_pct":chg or 0} + except: + pass # Fallback: 腾讯 try: prefix = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk"