* feat: add a global desktop update entry in the shell
Expose a top-right DesktopUpdateIndicator in Electron runtime that
shares update state with Settings, without triggering extra background
checks or showing the control in ordinary browser WebUI.
Co-authored-by: SPEC <zt1y17@soton.ac.uk>
* fix: restore changelog header and honor desktop update deep links
Keep the Unreleased section intact, show download progress in the
shell tooltip, and open Settings version info from the search/hash.
Co-authored-by: SPEC <zt1y17@soton.ac.uk>
* fix: share desktop update checking state across header and settings
Keep a module-level update store so the shell indicator and settings
page disable together, and reject overlapping manual checks in the
Electron main process.
Co-authored-by: SPEC <zt1y17@soton.ac.uk>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
* feat: add Futu OpenD as an optional HK realtime and fundamental data source
Add FutuFetcher and FutuFundamentalAdapter behind FUTU_OPEND_HOST/PORT,
register the settings in Config and config_registry so the Web settings
page can expose host, port and HK realtime priority, and route HK
realtime quotes through a configurable futu/longbridge/akshare/yfinance
order while keeping A-share priority untouched. Include offline tests
for the adapter, config schema and HK routing/fallback, plus docs and
CHANGELOG entries.
* fix: wire Futu fundamentals into HK pipeline and restore quote supplementation
- _fetch_offshore_fundamental_bundle() prefers the Futu fundamental
adapter for HK when FUTU_OPEND_HOST is configured, and falls back to
yfinance when Futu is absent or returns no usable content.
- HK realtime priority loop now supplements missing quote fields
(volume_ratio / turnover_rate / pe/pb / market cap) from later
configured sources instead of returning after the first non-empty
quote, matching the US path's _supplement_quote behavior.
- capital_flow / boards blocks are filled from the Futu bundle for HK
instead of being hard-coded not_supported; status and missing_fields
aggregation updated accordingly.
- Add regression tests for partial-quote supplementation and Futu
fundamental bundle routing/fallback.
* test: expect boards block ok when bundle provides belong_boards
The Futu integration made the offshore boards block data-driven instead
of hard-coded not_supported; update the existing US/HK fundamental
context test to match (belong_boards from the bundle now surface as an
ok boards block).
* fix: preserve HK fallback_from metadata and normalize Futu quote timestamps
- HK realtime priority loop now records the failed preferred source token
and passes it as fallback_from when a later source takes over, so the
pipeline and analysis context can mark the quote as degraded.
- Futu snapshot update_time is a naive Beijing-time (UTC+8) string; attach
the +08:00 offset before storing provider_timestamp so stale_seconds /
is_stale / provider_timestamp freshness semantics are correct instead of
being parsed as UTC.
- Add regression tests for fallback_from propagation and timestamp
normalization.
* fix: normalize Futu belong_boards to name/type/code contract
OpenD owner_plate returns plate_code / plate_name / plate_type, but DSA
downstream consumers (notification, extract_board_detail_fields, market
structure) only read name/type/code. Map the fields in
FutuFundamentalAdapter._boards so HK Futu boards are actually consumed
instead of silently dropped, and add regression tests including an
end-to-end check through extract_board_detail_fields.
* fix: merge yfinance bundle when Futu fundamental returns partial blocks
Futu partial success (e.g. statements failed but static info worked) used
to short-circuit the whole bundle, silently dropping the growth/earnings
that the existing yfinance path could still provide. Now, when Futu
returns content but is missing growth or earnings, fetch the yfinance
bundle within the remaining budget and merge the missing blocks
(growth/earnings/institution/capital_flow/belong_boards), keeping
Futu-preferred values where both exist. Add regression test for the
partial-success merge path.
* fix: use field-level checks when deciding Futu-vs-yfinance growth/earnings
The previous merge condition only checked dict truthiness, so a truthy
growth/earnings shell (all-None core values or metadata-only keys such
as report_date/period/currency) would skip the yfinance supplement and
silently downgrade existing HK fundamentals. Add _earnings_block_has_values
(a core numeric field or a populated dividend is required) and reuse the
existing _has_meaningful_payload for growth; both the missing_core check
and the merge loop now use these. Add regression test for the
all-None-shell scenario.
* fix: fill HK fundamental field gaps from yfinance instead of block-level checks
Block-level meaningful checks still skipped the yfinance supplement when
Futu hit only part of the growth/earnings fields (e.g. revenue_yoy but
None net_profit_yoy, or earnings with only basic_eps), silently dropping
fields the main branch used to provide. Replace the missing_core decision
with a per-field gap list (growth: revenue_yoy/net_profit_yoy/gross_margin;
earnings.financial_report: revenue/net_profit_parent/basic_eps/gross_profit)
and make the merge field-level: keep Futu values, fill each missing field
from yfinance. Add regression tests for partial-hit and all-None shells.
* fix: normalize Futu dividends to the repo contract and treat dividend gaps as supplement triggers
Futu OpenD dividend_list carries raw fields (statement/ex_date/record_date)
which the notification/data_processing market-structure consumers do not
read; the repo contract is ttm_cash_dividend_per_share,
ttm_dividend_yield_pct and events[].cash_dividend_per_share /
ex_dividend_date / event_date. Normalize events in
FutuFundamentalAdapter._dividends_and_splits, compute TTM count/cash and
yield from the latest quote, and teach _field_gaps/_merge_bundles to treat
a dividend block that does not satisfy the contract as a gap so yfinance
supplements it. Also dedupe FUTU_OPEND_HOST/PORT in full-guide_EN.
* fix: read dividend yield price from UnifiedRealtimeQuote objects
FutuFetcher.get_realtime_quote returns a UnifiedRealtimeQuote dataclass,
not a dict, so the yield branch in _dividends_and_splits that guarded on
isinstance(quote, dict) never ran on the live Futu path, silently dropping
ttm_dividend_yield_pct while the contract check considered the dividend
block complete. Read price via getattr(quote, 'price', None) and keep the
dict fallback for other fetchers; add a regression test driving the real
UnifiedRealtimeQuote shape.
* fix: treat dividend blocks with TTM cash but no yield as supplement gaps
The repo contract consumes ttm_cash_dividend_per_share and
ttm_dividend_yield_pct together. When the Futu dividend path has events
and TTM cash but the extra realtime price snapshot failed (quote None /
no price), ttm_dividend_yield_pct cannot be computed and the block was
previously treated as complete, so yfinance was never consulted and the
notification rendered the yield as N/A.
_dividend_contract_has_values() now requires the paired yield whenever
TTM cash is present, so _field_gaps() triggers the yfinance supplement
and _merge_bundles() replaces the incomplete dividend block.
Add regression tests for the adapter-level gap shape (quote unavailable
leaves no yield) and the manager-level supplement path (Futu cash
without yield pulls yfinance and fills the yield).
* fix: skip unconfigured Futu in HK realtime routing
When FUTU_OPEND_HOST is not configured, the HK realtime priority loop
used to still attempt the futu source, record it as the failed primary,
and attach fallback_from='futu' to a successful quote from the next
enabled source (longbridge/akshare/yfinance). Consumers then wrongly
treated an enabled source's first success as degraded fallback data,
contradicting the documented contract that Futu only participates when
OpenD is configured.
The HK loop now checks FutuFetcher.has_configured_endpoint() once and
skips the futu token entirely when it is disabled, so no fallback_from
is written. Existing configured-Futu routing tests explicitly patch the
endpoint check; a new regression test asserts an unconfigured Futu is
never called and the enriched quote carries fallback_from=None.
* fix: release cached HK Futu fundamental fetcher in DataFetcherManager.close()
The HK Futu fundamental path lazily creates and caches its own
FutuFetcher (an OpenQuoteContext-backed OpenD connection) on
_futu_fundamental_fetcher, but close() only released the TickFlow
fetcher and the default fetchers snapshot. Explicit close / reload
paths therefore left the OpenD connection hanging.
close() now takes the cached _futu_fundamental_fetcher, clears the
reference and calls its close() best-effort. A regression test injects
an observable fetcher into _futu_fundamental_fetcher and asserts
close() invokes it and clears the attribute.
---------
Co-authored-by: BayMax local review <baymax-local@invalid>
* fix(#1970): 关闭认证强制要求当前管理员密码二次确认
后端 api/v1/endpoints/auth.py 的 auth_update_settings 在 disable 路径上即使携带有效 session cookie 也强制要求 current_admin_password,否则返回 400;密码错误统一返回 401,命中 rate limit 与 enable 路径一致返回 429。enable 与 initial setup 路径行为保持不变。
前端 AuthSettingsCard 在关闭认证场景下若 currentPassword 缺失,submit 按钮保持可点击(disabled 仅由 isDirty 决定),handleSubmit 校验后给出内联错误,避免用户面对一个长期 disabled 的按钮但不知所缺。新增 i18n key settings.authDisableRequiredCurrentPassword 中英文本,同步修订 authHelperTurnOff / authPasswordHintOff 文案以反映新契约,并避免 hint 文案与 inline error 文案完全重复导致测试 findByText 多匹配。
测试覆盖:
- tests/test_auth_api.py 新增 disable 路径在有/无 session、有/无 current_password、密码对错、rate limit 命中 6 种分支用例,全部通过。
- apps/dsa-web/__tests__/AuthSettingsCard.test.tsx 把原 'missing current password when session valid' 反向为 'blocks disabling when current password missing',并补 'disables auth with current password provided'。本机 vitest run AuthSettingsCard.test.tsx 6/6 通过。
issue #1970
* test(#1970): 关闭认证回归补真实 ASGI 端到端用例并清理无效 mock
针对 PR #2050 review 反馈,补齐两条回归用例并修正既有用例的误导性 mock:
1. 删除 AuthApiTestCase 三个 valid-session 用例中对 verify_session 的 patch。
Disable 分支不会调用 verify_session(仅在 enable 分支的 TOCTOU 复检里用到),
旧 mock 既不生效也容易让读者误以为 disable 路径会做 session 校验。
2. 新增 AuthDisableViaRealASGITestCase 通过真实 ASGI / AuthMiddleware / auth
路由组合链路(create_app + httpx.ASGITransport,与 test_api_health.py 同路径)
验证 Issue #1970 修复:
- 真实 POST /api/v1/auth/login 拿到签名 cookie 后,仅带 session 不带
currentPassword 调 /api/v1/auth/settings 关闭认证 -> 400 current_required;
- 同上下文携带正确 currentPassword 关闭认证 -> 200,.env 翻转至
ADMIN_AUTH_ENABLED=false,响应头携带 Set-Cookie 轮换 session secret。
3. 同步 /api/v1/auth/settings OpenAPI description:明确「关闭认证时
currentPassword 必填、有效 session 不足够」这一新契约,与 endpoint
行为及 Issue #1970 上下文对齐。
测试:tests/test_auth_api.py 35/35 全过。
* test(auth): fix rate-limit trigger test — needs MAX+1 iterations to reach 429
Previously range(RATE_LIMIT_MAX_FAILURES) ran only 5 iterations, but
check_rate_limit returns False only when count >= MAX. This means the
5th request enters with count=4 (4 < 5), passes check_rate_limit,
runs verify_stored_password + record_login_failure, and returns 401.
Only a 6th request - entering with count=5 (5 >= 5) - is rejected
early by check_rate_limit and returns 429.
Fix the loop range to RATE_LIMIT_MAX_FAILURES + 1 and update the
assertion: the first MAX attempts return 401 (each recording a
failure), and the final attempt returns 429. Also normalise a
mixed Chinese/English docstring to English-only. 35 tests pass.
* test: tighten ASGI auth disable coverage
- Drop the middleware-is_auth_enabled patch now that the endpoint
disables auth by rotating the session secret in a single in-memory
transition. Subsequent middleware checks in the same client see the
disabled state via the auth module, so the patch was masking a
state-leak instead of testing the real path.
- Make the positive disable test assert cookie deletion semantics
(empty value + Max-Age=0/Expires-past + jar cleared) instead of just
a presence check on Set-Cookie. A leaked pre-disable cookie must not
remain usable after disable, and the previous assertion would still
pass if the endpoint rotated to a fresh session id.
* test(auth): strip surrounding quotes when asserting empty dsa_session value
Starlette's delete_cookie serializes the empty cookie value as
dsa_session=""; Max-Age=0; ... — i.e. with surrounding double quotes.
The previous assertion expected the raw value to equal '' and so
failed on CI (which uses Starlette's stock serializer); locally the
TestClient happened to round-trip the same way but the strip happened
to make the assertion spuriously true (or the assertion was correct
against an older Starlette). Strip surrounding double quotes before
comparison so the test matches the actual deletion-form emitted by
delete_cookie.
* docs(changelog): move #1970 entry to [Unreleased] + drop 5 stray 3.28.0 文档段 entries
OR-COR-3defa936 blocker fix: 上轮 commit (`5219a43b`) 把 6 条 bullet 错误地追加到了已发布的 `## [3.28.0] - 2026-07-26` -> `### 文档` 段落,而不是 `[Unreleased]`。其中:
- 1 条属于本 PR (#1970) — 应放进 [Unreleased]
- 5 条属于其他 PR / issue (#2026 / #1985 / #2051 / Windows mimetypes / TUSHARE_HTTP_URL 工作流映射),且这些条目在 `3.28.0` 的 `### 新功能` / `### 改进` / `### 修复` 段已经发布过,再放进 `### 文档` 段属于重复 + 跨段误归类。
修复:
- 删除 `3.28.0 -> ### 文档` 段下的 6 条新增 bullet,恢复该段原本只剩「修复文档中的失效相对链接。」的 upstream/main 原貌;
- 在 `[Unreleased]` 段追加本 PR 的 #1970 单条扁平条目(与仓库约定一致:本 PR 自身只追加自身条目,不替其他 PR 处理)。
合并后 `docs/CHANGELOG.md` 的 `3.28.0 -> ### 文档` 段恢复 1 条原貌;`[Unreleased]` 段只新增 1 条本 PR 的条目,不再污染已发布版本历史。
* chore: trigger CI re-run after changelog fix (e01e0cf7 didn't fire pull_request event)
---------
Co-authored-by: xxiaoxiong <xxiaoxiong@users.noreply.github.com>
* feat: add reliable home watchlist workspace
* fix(review-feedback-1984): Don't trust stale stock-bar rows after refresh failures
* fix(review-feedback-1984): apps/dsa-web/src/stores/stockPoolStore.ts 的 refreshStockBar
* fix(review-feedback-1984): Clear loading when refresh supersedes initial stock-bar load
* fix(review-feedback-1984): Type stock-bar test fixtures as StockBarItem and 跟进结论 - 结论 :不接受;最新
* fix(review-feedback-1984): tying the Today fetch to the same refresh path while this tab is