insighta-sdk 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.
- insighta_sdk-0.1.0/.gitignore +14 -0
- insighta_sdk-0.1.0/CONTRIBUTING.md +66 -0
- insighta_sdk-0.1.0/LICENSE +20 -0
- insighta_sdk-0.1.0/PKG-INFO +103 -0
- insighta_sdk-0.1.0/README.md +80 -0
- insighta_sdk-0.1.0/insighta_sdk/__init__.py +41 -0
- insighta_sdk-0.1.0/insighta_sdk/client.py +144 -0
- insighta_sdk-0.1.0/insighta_sdk/models.py +212 -0
- insighta_sdk-0.1.0/insighta_sdk/utils.py +148 -0
- insighta_sdk-0.1.0/pyproject.toml +37 -0
- insighta_sdk-0.1.0/tests/__init__.py +0 -0
- insighta_sdk-0.1.0/tests/test_client.py +197 -0
- insighta_sdk-0.1.0/tests/test_models.py +124 -0
- insighta_sdk-0.1.0/tests/test_utils.py +135 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Contributing to insighta-sdk
|
|
2
|
+
|
|
3
|
+
Thank you for your interest in contributing! This document provides guidelines for contributing to this project.
|
|
4
|
+
|
|
5
|
+
## Development Setup
|
|
6
|
+
|
|
7
|
+
### Requirements
|
|
8
|
+
|
|
9
|
+
- Python 3.10+
|
|
10
|
+
- pip
|
|
11
|
+
|
|
12
|
+
### Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
git clone https://github.com/insighta-cloud/insighta-sdk.git
|
|
16
|
+
cd insighta-sdk
|
|
17
|
+
pip install -e ".[dev]"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Running Tests
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pytest
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Code Style
|
|
27
|
+
|
|
28
|
+
- Follow PEP 8
|
|
29
|
+
- All comments and docstrings in English
|
|
30
|
+
- Type hints required for public APIs
|
|
31
|
+
- Use `Decimal` for financial values, never `float`
|
|
32
|
+
|
|
33
|
+
## Commit Messages
|
|
34
|
+
|
|
35
|
+
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
feat: add new API method
|
|
39
|
+
fix: correct rate lookup for edge case
|
|
40
|
+
docs: update README
|
|
41
|
+
test: add missing test for merge_and_sort_groups
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Pull Request Process
|
|
45
|
+
|
|
46
|
+
1. Fork the repository
|
|
47
|
+
2. Create a feature branch (`git checkout -b feat/my-feature`)
|
|
48
|
+
3. Write tests for new functionality
|
|
49
|
+
4. Ensure all tests pass (`pytest`)
|
|
50
|
+
5. Submit a pull request against `main`
|
|
51
|
+
|
|
52
|
+
## Multilingual Support (i18n)
|
|
53
|
+
|
|
54
|
+
This project uses **English** as the primary language for:
|
|
55
|
+
- Source code, comments, and docstrings
|
|
56
|
+
- README and documentation
|
|
57
|
+
- Commit messages and PR descriptions
|
|
58
|
+
|
|
59
|
+
User-facing messages (CLI output, error messages) may support multiple locales via the i18n system in `insighta-cli`. When adding translatable strings:
|
|
60
|
+
- Add the English key first
|
|
61
|
+
- Use ICU MessageFormat or simple key-value pairs
|
|
62
|
+
- Keep keys descriptive (e.g., `upload_success`, not `msg_01`)
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
By contributing, you agree that your contributions will be licensed under the CC-BY-NC-4.0 license.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Creative Commons Attribution-NonCommercial 4.0 International
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Insighta Cloud Inc.
|
|
4
|
+
|
|
5
|
+
You are free to:
|
|
6
|
+
|
|
7
|
+
Share — copy and redistribute the material in any medium or format
|
|
8
|
+
Adapt — remix, transform, and build upon the material
|
|
9
|
+
|
|
10
|
+
Under the following terms:
|
|
11
|
+
|
|
12
|
+
Attribution — You must give appropriate credit, provide a link to the
|
|
13
|
+
license, and indicate if changes were made.
|
|
14
|
+
|
|
15
|
+
NonCommercial — You may not use the material for commercial purposes.
|
|
16
|
+
|
|
17
|
+
No additional restrictions — You may not apply legal terms or technological
|
|
18
|
+
measures that legally restrict others from doing anything the license permits.
|
|
19
|
+
|
|
20
|
+
Full license text: https://creativecommons.org/licenses/by-nc/4.0/legalcode
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: insighta-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Insighta Cloud SDK - API client and data models
|
|
5
|
+
Project-URL: Homepage, https://insighta.cloud
|
|
6
|
+
Author: insighta cloud Inc.
|
|
7
|
+
License-Expression: CC-BY-NC-4.0
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: api-client,insighta,investment,portfolio,sdk
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: Other/Proprietary 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: Topic :: Office/Business :: Financial :: Investment
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: pyyaml>=6.0
|
|
21
|
+
Requires-Dist: requests>=2.28.0
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# insighta-sdk
|
|
25
|
+
|
|
26
|
+
Insighta Cloud SDK — API client and data models for portfolio management.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install insighta-sdk
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from insighta_sdk import Credentials, InsightaClient
|
|
38
|
+
|
|
39
|
+
creds = Credentials.from_file("credentials.yaml")
|
|
40
|
+
client = InsightaClient(creds)
|
|
41
|
+
|
|
42
|
+
# List portfolios
|
|
43
|
+
portfolios = client.get_portfolios()
|
|
44
|
+
|
|
45
|
+
# Create a portfolio
|
|
46
|
+
from insighta_sdk import UploadConfig
|
|
47
|
+
config = UploadConfig.from_file("upload.yaml")
|
|
48
|
+
portfolio_id = client.create_portfolio(config)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
- **API Client** — Full coverage of Insighta OpenAPI (portfolios, orders, metrics)
|
|
54
|
+
- **Data Models** — `Trade`, `Holding`, `Deposit`, `OrderGroup`, `CashDeposit`, `RateEntry`
|
|
55
|
+
- **Utilities** — Rate lookup, order grouping, deposit merging
|
|
56
|
+
- **Workspace Management** — `Dirs` class for consistent file path resolution
|
|
57
|
+
|
|
58
|
+
## API Reference
|
|
59
|
+
|
|
60
|
+
### Client
|
|
61
|
+
|
|
62
|
+
| Method | Description |
|
|
63
|
+
|--------|-------------|
|
|
64
|
+
| `create_portfolio(config)` | Create a new portfolio |
|
|
65
|
+
| `get_portfolios()` | List own portfolios |
|
|
66
|
+
| `search_portfolios(...)` | Search public portfolios |
|
|
67
|
+
| `delete_portfolio(id)` | Delete a portfolio |
|
|
68
|
+
| `send_order(portfolio_id, group, currency)` | Submit an order group |
|
|
69
|
+
| `get_nav_history(id)` | Get NAV history |
|
|
70
|
+
| `get_metrics_history(id, ...)` | Get metrics (TWR, etc.) |
|
|
71
|
+
|
|
72
|
+
### Utilities
|
|
73
|
+
|
|
74
|
+
| Function | Description |
|
|
75
|
+
|----------|-------------|
|
|
76
|
+
| `load_order_groups(path)` | Parse order.csv into OrderGroup list |
|
|
77
|
+
| `load_cash_deposits(path)` | Parse cash_deposits.csv |
|
|
78
|
+
| `merge_and_sort_groups(orders, deposits, memos)` | Merge and sequence order groups |
|
|
79
|
+
| `load_rate_file(path)` | Load exchange rate CSV |
|
|
80
|
+
| `lookup_rate(entries, dt, cur, base)` | Find applicable rate for a trade |
|
|
81
|
+
| `fetch_ticker_info(tickers)` | Query ticker metadata from API |
|
|
82
|
+
|
|
83
|
+
## API Endpoints
|
|
84
|
+
|
|
85
|
+
| Environment | Base URL |
|
|
86
|
+
|-------------|----------|
|
|
87
|
+
| Production | `https://openapi.insighta.cloud` |
|
|
88
|
+
| Development | `https://dev.openapi.insighta.cloud` |
|
|
89
|
+
|
|
90
|
+
OpenAPI spec: [`insighta-app/openapi-docs/`](https://github.com/insighta-cloud/insighta/tree/main/insighta-app/openapi-docs)
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install -e .
|
|
96
|
+
pytest
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
CC-BY-NC-4.0 — See [LICENSE](LICENSE)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# insighta-sdk
|
|
2
|
+
|
|
3
|
+
Insighta Cloud SDK — API client and data models for portfolio management.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install insighta-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from insighta_sdk import Credentials, InsightaClient
|
|
15
|
+
|
|
16
|
+
creds = Credentials.from_file("credentials.yaml")
|
|
17
|
+
client = InsightaClient(creds)
|
|
18
|
+
|
|
19
|
+
# List portfolios
|
|
20
|
+
portfolios = client.get_portfolios()
|
|
21
|
+
|
|
22
|
+
# Create a portfolio
|
|
23
|
+
from insighta_sdk import UploadConfig
|
|
24
|
+
config = UploadConfig.from_file("upload.yaml")
|
|
25
|
+
portfolio_id = client.create_portfolio(config)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
30
|
+
- **API Client** — Full coverage of Insighta OpenAPI (portfolios, orders, metrics)
|
|
31
|
+
- **Data Models** — `Trade`, `Holding`, `Deposit`, `OrderGroup`, `CashDeposit`, `RateEntry`
|
|
32
|
+
- **Utilities** — Rate lookup, order grouping, deposit merging
|
|
33
|
+
- **Workspace Management** — `Dirs` class for consistent file path resolution
|
|
34
|
+
|
|
35
|
+
## API Reference
|
|
36
|
+
|
|
37
|
+
### Client
|
|
38
|
+
|
|
39
|
+
| Method | Description |
|
|
40
|
+
|--------|-------------|
|
|
41
|
+
| `create_portfolio(config)` | Create a new portfolio |
|
|
42
|
+
| `get_portfolios()` | List own portfolios |
|
|
43
|
+
| `search_portfolios(...)` | Search public portfolios |
|
|
44
|
+
| `delete_portfolio(id)` | Delete a portfolio |
|
|
45
|
+
| `send_order(portfolio_id, group, currency)` | Submit an order group |
|
|
46
|
+
| `get_nav_history(id)` | Get NAV history |
|
|
47
|
+
| `get_metrics_history(id, ...)` | Get metrics (TWR, etc.) |
|
|
48
|
+
|
|
49
|
+
### Utilities
|
|
50
|
+
|
|
51
|
+
| Function | Description |
|
|
52
|
+
|----------|-------------|
|
|
53
|
+
| `load_order_groups(path)` | Parse order.csv into OrderGroup list |
|
|
54
|
+
| `load_cash_deposits(path)` | Parse cash_deposits.csv |
|
|
55
|
+
| `merge_and_sort_groups(orders, deposits, memos)` | Merge and sequence order groups |
|
|
56
|
+
| `load_rate_file(path)` | Load exchange rate CSV |
|
|
57
|
+
| `lookup_rate(entries, dt, cur, base)` | Find applicable rate for a trade |
|
|
58
|
+
| `fetch_ticker_info(tickers)` | Query ticker metadata from API |
|
|
59
|
+
|
|
60
|
+
## API Endpoints
|
|
61
|
+
|
|
62
|
+
| Environment | Base URL |
|
|
63
|
+
|-------------|----------|
|
|
64
|
+
| Production | `https://openapi.insighta.cloud` |
|
|
65
|
+
| Development | `https://dev.openapi.insighta.cloud` |
|
|
66
|
+
|
|
67
|
+
OpenAPI spec: [`insighta-app/openapi-docs/`](https://github.com/insighta-cloud/insighta/tree/main/insighta-app/openapi-docs)
|
|
68
|
+
|
|
69
|
+
## Development
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install -e .
|
|
73
|
+
pytest
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
CC-BY-NC-4.0 — See [LICENSE](LICENSE)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Insighta Cloud SDK - API client and data models."""
|
|
2
|
+
|
|
3
|
+
from .client import InsightaClient
|
|
4
|
+
from .models import (
|
|
5
|
+
Credentials,
|
|
6
|
+
UploadConfig,
|
|
7
|
+
OrderGroup,
|
|
8
|
+
CashDeposit,
|
|
9
|
+
Trade,
|
|
10
|
+
Holding,
|
|
11
|
+
Deposit,
|
|
12
|
+
RateEntry,
|
|
13
|
+
Dirs,
|
|
14
|
+
)
|
|
15
|
+
from .utils import (
|
|
16
|
+
load_order_groups,
|
|
17
|
+
load_cash_deposits,
|
|
18
|
+
merge_and_sort_groups,
|
|
19
|
+
fetch_ticker_info,
|
|
20
|
+
load_rate_file,
|
|
21
|
+
lookup_rate,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"InsightaClient",
|
|
26
|
+
"Credentials",
|
|
27
|
+
"UploadConfig",
|
|
28
|
+
"OrderGroup",
|
|
29
|
+
"CashDeposit",
|
|
30
|
+
"Trade",
|
|
31
|
+
"Holding",
|
|
32
|
+
"Deposit",
|
|
33
|
+
"RateEntry",
|
|
34
|
+
"Dirs",
|
|
35
|
+
"load_order_groups",
|
|
36
|
+
"load_cash_deposits",
|
|
37
|
+
"merge_and_sort_groups",
|
|
38
|
+
"fetch_ticker_info",
|
|
39
|
+
"load_rate_file",
|
|
40
|
+
"lookup_rate",
|
|
41
|
+
]
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Insighta OpenAPI client."""
|
|
2
|
+
|
|
3
|
+
import json as _json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from .models import Credentials, OrderGroup, UploadConfig
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InsightaClient:
|
|
15
|
+
"""Insighta OpenAPI client."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, credentials: Credentials, output_dir: str = "output"):
|
|
18
|
+
self.endpoint = credentials.endpoint
|
|
19
|
+
self.output_dir = output_dir
|
|
20
|
+
self.headers = {
|
|
21
|
+
"Authorization": f"Bearer {credentials.api_key}",
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
def _request(self, method: str, path: str, **kwargs) -> requests.Response:
|
|
26
|
+
url = f"{self.endpoint}{path}"
|
|
27
|
+
payload = kwargs.get("json")
|
|
28
|
+
if payload is not None:
|
|
29
|
+
self._last_payload = payload
|
|
30
|
+
payload_str = _json.dumps(payload, indent=2, ensure_ascii=False)
|
|
31
|
+
log.debug("%s %s\n%s", method, url, payload_str)
|
|
32
|
+
log_path = os.path.join(self.output_dir, "request_payload.log")
|
|
33
|
+
with open(log_path, "a", encoding="utf-8") as lf:
|
|
34
|
+
lf.write(f"=== {method} {url} ===\n{payload_str}\n\n")
|
|
35
|
+
else:
|
|
36
|
+
log.debug("%s %s", method, url)
|
|
37
|
+
resp = requests.request(method, url, headers=self.headers, timeout=30, **kwargs)
|
|
38
|
+
log.debug("Response %s: %s", resp.status_code, resp.text)
|
|
39
|
+
resp.raise_for_status()
|
|
40
|
+
return resp
|
|
41
|
+
|
|
42
|
+
def create_portfolio(self, config: UploadConfig) -> str:
|
|
43
|
+
"""POST /portfolios → portfolio_id 반환."""
|
|
44
|
+
items = [
|
|
45
|
+
{
|
|
46
|
+
"ticker": str(item["ticker"]),
|
|
47
|
+
"type": str(item.get("type", "stock")),
|
|
48
|
+
"quantity": float(item.get("quantity", 0)),
|
|
49
|
+
"ratio": float(item.get("ratio", 0)),
|
|
50
|
+
"price": float(item.get("price", 0)),
|
|
51
|
+
"sector": str(item.get("sector", "N/A")),
|
|
52
|
+
"industry": str(item.get("industry", "N/A")),
|
|
53
|
+
}
|
|
54
|
+
for item in config.items
|
|
55
|
+
]
|
|
56
|
+
body = {
|
|
57
|
+
"name": config.name,
|
|
58
|
+
"description": config.description,
|
|
59
|
+
"type": config.portfolio_type,
|
|
60
|
+
"currency": config.currency,
|
|
61
|
+
"budget": float(config.budget),
|
|
62
|
+
"target_return": config.target_return,
|
|
63
|
+
"start_date": config.start_date,
|
|
64
|
+
"target_date": config.target_date,
|
|
65
|
+
"items": items,
|
|
66
|
+
}
|
|
67
|
+
if config.settings:
|
|
68
|
+
body["settings"] = config.settings
|
|
69
|
+
resp = self._request("POST", "/portfolios", json=body)
|
|
70
|
+
return resp.json()["portfolio_id"]
|
|
71
|
+
|
|
72
|
+
def get_portfolios(self) -> list[dict]:
|
|
73
|
+
"""GET /portfolios → return caller's own portfolios."""
|
|
74
|
+
resp = self._request("GET", "/portfolios")
|
|
75
|
+
return resp.json()
|
|
76
|
+
|
|
77
|
+
def search_portfolios(
|
|
78
|
+
self,
|
|
79
|
+
search: str | None = None,
|
|
80
|
+
country: str | None = None,
|
|
81
|
+
sort_by: str | None = None,
|
|
82
|
+
last_item: str | None = None,
|
|
83
|
+
) -> dict:
|
|
84
|
+
"""GET /portfolios with search params."""
|
|
85
|
+
params = {k: v for k, v in {
|
|
86
|
+
"search": search, "country": country,
|
|
87
|
+
"sort_by": sort_by, "last_item": last_item,
|
|
88
|
+
}.items() if v is not None}
|
|
89
|
+
resp = self._request("GET", "/portfolios", params=params)
|
|
90
|
+
return resp.json()
|
|
91
|
+
|
|
92
|
+
def delete_portfolio(self, portfolio_id: str) -> None:
|
|
93
|
+
"""DELETE /portfolios/{portfolio_id}."""
|
|
94
|
+
self._request("DELETE", f"/portfolios/{portfolio_id}")
|
|
95
|
+
|
|
96
|
+
def get_nav_history(self, portfolio_id: str) -> dict:
|
|
97
|
+
"""GET /portfolios/{portfolio_id}/nav-history."""
|
|
98
|
+
resp = self._request("GET", f"/portfolios/{portfolio_id}/nav-history")
|
|
99
|
+
return resp.json()
|
|
100
|
+
|
|
101
|
+
def get_metrics_history(
|
|
102
|
+
self,
|
|
103
|
+
portfolio_id: str,
|
|
104
|
+
metrics: str = "twr",
|
|
105
|
+
from_t: int | None = None,
|
|
106
|
+
to_t: int | None = None,
|
|
107
|
+
) -> dict:
|
|
108
|
+
"""GET /portfolios/{portfolio_id}/metrics-history."""
|
|
109
|
+
params: dict = {"metrics": metrics}
|
|
110
|
+
if from_t is not None:
|
|
111
|
+
params["from_t"] = str(from_t)
|
|
112
|
+
if to_t is not None:
|
|
113
|
+
params["to_t"] = str(to_t)
|
|
114
|
+
resp = self._request(
|
|
115
|
+
"GET", f"/portfolios/{portfolio_id}/metrics-history",
|
|
116
|
+
params=params)
|
|
117
|
+
return resp.json()
|
|
118
|
+
|
|
119
|
+
def send_order(self, portfolio_id: str, order_group: OrderGroup, portfolio_currency: str) -> dict:
|
|
120
|
+
"""POST /orders → 주문 그룹 하나 전송."""
|
|
121
|
+
body = {
|
|
122
|
+
"portfolio_id": portfolio_id,
|
|
123
|
+
"currency": portfolio_currency,
|
|
124
|
+
"payment_currency": order_group.currency,
|
|
125
|
+
"items": order_group.items,
|
|
126
|
+
}
|
|
127
|
+
if order_group.memo:
|
|
128
|
+
body["memo"] = order_group.memo
|
|
129
|
+
if order_group.exchange_rate:
|
|
130
|
+
body["custom_exchange_rate"] = order_group.exchange_rate
|
|
131
|
+
body["is_custom_exchange_rate"] = True
|
|
132
|
+
if order_group.cash_deposits:
|
|
133
|
+
body["cash_deposits"] = [
|
|
134
|
+
{k: v for k, v in {
|
|
135
|
+
"type": d.type,
|
|
136
|
+
"amount": d.amount,
|
|
137
|
+
"currency": d.currency,
|
|
138
|
+
"ticker": d.ticker,
|
|
139
|
+
"timestamp": d.timestamp,
|
|
140
|
+
}.items() if v is not None}
|
|
141
|
+
for d in order_group.cash_deposits
|
|
142
|
+
]
|
|
143
|
+
resp = self._request("POST", "/orders", json=body)
|
|
144
|
+
return resp.json() if resp.text else {}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Data models for Insighta SDK."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timezone, timedelta
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
WORKSPACES_DIR = "workspaces"
|
|
12
|
+
JST = timezone(timedelta(hours=9))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Credentials:
|
|
17
|
+
api_key: str
|
|
18
|
+
endpoint: str
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def masked_key(self) -> str:
|
|
22
|
+
if len(self.api_key) <= 8:
|
|
23
|
+
return "****"
|
|
24
|
+
return self.api_key[:4] + "****" + self.api_key[-4:]
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_file(cls, path: str) -> "Credentials":
|
|
28
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
29
|
+
data = yaml.safe_load(f)
|
|
30
|
+
return cls(api_key=data["api_key"], endpoint=data["endpoint"].rstrip("/"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class UploadConfig:
|
|
35
|
+
name: str
|
|
36
|
+
description: str
|
|
37
|
+
portfolio_type: str
|
|
38
|
+
currency: str
|
|
39
|
+
budget: Decimal
|
|
40
|
+
balance: Decimal
|
|
41
|
+
order_file: str
|
|
42
|
+
target_return: float = 0.0
|
|
43
|
+
start_date: str = ""
|
|
44
|
+
target_date: str = ""
|
|
45
|
+
items: list = field(default_factory=list)
|
|
46
|
+
cash_deposits_file: str | None = None
|
|
47
|
+
memo_file: str | None = None
|
|
48
|
+
settings: dict | None = None
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_file(cls, path: str) -> "UploadConfig":
|
|
52
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
53
|
+
data = yaml.safe_load(f)
|
|
54
|
+
p = data["portfolio"]
|
|
55
|
+
files = data.get("files", {})
|
|
56
|
+
return cls(
|
|
57
|
+
name=p["name"],
|
|
58
|
+
description=p.get("description", ""),
|
|
59
|
+
portfolio_type=p["type"],
|
|
60
|
+
currency=p["currency"],
|
|
61
|
+
budget=Decimal(str(p["budget"])),
|
|
62
|
+
balance=Decimal(str(p["budget"])),
|
|
63
|
+
target_return=float(p.get("target_return", 0)),
|
|
64
|
+
start_date=p.get("start_date", ""),
|
|
65
|
+
target_date=p.get("target_date", ""),
|
|
66
|
+
items=p.get("items", []),
|
|
67
|
+
order_file=files["order"],
|
|
68
|
+
cash_deposits_file=files.get("cash_deposits"),
|
|
69
|
+
memo_file=files.get("memo"),
|
|
70
|
+
settings=p.get("settings"),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class CashDeposit:
|
|
76
|
+
type: str # budget | dividend
|
|
77
|
+
amount: float
|
|
78
|
+
currency: str | None = None
|
|
79
|
+
ticker: str | None = None
|
|
80
|
+
timestamp: int | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class OrderGroup:
|
|
85
|
+
group_id: str
|
|
86
|
+
currency: str
|
|
87
|
+
items: list = field(default_factory=list)
|
|
88
|
+
cash_deposits: list[CashDeposit] = field(default_factory=list)
|
|
89
|
+
exchange_rate: float | None = None
|
|
90
|
+
memo: str = ""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class Trade:
|
|
95
|
+
dt: str # ISO 8601 JST
|
|
96
|
+
ticker: str
|
|
97
|
+
qty: int
|
|
98
|
+
acct: str
|
|
99
|
+
price: Decimal
|
|
100
|
+
avg: Decimal
|
|
101
|
+
cur: str # 決済通貨 (JPY or USD)
|
|
102
|
+
base: str = "USD" # 銘柄の基準通貨
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class Holding:
|
|
107
|
+
ticker: str
|
|
108
|
+
acct: str
|
|
109
|
+
qty: int
|
|
110
|
+
cost: Decimal = Decimal("0")
|
|
111
|
+
price: Decimal = Decimal("0")
|
|
112
|
+
pnl: Decimal = Decimal("0")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class Deposit:
|
|
117
|
+
dt: str # ISO 8601 JST or raw datetime
|
|
118
|
+
amount: Decimal
|
|
119
|
+
cur: str
|
|
120
|
+
type: str = "budget" # budget | dividend
|
|
121
|
+
ticker: str = ""
|
|
122
|
+
rate: Decimal | None = None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class RateEntry:
|
|
127
|
+
start: str
|
|
128
|
+
end: str
|
|
129
|
+
pair: str
|
|
130
|
+
rate: Decimal
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class Dirs:
|
|
135
|
+
"""作業ディレクトリ設定。--work オプションで切り替え可能。"""
|
|
136
|
+
work: str = ""
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def from_work(cls, work: str = "") -> "Dirs":
|
|
140
|
+
return cls(work=work)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def _base(self) -> str:
|
|
144
|
+
return os.path.join(WORKSPACES_DIR, self.work) if self.work else ""
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def input(self) -> str:
|
|
148
|
+
return os.path.join(self._base, "input") if self._base else "input"
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def output(self) -> str:
|
|
152
|
+
return os.path.join(self._base, "output") if self._base else "output"
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def history(self) -> str:
|
|
156
|
+
return os.path.join(self.input, "history")
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def summary(self) -> str:
|
|
160
|
+
return os.path.join(self.input, "summary")
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def seed(self) -> str:
|
|
164
|
+
return os.path.join(self.input, "seed")
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def deposit(self) -> str:
|
|
168
|
+
return os.path.join(self.input, "deposit")
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def exchange(self) -> str:
|
|
172
|
+
return os.path.join(self.input, "currency_exchange")
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def manual(self) -> str:
|
|
176
|
+
return os.path.join(self.input, "manual")
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def rate_csv(self) -> str:
|
|
180
|
+
return os.path.join(self.input, "rate.csv")
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def ratio_csv(self) -> str:
|
|
184
|
+
return os.path.join(self.input, "ratio.csv")
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def history_csv(self) -> str:
|
|
188
|
+
return os.path.join(self.output, "history.csv")
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def order_csv(self) -> str:
|
|
192
|
+
return os.path.join(self.output, "order.csv")
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def upload_yaml(self) -> str:
|
|
196
|
+
return os.path.join(self.output, "upload.yaml")
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def memo_csv(self) -> str:
|
|
200
|
+
return os.path.join(self.output, "memo.csv")
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def cash_deposits_csv(self) -> str:
|
|
204
|
+
return os.path.join(self.output, "cash_deposits.csv")
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def request_payload_log(self) -> str:
|
|
208
|
+
return os.path.join(self.output, "request_payload.log")
|
|
209
|
+
|
|
210
|
+
def ensure_output(self):
|
|
211
|
+
"""output ディレクトリを作成する。"""
|
|
212
|
+
os.makedirs(self.output, exist_ok=True)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Utility functions for Insighta SDK."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
from collections import OrderedDict
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from .models import CashDeposit, OrderGroup, RateEntry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse_timestamp(val: str) -> int | None:
|
|
14
|
+
if not val:
|
|
15
|
+
return None
|
|
16
|
+
try:
|
|
17
|
+
return int(val)
|
|
18
|
+
except ValueError:
|
|
19
|
+
pass
|
|
20
|
+
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
|
|
21
|
+
try:
|
|
22
|
+
return int(datetime.strptime(val, fmt).replace(tzinfo=timezone.utc).timestamp() * 1000)
|
|
23
|
+
except ValueError:
|
|
24
|
+
continue
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_order_groups(filepath: str) -> list[OrderGroup]:
|
|
29
|
+
"""order.csv를 읽어서 group_dt별로 묶어 반환."""
|
|
30
|
+
groups: OrderedDict[str, OrderGroup] = OrderedDict()
|
|
31
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
32
|
+
for row in csv.DictReader(f):
|
|
33
|
+
gdt = row["group_dt"]
|
|
34
|
+
rate_val = row.get("rate", "").strip() if row.get("rate") else ""
|
|
35
|
+
if gdt not in groups:
|
|
36
|
+
groups[gdt] = OrderGroup(
|
|
37
|
+
group_id=gdt,
|
|
38
|
+
currency=row.get("settle_currency", row["currency"]),
|
|
39
|
+
exchange_rate=float(rate_val) if rate_val else None,
|
|
40
|
+
)
|
|
41
|
+
groups[gdt].items.append({
|
|
42
|
+
"id": row["ticker"],
|
|
43
|
+
"ticker": row["ticker"],
|
|
44
|
+
"quantity": float(row["quantity"]),
|
|
45
|
+
"price": float(row["price"]),
|
|
46
|
+
"currency": row["currency"],
|
|
47
|
+
"price_type": row["price_type"],
|
|
48
|
+
"timestamp": _parse_timestamp(row.get("timestamp", "")),
|
|
49
|
+
})
|
|
50
|
+
return list(groups.values())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_cash_deposits(filepath: str) -> dict[str, list[CashDeposit]]:
|
|
54
|
+
"""cash_deposits.csv를 읽어서 group_dt별로 묶어 반환."""
|
|
55
|
+
groups: dict[str, list[CashDeposit]] = {}
|
|
56
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
57
|
+
for row in csv.DictReader(f):
|
|
58
|
+
gdt = row["group_dt"]
|
|
59
|
+
groups.setdefault(gdt, []).append(CashDeposit(
|
|
60
|
+
type=row["type"],
|
|
61
|
+
amount=float(row["amount"]),
|
|
62
|
+
currency=row.get("currency") or None,
|
|
63
|
+
ticker=row.get("ticker") or None,
|
|
64
|
+
timestamp=_parse_timestamp(row.get("timestamp", "")),
|
|
65
|
+
))
|
|
66
|
+
return groups
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def merge_and_sort_groups(
|
|
70
|
+
orders: list[OrderGroup],
|
|
71
|
+
deposits_by_gdt: dict[str, list[CashDeposit]],
|
|
72
|
+
memos: dict[str, str],
|
|
73
|
+
) -> list[OrderGroup]:
|
|
74
|
+
"""order + deposit을 group_dt 기준으로 머지하고 시간순 정렬."""
|
|
75
|
+
existing_gdts = {g.group_id for g in orders}
|
|
76
|
+
for gdt, deps in deposits_by_gdt.items():
|
|
77
|
+
if gdt not in existing_gdts:
|
|
78
|
+
cur = deps[0].currency or "USD"
|
|
79
|
+
orders.append(OrderGroup(group_id=gdt, currency=cur))
|
|
80
|
+
deps_map = dict(deposits_by_gdt)
|
|
81
|
+
for g in orders:
|
|
82
|
+
if g.group_id in deps_map:
|
|
83
|
+
g.cash_deposits = deps_map[g.group_id]
|
|
84
|
+
|
|
85
|
+
def _sort_key(g: OrderGroup):
|
|
86
|
+
ts = _parse_timestamp(g.group_id)
|
|
87
|
+
return ts if ts is not None else float("inf")
|
|
88
|
+
orders.sort(key=_sort_key)
|
|
89
|
+
for i, g in enumerate(orders, 1):
|
|
90
|
+
g.group_id = str(i)
|
|
91
|
+
for g in orders:
|
|
92
|
+
if g.group_id in memos:
|
|
93
|
+
g.memo = memos[g.group_id]
|
|
94
|
+
return orders
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def fetch_ticker_info(tickers: list[str]) -> dict[str, dict]:
|
|
98
|
+
"""Insighta /tickers/info API로 sector/industry/type 조회."""
|
|
99
|
+
if not tickers:
|
|
100
|
+
return {}
|
|
101
|
+
resp = requests.get(
|
|
102
|
+
"https://api.insighta.cloud/tickers/info",
|
|
103
|
+
params={"tickers": ",".join(tickers), "conditions": "sector,industry,type"},
|
|
104
|
+
timeout=10,
|
|
105
|
+
)
|
|
106
|
+
resp.raise_for_status()
|
|
107
|
+
return resp.json()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load_rate_file(filepath: str) -> list[RateEntry]:
|
|
111
|
+
"""為替レートCSVを読み込む。
|
|
112
|
+
|
|
113
|
+
CSV format:
|
|
114
|
+
from,to,pair,rate
|
|
115
|
+
2024/01/01,2024/12/31,USD/JPY,155.50
|
|
116
|
+
"""
|
|
117
|
+
entries: list[RateEntry] = []
|
|
118
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
119
|
+
for row in csv.DictReader(f):
|
|
120
|
+
entries.append(RateEntry(
|
|
121
|
+
start=row["from"].strip(),
|
|
122
|
+
end=row["to"].strip(),
|
|
123
|
+
pair=row["pair"].strip(),
|
|
124
|
+
rate=Decimal(row["rate"].strip()),
|
|
125
|
+
))
|
|
126
|
+
return entries
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _normalize_dt(val: str) -> str:
|
|
130
|
+
return val if " " in val else f"{val} 00:00"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _normalize_dt_end(val: str) -> str:
|
|
134
|
+
return val if " " in val else f"{val} 23:59"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def lookup_rate(entries: list[RateEntry], dt: str, cur: str, base: str) -> Decimal | None:
|
|
138
|
+
"""決済通貨と基準通貨が異なる場合のみ該当期間のレートを返す。"""
|
|
139
|
+
if cur == base:
|
|
140
|
+
return None
|
|
141
|
+
trade_dt = dt[:16].replace("-", "/").replace("T", " ") if dt else ""
|
|
142
|
+
pair = f"{base}/{cur}"
|
|
143
|
+
for e in entries:
|
|
144
|
+
start = _normalize_dt(e.start)
|
|
145
|
+
end = _normalize_dt_end(e.end)
|
|
146
|
+
if e.pair == pair and start <= trade_dt <= end:
|
|
147
|
+
return e.rate
|
|
148
|
+
return None
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "insighta-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Insighta Cloud SDK - API client and data models"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "CC-BY-NC-4.0"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "insighta cloud Inc." }]
|
|
13
|
+
keywords = ["insighta", "portfolio", "investment", "sdk", "api-client"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: Other/Proprietary License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"requests>=2.28.0",
|
|
27
|
+
"pyyaml>=6.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://insighta.cloud"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["insighta_sdk"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Tests for insighta_sdk.client (InsightaClient)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from insighta_sdk.client import InsightaClient
|
|
11
|
+
from insighta_sdk.models import CashDeposit, Credentials, OrderGroup, UploadConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def creds():
|
|
16
|
+
return Credentials(api_key="sk-test-12345678", endpoint="https://api.example.com")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def client(creds, tmp_path):
|
|
21
|
+
return InsightaClient(creds, output_dir=str(tmp_path))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestInit:
|
|
25
|
+
def test_headers(self, client):
|
|
26
|
+
assert client.headers["Authorization"] == "Bearer sk-test-12345678"
|
|
27
|
+
assert client.headers["Content-Type"] == "application/json"
|
|
28
|
+
|
|
29
|
+
def test_endpoint(self, client):
|
|
30
|
+
assert client.endpoint == "https://api.example.com"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestRequest:
|
|
34
|
+
@patch("insighta_sdk.client.requests.request")
|
|
35
|
+
def test_get(self, mock_req, client):
|
|
36
|
+
mock_resp = MagicMock()
|
|
37
|
+
mock_resp.status_code = 200
|
|
38
|
+
mock_resp.text = '[]'
|
|
39
|
+
mock_resp.json.return_value = []
|
|
40
|
+
mock_req.return_value = mock_resp
|
|
41
|
+
|
|
42
|
+
resp = client._request("GET", "/portfolios")
|
|
43
|
+
mock_req.assert_called_once_with(
|
|
44
|
+
"GET", "https://api.example.com/portfolios",
|
|
45
|
+
headers=client.headers, timeout=30,
|
|
46
|
+
)
|
|
47
|
+
assert resp.json() == []
|
|
48
|
+
|
|
49
|
+
@patch("insighta_sdk.client.requests.request")
|
|
50
|
+
def test_post_logs_payload(self, mock_req, client, tmp_path):
|
|
51
|
+
mock_resp = MagicMock()
|
|
52
|
+
mock_resp.status_code = 200
|
|
53
|
+
mock_resp.text = '{"id": "123"}'
|
|
54
|
+
mock_resp.json.return_value = {"id": "123"}
|
|
55
|
+
mock_req.return_value = mock_resp
|
|
56
|
+
|
|
57
|
+
client._request("POST", "/test", json={"key": "value"})
|
|
58
|
+
log_path = os.path.join(str(tmp_path), "request_payload.log")
|
|
59
|
+
assert os.path.exists(log_path)
|
|
60
|
+
content = open(log_path).read()
|
|
61
|
+
assert '"key": "value"' in content
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestCreatePortfolio:
|
|
65
|
+
@patch("insighta_sdk.client.requests.request")
|
|
66
|
+
def test_create(self, mock_req, client):
|
|
67
|
+
mock_resp = MagicMock()
|
|
68
|
+
mock_resp.status_code = 201
|
|
69
|
+
mock_resp.text = '{"portfolio_id": "pf-123"}'
|
|
70
|
+
mock_resp.json.return_value = {"portfolio_id": "pf-123"}
|
|
71
|
+
mock_req.return_value = mock_resp
|
|
72
|
+
|
|
73
|
+
config = UploadConfig(
|
|
74
|
+
name="Test", description="", portfolio_type="record",
|
|
75
|
+
currency="USD", budget=Decimal("10000"), balance=Decimal("10000"),
|
|
76
|
+
order_file="order.csv", items=[{"ticker": "SPY", "type": "stock", "quantity": 10, "ratio": 1.0, "price": 0, "sector": "N/A", "industry": "N/A"}],
|
|
77
|
+
)
|
|
78
|
+
pid = client.create_portfolio(config)
|
|
79
|
+
assert pid == "pf-123"
|
|
80
|
+
call_kwargs = mock_req.call_args[1]
|
|
81
|
+
body = call_kwargs["json"]
|
|
82
|
+
assert body["name"] == "Test"
|
|
83
|
+
assert body["budget"] == 10000.0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestGetPortfolios:
|
|
87
|
+
@patch("insighta_sdk.client.requests.request")
|
|
88
|
+
def test_get(self, mock_req, client):
|
|
89
|
+
mock_resp = MagicMock()
|
|
90
|
+
mock_resp.status_code = 200
|
|
91
|
+
mock_resp.text = '[{"portfolio_id": "pf-1", "name": "A"}]'
|
|
92
|
+
mock_resp.json.return_value = [{"portfolio_id": "pf-1", "name": "A"}]
|
|
93
|
+
mock_req.return_value = mock_resp
|
|
94
|
+
|
|
95
|
+
result = client.get_portfolios()
|
|
96
|
+
assert len(result) == 1
|
|
97
|
+
assert result[0]["name"] == "A"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TestSearchPortfolios:
|
|
101
|
+
@patch("insighta_sdk.client.requests.request")
|
|
102
|
+
def test_search(self, mock_req, client):
|
|
103
|
+
mock_resp = MagicMock()
|
|
104
|
+
mock_resp.json.return_value = {"items": []}
|
|
105
|
+
mock_resp.status_code = 200
|
|
106
|
+
mock_resp.text = '{}'
|
|
107
|
+
mock_req.return_value = mock_resp
|
|
108
|
+
|
|
109
|
+
result = client.search_portfolios(search="tech", country="US")
|
|
110
|
+
call_kwargs = mock_req.call_args[1]
|
|
111
|
+
assert call_kwargs["params"]["search"] == "tech"
|
|
112
|
+
assert call_kwargs["params"]["country"] == "US"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TestDeletePortfolio:
|
|
116
|
+
@patch("insighta_sdk.client.requests.request")
|
|
117
|
+
def test_delete(self, mock_req, client):
|
|
118
|
+
mock_resp = MagicMock()
|
|
119
|
+
mock_resp.status_code = 204
|
|
120
|
+
mock_resp.text = ''
|
|
121
|
+
mock_req.return_value = mock_resp
|
|
122
|
+
|
|
123
|
+
client.delete_portfolio("pf-123")
|
|
124
|
+
mock_req.assert_called_once()
|
|
125
|
+
assert "/portfolios/pf-123" in mock_req.call_args[0][1]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TestSendOrder:
|
|
129
|
+
@patch("insighta_sdk.client.requests.request")
|
|
130
|
+
def test_basic_order(self, mock_req, client):
|
|
131
|
+
mock_resp = MagicMock()
|
|
132
|
+
mock_resp.status_code = 200
|
|
133
|
+
mock_resp.text = '{"order_id": "o-1"}'
|
|
134
|
+
mock_resp.json.return_value = {"order_id": "o-1"}
|
|
135
|
+
mock_req.return_value = mock_resp
|
|
136
|
+
|
|
137
|
+
group = OrderGroup(
|
|
138
|
+
group_id="1", currency="USD",
|
|
139
|
+
items=[{"ticker": "AAPL", "quantity": 5, "price": 150.0}],
|
|
140
|
+
)
|
|
141
|
+
result = client.send_order("pf-123", group, "USD")
|
|
142
|
+
assert result["order_id"] == "o-1"
|
|
143
|
+
body = mock_req.call_args[1]["json"]
|
|
144
|
+
assert body["portfolio_id"] == "pf-123"
|
|
145
|
+
assert body["payment_currency"] == "USD"
|
|
146
|
+
|
|
147
|
+
@patch("insighta_sdk.client.requests.request")
|
|
148
|
+
def test_order_with_deposits_and_rate(self, mock_req, client):
|
|
149
|
+
mock_resp = MagicMock()
|
|
150
|
+
mock_resp.status_code = 200
|
|
151
|
+
mock_resp.text = '{}'
|
|
152
|
+
mock_resp.json.return_value = {}
|
|
153
|
+
mock_req.return_value = mock_resp
|
|
154
|
+
|
|
155
|
+
group = OrderGroup(
|
|
156
|
+
group_id="1", currency="JPY",
|
|
157
|
+
items=[{"ticker": "AAPL", "quantity": 5, "price": 150.0}],
|
|
158
|
+
cash_deposits=[CashDeposit(type="budget", amount=100000.0, currency="JPY")],
|
|
159
|
+
exchange_rate=155.0,
|
|
160
|
+
memo="test memo",
|
|
161
|
+
)
|
|
162
|
+
client.send_order("pf-123", group, "USD")
|
|
163
|
+
body = mock_req.call_args[1]["json"]
|
|
164
|
+
assert body["custom_exchange_rate"] == 155.0
|
|
165
|
+
assert body["is_custom_exchange_rate"] is True
|
|
166
|
+
assert body["memo"] == "test memo"
|
|
167
|
+
assert len(body["cash_deposits"]) == 1
|
|
168
|
+
assert body["cash_deposits"][0]["amount"] == 100000.0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class TestGetNavHistory:
|
|
172
|
+
@patch("insighta_sdk.client.requests.request")
|
|
173
|
+
def test_nav(self, mock_req, client):
|
|
174
|
+
mock_resp = MagicMock()
|
|
175
|
+
mock_resp.json.return_value = {"history": [{"date": "2024-01-01", "nav": 10000}]}
|
|
176
|
+
mock_resp.status_code = 200
|
|
177
|
+
mock_resp.text = '{}'
|
|
178
|
+
mock_req.return_value = mock_resp
|
|
179
|
+
|
|
180
|
+
result = client.get_nav_history("pf-123")
|
|
181
|
+
assert "history" in result
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class TestGetMetricsHistory:
|
|
185
|
+
@patch("insighta_sdk.client.requests.request")
|
|
186
|
+
def test_metrics(self, mock_req, client):
|
|
187
|
+
mock_resp = MagicMock()
|
|
188
|
+
mock_resp.json.return_value = {"history": []}
|
|
189
|
+
mock_resp.status_code = 200
|
|
190
|
+
mock_resp.text = '{}'
|
|
191
|
+
mock_req.return_value = mock_resp
|
|
192
|
+
|
|
193
|
+
result = client.get_metrics_history("pf-123", metrics="twr", from_t=1000, to_t=2000)
|
|
194
|
+
params = mock_req.call_args[1]["params"]
|
|
195
|
+
assert params["metrics"] == "twr"
|
|
196
|
+
assert params["from_t"] == "1000"
|
|
197
|
+
assert params["to_t"] == "2000"
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Tests for insighta_sdk.models."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from insighta_sdk.models import (
|
|
11
|
+
Credentials,
|
|
12
|
+
Deposit,
|
|
13
|
+
Dirs,
|
|
14
|
+
Holding,
|
|
15
|
+
RateEntry,
|
|
16
|
+
Trade,
|
|
17
|
+
UploadConfig,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestDirs:
|
|
22
|
+
def test_from_work_empty(self):
|
|
23
|
+
d = Dirs.from_work("")
|
|
24
|
+
assert d._base == ""
|
|
25
|
+
assert d.input == "input"
|
|
26
|
+
assert d.output == "output"
|
|
27
|
+
|
|
28
|
+
def test_from_work_named(self):
|
|
29
|
+
d = Dirs.from_work("my-portfolio")
|
|
30
|
+
assert d._base == "workspaces/my-portfolio"
|
|
31
|
+
assert d.input == "workspaces/my-portfolio/input"
|
|
32
|
+
assert d.output == "workspaces/my-portfolio/output"
|
|
33
|
+
assert d.history_csv == "workspaces/my-portfolio/output/history.csv"
|
|
34
|
+
assert d.rate_csv == "workspaces/my-portfolio/input/rate.csv"
|
|
35
|
+
|
|
36
|
+
def test_ensure_output(self, tmp_path):
|
|
37
|
+
d = Dirs(work="")
|
|
38
|
+
d_output = str(tmp_path / "out")
|
|
39
|
+
# monkey-patch output property
|
|
40
|
+
Dirs.output = property(lambda self: d_output)
|
|
41
|
+
d.ensure_output()
|
|
42
|
+
assert os.path.isdir(d_output)
|
|
43
|
+
# restore
|
|
44
|
+
Dirs.output = property(lambda self: os.path.join(self._base, "output") if self._base else "output")
|
|
45
|
+
|
|
46
|
+
def test_all_paths(self):
|
|
47
|
+
d = Dirs.from_work("test")
|
|
48
|
+
assert "seed" in d.seed
|
|
49
|
+
assert "deposit" in d.deposit
|
|
50
|
+
assert "manual" in d.manual
|
|
51
|
+
assert "order.csv" in d.order_csv
|
|
52
|
+
assert "upload.yaml" in d.upload_yaml
|
|
53
|
+
assert "memo.csv" in d.memo_csv
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TestCredentials:
|
|
57
|
+
def test_from_file(self, tmp_path):
|
|
58
|
+
cred_file = tmp_path / "creds.yaml"
|
|
59
|
+
cred_file.write_text(yaml.dump({"api_key": "sk-test-12345678", "endpoint": "https://api.example.com/"}))
|
|
60
|
+
c = Credentials.from_file(str(cred_file))
|
|
61
|
+
assert c.api_key == "sk-test-12345678"
|
|
62
|
+
assert c.endpoint == "https://api.example.com" # trailing slash stripped
|
|
63
|
+
|
|
64
|
+
def test_masked_key_long(self):
|
|
65
|
+
c = Credentials(api_key="sk-test-12345678", endpoint="")
|
|
66
|
+
assert c.masked_key == "sk-t****5678"
|
|
67
|
+
|
|
68
|
+
def test_masked_key_short(self):
|
|
69
|
+
c = Credentials(api_key="short", endpoint="")
|
|
70
|
+
assert c.masked_key == "****"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestUploadConfig:
|
|
74
|
+
def test_from_file(self, tmp_path):
|
|
75
|
+
config = {
|
|
76
|
+
"portfolio": {
|
|
77
|
+
"name": "Test",
|
|
78
|
+
"description": "desc",
|
|
79
|
+
"type": "record",
|
|
80
|
+
"currency": "USD",
|
|
81
|
+
"budget": 10000,
|
|
82
|
+
"target_return": 0.1,
|
|
83
|
+
"start_date": "2024-01-01",
|
|
84
|
+
"target_date": "2034-01-01",
|
|
85
|
+
"items": [{"ticker": "SPY", "ratio": 1.0}],
|
|
86
|
+
},
|
|
87
|
+
"files": {"order": "order.csv", "cash_deposits": "deposits.csv"},
|
|
88
|
+
}
|
|
89
|
+
f = tmp_path / "upload.yaml"
|
|
90
|
+
f.write_text(yaml.dump(config))
|
|
91
|
+
uc = UploadConfig.from_file(str(f))
|
|
92
|
+
assert uc.name == "Test"
|
|
93
|
+
assert uc.budget == Decimal("10000")
|
|
94
|
+
assert uc.order_file == "order.csv"
|
|
95
|
+
assert uc.cash_deposits_file == "deposits.csv"
|
|
96
|
+
assert uc.items == [{"ticker": "SPY", "ratio": 1.0}]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TestTrade:
|
|
100
|
+
def test_creation(self):
|
|
101
|
+
t = Trade(dt="2024-01-01T10:00:00+09:00", ticker="AAPL", qty=10,
|
|
102
|
+
acct="TT", price=Decimal("150.5"), avg=Decimal("150.5"), cur="USD")
|
|
103
|
+
assert t.base == "USD"
|
|
104
|
+
assert t.qty == 10
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class TestHolding:
|
|
108
|
+
def test_defaults(self):
|
|
109
|
+
h = Holding(ticker="MSFT", acct="NISA", qty=5)
|
|
110
|
+
assert h.cost == Decimal("0")
|
|
111
|
+
assert h.pnl == Decimal("0")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TestDeposit:
|
|
115
|
+
def test_creation(self):
|
|
116
|
+
d = Deposit(dt="2024-01-01", amount=Decimal("1000"), cur="JPY", type="budget")
|
|
117
|
+
assert d.ticker == ""
|
|
118
|
+
assert d.rate is None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class TestRateEntry:
|
|
122
|
+
def test_creation(self):
|
|
123
|
+
r = RateEntry(start="2024/01/01", end="2024/12/31", pair="USD/JPY", rate=Decimal("155.5"))
|
|
124
|
+
assert r.pair == "USD/JPY"
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Tests for insighta_sdk.utils."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import os
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from insighta_sdk.models import CashDeposit, OrderGroup, RateEntry
|
|
10
|
+
from insighta_sdk.utils import (
|
|
11
|
+
_parse_timestamp,
|
|
12
|
+
load_cash_deposits,
|
|
13
|
+
load_order_groups,
|
|
14
|
+
load_rate_file,
|
|
15
|
+
lookup_rate,
|
|
16
|
+
merge_and_sort_groups,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestParseTimestamp:
|
|
21
|
+
def test_integer_string(self):
|
|
22
|
+
assert _parse_timestamp("1700000000000") == 1700000000000
|
|
23
|
+
|
|
24
|
+
def test_datetime_string(self):
|
|
25
|
+
ts = _parse_timestamp("2024-01-01 00:00:00")
|
|
26
|
+
assert ts == 1704067200000
|
|
27
|
+
|
|
28
|
+
def test_empty(self):
|
|
29
|
+
assert _parse_timestamp("") is None
|
|
30
|
+
|
|
31
|
+
def test_invalid(self):
|
|
32
|
+
assert _parse_timestamp("not-a-date") is None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestLoadRateFile:
|
|
36
|
+
def test_load(self, tmp_path):
|
|
37
|
+
f = tmp_path / "rate.csv"
|
|
38
|
+
f.write_text("from,to,pair,rate\n2024/01/01,2024/06/30,USD/JPY,150.00\n2024/07/01,2024/12/31,USD/JPY,155.50\n")
|
|
39
|
+
entries = load_rate_file(str(f))
|
|
40
|
+
assert len(entries) == 2
|
|
41
|
+
assert entries[0].pair == "USD/JPY"
|
|
42
|
+
assert entries[0].rate == Decimal("150.00")
|
|
43
|
+
assert entries[1].rate == Decimal("155.50")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestLookupRate:
|
|
47
|
+
@pytest.fixture
|
|
48
|
+
def rates(self):
|
|
49
|
+
return [
|
|
50
|
+
RateEntry(start="2024/01/01", end="2024/06/30", pair="USD/JPY", rate=Decimal("150")),
|
|
51
|
+
RateEntry(start="2024/07/01", end="2024/12/31", pair="USD/JPY", rate=Decimal("155")),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
def test_match_first_period(self, rates):
|
|
55
|
+
r = lookup_rate(rates, "2024-03-15T10:00:00+09:00", "JPY", "USD")
|
|
56
|
+
assert r == Decimal("150")
|
|
57
|
+
|
|
58
|
+
def test_match_second_period(self, rates):
|
|
59
|
+
r = lookup_rate(rates, "2024-08-01T10:00:00+09:00", "JPY", "USD")
|
|
60
|
+
assert r == Decimal("155")
|
|
61
|
+
|
|
62
|
+
def test_same_currency_returns_none(self, rates):
|
|
63
|
+
r = lookup_rate(rates, "2024-03-15T10:00:00+09:00", "USD", "USD")
|
|
64
|
+
assert r is None
|
|
65
|
+
|
|
66
|
+
def test_no_match_returns_none(self, rates):
|
|
67
|
+
r = lookup_rate(rates, "2025-01-01T10:00:00+09:00", "JPY", "USD")
|
|
68
|
+
assert r is None
|
|
69
|
+
|
|
70
|
+
def test_date_only_format(self, rates):
|
|
71
|
+
r = lookup_rate(rates, "2024-01-15", "JPY", "USD")
|
|
72
|
+
assert r == Decimal("150")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TestLoadOrderGroups:
|
|
76
|
+
def test_load(self, tmp_path):
|
|
77
|
+
f = tmp_path / "order.csv"
|
|
78
|
+
f.write_text(
|
|
79
|
+
"group_dt,ticker,quantity,price,currency,settle_currency,rate,price_type,timestamp\n"
|
|
80
|
+
"2024-01-01 10:00:00,AAPL,5,150.0,USD,USD,,LIMIT,2024-01-01 01:00:00\n"
|
|
81
|
+
"2024-01-01 10:00:00,MSFT,3,300.0,USD,USD,,LIMIT,2024-01-01 01:00:00\n"
|
|
82
|
+
"2024-01-02 10:00:00,GOOG,2,140.0,USD,JPY,155.0,LIMIT,2024-01-02 01:00:00\n"
|
|
83
|
+
)
|
|
84
|
+
groups = load_order_groups(str(f))
|
|
85
|
+
assert len(groups) == 2
|
|
86
|
+
assert groups[0].group_id == "2024-01-01 10:00:00"
|
|
87
|
+
assert len(groups[0].items) == 2
|
|
88
|
+
assert groups[0].items[0]["ticker"] == "AAPL"
|
|
89
|
+
assert groups[1].exchange_rate == 155.0
|
|
90
|
+
|
|
91
|
+
def test_empty_file(self, tmp_path):
|
|
92
|
+
f = tmp_path / "order.csv"
|
|
93
|
+
f.write_text("group_dt,ticker,quantity,price,currency,settle_currency,rate,price_type,timestamp\n")
|
|
94
|
+
groups = load_order_groups(str(f))
|
|
95
|
+
assert groups == []
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestLoadCashDeposits:
|
|
99
|
+
def test_load(self, tmp_path):
|
|
100
|
+
f = tmp_path / "deposits.csv"
|
|
101
|
+
f.write_text(
|
|
102
|
+
"group_dt,type,amount,currency,ticker,timestamp\n"
|
|
103
|
+
"2024-01-01 00:00:00,budget,5000.0,USD,,2024-01-01 00:00:00\n"
|
|
104
|
+
"2024-01-01 00:00:00,dividend,50.0,USD,AAPL,2024-01-01 00:00:00\n"
|
|
105
|
+
"2024-01-02 00:00:00,budget,3000.0,JPY,,2024-01-02 00:00:00\n"
|
|
106
|
+
)
|
|
107
|
+
groups = load_cash_deposits(str(f))
|
|
108
|
+
assert len(groups) == 2
|
|
109
|
+
assert len(groups["2024-01-01 00:00:00"]) == 2
|
|
110
|
+
assert groups["2024-01-01 00:00:00"][0].type == "budget"
|
|
111
|
+
assert groups["2024-01-01 00:00:00"][1].ticker == "AAPL"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TestMergeAndSortGroups:
|
|
115
|
+
def test_merge_basic(self):
|
|
116
|
+
orders = [
|
|
117
|
+
OrderGroup(group_id="1704067200000", currency="USD", items=[{"ticker": "AAPL"}]),
|
|
118
|
+
]
|
|
119
|
+
deposits = {
|
|
120
|
+
"1704067200000": [CashDeposit(type="budget", amount=5000.0, currency="USD")],
|
|
121
|
+
"1704153600000": [CashDeposit(type="budget", amount=3000.0, currency="JPY")],
|
|
122
|
+
}
|
|
123
|
+
result = merge_and_sort_groups(orders, deposits, {"1": "first memo"})
|
|
124
|
+
assert len(result) == 2
|
|
125
|
+
# 정렬 후 순번 재배정
|
|
126
|
+
assert result[0].group_id == "1"
|
|
127
|
+
assert result[1].group_id == "2"
|
|
128
|
+
# deposit 붙었는지
|
|
129
|
+
assert len(result[0].cash_deposits) == 1
|
|
130
|
+
# memo 적용
|
|
131
|
+
assert result[0].memo == "first memo"
|
|
132
|
+
|
|
133
|
+
def test_empty(self):
|
|
134
|
+
result = merge_and_sort_groups([], {}, {})
|
|
135
|
+
assert result == []
|