From 03e569e3c55c04991fe617b07d97b3c3534e2c9a Mon Sep 17 00:00:00 2001 From: baobao Date: Thu, 22 Oct 2026 10:37:51 +0800 Subject: [PATCH] =?UTF-8?q?=E9=AB=98=E8=82=A1=E6=81=AF=E5=BB=BA=E4=BB=93?= =?UTF-8?q?=EF=BC=9A=E7=8B=AC=E7=AB=8B=E5=80=99=E9=80=89=E5=A4=8D=E7=94=A8?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=87=86=E5=A4=87=E4=B8=8E=E5=8E=9F=E5=AD=90?= =?UTF-8?q?=E9=87=87=E7=BA=B3=EF=BC=88=E5=8F=B0=E8=B4=A6=20082=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、补齐研究至建仓的缺失入口。未知资料不放行,固定依据变化交后台处理。 二、本地桥9项新增与PMS24项专项通过,PMS48批隔离禁网回归通过。部署另行判收。 三、保留原每日榜单、自主档位、仓位和执行逻辑。新来源人工发起,不合并进原自主扫描,不制造交易验收。 --- DEVLOG.md | 7 + app/core/action_engine.py | 4 + app/core/plan_revision.py | 8 +- app/services/dividend_feed.py | 147 +++++++++++++++++++++ app/services/plan_recheck.py | 22 +++- app/services/proposal_service.py | 31 ++++- app/web/main.py | 33 ++++- app/web/static/index.html | 61 ++++++++- scripts/run_tests.py | 3 +- scripts/test_batch44_units.py | 216 +++++++++++++++++++++++++++++++ scripts/test_wiring.py | 1 + 11 files changed, 509 insertions(+), 24 deletions(-) create mode 100644 app/services/dividend_feed.py create mode 100644 scripts/test_batch44_units.py diff --git a/DEVLOG.md b/DEVLOG.md index 5ae60d0..4b94eec 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2113,3 +2113,10 @@ QMT在线、对账无差异。午休任务按原时段跳过,不制造交易 桥逐票0.208—0.326秒;真实提议预检1.085—1.227秒。自然网格买入后仍保留原500股确认内容。 10:22:38对账无差异、QMT在线。实际用户确认未代验,没有制造交易。 详细备份、回退、历史边界见四仓交接10:25节。 + + +## 2026-10-22 高股息独立候选与建仓准备(开发验证) + +桥提供独立资格契约,PMS通过dividend_feed消费。人工请求先保存准备状态,正常交易时段完成计算与研判,原采纳事务负责最终确认。固定研究、消费凭据与来源跟随提议、审计和指令。原自动扫描候选及所有参数保持。 + +新增24项专项通过。全量48批配置隔离、禁止联网回归通过,含三页面守卫。桥新增9项通过。生产部署与自然采纳尚未判收。详见四仓《高股息候选接入PMS开发规格_2026-10-22.md》及同日交接。 diff --git a/app/core/action_engine.py b/app/core/action_engine.py index e33c620..cb09e52 100644 --- a/app/core/action_engine.py +++ b/app/core/action_engine.py @@ -860,6 +860,7 @@ def eval_open(c: dict, params: dict, caps: dict, room_amt: float): "plan_src": c.get("src"), "sector": c.get("sector"), **{k: c.get(k) for k in ('decision', 'consumption_id', 'consumption_error', 'plan_basis', 'selection_rank', 'research_rank', 'research', 'review_ref')}, + **{k: c[k] for k in ('candidate_source','dividend_ref','dividend_policy_version','dividend_research') if k in c}, # 决策系统今天盘中判过这只票转多没有。这是**定性材料**不是仓位数字, 所以送研判时 # 也带过去 (见 judge.OPEN_JUDGE_KEYS) —— 等于当面告诉它「你自己今天判过这只票转多」, # 它可以拿自己的结论去对照; 没有信号时这两项是 None, 不误导。 @@ -903,6 +904,9 @@ def eval_open(c: dict, params: dict, caps: dict, room_amt: float): + ("; **决策系统今天盘中判过该股转多**" if c.get("sig_buy") else "") + f", 现价 {price}, 目标仓位 {target_pct:.0%} 约 {full_amt:,.0f} 元, " f"先建底仓 {qty} 股 (约 {qty * price:,.0f} 元)") + if c.get('candidate_source') == 'dividend': + reason = (f"高股息建仓:研究核验通过,现价 {price},目标仓位 {target_pct:.0%}," + f"先建底仓 {qty} 股(约 {qty * price:,.0f} 元)") cand = _cand(c, A_OPEN, BUY, qty, reason, hard) # 这两项是 OPEN 独有的, 供 proposal_service 用: # price —— 新票在账本里没有行, _pos_of 拿不到现价, 取它会是 0 而被规则闸判 PRICE_MISSING diff --git a/app/core/plan_revision.py b/app/core/plan_revision.py index 9e58e96..b7eefd9 100644 --- a/app/core/plan_revision.py +++ b/app/core/plan_revision.py @@ -10,9 +10,15 @@ def fingerprint(value): def basis(row): # 排名与审计时间不是资格/风险/数量依据。完整评析引用变化需要重新研判。 - return {k: row.get(k) for k in ('score', 'tier', 'upside', 'heat', 'theme', + result = {k: row.get(k) for k in ('score', 'tier', 'upside', 'heat', 'theme', 'verdict', 'risk', 'missing', 'reasons', 'logic_state', 'company_gate', 'company_review', 'company_review_text', 'events', 'pricing_state', 'news')} + if row.get('candidate_source') == 'dividend': + result.update({k: row.get(k) for k in ('candidate_source', 'dividend_ref', 'dividend_policy_version')}) + # 资料年龄不是研究变化;固定报告引用和研究内容才是。 + result['company_review'] = {k:v for k,v in (row.get('company_review') or {}).items() if k != 'age_days'} + result['review_ref'] = row.get('review_ref') + return result def portfolio_context(view, commitments, stock=None): diff --git a/app/services/dividend_feed.py b/app/services/dividend_feed.py new file mode 100644 index 0000000..fbc20d1 --- /dev/null +++ b/app/services/dividend_feed.py @@ -0,0 +1,147 @@ +"""高股息独立候选来源。人工发起,复用后台准备与原子采纳,不参与原主榜扫描。""" +from copy import deepcopy +from datetime import datetime, timezone, timedelta +import logging +import re +import requests +from app.core.plan_revision import fingerprint, basis +from app.core import tradedays as td +from app.services.plan_feed import PlanFeedError + +SOURCE = 'dividend' +BJ = timezone(timedelta(hours=8)) +logger = logging.getLogger('pms.dividend') + + +def read(*, code=None): + from app.services import param_store + if code and not re.fullmatch(r'\d{6}\.(SH|SZ|BJ)', code): + raise ValueError('股票代码格式无效') + base = (param_store.get('PMS_PLAN_API_BASE', '') or '').rstrip('/') + if not base: + raise PlanFeedError('高股息研究地址尚未配置') + try: + res = requests.get(base+'/research/dividend/candidates', params={'code':code} if code else {}, timeout=(1,3)) + res.raise_for_status() + data = res.json() + now = datetime.now(BJ) + if td.calendar_degraded(): + raise ValueError('交易日历暂不可核对') + expected = str(td.prev_trade_day(now.date())) + if data['schema_version'] != 1 or data['source'] != SOURCE or data['data_date'] != expected: + raise ValueError('高股息资料尚未更新到上一交易日') + decision = data['decision'] + if (decision.get('source') != SOURCE or decision.get('policy_version') != data.get('policy_version') + or not re.fullmatch(r'dividend_[0-9a-f]{64}', decision['id'])): + raise ValueError('高股息研究来源不完整') + for value in (decision['cutoff_at'], data['checked_at']): + at = datetime.fromisoformat(value.replace('Z','+00:00')) + if at.tzinfo is None or at > now: + raise ValueError('研究检查时间无法核对') + codes = set() + for row in data['rows']: + c = row['candidate'] + if (not re.fullmatch(r'\d{6}\.(SH|SZ|BJ)', row['ts_code']) or row['ts_code'] in codes + or (code and row['ts_code'] != code) or c['ts_code'] != row['ts_code'] + or c.get('candidate_source') != SOURCE or c.get('decision') != decision + or not isinstance(row.get('eligible'), bool)): + raise ValueError('高股息候选内容无法核对') + codes.add(row['ts_code']) + if row['eligible'] and (c.get('verdict') != '候选' or row.get('reasons') + or not re.fullmatch('[0-9a-f]{64}', str((c.get('dividend_research') or {}).get('ref', '')))): + raise ValueError('高股息候选缺少准入依据') + return data + except PlanFeedError: + raise + except (requests.RequestException, KeyError, TypeError, ValueError) as e: + logger.warning('高股息来源暂不可用: %s', type(e).__name__) + message = str(e) if isinstance(e, ValueError) and not isinstance(e, requests.RequestException) else '高股息研究暂不可用,恢复后可继续' + raise PlanFeedError(message) from e + + +def candidates(*, held=(), black=(), recheck_code=None, record=True, route_by_verdict=True): + data = read(code=recheck_code) + items, excluded = [], [] + for row in data['rows']: + reason = ('已持仓' if row['ts_code'] in held else '在黑名单中' if row['ts_code'] in black + else ';'.join(row['reasons']) if not row['eligible'] else '') + if reason: + excluded.append({'ts_code':row['ts_code'], 'why':reason}) + else: + c = deepcopy(row['candidate']) + c['dividend_ref'] = row['research_ref'] + c['dividend_policy_version'] = data['policy_version'] + items.append(c) + consumption = {'kind':'rechecked' if recheck_code else 'consumed', 'source':SOURCE, + 'decision':data['decision'], 'params':{'policy_version':data['policy_version']}, + 'context':{'held':sorted(held),'black':sorted(black),'recheck_code':recheck_code}, + 'rank_mode':'code', 'filtered':excluded, 'order':[r['ts_code'] for r in items]} + cid = fingerprint(consumption) + for row in items: + row.update(consumption_id=cid, plan_basis=basis(row)) + out = {'items':items, 'decision':data['decision'], 'date':data['data_date'], + 'display_only':[], 'filtered':excluded, 'rank_mode':'code', 'consumption':dict(consumption,id=cid)} + if record: + from app.repo import pms_repo + try: + pms_repo.insert_plan_snapshot(plan_date=data['data_date'],digest=cid[:40],roster=items, + meta=out['consumption'],n_main=len(items)) + except Exception as e: + logger.exception('高股息消费凭据保存失败') + out['consumption']['error'] = type(e).__name__ + for row in items: + row['consumption_error'] = type(e).__name__ + return out + + +def request_preparation(code, research_ref, *, actor): + """只排入准备,不计算数量或调用模型。确定性编号与唯一键拦重复请求。""" + from app.services import portfolio, command_service, proposal_service as ps, param_store + from app.services.execution_conflicts import buy_blocker + from app.repo import pms_repo + now = datetime.now() + if not td.is_trade_day(now.date()) or now.hour >= 15 or td.calendar_degraded(): + raise ValueError('请在交易日收盘前发起,建议当日有效') + if (param_store.get_bool('PMS_GLOBAL_EXEC_HALT',False) + or param_store.get('PMS_AUTONOMY','propose_only') == 'off' + or param_store.get('PMS_OPEN_AUTONOMY','full') == 'off'): + raise ValueError('建仓评估已暂停') + data = read(code=code) + row = next((r for r in data['rows'] if r['ts_code'] == code), None) + if not row or not row['eligible']: + raise ValueError('暂不具备建仓评估条件:'+(';'.join(row['reasons']) if row else '未进入有效研究名单')) + if not research_ref or row['research_ref'] != research_ref: + raise ValueError('研究内容已更新,请刷新后查看') + pid = f"PRP_{td.ymd(now)}_{code.replace('.','')}_OPEN" + old = pms_repo.get_proposal(pid) + if old: + return _existing(old) + stock = command_service.effective_stock_params() + view = portfolio.positions_view() + if any(r['ts_code'] == code for r in view['held']) or (stock.get(code) or {}).get('black'): + raise ValueError('该票已持仓或在黑名单中,不能新建仓') + blocked = buy_blocker(code) + if blocked: + raise ValueError(blocked) + if code in ps._inflight_open_commitments(view, strict=True)['names']: + raise ValueError('该票已有建仓提议或在途买单,请查看现有进度') + hard = {'candidate_source':SOURCE,'dividend_ref':research_ref,'dividend_policy_version':data['policy_version'], + 'decision':data['decision'],'requested_by':actor,'requested_at':now.astimezone().isoformat(), + 'name':row.get('name'), 'preparation':{'status':'pending','stage':'requested', + 'reason':'等待交易时段完成建仓评估'}} + try: + pms_repo.insert_proposal(proposal_id=pid,ts_code=code,action='OPEN',qty=0,hard_numbers=hard, + expire_at=ps.proposal_expire_at(ttl_hours=24),judge_verdict=None,judge_reason='高股息建仓评估待完成') + except Exception: + old = pms_repo.get_proposal(pid) + if old: + return _existing(old) + raise + return {'ok':True,'proposal_id':pid,'preparing':True,'message':'已排入建仓评估,准备完成后可确认'} + + +def _existing(old): + if old['status'] in ('WAIT_USER','ACCEPTED'): + return {'ok':True,'proposal_id':old['proposal_id'],'already_requested':True, + 'message':'已有该票建议,请查看待确认提议或指令进度'} + raise ValueError('该票今日建议已经结束,请先核对原处理结果') diff --git a/app/services/plan_recheck.py b/app/services/plan_recheck.py index 41d791c..4ace2e9 100644 --- a/app/services/plan_recheck.py +++ b/app/services/plan_recheck.py @@ -13,8 +13,13 @@ def current_basis(hard, code, proposal_id=None, *, record=True): view = portfolio.positions_view() stock = command_service.effective_stock_params() params = ps._scan_params(view) - options = {'force': True} if record else {'record': False, 'plan_override': plan_feed.read_current_plan(code=code)} - selected = plan_feed.candidates(held=[x['ts_code'] for x in view['held']], + feed = plan_feed + if hard.get('candidate_source') == 'dividend': + from app.services import dividend_feed as feed + options = {'record': record} + else: + options = {'force': True} if record else {'record': False, 'plan_override': plan_feed.read_current_plan(code=code)} + selected = feed.candidates(held=[x['ts_code'] for x in view['held']], black=[c for c, v in stock.items() if v.get('black')], route_by_verdict=bool(params.get('open_route_by_verdict')), recheck_code=code, **options) current = next((r for r in selected['items'] if r['ts_code'] == code), None) @@ -31,7 +36,10 @@ def current_basis(hard, code, proposal_id=None, *, record=True): if result == 'unavailable': return {'ok': False, 'error': '当前研究资料无法核对,恢复后自动更新建议', 'stage': 'source'} ph = fingerprint({k: v for k, v in params.items() if not k.startswith('_')}) - commitments = ps._inflight_open_commitments(view, exclude_proposal=proposal_id) + commitments = ps._inflight_open_commitments(view, exclude_proposal=proposal_id, + **({'strict':True} if hard.get('candidate_source') == 'dividend' else {})) + if hard.get('candidate_source') == 'dividend' and code in commitments['names']: + return {'ok':False, 'withdraw':True, 'material':True, 'error':'该票已有其他建仓承诺,当前建议已撤下'} context = portfolio_context(view, commitments, stock.get(code)) changed = {'research_changed': result == 'material', 'params_changed': hard.get('scan_params_hash') != ph, @@ -181,8 +189,12 @@ def refresh_pending(*, now=None, deadline=None): from app.services import proposal_service as ps from app.repo import pms_repo results = [] + from app.services import param_store + legacy_on = param_store.get_bool('PMS_PLAN_VERSION_RECHECK', False) rows = pms_repo.list_proposals(statuses=('WAIT_USER',), limit=200) for p in rows: + if not legacy_on and (p.get('hard_numbers') or {}).get('candidate_source') != 'dividend': + continue if p.get('action') != 'OPEN' or (p.get('hard_numbers') or {}).get('kind') == 'strategy' or ps.is_expired(p, now): continue try: @@ -246,7 +258,8 @@ def refresh_one(p, *, now=None, deadline=None): mkt = {} params['_mkt'] = mkt fresh = ps._scan_open(check['view'], params, check['stock'], {}, mkt, out, now=now, - review_code=p['ts_code'], exclude_proposal=p['proposal_id']) + review_code=p['ts_code'], exclude_proposal=p['proposal_id'], + **({'candidate_source':'dividend'} if hard.get('candidate_source') == 'dividend' else {})) if not fresh: return _failed_recheck(p, out) c = fresh[0] @@ -258,6 +271,7 @@ def refresh_one(p, *, now=None, deadline=None): if not result: return _failed_recheck(p, out) hn = c['hard_numbers'] + hn.update({k:hard[k] for k in ('requested_by','requested_at') if k in hard}) latest = current_basis(hn, p['ts_code'], proposal_id=p['proposal_id']) if not latest.get('ok'): return _pending_response(p, latest.get('error') or '建议依据再次变化,下一轮继续更新', latest.get('stage', 'changed')) diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py index e8d8d12..142907d 100644 --- a/app/services/proposal_service.py +++ b/app/services/proposal_service.py @@ -235,7 +235,7 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict: 0, param_store.get_int("PMS_JUDGE_TICK_BUDGET_SEC", 150)) for group_index, group in enumerate((list(scanned['candidates']), open_cands)): - if group_index == 1 and not dry_run and param_store.get_bool('PMS_PLAN_VERSION_RECHECK', False): + if group_index == 1 and not dry_run: from app.services.plan_recheck import refresh_pending out['prepared_proposals'] = refresh_pending(now=now, deadline=deadline) for c in group: @@ -315,12 +315,13 @@ def _save_tech_exit_done(codes) -> None: # ================================================================ 新建仓的取数与筛选 def _scan_open(view, params, stock_params, skip, mkt, out, now=None, - review_code=None, exclude_proposal=None, record_consumption=True) -> list: + review_code=None, exclude_proposal=None, record_consumption=True, candidate_source='plan') -> list: """候选池 → 新建仓候选。产出的候选数天生不超过剩余名额 (名额与金额在纯逻辑里边走边扣)。 与命令驱动那条路 (command_service._candidates) 有意不同的两点: 1. **只认上游计划接口这一个来源。** buy_plan 那张旧表在目标架构下没有明确的写入方, 白名单是给人下命令用的 —— 无人值守地建仓, 事实源必须单一。 + 人工发起的高股息准备显式传来源,只从原来源重核,不加入此自主扫描。 2. **价格只认实时价。** 那边用 market.plan_price, 拿不到实时价会回落昨收; 它自己的 注释就写着「拿昨收当现价去做不追高这类判断会出错, 只给规划期定量用」。 这条路是要真下单的, 所以走 market.get_prices, 取不到就整只跳过并留痕。 @@ -336,7 +337,7 @@ def _scan_open(view, params, stock_params, skip, mkt, out, now=None, src = (param_store.get("PMS_CANDIDATE_SOURCE", plan_feed.SRC_PLAN_API) or plan_feed.SRC_PLAN_API).strip() - if src == plan_feed.SRC_BUY_PLAN: + if src == plan_feed.SRC_BUY_PLAN and candidate_source == 'plan': out["skipped"].append({"action": ae.A_OPEN, "why": f"候选池来源是 {src}, 自主新建仓只认上游计划接口"}) return [] @@ -346,7 +347,10 @@ def _scan_open(view, params, stock_params, skip, mkt, out, now=None, try: # 按判决分流开着时, 上游判为「仅展示」的票在候选阶段就剔掉 (理由见 # plan_feed.select_candidates 的说明: 不剔的话它们会白占 top_n 的名额)。 - sel = plan_feed.candidates(held=held, black=black, + feed = plan_feed + if candidate_source == 'dividend': + from app.services import dividend_feed as feed + sel = feed.candidates(held=held, black=black, route_by_verdict=bool(params.get("open_route_by_verdict")), **({'recheck_code': review_code} if review_code else {}), **({'record': False} if not record_consumption else {})) @@ -393,8 +397,12 @@ def _scan_open(view, params, stock_params, skip, mkt, out, now=None, # 名额与金额都要扣掉**在途的建仓承诺** (2026-08-28 审查修): names_count 只数已成交 # 持仓, 在途 OPEN 指令与等拍板的 OPEN 提议两头都不占 —— 候选没进买入区间时指令滞留 # 在途, 下一跳名额照旧又放两只, 几分钟就把持仓数上限击穿。同票去重挡不住换一只票。 - inflight = (_inflight_open_commitments(view, exclude_proposal=exclude_proposal) - if exclude_proposal else _inflight_open_commitments(view)) + if candidate_source == 'dividend': + inflight = _inflight_open_commitments(view, exclude_proposal=exclude_proposal, strict=True) + cands = [c for c in cands if c['ts_code'] not in inflight['names']] + else: + inflight = (_inflight_open_commitments(view, exclude_proposal=exclude_proposal) + if exclude_proposal else _inflight_open_commitments(view)) slots = int(p["max_names"] or 0) - int(t["names_count"] or 0) - len(inflight["names"]) room = float(p["portfolio_cap"] or 0) * float(p["scale"] or 0) \ - float(t["portfolio_mv"] or 0) - inflight["amount"] @@ -602,7 +610,7 @@ def _no_candidate_why(sel) -> str: return head + ":" + ",".join(parts) + "。" -def _inflight_open_commitments(view, exclude_proposal=None) -> dict: +def _inflight_open_commitments(view, exclude_proposal=None, strict=False) -> dict: """在途的新建仓承诺: names 为代码集合,amount 为预估占用,basis 为稳定核验依据。 口径: 在途 (LIVE) 的 OPEN 指令 + 等拍板 (WAIT_USER) 的 OPEN 提议, 只算**尚未持有** @@ -620,6 +628,8 @@ def _inflight_open_commitments(view, exclude_proposal=None) -> dict: basis.append(('instruction', str(i.get('instruction_id') or ''), i['ts_code'], int(i.get('qty') or 0), int(i.get('exec_qty') or 0))) except Exception as e: + if strict: + raise logger.warning("读在途 OPEN 指令失败 (按无在途): %s", e) try: for pr in pms_repo.list_proposals(statuses=("WAIT_USER",), limit=200): @@ -632,6 +642,8 @@ def _inflight_open_commitments(view, exclude_proposal=None) -> dict: basis.append(('proposal', str(pr.get('proposal_id') or ''), pr['ts_code'], int(pr.get('qty') or 0), 0)) except Exception as e: + if strict: + raise logger.warning("读在途 OPEN 提议失败 (按无在途): %s", e) if not rows: return {"names": names, "amount": 0.0, "basis": []} @@ -1468,6 +1480,11 @@ def _make_proposal(c, price, verdict) -> str: judge_verdict=verdict.get("verdict"), judge_reason=(verdict.get("reason") or "")[:500]) except Exception: + existing = pms_repo.get_proposal(pid) + if existing and existing.get('status') in ('WAIT_USER', 'ACCEPTED'): + return pid + if existing is None: + raise # 确定性编号被当日已终态 (过期/驳回) 的旧提议占着 —— 补时间后缀重试一次, # 不让唯一键冲突把整轮扫描刷成 error (2026-08-28 审查修; 用户驳回的当日 # 不重提另有 _declined_today_keys 挡在扫描入口, 这里兜的是过期重生成) diff --git a/app/web/main.py b/app/web/main.py index 7c3c6b9..21c6eaf 100644 --- a/app/web/main.py +++ b/app/web/main.py @@ -476,9 +476,10 @@ def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100, tech_service.attach(rows) except Exception as e: # noqa: BLE001 logger.warning("[提议] 技术面立场挂不上: %s", e) - if param_store.get_bool('PMS_PLAN_VERSION_RECHECK', False): - for row in rows: - h = row.get('hard_numbers') or {} + legacy_on = param_store.get_bool('PMS_PLAN_VERSION_RECHECK', False) + for row in rows: + h = row.get('hard_numbers') or {} + if (legacy_on or h.get('candidate_source') == 'dividend'): if row.get('action') == 'OPEN' and h.get('kind') != 'strategy': preparation = h.get('preparation') or {} row['accept_ready'] = preparation.get('status') == 'ready' and bool(h.get('consumption_id')) @@ -487,6 +488,23 @@ def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100, return ok(_list) +@app.get("/api/dividend/candidates") +def api_dividend_candidates(): + from app.services import dividend_feed + return ok(dividend_feed.read) + + +@app.post("/api/dividend/proposals") +def api_dividend_proposal(request: Request, payload: dict = Body(default={})): + from app.services import dividend_feed + actor = _actor(request, payload) + result = ok(dividend_feed.request_preparation, str(payload.get('ts_code') or ''), + payload.get('research_ref'), actor=actor) + _oplog('dividend_prepare', ok_flag=result.get('ok',False), reason=result.get('error') or result.get('message'), + ts_code=payload.get('ts_code'), ref=result.get('proposal_id'), by=actor) + return result + + @app.get("/api/proposals/decide-reasons") def api_decide_reasons(): """裁决预设理由的两张码表 (采纳 / 驳回), 页面据此铺按钮。台账 015。""" @@ -549,7 +567,8 @@ def api_decide(request: Request, proposal_id: str, payload: dict = Body(default= return {'ok': False, 'error': blocked, 'proposal_id': proposal_id} expected_hard = None if (decision == 'ACCEPTED' and p['action'] == ae.A_OPEN - and param_store.get_bool('PMS_PLAN_VERSION_RECHECK', False)): + and (param_store.get_bool('PMS_PLAN_VERSION_RECHECK', False) + or (p.get('hard_numbers') or {}).get('candidate_source') == 'dividend')): from app.services.plan_recheck import before_accept checked = before_accept(p, payload.get('proposal_revision')) if not checked.get('ok'): @@ -567,7 +586,8 @@ def api_decide(request: Request, proposal_id: str, payload: dict = Body(default= instruction = {'id': iid, 'window': window, 'progress': { 'from_proposal': proposal_id, 'is_command': False, 'children': [], 'plan': {k: hn.get(k) for k in ('decision', 'consumption_id', 'review_ref', - 'execution_rank', 'priority_reason', 'version_check')}, + 'execution_rank', 'priority_reason', 'version_check', + 'candidate_source','dividend_ref','dividend_policy_version')}, 'deadline': str(td.window_deadline(None, window))}} if pms_repo.accept_open_proposal(p, hn, instruction, reason): return {'ok': True, 'decision': decision, 'instruction_id': iid} @@ -632,7 +652,8 @@ def api_decide(request: Request, proposal_id: str, payload: dict = Body(default= status="PROPOSED", progress={"from_proposal": proposal_id, "is_command": False, "children": [], 'plan': {k: hn.get(k) for k in ('decision', 'consumption_id', 'review_ref', - 'execution_rank', 'priority_reason', 'version_check')}, + 'execution_rank', 'priority_reason', 'version_check', + 'candidate_source','dividend_ref','dividend_policy_version')}, "deadline": str(td.window_deadline(None, _win))}) # 与自主执行同一口径: 采纳即算「做过一次」, 计数器要跟着走 # (否则页面采纳的那条动作绕开了 §6 的一次性约束) diff --git a/app/web/static/index.html b/app/web/static/index.html index 7bf6a77..7c86634 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -1250,7 +1250,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} 公司深度那句最长 —— 现在卡面只留结论标签, 整句进弹窗。判定用的键一个没动。 -->
- 选股系统 + {{ (p.hard_numbers||{}).candidate_source==='dividend' ? '高股息研究' : '选股系统' }} {{ propPlanTag(p).text }} {{ propPlanSub(p) }}›
@@ -1404,6 +1404,26 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
+
+
高股息研究 + · {{ dividend.eligible_count || 0 }} 只可评估{{ sectClosed.dividend?'▸':'▾' }}
+
+
股息数据截至 {{ dividend.data_date }} · 人工发起建仓评估
+
{{ dividend.error }}
+
暂无公司通过全部核验。
+
+
{{ r.name || r.ts_code }}滚动股息 {{ Number(r.yield_ttm_pct).toFixed(2) }}%
+
{{ r.eligible ? '可评估建仓' : r.conclusion }}
+
依据与待核验事项 +

{{ r.thesis }}

风险:{{ r.antithesis }}

+
{{ reason }}
+
+ 准备建仓建议 +
+ +
+
@@ -2154,7 +2174,14 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
为什么选它
· {{ x }}
-
榜单
{{ propWhy(propDetailP) }}
+ +
榜单
{{ propWhy(propDetailP) }}