From 15636fb9100becf8c7b994a8b711c2da25d0ee40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=B9=8F?= Date: Thu, 9 Jul 2026 10:45:27 +0800 Subject: [PATCH] fix: separate trade price from realtime price --- README.md | 4 +- .../plans/2026-07-08-tencent-quotes.md | 6 +-- .../2026-07-08-grid-trading-gui-design.md | 16 ++++---- .../specs/2026-07-08-tencent-quotes-design.md | 12 +++--- src/grid_trading/domain/calculations.py | 19 +-------- src/grid_trading/ui/dialogs.py | 16 ++------ tests/test_calculations.py | 40 ++++++++++++++++-- tests/test_services.py | 6 ++- tests/test_ui.py | 41 +++++++++++++++++++ 9 files changed, 105 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 66dc430..9c3b942 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## 第一阶段功能 - 创建/编辑本地账户和初始资金。 -- 添加 A股/ETF 标的,维护交易单位和手动估值价格。 +- 添加 A股/ETF 标的,维护交易单位。 - 配置默认网格策略模板。 - 手动录入、编辑、删除买入/卖出成交。 - 自动计算持仓数量、T+1 可用数量、持仓成本、已实现盈亏、累计网格利润、持仓回本价、账户回本价。 @@ -22,7 +22,7 @@ http://qt.gtimg.cn/q= 例如 `000001` 会自动转换为 `sz000001`,`600000` 会自动转换为 `sh600000`。 -实时行情只保存在当前程序内存中,用于显示现价、持仓市值、浮动盈亏、总资产和资金使用率;不会覆盖标的里的手动价格,也不会写入 SQLite。关闭软件后再次打开,需要重新点击“刷新行情”。 +实时行情只保存在当前程序内存中,用于显示现价、持仓市值、浮动盈亏、总资产和资金使用率;不会写入 SQLite。成交录入里的价格只作为成交价保存,不会作为现价兜底。关闭软件后再次打开,需要重新点击“刷新行情”。 ## 开发环境 diff --git a/docs/superpowers/plans/2026-07-08-tencent-quotes.md b/docs/superpowers/plans/2026-07-08-tencent-quotes.md index f1631aa..f6d5314 100644 --- a/docs/superpowers/plans/2026-07-08-tencent-quotes.md +++ b/docs/superpowers/plans/2026-07-08-tencent-quotes.md @@ -15,11 +15,11 @@ - Create `src/grid_trading/market/__init__.py`: market package marker. - Create `src/grid_trading/market/tencent.py`: Tencent symbol inference, response parsing, and HTTP quote provider. - Modify `src/grid_trading/domain/models.py`: add `QuoteSnapshot`. -- Modify `src/grid_trading/domain/calculations.py`: let quote snapshots override manual price when computing positions. +- Modify `src/grid_trading/domain/calculations.py`: let quote snapshots supply current price when computing positions. - Modify `src/grid_trading/services/trading_service.py`: add quote provider injection, `refresh_quotes`, and quote cache use in summaries. - Modify `src/grid_trading/ui/main_window.py`: make the toolbar refresh action fetch Tencent quotes and display quote refresh errors. - Create `tests/test_tencent_quotes.py`: parser and symbol inference tests. -- Modify `tests/test_calculations.py`: verify quote price overrides manual price. +- Modify `tests/test_calculations.py`: verify quote price supplies current price. - Modify `tests/test_services.py`: verify service refresh uses fake quote provider and summaries use realtime price. ## Tasks @@ -98,7 +98,7 @@ git commit -m "feat: add Tencent quote parser" - [ ] **Step 1: Write failing calculation and service tests** -Add a calculation test where an `Instrument` has `manual_price=Decimal("9")`, a `QuoteSnapshot(price=Decimal("10"))` is passed, and `PositionSummary.current_price` becomes `10` with `price_source == "tencent"`. +Add a calculation test where a `QuoteSnapshot(price=Decimal("10"))` is passed, and `PositionSummary.current_price` becomes `10` with `price_source == "tencent"`. Add a service test with a fake provider: diff --git a/docs/superpowers/specs/2026-07-08-grid-trading-gui-design.md b/docs/superpowers/specs/2026-07-08-grid-trading-gui-design.md index 681a75f..d17ce24 100644 --- a/docs/superpowers/specs/2026-07-08-grid-trading-gui-design.md +++ b/docs/superpowers/specs/2026-07-08-grid-trading-gui-design.md @@ -13,7 +13,7 @@ - 应用形态:Python 桌面 GUI。 - 技术路线:PySide6 + SQLite + 分层业务计算模块。 - 交易市场:A股股票和 ETF。 -- 数据来源:成交手动录入;行情后续半自动刷新。 +- 数据来源:成交手动录入;现价通过腾讯接口手动刷新。 - 首屏布局:持仓表优先,左侧导航,顶部账户摘要,中间持仓表,下方选中标的详情。 - 网格策略:默认模板 + 单标的覆盖。 - 回本价:同时显示持仓回本价和账户回本价。 @@ -40,14 +40,14 @@ 4. 成交记录 - 手动录入买入、卖出成交。 - - 成交字段包括日期、代码、方向、价格、数量、手续费、印花税、过户费、交易分组、备注。 + - 成交字段包括日期、代码、方向、成交价、数量、手续费、印花税、过户费、交易分组、备注。 - 交易分组包括底仓、网格、其他,用于区分底仓和网格仓。 - 手续费支持自动估算和手动覆盖,费率在设置中可配置。 - 支持修改和删除成交,保存后自动重算相关持仓。 5. 持仓表 - 每个标的一行,展示代码、名称、当前价格、总持仓、可用数量、底仓数量、网格仓数量、持仓成本、持仓回本价、账户回本价、已实现盈亏、累计网格利润、浮动盈亏。 - - 第一阶段没有行情源时,当前价格可手动维护;未维护价格时使用最近成交价作为估值参考,并在界面中标记。 + - 当前价格只来自腾讯行情刷新;没有行情快照时显示空值,不用成交价或标的字段兜底估值。 - A股 T+1 可用数量按交易日期计算:当日买入数量不可卖出,当日卖出会减少可用数量。 6. 数据持久化 @@ -61,7 +61,7 @@ - 不自动登录券商。 - 不自动下单。 -- 不接实时行情。 +- 不自动定时刷新行情。 - 不生成盘中弹窗提醒。 - 不做复杂图表。 - 不做多账户同步或云端备份。 @@ -85,7 +85,7 @@ GUI 只负责展示和收集输入;所有计算通过 service 调用 domain 核心表: - `accounts`:账户基础信息、初始现金、当前现金。 -- `instruments`:标的信息,包括代码、名称、市场、交易单位、手动价格。 +- `instruments`:标的信息,包括代码、名称、市场、交易单位。 - `strategy_templates`:默认网格模板。 - `instrument_strategy_overrides`:单标的策略覆盖。 - `trades`:成交记录。 @@ -125,7 +125,7 @@ GUI 只负责展示和收集输入;所有计算通过 service 调用 domain 估值: - 持仓市值 = 当前价格 * 当前总持仓数量。 -- 当前价格优先使用手动维护价格;没有手动价格时使用最近成交价,并显示“估值价来自最近成交”。 +- 当前价格只使用腾讯接口返回的行情快照;没有行情快照时显示为空,持仓市值和浮动盈亏也显示为空。 - 浮动盈亏 = 持仓市值 - 当前剩余持仓成本。 ## 主界面 @@ -138,7 +138,7 @@ GUI 只负责展示和收集输入;所有计算通过 service 调用 domain - 中央持仓表:展示所有标的的核心指标。 - 底部详情区:展示选中标的的最近成交、分组持仓、策略参数和计算说明。 -第一阶段的“刷新”只从数据库重算,不调用行情接口。 +“刷新行情”从腾讯接口拉取当前价格并重算持仓表和账户摘要。 ## 错误处理 @@ -172,7 +172,7 @@ GUI 只负责展示和收集输入;所有计算通过 service 调用 domain ## 后续阶段 -第二阶段:接入 A股/ETF 行情源,支持手动刷新和定时刷新,生成买卖档位和今日委托建议。 +第二阶段:支持定时刷新行情,生成买卖档位和今日委托建议。 第三阶段:加入图表,包括成本变化曲线、累计网格收益、账户盈亏和资金使用率。 diff --git a/docs/superpowers/specs/2026-07-08-tencent-quotes-design.md b/docs/superpowers/specs/2026-07-08-tencent-quotes-design.md index 83162c2..23f5113 100644 --- a/docs/superpowers/specs/2026-07-08-tencent-quotes-design.md +++ b/docs/superpowers/specs/2026-07-08-tencent-quotes-design.md @@ -6,7 +6,7 @@ Date: 2026-07-08 Add Tencent A-share quote integration so the GUI holdings table displays realtime prices from `http://qt.gtimg.cn/q=`. -The first implementation is manual refresh only. It does not auto-refresh, generate buy/sell grid orders, or overwrite user-entered manual prices. +The first implementation is manual refresh only. It does not auto-refresh, generate buy/sell grid orders, persist quote prices, or use trade-entry prices as current prices. ## Confirmed Decision @@ -45,14 +45,12 @@ Add a `market` package with a Tencent quote client. The client converts local in `TradingService` keeps an in-memory quote cache. When the user clicks refresh in the GUI, the service fetches quotes for all active instruments and stores them in memory. -Position calculations will prefer prices in this order: +Position calculations will treat Tencent quotes as the only current-price source: 1. Tencent realtime quote -2. Manual price -3. Last trade price -4. Empty value +2. Empty value -The holdings table and account summary will immediately reflect quote prices after refresh. Quote prices are not persisted to SQLite in this step, so stale prices do not silently survive app restarts. +The holdings table and account summary will immediately reflect quote prices after refresh. Quote prices are not persisted to SQLite in this step, so stale prices do not silently survive app restarts. Trade-entry prices remain historical成交价 only and never become current-price fallbacks. ## Error Handling @@ -65,6 +63,6 @@ The holdings table and account summary will immediately reflect quote prices aft - Unit-test Tencent response parsing with a fixture response. - Unit-test symbol inference. -- Unit-test position calculations with a realtime quote overriding manual price. +- Unit-test position calculations with a realtime quote supplying current price and a missing quote leaving current price empty. - Unit-test service quote refresh using a fake quote provider. - Keep existing GUI smoke tests passing. diff --git a/src/grid_trading/domain/calculations.py b/src/grid_trading/domain/calculations.py index 156204d..2910af7 100644 --- a/src/grid_trading/domain/calculations.py +++ b/src/grid_trading/domain/calculations.py @@ -75,7 +75,6 @@ def calculate_positions( lambda: {group: _GroupState() for group in TradeGroup} ) net_invested: dict[int, Decimal] = defaultdict(lambda: Decimal("0")) - last_trade_price: dict[int, Decimal] = {} today_buys: dict[int, int] = defaultdict(int) for trade in sorted(trades, key=lambda item: (item.trade_date, item.id or 0)): @@ -89,8 +88,6 @@ def calculate_positions( group_state = states[trade.instrument_id][trade.trade_group] gross = money(trade.gross_amount) fees = money(trade.total_fee) - last_trade_price[trade.instrument_id] = trade.price - if trade.side is TradeSide.BUY: group_state.quantity += trade.quantity group_state.cost = money(group_state.cost + gross + fees) @@ -123,11 +120,7 @@ def calculate_positions( remaining_cost = money(sum((state.cost for state in group_states.values()), Decimal("0"))) realized_pnl = money(sum((state.realized_pnl for state in group_states.values()), Decimal("0"))) grid_profit = money(group_states[TradeGroup.GRID].realized_pnl) - current_price, price_source = _resolve_current_price( - instrument, - last_trade_price.get(instrument.id), - quotes.get(instrument.id), - ) + current_price, price_source = _resolve_current_price(quotes.get(instrument.id)) market_value = money(current_price * Decimal(total_quantity)) if current_price is not None else None floating_pnl = money(market_value - remaining_cost) if market_value is not None else None available_quantity = max(0, total_quantity - today_buys[instrument.id]) @@ -207,17 +200,9 @@ def _validate_trade(trade: Trade) -> None: raise CalculationError("Trade fees cannot be negative") -def _resolve_current_price( - instrument: Instrument, - fallback_trade_price: Decimal | None, - quote_snapshot: QuoteSnapshot | None, -) -> tuple[Decimal | None, str]: +def _resolve_current_price(quote_snapshot: QuoteSnapshot | None) -> tuple[Decimal | None, str]: if quote_snapshot is not None: return quote_snapshot.price, quote_snapshot.source - if instrument.manual_price is not None: - return instrument.manual_price, "manual" - if fallback_trade_price is not None: - return fallback_trade_price, "last_trade" return None, "missing" diff --git a/src/grid_trading/ui/dialogs.py b/src/grid_trading/ui/dialogs.py index b8f1768..eda49e4 100644 --- a/src/grid_trading/ui/dialogs.py +++ b/src/grid_trading/ui/dialogs.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import date -from decimal import Decimal, InvalidOperation +from decimal import Decimal from PySide6.QtCore import QDate from PySide6.QtWidgets import ( @@ -81,7 +81,6 @@ class InstrumentDialog(QDialog): self.lot_size_edit = QSpinBox() self.lot_size_edit.setRange(1, 1_000_000) self.lot_size_edit.setValue(instrument.lot_size if instrument else 100) - self.manual_price_edit = QLineEdit(str(instrument.manual_price or "") if instrument else "") self.allow_odd_lot_edit = QCheckBox("允许非整手") self.allow_odd_lot_edit.setChecked(instrument.allow_odd_lot if instrument else False) @@ -90,7 +89,6 @@ class InstrumentDialog(QDialog): form.addRow("名称", self.name_edit) form.addRow("市场", self.market_edit) form.addRow("交易单位", self.lot_size_edit) - form.addRow("手动价格", self.manual_price_edit) form.addRow("", self.allow_odd_lot_edit) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) @@ -102,14 +100,13 @@ class InstrumentDialog(QDialog): layout.addWidget(buttons) def to_instrument(self) -> Instrument: - manual_price = _optional_decimal(self.manual_price_edit.text().strip(), "手动价格") return Instrument( id=self._instrument.id if self._instrument else None, code=self.code_edit.text().strip(), name=self.name_edit.text().strip(), market=self.market_edit.currentText(), lot_size=self.lot_size_edit.value(), - manual_price=manual_price, + manual_price=None, allow_odd_lot=self.allow_odd_lot_edit.isChecked(), active=True, ) @@ -227,7 +224,7 @@ class TradeDialog(QDialog): form.addRow("日期", self.date_edit) form.addRow("方向", self.side_edit) form.addRow("分组", self.group_edit) - form.addRow("价格", self.price_edit) + form.addRow("成交价", self.price_edit) form.addRow("数量", self.quantity_edit) form.addRow(fee_row) form.addRow("备注", self.notes_edit) @@ -291,10 +288,3 @@ def _percent_spinbox(value: Decimal) -> QDoubleSpinBox: return spinbox -def _optional_decimal(raw: str, label: str) -> Decimal | None: - if not raw: - return None - try: - return Decimal(raw) - except InvalidOperation as exc: - raise ValueError(f"{label} 不是有效数字") from exc diff --git a/tests/test_calculations.py b/tests/test_calculations.py index d685718..9c55224 100644 --- a/tests/test_calculations.py +++ b/tests/test_calculations.py @@ -37,7 +37,7 @@ def make_trade( def test_buy_sell_grid_profit_and_breakeven(): today = date(2026, 7, 8) - instrument = Instrument(id=1, code="510300", name="沪深300ETF", manual_price=Decimal("9.50")) + instrument = Instrument(id=1, code="510300", name="沪深300ETF") trades = [ make_trade( trade_id=1, @@ -68,7 +68,17 @@ def test_buy_sell_grid_profit_and_breakeven(): ), ] - [summary] = calculate_positions([instrument], trades, as_of=today) + quotes = { + 1: QuoteSnapshot( + symbol="sh510300", + code="510300", + name="沪深300ETF", + price=Decimal("9.50"), + source="tencent", + ) + } + + [summary] = calculate_positions([instrument], trades, as_of=today, quote_snapshots=quotes) assert summary.total_quantity == 100 assert summary.base_quantity == 100 @@ -79,6 +89,8 @@ def test_buy_sell_grid_profit_and_breakeven(): assert summary.cost_price == Decimal("9.01") assert summary.position_breakeven_price == Decimal("8.03") assert summary.account_breakeven_price == Decimal("8.03") + assert summary.current_price == Decimal("9.50") + assert summary.price_source == "tencent" assert summary.market_value == Decimal("950.00") assert summary.floating_pnl == Decimal("49.00") @@ -111,7 +123,7 @@ def test_t_plus_one_available_quantity_excludes_today_buys(): assert summary.available_quantity == 200 -def test_quote_snapshot_overrides_manual_price_for_position_value(): +def test_quote_snapshot_supplies_current_price_for_position_value(): today = date(2026, 7, 8) instrument = Instrument(id=1, code="510300", name="沪深300ETF", manual_price=Decimal("3.90")) trades = [ @@ -141,6 +153,28 @@ def test_quote_snapshot_overrides_manual_price_for_position_value(): assert summary.market_value == Decimal("4120.00") +def test_missing_quote_does_not_use_manual_or_last_trade_price_for_current_price(): + today = date(2026, 7, 8) + instrument = Instrument(id=1, code="510300", name="沪深300ETF", manual_price=Decimal("3.90")) + trades = [ + make_trade( + trade_id=1, + trade_date=today - timedelta(days=1), + side=TradeSide.BUY, + price="4.00", + quantity=1000, + trade_group=TradeGroup.BASE, + ) + ] + + [summary] = calculate_positions([instrument], trades, as_of=today) + + assert summary.current_price is None + assert summary.price_source == "missing" + assert summary.market_value is None + assert summary.floating_pnl is None + + def test_sell_more_than_group_position_raises(): today = date(2026, 7, 8) instrument = Instrument(id=1, code="159915", name="创业板ETF") diff --git a/tests/test_services.py b/tests/test_services.py index 05e4f10..92f4948 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -64,10 +64,12 @@ def test_service_creates_default_account_and_computes_summary(tmp_path): assert len(positions) == 1 assert positions[0].total_quantity == 500 + assert positions[0].current_price is None + assert positions[0].price_source == "missing" assert positions[0].grid_profit == Decimal("91.47") assert summary.cash == Decimal("98138.97") - assert summary.market_value == Decimal("2000.00") - assert summary.total_assets == Decimal("100138.97") + assert summary.market_value == Decimal("0.00") + assert summary.total_assets == Decimal("98138.97") def test_service_validates_lot_size_and_available_sell_quantity(tmp_path): diff --git a/tests/test_ui.py b/tests/test_ui.py index 80a7864..d1049a3 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -35,6 +35,47 @@ def test_main_window_can_be_constructed_offscreen(tmp_path, monkeypatch): app.processEvents() +def test_instrument_dialog_does_not_collect_manual_current_price(monkeypatch): + monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") + + from PySide6.QtWidgets import QApplication, QLabel + + from grid_trading.ui.dialogs import InstrumentDialog + + app = QApplication.instance() or QApplication([]) + dialog = InstrumentDialog() + + labels = [label.text() for label in dialog.findChildren(QLabel)] + assert "手动价格" not in labels + assert not hasattr(dialog, "manual_price_edit") + assert dialog.to_instrument().manual_price is None + + dialog.close() + app.processEvents() + + +def test_trade_dialog_labels_price_as_trade_price(tmp_path, monkeypatch): + monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") + + from PySide6.QtWidgets import QApplication, QLabel + + from grid_trading.services.trading_service import TradingService + from grid_trading.ui.dialogs import TradeDialog + + app = QApplication.instance() or QApplication([]) + service = TradingService(tmp_path / "grid.db") + service.ensure_defaults() + account = service.get_active_account() + dialog = TradeDialog(service, account.id, [Instrument(id=1, code="510300", name="沪深300ETF")]) + + labels = [label.text() for label in dialog.findChildren(QLabel)] + assert "成交价" in labels + + dialog.close() + service.close() + app.processEvents() + + def test_quote_refresh_does_not_block_main_window(tmp_path, monkeypatch): monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen")