Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/digest-candidates-wiring.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@

GitHub workflow 成功结论只是**存在性**辅助证据;成交/订单数未知时必须显示「未知」,不得写成 0。

IBKR 的 USD 期末已结算现金从各账户的私有 Flex 摘要对象读取。日报核对账户、目标与报表日期,
并显示实际报表日期。现金摘要缺失时显示未知,交易和成交覆盖仍按原候选记录计算。

## JSON Schema 归档

正式合同文件:[`schemas/qsl-digest-candidates.v1.schema.json`](../schemas/qsl-digest-candidates.v1.schema.json)(`schema_version` = `qsl.digest_candidates.v1`)。本文件示例与平台 producer 文档必须与该 schema 对齐;未知成交数保持 `null` + `field_status.counts_unknown`。
Expand Down
7 changes: 6 additions & 1 deletion python/scripts/account_daily_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,11 @@ def strategy_for(record):
asset_line = ('💰 总资产:' if zh else '💰 Total assets: ') + equity
if stamp_label:
asset_line += ' · ' + stamp_label
cash_lines = []
if platform == 'ibkr':
cash = account_fact.get('settled_cash')
value = f"USD {Decimal(cash['amount']):,f} · {cash['statement_date']}" if cash else unknown
cash_lines.append(('期末已结算现金:' if zh else 'Period-end settled cash: ') + value)
holding_label = '📦 持仓:' if zh else '📦 Holdings: '
if not holdings:
holding_label += ('空仓' if zh else 'No holdings') if complete else unknown
Expand Down Expand Up @@ -363,7 +368,7 @@ def strategy_for(record):
state = '⚠️ 昨日交易情况未确认' if zh else '⚠️ Trading activity unconfirmed'
if not full_scope and any(count is not None for count in counts.values()):
state += '(已核验策略范围)' if zh else ' (verified strategy scope)'
blocks.append((header, [state, ('|' if zh else ' | ').join(count_lines), filled_orders, asset_line, holding_label],
blocks.append((header, [state, ('|' if zh else ' | ').join(count_lines), filled_orders, asset_line, *cash_lines, holding_label],
details, holding_lines))
lines = [('📊 昨日账户日报|' if zh else '📊 Daily account report | ') + payload.business_day,
'🕒 ' + _window_text(payload.business_day, locale)]
Expand Down
39 changes: 39 additions & 0 deletions python/scripts/cloud_run_daily_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,43 @@ def _sources(configuration, store, day, end, *, now, monotonic):
except (ValueError, TypeError, KeyError, AttributeError):
outcome = 'invalid'
outcomes.append(outcome)
for binding in configuration.expected:
if binding['platform_id'] != 'ibkr':
continue
timeout = min(20, budget_end - monotonic(), (end - now()).total_seconds())
if timeout <= 0:
break
try:
cash = store.candidate(f"ibkr/flex-cash/{binding['candidate_slot']}.json", timeout)
if cash is not None:
stale_observations.append(_ibkr_settled_cash(cash, binding, day))
except (StoreUnavailable, ValueError, TypeError, KeyError, AttributeError):
continue
return rows, outcomes, stale_observations


@dataclass(frozen=True)
class _SettledCashObservation:
account_uid: str
amount: str
statement_date: str


def _ibkr_settled_cash(raw, binding, day):
if (not isinstance(raw, dict)
or set(raw) != {'schema_version', 'target_id', 'opaque_account_uid', 'statement_date', 'currency', 'amount'}
or raw['schema_version'] != 'ibkr_digest_settled_cash.v1'
or raw['currency'] != 'USD'
or any(raw[name] != binding[name] for name in ('target_id', 'opaque_account_uid'))
or not isinstance(raw['amount'], str) or len(raw['amount']) > 64
or not re.fullmatch(r'-?[0-9]+(?:\.[0-9]+)?', raw['amount'])):
raise ValueError('digest_ibkr_settled_cash_invalid')
statement_day = date.fromisoformat(raw['statement_date']).isoformat()
if statement_day != raw['statement_date'] or statement_day > day:
raise ValueError('digest_ibkr_settled_cash_date_invalid')
return _SettledCashObservation(binding['opaque_account_uid'], raw['amount'], statement_day)


def _report(configuration, rows, outcomes, day, platform_config, *, stale_observations=()):
config = deepcopy(platform_config or aggregator.load_platform_config())
config['notifications']['quant_sentinel']['daily_digest'].setdefault('aggregator', {})['expected_targets'] = configuration.expected
Expand All @@ -273,6 +307,11 @@ def _report(configuration, rows, outcomes, day, platform_config, *, stale_observ
covered = sum(value in {'present', 'not_run', 'snapshot_only'} for value in outcomes)
coverage = 'complete' if covered == len(outcomes) else 'partial' if covered else 'unavailable'
facts = collect_account_facts([*rows, *stale_observations], configuration.expected, day)
for observation in stale_observations:
if isinstance(observation, _SettledCashObservation):
facts['ibkr', observation.account_uid]['settled_cash'] = {
'amount': observation.amount, 'statement_date': observation.statement_date,
}
if coverage == 'complete' and (payload.notification_summary or {}).get('counts_unknown'):
coverage = 'partial'
platforms = dict.fromkeys((*adapter.PLATFORMS, *config.get('platforms', {})))
Expand Down
21 changes: 18 additions & 3 deletions python/tests/test_cloud_run_daily_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ def test_account_snapshot_keeps_independently_verified_fields(self):
for part in excluded:
self.assertNotIn(part, self.sent[0])

def test_ibkr_account_report_uses_only_the_existing_candidate(self):
def test_ibkr_account_report_reads_cash_without_changing_run_coverage(self):
from dataclasses import replace
binding = {**EXPECTED[0], 'platform_id': 'ibkr'}
configuration = replace(self.enable(), expected=[binding])
Expand All @@ -231,11 +231,15 @@ def test_ibkr_account_report_uses_only_the_existing_candidate(self):
row = {**ROW, 'platform_id': 'ibkr', 'fill_count': None,
'account_daily_facts': {'snapshots': [snapshot]}}
row.pop('notification_event')
self.store = MemoryStore({key: {'runs': [row]}})
cash_key = f"ibkr/flex-cash/{binding['candidate_slot']}.json"
cash = {'schema_version': 'ibkr_digest_settled_cash.v1', 'currency': 'USD', 'amount': '0.00',
'statement_date': '2026-10-14', 'target_id': binding['target_id'],
'opaque_account_uid': binding['opaque_account_uid']}
self.store = MemoryStore({key: {'runs': [row]}, cash_key: cash})
read = self.store.candidate

def ordinary_candidate(requested, timeout):
self.assertEqual(requested, key)
self.assertIn(requested, (key, cash_key))
return read(requested, timeout)

self.store.candidate = ordinary_candidate
Expand All @@ -247,10 +251,21 @@ def ordinary_candidate(requested, timeout):
self.assertEqual(self.run_request(configuration=configuration, headers=headers)[1]['delivery'], 'confirmed')
self.assertEqual(len(self.sent), 1)
self.assertIn('总资产:USD 250.00', self.sent[0])
self.assertIn('期末已结算现金:USD 0.00 · 2026-10-14', self.sent[0])
self.assertIn('TQQQ 3股', self.sent[0])
self.assertIn('成交回报 未知', self.sent[0])
self.assertNotIn('成交回报 0笔', self.sent[0])

def test_ibkr_cash_rejects_wrong_identity_future_date_and_non_decimal(self):
binding = {**EXPECTED[0], 'platform_id': 'ibkr'}
cash = {'schema_version': 'ibkr_digest_settled_cash.v1', 'currency': 'USD', 'amount': '-12.34',
'statement_date': '2026-10-15', 'target_id': binding['target_id'],
'opaque_account_uid': binding['opaque_account_uid']}
for change in ({'opaque_account_uid': 'different'}, {'target_id': 'different'},
{'statement_date': '2026-10-16'}, {'amount': '1e4'}, {'currency': 'HKD'}):
with self.subTest(change=change), self.assertRaises(ValueError):
app._ibkr_settled_cash({**cash, **change}, binding, '2026-10-15')

def test_locale_variants_are_normalized_at_the_production_boundary(self):
from dataclasses import replace
for locale, expected in (("zh", "zh"), ("zh-CN", "zh"), (" ZH_hans ", "zh"),
Expand Down
Loading