tradingSystem/app/services/dividend_feed.py

148 lines
7.8 KiB
Python
Raw Normal View History

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