From b1eea9f5b44ce10219afcee84f2f24a1bd3abd49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=B9=8F?= Date: Tue, 14 Jul 2026 16:21:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=E5=85=85=E7=BD=91=E6=A0=BC=E6=88=90=E4=BA=A4?= =?UTF-8?q?=E5=88=86=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 217 ++++++++++--- .../plans/2026-07-09-gui-visual-refresh.md | 200 ++++++++++++ .../2026-07-09-gui-visual-refresh-design.md | 53 ++++ src/grid_trading/domain/calculations.py | 5 +- src/grid_trading/domain/models.py | 14 + src/grid_trading/domain/open_grid_lots.py | 58 +++- src/grid_trading/services/trading_service.py | 15 +- src/grid_trading/ui/main_window.py | 293 ++++++++++++++++-- tests/test_calculations.py | 42 ++- tests/test_open_grid_lots.py | 48 ++- tests/test_services.py | 52 ++++ tests/test_ui.py | 83 ++++- 12 files changed, 1014 insertions(+), 66 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-09-gui-visual-refresh.md create mode 100644 docs/superpowers/specs/2026-07-09-gui-visual-refresh-design.md diff --git a/README.md b/README.md index 52a2e57..9bff363 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,216 @@ -# Grid Trading +# Grid Trading:本地网格交易管理器 -本项目是一个本地运行的 Python GUI 网格交易管理工具,第一阶段聚焦 A股/ETF 的账户、标的、手工成交、持仓成本、网格收益和回本价计算。 +一个使用 Python 和 PySide6 开发的本地桌面工具,用于管理 A 股、ETF 的账户、标的与手工成交,并辅助计算持仓成本、网格收益、回本价和网格档位。 -## 第一阶段功能 +> 本项目只提供交易记录与策略辅助功能,不会连接券商账户,也不会自动下单。所有计算结果仅供参考,不构成投资建议。 -- 创建/编辑本地账户和初始资金。 -- 添加 A股/ETF 标的,维护交易单位。 -- 配置默认网格策略模板。 -- 手动录入、编辑、删除买入/卖出成交。 -- 自动计算持仓数量、T+1 可用数量、持仓成本、已实现盈亏、累计网格利润、持仓回本价、账户回本价。 -- 点击“刷新行情”从腾讯接口获取实时价格,并用实时价更新持仓市值和账户摘要。 -- 根据刷新后的现价和默认网格策略,生成选中标的的网格档位建议。 -- 使用 SQLite 本地保存数据,默认路径为 `data/grid_trading.db`。 +## 功能特性 -## 行情说明 +- **账户概览**:展示账户权益、现金、持仓市值、浮动盈亏和资金使用率。 +- **标的管理**:维护股票或 ETF 的代码、名称、市场、交易单位及零股规则。 +- **成交管理**:手工录入、编辑和删除买卖记录,并区分底仓、网格仓和其他仓位。 +- **费用估算**:按内置费率估算佣金、印花税和过户费,估算后仍可手工调整。 +- **持仓分析**:计算总持仓、T+1 可用数量、剩余成本、已实现盈亏、浮动盈亏及网格利润。 +- **回本价计算**:同时提供持仓回本价与账户回本价,便于观察网格收益对成本的影响。 +- **实时行情**:异步获取腾讯行情,刷新过程中不会阻塞主界面。 +- **网格建议**:根据实时价格和默认策略,生成最多 100 档的买入价、建议数量、卖出价与预计毛利润。 +- **待卖网格**:按批次展示尚未卖出的网格买入及其目标卖出价、当前状态。 +- **成交配对**:使用 FIFO(先进先出)将网格卖出与历史网格买入配对,一笔卖出可拆分匹配多笔买入。 +- **本地存储**:账户、标的、策略和成交数据均保存在 SQLite 数据库中。 -行情刷新使用腾讯接口: +## 技术栈 -```text -http://qt.gtimg.cn/q= +- Python 3.11+ +- PySide6 +- SQLite +- pytest + +## 快速开始 + +### 1. 进入项目目录 + +```powershell +cd Grid_Trading ``` -例如 `000001` 会自动转换为 `sz000001`,`600000` 会自动转换为 `sh600000`。 +请先下载或克隆项目,再进入项目根目录。 -实时行情只保存在当前程序内存中,用于显示现价、持仓市值、浮动盈亏、总资产和资金使用率;不会写入 SQLite。成交录入里的价格只作为成交价保存,不会作为现价兜底。关闭软件后再次打开,需要重新点击“刷新行情”。 +### 2. 创建虚拟环境并安装依赖 -## 网格档位说明 - -选中标的后,底部“网格档位”表会根据腾讯现价和默认策略模板生成建议: - -- 买入价按 `现价 * (1 - 网格间距)` 逐档向下递减。 -- 卖出价按 `买入价 * (1 + 网格间距)` 计算。 -- 建议买入股数按标的交易单位向下取整。 -- 预计单轮毛利润不扣手续费,生成结果只用于展示,不会写入成交记录或 SQLite。 - -## 待卖网格说明 - -底部“待卖网格”表会展示尚未被网格卖出抵消的网格买入批次,包括买入价、剩余股数、建议卖出价、预计毛利润、当前价和状态。网格卖出按时间顺序 FIFO 抵消最早的网格买入;建议卖出价按 `买入价 * (1 + 网格间距)` 计算。 - -## 开发环境 +Windows PowerShell: ```powershell python -m venv .venv .\.venv\Scripts\Activate.ps1 +python -m pip install --upgrade pip python -m pip install -e ".[dev]" -pytest ``` -## 启动 +macOS / Linux: + +```bash +python3 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -e ".[dev]" +``` + +### 3. 启动程序 ```powershell python -m grid_trading.app ``` -也可以指定数据库路径: +安装后也可以使用命令行入口: + +```powershell +grid-trading +``` + +默认数据库位置为 `data/grid_trading.db`。如需使用其他数据库文件: ```powershell python -m grid_trading.app --db data/dev.db ``` -## 验证 +首次启动时,程序会自动创建: + +- 初始资金为 `0` 的“默认账户”; +- 间距为 `3%`、每格金额为 `5000` 元的“默认网格模板”; +- 数据库及所需数据表。 + +## 基本使用流程 + +1. 点击“账户设置”,填写账户名称和初始资金。 +2. 点击“添加标的”,填写证券代码、名称、市场和交易单位。 +3. 点击“策略设置”,调整网格间距、每格金额等参数。 +4. 点击“录入成交”,选择买卖方向、仓位分组并填写实际成交信息。 +5. 点击“刷新行情”,获取现价并更新持仓市值、浮动盈亏和账户摘要。 +6. 在持仓表中选择一个标的,查看“网格档位”“待卖网格”“配对明细”和“最近成交”。 + +## 行情说明 + +行情数据来自腾讯接口: + +```text +http://qt.gtimg.cn/q= +``` + +程序会根据六位证券代码推断交易所前缀,例如: + +| 输入代码 | 请求代码 | +| --- | --- | +| `000001` | `sz000001` | +| `600000` | `sh600000` | +| `510300` | `sh510300` | +| `430047` | `bj430047` | + +需要注意: + +- 行情刷新需要网络连接,单次请求超时时间为 5 秒,失败后会自动重试一次。 +- 行情快照只保存在当前进程内存中,不会写入 SQLite。 +- 重新启动程序后,需要再次点击“刷新行情”。 +- 未刷新行情时,不使用手工价格或最近成交价代替现价,因此市值、浮动盈亏和网格档位可能为空。 + +## 计算口径 + +### 持仓与盈亏 + +- 买入成本 = 成交金额 + 交易费用。 +- 卖出净收入 = 成交金额 - 交易费用。 +- 同一仓位分组内的卖出成本按移动平均成本释放。 +- 已实现盈亏 = 卖出净收入 - 被释放的持仓成本。 +- 网格利润只统计“网格”分组的已实现盈亏。 +- T+1 可用数量会扣除计算当日的买入数量;卖出数量不得超过可用数量,也不得超过对应分组的持仓数量。 +- 修改或删除历史成交后,程序会重新校验后续成交,避免形成负持仓。 + +### 回本价与账户摘要 + +- 持仓回本价 =(剩余持仓成本 - 已实现网格利润)/ 当前持仓数量。 +- 账户回本价 = 标的累计净投入 / 当前持仓数量。 +- 账户权益 = 现金 + 持仓市值。 +- 资金使用率 = 持仓市值 /(持仓市值 + 非负现金);现金为负时最高显示为 100%。 + +### 网格档位 + +第 1 档买入价按下式计算,之后逐档复合递减: + +```text +买入价 = 上一档参考价 × (1 - 网格间距) +卖出价 = 买入价 × (1 + 网格间距) +建议数量 = 每格金额 ÷ 买入价,并按交易单位向下取整 +``` + +档位表中的“预计单轮毛利润”不扣除交易费用,且建议结果只用于展示,不会自动生成成交记录或下单。 + +### 待卖网格与配对明细 + +- 只有“网格”分组的成交参与待卖批次和配对计算。 +- 网格卖出按照成交日期和记录顺序,优先抵消最早的网格买入。 +- 待卖批次状态包括“未刷新行情”“未到价”和“可卖”。 +- 配对明细展示历史买卖的对应关系和毛利润,不扣除费用。 + +## 默认费用参数 + +点击成交窗口中的“估算费用”时,程序使用以下默认参数: + +| 项目 | 默认值 | 说明 | +| --- | ---: | --- | +| 佣金 | 0.025% | 最低 5 元,买卖双向收取 | +| 印花税 | 0.05% | 仅卖出时收取 | +| 过户费 | 0.001% | 买卖双向收取 | + +不同券商、市场和交易品种的实际费用可能不同,请以实际交割单为准,并在保存成交前手工修正。 + +## 数据与隐私 + +- 默认数据文件:`data/grid_trading.db` +- 账户、成交和策略数据仅存放在本机;刷新行情时只会向腾讯行情接口发送证券代码。 +- `data/*.db`、`data/*.sqlite` 和 `data/backups/` 已在 `.gitignore` 中忽略。 +- 建议定期备份数据库文件;复制数据库前请先退出程序,避免备份到尚未提交的写入状态。 + +## 项目结构 + +```text +Grid_Trading/ +├── src/grid_trading/ +│ ├── app.py # 命令行入口 +│ ├── config.py # 默认路径与费用配置 +│ ├── domain/ # 数据模型、持仓和网格计算 +│ ├── market/ # 腾讯行情适配 +│ ├── services/ # 业务编排与校验 +│ ├── storage/ # SQLite 数据库与仓储层 +│ └── ui/ # PySide6 主窗口、对话框与格式化 +├── tests/ # 自动化测试 +├── docs/ # 设计说明与开发计划 +├── data/ # 本地数据库目录 +└── pyproject.toml # 项目及依赖配置 +``` + +## 测试 + +运行完整测试: ```powershell pytest -v -python -m grid_trading.app --help ``` -如果只想确认 GUI 能构造起来,不打开真实窗口,可以运行测试里的 offscreen smoke: +只验证界面可在无显示环境下正常构造: ```powershell pytest tests/test_ui.py -v ``` + +检查命令行参数: + +```powershell +python -m grid_trading.app --help +``` + +## 当前限制 + +- 当前定位为单机、单用户的手工交易管理工具。 +- 不包含券商登录、自动下单、条件单或实盘同步功能。 +- 行情接口不是正式授权的数据服务,其可用性、实时性和准确性不作保证。 +- 网格建议未考虑手续费、滑点、涨跌停、停牌和流动性等实际交易约束。 +- 使用本项目产生的任何交易决策与风险均由使用者自行承担。 diff --git a/docs/superpowers/plans/2026-07-09-gui-visual-refresh.md b/docs/superpowers/plans/2026-07-09-gui-visual-refresh.md new file mode 100644 index 0000000..0a4b22c --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-gui-visual-refresh.md @@ -0,0 +1,200 @@ +# GUI Visual Refresh Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refresh the PySide6 main window into a clean light desktop-tool style and apply A-share profit/loss colors to all PnL-like fields. + +**Architecture:** Keep the existing `MainWindow` layout and data flow. Add focused UI helpers in `main_window.py` for theme setup, table setup, and PnL styling, then cover them with lightweight offscreen GUI tests. + +**Tech Stack:** Python 3.11+, PySide6, pytest. + +--- + +## File Structure + +- Modify `src/grid_trading/ui/main_window.py`: apply QSS theme, assign object names, centralize table setup, and style PnL labels/items. +- Modify `tests/test_ui.py`: add offscreen GUI tests for theme markers and profit/loss item styling. +- No domain, service, storage, or database changes. + +### Task 1: Add Failing GUI Tests + +**Files:** +- Test: `tests/test_ui.py` + +- [ ] **Step 1: Write failing tests for theme markers and PnL colors** + +```python +def test_main_window_applies_light_theme(tmp_path, monkeypatch): + monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") + + from PySide6.QtWidgets import QApplication + from grid_trading.services.trading_service import TradingService + from grid_trading.ui.main_window import MainWindow + + app = QApplication.instance() or QApplication([]) + service = TradingService(tmp_path / "grid.db") + window = MainWindow(service) + + assert window.centralWidget().objectName() == "appRoot" + assert window.holdings_table.alternatingRowColors() + assert "QMainWindow" in window.styleSheet() + + window.close() + service.close() + app.processEvents() +``` + +```python +def test_profit_and_loss_cells_use_a_share_colors(tmp_path, monkeypatch): + monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") + + from datetime import datetime + from decimal import Decimal + from PySide6.QtGui import QColor + from PySide6.QtWidgets import QApplication + from grid_trading.domain.models import PositionSummary + from grid_trading.services.trading_service import TradingService + from grid_trading.ui.main_window import MainWindow + + app = QApplication.instance() or QApplication([]) + service = TradingService(tmp_path / "grid.db") + window = MainWindow(service) + window._positions = [ + PositionSummary( + instrument_id=1, + code="510300", + name="沪深300ETF", + total_quantity=100, + available_quantity=100, + base_quantity=0, + grid_quantity=100, + remaining_cost=Decimal("1000"), + average_cost=Decimal("10"), + position_breakeven_price=Decimal("10"), + account_breakeven_price=Decimal("10"), + realized_pnl=Decimal("12.34"), + grid_profit=Decimal("-5.67"), + current_price=Decimal("10.50"), + market_value=Decimal("1050"), + floating_pnl=Decimal("50"), + last_quote_at=datetime(2026, 7, 9, 10, 0, 0), + ) + ] + + window._fill_holdings_table() + + profit_item = window.holdings_table.item(0, 10) + loss_item = window.holdings_table.item(0, 11) + assert profit_item.foreground().color() == QColor("#c62828") + assert profit_item.font().bold() + assert loss_item.foreground().color() == QColor("#2e7d32") + assert loss_item.font().bold() + + window.close() + service.close() + app.processEvents() +``` + +- [ ] **Step 2: Run the tests and verify they fail** + +Run: + +```bash +pytest tests/test_ui.py::test_main_window_applies_light_theme tests/test_ui.py::test_profit_and_loss_cells_use_a_share_colors -q +``` + +Expected: both new tests fail because object names, theme QSS, alternating row setup, and PnL item colors do not exist yet. + +### Task 2: Add Light Theme and Shared Table Setup + +**Files:** +- Modify: `src/grid_trading/ui/main_window.py` + +- [ ] **Step 1: Add imports and theme constants** + +Add `Decimal`, `QBrush`, `QColor`, `QFont`, and `QAbstractItemView` imports needed by the helpers. + +- [ ] **Step 2: Apply theme in `_build_ui`** + +Set `root.setObjectName("appRoot")`, call `self._apply_light_theme()`, and set object names on the navigation, summary card frames, toolbar buttons, tab widget, and tables. + +- [ ] **Step 3: Add `_apply_light_theme` and `_configure_table`** + +Create helpers that set the QSS theme once and apply consistent table settings: alternating rows, row selection, no editing, visible grid, row height, and header resize behavior. + +- [ ] **Step 4: Run theme test** + +Run: + +```bash +pytest tests/test_ui.py::test_main_window_applies_light_theme -q +``` + +Expected: PASS. + +### Task 3: Add PnL Styling Helpers + +**Files:** +- Modify: `src/grid_trading/ui/main_window.py` + +- [ ] **Step 1: Add `_style_pnl_item`, `_style_pnl_label`, and `_make_item`** + +Use the raw `Decimal | None` value, not the formatted string, to decide color and font weight: + +- `value > 0`: foreground `#c62828`, bold. +- `value < 0`: foreground `#2e7d32`, bold. +- otherwise: default foreground, non-bold. + +- [ ] **Step 2: Use helpers in summary and tables** + +Apply the helpers to summary floating PnL and these table columns: + +- holdings: columns 10, 11, 12. +- grid levels: column 6. +- open grid lots: column 5. +- grid matches: column 7. + +- [ ] **Step 3: Run PnL style test** + +Run: + +```bash +pytest tests/test_ui.py::test_profit_and_loss_cells_use_a_share_colors -q +``` + +Expected: PASS. + +### Task 4: Full Verification + +**Files:** +- No additional changes expected. + +- [ ] **Step 1: Run UI test module** + +Run: + +```bash +pytest tests/test_ui.py -q +``` + +Expected: PASS. + +- [ ] **Step 2: Run full test suite** + +Run: + +```bash +pytest -q +``` + +Expected: PASS. + +- [ ] **Step 3: Review diff** + +Run: + +```bash +git diff -- src/grid_trading/ui/main_window.py tests/test_ui.py docs/superpowers/specs/2026-07-09-gui-visual-refresh-design.md docs/superpowers/plans/2026-07-09-gui-visual-refresh.md +``` + +Expected: diff only contains the UI theme, PnL styling, tests, and docs for this task. diff --git a/docs/superpowers/specs/2026-07-09-gui-visual-refresh-design.md b/docs/superpowers/specs/2026-07-09-gui-visual-refresh-design.md new file mode 100644 index 0000000..693a81b --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-gui-visual-refresh-design.md @@ -0,0 +1,53 @@ +# GUI 页面美化与盈亏着色设计 + +日期:2026-07-09 + +## 目标 + +把现有 PySide6 桌面 GUI 调整为清爽浅色桌面工具风格,并统一处理盈亏、利润字段的视觉反馈:亏损显示绿色加粗,盈利显示红色加粗,零值和空值保持中性显示。 + +本次只改视觉表现和表格单元格样式,不改变业务计算、数据结构、行情刷新、成交录入或持仓选择逻辑。 + +## 视觉方向 + +采用轻量主题化方案: + +- 保留当前左侧导航、顶部摘要、工具栏、持仓表和底部详情 tab 的布局结构。 +- 主窗口使用浅灰背景,内容区使用白色和浅边框分区,减少默认 Qt 控件的粗糙感。 +- 侧边栏使用白底、蓝色选中态和更紧凑的列表项。 +- 摘要卡片使用白底、浅边框、圆角和更清晰的标题/数值层级。 +- 操作按钮使用蓝色主按钮样式,禁用态降低对比。 +- 表格使用白底、浅灰表头、斑马纹、整行选中和统一网格线颜色。 +- Tab 使用浅色选项卡,当前 tab 用蓝色强调。 + +## 盈亏字段 + +需要应用盈亏样式的字段: + +- 汇总卡:浮动盈亏。 +- 持仓表:已实现盈亏、网格利润、浮动盈亏。 +- 网格档位表:预计单轮毛利润。 +- 待卖网格表:预计毛利润。 +- 配对明细表:毛利润。 + +样式规则: + +- 大于 0:红色、加粗。 +- 小于 0:绿色、加粗。 +- 等于 0、空值或无法解析:默认文字色和普通字重。 + +颜色按 A 股常见习惯处理,红色代表盈利,绿色代表亏损。 + +## 实现方式 + +- 在 `MainWindow` 中新增一个全局样式应用方法,用 QSS 统一主窗口、按钮、列表、表格、tab 和摘要卡片样式。 +- 给摘要卡片和关键控件设置 objectName,让 QSS 精准命中,避免影响弹窗内部布局。 +- 新增表格初始化 helper,复用表头、选择、斑马纹、行高和滚动行为设置。 +- 新增数值单元格 helper,根据 Decimal 原始值给 `QTableWidgetItem` 设置前景色和加粗字体。 +- 刷新汇总卡时对浮动盈亏标签应用同样的盈亏样式。 + +## 测试策略 + +- 增加 GUI 烟测,验证主窗口应用了浅色主题相关 objectName 或样式入口。 +- 增加单元格样式测试,直接填充带盈利和亏损的持仓数据,验证盈利字段为红色加粗、亏损字段为绿色加粗。 +- 保留现有窗口构造、tab、刷新线程测试。 diff --git a/src/grid_trading/domain/calculations.py b/src/grid_trading/domain/calculations.py index 2910af7..29f9518 100644 --- a/src/grid_trading/domain/calculations.py +++ b/src/grid_trading/domain/calculations.py @@ -177,9 +177,10 @@ def calculate_account_summary( sum((position.floating_pnl or Decimal("0") for position in positions), Decimal("0")) ) total_assets = money(cash + market_value) + deployable_assets = market_value + max(cash, Decimal("0")) capital_usage_rate = ( - (market_value / total_assets).quantize(RATE_PLACES, rounding=ROUND_HALF_UP) - if total_assets > 0 + (market_value / deployable_assets).quantize(RATE_PLACES, rounding=ROUND_HALF_UP) + if deployable_assets > 0 else Decimal("0") ) return AccountSummary( diff --git a/src/grid_trading/domain/models.py b/src/grid_trading/domain/models.py index 0d0d766..a97eb06 100644 --- a/src/grid_trading/domain/models.py +++ b/src/grid_trading/domain/models.py @@ -138,6 +138,20 @@ class OpenGridLot: status: str +@dataclass(frozen=True) +class GridTradeMatch: + sell_trade_id: int | None + sell_date: date + sell_price: Decimal + buy_trade_id: int | None + buy_date: date + buy_price: Decimal + matched_quantity: int + buy_amount: Decimal + sell_amount: Decimal + gross_profit: Decimal + + @dataclass(frozen=True) class QuoteSnapshot: symbol: str diff --git a/src/grid_trading/domain/open_grid_lots.py b/src/grid_trading/domain/open_grid_lots.py index 55c795f..1429bda 100644 --- a/src/grid_trading/domain/open_grid_lots.py +++ b/src/grid_trading/domain/open_grid_lots.py @@ -1,11 +1,19 @@ from __future__ import annotations -from dataclasses import replace +from dataclasses import dataclass, replace from datetime import date from decimal import Decimal from grid_trading.domain.calculations import money, price -from grid_trading.domain.models import OpenGridLot, Trade, TradeGroup, TradeSide +from grid_trading.domain.models import GridTradeMatch, OpenGridLot, Trade, TradeGroup, TradeSide + + +@dataclass +class _OpenBuyLot: + trade_id: int | None + buy_date: date + buy_price: Decimal + remaining_quantity: int def calculate_open_grid_lots( @@ -45,6 +53,52 @@ def calculate_open_grid_lots( return lots +def calculate_grid_trade_matches( + trades: list[Trade], + *, + as_of: date, +) -> list[GridTradeMatch]: + open_buys: list[_OpenBuyLot] = [] + matches: list[GridTradeMatch] = [] + for trade in sorted(trades, key=lambda item: (item.trade_date, item.id or 0)): + if trade.trade_date > as_of or trade.trade_group is not TradeGroup.GRID: + continue + if trade.side is TradeSide.BUY: + open_buys.append( + _OpenBuyLot( + trade_id=trade.id, + buy_date=trade.trade_date, + buy_price=price(trade.price), + remaining_quantity=trade.quantity, + ) + ) + continue + sell_price = price(trade.price) + remaining_sell_quantity = trade.quantity + while remaining_sell_quantity > 0 and open_buys: + buy_lot = open_buys[0] + matched_quantity = min(buy_lot.remaining_quantity, remaining_sell_quantity) + matches.append( + GridTradeMatch( + sell_trade_id=trade.id, + sell_date=trade.trade_date, + sell_price=sell_price, + buy_trade_id=buy_lot.trade_id, + buy_date=buy_lot.buy_date, + buy_price=buy_lot.buy_price, + matched_quantity=matched_quantity, + buy_amount=money(buy_lot.buy_price * Decimal(matched_quantity)), + sell_amount=money(sell_price * Decimal(matched_quantity)), + gross_profit=money((sell_price - buy_lot.buy_price) * Decimal(matched_quantity)), + ) + ) + buy_lot.remaining_quantity -= matched_quantity + remaining_sell_quantity -= matched_quantity + if buy_lot.remaining_quantity == 0: + open_buys.pop(0) + return matches + + def _match_sell_against_lots(lots: list[OpenGridLot], quantity: int) -> list[OpenGridLot]: remaining_sell_quantity = quantity updated_lots: list[OpenGridLot] = [] diff --git a/src/grid_trading/services/trading_service.py b/src/grid_trading/services/trading_service.py index c8b6ee5..a93460e 100644 --- a/src/grid_trading/services/trading_service.py +++ b/src/grid_trading/services/trading_service.py @@ -19,6 +19,7 @@ from grid_trading.domain.models import ( FeeEstimate, FeeRules, GridLevelSuggestion, + GridTradeMatch, Instrument, OpenGridLot, PositionSummary, @@ -28,7 +29,7 @@ from grid_trading.domain.models import ( Trade, TradeSide, ) -from grid_trading.domain.open_grid_lots import calculate_open_grid_lots +from grid_trading.domain.open_grid_lots import calculate_grid_trade_matches, calculate_open_grid_lots from grid_trading.market.tencent import TencentQuoteProvider from grid_trading.storage.repositories import Repository @@ -262,6 +263,18 @@ class TradingService: as_of=as_of_date, ) + def get_grid_trade_matches( + self, + instrument_id: int, + *, + as_of: date | None = None, + ) -> list[GridTradeMatch]: + as_of_date = as_of or date.today() + self._require_instrument(instrument_id) + account = self.get_active_account() + trades = self.repository.list_trades(account_id=account.id, instrument_id=instrument_id) + return calculate_grid_trade_matches(trades, as_of=as_of_date) + def _require_account(self, account_id: int) -> Account: account = self.repository.get_account(account_id) if account is None: diff --git a/src/grid_trading/ui/main_window.py b/src/grid_trading/ui/main_window.py index a0643d0..05fc67b 100644 --- a/src/grid_trading/ui/main_window.py +++ b/src/grid_trading/ui/main_window.py @@ -1,10 +1,13 @@ from __future__ import annotations import sys +from decimal import Decimal from pathlib import Path from PySide6.QtCore import QObject, Qt, QThread, Signal +from PySide6.QtGui import QBrush, QColor, QFont from PySide6.QtWidgets import ( + QAbstractItemView, QApplication, QDialog, QFrame, @@ -26,7 +29,14 @@ from PySide6.QtWidgets import ( ) from grid_trading.config import DEFAULT_DB_PATH -from grid_trading.domain.models import GridLevelSuggestion, OpenGridLot, PositionSummary, Trade, TradeSide +from grid_trading.domain.models import ( + GridLevelSuggestion, + GridTradeMatch, + OpenGridLot, + PositionSummary, + Trade, + TradeSide, +) from grid_trading.services.trading_service import TradingService from grid_trading.ui.dialogs import AccountDialog, InstrumentDialog, StrategyTemplateDialog, TradeDialog from grid_trading.ui.formatters import format_money, format_percent, format_price, format_quantity @@ -51,6 +61,10 @@ class QuoteRefreshWorker(QObject): class MainWindow(QMainWindow): + PROFIT_COLOR = QColor("#c62828") + LOSS_COLOR = QColor("#2e7d32") + NEUTRAL_COLOR = QColor("#334155") + HOLDING_COLUMNS = [ "代码", "名称", @@ -86,6 +100,17 @@ class MainWindow(QMainWindow): "当前价", "状态", ] + GRID_MATCH_COLUMNS = [ + "卖出日期", + "卖出价", + "匹配买入日期", + "买入价", + "匹配股数", + "买入金额", + "卖出金额", + "毛利润", + "对应成交", + ] def __init__(self, service: TradingService): super().__init__() @@ -104,16 +129,26 @@ class MainWindow(QMainWindow): self.refresh_all() def _build_ui(self) -> None: + self._apply_light_theme() + root = QWidget() + root.setObjectName("appRoot") root_layout = QHBoxLayout(root) + root_layout.setContentsMargins(14, 14, 14, 14) + root_layout.setSpacing(14) nav = QListWidget() + nav.setObjectName("sideNav") nav.addItems(["账户总览", "持仓管理", "成交记录", "网格策略", "设置"]) nav.setFixedWidth(150) + nav.setCurrentRow(1) root_layout.addWidget(nav) content = QWidget() + content.setObjectName("contentPanel") content_layout = QVBoxLayout(content) + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.setSpacing(12) self.summary_labels = self._create_summary_cards() content_layout.addLayout(self.summary_cards_layout) content_layout.addLayout(self._create_toolbar()) @@ -121,17 +156,17 @@ class MainWindow(QMainWindow): splitter = QSplitter(Qt.Orientation.Vertical) self.holdings_table = QTableWidget(0, len(self.HOLDING_COLUMNS)) self.holdings_table.setHorizontalHeaderLabels(self.HOLDING_COLUMNS) - self.holdings_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) - self.holdings_table.horizontalHeader().setStretchLastSection(True) - self.holdings_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) - self.holdings_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self._configure_table(self.holdings_table) self.holdings_table.itemSelectionChanged.connect(self._on_holding_selected) splitter.addWidget(self.holdings_table) self.details_tabs = QTabWidget() + self.details_tabs.setObjectName("detailsTabs") grid_tab = QWidget() grid_layout = QVBoxLayout(grid_tab) + grid_layout.setContentsMargins(10, 10, 10, 10) + grid_layout.setSpacing(8) grid_controls = QHBoxLayout() self.grid_levels_count_edit = QSpinBox() self.grid_levels_count_edit.setRange(1, 100) @@ -144,41 +179,51 @@ class MainWindow(QMainWindow): grid_controls.addStretch() self.grid_levels_table = QTableWidget(0, len(self.GRID_LEVEL_COLUMNS)) self.grid_levels_table.setHorizontalHeaderLabels(self.GRID_LEVEL_COLUMNS) - self.grid_levels_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) - self.grid_levels_table.horizontalHeader().setStretchLastSection(True) - self.grid_levels_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self._configure_table(self.grid_levels_table) grid_layout.addLayout(grid_controls) grid_layout.addWidget(self.grid_levels_table) open_grid_tab = QWidget() open_grid_layout = QVBoxLayout(open_grid_tab) + open_grid_layout.setContentsMargins(10, 10, 10, 10) + open_grid_layout.setSpacing(8) self.open_grid_lots_table = QTableWidget(0, len(self.OPEN_GRID_LOT_COLUMNS)) self.open_grid_lots_table.setHorizontalHeaderLabels(self.OPEN_GRID_LOT_COLUMNS) - self.open_grid_lots_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) - self.open_grid_lots_table.horizontalHeader().setStretchLastSection(True) - self.open_grid_lots_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self._configure_table(self.open_grid_lots_table) open_grid_layout.addWidget(self.open_grid_lots_table) + grid_match_tab = QWidget() + grid_match_layout = QVBoxLayout(grid_match_tab) + grid_match_layout.setContentsMargins(10, 10, 10, 10) + grid_match_layout.setSpacing(8) + self.grid_matches_table = QTableWidget(0, len(self.GRID_MATCH_COLUMNS)) + self.grid_matches_table.setHorizontalHeaderLabels(self.GRID_MATCH_COLUMNS) + self._configure_table(self.grid_matches_table) + grid_match_layout.addWidget(self.grid_matches_table) + trade_tab = QWidget() trade_layout = QVBoxLayout(trade_tab) + trade_layout.setContentsMargins(10, 10, 10, 10) + trade_layout.setSpacing(8) trade_buttons = QHBoxLayout() edit_trade_button = QPushButton("编辑成交") + edit_trade_button.setObjectName("toolbarButton") edit_trade_button.clicked.connect(self._edit_selected_trade) delete_trade_button = QPushButton("删除成交") + delete_trade_button.setObjectName("toolbarButton") delete_trade_button.clicked.connect(self._delete_selected_trade) trade_buttons.addWidget(edit_trade_button) trade_buttons.addWidget(delete_trade_button) trade_buttons.addStretch() self.trades_table = QTableWidget(0, len(self.TRADE_COLUMNS)) self.trades_table.setHorizontalHeaderLabels(self.TRADE_COLUMNS) - self.trades_table.horizontalHeader().setStretchLastSection(True) - self.trades_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) - self.trades_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self._configure_table(self.trades_table) trade_layout.addLayout(trade_buttons) trade_layout.addWidget(self.trades_table) self.details_tabs.addTab(grid_tab, "网格档位") self.details_tabs.addTab(open_grid_tab, "待卖网格") + self.details_tabs.addTab(grid_match_tab, "配对明细") self.details_tabs.addTab(trade_tab, "最近成交") self.details_tabs.setCurrentIndex(1) splitter.addWidget(self.details_tabs) @@ -188,12 +233,171 @@ class MainWindow(QMainWindow): root_layout.addWidget(content) self.setCentralWidget(root) + def _apply_light_theme(self) -> None: + self.setStyleSheet( + """ + QMainWindow { + background: #f4f7fb; + color: #334155; + font-family: "Microsoft YaHei", "Segoe UI", Arial, sans-serif; + font-size: 13px; + } + QWidget#appRoot { + background: #f4f7fb; + } + QListWidget#sideNav { + background: #ffffff; + border: 1px solid #dbe4ef; + border-radius: 8px; + padding: 8px; + outline: 0; + } + QListWidget#sideNav::item { + border-radius: 6px; + color: #475569; + margin: 2px 0; + padding: 10px 12px; + } + QListWidget#sideNav::item:selected { + background: #e8f1ff; + color: #1d4ed8; + font-weight: 700; + } + QFrame#summaryCard { + background: #ffffff; + border: 1px solid #dbe4ef; + border-radius: 8px; + } + QLabel#summaryTitle { + color: #64748b; + font-size: 12px; + } + QLabel#summaryValue { + color: #0f172a; + font-size: 20px; + font-weight: 700; + } + QPushButton#toolbarButton { + background: #2563eb; + border: 1px solid #1d4ed8; + border-radius: 6px; + color: #ffffff; + font-weight: 700; + min-height: 30px; + padding: 6px 12px; + } + QPushButton#toolbarButton:hover { + background: #1d4ed8; + } + QPushButton#toolbarButton:pressed { + background: #1e40af; + } + QPushButton#toolbarButton:disabled { + background: #cbd5e1; + border-color: #cbd5e1; + color: #f8fafc; + } + QTabWidget#detailsTabs::pane { + background: #ffffff; + border: 1px solid #dbe4ef; + border-radius: 8px; + top: -1px; + } + QTabBar::tab { + background: #eef2f7; + border: 1px solid #dbe4ef; + border-bottom: 0; + border-top-left-radius: 6px; + border-top-right-radius: 6px; + color: #475569; + margin-right: 4px; + padding: 8px 14px; + } + QTabBar::tab:selected { + background: #ffffff; + color: #1d4ed8; + font-weight: 700; + } + QTableWidget { + background: #ffffff; + alternate-background-color: #f8fafc; + border: 1px solid #dbe4ef; + border-radius: 8px; + gridline-color: #e2e8f0; + selection-background-color: #dbeafe; + selection-color: #0f172a; + } + QHeaderView::section { + background: #f1f5f9; + border: 0; + border-right: 1px solid #dbe4ef; + border-bottom: 1px solid #dbe4ef; + color: #475569; + font-weight: 700; + padding: 8px 10px; + } + QSpinBox { + background: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 6px; + min-height: 28px; + padding: 2px 8px; + } + QLabel { + color: #334155; + } + """ + ) + + def _configure_table(self, table: QTableWidget) -> None: + table.setAlternatingRowColors(True) + table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + table.setShowGrid(True) + table.setWordWrap(False) + table.verticalHeader().setVisible(False) + table.verticalHeader().setDefaultSectionSize(34) + table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + table.horizontalHeader().setStretchLastSection(True) + + def _make_item(self, value: str, pnl_value: Decimal | None = None) -> QTableWidgetItem: + item = QTableWidgetItem(value) + if pnl_value is not None: + self._style_pnl_item(item, pnl_value) + return item + + def _style_pnl_item(self, item: QTableWidgetItem, value: Decimal | None) -> None: + if value is None: + return + + font = QFont(item.font()) + if value > 0: + item.setForeground(QBrush(self.PROFIT_COLOR)) + font.setBold(True) + elif value < 0: + item.setForeground(QBrush(self.LOSS_COLOR)) + font.setBold(True) + else: + font.setBold(False) + item.setFont(font) + + def _style_pnl_label(self, label: QLabel, value: Decimal | None) -> None: + if value is None or value == 0: + label.setStyleSheet(f"color: {self.NEUTRAL_COLOR.name()}; font-weight: 400;") + elif value > 0: + label.setStyleSheet(f"color: {self.PROFIT_COLOR.name()}; font-weight: 700;") + else: + label.setStyleSheet(f"color: {self.LOSS_COLOR.name()}; font-weight: 700;") + def _create_summary_cards(self) -> dict[str, QLabel]: self.summary_cards_layout = QGridLayout() + self.summary_cards_layout.setHorizontalSpacing(10) + self.summary_cards_layout.setVerticalSpacing(10) labels: dict[str, QLabel] = {} for column, (key, title) in enumerate( [ - ("total_assets", "总资产"), + ("total_assets", "账户权益"), ("cash", "现金"), ("market_value", "持仓市值"), ("floating_pnl", "浮动盈亏"), @@ -201,11 +405,15 @@ class MainWindow(QMainWindow): ] ): frame = QFrame() + frame.setObjectName("summaryCard") frame.setFrameShape(QFrame.Shape.StyledPanel) layout = QVBoxLayout(frame) + layout.setContentsMargins(14, 12, 14, 12) + layout.setSpacing(6) title_label = QLabel(title) + title_label.setObjectName("summaryTitle") value_label = QLabel("-") - value_label.setStyleSheet("font-size: 20px; font-weight: 700;") + value_label.setObjectName("summaryValue") layout.addWidget(title_label) layout.addWidget(value_label) labels[key] = value_label @@ -223,6 +431,7 @@ class MainWindow(QMainWindow): ] for text, handler in buttons: button = QPushButton(text) + button.setObjectName("toolbarButton") button.clicked.connect(handler) if text == "刷新行情": self.refresh_quotes_button = button @@ -241,6 +450,7 @@ class MainWindow(QMainWindow): self.summary_labels["cash"].setText(format_money(account_summary.cash)) self.summary_labels["market_value"].setText(format_money(account_summary.market_value)) self.summary_labels["floating_pnl"].setText(format_money(account_summary.floating_pnl)) + self._style_pnl_label(self.summary_labels["floating_pnl"], account_summary.floating_pnl) self.summary_labels["usage"].setText(format_percent(account_summary.capital_usage_rate)) self._fill_holdings_table() self._refresh_details() @@ -309,8 +519,13 @@ class MainWindow(QMainWindow): format_money(position.grid_profit), format_money(position.floating_pnl), ] + pnl_values = { + 10: position.realized_pnl, + 11: position.grid_profit, + 12: position.floating_pnl, + } for column, value in enumerate(values): - item = QTableWidgetItem(value) + item = self._make_item(value, pnl_values.get(column)) item.setData(Qt.ItemDataRole.UserRole, position.instrument_id) self.holdings_table.setItem(row, column, item) if self._positions and self.holdings_table.currentRow() < 0: @@ -327,10 +542,12 @@ class MainWindow(QMainWindow): if position is None: self._refresh_grid_levels(None) self._refresh_open_grid_lots(None) + self._refresh_grid_matches(None) self._fill_trades_table([]) return self._refresh_grid_levels(position) self._refresh_open_grid_lots(position) + self._refresh_grid_matches(position) self._fill_trades_table(self.service.list_trades(instrument_id=position.instrument_id)) def _refresh_grid_levels(self, position: PositionSummary | None) -> None: @@ -368,7 +585,11 @@ class MainWindow(QMainWindow): format_money(level.estimated_gross_profit), ] for column, value in enumerate(values): - self.grid_levels_table.setItem(row, column, QTableWidgetItem(value)) + item = self._make_item( + value, + level.estimated_gross_profit if column == 6 else None, + ) + self.grid_levels_table.setItem(row, column, item) def _refresh_open_grid_lots(self, position: PositionSummary | None) -> None: if position is None: @@ -396,7 +617,41 @@ class MainWindow(QMainWindow): lot.status, ] for column, value in enumerate(values): - self.open_grid_lots_table.setItem(row, column, QTableWidgetItem(value)) + item = self._make_item( + value, + lot.estimated_gross_profit if column == 5 else None, + ) + self.open_grid_lots_table.setItem(row, column, item) + + def _refresh_grid_matches(self, position: PositionSummary | None) -> None: + if position is None: + self._fill_grid_matches_table([]) + return + try: + matches = self.service.get_grid_trade_matches(position.instrument_id) + except Exception as exc: + QMessageBox.warning(self, "配对明细失败", str(exc)) + self._fill_grid_matches_table([]) + return + self._fill_grid_matches_table(matches) + + def _fill_grid_matches_table(self, matches: list[GridTradeMatch]) -> None: + self.grid_matches_table.setRowCount(len(matches)) + for row, match in enumerate(matches): + values = [ + match.sell_date.isoformat(), + format_price(match.sell_price), + match.buy_date.isoformat(), + format_price(match.buy_price), + format_quantity(match.matched_quantity), + format_money(match.buy_amount), + format_money(match.sell_amount), + format_money(match.gross_profit), + f"买{match.buy_trade_id or '-'} -> 卖{match.sell_trade_id or '-'}", + ] + for column, value in enumerate(values): + item = self._make_item(value, match.gross_profit if column == 7 else None) + self.grid_matches_table.setItem(row, column, item) def _fill_trades_table(self, trades: list[Trade]) -> None: self._trade_ids_by_row = {} diff --git a/tests/test_calculations.py b/tests/test_calculations.py index 9c55224..c844138 100644 --- a/tests/test_calculations.py +++ b/tests/test_calculations.py @@ -3,8 +3,13 @@ from decimal import Decimal import pytest -from grid_trading.domain.calculations import CalculationError, calculate_positions, estimate_fees -from grid_trading.domain.models import FeeRules, Instrument, QuoteSnapshot, Trade, TradeGroup, TradeSide +from grid_trading.domain.calculations import ( + CalculationError, + calculate_account_summary, + calculate_positions, + estimate_fees, +) +from grid_trading.domain.models import Account, FeeRules, Instrument, QuoteSnapshot, Trade, TradeGroup, TradeSide def make_trade( @@ -175,6 +180,39 @@ def test_missing_quote_does_not_use_manual_or_last_trade_price_for_current_price assert summary.floating_pnl is None +def test_account_summary_caps_capital_usage_when_cash_is_negative(): + today = date(2026, 7, 8) + account = Account(id=1, name="主账户", initial_cash=Decimal("100")) + instrument = Instrument(id=1, code="600588", name="用友网络") + trades = [ + make_trade( + trade_id=1, + trade_date=today, + side=TradeSide.BUY, + price="1.50", + quantity=100, + trade_group=TradeGroup.BASE, + ) + ] + quotes = { + 1: QuoteSnapshot( + symbol="sh600588", + code="600588", + name="用友网络", + price=Decimal("1.00"), + source="tencent", + ) + } + + positions = calculate_positions([instrument], trades, as_of=today, quote_snapshots=quotes) + summary = calculate_account_summary(account, positions, [], trades) + + assert summary.cash == Decimal("-50.00") + assert summary.market_value == Decimal("100.00") + assert summary.total_assets == Decimal("50.00") + assert summary.capital_usage_rate == Decimal("1.0000") + + 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_open_grid_lots.py b/tests/test_open_grid_lots.py index 1bd0694..e883637 100644 --- a/tests/test_open_grid_lots.py +++ b/tests/test_open_grid_lots.py @@ -2,7 +2,7 @@ from datetime import date, timedelta from decimal import Decimal from grid_trading.domain.models import Trade, TradeGroup, TradeSide -from grid_trading.domain.open_grid_lots import calculate_open_grid_lots +from grid_trading.domain.open_grid_lots import calculate_grid_trade_matches, calculate_open_grid_lots def make_trade( @@ -80,6 +80,52 @@ def test_calculate_open_grid_lots_fifo_matches_grid_sells_against_grid_buys(): assert lot.status == "可卖" +def test_calculate_grid_trade_matches_splits_sell_against_fifo_buy_lots(): + today = date(2026, 7, 9) + trades = [ + make_trade( + trade_id=1, + trade_date=today - timedelta(days=3), + side=TradeSide.BUY, + price="10.00", + quantity=300, + ), + make_trade( + trade_id=2, + trade_date=today - timedelta(days=2), + side=TradeSide.BUY, + price="9.50", + quantity=200, + ), + make_trade( + trade_id=3, + trade_date=today - timedelta(days=1), + side=TradeSide.SELL, + price="10.30", + quantity=350, + ), + ] + + matches = calculate_grid_trade_matches(trades, as_of=today) + + assert len(matches) == 2 + assert matches[0].sell_trade_id == 3 + assert matches[0].sell_date == today - timedelta(days=1) + assert matches[0].sell_price == Decimal("10.30") + assert matches[0].buy_trade_id == 1 + assert matches[0].buy_date == today - timedelta(days=3) + assert matches[0].buy_price == Decimal("10.00") + assert matches[0].matched_quantity == 300 + assert matches[0].buy_amount == Decimal("3000.00") + assert matches[0].sell_amount == Decimal("3090.00") + assert matches[0].gross_profit == Decimal("90.00") + assert matches[1].buy_trade_id == 2 + assert matches[1].matched_quantity == 50 + assert matches[1].buy_amount == Decimal("475.00") + assert matches[1].sell_amount == Decimal("515.00") + assert matches[1].gross_profit == Decimal("40.00") + + def test_calculate_open_grid_lots_reports_not_reached_and_missing_quote_statuses(): today = date(2026, 7, 9) trades = [ diff --git a/tests/test_services.py b/tests/test_services.py index 4c8d944..620b8a7 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -298,3 +298,55 @@ def test_service_returns_open_grid_lots_with_suggested_sell_price(tmp_path): assert lot.suggested_sell_price == Decimal("4.12") assert lot.current_price == Decimal("4.12") assert lot.status == "可卖" + + +def test_service_returns_grid_trade_matches(tmp_path): + service = TradingService(tmp_path / "grid.db") + service.ensure_defaults() + account = service.get_active_account() + instrument = service.add_instrument(Instrument(id=None, code="510300", name="沪深300ETF", market="ETF")) + service.save_trade( + Trade( + id=None, + account_id=account.id, + instrument_id=instrument.id, + trade_date=date(2026, 7, 6), + side=TradeSide.BUY, + price=Decimal("10.00"), + quantity=300, + trade_group=TradeGroup.GRID, + ) + ) + service.save_trade( + Trade( + id=None, + account_id=account.id, + instrument_id=instrument.id, + trade_date=date(2026, 7, 7), + side=TradeSide.BUY, + price=Decimal("9.50"), + quantity=200, + trade_group=TradeGroup.GRID, + ) + ) + service.save_trade( + Trade( + id=None, + account_id=account.id, + instrument_id=instrument.id, + trade_date=date(2026, 7, 8), + side=TradeSide.SELL, + price=Decimal("10.30"), + quantity=400, + trade_group=TradeGroup.GRID, + ) + ) + + matches = service.get_grid_trade_matches(instrument.id, as_of=date(2026, 7, 9)) + + assert len(matches) == 2 + assert matches[0].buy_trade_id is not None + assert matches[0].matched_quantity == 300 + assert matches[0].gross_profit == Decimal("90.00") + assert matches[1].matched_quantity == 100 + assert matches[1].gross_profit == Decimal("80.00") diff --git a/tests/test_ui.py b/tests/test_ui.py index 386fd1a..958d324 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -17,7 +17,7 @@ def test_formatters_render_money_percent_and_empty_values(): def test_main_window_can_be_constructed_offscreen(tmp_path, monkeypatch): monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") - from PySide6.QtWidgets import QApplication, QPushButton + from PySide6.QtWidgets import QApplication, QLabel, QPushButton from grid_trading.services.trading_service import TradingService from grid_trading.ui.main_window import MainWindow @@ -29,6 +29,9 @@ def test_main_window_can_be_constructed_offscreen(tmp_path, monkeypatch): assert window.windowTitle() == "Grid Trading Manager" assert window.holdings_table.columnCount() > 0 assert any(button.text() == "刷新行情" for button in window.findChildren(QPushButton)) + summary_titles = [label.text() for label in window.findChildren(QLabel)] + assert "账户权益" in summary_titles + assert "总资产" not in summary_titles window.close() service.close() @@ -49,7 +52,7 @@ def test_main_window_contains_grid_level_table(tmp_path, monkeypatch): assert window.grid_levels_table.columnCount() == 7 tab_titles = _tab_titles(window.details_tabs) - assert tab_titles == ["网格档位", "待卖网格", "最近成交"] + assert tab_titles == ["网格档位", "待卖网格", "配对明细", "最近成交"] assert isinstance(window.details_tabs, QTabWidget) window.close() @@ -71,6 +74,82 @@ def test_main_window_contains_open_grid_lots_table(tmp_path, monkeypatch): assert window.open_grid_lots_table.columnCount() == 8 assert "待卖网格" in _tab_titles(window.details_tabs) + assert window.grid_matches_table.columnCount() == 9 + assert "配对明细" in _tab_titles(window.details_tabs) + + window.close() + service.close() + app.processEvents() + + +def test_main_window_applies_light_theme(tmp_path, monkeypatch): + monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") + + from PySide6.QtWidgets import QApplication + + from grid_trading.services.trading_service import TradingService + from grid_trading.ui.main_window import MainWindow + + app = QApplication.instance() or QApplication([]) + service = TradingService(tmp_path / "grid.db") + window = MainWindow(service) + + assert window.centralWidget().objectName() == "appRoot" + assert window.holdings_table.alternatingRowColors() + assert "QMainWindow" in window.styleSheet() + + window.close() + service.close() + app.processEvents() + + +def test_profit_and_loss_cells_use_a_share_colors(tmp_path, monkeypatch): + monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") + + from PySide6.QtGui import QColor + from PySide6.QtWidgets import QApplication + + from grid_trading.domain.models import PositionSummary + from grid_trading.services.trading_service import TradingService + from grid_trading.ui.main_window import MainWindow + + app = QApplication.instance() or QApplication([]) + service = TradingService(tmp_path / "grid.db") + window = MainWindow(service) + window._positions = [ + PositionSummary( + instrument_id=1, + code="510300", + name="沪深300ETF", + market="ETF", + current_price=Decimal("10.50"), + price_source="quote", + total_quantity=100, + available_quantity=100, + base_quantity=0, + grid_quantity=100, + other_quantity=0, + remaining_cost=Decimal("1000"), + cost_price=Decimal("10"), + position_breakeven_price=Decimal("10"), + account_breakeven_price=Decimal("10"), + realized_pnl=Decimal("12.34"), + grid_profit=Decimal("-5.67"), + floating_pnl=Decimal("50"), + market_value=Decimal("1050"), + ) + ] + + window.holdings_table.blockSignals(True) + window._fill_holdings_table() + window.holdings_table.blockSignals(False) + + profit_item = window.holdings_table.item(0, 10) + loss_item = window.holdings_table.item(0, 11) + assert profit_item.foreground().color() == QColor("#c62828") + assert profit_item.font().bold() + assert loss_item.foreground().color() == QColor("#2e7d32") + assert loss_item.font().bold() window.close() service.close()