Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,15 @@ STRATEGY_PROFILE=
FIRSTRADE_DRY_RUN_ONLY=true
ACCOUNT_PREFIX=FIRSTRADE
ACCOUNT_REGION=US
NOTIFY_LANG=en
TELEGRAM_TOKEN=
GLOBAL_TELEGRAM_CHAT_ID=

# Runtime safety controls.
FIRSTRADE_COOKIE_DIR=.runtime/firstrade-cookies
FIRSTRADE_ENABLE_LIVE_TRADING=false
FIRSTRADE_RUN_SMOKE_ON_HTTP=false
FIRSTRADE_RUN_STRATEGY_ON_HTTP=false
FIRSTRADE_LIVE_ORDER_ACK=false
FIRSTRADE_MAX_ORDER_NOTIONAL_USD=25
FIRSTRADE_SMOKE_SYMBOL=SPY
76 changes: 74 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,15 @@ commit credentials.
| `FIRSTRADE_DRY_RUN_ONLY` | Optional | Defaults to `true` for platform runtime |
| `ACCOUNT_PREFIX` | Optional | Alert/log prefix, default `FIRSTRADE` |
| `ACCOUNT_REGION` | Optional | Runtime account scope, default `US` |
| `NOTIFY_LANG` | Optional | Notification language, `en` or `zh` |
| `TELEGRAM_TOKEN` | Optional | Telegram bot token for strategy-cycle summaries |
| `GLOBAL_TELEGRAM_CHAT_ID` | Optional | Telegram chat ID for strategy-cycle summaries |
| `FIRSTRADE_COOKIE_DIR` | Optional | Cookie cache directory, default `.runtime/firstrade-cookies` |
| `FIRSTRADE_ENABLE_LIVE_TRADING` | Optional | Must be `true` before any live order can be submitted |
| `FIRSTRADE_RUN_SMOKE_ON_HTTP` | Optional | Must be `true` before `/smoke` performs a real login/quote |
| `FIRSTRADE_RUN_STRATEGY_ON_HTTP` | Optional | Must be `true` before `/run` performs strategy evaluation and order routing |
| `FIRSTRADE_LIVE_ORDER_ACK` | Optional | Must be `true` before `/run` can submit live orders |
| `FIRSTRADE_MAX_ORDER_NOTIONAL_USD` | Optional | Single-order cap for strategy-generated orders, default `25` |

## Local Validation

Expand All @@ -92,6 +98,19 @@ Quote-only smoke check:
.venv/bin/python scripts/firstrade_smoke_check.py --quote-only --symbol SPY
```

Read-only account payload check:

```bash
.venv/bin/python scripts/firstrade_smoke_check.py \
--quote-only \
--symbol SPY \
--include-balances \
--include-positions
```

The balance and position payloads are printed from the upstream package response.
Treat that output as sensitive account data.

Dry-run order preview for a tiny notional buy:

```bash
Expand Down Expand Up @@ -127,6 +146,32 @@ The example does not recommend any security. Choose the validation symbol
yourself and confirm Firstrade account permissions, fractional trading
agreement status, market session, and order preview before live use.

## Strategy Cycle

`/run` and `application.rebalance_service.run_strategy_cycle()` now perform a
full guarded strategy cycle:

- connect to Firstrade with the unofficial client
- read the selected account, balances, positions, quotes, and OHLC history
- load the selected shared `UsEquityStrategies` runtime
- map the strategy decision into a value-target Firstrade plan
- route generated orders through the local safety layer
- publish a compact Telegram summary when `TELEGRAM_TOKEN` and
`GLOBAL_TELEGRAM_CHAT_ID` are configured

The default mode remains dry-run. A live HTTP-triggered strategy order requires
all of these gates:

- `FIRSTRADE_RUN_STRATEGY_ON_HTTP=true`
- `FIRSTRADE_DRY_RUN_ONLY=false`
- `FIRSTRADE_ENABLE_LIVE_TRADING=true`
- `FIRSTRADE_LIVE_ORDER_ACK=true`
- order value at or below `FIRSTRADE_MAX_ORDER_NOTIONAL_USD`

The strategy execution service uses whole-share limit orders for generated
strategy orders. If the notional cap is below the current price of a target
symbol, that order is skipped instead of being enlarged.

## Cloud Run Shape

`main.py` exposes:
Expand All @@ -136,8 +181,11 @@ agreement status, market session, and order preview before live use.
- `/probe` health metadata only
- `/profiles` shared US equity strategy matrix
- `/smoke` login + quote only when `FIRSTRADE_RUN_SMOKE_ON_HTTP=true`
- `/run` strategy evaluation + guarded order routing only when
`FIRSTRADE_RUN_STRATEGY_ON_HTTP=true`

The HTTP entrypoint does not place orders.
With the default environment, `/run` previews orders only. It can submit live
orders only when every live-trading gate above is enabled.

## License And Upstream Compliance

Expand Down Expand Up @@ -165,16 +213,40 @@ Firstrade 登录、账户/行情读取、下单转换、安全闸和部署 wirin
- 登录和 MFA 验证
- 账户、持仓、行情、OHLC 读取
- dry-run / preview 下单验证
- `/run` 执行通用美股策略的 dry-run 调仓闭环
- 配置 `TELEGRAM_TOKEN` 和 `GLOBAL_TELEGRAM_CHAT_ID` 后发送运行摘要
- 在你再次确认后,才允许极小金额实盘验证
- 通用 `us_equity` 策略 profile 的平台层接入

默认所有订单都是 preview。实盘必须同时满足:
可以用只读 smoke 命令读取余额和持仓:

```bash
.venv/bin/python scripts/firstrade_smoke_check.py \
--quote-only \
--symbol SPY \
--include-balances \
--include-positions
```

该输出包含账户敏感信息,不要贴到公开 issue、日志或 PR。

默认所有订单都是 preview。CLI 实盘必须同时满足:

- 设置 `FIRSTRADE_ENABLE_LIVE_TRADING=true`
- CLI 使用 `--live-order`
- CLI 使用 `--yes-i-understand-unofficial-api-risk`
- 金额不超过 `--max-notional-usd`

HTTP 策略闭环实盘还必须额外满足:

- `FIRSTRADE_RUN_STRATEGY_ON_HTTP=true`
- `FIRSTRADE_DRY_RUN_ONLY=false`
- `FIRSTRADE_LIVE_ORDER_ACK=true`
- 单笔金额不超过 `FIRSTRADE_MAX_ORDER_NOTIONAL_USD`

策略闭环生成的是整数股限价单。如果 `FIRSTRADE_MAX_ORDER_NOTIONAL_USD`
低于目标标的当前价格,本轮会跳过该订单,而不是放大金额。

请不要把 Firstrade 登录凭据、MFA secret、cookie 文件提交到 Git。`.env`、
`.runtime/` 和 `ft_cookies*.json` 已经在 `.gitignore` 中。

Expand Down
172 changes: 172 additions & 0 deletions application/execution_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"""Dry-run-first value-target execution planning for FirstradePlatform."""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

from quant_platform_kit.common.models import OrderIntent
from quant_platform_kit.common.ports import ExecutionPort, MarketDataPort


@dataclass(frozen=True)
class ExecutionCycleResult:
submitted_orders: tuple[dict[str, Any], ...]
skipped_orders: tuple[dict[str, Any], ...]
action_done: bool


def _floor_quantity(quantity: float) -> int:
return max(0, int(float(quantity or 0.0)))


def _quote_price(market_data_port: MarketDataPort, symbol: str) -> float | None:
try:
price = float(market_data_port.get_quote(symbol).last_price)
except Exception:
return None
return price if price > 0 else None


def _submit_order(
execution_port: ExecutionPort,
*,
symbol: str,
side: str,
quantity: int,
limit_price: float,
max_notional_usd: float,
) -> dict[str, Any]:
report = execution_port.submit_order(
OrderIntent(
symbol=symbol,
side=side,
quantity=float(quantity),
order_type="limit",
limit_price=round(float(limit_price), 2),
time_in_force="day",
metadata={"max_notional_usd": float(max_notional_usd)},
)
)
return {
"symbol": report.symbol,
"side": report.side,
"quantity": report.quantity,
"status": report.status,
"broker_order_id": report.broker_order_id,
"raw_payload": report.raw_payload,
}


def execute_value_target_plan(
*,
plan: dict[str, Any],
market_data_port: MarketDataPort,
execution_port: ExecutionPort,
dry_run_only: bool,
limit_sell_discount: float = 0.995,
limit_buy_premium: float = 1.005,
max_order_notional_usd: float = 25.0,
) -> ExecutionCycleResult:
del dry_run_only # ExecutionPort owns preview vs live submission.
allocation = dict(plan.get("allocation") or {})
portfolio = dict(plan.get("portfolio") or {})
execution = dict(plan.get("execution") or {})
targets = {str(k).upper(): float(v or 0.0) for k, v in dict(allocation.get("targets") or {}).items()}
market_values = {
str(k).upper(): float(v or 0.0)
for k, v in dict(portfolio.get("market_values") or {}).items()
}
sellable_quantities = {
str(k).upper(): float(v or 0.0)
for k, v in dict(portfolio.get("sellable_quantities") or {}).items()
}
threshold = float(
execution.get("current_min_trade")
or execution.get("trade_threshold_value")
or 0.0
)
investable_cash = max(
0.0,
float(execution.get("investable_cash") or portfolio.get("liquid_cash") or 0.0),
)
order_notional_cap = max(0.0, float(max_order_notional_usd or 0.0))

submitted: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []

tradable_deltas: list[tuple[str, float, float]] = []
for symbol in sorted(set(targets) | set(market_values)):
target_value = float(targets.get(symbol, 0.0))
current_value = float(market_values.get(symbol, 0.0))
delta_value = target_value - current_value
if abs(delta_value) < threshold:
skipped.append(
{
"symbol": symbol,
"reason": "below_trade_threshold",
"delta_value": round(delta_value, 2),
}
)
continue
price = _quote_price(market_data_port, symbol)
if price is None:
skipped.append({"symbol": symbol, "reason": "quote_unavailable"})
continue
tradable_deltas.append((symbol, delta_value, price))

for symbol, delta_value, price in [item for item in tradable_deltas if item[1] < 0]:
if delta_value < 0:
sellable = sellable_quantities.get(symbol, 0.0)
sell_budget = min(abs(delta_value), sellable * price, order_notional_cap)
quantity = _floor_quantity(sell_budget / price)
if quantity <= 0:
skipped.append(
{
"symbol": symbol,
"reason": "sell_quantity_zero",
"max_order_notional_usd": round(order_notional_cap, 2),
}
)
continue
submitted.append(
_submit_order(
execution_port,
symbol=symbol,
side="sell",
quantity=quantity,
limit_price=price * float(limit_sell_discount),
max_notional_usd=max_order_notional_usd,
)
)
continue

for symbol, delta_value, price in [item for item in tradable_deltas if item[1] > 0]:
buy_budget = min(float(delta_value), investable_cash, order_notional_cap)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add sell proceeds to investable cash before buy planning

execute_value_target_plan sells first but never credits sell proceeds back into investable_cash; buy sizing is computed from the pre-sell cash at this line and only reduced after buys. In rotations where starting cash is low and buys depend on same-cycle sells, the buy leg is skipped as buy_quantity_zero, leaving the portfolio unintentionally underinvested even though sells were submitted.

Useful? React with 👍 / 👎.

quantity = _floor_quantity(buy_budget / price)
if quantity <= 0:
skipped.append(
{
"symbol": symbol,
"reason": "buy_quantity_zero",
"max_order_notional_usd": round(order_notional_cap, 2),
}
)
continue
submitted.append(
_submit_order(
execution_port,
symbol=symbol,
side="buy",
quantity=quantity,
limit_price=price * float(limit_buy_premium),
max_notional_usd=max_order_notional_usd,
)
)
investable_cash = max(0.0, investable_cash - (quantity * price))

return ExecutionCycleResult(
submitted_orders=tuple(submitted),
skipped_orders=tuple(skipped),
action_done=bool(submitted),
)
Loading