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