alpha-strike 0.3.0__py3-none-any.whl
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.
- alpha_strike/__init__.py +10 -0
- alpha_strike/cli.py +68 -0
- alpha_strike/event_logger.py +74 -0
- alpha_strike/handlers/__init__.py +5 -0
- alpha_strike/handlers/base.py +22 -0
- alpha_strike/handlers/moomoo_handler.py +195 -0
- alpha_strike/handlers/oanda_handler.py +122 -0
- alpha_strike/models.py +186 -0
- alpha_strike/services/__init__.py +0 -0
- alpha_strike/services/fill_service.py +274 -0
- alpha_strike/services/order_service.py +37 -0
- alpha_strike/webhook_server.py +371 -0
- alpha_strike-0.3.0.dist-info/METADATA +221 -0
- alpha_strike-0.3.0.dist-info/RECORD +18 -0
- alpha_strike-0.3.0.dist-info/WHEEL +4 -0
- alpha_strike-0.3.0.dist-info/entry_points.txt +2 -0
- alpha_strike-0.3.0.dist-info/licenses/LICENSE +200 -0
- alpha_strike-0.3.0.dist-info/licenses/NOTICE +9 -0
alpha_strike/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""alpha-strike — TradingView Webhook を moomoo / OANDA へルーティングする FastAPI サーバー."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("alpha-strike")
|
|
7
|
+
except PackageNotFoundError: # editable install / source 直接実行時
|
|
8
|
+
__version__ = "0.0.0+local"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
alpha_strike/cli.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""alpha-strike CLI エントリポイント.
|
|
2
|
+
|
|
3
|
+
PyPI からインストール後、以下のコマンドで Webhook サーバーを起動できる:
|
|
4
|
+
|
|
5
|
+
alpha-strike # 既定: 0.0.0.0:8080
|
|
6
|
+
alpha-strike --host 127.0.0.1 # ホスト指定
|
|
7
|
+
alpha-strike --port 9000 # ポート指定
|
|
8
|
+
alpha-strike --version # バージョン表示
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
import uvicorn
|
|
18
|
+
|
|
19
|
+
from alpha_strike import __version__
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
23
|
+
parser = argparse.ArgumentParser(
|
|
24
|
+
prog="alpha-strike",
|
|
25
|
+
description=(
|
|
26
|
+
"alpha-strike — TradingView Webhook を moomoo / OANDA へ"
|
|
27
|
+
"ルーティングする FastAPI サーバー"
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--host",
|
|
32
|
+
default=os.getenv("ALPHA_STRIKE_HOST", "0.0.0.0"),
|
|
33
|
+
help="バインドするホスト (既定: 0.0.0.0、環境変数 ALPHA_STRIKE_HOST でも上書き可)",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--port",
|
|
37
|
+
type=int,
|
|
38
|
+
default=int(os.getenv("ALPHA_STRIKE_PORT", "8080")),
|
|
39
|
+
help="バインドするポート (既定: 8080、環境変数 ALPHA_STRIKE_PORT でも上書き可)",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--reload",
|
|
43
|
+
action="store_true",
|
|
44
|
+
help="開発時のホットリロードを有効化",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--version",
|
|
48
|
+
action="version",
|
|
49
|
+
version=f"%(prog)s {__version__}",
|
|
50
|
+
)
|
|
51
|
+
return parser
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main(argv: list[str] | None = None) -> int:
|
|
55
|
+
"""CLI エントリポイント. uvicorn でサーバーを起動する。"""
|
|
56
|
+
parser = _build_parser()
|
|
57
|
+
args = parser.parse_args(argv)
|
|
58
|
+
uvicorn.run(
|
|
59
|
+
"alpha_strike.webhook_server:app",
|
|
60
|
+
host=args.host,
|
|
61
|
+
port=args.port,
|
|
62
|
+
reload=args.reload,
|
|
63
|
+
)
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__": # pragma: no cover - python -m alpha_strike 実行時
|
|
68
|
+
sys.exit(main())
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""JSON Lines 形式で live trading イベントを保存する。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JsonlEventLogger:
|
|
17
|
+
"""JSONL ファイルにイベントを書き出す軽量ロガー。"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, base_path: str | Path | None = None) -> None:
|
|
20
|
+
self.base_path = Path(base_path) if base_path is not None else None
|
|
21
|
+
|
|
22
|
+
def _resolve_base_path(self) -> Path:
|
|
23
|
+
env_path = os.getenv("LIVE_EVENTS_PATH")
|
|
24
|
+
return self.base_path or Path(env_path or "./data/live/events")
|
|
25
|
+
|
|
26
|
+
def append(self, event: BaseModel) -> None:
|
|
27
|
+
"""イベントを日次・ブローカー別 JSONL に追記する。失敗時は警告ログのみ残す。"""
|
|
28
|
+
try:
|
|
29
|
+
occurred_at = getattr(event, "occurred_at", datetime.now())
|
|
30
|
+
broker = getattr(event, "broker", "unknown")
|
|
31
|
+
day = occurred_at.strftime("%Y-%m-%d")
|
|
32
|
+
path = self._resolve_base_path() / f"{day}.{broker}.jsonl"
|
|
33
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
with path.open("a", encoding="utf-8") as fp:
|
|
35
|
+
fp.write(event.model_dump_json())
|
|
36
|
+
fp.write("\n")
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
logger.warning("イベントログ保存に失敗しました: %s", exc)
|
|
39
|
+
|
|
40
|
+
def load_events(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
broker: str | None = None,
|
|
44
|
+
event_type: str | None = None,
|
|
45
|
+
ticker: str | None = None,
|
|
46
|
+
strategy_id: str | None = None,
|
|
47
|
+
limit: int | None = 200,
|
|
48
|
+
) -> list[dict]:
|
|
49
|
+
"""保存済み JSONL event を新しい順で読む。"""
|
|
50
|
+
events: list[dict] = []
|
|
51
|
+
try:
|
|
52
|
+
base_path = self._resolve_base_path()
|
|
53
|
+
if not base_path.exists():
|
|
54
|
+
return []
|
|
55
|
+
files = sorted(base_path.glob("*.jsonl"), reverse=True)
|
|
56
|
+
for path in files:
|
|
57
|
+
if broker and not path.name.endswith(f".{broker}.jsonl"):
|
|
58
|
+
continue
|
|
59
|
+
for line in reversed(path.read_text(encoding="utf-8").splitlines()):
|
|
60
|
+
if not line.strip():
|
|
61
|
+
continue
|
|
62
|
+
payload = json.loads(line)
|
|
63
|
+
if event_type and payload.get("event_type") != event_type:
|
|
64
|
+
continue
|
|
65
|
+
if ticker and payload.get("ticker") != ticker:
|
|
66
|
+
continue
|
|
67
|
+
if strategy_id and payload.get("strategy_id") != strategy_id:
|
|
68
|
+
continue
|
|
69
|
+
events.append(payload)
|
|
70
|
+
if limit is not None and len(events) >= limit:
|
|
71
|
+
return events
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
logger.warning("イベントログ読込に失敗しました: %s", exc)
|
|
74
|
+
return events
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""ブローカーハンドラーの抽象インターフェース"""
|
|
2
|
+
from typing import Protocol
|
|
3
|
+
|
|
4
|
+
from alpha_strike.models import WebhookPayload
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BrokerHandler(Protocol):
|
|
8
|
+
"""ブローカー注文ハンドラーの共通インターフェース。
|
|
9
|
+
|
|
10
|
+
新しいブローカーを追加するには、このProtocolを満たすクラスを
|
|
11
|
+
`handlers/` に追加し `build_default_router()` に登録するだけでよい。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def execute(self, payload: WebhookPayload) -> dict:
|
|
15
|
+
"""注文を実行し、結果dictを返す。
|
|
16
|
+
|
|
17
|
+
Raises:
|
|
18
|
+
ImportError: 必要なライブラリが未インストールの場合
|
|
19
|
+
ValueError: 環境変数が不足または不正な場合
|
|
20
|
+
RuntimeError: APIがエラーを返した場合
|
|
21
|
+
"""
|
|
22
|
+
...
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""moomoo証券(Futu OpenAPI)アダプター
|
|
2
|
+
|
|
3
|
+
ローカルで稼働するOpenDゲートウェイ経由で注文を実行します。
|
|
4
|
+
|
|
5
|
+
テスト時は MOOMOO_TRD_ENV=SIMULATE(デモ環境)を使用してください。
|
|
6
|
+
OpenD を先に起動してからサーバーを起動してください。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import socket
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
|
|
15
|
+
|
|
16
|
+
from alpha_strike.models import WebhookPayload
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
import futu
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import futu
|
|
25
|
+
|
|
26
|
+
FUTU_AVAILABLE = True
|
|
27
|
+
except ImportError:
|
|
28
|
+
FUTU_AVAILABLE = False
|
|
29
|
+
logger.warning(
|
|
30
|
+
"futu-api がインポートできません。moomoo注文は実行時に失敗します。OpenDが起動しているか確認してください。"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@retry(
|
|
35
|
+
retry=retry_if_exception_type((OSError, socket.timeout)),
|
|
36
|
+
wait=wait_fixed(2),
|
|
37
|
+
stop=stop_after_attempt(3),
|
|
38
|
+
reraise=True,
|
|
39
|
+
)
|
|
40
|
+
def _check_opend_connection(host: str, port: int) -> None:
|
|
41
|
+
"""OpenD への TCP 接続確認。一時障害に対して最大3回リトライする。"""
|
|
42
|
+
with socket.create_connection((host, port), timeout=3):
|
|
43
|
+
pass # 接続確認のみ。コンテキスト終了時に自動クローズ
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_MARKET_MAP: "dict[str, str]" = {
|
|
47
|
+
"HK": "HK",
|
|
48
|
+
"CRYPTO": "CRYPTO",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _get_trade_context(
|
|
53
|
+
asset_class: str,
|
|
54
|
+
host: str,
|
|
55
|
+
port: int,
|
|
56
|
+
) -> "futu.OpenSecTradeContext":
|
|
57
|
+
"""asset_class に基づいて統一トレードコンテキスト OpenSecTradeContext を返す。
|
|
58
|
+
|
|
59
|
+
futu/moomoo SDK 10.5.6508 以降は OpenUSTradeContext / OpenHKTradeContext が
|
|
60
|
+
廃止され、OpenSecTradeContext + filter_trdmarket に統一された。
|
|
61
|
+
|
|
62
|
+
対応:
|
|
63
|
+
- "HK" → filter_trdmarket=TrdMarket.HK
|
|
64
|
+
- "CRYPTO" → filter_trdmarket=TrdMarket.CRYPTO
|
|
65
|
+
- その他 → filter_trdmarket=TrdMarket.US (US / INDEX / COMMODITY / FX)
|
|
66
|
+
|
|
67
|
+
security_firm はすべての市場で SecurityFirm.NONE をデフォルト指定する。
|
|
68
|
+
REAL 取引で broker 固有の firm が必要な場合は呼び出し側で上書きする。
|
|
69
|
+
"""
|
|
70
|
+
ac = asset_class.upper()
|
|
71
|
+
market_name = _MARKET_MAP.get(ac, "US")
|
|
72
|
+
market = getattr(futu.TrdMarket, market_name)
|
|
73
|
+
return futu.OpenSecTradeContext(
|
|
74
|
+
filter_trdmarket=market,
|
|
75
|
+
host=host,
|
|
76
|
+
port=port,
|
|
77
|
+
security_firm=futu.SecurityFirm.NONE,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class MoomooHandler:
|
|
82
|
+
"""moomoo証券(Futu OpenAPI)への注文を実行するハンドラー。"""
|
|
83
|
+
|
|
84
|
+
def execute(self, payload: WebhookPayload) -> dict:
|
|
85
|
+
"""moomoo証券(Futu OpenAPI)へ注文を送信する。
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
ImportError: futu-api が利用不可の場合
|
|
89
|
+
ValueError: 必須設定が不足している場合
|
|
90
|
+
RuntimeError: 注文APIがエラーを返した場合
|
|
91
|
+
"""
|
|
92
|
+
if not FUTU_AVAILABLE:
|
|
93
|
+
raise ImportError(
|
|
94
|
+
"futu-api が利用できません。`uv add futu-api` でインストール後、OpenDを起動してください。"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
host = os.getenv("MOOMOO_HOST", "127.0.0.1")
|
|
98
|
+
try:
|
|
99
|
+
port = int(os.getenv("MOOMOO_PORT", "11111"))
|
|
100
|
+
except ValueError as e:
|
|
101
|
+
raise ValueError("MOOMOO_PORT に不正な値が設定されています") from e
|
|
102
|
+
trd_env_str = os.getenv("MOOMOO_TRD_ENV", "SIMULATE").upper()
|
|
103
|
+
|
|
104
|
+
# TrdEnv マッピング(デフォルトはSIMULATEで安全側)
|
|
105
|
+
trd_env_map = {
|
|
106
|
+
"SIMULATE": futu.TrdEnv.SIMULATE,
|
|
107
|
+
"REAL": futu.TrdEnv.REAL,
|
|
108
|
+
}
|
|
109
|
+
if trd_env_str not in trd_env_map:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"MOOMOO_TRD_ENV は SIMULATE または REAL を指定してください(現在: {trd_env_str})"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
trd_env = trd_env_map[trd_env_str]
|
|
115
|
+
trd_side = futu.TrdSide.BUY if payload.action == "buy" else futu.TrdSide.SELL
|
|
116
|
+
|
|
117
|
+
logger.info(
|
|
118
|
+
"moomoo注文開始: trd_env=%s ticker=%s action=%s qty=%s",
|
|
119
|
+
trd_env_str,
|
|
120
|
+
payload.ticker,
|
|
121
|
+
payload.action,
|
|
122
|
+
payload.quantity,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# OpenD への接続可否を事前確認(タイムアウト3秒、最大3回リトライ)
|
|
126
|
+
try:
|
|
127
|
+
_check_opend_connection(host, port)
|
|
128
|
+
except (OSError, socket.timeout) as e:
|
|
129
|
+
logger.error("OpenD に接続できません (%s:%s): %s", host, port, e)
|
|
130
|
+
raise RuntimeError(
|
|
131
|
+
f"OpenD ({host}:{port}) が起動していません。先にOpenDを起動してください。"
|
|
132
|
+
) from e
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
ctx = _get_trade_context(payload.asset_class, host, port)
|
|
136
|
+
with ctx:
|
|
137
|
+
ret_code, data = ctx.place_order(
|
|
138
|
+
price=0,
|
|
139
|
+
qty=payload.quantity,
|
|
140
|
+
code=payload.ticker,
|
|
141
|
+
trd_side=trd_side,
|
|
142
|
+
order_type=futu.OrderType.MARKET,
|
|
143
|
+
trd_env=trd_env,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if ret_code != futu.RET_OK:
|
|
147
|
+
logger.error(
|
|
148
|
+
"moomoo注文失敗: ticker=%s ret_code=%s data=%s",
|
|
149
|
+
payload.ticker,
|
|
150
|
+
ret_code,
|
|
151
|
+
data,
|
|
152
|
+
)
|
|
153
|
+
raise RuntimeError(f"moomoo注文エラー: {data}")
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
if (
|
|
157
|
+
hasattr(data, "empty")
|
|
158
|
+
and not data.empty
|
|
159
|
+
and "order_id" in data.columns
|
|
160
|
+
):
|
|
161
|
+
order_id = str(data["order_id"].iloc[0])
|
|
162
|
+
else:
|
|
163
|
+
order_id = str(data)
|
|
164
|
+
logger.warning(
|
|
165
|
+
"order_idの取得に失敗。レスポンス全体を使用: %s", data
|
|
166
|
+
)
|
|
167
|
+
except (AttributeError, KeyError, IndexError) as e:
|
|
168
|
+
logger.warning("order_idのパース失敗: %s。レスポンス全体を使用。", e)
|
|
169
|
+
order_id = str(data)
|
|
170
|
+
|
|
171
|
+
filled_qty = None
|
|
172
|
+
filled_price = None
|
|
173
|
+
if hasattr(data, "empty") and not data.empty:
|
|
174
|
+
if "dealt_qty" in data.columns:
|
|
175
|
+
filled_qty = float(data["dealt_qty"].iloc[0])
|
|
176
|
+
if "dealt_avg_price" in data.columns:
|
|
177
|
+
filled_price = float(data["dealt_avg_price"].iloc[0])
|
|
178
|
+
|
|
179
|
+
logger.info("moomoo注文成功: order_id=%s", order_id)
|
|
180
|
+
return {
|
|
181
|
+
"order_id": order_id,
|
|
182
|
+
"ret_code": ret_code,
|
|
183
|
+
"filled_qty": filled_qty,
|
|
184
|
+
"filled_price": filled_price,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
except Exception as e:
|
|
188
|
+
if isinstance(e, (ImportError, ValueError, RuntimeError)):
|
|
189
|
+
raise
|
|
190
|
+
logger.error(
|
|
191
|
+
"moomoo注文で予期しないエラー: ticker=%s error=%s",
|
|
192
|
+
payload.ticker,
|
|
193
|
+
e,
|
|
194
|
+
)
|
|
195
|
+
raise RuntimeError(f"moomoo注文失敗: {e}") from e
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""OANDA証券 REST API v20 を使用した注文ハンドラー"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
|
|
8
|
+
|
|
9
|
+
from alpha_strike.models import WebhookPayload
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
OANDA_PRACTICE_URL = "https://api-fxpractice.oanda.com"
|
|
14
|
+
OANDA_LIVE_URL = "https://api-fxtrade.oanda.com"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_retryable_oanda_error(exc: Exception) -> bool:
|
|
18
|
+
"""5xx エラーおよびネットワーク一時障害のみリトライ対象とする。4xx(設定ミス等)はリトライしない。"""
|
|
19
|
+
if isinstance(exc, requests.HTTPError):
|
|
20
|
+
return exc.response is not None and exc.response.status_code >= 500
|
|
21
|
+
return isinstance(exc, (requests.ConnectionError, requests.Timeout))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@retry(
|
|
25
|
+
retry=retry_if_exception(_is_retryable_oanda_error),
|
|
26
|
+
wait=wait_exponential(multiplier=1, min=1, max=10),
|
|
27
|
+
stop=stop_after_attempt(3),
|
|
28
|
+
reraise=True,
|
|
29
|
+
)
|
|
30
|
+
def _call_oanda_api(url: str, body: dict, headers: dict) -> dict:
|
|
31
|
+
"""OANDA REST API を呼び出す。一時障害(5xx、接続エラー)は最大3回リトライする。"""
|
|
32
|
+
response = requests.post(url, json=body, headers=headers, timeout=10)
|
|
33
|
+
response.raise_for_status()
|
|
34
|
+
return response.json()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _to_oanda_instrument(ticker: str, asset_class: str) -> str:
|
|
38
|
+
"""TradingViewのティッカーをOANDA instrument形式に変換する。
|
|
39
|
+
|
|
40
|
+
asset_class に応じた変換ルール:
|
|
41
|
+
- "FX" / "COMMODITY": 6文字の場合 "USDJPY" → "USD_JPY"
|
|
42
|
+
- "US" / "INDEX": アンダースコアなしの場合 "AAPL" → "AAPL_USD"
|
|
43
|
+
- その他: そのまま使用(OANDA形式で直接指定)
|
|
44
|
+
"""
|
|
45
|
+
if asset_class in ("FX", "COMMODITY") and len(ticker) == 6 and "_" not in ticker:
|
|
46
|
+
return f"{ticker[:3]}_{ticker[3:]}"
|
|
47
|
+
if asset_class in ("US", "INDEX") and "_" not in ticker:
|
|
48
|
+
return f"{ticker}_USD"
|
|
49
|
+
if asset_class in ("FX", "COMMODITY") and len(ticker) != 6:
|
|
50
|
+
logger.warning(
|
|
51
|
+
"FX/COMMODITYの ticker が6文字ではありません。変換せずに送信します: %s",
|
|
52
|
+
ticker,
|
|
53
|
+
)
|
|
54
|
+
return ticker
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class OandaHandler:
|
|
58
|
+
"""OANDA証券への注文を実行するハンドラー。"""
|
|
59
|
+
|
|
60
|
+
def execute(self, payload: WebhookPayload) -> dict:
|
|
61
|
+
"""OANDA証券に成行注文を送信する。
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
{"order_id": str, "instrument": str, ...}
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ValueError: 環境変数が不足または不正な場合
|
|
68
|
+
requests.RequestException: API呼び出しに失敗した場合
|
|
69
|
+
"""
|
|
70
|
+
api_key = os.getenv("OANDA_API_KEY", "")
|
|
71
|
+
account_id = os.getenv("OANDA_ACCOUNT_ID", "")
|
|
72
|
+
oanda_env = os.getenv("OANDA_ENV", "PRACTICE").upper()
|
|
73
|
+
|
|
74
|
+
if not api_key:
|
|
75
|
+
raise ValueError("環境変数 OANDA_API_KEY が設定されていません")
|
|
76
|
+
if not account_id:
|
|
77
|
+
raise ValueError("環境変数 OANDA_ACCOUNT_ID が設定されていません")
|
|
78
|
+
if oanda_env not in ("PRACTICE", "LIVE"):
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"OANDA_ENV は PRACTICE または LIVE である必要があります: {oanda_env!r}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
base_url = OANDA_PRACTICE_URL if oanda_env == "PRACTICE" else OANDA_LIVE_URL
|
|
84
|
+
instrument = _to_oanda_instrument(payload.ticker, payload.asset_class)
|
|
85
|
+
|
|
86
|
+
# SELL は負の units で表現する
|
|
87
|
+
units = payload.quantity if payload.action == "buy" else -payload.quantity
|
|
88
|
+
|
|
89
|
+
headers = {
|
|
90
|
+
"Authorization": f"Bearer {api_key}",
|
|
91
|
+
"Content-Type": "application/json",
|
|
92
|
+
}
|
|
93
|
+
body = {
|
|
94
|
+
"order": {
|
|
95
|
+
"type": "MARKET",
|
|
96
|
+
"instrument": instrument,
|
|
97
|
+
"units": str(units),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
url = f"{base_url}/v3/accounts/{account_id}/orders"
|
|
102
|
+
logger.info(
|
|
103
|
+
"OANDA注文送信: instrument=%s units=%s env=%s", instrument, units, oanda_env
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
data = _call_oanda_api(url, body, headers)
|
|
107
|
+
order_id = data.get("orderCreateTransaction", {}).get("id", "unknown")
|
|
108
|
+
fill_tx = data.get("orderFillTransaction", {})
|
|
109
|
+
filled_qty = abs(float(fill_tx["units"])) if fill_tx.get("units") is not None else None
|
|
110
|
+
filled_price = (
|
|
111
|
+
float(fill_tx["price"]) if fill_tx.get("price") is not None else None
|
|
112
|
+
)
|
|
113
|
+
fill_id = str(fill_tx["id"]) if fill_tx.get("id") is not None else None
|
|
114
|
+
|
|
115
|
+
logger.info("OANDA注文成功: order_id=%s instrument=%s", order_id, instrument)
|
|
116
|
+
return {
|
|
117
|
+
"order_id": order_id,
|
|
118
|
+
"instrument": instrument,
|
|
119
|
+
"fill_id": fill_id,
|
|
120
|
+
"filled_qty": filled_qty,
|
|
121
|
+
"filled_price": filled_price,
|
|
122
|
+
}
|
alpha_strike/models.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class WebhookPayload(BaseModel):
|
|
8
|
+
passphrase: str = Field(repr=False)
|
|
9
|
+
broker: Literal["oanda", "moomoo"]
|
|
10
|
+
asset_class: Literal["FX", "COMMODITY", "US", "HK", "INDEX", "CRYPTO"]
|
|
11
|
+
action: Literal["buy", "sell"]
|
|
12
|
+
ticker: str = Field(
|
|
13
|
+
pattern=r"^[A-Z0-9_.]{1,20}$",
|
|
14
|
+
description="ティッカーシンボル(英大文字・数字・ドット・アンダースコアのみ、20文字以内)",
|
|
15
|
+
)
|
|
16
|
+
quantity: float = Field(gt=0, description="注文数量(株数またはロット数)")
|
|
17
|
+
strategy_id: str | None = Field(
|
|
18
|
+
default=None,
|
|
19
|
+
pattern=r"^[a-zA-Z0-9_.-]{1,64}$",
|
|
20
|
+
description="alpha-forge 側の strategy_id",
|
|
21
|
+
)
|
|
22
|
+
strategy_version: str | None = Field(
|
|
23
|
+
default=None,
|
|
24
|
+
max_length=32,
|
|
25
|
+
description="戦略バージョン",
|
|
26
|
+
)
|
|
27
|
+
snapshot_id: str | None = Field(
|
|
28
|
+
default=None,
|
|
29
|
+
pattern=r"^snap_[0-9]{20}$",
|
|
30
|
+
description="alpha-forge journal snapshot_id",
|
|
31
|
+
)
|
|
32
|
+
signal_id: str | None = Field(
|
|
33
|
+
default=None,
|
|
34
|
+
max_length=80,
|
|
35
|
+
description="シグナル一意ID。未指定なら alpha-strike 側で採番",
|
|
36
|
+
)
|
|
37
|
+
timeframe: str | None = Field(
|
|
38
|
+
default=None,
|
|
39
|
+
max_length=16,
|
|
40
|
+
description="例: 1m, 5m, 1h, 1d",
|
|
41
|
+
)
|
|
42
|
+
alert_timestamp: datetime | None = Field(
|
|
43
|
+
default=None,
|
|
44
|
+
description="TradingView 側でシグナルが発火した時刻",
|
|
45
|
+
)
|
|
46
|
+
run_mode: Literal["paper", "live"] = Field(
|
|
47
|
+
default="live",
|
|
48
|
+
description="paper は模擬運用・ログ用途",
|
|
49
|
+
)
|
|
50
|
+
alert_name: str | None = Field(
|
|
51
|
+
default=None,
|
|
52
|
+
max_length=128,
|
|
53
|
+
description="TradingView アラート名",
|
|
54
|
+
)
|
|
55
|
+
order_comment: str | None = Field(
|
|
56
|
+
default=None,
|
|
57
|
+
max_length=256,
|
|
58
|
+
description="任意メモ",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class OrderResult(BaseModel):
|
|
63
|
+
status: Literal["success", "error"]
|
|
64
|
+
broker: Literal["oanda", "moomoo"]
|
|
65
|
+
ticker: str
|
|
66
|
+
message: str
|
|
67
|
+
signal_id: str | None = None
|
|
68
|
+
order_id: str | None = None
|
|
69
|
+
broker_order_id: str | None = None
|
|
70
|
+
event_id: str | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class EventIngestResult(BaseModel):
|
|
74
|
+
status: str
|
|
75
|
+
event_id: str
|
|
76
|
+
message: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SignalEvent(BaseModel):
|
|
80
|
+
event_type: Literal["signal_received"] = "signal_received"
|
|
81
|
+
event_id: str
|
|
82
|
+
signal_id: str
|
|
83
|
+
occurred_at: datetime
|
|
84
|
+
broker: Literal["oanda", "moomoo"]
|
|
85
|
+
asset_class: Literal["FX", "COMMODITY", "US", "HK", "INDEX", "CRYPTO"]
|
|
86
|
+
action: Literal["buy", "sell"]
|
|
87
|
+
ticker: str
|
|
88
|
+
quantity: float
|
|
89
|
+
strategy_id: str | None = None
|
|
90
|
+
strategy_version: str | None = None
|
|
91
|
+
snapshot_id: str | None = None
|
|
92
|
+
timeframe: str | None = None
|
|
93
|
+
alert_timestamp: datetime | None = None
|
|
94
|
+
run_mode: Literal["paper", "live"] = "live"
|
|
95
|
+
alert_name: str | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class OrderEvent(BaseModel):
|
|
99
|
+
event_type: Literal["order_recorded"] = "order_recorded"
|
|
100
|
+
event_id: str
|
|
101
|
+
signal_id: str
|
|
102
|
+
order_id: str
|
|
103
|
+
occurred_at: datetime
|
|
104
|
+
broker: Literal["oanda", "moomoo"]
|
|
105
|
+
asset_class: Literal["FX", "COMMODITY", "US", "HK", "INDEX", "CRYPTO"]
|
|
106
|
+
action: Literal["buy", "sell"]
|
|
107
|
+
ticker: str
|
|
108
|
+
quantity: float
|
|
109
|
+
status: Literal["accepted", "failed"]
|
|
110
|
+
request_latency_ms: int | None = None
|
|
111
|
+
broker_order_id: str | None = None
|
|
112
|
+
strategy_id: str | None = None
|
|
113
|
+
strategy_version: str | None = None
|
|
114
|
+
snapshot_id: str | None = None
|
|
115
|
+
run_mode: Literal["paper", "live"] = "live"
|
|
116
|
+
error_type: str | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class FillEvent(BaseModel):
|
|
120
|
+
event_type: Literal["fill_received"] = "fill_received"
|
|
121
|
+
event_id: str
|
|
122
|
+
signal_id: str
|
|
123
|
+
order_id: str
|
|
124
|
+
fill_id: str
|
|
125
|
+
occurred_at: datetime
|
|
126
|
+
broker: Literal["oanda", "moomoo"]
|
|
127
|
+
asset_class: Literal["FX", "COMMODITY", "US", "HK", "INDEX", "CRYPTO"]
|
|
128
|
+
action: Literal["buy", "sell"]
|
|
129
|
+
ticker: str
|
|
130
|
+
quantity: float
|
|
131
|
+
filled_qty: float
|
|
132
|
+
filled_price: float
|
|
133
|
+
broker_order_id: str | None = None
|
|
134
|
+
trade_id: str | None = None
|
|
135
|
+
strategy_id: str | None = None
|
|
136
|
+
strategy_version: str | None = None
|
|
137
|
+
snapshot_id: str | None = None
|
|
138
|
+
run_mode: Literal["paper", "live"] = "live"
|
|
139
|
+
commission: float | None = None
|
|
140
|
+
slippage_bps: float | None = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class TradeClosedEvent(BaseModel):
|
|
144
|
+
event_type: Literal["trade_closed"] = "trade_closed"
|
|
145
|
+
event_id: str
|
|
146
|
+
signal_id: str
|
|
147
|
+
trade_id: str
|
|
148
|
+
occurred_at: datetime
|
|
149
|
+
closed_at: datetime
|
|
150
|
+
broker: Literal["oanda", "moomoo"]
|
|
151
|
+
asset_class: Literal["FX", "COMMODITY", "US", "HK", "INDEX", "CRYPTO"]
|
|
152
|
+
action: Literal["buy", "sell"]
|
|
153
|
+
ticker: str
|
|
154
|
+
quantity: float
|
|
155
|
+
entry_price: float
|
|
156
|
+
exit_price: float
|
|
157
|
+
gross_pnl: float
|
|
158
|
+
net_pnl: float
|
|
159
|
+
strategy_id: str | None = None
|
|
160
|
+
strategy_version: str | None = None
|
|
161
|
+
snapshot_id: str | None = None
|
|
162
|
+
run_mode: Literal["paper", "live"] = "live"
|
|
163
|
+
commission: float | None = None
|
|
164
|
+
exit_reason: str | None = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class TradeClosedPayload(BaseModel):
|
|
168
|
+
passphrase: str = Field(repr=False)
|
|
169
|
+
signal_id: str
|
|
170
|
+
trade_id: str
|
|
171
|
+
closed_at: datetime
|
|
172
|
+
broker: Literal["oanda", "moomoo"]
|
|
173
|
+
asset_class: Literal["FX", "COMMODITY", "US", "HK", "INDEX", "CRYPTO"]
|
|
174
|
+
action: Literal["buy", "sell"]
|
|
175
|
+
ticker: str
|
|
176
|
+
quantity: float = Field(gt=0)
|
|
177
|
+
entry_price: float = Field(gt=0)
|
|
178
|
+
exit_price: float = Field(gt=0)
|
|
179
|
+
gross_pnl: float
|
|
180
|
+
net_pnl: float
|
|
181
|
+
strategy_id: str | None = None
|
|
182
|
+
strategy_version: str | None = None
|
|
183
|
+
snapshot_id: str | None = None
|
|
184
|
+
run_mode: Literal["paper", "live"] = "live"
|
|
185
|
+
commission: float | None = None
|
|
186
|
+
exit_reason: str | None = None
|
|
File without changes
|