sj-sync 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sj_sync-0.1.0/PKG-INFO +262 -0
- sj_sync-0.1.0/README.md +233 -0
- sj_sync-0.1.0/pyproject.toml +101 -0
- sj_sync-0.1.0/src/sj_sync/__init__.py +17 -0
- sj_sync-0.1.0/src/sj_sync/models.py +66 -0
- sj_sync-0.1.0/src/sj_sync/position_sync.py +530 -0
sj_sync-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: sj-sync
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Real-time position synchronization for Shioaji
|
|
5
|
+
Keywords: shioaji,trading,position,real-time,taiwan,stock
|
|
6
|
+
Author: yvictor
|
|
7
|
+
Author-email: yvictor <yvictor3141@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Dist: shioaji>=1.2.0
|
|
22
|
+
Requires-Dist: pydantic>=2.0.0
|
|
23
|
+
Requires-Dist: loguru>=0.7.0
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Project-URL: Homepage, https://github.com/yvictor/sj_sync
|
|
26
|
+
Project-URL: Issues, https://github.com/yvictor/sj_sync/issues
|
|
27
|
+
Project-URL: Repository, https://github.com/yvictor/sj_sync
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# sj_sync
|
|
31
|
+
|
|
32
|
+
[](https://github.com/yvictor/sj_sync/actions/workflows/ci.yml)
|
|
33
|
+
[](https://codecov.io/gh/yvictor/sj_sync)
|
|
34
|
+
[](https://badge.fury.io/py/sj-sync)
|
|
35
|
+
[](https://www.python.org/downloads/)
|
|
36
|
+
[](https://opensource.org/licenses/MIT)
|
|
37
|
+
|
|
38
|
+
Real-time position synchronization for Shioaji.
|
|
39
|
+
|
|
40
|
+
## Overview
|
|
41
|
+
|
|
42
|
+
`sj_sync` provides real-time position tracking using deal callbacks instead of repeatedly calling `api.list_positions()`. This approach:
|
|
43
|
+
|
|
44
|
+
- **Reduces API calls**: Initialize once with `list_positions()`, then update via callbacks
|
|
45
|
+
- **More responsive**: Positions update immediately when deals are executed
|
|
46
|
+
- **Tracks all details**: Supports cash, margin trading, short selling, day trading, and futures/options
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
- ✅ **Real-time updates** via `OrderState.StockDeal` and `OrderState.FuturesDeal` callbacks
|
|
51
|
+
- ✅ **Multiple trading types**: Cash, margin trading, short selling, day trading settlement
|
|
52
|
+
- ✅ **Futures/options support**: Tracks futures and options positions
|
|
53
|
+
- ✅ **Yesterday's quantity tracking**: Maintains `yd_quantity` for each position
|
|
54
|
+
- ✅ **Automatic cleanup**: Removes positions when quantity reaches zero
|
|
55
|
+
- ✅ **Multi-account support**: Properly isolates positions across different accounts
|
|
56
|
+
- ✅ **Pydantic models**: Type-safe position objects
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uv add sj-sync
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Or with pip:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install sj-sync
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Usage
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import shioaji as sj
|
|
74
|
+
from sj_sync import PositionSync
|
|
75
|
+
|
|
76
|
+
# Initialize and login
|
|
77
|
+
api = sj.Shioaji()
|
|
78
|
+
api.login("YOUR_API_KEY", "YOUR_SECRET_KEY")
|
|
79
|
+
|
|
80
|
+
# Create PositionSync (auto-loads positions and registers callbacks)
|
|
81
|
+
sync = PositionSync(api)
|
|
82
|
+
|
|
83
|
+
# Get all positions
|
|
84
|
+
positions = sync.list_positions()
|
|
85
|
+
for pos in positions:
|
|
86
|
+
print(f"{pos.code}: {pos.direction} {pos.quantity}")
|
|
87
|
+
|
|
88
|
+
# Get positions for specific account
|
|
89
|
+
stock_positions = sync.list_positions(account=api.stock_account)
|
|
90
|
+
futures_positions = sync.list_positions(account=api.futopt_account)
|
|
91
|
+
|
|
92
|
+
# Positions auto-update when orders are filled!
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Position Models
|
|
96
|
+
|
|
97
|
+
### StockPosition
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
class StockPosition(BaseModel):
|
|
101
|
+
code: str # Stock code (e.g., "2330")
|
|
102
|
+
direction: Action # Action.Buy or Action.Sell
|
|
103
|
+
quantity: int # Current position quantity
|
|
104
|
+
yd_quantity: int # Yesterday's position quantity
|
|
105
|
+
cond: StockOrderCond # Cash, MarginTrading, or ShortSelling
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### FuturesPosition
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
class FuturesPosition(BaseModel):
|
|
112
|
+
code: str # Contract code (e.g., "TXFJ4")
|
|
113
|
+
direction: Action # Action.Buy or Action.Sell
|
|
114
|
+
quantity: int # Current position quantity
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## API Reference
|
|
118
|
+
|
|
119
|
+
### PositionSync
|
|
120
|
+
|
|
121
|
+
#### `__init__(api: sj.Shioaji)`
|
|
122
|
+
Initialize with Shioaji API instance. Automatically:
|
|
123
|
+
- Loads all positions from all accounts
|
|
124
|
+
- Registers deal callback for real-time updates
|
|
125
|
+
|
|
126
|
+
#### `list_positions(account: Optional[Account] = None, unit: Unit = Unit.Common) -> Union[List[StockPosition], List[FuturesPosition]]`
|
|
127
|
+
Get current positions.
|
|
128
|
+
|
|
129
|
+
**Args:**
|
|
130
|
+
- `account`: Account to filter. `None` uses default account (stock_account first, then futopt_account if no stock)
|
|
131
|
+
- `unit`: `Unit.Common` (lots) or `Unit.Share` (shares) - for compatibility, not used in real-time tracking
|
|
132
|
+
|
|
133
|
+
**Returns:**
|
|
134
|
+
- Stock account: `List[StockPosition]`
|
|
135
|
+
- Futures account: `List[FuturesPosition]`
|
|
136
|
+
- `None` (default): Prioritizes stock_account, falls back to futopt_account
|
|
137
|
+
|
|
138
|
+
**Example:**
|
|
139
|
+
```python
|
|
140
|
+
# Get default account positions
|
|
141
|
+
positions = sync.list_positions()
|
|
142
|
+
|
|
143
|
+
# Get specific account positions
|
|
144
|
+
stock_positions = sync.list_positions(account=api.stock_account)
|
|
145
|
+
futures_positions = sync.list_positions(account=api.futopt_account)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
#### `on_order_deal_event(state: OrderState, data: Dict)`
|
|
149
|
+
Callback for order deal events. Automatically registered on init.
|
|
150
|
+
|
|
151
|
+
Handles:
|
|
152
|
+
- `OrderState.StockDeal`: Stock deal events
|
|
153
|
+
- `OrderState.FuturesDeal`: Futures/options deal events
|
|
154
|
+
|
|
155
|
+
## How It Works
|
|
156
|
+
|
|
157
|
+
1. **Initialization**:
|
|
158
|
+
- Calls `api.list_accounts()` to get all accounts
|
|
159
|
+
- Loads positions for each account via `api.list_positions(account)`
|
|
160
|
+
- Registers `on_order_deal_event` callback
|
|
161
|
+
|
|
162
|
+
2. **Real-time Updates**:
|
|
163
|
+
- When orders are filled, Shioaji triggers the callback
|
|
164
|
+
- Callback updates internal position dictionaries
|
|
165
|
+
- Buy deals increase quantity (or create new position)
|
|
166
|
+
- Sell deals decrease quantity
|
|
167
|
+
- Zero quantity positions are automatically removed
|
|
168
|
+
|
|
169
|
+
3. **Position Storage**:
|
|
170
|
+
- Stock positions: `{account_key: {(code, cond): StockPosition}}`
|
|
171
|
+
- Futures positions: `{account_key: {code: FuturesPosition}}`
|
|
172
|
+
- Account key = `broker_id + account_id`
|
|
173
|
+
|
|
174
|
+
## Development
|
|
175
|
+
|
|
176
|
+
### Setup
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
git clone https://github.com/yvictor/sj_sync.git
|
|
180
|
+
cd sj_sync
|
|
181
|
+
uv sync
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Run Tests
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
# All tests
|
|
188
|
+
uv run pytest tests/ -v
|
|
189
|
+
|
|
190
|
+
# With coverage
|
|
191
|
+
uv run pytest --cov=sj_sync --cov-report=html
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Code Quality
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
# Linting
|
|
198
|
+
uv run ruff check src/ tests/
|
|
199
|
+
|
|
200
|
+
# Formatting
|
|
201
|
+
uv run ruff format src/ tests/
|
|
202
|
+
|
|
203
|
+
# Type checking
|
|
204
|
+
uv run zuban check src/
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### CI/CD
|
|
208
|
+
|
|
209
|
+
Every push and pull request triggers automated:
|
|
210
|
+
- ✅ Code quality checks (ruff, zuban)
|
|
211
|
+
- ✅ All 32 tests (unit + BDD)
|
|
212
|
+
- ✅ Coverage report to Codecov
|
|
213
|
+
- ✅ Build verification
|
|
214
|
+
|
|
215
|
+
See [CI Setup Guide](.github/CI_SETUP.md) for details.
|
|
216
|
+
|
|
217
|
+
## Testing
|
|
218
|
+
|
|
219
|
+
The project includes comprehensive pytest tests covering:
|
|
220
|
+
|
|
221
|
+
**Unit Tests (18 tests):**
|
|
222
|
+
- ✅ Position initialization from `list_positions()`
|
|
223
|
+
- ✅ Buy/sell deal events
|
|
224
|
+
- ✅ Day trading scenarios
|
|
225
|
+
- ✅ Margin trading and short selling
|
|
226
|
+
- ✅ Futures/options deals
|
|
227
|
+
- ✅ Multi-account support
|
|
228
|
+
- ✅ Edge cases and error handling
|
|
229
|
+
|
|
230
|
+
**BDD Tests (14 scenarios in Chinese):**
|
|
231
|
+
- ✅ 當沖交易 (Day trading offset rules)
|
|
232
|
+
- ✅ 融資融券 (Margin/short trading with yesterday's positions)
|
|
233
|
+
- ✅ 混合場景 (Complex mixed trading scenarios)
|
|
234
|
+
- ✅ Correct handling of `yd_quantity` and `yd_offset_quantity`
|
|
235
|
+
|
|
236
|
+
Run tests with:
|
|
237
|
+
```bash
|
|
238
|
+
# All tests (32 total)
|
|
239
|
+
uv run pytest tests/ -v
|
|
240
|
+
|
|
241
|
+
# With coverage report
|
|
242
|
+
uv run pytest --cov=sj_sync --cov-report=html --cov-report=term-missing
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
View coverage report:
|
|
246
|
+
```bash
|
|
247
|
+
open htmlcov/index.html # macOS
|
|
248
|
+
xdg-open htmlcov/index.html # Linux
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## License
|
|
252
|
+
|
|
253
|
+
MIT License
|
|
254
|
+
|
|
255
|
+
## Contributing
|
|
256
|
+
|
|
257
|
+
Contributions welcome! Please:
|
|
258
|
+
1. Fork the repository
|
|
259
|
+
2. Create a feature branch
|
|
260
|
+
3. Add tests for new functionality
|
|
261
|
+
4. Ensure all tests pass (`pytest`, `zuban check`, `ruff check`)
|
|
262
|
+
5. Submit a pull request
|
sj_sync-0.1.0/README.md
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# sj_sync
|
|
2
|
+
|
|
3
|
+
[](https://github.com/yvictor/sj_sync/actions/workflows/ci.yml)
|
|
4
|
+
[](https://codecov.io/gh/yvictor/sj_sync)
|
|
5
|
+
[](https://badge.fury.io/py/sj-sync)
|
|
6
|
+
[](https://www.python.org/downloads/)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
|
|
9
|
+
Real-time position synchronization for Shioaji.
|
|
10
|
+
|
|
11
|
+
## Overview
|
|
12
|
+
|
|
13
|
+
`sj_sync` provides real-time position tracking using deal callbacks instead of repeatedly calling `api.list_positions()`. This approach:
|
|
14
|
+
|
|
15
|
+
- **Reduces API calls**: Initialize once with `list_positions()`, then update via callbacks
|
|
16
|
+
- **More responsive**: Positions update immediately when deals are executed
|
|
17
|
+
- **Tracks all details**: Supports cash, margin trading, short selling, day trading, and futures/options
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- ✅ **Real-time updates** via `OrderState.StockDeal` and `OrderState.FuturesDeal` callbacks
|
|
22
|
+
- ✅ **Multiple trading types**: Cash, margin trading, short selling, day trading settlement
|
|
23
|
+
- ✅ **Futures/options support**: Tracks futures and options positions
|
|
24
|
+
- ✅ **Yesterday's quantity tracking**: Maintains `yd_quantity` for each position
|
|
25
|
+
- ✅ **Automatic cleanup**: Removes positions when quantity reaches zero
|
|
26
|
+
- ✅ **Multi-account support**: Properly isolates positions across different accounts
|
|
27
|
+
- ✅ **Pydantic models**: Type-safe position objects
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv add sj-sync
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or with pip:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install sj-sync
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import shioaji as sj
|
|
45
|
+
from sj_sync import PositionSync
|
|
46
|
+
|
|
47
|
+
# Initialize and login
|
|
48
|
+
api = sj.Shioaji()
|
|
49
|
+
api.login("YOUR_API_KEY", "YOUR_SECRET_KEY")
|
|
50
|
+
|
|
51
|
+
# Create PositionSync (auto-loads positions and registers callbacks)
|
|
52
|
+
sync = PositionSync(api)
|
|
53
|
+
|
|
54
|
+
# Get all positions
|
|
55
|
+
positions = sync.list_positions()
|
|
56
|
+
for pos in positions:
|
|
57
|
+
print(f"{pos.code}: {pos.direction} {pos.quantity}")
|
|
58
|
+
|
|
59
|
+
# Get positions for specific account
|
|
60
|
+
stock_positions = sync.list_positions(account=api.stock_account)
|
|
61
|
+
futures_positions = sync.list_positions(account=api.futopt_account)
|
|
62
|
+
|
|
63
|
+
# Positions auto-update when orders are filled!
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Position Models
|
|
67
|
+
|
|
68
|
+
### StockPosition
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
class StockPosition(BaseModel):
|
|
72
|
+
code: str # Stock code (e.g., "2330")
|
|
73
|
+
direction: Action # Action.Buy or Action.Sell
|
|
74
|
+
quantity: int # Current position quantity
|
|
75
|
+
yd_quantity: int # Yesterday's position quantity
|
|
76
|
+
cond: StockOrderCond # Cash, MarginTrading, or ShortSelling
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### FuturesPosition
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
class FuturesPosition(BaseModel):
|
|
83
|
+
code: str # Contract code (e.g., "TXFJ4")
|
|
84
|
+
direction: Action # Action.Buy or Action.Sell
|
|
85
|
+
quantity: int # Current position quantity
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## API Reference
|
|
89
|
+
|
|
90
|
+
### PositionSync
|
|
91
|
+
|
|
92
|
+
#### `__init__(api: sj.Shioaji)`
|
|
93
|
+
Initialize with Shioaji API instance. Automatically:
|
|
94
|
+
- Loads all positions from all accounts
|
|
95
|
+
- Registers deal callback for real-time updates
|
|
96
|
+
|
|
97
|
+
#### `list_positions(account: Optional[Account] = None, unit: Unit = Unit.Common) -> Union[List[StockPosition], List[FuturesPosition]]`
|
|
98
|
+
Get current positions.
|
|
99
|
+
|
|
100
|
+
**Args:**
|
|
101
|
+
- `account`: Account to filter. `None` uses default account (stock_account first, then futopt_account if no stock)
|
|
102
|
+
- `unit`: `Unit.Common` (lots) or `Unit.Share` (shares) - for compatibility, not used in real-time tracking
|
|
103
|
+
|
|
104
|
+
**Returns:**
|
|
105
|
+
- Stock account: `List[StockPosition]`
|
|
106
|
+
- Futures account: `List[FuturesPosition]`
|
|
107
|
+
- `None` (default): Prioritizes stock_account, falls back to futopt_account
|
|
108
|
+
|
|
109
|
+
**Example:**
|
|
110
|
+
```python
|
|
111
|
+
# Get default account positions
|
|
112
|
+
positions = sync.list_positions()
|
|
113
|
+
|
|
114
|
+
# Get specific account positions
|
|
115
|
+
stock_positions = sync.list_positions(account=api.stock_account)
|
|
116
|
+
futures_positions = sync.list_positions(account=api.futopt_account)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
#### `on_order_deal_event(state: OrderState, data: Dict)`
|
|
120
|
+
Callback for order deal events. Automatically registered on init.
|
|
121
|
+
|
|
122
|
+
Handles:
|
|
123
|
+
- `OrderState.StockDeal`: Stock deal events
|
|
124
|
+
- `OrderState.FuturesDeal`: Futures/options deal events
|
|
125
|
+
|
|
126
|
+
## How It Works
|
|
127
|
+
|
|
128
|
+
1. **Initialization**:
|
|
129
|
+
- Calls `api.list_accounts()` to get all accounts
|
|
130
|
+
- Loads positions for each account via `api.list_positions(account)`
|
|
131
|
+
- Registers `on_order_deal_event` callback
|
|
132
|
+
|
|
133
|
+
2. **Real-time Updates**:
|
|
134
|
+
- When orders are filled, Shioaji triggers the callback
|
|
135
|
+
- Callback updates internal position dictionaries
|
|
136
|
+
- Buy deals increase quantity (or create new position)
|
|
137
|
+
- Sell deals decrease quantity
|
|
138
|
+
- Zero quantity positions are automatically removed
|
|
139
|
+
|
|
140
|
+
3. **Position Storage**:
|
|
141
|
+
- Stock positions: `{account_key: {(code, cond): StockPosition}}`
|
|
142
|
+
- Futures positions: `{account_key: {code: FuturesPosition}}`
|
|
143
|
+
- Account key = `broker_id + account_id`
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
### Setup
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
git clone https://github.com/yvictor/sj_sync.git
|
|
151
|
+
cd sj_sync
|
|
152
|
+
uv sync
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Run Tests
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
# All tests
|
|
159
|
+
uv run pytest tests/ -v
|
|
160
|
+
|
|
161
|
+
# With coverage
|
|
162
|
+
uv run pytest --cov=sj_sync --cov-report=html
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Code Quality
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
# Linting
|
|
169
|
+
uv run ruff check src/ tests/
|
|
170
|
+
|
|
171
|
+
# Formatting
|
|
172
|
+
uv run ruff format src/ tests/
|
|
173
|
+
|
|
174
|
+
# Type checking
|
|
175
|
+
uv run zuban check src/
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### CI/CD
|
|
179
|
+
|
|
180
|
+
Every push and pull request triggers automated:
|
|
181
|
+
- ✅ Code quality checks (ruff, zuban)
|
|
182
|
+
- ✅ All 32 tests (unit + BDD)
|
|
183
|
+
- ✅ Coverage report to Codecov
|
|
184
|
+
- ✅ Build verification
|
|
185
|
+
|
|
186
|
+
See [CI Setup Guide](.github/CI_SETUP.md) for details.
|
|
187
|
+
|
|
188
|
+
## Testing
|
|
189
|
+
|
|
190
|
+
The project includes comprehensive pytest tests covering:
|
|
191
|
+
|
|
192
|
+
**Unit Tests (18 tests):**
|
|
193
|
+
- ✅ Position initialization from `list_positions()`
|
|
194
|
+
- ✅ Buy/sell deal events
|
|
195
|
+
- ✅ Day trading scenarios
|
|
196
|
+
- ✅ Margin trading and short selling
|
|
197
|
+
- ✅ Futures/options deals
|
|
198
|
+
- ✅ Multi-account support
|
|
199
|
+
- ✅ Edge cases and error handling
|
|
200
|
+
|
|
201
|
+
**BDD Tests (14 scenarios in Chinese):**
|
|
202
|
+
- ✅ 當沖交易 (Day trading offset rules)
|
|
203
|
+
- ✅ 融資融券 (Margin/short trading with yesterday's positions)
|
|
204
|
+
- ✅ 混合場景 (Complex mixed trading scenarios)
|
|
205
|
+
- ✅ Correct handling of `yd_quantity` and `yd_offset_quantity`
|
|
206
|
+
|
|
207
|
+
Run tests with:
|
|
208
|
+
```bash
|
|
209
|
+
# All tests (32 total)
|
|
210
|
+
uv run pytest tests/ -v
|
|
211
|
+
|
|
212
|
+
# With coverage report
|
|
213
|
+
uv run pytest --cov=sj_sync --cov-report=html --cov-report=term-missing
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
View coverage report:
|
|
217
|
+
```bash
|
|
218
|
+
open htmlcov/index.html # macOS
|
|
219
|
+
xdg-open htmlcov/index.html # Linux
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## License
|
|
223
|
+
|
|
224
|
+
MIT License
|
|
225
|
+
|
|
226
|
+
## Contributing
|
|
227
|
+
|
|
228
|
+
Contributions welcome! Please:
|
|
229
|
+
1. Fork the repository
|
|
230
|
+
2. Create a feature branch
|
|
231
|
+
3. Add tests for new functionality
|
|
232
|
+
4. Ensure all tests pass (`pytest`, `zuban check`, `ruff check`)
|
|
233
|
+
5. Submit a pull request
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sj-sync"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Real-time position synchronization for Shioaji"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "yvictor", email = "yvictor3141@gmail.com" }
|
|
9
|
+
]
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
keywords = ["shioaji", "trading", "position", "real-time", "taiwan", "stock"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"shioaji>=1.2.0",
|
|
28
|
+
"pydantic>=2.0.0",
|
|
29
|
+
"loguru>=0.7.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/yvictor/sj_sync"
|
|
34
|
+
Repository = "https://github.com/yvictor/sj_sync"
|
|
35
|
+
Issues = "https://github.com/yvictor/sj_sync/issues"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
sj-sync = "sj_sync:main"
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["uv_build>=0.9.5,<0.10.0"]
|
|
42
|
+
build-backend = "uv_build"
|
|
43
|
+
|
|
44
|
+
[dependency-groups]
|
|
45
|
+
dev = [
|
|
46
|
+
"ruff>=0.14.4",
|
|
47
|
+
"zuban>=0.2.2",
|
|
48
|
+
"pytest>=8.0.0",
|
|
49
|
+
"pytest-mock>=3.12.0",
|
|
50
|
+
"pytest-bdd>=7.0.0",
|
|
51
|
+
"pytest-cov>=6.0.0",
|
|
52
|
+
"tomli>=2.0.0",
|
|
53
|
+
"ipykernel>=7.1.0",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
testpaths = ["tests"]
|
|
58
|
+
python_files = ["test_*.py"]
|
|
59
|
+
python_classes = ["Test*"]
|
|
60
|
+
python_functions = ["test_*"]
|
|
61
|
+
addopts = [
|
|
62
|
+
"-v",
|
|
63
|
+
"--strict-markers",
|
|
64
|
+
"--strict-config",
|
|
65
|
+
"--cov=sj_sync",
|
|
66
|
+
"--cov-report=term-missing:skip-covered",
|
|
67
|
+
"--cov-report=html",
|
|
68
|
+
"--cov-report=xml",
|
|
69
|
+
]
|
|
70
|
+
markers = [
|
|
71
|
+
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
|
72
|
+
"integration: marks tests as integration tests",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
[tool.coverage.run]
|
|
76
|
+
source = ["sj_sync"]
|
|
77
|
+
branch = true
|
|
78
|
+
omit = [
|
|
79
|
+
"*/tests/*",
|
|
80
|
+
"*/test_*.py",
|
|
81
|
+
"*/__pycache__/*",
|
|
82
|
+
"*/site-packages/*",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
[tool.coverage.report]
|
|
86
|
+
precision = 2
|
|
87
|
+
show_missing = true
|
|
88
|
+
skip_covered = false
|
|
89
|
+
exclude_lines = [
|
|
90
|
+
"pragma: no cover",
|
|
91
|
+
"def __repr__",
|
|
92
|
+
"raise AssertionError",
|
|
93
|
+
"raise NotImplementedError",
|
|
94
|
+
"if __name__ == .__main__.:",
|
|
95
|
+
"if TYPE_CHECKING:",
|
|
96
|
+
"class .*\\bProtocol\\):",
|
|
97
|
+
"@(abc\\.)?abstractmethod",
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
[tool.coverage.html]
|
|
101
|
+
directory = "htmlcov"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""sj_sync - Real-time position synchronization for Shioaji."""
|
|
2
|
+
|
|
3
|
+
from .position_sync import PositionSync
|
|
4
|
+
from .models import StockPosition, FuturesPosition, Position, AccountDict
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"PositionSync",
|
|
8
|
+
"StockPosition",
|
|
9
|
+
"FuturesPosition",
|
|
10
|
+
"Position",
|
|
11
|
+
"AccountDict",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main() -> None:
|
|
16
|
+
"""Main entry point for CLI."""
|
|
17
|
+
print("Hello from sj-sync!")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Position models for sj_sync."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
from typing import Union, TypedDict
|
|
5
|
+
from shioaji.constant import Action, StockOrderCond
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AccountDict(TypedDict):
|
|
9
|
+
"""Account dictionary structure from deal callback."""
|
|
10
|
+
|
|
11
|
+
broker_id: str
|
|
12
|
+
account_id: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StockPosition(BaseModel):
|
|
16
|
+
"""Stock position model compatible with shioaji Position interface.
|
|
17
|
+
|
|
18
|
+
Tracks real-time position with minimal fields:
|
|
19
|
+
- code: Stock symbol
|
|
20
|
+
- direction: Buy or Sell (Action enum)
|
|
21
|
+
- quantity: Current position quantity (in shares or lots depending on unit)
|
|
22
|
+
- yd_quantity: Yesterday's position quantity (fixed reference, never modified)
|
|
23
|
+
- yd_offset_quantity: Yesterday's offset quantity (accumulated today)
|
|
24
|
+
- cond: Order condition (StockOrderCond enum)
|
|
25
|
+
|
|
26
|
+
Calculations:
|
|
27
|
+
- Yesterday's actual remaining = yd_quantity - yd_offset_quantity
|
|
28
|
+
- Today's actual remaining = quantity - (yd_quantity - yd_offset_quantity)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
model_config = ConfigDict(frozen=False, arbitrary_types_allowed=True)
|
|
32
|
+
|
|
33
|
+
code: str = Field(..., description="Stock code/symbol")
|
|
34
|
+
direction: Action = Field(..., description="Buy or Sell")
|
|
35
|
+
quantity: int = Field(default=0, description="Current position quantity")
|
|
36
|
+
yd_quantity: int = Field(
|
|
37
|
+
default=0, description="Yesterday's position quantity (fixed)"
|
|
38
|
+
)
|
|
39
|
+
yd_offset_quantity: int = Field(
|
|
40
|
+
default=0, description="Yesterday's offset quantity (today)"
|
|
41
|
+
)
|
|
42
|
+
cond: StockOrderCond = Field(
|
|
43
|
+
default=StockOrderCond.Cash, description="Order condition"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FuturesPosition(BaseModel):
|
|
48
|
+
"""Futures/Options position model.
|
|
49
|
+
|
|
50
|
+
Simplified futures position tracking:
|
|
51
|
+
- code: Contract code
|
|
52
|
+
- direction: Buy or Sell (Action enum)
|
|
53
|
+
- quantity: Current position quantity
|
|
54
|
+
# - yd_quantity: Yesterday's position quantity
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
model_config = ConfigDict(frozen=False, arbitrary_types_allowed=True)
|
|
58
|
+
|
|
59
|
+
code: str = Field(..., description="Contract code")
|
|
60
|
+
direction: Action = Field(..., description="Buy or Sell")
|
|
61
|
+
quantity: int = Field(default=0, description="Current position quantity")
|
|
62
|
+
# yd_quantity: int = Field(default=0, description="Yesterday's position quantity")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Type alias for any position type
|
|
66
|
+
Position = Union[StockPosition, FuturesPosition]
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"""Real-time position synchronization for Shioaji."""
|
|
2
|
+
|
|
3
|
+
from loguru import logger
|
|
4
|
+
from typing import Dict, List, Optional, Union, Tuple
|
|
5
|
+
import shioaji as sj
|
|
6
|
+
from shioaji.constant import OrderState, Action, StockOrderCond, Unit
|
|
7
|
+
from shioaji.account import Account, AccountType
|
|
8
|
+
from shioaji.position import StockPosition as SjStockPostion
|
|
9
|
+
from shioaji.position import FuturePosition as SjFuturePostion
|
|
10
|
+
from .models import StockPosition, FuturesPosition, AccountDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PositionSync:
|
|
14
|
+
"""Synchronize positions in real-time using deal callbacks.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
sync = PositionSync(api)
|
|
18
|
+
# Positions are automatically loaded on init
|
|
19
|
+
positions = sync.list_positions() # Get all positions
|
|
20
|
+
positions = sync.list_positions(account=api.stock_account) # Filter by account
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, api: sj.Shioaji):
|
|
24
|
+
"""Initialize PositionSync with Shioaji API instance.
|
|
25
|
+
|
|
26
|
+
Automatically loads all positions and registers deal callback.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
api: Shioaji API instance
|
|
30
|
+
"""
|
|
31
|
+
self.api = api
|
|
32
|
+
self.api.set_order_callback(self.on_order_deal_event)
|
|
33
|
+
|
|
34
|
+
# Separate dicts for stock and futures positions
|
|
35
|
+
# Stock: {account_key: {(code, cond): StockPosition}}
|
|
36
|
+
# Futures: {account_key: {code: FuturesPosition}}
|
|
37
|
+
# account_key = broker_id + account_id
|
|
38
|
+
self._stock_positions: Dict[
|
|
39
|
+
str, Dict[Tuple[str, StockOrderCond], StockPosition]
|
|
40
|
+
] = {}
|
|
41
|
+
self._futures_positions: Dict[str, Dict[str, FuturesPosition]] = {}
|
|
42
|
+
|
|
43
|
+
# Auto-load positions on init
|
|
44
|
+
self._initialize_positions()
|
|
45
|
+
|
|
46
|
+
def _get_account_key(self, account: Union[Account, AccountDict]) -> str:
|
|
47
|
+
"""Generate account key from Account object or dict.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
account: Account object or AccountDict with broker_id and account_id
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Account key string (broker_id + account_id)
|
|
54
|
+
"""
|
|
55
|
+
if isinstance(account, dict):
|
|
56
|
+
return f"{account['broker_id']}{account['account_id']}"
|
|
57
|
+
return f"{account.broker_id}{account.account_id}"
|
|
58
|
+
|
|
59
|
+
def _initialize_positions(self) -> None:
|
|
60
|
+
"""Initialize positions from api.list_positions() for all accounts."""
|
|
61
|
+
# Get all accounts
|
|
62
|
+
accounts = self.api.list_accounts()
|
|
63
|
+
|
|
64
|
+
for account in accounts:
|
|
65
|
+
account_key = self._get_account_key(account)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
# Load positions for this account
|
|
69
|
+
positions_pnl = self.api.list_positions(
|
|
70
|
+
account=account, unit=Unit.Common
|
|
71
|
+
)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.warning(f"Failed to load positions for account {account}: {e}")
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
# Determine if this is stock or futures account based on account_type
|
|
77
|
+
account_type = account.account_type
|
|
78
|
+
if account_type == AccountType.Stock:
|
|
79
|
+
for pnl in positions_pnl:
|
|
80
|
+
if isinstance(pnl, SjStockPostion):
|
|
81
|
+
position = StockPosition(
|
|
82
|
+
code=pnl.code,
|
|
83
|
+
direction=pnl.direction,
|
|
84
|
+
quantity=pnl.quantity,
|
|
85
|
+
yd_quantity=pnl.yd_quantity,
|
|
86
|
+
yd_offset_quantity=0, # Today starts with 0 offset
|
|
87
|
+
cond=pnl.cond,
|
|
88
|
+
)
|
|
89
|
+
if account_key not in self._stock_positions:
|
|
90
|
+
self._stock_positions[account_key] = {}
|
|
91
|
+
key = (position.code, position.cond)
|
|
92
|
+
self._stock_positions[account_key][key] = position
|
|
93
|
+
|
|
94
|
+
elif account_type == AccountType.Future:
|
|
95
|
+
for pnl in positions_pnl:
|
|
96
|
+
if isinstance(pnl, SjFuturePostion):
|
|
97
|
+
position = FuturesPosition(
|
|
98
|
+
code=pnl.code,
|
|
99
|
+
direction=pnl.direction,
|
|
100
|
+
quantity=pnl.quantity,
|
|
101
|
+
)
|
|
102
|
+
if account_key not in self._futures_positions:
|
|
103
|
+
self._futures_positions[account_key] = {}
|
|
104
|
+
self._futures_positions[account_key][position.code] = position
|
|
105
|
+
|
|
106
|
+
logger.info(f"Initialized positions for account {account_key}")
|
|
107
|
+
|
|
108
|
+
def list_positions( # noqa: ARG002
|
|
109
|
+
self, account: Optional[Account] = None, unit: Unit = Unit.Common
|
|
110
|
+
) -> Union[List[StockPosition], List[FuturesPosition]]:
|
|
111
|
+
"""Get all current positions.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
account: Account to filter. None uses default stock_account first, then futopt_account if no stock.
|
|
115
|
+
unit: Unit.Common or Unit.Share (for compatibility, not used in real-time tracking)
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
List of position objects for the specified account type:
|
|
119
|
+
- Stock account: List[StockPosition]
|
|
120
|
+
- Futures account: List[FuturesPosition]
|
|
121
|
+
- None (default): List[StockPosition] from stock_account, or List[FuturesPosition] if no stock
|
|
122
|
+
"""
|
|
123
|
+
if account is None:
|
|
124
|
+
# Use default accounts - prioritize stock_account
|
|
125
|
+
if (
|
|
126
|
+
hasattr(self.api, "stock_account")
|
|
127
|
+
and self.api.stock_account is not None
|
|
128
|
+
):
|
|
129
|
+
stock_account_key = self._get_account_key(self.api.stock_account)
|
|
130
|
+
if stock_account_key in self._stock_positions:
|
|
131
|
+
return list(self._stock_positions[stock_account_key].values())
|
|
132
|
+
|
|
133
|
+
# No stock positions, try futures
|
|
134
|
+
if (
|
|
135
|
+
hasattr(self.api, "futopt_account")
|
|
136
|
+
and self.api.futopt_account is not None
|
|
137
|
+
):
|
|
138
|
+
futopt_account_key = self._get_account_key(self.api.futopt_account)
|
|
139
|
+
if futopt_account_key in self._futures_positions:
|
|
140
|
+
futures_list: List[FuturesPosition] = list(
|
|
141
|
+
self._futures_positions[futopt_account_key].values()
|
|
142
|
+
)
|
|
143
|
+
return futures_list
|
|
144
|
+
|
|
145
|
+
# No positions at all
|
|
146
|
+
return []
|
|
147
|
+
else:
|
|
148
|
+
# Specific account - use AccountType enum
|
|
149
|
+
account_key = self._get_account_key(account)
|
|
150
|
+
account_type = account.account_type
|
|
151
|
+
|
|
152
|
+
if account_type == AccountType.Stock:
|
|
153
|
+
if account_key in self._stock_positions:
|
|
154
|
+
return list(self._stock_positions[account_key].values())
|
|
155
|
+
return []
|
|
156
|
+
elif account_type == AccountType.Future:
|
|
157
|
+
if account_key in self._futures_positions:
|
|
158
|
+
futures_list: List[FuturesPosition] = list(
|
|
159
|
+
self._futures_positions[account_key].values()
|
|
160
|
+
)
|
|
161
|
+
return futures_list
|
|
162
|
+
return []
|
|
163
|
+
|
|
164
|
+
return []
|
|
165
|
+
|
|
166
|
+
def on_order_deal_event(self, state: OrderState, data: Dict) -> None:
|
|
167
|
+
"""Callback for order deal events.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
state: OrderState enum value
|
|
171
|
+
data: Order/deal data dictionary
|
|
172
|
+
"""
|
|
173
|
+
# Handle stock deals
|
|
174
|
+
if state == OrderState.StockDeal:
|
|
175
|
+
self._update_position(data, is_futures=False)
|
|
176
|
+
# Handle futures deals
|
|
177
|
+
elif state == OrderState.FuturesDeal:
|
|
178
|
+
self._update_position(data, is_futures=True)
|
|
179
|
+
|
|
180
|
+
def _update_position(self, deal: Dict, is_futures: bool = False) -> None:
|
|
181
|
+
"""Update position based on deal event.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
deal: Deal data from callback
|
|
185
|
+
is_futures: True if futures/options deal, False if stock deal
|
|
186
|
+
"""
|
|
187
|
+
code = deal.get("code")
|
|
188
|
+
action_value = deal.get("action")
|
|
189
|
+
quantity = deal.get("quantity", 0)
|
|
190
|
+
price = deal.get("price", 0)
|
|
191
|
+
account = deal.get("account")
|
|
192
|
+
|
|
193
|
+
if not code or not action_value or not account:
|
|
194
|
+
logger.warning(f"Deal missing required fields: {deal}")
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
action = self._normalize_direction(action_value)
|
|
198
|
+
|
|
199
|
+
if is_futures:
|
|
200
|
+
self._update_futures_position(account, code, action, quantity, price)
|
|
201
|
+
else:
|
|
202
|
+
order_cond = self._normalize_cond(
|
|
203
|
+
deal.get("order_cond", StockOrderCond.Cash)
|
|
204
|
+
)
|
|
205
|
+
self._update_stock_position(
|
|
206
|
+
account, code, action, quantity, price, order_cond
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def _is_day_trading_offset(
|
|
210
|
+
self, code: str, account_key: str, action: Action, order_cond: StockOrderCond
|
|
211
|
+
) -> tuple[bool, StockOrderCond | None]:
|
|
212
|
+
"""Check if this is a day trading offset transaction.
|
|
213
|
+
|
|
214
|
+
Day trading rules:
|
|
215
|
+
- MarginTrading Buy + ShortSelling Sell = offset MarginTrading today's quantity
|
|
216
|
+
- ShortSelling Sell + MarginTrading Buy = offset ShortSelling today's quantity
|
|
217
|
+
- Cash Buy + Cash Sell = offset Cash today's quantity
|
|
218
|
+
- Cash Sell (short) + Cash Buy = offset Cash today's quantity
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
(is_day_trading, opposite_cond)
|
|
222
|
+
"""
|
|
223
|
+
# MarginTrading + ShortSelling day trading
|
|
224
|
+
if order_cond == StockOrderCond.ShortSelling and action == Action.Sell:
|
|
225
|
+
# Check if there's today's MarginTrading position
|
|
226
|
+
margin_key = (code, StockOrderCond.MarginTrading)
|
|
227
|
+
if margin_key in self._stock_positions.get(account_key, {}):
|
|
228
|
+
margin_pos = self._stock_positions[account_key][margin_key]
|
|
229
|
+
# Today's quantity = quantity - (yd_quantity - yd_offset_quantity)
|
|
230
|
+
yd_remaining = margin_pos.yd_quantity - margin_pos.yd_offset_quantity
|
|
231
|
+
today_qty = margin_pos.quantity - yd_remaining
|
|
232
|
+
if today_qty > 0:
|
|
233
|
+
return True, StockOrderCond.MarginTrading
|
|
234
|
+
|
|
235
|
+
if order_cond == StockOrderCond.MarginTrading and action == Action.Buy:
|
|
236
|
+
# Check if there's today's ShortSelling position
|
|
237
|
+
short_key = (code, StockOrderCond.ShortSelling)
|
|
238
|
+
if short_key in self._stock_positions.get(account_key, {}):
|
|
239
|
+
short_pos = self._stock_positions[account_key][short_key]
|
|
240
|
+
# Today's quantity = quantity - (yd_quantity - yd_offset_quantity)
|
|
241
|
+
yd_remaining = short_pos.yd_quantity - short_pos.yd_offset_quantity
|
|
242
|
+
today_qty = short_pos.quantity - yd_remaining
|
|
243
|
+
if today_qty > 0:
|
|
244
|
+
return True, StockOrderCond.ShortSelling
|
|
245
|
+
|
|
246
|
+
# Cash day trading
|
|
247
|
+
if order_cond == StockOrderCond.Cash:
|
|
248
|
+
cash_key = (code, StockOrderCond.Cash)
|
|
249
|
+
if cash_key in self._stock_positions.get(account_key, {}):
|
|
250
|
+
cash_pos = self._stock_positions[account_key][cash_key]
|
|
251
|
+
# Buy then Sell or Sell then Buy
|
|
252
|
+
if cash_pos.direction != action:
|
|
253
|
+
# Today's quantity = quantity - (yd_quantity - yd_offset_quantity)
|
|
254
|
+
yd_remaining = cash_pos.yd_quantity - cash_pos.yd_offset_quantity
|
|
255
|
+
today_qty = cash_pos.quantity - yd_remaining
|
|
256
|
+
if today_qty > 0:
|
|
257
|
+
return True, StockOrderCond.Cash
|
|
258
|
+
|
|
259
|
+
return False, None
|
|
260
|
+
|
|
261
|
+
def _update_stock_position(
|
|
262
|
+
self,
|
|
263
|
+
account: Union[Account, AccountDict],
|
|
264
|
+
code: str,
|
|
265
|
+
action: Action,
|
|
266
|
+
quantity: int,
|
|
267
|
+
price: float,
|
|
268
|
+
order_cond: StockOrderCond,
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Update stock position.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
account: Account object or AccountDict from deal callback
|
|
274
|
+
code: Stock code
|
|
275
|
+
action: Buy or Sell action
|
|
276
|
+
quantity: Trade quantity
|
|
277
|
+
price: Trade price
|
|
278
|
+
order_cond: Order condition (Cash, MarginTrading, ShortSelling)
|
|
279
|
+
"""
|
|
280
|
+
account_key = self._get_account_key(account)
|
|
281
|
+
|
|
282
|
+
# Initialize account dict if needed
|
|
283
|
+
if account_key not in self._stock_positions:
|
|
284
|
+
self._stock_positions[account_key] = {}
|
|
285
|
+
|
|
286
|
+
# Check for day trading offset
|
|
287
|
+
is_day_trading, opposite_cond = self._is_day_trading_offset(
|
|
288
|
+
code, account_key, action, order_cond
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if is_day_trading and opposite_cond:
|
|
292
|
+
# Day trading: offset today's position in opposite condition
|
|
293
|
+
self._process_day_trading_offset(
|
|
294
|
+
account_key, code, quantity, price, order_cond, opposite_cond, action
|
|
295
|
+
)
|
|
296
|
+
else:
|
|
297
|
+
# Normal trading or same-cond offset
|
|
298
|
+
self._process_normal_trading(
|
|
299
|
+
account_key, code, action, quantity, price, order_cond
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def _process_day_trading_offset(
|
|
303
|
+
self,
|
|
304
|
+
account_key: str,
|
|
305
|
+
code: str,
|
|
306
|
+
quantity: int,
|
|
307
|
+
price: float,
|
|
308
|
+
order_cond: StockOrderCond,
|
|
309
|
+
opposite_cond: StockOrderCond,
|
|
310
|
+
action: Action,
|
|
311
|
+
) -> None:
|
|
312
|
+
"""Process day trading offset transaction.
|
|
313
|
+
|
|
314
|
+
Day trading offsets today's quantity only.
|
|
315
|
+
Note: yd_quantity and yd_offset_quantity are NOT modified in day trading.
|
|
316
|
+
"""
|
|
317
|
+
opposite_key = (code, opposite_cond)
|
|
318
|
+
opposite_pos = self._stock_positions[account_key][opposite_key]
|
|
319
|
+
|
|
320
|
+
# Calculate today's quantity: quantity - (yd_quantity - yd_offset_quantity)
|
|
321
|
+
yd_remaining = opposite_pos.yd_quantity - opposite_pos.yd_offset_quantity
|
|
322
|
+
today_qty = opposite_pos.quantity - yd_remaining
|
|
323
|
+
offset_qty = min(quantity, today_qty)
|
|
324
|
+
remaining_qty = quantity - offset_qty
|
|
325
|
+
|
|
326
|
+
# Offset today's position (only reduce quantity, yd_quantity & yd_offset_quantity stay unchanged)
|
|
327
|
+
opposite_pos.quantity -= offset_qty
|
|
328
|
+
logger.info(
|
|
329
|
+
f"{code} DAY-TRADE OFFSET {action} {price} x {offset_qty} "
|
|
330
|
+
f"[{order_cond}] offsets [{opposite_cond}] -> {opposite_pos}"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Remove if zero
|
|
334
|
+
if opposite_pos.quantity == 0:
|
|
335
|
+
del self._stock_positions[account_key][opposite_key]
|
|
336
|
+
logger.info(f"{code} [{opposite_cond}] REMOVED (day trading closed)")
|
|
337
|
+
|
|
338
|
+
# If there's remaining quantity after offsetting today's, it offsets yesterday's position
|
|
339
|
+
if remaining_qty > 0 and opposite_key in self._stock_positions[account_key]:
|
|
340
|
+
# Calculate how much yesterday's position is left
|
|
341
|
+
opposite_pos = self._stock_positions[account_key][opposite_key]
|
|
342
|
+
yd_available = opposite_pos.yd_quantity - opposite_pos.yd_offset_quantity
|
|
343
|
+
yd_offset = min(remaining_qty, yd_available)
|
|
344
|
+
|
|
345
|
+
if yd_offset > 0:
|
|
346
|
+
# Reduce quantity and increase yd_offset_quantity (yd_quantity never changes)
|
|
347
|
+
opposite_pos.quantity -= yd_offset
|
|
348
|
+
opposite_pos.yd_offset_quantity += yd_offset
|
|
349
|
+
remaining_qty -= yd_offset
|
|
350
|
+
logger.info(
|
|
351
|
+
f"{code} OFFSET YD {action} {price} x {yd_offset} "
|
|
352
|
+
f"[{order_cond}] offsets [{opposite_cond}] yd -> {opposite_pos}"
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if opposite_pos.quantity == 0:
|
|
356
|
+
del self._stock_positions[account_key][opposite_key]
|
|
357
|
+
logger.info(f"{code} [{opposite_cond}] REMOVED (fully closed)")
|
|
358
|
+
|
|
359
|
+
# If still remaining, create new position
|
|
360
|
+
if remaining_qty > 0:
|
|
361
|
+
self._create_or_update_position(
|
|
362
|
+
account_key, code, action, remaining_qty, price, order_cond
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def _process_normal_trading(
|
|
366
|
+
self,
|
|
367
|
+
account_key: str,
|
|
368
|
+
code: str,
|
|
369
|
+
action: Action,
|
|
370
|
+
quantity: int,
|
|
371
|
+
price: float,
|
|
372
|
+
order_cond: StockOrderCond,
|
|
373
|
+
) -> None:
|
|
374
|
+
"""Process normal trading (non-day-trading).
|
|
375
|
+
|
|
376
|
+
For margin/short trading with opposite direction:
|
|
377
|
+
- Can only offset yesterday's position
|
|
378
|
+
- Increase yd_offset_quantity, decrease quantity
|
|
379
|
+
- yd_quantity never changes
|
|
380
|
+
"""
|
|
381
|
+
key = (code, order_cond)
|
|
382
|
+
position = self._stock_positions[account_key].get(key)
|
|
383
|
+
|
|
384
|
+
if position is None:
|
|
385
|
+
# Create new position
|
|
386
|
+
self._create_or_update_position(
|
|
387
|
+
account_key, code, action, quantity, price, order_cond
|
|
388
|
+
)
|
|
389
|
+
else:
|
|
390
|
+
# Existing position
|
|
391
|
+
if position.direction == action:
|
|
392
|
+
# Same direction: add to position
|
|
393
|
+
position.quantity += quantity
|
|
394
|
+
logger.info(
|
|
395
|
+
f"{code} ADD {action} {price} x {quantity} [{order_cond}] -> {position}"
|
|
396
|
+
)
|
|
397
|
+
else:
|
|
398
|
+
# Opposite direction: can only offset yesterday's position
|
|
399
|
+
# Calculate yesterday's remaining
|
|
400
|
+
yd_available = position.yd_quantity - position.yd_offset_quantity
|
|
401
|
+
offset_qty = min(quantity, yd_available)
|
|
402
|
+
|
|
403
|
+
if offset_qty > 0:
|
|
404
|
+
# Reduce quantity and increase yd_offset_quantity (yd_quantity never changes)
|
|
405
|
+
position.quantity -= offset_qty
|
|
406
|
+
position.yd_offset_quantity += offset_qty
|
|
407
|
+
logger.info(
|
|
408
|
+
f"{code} OFFSET YD {action} {price} x {offset_qty} [{order_cond}] -> {position}"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# Remove if zero
|
|
412
|
+
if position.quantity == 0:
|
|
413
|
+
del self._stock_positions[account_key][key]
|
|
414
|
+
logger.info(f"{code} CLOSED [{order_cond}] -> REMOVED")
|
|
415
|
+
|
|
416
|
+
def _create_or_update_position(
|
|
417
|
+
self,
|
|
418
|
+
account_key: str,
|
|
419
|
+
code: str,
|
|
420
|
+
action: Action,
|
|
421
|
+
quantity: int,
|
|
422
|
+
price: float,
|
|
423
|
+
order_cond: StockOrderCond,
|
|
424
|
+
) -> None:
|
|
425
|
+
"""Create new position or add to existing."""
|
|
426
|
+
key = (code, order_cond)
|
|
427
|
+
position = self._stock_positions[account_key].get(key)
|
|
428
|
+
|
|
429
|
+
if position is None:
|
|
430
|
+
position = StockPosition(
|
|
431
|
+
code=code,
|
|
432
|
+
direction=action,
|
|
433
|
+
quantity=quantity,
|
|
434
|
+
yd_quantity=0,
|
|
435
|
+
yd_offset_quantity=0, # New position today has no offset
|
|
436
|
+
cond=order_cond,
|
|
437
|
+
)
|
|
438
|
+
self._stock_positions[account_key][key] = position
|
|
439
|
+
logger.info(
|
|
440
|
+
f"{code} NEW {action} {price} x {quantity} [{order_cond}] -> {position}"
|
|
441
|
+
)
|
|
442
|
+
else:
|
|
443
|
+
position.quantity += quantity
|
|
444
|
+
logger.info(
|
|
445
|
+
f"{code} ADD {action} {price} x {quantity} [{order_cond}] -> {position}"
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
def _update_futures_position(
|
|
449
|
+
self,
|
|
450
|
+
account: Union[Account, AccountDict],
|
|
451
|
+
code: str,
|
|
452
|
+
action: Action,
|
|
453
|
+
quantity: int,
|
|
454
|
+
price: float,
|
|
455
|
+
) -> None:
|
|
456
|
+
"""Update futures position.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
account: Account object or AccountDict from deal callback
|
|
460
|
+
code: Contract code
|
|
461
|
+
action: Buy or Sell action
|
|
462
|
+
quantity: Trade quantity
|
|
463
|
+
price: Trade price
|
|
464
|
+
"""
|
|
465
|
+
account_key = self._get_account_key(account)
|
|
466
|
+
|
|
467
|
+
# Initialize account dict if needed
|
|
468
|
+
if account_key not in self._futures_positions:
|
|
469
|
+
self._futures_positions[account_key] = {}
|
|
470
|
+
|
|
471
|
+
position = self._futures_positions[account_key].get(code)
|
|
472
|
+
|
|
473
|
+
if position is None:
|
|
474
|
+
# Create new position
|
|
475
|
+
position = FuturesPosition(
|
|
476
|
+
code=code,
|
|
477
|
+
direction=action,
|
|
478
|
+
quantity=quantity,
|
|
479
|
+
)
|
|
480
|
+
self._futures_positions[account_key][code] = position
|
|
481
|
+
logger.info(f"{code} NEW {action} {price} x {quantity} -> {position}")
|
|
482
|
+
else:
|
|
483
|
+
# Update existing position
|
|
484
|
+
if position.direction == action:
|
|
485
|
+
position.quantity += quantity
|
|
486
|
+
else:
|
|
487
|
+
position.quantity -= quantity
|
|
488
|
+
|
|
489
|
+
# Remove if quantity becomes zero
|
|
490
|
+
if position.quantity == 0:
|
|
491
|
+
del self._futures_positions[account_key][code]
|
|
492
|
+
logger.info(f"{code} CLOSED {action} {price} x {quantity} -> REMOVED")
|
|
493
|
+
else:
|
|
494
|
+
logger.info(f"{code} {action} {price} x {quantity} -> {position}")
|
|
495
|
+
|
|
496
|
+
def _normalize_direction(self, direction: Union[Action, str]) -> Action:
|
|
497
|
+
"""Normalize direction to Action enum.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
direction: Action enum or string
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
Action enum (Buy or Sell)
|
|
504
|
+
"""
|
|
505
|
+
if isinstance(direction, Action):
|
|
506
|
+
return direction
|
|
507
|
+
# Convert string to Action enum
|
|
508
|
+
if direction == "Buy" or direction == "buy":
|
|
509
|
+
return Action.Buy
|
|
510
|
+
elif direction == "Sell" or direction == "sell":
|
|
511
|
+
return Action.Sell
|
|
512
|
+
return Action[direction] # Fallback to enum lookup
|
|
513
|
+
|
|
514
|
+
def _normalize_cond(self, cond: Union[StockOrderCond, str]) -> StockOrderCond:
|
|
515
|
+
"""Normalize order condition to StockOrderCond enum.
|
|
516
|
+
|
|
517
|
+
Args:
|
|
518
|
+
cond: StockOrderCond enum or string
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
StockOrderCond enum
|
|
522
|
+
"""
|
|
523
|
+
if isinstance(cond, StockOrderCond):
|
|
524
|
+
return cond
|
|
525
|
+
# Convert string to StockOrderCond enum
|
|
526
|
+
try:
|
|
527
|
+
return StockOrderCond[cond]
|
|
528
|
+
except KeyError:
|
|
529
|
+
# Fallback to Cash if invalid
|
|
530
|
+
return StockOrderCond.Cash
|