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.
@@ -0,0 +1,14 @@
1
+ # Workspaces (input/output per portfolio)
2
+ workspaces/*
3
+
4
+ credentials.yaml
5
+ !templates/credentials.yaml
6
+ .insighta.yaml
7
+
8
+ __pycache__/
9
+ *.pyc
10
+ *.egg-info/
11
+
12
+ .venv/
13
+ .cache/
14
+ .gitkeep
@@ -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 == []