1use anyhow::Result;
2use serde_json::json;
3
4use crate::cli::{PortfolioCommands, SafetyCommands, TradeCommands};
5use crate::config::RuntimeConfig;
6use crate::human;
7use crate::order_builder::{
8 build_equity_order, parse_duration, parse_session, parse_trade_order_type, TradeSide,
9};
10use crate::output::ResponseEnvelope;
11use crate::portfolio::{
12 account_buying_power, account_equity, ensure_sufficient_buying_power, estimate_equity_buy_cost,
13 summarize_accounts, summary_to_json,
14};
15use crate::safety::{execute_trading_order, require_trading_approval};
16use crate::safety_config::{config_path, SafetyConfig};
17
18pub async fn run_portfolio(runtime: &RuntimeConfig, command: PortfolioCommands) -> Result<()> {
19 let api = runtime.build_api()?;
20
21 match command {
22 PortfolioCommands::Summary => {
23 let accounts = api.accounts().list(Some("positions")).await?;
24 let summary = summarize_accounts(&accounts);
25 runtime.emit(
26 ResponseEnvelope::ok("portfolio summary", summary_to_json(&summary))
27 .with_next_actions(vec![
28 "schwab portfolio buying-power --account-number <hash> --json".into(),
29 "schwab trade buy --help".into(),
30 ]),
31 );
32 }
33 PortfolioCommands::BuyingPower { account_number } => {
34 let mut account_number = account_number;
35 if runtime.is_interactive() && account_number.is_empty() {
36 account_number = human::pick_account_hash(runtime, &api).await?;
37 }
38 let buying_power = account_buying_power(&api, &account_number).await?;
39 runtime.emit(
40 ResponseEnvelope::ok(
41 "portfolio buying-power",
42 json!({
43 "account_number": account_number,
44 "buying_power": buying_power,
45 }),
46 )
47 .with_next_actions(vec![
48 "schwab market quotes --symbols <SYMBOL> --fields quote --json".into(),
49 "schwab trade buy --account-number <hash> --symbol <SYMBOL> --quantity <N> --order-type limit --price <ASK> --dry-run --json".into(),
50 ]),
51 );
52 }
53 }
54
55 Ok(())
56}
57
58pub async fn run_trade(runtime: &RuntimeConfig, command: TradeCommands) -> Result<()> {
59 let api = runtime.build_api()?;
60
61 match command {
62 TradeCommands::Buy {
63 mut account_number,
64 symbol,
65 quantity,
66 order_type,
67 price,
68 duration,
69 session,
70 } => {
71 run_side(
72 runtime,
73 &api,
74 TradeRequest {
75 side: TradeSide::Buy,
76 account_number: &mut account_number,
77 symbol: &symbol,
78 quantity,
79 order_type_raw: &order_type,
80 limit_price: price,
81 duration_raw: duration.as_deref(),
82 session_raw: session.as_deref(),
83 },
84 )
85 .await
86 }
87 TradeCommands::Sell {
88 mut account_number,
89 symbol,
90 quantity,
91 order_type,
92 price,
93 duration,
94 session,
95 } => {
96 run_side(
97 runtime,
98 &api,
99 TradeRequest {
100 side: TradeSide::Sell,
101 account_number: &mut account_number,
102 symbol: &symbol,
103 quantity,
104 order_type_raw: &order_type,
105 limit_price: price,
106 duration_raw: duration.as_deref(),
107 session_raw: session.as_deref(),
108 },
109 )
110 .await
111 }
112 }
113}
114
115struct TradeRequest<'a> {
116 side: TradeSide,
117 account_number: &'a mut String,
118 symbol: &'a str,
119 quantity: f64,
120 order_type_raw: &'a str,
121 limit_price: Option<f64>,
122 duration_raw: Option<&'a str>,
123 session_raw: Option<&'a str>,
124}
125
126async fn run_side(
127 runtime: &RuntimeConfig,
128 api: &schwab_api::TraderApi,
129 req: TradeRequest<'_>,
130) -> Result<()> {
131 let TradeRequest {
132 side,
133 account_number,
134 symbol,
135 quantity,
136 order_type_raw,
137 limit_price,
138 duration_raw,
139 session_raw,
140 } = req;
141 let side_label = if side == TradeSide::Buy {
142 "buy"
143 } else {
144 "sell"
145 };
146 require_trading_approval(
147 runtime,
148 &format!("trade {side_label}"),
149 &format!("Place a live {side_label} order for {quantity} shares of {symbol}."),
150 )?;
151
152 if runtime.is_interactive() && account_number.is_empty() {
153 *account_number = human::pick_account_hash(runtime, api).await?;
154 }
155
156 let order_type = parse_trade_order_type(order_type_raw)?;
157 let duration = parse_duration(duration_raw)?;
158 let session = parse_session(session_raw)?;
159 let order = build_equity_order(
160 side,
161 symbol,
162 quantity,
163 order_type,
164 limit_price,
165 duration,
166 session,
167 )?;
168
169 let mut buying_power_check = None;
170 if side == TradeSide::Buy {
171 let buying_power = account_buying_power(api, account_number).await?;
172 let market_ask = if order_type == crate::order_builder::TradeOrderType::Market {
173 Some(fetch_equity_ask(runtime, symbol).await?)
174 } else {
175 None
176 };
177 let estimated_cost =
178 estimate_equity_buy_cost(quantity, order_type_raw, limit_price, market_ask)?;
179 ensure_sufficient_buying_power(&buying_power, estimated_cost)?;
180 buying_power_check = Some(json!({
181 "cash_available_for_trading": buying_power.cash_available_for_trading,
182 "estimated_cost": estimated_cost,
183 "remaining_after_trade": buying_power.cash_available_for_trading - estimated_cost,
184 }));
185 }
186
187 let inputs = json!({
188 "account_number": account_number,
189 "symbol": symbol.to_uppercase(),
190 "quantity": quantity,
191 "order_type": order_type_raw,
192 "price": limit_price,
193 "side": side_label,
194 "buying_power_check": buying_power_check,
195 });
196
197 if runtime.dry_run {
198 let equity = account_equity(api, account_number).await.ok().flatten();
199 runtime.safety.validate_order(&order, None, equity)?;
200 runtime.emit(
201 ResponseEnvelope::ok(
202 format!("trade {side_label}"),
203 json!({ "dry_run": true, "order": order, "buying_power_check": buying_power_check }),
204 )
205 .with_inputs(inputs),
206 );
207 return Ok(());
208 }
209
210 let result = execute_trading_order(runtime, api, account_number, &order).await?;
211 runtime.emit(ResponseEnvelope::ok(format!("trade {side_label}"), result).with_inputs(inputs));
212 Ok(())
213}
214
215async fn fetch_equity_ask(runtime: &RuntimeConfig, symbol: &str) -> Result<f64> {
216 use anyhow::Context;
217
218 let market_api = runtime.build_market_api()?;
219 let symbol = symbol.trim().to_uppercase();
220 let quotes = market_api
221 .quotes()
222 .get_quotes(&symbol, Some("quote"), None)
223 .await
224 .context("Failed to fetch market quote for buying-power estimate")?;
225 let quote = quotes
226 .get(&symbol)
227 .or_else(|| quotes.as_object().and_then(|m| m.values().next()))
228 .and_then(|entry| entry.get("quote"))
229 .with_context(|| format!("Quote payload missing for symbol {symbol}"))?;
230 quote
231 .get("askPrice")
232 .and_then(|v| v.as_f64())
233 .context("Quote missing askPrice for buying-power estimate")
234}
235
236pub async fn run_safety(runtime: &RuntimeConfig, command: SafetyCommands) -> Result<()> {
237 match command {
238 SafetyCommands::Show => {
239 let path = config_path();
240 let loaded_from_file = path.is_file();
241 runtime.emit(ResponseEnvelope::ok(
242 "safety show",
243 json!({
244 "path": path,
245 "loaded_from_file": loaded_from_file,
246 "config": &*runtime.safety,
247 "trust_mode": runtime.trust,
248 }),
249 ));
250 }
251 SafetyCommands::Init => {
252 crate::safety::require_mutation_approval(
253 runtime,
254 "safety init",
255 "Write default safety.json to the config directory.",
256 )?;
257 let path = SafetyConfig::init_at_default_path()?;
258 let cfg = SafetyConfig::load()?;
259 runtime.emit(
260 ResponseEnvelope::ok(
261 "safety init",
262 json!({
263 "path": path,
264 "created": true,
265 "config": cfg,
266 }),
267 )
268 .with_next_actions(vec![
269 "Edit safety.json limits before enabling --trust".into(),
270 ]),
271 );
272 }
273 SafetyCommands::Path => {
274 runtime.emit(ResponseEnvelope::ok(
275 "safety path",
276 json!({ "path": config_path() }),
277 ));
278 }
279 }
280 Ok(())
281}