feat(sector_radar): enhance sector radar functionality with active moneyflow and detailed metrics
- Introduced ActiveMoneyflowSource to fetch optional active-order flow, enhancing the sector radar's data capabilities. - Updated StockFactRecord and DailyAggregateRecord to include pct_change and active_buy_net_amount_yuan for improved financial insights. - Modified the build process to incorporate active moneyflow data without invalidating main rankings on failure. - Enhanced the HTTP API to return detailed sector history and metrics, including pct_change and active buy metrics for members. - Updated tests to validate the new functionality and ensure data integrity across various scenarios.
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
"""Collect one real sector into an isolated PostgreSQL snapshot store.
|
||||
|
||||
Run with the server's uv environment from zhixing-server. No production
|
||||
publication is created; facts must be read back from this store for validation.
|
||||
"""
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
import json
|
||||
import time
|
||||
import tushare as ts
|
||||
from zhixing_server.bootstrap.config import Settings
|
||||
from zhixing_server.modules.sector_radar.domain.source import build_source_snapshot
|
||||
from zhixing_server.modules.sector_radar.infrastructure.postgres import PostgresSectorRadarRepository
|
||||
|
||||
|
||||
def main():
|
||||
"""Persist provider responses before inspecting their values; redact errors."""
|
||||
settings = Settings(_env_file='../.env')
|
||||
url = urlsplit(settings.database_url)
|
||||
host = url.netloc.rsplit('@', 1)[0] + '@127.0.0.1:5433'
|
||||
database = urlunsplit((url.scheme, host, '/radar_detail_selftest_0906', url.query, ''))
|
||||
repository = PostgresSectorRadarRepository(database, max_connections=2)
|
||||
client = ts.pro_api(settings.tushare_token)
|
||||
target = date(2026, 9, 4)
|
||||
counts = []
|
||||
|
||||
def collect(api, fields, **params):
|
||||
"""Store each raw response atomically and return persisted rows."""
|
||||
time.sleep(0.25)
|
||||
frame = client.query(api, fields=fields, **params)
|
||||
snapshot = build_source_snapshot(api_name=api, params=params,
|
||||
rows=frame.to_dict('records'), target_trade_date=target)
|
||||
repository.save_source_snapshots((snapshot,))
|
||||
counts.append({'api':api, 'rows':snapshot.row_count, 'snapshot':snapshot.snapshot_id})
|
||||
return snapshot.rows
|
||||
|
||||
try:
|
||||
collect('dc_index','ts_code,trade_date,name,idx_type,level,pct_change,leading_code',
|
||||
trade_date='20260904', ts_code='BK1147.DC')
|
||||
members = collect('dc_member','trade_date,ts_code,con_code,name',
|
||||
trade_date='20260904', ts_code='BK1147.DC')
|
||||
collect('stock_basic','ts_code,symbol,name,market,exchange,list_status,list_date,delist_date',list_status='L')
|
||||
collect('suspend_d','ts_code,trade_date,suspend_timing,suspend_type',trade_date='20260904')
|
||||
collect('trade_cal','exchange,cal_date,is_open,pretrade_date',exchange='SSE',start_date='20260720',end_date='20260904')
|
||||
for member in members:
|
||||
code = member['con_code']
|
||||
collect('daily','ts_code,trade_date,close,pre_close,pct_chg,vol,amount',trade_date='20260904',ts_code=code)
|
||||
collect('moneyflow_dc','trade_date,ts_code,name,net_amount,net_amount_rate,pct_change,close',trade_date='20260904',ts_code=code)
|
||||
collect('moneyflow','trade_date,ts_code,net_mf_amount',trade_date='20260904',ts_code=code)
|
||||
result={'status':'collected','sector':'BK1147.DC','trade_date':str(target),'members':len(members),'snapshots':counts}
|
||||
except Exception as exc:
|
||||
result={'status':'partial','snapshots':counts,'error_type':type(exc).__name__,
|
||||
'message':str(exc).replace(settings.tushare_token,'[redacted]')[:300]}
|
||||
finally:
|
||||
repository.close()
|
||||
Path('../.trellis/tasks/09-06-capital-radar-daily-detail/research/collection-result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2))
|
||||
print(json.dumps({k:v for k,v in result.items() if k!='snapshots'},ensure_ascii=False))
|
||||
print('Stored snapshots:',len(counts))
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,243 @@
|
||||
{
|
||||
"status": "collected",
|
||||
"sector": "BK1147.DC",
|
||||
"trade_date": "2026-09-04",
|
||||
"members": 14,
|
||||
"snapshots": [
|
||||
{
|
||||
"api": "dc_index",
|
||||
"rows": 1,
|
||||
"snapshot": "73f94be4b5c94238b611396b7cdf2e314f17cdd23532c3773febf3e3277f1674"
|
||||
},
|
||||
{
|
||||
"api": "dc_member",
|
||||
"rows": 14,
|
||||
"snapshot": "2046df68966d3eda9a392b0254b84576842c42c487ea0a2f1044b6cc75de3afa"
|
||||
},
|
||||
{
|
||||
"api": "stock_basic",
|
||||
"rows": 5556,
|
||||
"snapshot": "1c87ce65e081e670add1c1167473f798e97e8c9cf41db3e4ac71df0e3de5bb90"
|
||||
},
|
||||
{
|
||||
"api": "suspend_d",
|
||||
"rows": 8,
|
||||
"snapshot": "b91d0981345538463407939a7ed863b8fb8de5e447b8ec731b8767b7ae13aa5d"
|
||||
},
|
||||
{
|
||||
"api": "trade_cal",
|
||||
"rows": 47,
|
||||
"snapshot": "afff3cba955380cc0af9cdaac1d8861c17a70b089c70ea95aadb1cc568f8a26e"
|
||||
},
|
||||
{
|
||||
"api": "daily",
|
||||
"rows": 1,
|
||||
"snapshot": "4f2c4f92685180b3de23a26c9d0602a8acf3fedb7dbc2413ac92bda76d8fa65a"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow_dc",
|
||||
"rows": 1,
|
||||
"snapshot": "7c1907d19c2db8134a807edba87fe165b44a1e96d6b3731e65a26f52715f6f12"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow",
|
||||
"rows": 1,
|
||||
"snapshot": "521254cf598c28608571973eeec1e52c56c856ee547bbd2d9ceacc4e1a29727e"
|
||||
},
|
||||
{
|
||||
"api": "daily",
|
||||
"rows": 1,
|
||||
"snapshot": "9bf1ca69c3be480113c933c25681dab1379e0a068bdf9a31452794de8c4bff94"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow_dc",
|
||||
"rows": 1,
|
||||
"snapshot": "8b81006f266edabf8ac1e2aa292e9b278ca9131eea47f86fbef89373197215f6"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow",
|
||||
"rows": 1,
|
||||
"snapshot": "e90138a791bf7e3d458d3865838ac3aee394fe491ebf93cb363425852d1bca92"
|
||||
},
|
||||
{
|
||||
"api": "daily",
|
||||
"rows": 1,
|
||||
"snapshot": "81ca417096a073698375daa03f0a660398d53c3943b16c9598a8faa918ff177f"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow_dc",
|
||||
"rows": 1,
|
||||
"snapshot": "40fb4ad0d2bbe8413e6dfcc213e8efe7f3367f84f492f68e430f1a9d08e9cc51"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow",
|
||||
"rows": 1,
|
||||
"snapshot": "47d32d618d5d8a2118253b1e3d7b354628bae22a7b10880f4cfdd3ee370dae22"
|
||||
},
|
||||
{
|
||||
"api": "daily",
|
||||
"rows": 1,
|
||||
"snapshot": "2418e65c6800ef5633e6f260cbe617eb3b5bbda4d71e16e56589cd1603827b6b"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow_dc",
|
||||
"rows": 1,
|
||||
"snapshot": "d3559b1413d931dfe882fa64f501b75c76c91125fa0fbe616b385e9a3b7c9e15"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow",
|
||||
"rows": 1,
|
||||
"snapshot": "76a6e0cbe16e92c707af828a9936d87bd810844c357256c18e4d9ef10d86e40d"
|
||||
},
|
||||
{
|
||||
"api": "daily",
|
||||
"rows": 1,
|
||||
"snapshot": "a3307443f9e89ff5523043055509dc309c6766067a5d9eb83bdef5d19e6e44c1"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow_dc",
|
||||
"rows": 1,
|
||||
"snapshot": "8d4c401bf8ade3dae6c0eab35069d59e61d4c6383b190e21a420d7735d0ab5a2"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow",
|
||||
"rows": 1,
|
||||
"snapshot": "ee3a90e0e406940f60358edfa3688f8774687bc768c4e9c069ca6d8337a39bcc"
|
||||
},
|
||||
{
|
||||
"api": "daily",
|
||||
"rows": 1,
|
||||
"snapshot": "bf21400564f20c771905d46be0dd225eb9d0579393ab5734038ba93db63042f4"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow_dc",
|
||||
"rows": 1,
|
||||
"snapshot": "66611bdd15e87cd5ccec0f48d7e94282cedb210f0393a313519b6896abf0de1f"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow",
|
||||
"rows": 1,
|
||||
"snapshot": "eb98e9d138478b7ec2ae57557a1e61d34f825fd9faf8f30ef530c4d54f5e391e"
|
||||
},
|
||||
{
|
||||
"api": "daily",
|
||||
"rows": 1,
|
||||
"snapshot": "550627fe30dccb760e4dbc73dd0ecee85676f781dac1ba059483e7d83b701091"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow_dc",
|
||||
"rows": 1,
|
||||
"snapshot": "5d1e6b1222b37896ad79d759e8fb8a240fb438bf6d50eda687c56181648c3a12"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow",
|
||||
"rows": 1,
|
||||
"snapshot": "7bc22506f40cd9c550a48699c93bee28f00c8717e304cd26761b33d9e66dbf35"
|
||||
},
|
||||
{
|
||||
"api": "daily",
|
||||
"rows": 1,
|
||||
"snapshot": "39dda855afcfcf4e32ccd67e4cd28942f8eed13a171b82fa438000bfcff87252"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow_dc",
|
||||
"rows": 1,
|
||||
"snapshot": "b9042adf9d7c2823a7155691c6c349f451b6d84fe7eb2ca83ab749a51eaa6c6e"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow",
|
||||
"rows": 1,
|
||||
"snapshot": "aed26a777f4d4eae9236b677d6ed7180a093aa49076eeee3b796325db3ce2e9f"
|
||||
},
|
||||
{
|
||||
"api": "daily",
|
||||
"rows": 1,
|
||||
"snapshot": "26d7d7beb26a8318f9b1104b5bc7446562663bba3e7142d9957562f5dce16c64"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow_dc",
|
||||
"rows": 1,
|
||||
"snapshot": "560d0e8e0a1d1220acab7a4d22ba6aadb4d47555344f75bbdbaf7650d62d95bf"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow",
|
||||
"rows": 1,
|
||||
"snapshot": "7f6759aea68eb193c271c6f8230de9237bcd25bb445eef786adc5b39d5527e08"
|
||||
},
|
||||
{
|
||||
"api": "daily",
|
||||
"rows": 1,
|
||||
"snapshot": "467d8fa44adf1aeecb81bb970a3239db4f48c75ec680bd802310ba0a40b9a92f"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow_dc",
|
||||
"rows": 1,
|
||||
"snapshot": "cc80509ebf2c47e85cb1eebe0735db7c84facd01eba73212db218fbabcf6c980"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow",
|
||||
"rows": 1,
|
||||
"snapshot": "7f145408f62626e955b83617821e4c41ce0af3353533de790de22211f02ba36c"
|
||||
},
|
||||
{
|
||||
"api": "daily",
|
||||
"rows": 1,
|
||||
"snapshot": "e515db0257399b1433fc14c4062a020a4837dfe6ff968cd70ab2b60f1035794e"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow_dc",
|
||||
"rows": 1,
|
||||
"snapshot": "31aa693eb092cf03a7b1e0da65ffd46e79389831f93809e6738789bfa6e8d619"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow",
|
||||
"rows": 1,
|
||||
"snapshot": "b4073fdbd1e5f73c52d64aae07ea3dd8a9613f12cdea316abcbbaf4bdff6f28e"
|
||||
},
|
||||
{
|
||||
"api": "daily",
|
||||
"rows": 1,
|
||||
"snapshot": "ffd0d7d09fee0dc7e5a035c9131220042563a8d5c852ff5ca8d0a4d8b7ba8004"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow_dc",
|
||||
"rows": 1,
|
||||
"snapshot": "7e9bff54c0b00afc12bbcc7a98dfcd7e504ce780e47ac7a3c3443e989d9da031"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow",
|
||||
"rows": 1,
|
||||
"snapshot": "fd045165e72b1127df96bd73487a99d2b561c641783a0dfa3b0ed8b470e4abda"
|
||||
},
|
||||
{
|
||||
"api": "daily",
|
||||
"rows": 1,
|
||||
"snapshot": "30ccd5069b7a8c361cc5408ecb9e0013f36d314cf5dde4c6d79b9e10f8942315"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow_dc",
|
||||
"rows": 1,
|
||||
"snapshot": "bbcc7b25b1e2d51487dcb76b28404966db7e83c9153af8f3119e1653f20f8618"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow",
|
||||
"rows": 1,
|
||||
"snapshot": "c210e7f840c5baecc94d6fd333f73fd98b994b65575f2ce38faed428e59b8f9f"
|
||||
},
|
||||
{
|
||||
"api": "daily",
|
||||
"rows": 1,
|
||||
"snapshot": "bbb69dfcf4862e214b3a47265ba3607476695cb9147eed3d9f8199a0e7501ff1"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow_dc",
|
||||
"rows": 1,
|
||||
"snapshot": "fce61a39b30de56dc3b898f6d8e4bf156504bedc001c5c85abfceaecbb490f36"
|
||||
},
|
||||
{
|
||||
"api": "moneyflow",
|
||||
"rows": 1,
|
||||
"snapshot": "2e0739949d25df4a56631c761e064121ff8f99ead4e95306c1c1f1bfa44111ae"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"trade_date": "2026-09-04",
|
||||
"sector": "BK1147.DC",
|
||||
"members": 14,
|
||||
"pct_change": 1.82,
|
||||
"main_net_yuan": "485456500.00",
|
||||
"turnover_yuan": "2615472881.48000",
|
||||
"daily_ratio": "0.1856094564915916866216729052",
|
||||
"active_net_yuan": "-255455700.00",
|
||||
"rows_by_api": {
|
||||
"dc_index": 1,
|
||||
"dc_member": 14,
|
||||
"stock_basic": 5556,
|
||||
"suspend_d": 8,
|
||||
"trade_cal": 47,
|
||||
"daily": 14,
|
||||
"moneyflow_dc": 14,
|
||||
"moneyflow": 14
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Replay the persisted real SPD sample in an isolated database, with no network.
|
||||
|
||||
The one-sector publication is exclusively a local integration fixture; its ranks
|
||||
must never be interpreted as a market-wide ranking.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
import psycopg
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from zhixing_server.bootstrap.config import Settings, get_settings
|
||||
from zhixing_server.modules.sector_radar.application.build import BuildSectorRadar, BuildSectorRadarCommand
|
||||
from zhixing_server.modules.sector_radar.domain.models import SectorType
|
||||
from zhixing_server.modules.sector_radar.domain.source import (
|
||||
SourceSnapshot, SourceResult, TradeCalendarRow, SectorIndexRow, SectorMemberRow,
|
||||
StockBasicRow, SuspendRow, DailyRow, MoneyflowDcRow, MoneyflowRow, build_source_snapshot,
|
||||
)
|
||||
from zhixing_server.modules.sector_radar.infrastructure.postgres import PostgresSectorRadarRepository
|
||||
|
||||
|
||||
def database_url():
|
||||
"""Resolve only the named localhost fixture database without printing secrets."""
|
||||
s=Settings(_env_file='../.env'); u=urlsplit(s.database_url)
|
||||
return urlunsplit((u.scheme,u.netloc.rsplit('@',1)[0]+'@127.0.0.1:5433','/radar_detail_selftest_0906',u.query,''))
|
||||
|
||||
|
||||
class StoredSource:
|
||||
"""Implement provider port using already committed raw snapshots only."""
|
||||
def __init__(self, dsn):
|
||||
self.by_api={}
|
||||
with psycopg.connect(dsn) as c:
|
||||
for r in c.execute('SELECT id,api_name,normalized_params,target_trade_date,partition_key,observed_at,payload,row_count,returned_fields,content_sha256,row_limit,limit_reached FROM sector_radar_source_snapshot').fetchall():
|
||||
snap=SourceSnapshot(r[0],r[1],tuple(sorted(r[2].items())),r[3],r[4],r[5],tuple(r[6]),r[7],tuple(r[8]),r[9],r[10],r[11])
|
||||
self.by_api.setdefault(r[1],[]).append(snap)
|
||||
|
||||
def result(self, api, parser):
|
||||
snaps=tuple(self.by_api[api])
|
||||
return SourceResult(snaps,tuple(parser(row) for s in snaps for row in s.rows))
|
||||
|
||||
def fetch_trade_calendar(self,start,end):
|
||||
return self.result('trade_cal',TradeCalendarRow.from_mapping)
|
||||
def fetch_sector_indices(self,target,kind):
|
||||
if kind is SectorType.INDUSTRY:
|
||||
# Explicitly empty industry scope for this one-concept test fixture.
|
||||
snap=build_source_snapshot(api_name='dc_index',params={'test_scope':'empty_industry'},rows=(),target_trade_date=target)
|
||||
return SourceResult((snap,),())
|
||||
return self.result('dc_index',lambda row:SectorIndexRow.from_mapping(row,kind))
|
||||
def fetch_sector_members(self,target,codes):
|
||||
return self.result('dc_member',SectorMemberRow.from_mapping)
|
||||
def fetch_stock_basics(self):
|
||||
return self.result('stock_basic',StockBasicRow.from_mapping)
|
||||
def fetch_suspensions(self,target):
|
||||
return self.result('suspend_d',SuspendRow.from_mapping)
|
||||
def fetch_daily(self,target):
|
||||
return self.result('daily',DailyRow.from_mapping)
|
||||
def fetch_moneyflow_dc(self,target,codes):
|
||||
return self.result('moneyflow_dc',MoneyflowDcRow.from_mapping)
|
||||
def fetch_moneyflow(self,target):
|
||||
return self.result('moneyflow',MoneyflowRow.from_mapping)
|
||||
|
||||
|
||||
def main():
|
||||
"""Run actual build and HTTP reads, checking independently recomputed facts."""
|
||||
dsn=database_url()
|
||||
os.environ['ZHIXING_DATABASE_URL']=dsn
|
||||
get_settings.cache_clear()
|
||||
command.upgrade(Config('alembic.ini'),'head')
|
||||
repository=PostgresSectorRadarRepository(dsn,max_connections=2)
|
||||
result=BuildSectorRadar(StoredSource(dsn),repository,today=date(2026,9,6)).execute(BuildSectorRadarCommand(trade_date=date(2026,9,4)))
|
||||
print(json.dumps(result.as_dict(),ensure_ascii=False))
|
||||
repository.close()
|
||||
|
||||
if __name__=='__main__':
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
"""Verify API facts against independently calculated persisted real data."""
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen
|
||||
|
||||
root='http://127.0.0.1:8016/api/v1/sector-radar/'
|
||||
d=json.load(urlopen(root+'sectors/concept/BK1147.DC/detail?trade_date=2026-09-04'))
|
||||
assert d['status']=='success'
|
||||
assert len(d['members'])==14
|
||||
assert Decimal(d['pct_change'])==Decimal('1.82')
|
||||
assert sum(Decimal(m['net_amount_yuan']) for m in d['members'])==Decimal('485456500')
|
||||
assert sum(Decimal(m['active_buy_net_amount_yuan']) for m in d['members'])==Decimal('-255455700')
|
||||
assert Decimal(d['summary']['amount']['metric_value'])==Decimal('4.854565')
|
||||
# Storage uses 12 fractional digits for persisted metric observations.
|
||||
assert abs(Decimal(d['summary']['ratio']['metric_value'])-Decimal('0.18560945649159168662'))<Decimal('1e-12')
|
||||
assert d['history']['available_days']==1
|
||||
assert len(d['history']['points'])==30
|
||||
assert d['summary']['swing']['missing'] is True
|
||||
assert len(d['leaders']['pct_change']['top'])==5
|
||||
for view in ['amount','ratio']:
|
||||
r=json.load(urlopen(root+'rankings?sector_type=concept&view='+view+'&side=top&trade_date=2026-09-04'))['rows'][0]
|
||||
assert Decimal(r['pct_change'])==Decimal('1.82')
|
||||
assert Decimal(r['daily_net_amount_yuan'])==Decimal('485456500')
|
||||
assert r['on_list_count']==1
|
||||
Path(__file__).with_name('verified-detail.json').write_text(json.dumps(d,ensure_ascii=False,indent=2))
|
||||
print('PASS: real persisted SPD data matches detail and both ranking APIs; 14 members, 30 slots / 1 available day, absent swing remains null.')
|
||||
Reference in New Issue
Block a user