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('该票今日建议已经结束,请先核对原处理结果')
|