feat: 优化界面并补充网格成交分析
This commit is contained in:
217
README.md
217
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
|
- Python 3.11+
|
||||||
http://qt.gtimg.cn/q=<symbol>
|
- PySide6
|
||||||
|
- SQLite
|
||||||
|
- pytest
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 进入项目目录
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd Grid_Trading
|
||||||
```
|
```
|
||||||
|
|
||||||
例如 `000001` 会自动转换为 `sz000001`,`600000` 会自动转换为 `sh600000`。
|
请先下载或克隆项目,再进入项目根目录。
|
||||||
|
|
||||||
实时行情只保存在当前程序内存中,用于显示现价、持仓市值、浮动盈亏、总资产和资金使用率;不会写入 SQLite。成交录入里的价格只作为成交价保存,不会作为现价兜底。关闭软件后再次打开,需要重新点击“刷新行情”。
|
### 2. 创建虚拟环境并安装依赖
|
||||||
|
|
||||||
## 网格档位说明
|
Windows PowerShell:
|
||||||
|
|
||||||
选中标的后,底部“网格档位”表会根据腾讯现价和默认策略模板生成建议:
|
|
||||||
|
|
||||||
- 买入价按 `现价 * (1 - 网格间距)` 逐档向下递减。
|
|
||||||
- 卖出价按 `买入价 * (1 + 网格间距)` 计算。
|
|
||||||
- 建议买入股数按标的交易单位向下取整。
|
|
||||||
- 预计单轮毛利润不扣手续费,生成结果只用于展示,不会写入成交记录或 SQLite。
|
|
||||||
|
|
||||||
## 待卖网格说明
|
|
||||||
|
|
||||||
底部“待卖网格”表会展示尚未被网格卖出抵消的网格买入批次,包括买入价、剩余股数、建议卖出价、预计毛利润、当前价和状态。网格卖出按时间顺序 FIFO 抵消最早的网格买入;建议卖出价按 `买入价 * (1 + 网格间距)` 计算。
|
|
||||||
|
|
||||||
## 开发环境
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
.\.venv\Scripts\Activate.ps1
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
python -m pip install --upgrade pip
|
||||||
python -m pip install -e ".[dev]"
|
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
|
```powershell
|
||||||
python -m grid_trading.app
|
python -m grid_trading.app
|
||||||
```
|
```
|
||||||
|
|
||||||
也可以指定数据库路径:
|
安装后也可以使用命令行入口:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
grid-trading
|
||||||
|
```
|
||||||
|
|
||||||
|
默认数据库位置为 `data/grid_trading.db`。如需使用其他数据库文件:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
python -m grid_trading.app --db data/dev.db
|
python -m grid_trading.app --db data/dev.db
|
||||||
```
|
```
|
||||||
|
|
||||||
## 验证
|
首次启动时,程序会自动创建:
|
||||||
|
|
||||||
|
- 初始资金为 `0` 的“默认账户”;
|
||||||
|
- 间距为 `3%`、每格金额为 `5000` 元的“默认网格模板”;
|
||||||
|
- 数据库及所需数据表。
|
||||||
|
|
||||||
|
## 基本使用流程
|
||||||
|
|
||||||
|
1. 点击“账户设置”,填写账户名称和初始资金。
|
||||||
|
2. 点击“添加标的”,填写证券代码、名称、市场和交易单位。
|
||||||
|
3. 点击“策略设置”,调整网格间距、每格金额等参数。
|
||||||
|
4. 点击“录入成交”,选择买卖方向、仓位分组并填写实际成交信息。
|
||||||
|
5. 点击“刷新行情”,获取现价并更新持仓市值、浮动盈亏和账户摘要。
|
||||||
|
6. 在持仓表中选择一个标的,查看“网格档位”“待卖网格”“配对明细”和“最近成交”。
|
||||||
|
|
||||||
|
## 行情说明
|
||||||
|
|
||||||
|
行情数据来自腾讯接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://qt.gtimg.cn/q=<symbol>
|
||||||
|
```
|
||||||
|
|
||||||
|
程序会根据六位证券代码推断交易所前缀,例如:
|
||||||
|
|
||||||
|
| 输入代码 | 请求代码 |
|
||||||
|
| --- | --- |
|
||||||
|
| `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
|
```powershell
|
||||||
pytest -v
|
pytest -v
|
||||||
python -m grid_trading.app --help
|
|
||||||
```
|
```
|
||||||
|
|
||||||
如果只想确认 GUI 能构造起来,不打开真实窗口,可以运行测试里的 offscreen smoke:
|
只验证界面可在无显示环境下正常构造:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
pytest tests/test_ui.py -v
|
pytest tests/test_ui.py -v
|
||||||
```
|
```
|
||||||
|
|
||||||
|
检查命令行参数:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m grid_trading.app --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## 当前限制
|
||||||
|
|
||||||
|
- 当前定位为单机、单用户的手工交易管理工具。
|
||||||
|
- 不包含券商登录、自动下单、条件单或实盘同步功能。
|
||||||
|
- 行情接口不是正式授权的数据服务,其可用性、实时性和准确性不作保证。
|
||||||
|
- 网格建议未考虑手续费、滑点、涨跌停、停牌和流动性等实际交易约束。
|
||||||
|
- 使用本项目产生的任何交易决策与风险均由使用者自行承担。
|
||||||
|
|||||||
200
docs/superpowers/plans/2026-07-09-gui-visual-refresh.md
Normal file
200
docs/superpowers/plans/2026-07-09-gui-visual-refresh.md
Normal file
@@ -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.
|
||||||
@@ -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、刷新线程测试。
|
||||||
@@ -177,9 +177,10 @@ def calculate_account_summary(
|
|||||||
sum((position.floating_pnl or Decimal("0") for position in positions), Decimal("0"))
|
sum((position.floating_pnl or Decimal("0") for position in positions), Decimal("0"))
|
||||||
)
|
)
|
||||||
total_assets = money(cash + market_value)
|
total_assets = money(cash + market_value)
|
||||||
|
deployable_assets = market_value + max(cash, Decimal("0"))
|
||||||
capital_usage_rate = (
|
capital_usage_rate = (
|
||||||
(market_value / total_assets).quantize(RATE_PLACES, rounding=ROUND_HALF_UP)
|
(market_value / deployable_assets).quantize(RATE_PLACES, rounding=ROUND_HALF_UP)
|
||||||
if total_assets > 0
|
if deployable_assets > 0
|
||||||
else Decimal("0")
|
else Decimal("0")
|
||||||
)
|
)
|
||||||
return AccountSummary(
|
return AccountSummary(
|
||||||
|
|||||||
@@ -138,6 +138,20 @@ class OpenGridLot:
|
|||||||
status: str
|
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)
|
@dataclass(frozen=True)
|
||||||
class QuoteSnapshot:
|
class QuoteSnapshot:
|
||||||
symbol: str
|
symbol: str
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import replace
|
from dataclasses import dataclass, replace
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from grid_trading.domain.calculations import money, price
|
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(
|
def calculate_open_grid_lots(
|
||||||
@@ -45,6 +53,52 @@ def calculate_open_grid_lots(
|
|||||||
return 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]:
|
def _match_sell_against_lots(lots: list[OpenGridLot], quantity: int) -> list[OpenGridLot]:
|
||||||
remaining_sell_quantity = quantity
|
remaining_sell_quantity = quantity
|
||||||
updated_lots: list[OpenGridLot] = []
|
updated_lots: list[OpenGridLot] = []
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from grid_trading.domain.models import (
|
|||||||
FeeEstimate,
|
FeeEstimate,
|
||||||
FeeRules,
|
FeeRules,
|
||||||
GridLevelSuggestion,
|
GridLevelSuggestion,
|
||||||
|
GridTradeMatch,
|
||||||
Instrument,
|
Instrument,
|
||||||
OpenGridLot,
|
OpenGridLot,
|
||||||
PositionSummary,
|
PositionSummary,
|
||||||
@@ -28,7 +29,7 @@ from grid_trading.domain.models import (
|
|||||||
Trade,
|
Trade,
|
||||||
TradeSide,
|
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.market.tencent import TencentQuoteProvider
|
||||||
from grid_trading.storage.repositories import Repository
|
from grid_trading.storage.repositories import Repository
|
||||||
|
|
||||||
@@ -262,6 +263,18 @@ class TradingService:
|
|||||||
as_of=as_of_date,
|
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:
|
def _require_account(self, account_id: int) -> Account:
|
||||||
account = self.repository.get_account(account_id)
|
account = self.repository.get_account(account_id)
|
||||||
if account is None:
|
if account is None:
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Qt, QThread, Signal
|
from PySide6.QtCore import QObject, Qt, QThread, Signal
|
||||||
|
from PySide6.QtGui import QBrush, QColor, QFont
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QAbstractItemView,
|
||||||
QApplication,
|
QApplication,
|
||||||
QDialog,
|
QDialog,
|
||||||
QFrame,
|
QFrame,
|
||||||
@@ -26,7 +29,14 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from grid_trading.config import DEFAULT_DB_PATH
|
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.services.trading_service import TradingService
|
||||||
from grid_trading.ui.dialogs import AccountDialog, InstrumentDialog, StrategyTemplateDialog, TradeDialog
|
from grid_trading.ui.dialogs import AccountDialog, InstrumentDialog, StrategyTemplateDialog, TradeDialog
|
||||||
from grid_trading.ui.formatters import format_money, format_percent, format_price, format_quantity
|
from grid_trading.ui.formatters import format_money, format_percent, format_price, format_quantity
|
||||||
@@ -51,6 +61,10 @@ class QuoteRefreshWorker(QObject):
|
|||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
|
PROFIT_COLOR = QColor("#c62828")
|
||||||
|
LOSS_COLOR = QColor("#2e7d32")
|
||||||
|
NEUTRAL_COLOR = QColor("#334155")
|
||||||
|
|
||||||
HOLDING_COLUMNS = [
|
HOLDING_COLUMNS = [
|
||||||
"代码",
|
"代码",
|
||||||
"名称",
|
"名称",
|
||||||
@@ -86,6 +100,17 @@ class MainWindow(QMainWindow):
|
|||||||
"当前价",
|
"当前价",
|
||||||
"状态",
|
"状态",
|
||||||
]
|
]
|
||||||
|
GRID_MATCH_COLUMNS = [
|
||||||
|
"卖出日期",
|
||||||
|
"卖出价",
|
||||||
|
"匹配买入日期",
|
||||||
|
"买入价",
|
||||||
|
"匹配股数",
|
||||||
|
"买入金额",
|
||||||
|
"卖出金额",
|
||||||
|
"毛利润",
|
||||||
|
"对应成交",
|
||||||
|
]
|
||||||
|
|
||||||
def __init__(self, service: TradingService):
|
def __init__(self, service: TradingService):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -104,16 +129,26 @@ class MainWindow(QMainWindow):
|
|||||||
self.refresh_all()
|
self.refresh_all()
|
||||||
|
|
||||||
def _build_ui(self) -> None:
|
def _build_ui(self) -> None:
|
||||||
|
self._apply_light_theme()
|
||||||
|
|
||||||
root = QWidget()
|
root = QWidget()
|
||||||
|
root.setObjectName("appRoot")
|
||||||
root_layout = QHBoxLayout(root)
|
root_layout = QHBoxLayout(root)
|
||||||
|
root_layout.setContentsMargins(14, 14, 14, 14)
|
||||||
|
root_layout.setSpacing(14)
|
||||||
|
|
||||||
nav = QListWidget()
|
nav = QListWidget()
|
||||||
|
nav.setObjectName("sideNav")
|
||||||
nav.addItems(["账户总览", "持仓管理", "成交记录", "网格策略", "设置"])
|
nav.addItems(["账户总览", "持仓管理", "成交记录", "网格策略", "设置"])
|
||||||
nav.setFixedWidth(150)
|
nav.setFixedWidth(150)
|
||||||
|
nav.setCurrentRow(1)
|
||||||
root_layout.addWidget(nav)
|
root_layout.addWidget(nav)
|
||||||
|
|
||||||
content = QWidget()
|
content = QWidget()
|
||||||
|
content.setObjectName("contentPanel")
|
||||||
content_layout = QVBoxLayout(content)
|
content_layout = QVBoxLayout(content)
|
||||||
|
content_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
content_layout.setSpacing(12)
|
||||||
self.summary_labels = self._create_summary_cards()
|
self.summary_labels = self._create_summary_cards()
|
||||||
content_layout.addLayout(self.summary_cards_layout)
|
content_layout.addLayout(self.summary_cards_layout)
|
||||||
content_layout.addLayout(self._create_toolbar())
|
content_layout.addLayout(self._create_toolbar())
|
||||||
@@ -121,17 +156,17 @@ class MainWindow(QMainWindow):
|
|||||||
splitter = QSplitter(Qt.Orientation.Vertical)
|
splitter = QSplitter(Qt.Orientation.Vertical)
|
||||||
self.holdings_table = QTableWidget(0, len(self.HOLDING_COLUMNS))
|
self.holdings_table = QTableWidget(0, len(self.HOLDING_COLUMNS))
|
||||||
self.holdings_table.setHorizontalHeaderLabels(self.HOLDING_COLUMNS)
|
self.holdings_table.setHorizontalHeaderLabels(self.HOLDING_COLUMNS)
|
||||||
self.holdings_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
self._configure_table(self.holdings_table)
|
||||||
self.holdings_table.horizontalHeader().setStretchLastSection(True)
|
|
||||||
self.holdings_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
|
||||||
self.holdings_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
|
||||||
self.holdings_table.itemSelectionChanged.connect(self._on_holding_selected)
|
self.holdings_table.itemSelectionChanged.connect(self._on_holding_selected)
|
||||||
splitter.addWidget(self.holdings_table)
|
splitter.addWidget(self.holdings_table)
|
||||||
|
|
||||||
self.details_tabs = QTabWidget()
|
self.details_tabs = QTabWidget()
|
||||||
|
self.details_tabs.setObjectName("detailsTabs")
|
||||||
|
|
||||||
grid_tab = QWidget()
|
grid_tab = QWidget()
|
||||||
grid_layout = QVBoxLayout(grid_tab)
|
grid_layout = QVBoxLayout(grid_tab)
|
||||||
|
grid_layout.setContentsMargins(10, 10, 10, 10)
|
||||||
|
grid_layout.setSpacing(8)
|
||||||
grid_controls = QHBoxLayout()
|
grid_controls = QHBoxLayout()
|
||||||
self.grid_levels_count_edit = QSpinBox()
|
self.grid_levels_count_edit = QSpinBox()
|
||||||
self.grid_levels_count_edit.setRange(1, 100)
|
self.grid_levels_count_edit.setRange(1, 100)
|
||||||
@@ -144,41 +179,51 @@ class MainWindow(QMainWindow):
|
|||||||
grid_controls.addStretch()
|
grid_controls.addStretch()
|
||||||
self.grid_levels_table = QTableWidget(0, len(self.GRID_LEVEL_COLUMNS))
|
self.grid_levels_table = QTableWidget(0, len(self.GRID_LEVEL_COLUMNS))
|
||||||
self.grid_levels_table.setHorizontalHeaderLabels(self.GRID_LEVEL_COLUMNS)
|
self.grid_levels_table.setHorizontalHeaderLabels(self.GRID_LEVEL_COLUMNS)
|
||||||
self.grid_levels_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
self._configure_table(self.grid_levels_table)
|
||||||
self.grid_levels_table.horizontalHeader().setStretchLastSection(True)
|
|
||||||
self.grid_levels_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
|
||||||
grid_layout.addLayout(grid_controls)
|
grid_layout.addLayout(grid_controls)
|
||||||
grid_layout.addWidget(self.grid_levels_table)
|
grid_layout.addWidget(self.grid_levels_table)
|
||||||
|
|
||||||
open_grid_tab = QWidget()
|
open_grid_tab = QWidget()
|
||||||
open_grid_layout = QVBoxLayout(open_grid_tab)
|
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 = QTableWidget(0, len(self.OPEN_GRID_LOT_COLUMNS))
|
||||||
self.open_grid_lots_table.setHorizontalHeaderLabels(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._configure_table(self.open_grid_lots_table)
|
||||||
self.open_grid_lots_table.horizontalHeader().setStretchLastSection(True)
|
|
||||||
self.open_grid_lots_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
|
||||||
open_grid_layout.addWidget(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_tab = QWidget()
|
||||||
trade_layout = QVBoxLayout(trade_tab)
|
trade_layout = QVBoxLayout(trade_tab)
|
||||||
|
trade_layout.setContentsMargins(10, 10, 10, 10)
|
||||||
|
trade_layout.setSpacing(8)
|
||||||
trade_buttons = QHBoxLayout()
|
trade_buttons = QHBoxLayout()
|
||||||
edit_trade_button = QPushButton("编辑成交")
|
edit_trade_button = QPushButton("编辑成交")
|
||||||
|
edit_trade_button.setObjectName("toolbarButton")
|
||||||
edit_trade_button.clicked.connect(self._edit_selected_trade)
|
edit_trade_button.clicked.connect(self._edit_selected_trade)
|
||||||
delete_trade_button = QPushButton("删除成交")
|
delete_trade_button = QPushButton("删除成交")
|
||||||
|
delete_trade_button.setObjectName("toolbarButton")
|
||||||
delete_trade_button.clicked.connect(self._delete_selected_trade)
|
delete_trade_button.clicked.connect(self._delete_selected_trade)
|
||||||
trade_buttons.addWidget(edit_trade_button)
|
trade_buttons.addWidget(edit_trade_button)
|
||||||
trade_buttons.addWidget(delete_trade_button)
|
trade_buttons.addWidget(delete_trade_button)
|
||||||
trade_buttons.addStretch()
|
trade_buttons.addStretch()
|
||||||
self.trades_table = QTableWidget(0, len(self.TRADE_COLUMNS))
|
self.trades_table = QTableWidget(0, len(self.TRADE_COLUMNS))
|
||||||
self.trades_table.setHorizontalHeaderLabels(self.TRADE_COLUMNS)
|
self.trades_table.setHorizontalHeaderLabels(self.TRADE_COLUMNS)
|
||||||
self.trades_table.horizontalHeader().setStretchLastSection(True)
|
self._configure_table(self.trades_table)
|
||||||
self.trades_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
|
||||||
self.trades_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
|
||||||
trade_layout.addLayout(trade_buttons)
|
trade_layout.addLayout(trade_buttons)
|
||||||
trade_layout.addWidget(self.trades_table)
|
trade_layout.addWidget(self.trades_table)
|
||||||
|
|
||||||
self.details_tabs.addTab(grid_tab, "网格档位")
|
self.details_tabs.addTab(grid_tab, "网格档位")
|
||||||
self.details_tabs.addTab(open_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.addTab(trade_tab, "最近成交")
|
||||||
self.details_tabs.setCurrentIndex(1)
|
self.details_tabs.setCurrentIndex(1)
|
||||||
splitter.addWidget(self.details_tabs)
|
splitter.addWidget(self.details_tabs)
|
||||||
@@ -188,12 +233,171 @@ class MainWindow(QMainWindow):
|
|||||||
root_layout.addWidget(content)
|
root_layout.addWidget(content)
|
||||||
self.setCentralWidget(root)
|
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]:
|
def _create_summary_cards(self) -> dict[str, QLabel]:
|
||||||
self.summary_cards_layout = QGridLayout()
|
self.summary_cards_layout = QGridLayout()
|
||||||
|
self.summary_cards_layout.setHorizontalSpacing(10)
|
||||||
|
self.summary_cards_layout.setVerticalSpacing(10)
|
||||||
labels: dict[str, QLabel] = {}
|
labels: dict[str, QLabel] = {}
|
||||||
for column, (key, title) in enumerate(
|
for column, (key, title) in enumerate(
|
||||||
[
|
[
|
||||||
("total_assets", "总资产"),
|
("total_assets", "账户权益"),
|
||||||
("cash", "现金"),
|
("cash", "现金"),
|
||||||
("market_value", "持仓市值"),
|
("market_value", "持仓市值"),
|
||||||
("floating_pnl", "浮动盈亏"),
|
("floating_pnl", "浮动盈亏"),
|
||||||
@@ -201,11 +405,15 @@ class MainWindow(QMainWindow):
|
|||||||
]
|
]
|
||||||
):
|
):
|
||||||
frame = QFrame()
|
frame = QFrame()
|
||||||
|
frame.setObjectName("summaryCard")
|
||||||
frame.setFrameShape(QFrame.Shape.StyledPanel)
|
frame.setFrameShape(QFrame.Shape.StyledPanel)
|
||||||
layout = QVBoxLayout(frame)
|
layout = QVBoxLayout(frame)
|
||||||
|
layout.setContentsMargins(14, 12, 14, 12)
|
||||||
|
layout.setSpacing(6)
|
||||||
title_label = QLabel(title)
|
title_label = QLabel(title)
|
||||||
|
title_label.setObjectName("summaryTitle")
|
||||||
value_label = QLabel("-")
|
value_label = QLabel("-")
|
||||||
value_label.setStyleSheet("font-size: 20px; font-weight: 700;")
|
value_label.setObjectName("summaryValue")
|
||||||
layout.addWidget(title_label)
|
layout.addWidget(title_label)
|
||||||
layout.addWidget(value_label)
|
layout.addWidget(value_label)
|
||||||
labels[key] = value_label
|
labels[key] = value_label
|
||||||
@@ -223,6 +431,7 @@ class MainWindow(QMainWindow):
|
|||||||
]
|
]
|
||||||
for text, handler in buttons:
|
for text, handler in buttons:
|
||||||
button = QPushButton(text)
|
button = QPushButton(text)
|
||||||
|
button.setObjectName("toolbarButton")
|
||||||
button.clicked.connect(handler)
|
button.clicked.connect(handler)
|
||||||
if text == "刷新行情":
|
if text == "刷新行情":
|
||||||
self.refresh_quotes_button = button
|
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["cash"].setText(format_money(account_summary.cash))
|
||||||
self.summary_labels["market_value"].setText(format_money(account_summary.market_value))
|
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.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.summary_labels["usage"].setText(format_percent(account_summary.capital_usage_rate))
|
||||||
self._fill_holdings_table()
|
self._fill_holdings_table()
|
||||||
self._refresh_details()
|
self._refresh_details()
|
||||||
@@ -309,8 +519,13 @@ class MainWindow(QMainWindow):
|
|||||||
format_money(position.grid_profit),
|
format_money(position.grid_profit),
|
||||||
format_money(position.floating_pnl),
|
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):
|
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)
|
item.setData(Qt.ItemDataRole.UserRole, position.instrument_id)
|
||||||
self.holdings_table.setItem(row, column, item)
|
self.holdings_table.setItem(row, column, item)
|
||||||
if self._positions and self.holdings_table.currentRow() < 0:
|
if self._positions and self.holdings_table.currentRow() < 0:
|
||||||
@@ -327,10 +542,12 @@ class MainWindow(QMainWindow):
|
|||||||
if position is None:
|
if position is None:
|
||||||
self._refresh_grid_levels(None)
|
self._refresh_grid_levels(None)
|
||||||
self._refresh_open_grid_lots(None)
|
self._refresh_open_grid_lots(None)
|
||||||
|
self._refresh_grid_matches(None)
|
||||||
self._fill_trades_table([])
|
self._fill_trades_table([])
|
||||||
return
|
return
|
||||||
self._refresh_grid_levels(position)
|
self._refresh_grid_levels(position)
|
||||||
self._refresh_open_grid_lots(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))
|
self._fill_trades_table(self.service.list_trades(instrument_id=position.instrument_id))
|
||||||
|
|
||||||
def _refresh_grid_levels(self, position: PositionSummary | None) -> None:
|
def _refresh_grid_levels(self, position: PositionSummary | None) -> None:
|
||||||
@@ -368,7 +585,11 @@ class MainWindow(QMainWindow):
|
|||||||
format_money(level.estimated_gross_profit),
|
format_money(level.estimated_gross_profit),
|
||||||
]
|
]
|
||||||
for column, value in enumerate(values):
|
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:
|
def _refresh_open_grid_lots(self, position: PositionSummary | None) -> None:
|
||||||
if position is None:
|
if position is None:
|
||||||
@@ -396,7 +617,41 @@ class MainWindow(QMainWindow):
|
|||||||
lot.status,
|
lot.status,
|
||||||
]
|
]
|
||||||
for column, value in enumerate(values):
|
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:
|
def _fill_trades_table(self, trades: list[Trade]) -> None:
|
||||||
self._trade_ids_by_row = {}
|
self._trade_ids_by_row = {}
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ from decimal import Decimal
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from grid_trading.domain.calculations import CalculationError, calculate_positions, estimate_fees
|
from grid_trading.domain.calculations import (
|
||||||
from grid_trading.domain.models import FeeRules, Instrument, QuoteSnapshot, Trade, TradeGroup, TradeSide
|
CalculationError,
|
||||||
|
calculate_account_summary,
|
||||||
|
calculate_positions,
|
||||||
|
estimate_fees,
|
||||||
|
)
|
||||||
|
from grid_trading.domain.models import Account, FeeRules, Instrument, QuoteSnapshot, Trade, TradeGroup, TradeSide
|
||||||
|
|
||||||
|
|
||||||
def make_trade(
|
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
|
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():
|
def test_sell_more_than_group_position_raises():
|
||||||
today = date(2026, 7, 8)
|
today = date(2026, 7, 8)
|
||||||
instrument = Instrument(id=1, code="159915", name="创业板ETF")
|
instrument = Instrument(id=1, code="159915", name="创业板ETF")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from datetime import date, timedelta
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from grid_trading.domain.models import Trade, TradeGroup, TradeSide
|
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(
|
def make_trade(
|
||||||
@@ -80,6 +80,52 @@ def test_calculate_open_grid_lots_fifo_matches_grid_sells_against_grid_buys():
|
|||||||
assert lot.status == "可卖"
|
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():
|
def test_calculate_open_grid_lots_reports_not_reached_and_missing_quote_statuses():
|
||||||
today = date(2026, 7, 9)
|
today = date(2026, 7, 9)
|
||||||
trades = [
|
trades = [
|
||||||
|
|||||||
@@ -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.suggested_sell_price == Decimal("4.12")
|
||||||
assert lot.current_price == Decimal("4.12")
|
assert lot.current_price == Decimal("4.12")
|
||||||
assert lot.status == "可卖"
|
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")
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ def test_formatters_render_money_percent_and_empty_values():
|
|||||||
def test_main_window_can_be_constructed_offscreen(tmp_path, monkeypatch):
|
def test_main_window_can_be_constructed_offscreen(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen")
|
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.services.trading_service import TradingService
|
||||||
from grid_trading.ui.main_window import MainWindow
|
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.windowTitle() == "Grid Trading Manager"
|
||||||
assert window.holdings_table.columnCount() > 0
|
assert window.holdings_table.columnCount() > 0
|
||||||
assert any(button.text() == "刷新行情" for button in window.findChildren(QPushButton))
|
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()
|
window.close()
|
||||||
service.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
|
assert window.grid_levels_table.columnCount() == 7
|
||||||
tab_titles = _tab_titles(window.details_tabs)
|
tab_titles = _tab_titles(window.details_tabs)
|
||||||
assert tab_titles == ["网格档位", "待卖网格", "最近成交"]
|
assert tab_titles == ["网格档位", "待卖网格", "配对明细", "最近成交"]
|
||||||
assert isinstance(window.details_tabs, QTabWidget)
|
assert isinstance(window.details_tabs, QTabWidget)
|
||||||
|
|
||||||
window.close()
|
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 window.open_grid_lots_table.columnCount() == 8
|
||||||
assert "待卖网格" in _tab_titles(window.details_tabs)
|
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()
|
window.close()
|
||||||
service.close()
|
service.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user