"""高股息独立候选来源。人工发起,复用后台准备与原子采纳,不参与原主榜扫描。""" 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('该票今日建议已经结束,请先核对原处理结果')