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 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
+ [![CI](https://github.com/yvictor/sj_sync/actions/workflows/ci.yml/badge.svg)](https://github.com/yvictor/sj_sync/actions/workflows/ci.yml)
33
+ [![codecov](https://codecov.io/gh/yvictor/sj_sync/branch/master/graph/badge.svg)](https://codecov.io/gh/yvictor/sj_sync)
34
+ [![PyPI version](https://badge.fury.io/py/sj-sync.svg)](https://badge.fury.io/py/sj-sync)
35
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
@@ -0,0 +1,233 @@
1
+ # sj_sync
2
+
3
+ [![CI](https://github.com/yvictor/sj_sync/actions/workflows/ci.yml/badge.svg)](https://github.com/yvictor/sj_sync/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/yvictor/sj_sync/branch/master/graph/badge.svg)](https://codecov.io/gh/yvictor/sj_sync)
5
+ [![PyPI version](https://badge.fury.io/py/sj-sync.svg)](https://badge.fury.io/py/sj-sync)
6
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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