| args = ()
|
| kwargs = {'account_nick': 'cuixia', 'action': 'option_open', 'date': datetime.datetime(2026, 8, 14, 23, 21, 12, 277850, tzinfo=zoneinfo.ZoneInfo(key='America/New_York')), 'dispatch': 'manual-option-schedule', ...}
|
| _sanitize_request_by = <function add_trading_plan.<locals>._sanitize_request_by at 0x13c874e00>
|
| _symbol = 'OPTION', _action = 'option_open'
|
| _VALID_ACTIONS = {'buy', 'cover', 'hold', 'option_open', 'reap_call', 'reap_put', ...}
|
| _shares = 1, _account = 'cuixia', _ZI = <class 'zoneinfo.ZoneInfo'>
|
| _ET = zoneinfo.ZoneInfo(key='America/New_York')
|
| date_value = datetime.datetime(2026, 8, 17, 0, 0, tzinfo=zoneinfo.ZoneInfo(key='America/New_York'))
|
| _sched = <module 'rtrader.scheduler' from '/Users/cao/robinhood-pm-93df26abc3d5/rtrader/scheduler.py'>
|
|
|
| @logger_wraps()
|
| def add_trading_plan(*args, **kwargs):
|
| def _sanitize_request_by(value: Any) -> Optional[str]:
|
| if value is None:
|
| return None
|
| try:
|
| text = str(value).strip()
|
| except Exception: # swallow-ok: defensive coercion of arbitrary input; None is the sentinel
|
| return None
|
| if not text:
|
| return None
|
| return text[:120]
|
|
|
| # ── Type guards — catch bad parameters early instead of corrupting MongoDB ──
|
| _symbol = kwargs.get("symbol")
|
| if not _symbol or not isinstance(_symbol, str):
|
| raise RuntimeError(f"symbol must be a non-empty string, got {type(_symbol)}: {_symbol!r}")
|
|
|
| _action = kwargs.get("action")
|
| if not _action or not isinstance(_action, str):
|
| raise RuntimeError(f"action must be a non-empty string, got {type(_action)}: {_action!r}")
|
| _VALID_ACTIONS = {"buy", "sell", "hold", "cover", "short", "snipe_call", "snipe_put", "reap_call", "reap_put", "roll_call", "roll_put", "option_open"}
|
| if _action.lower() not in _VALID_ACTIONS:
|
| raise RuntimeError(f"action must be one of {_VALID_ACTIONS}, got {_action!r}")
|
|
|
| _shares = kwargs.get("shares", 0)
|
| if not isinstance(_shares, (int, float)):
|
| raise RuntimeError(f"shares must be numeric, got {type(_shares)}: {_shares!r}")
|
|
|
| _account = kwargs.get("account_nick")
|
| if not _account or not isinstance(_account, str):
|
| raise RuntimeError(f"account_nick must be a non-empty string, got {type(_account)}: {_account!r}")
|
|
|
| # Parse and validate date — MUST be tz-aware datetime for MongoDB comparisons.
|
| # Naked date objects and naive datetimes cause tz mismatch crashes downstream.
|
| from zoneinfo import ZoneInfo as _ZI
|
| _ET = _ZI("America/New_York")
|
| date_value = kwargs.get("date")
|
| if date_value is None:
|
| raise RuntimeError("date is required for add_trading_plan")
|
| if isinstance(date_value, str):
|
| try:
|
| date_value = datetime.fromisoformat(date_value)
|
| except ValueError:
|
| date_value = datetime.strptime(date_value, "%Y-%m-%d")
|
| # String-parsed datetimes are naive — attach ET timezone
|
| if date_value.tzinfo is None:
|
| date_value = date_value.replace(tzinfo=_ET)
|
| elif isinstance(date_value, date) and not isinstance(date_value, datetime):
|
| # date object → convert to datetime with tzinfo=ET
|
| date_value = datetime.combine(date_value, datetime.min.time(), tzinfo=_ET) # tz-ok
|
| elif isinstance(date_value, datetime) and date_value.tzinfo is None:
|
| # Naive datetime → attach ET
|
| date_value = date_value.replace(tzinfo=_ET)
|
| if not isinstance(date_value, datetime):
|
| raise RuntimeError(f"date must be datetime/date/str, got {type(date_value)}: {date_value!r}")
|
|
|
| # Auto-advance plan date when today's session has CLOSED (post-close
|
| # creation can't fire today). Plans created BEFORE today's open should
|
| # stay dated today — `is_after_today_trading_hour()` distinguishes the
|
| # two; the old `not is_market_open()` conflated them and incorrectly
|
| # advanced before-open plans by one day.
|
| from rtrader import scheduler as _sched
|
| _now_et = datetime.now(_ET)
|
| _plan_date_et = date_value.astimezone(_ET).date()
|
| _today_et = _now_et.date()
|
| if _plan_date_et == _today_et and _sched.is_after_today_trading_hour(_now_et):
|
| # Use get_next_plan_session for holiday-aware advance.
|
| # Function takes no args — derives "now" internally via datetime.now(ET).
|
| _next_td = _sched.get_next_plan_session()
|
| date_value = datetime.combine(_next_td, datetime.min.time(), tzinfo=_ET) # tz-ok
|
| logger.warning(
|
| "[add_trading_plan] Auto-advanced plan date {} → {} "
|
| "(today's session closed, next plan session is {}; "
|
| "set strict_date=True to disable)",
|
| _today_et, _next_td, _next_td,
|
| )
|
|
|
| raw_metadata = kwargs.get("metadata")
|
|
|
| with DBAccounting() as mongo:
|
| rr = mongo.collection("TradingPlan")
|
| alpaca_mode = normalize_alpaca_mode(kwargs.get("alpaca_mode", "live"))
|
| # Sanitize action window values (ensure non-null, sensible bounds)
|
| raw_action_start = kwargs.get("action_start")
|
| try:
|
| action_start_value = int(raw_action_start)
|
| except (TypeError, ValueError):
|
| action_start_value = 1
|
| if action_start_value is None or action_start_value <= 0:
|
| action_start_value = 1
|
| if action_start_value > 390:
|
| action_start_value = 390
|
|
|
| raw_action_end = kwargs.get("action_end")
|
| try:
|
| action_end_value = int(raw_action_end) if raw_action_end is not None else 390
|
| except (TypeError, ValueError):
|
| action_end_value = 390
|
| if action_end_value is None or action_end_value < action_start_value:
|
| action_end_value = 390
|
|
|
| record = {
|
| "symbol": kwargs.get("symbol", "").upper(),
|
| "action": kwargs.get("action", "").lower(),
|
| "date": date_value,
|
| "shares": kwargs.get("shares", 0),
|
| "amount": kwargs.get("amount", 0),
|
| "price": kwargs.get("price", 0.0),
|
| "limit_price": kwargs.get("limit_price", None),
|
| "notes": kwargs.get("notes", ""),
|
| "status": kwargs.get("status", "review"),
|
| "action_start": action_start_value,
|
| "action_end": action_end_value,
|
| "created_at": datetime.now(),
|
| "updated_at": datetime.now(),
|
| "trigger": kwargs.get("trigger", ""),
|
| "price_operator": kwargs.get("price_operator", ""),
|
| "price_limit": kwargs.get("price_limit", 0.0),
|
| "level_operator": kwargs.get("level_operator", ""),
|
| "level_limit": kwargs.get("level_limit", 0.0),
|
| "log": kwargs.get("log", ""),
|
| "alpaca_mode": alpaca_mode,
|
| "account_nick": kwargs.get("account_nick") or "",
|
| "broker_backend": kwargs.get("broker_backend") or "",
|
| }
|
|
|
| # Dispatch tag — identifies which service/workflow created the plan.
|
| # Used for categorization (eod vs scalp vs squeeze), lock bypass,
|
| # and preventing duplicate plan creation.
|
| #
|
| # Dispatch format conventions:
|
| # eod-buy-{account}-{SYMBOL} — EOD top-pick buy (buy_trader_executor)
|
| # eod-stop-refresh-{account}-{SYMBOL} — daily stop-loss refresh (portfolio_risk_monitor)
|
| # verdict-{action}-{symbol}-{account} — LLM verdict sell/trim/exit/add (dispatch_llm_verdicts)
|
| # verdict-ah-add-{symbol}-{account} — after-hours institutional override ADD
|
| # verdict-profit-protect-{symbol}-{account} — profit protection stop-loss
|
| # scalp-pdt-exit-{job_id}-{type} — scalp PDT next-day exit (eod/stop/signal/open)
|
| # squeeze-short-vol-{symbol}-{account} — short squeeze entry (short_volume_cli)
|
| dispatch = kwargs.get("dispatch")
|
| if not dispatch:
|
| raise RuntimeError(
|
| f"dispatch is required for add_trading_plan (symbol={kwargs.get('symbol')}, "
|
| f"action={kwargs.get('action')}, account={kwargs.get('account_nick')})"
|
| )
|
| record["dispatch"] = str(dispatch)
|
|
|
| flow_bias = kwargs.get("flow_bias")
|
| if flow_bias:
|
| record["flow_bias"] = str(flow_bias)
|
|
|
| signal_bias = kwargs.get("signal_bias")
|
| if signal_bias:
|
| record["signal_bias"] = str(signal_bias)
|
|
|
| instrument_type = kwargs.get("instrument_type")
|
| if instrument_type:
|
| record["instrument_type"] = str(instrument_type).lower()
|
|
|
| mechanism_type = kwargs.get("mechanism_type")
|
| if mechanism_type:
|
| record["mechanism_type"] = str(mechanism_type)
|
|
|
| mechanism_mode = kwargs.get("mechanism_mode")
|
| if mechanism_mode:
|
| record["mechanism_mode"] = str(mechanism_mode)
|
|
|
| contract_symbol = kwargs.get("contract_symbol")
|
| if contract_symbol:
|
| record["contract_symbol"] = str(contract_symbol).upper()
|
|
|
| contract_mode = kwargs.get("contract_mode")
|
| if contract_mode:
|
| record["contract_mode"] = str(contract_mode)
|
|
|
| metadata = dict(raw_metadata) if isinstance(raw_metadata, dict) else raw_metadata
|
| sanitized_metadata = None
|
| if metadata is not None:
|
| if isinstance(metadata, dict):
|
| if "leg" in metadata and isinstance(metadata["leg"], str):
|
| metadata["leg"] = metadata["leg"].strip()
|
| if "account_id" in metadata and isinstance(metadata["account_id"], str):
|
| metadata["account_id"] = metadata["account_id"].strip()
|
| sanitized_metadata = _metadata_json_safe(metadata)
|
| if isinstance(sanitized_metadata, dict):
|
| record["metadata"] = sanitized_metadata
|
| elif sanitized_metadata not in (None, "", [], (), {}):
|
| record["metadata"] = sanitized_metadata
|
|
|
| # Option-intent plan (bug 93df26abc3d5): a plan that carries an OPTION
|
| # INTENT, not a contract symbol. The executor resolves the actual contract
|
| # at trigger time against the live quote. Allowed fields are
|
| # underlying / side / contracts / limit_price / expected_expiry_days; the
|
| # executor validates them at trigger — keeping the create-time surface
|
| # narrow lets us evolve the schema later without breaking existing plans.
|
| raw_option_intent = kwargs.get("option_intent")
|
| if isinstance(raw_option_intent, dict) and raw_option_intent:
|
| from rtrader.option_intent import normalize_option_intent
|
|
|
| try:
|
| normalized_intent = normalize_option_intent(raw_option_intent)
|
| except ValueError as exc:
|
| raise RuntimeError(f"option_intent invalid: {exc}") from exc
|
| metadata_dict = record.get("metadata")
|
| if not isinstance(metadata_dict, dict):
|
| metadata_dict = {}
|
| record["metadata"] = metadata_dict
|
| metadata_dict["option_intent"] = normalized_intent
|
|
|
| # Trade-attribute ledger stash (2026-07-02): the "why we entered" —
|
| # conviction score/climb/tier, TIDE regime, snapshot features, verdict
|
| # reason. Carried on the plan so the fill-reconciler can join it to the
|
| # actual fill by COID and write the TradeAttributeLedger entry record.
|
| entry_attributes = kwargs.get("entry_attributes")
|
| if isinstance(entry_attributes, dict) and entry_attributes:
|
| record["entry_attributes"] = _metadata_json_safe(entry_attributes)
|
|
|
| metadata_for_compare = record.get("metadata") or {}
|
| normalized_leg = str(metadata_for_compare.get("leg") or "").strip().lower()
|
| if not normalized_leg:
|
| normalized_leg = None
|
| normalized_account = str(metadata_for_compare.get("account_id") or "").strip()
|
|
|
| request_by_value = _sanitize_request_by(
|
| kwargs.get("request_by")
|
| or kwargs.get("requested_by")
|
| )
|
| if not request_by_value and isinstance(record.get("metadata"), dict):
|
| metadata_lookup = record["metadata"]
|
| request_by_value = _sanitize_request_by(
|
| metadata_lookup.get("request_by")
|
| or metadata_lookup.get("requested_by")
|
| or metadata_lookup.get("workflow")
|
| or metadata_lookup.get("source")
|
| )
|
| if not request_by_value:
|
| request_by_value = _sanitize_request_by(kwargs.get("trigger"))
|
| if not request_by_value:
|
| request_by_value = "system"
|
|
|
| record["request_by"] = request_by_value
|
| if isinstance(record.get("metadata"), dict):
|
| metadata_map = record["metadata"]
|
| metadata_map.setdefault("request_by", request_by_value)
|
| metadata_map.setdefault("requested_by", request_by_value)
|
|
|
| flow_bias = kwargs.get("flow_bias")
|
| record["flow_bias"] = str(flow_bias).lower() if flow_bias else ""
|
|
|
| signal_bias = kwargs.get("signal_bias")
|
| record["signal_bias"] = str(signal_bias).lower() if signal_bias else ""
|
|
|
| # TIDE bias — fires the plan as a market order only while the live avg-stock
|
| # breadth regime favors this direction (long/short). Analog of flow_bias on
|
| # market-wide breadth. See trading_plan_executor.is_tide_condition_met.
|
| tide_bias = kwargs.get("tide_bias")
|
| record["tide_bias"] = str(tide_bias).lower() if tide_bias else ""
|
|
|
| price_target = kwargs.get("price_target")
|
| record["price_target"] = str(price_target).lower() if price_target else ""
|
|
|
| # Enforce: seller flow sell plans MUST use market orders.
|
| # Price is falling under seller pressure — limit orders won't fill.
|
| if record["flow_bias"] == "seller" and record["action"] == "sell":
|
| if "metadata" not in record or not isinstance(record.get("metadata"), dict):
|
| record["metadata"] = {}
|
| if record["metadata"].get("order_type") != "market":
|
| record["metadata"]["order_type"] = "market"
|
| logger.info(
|
| "[add_trading_plan] Auto-set order_type=market for seller flow sell plan: {}",
|
| record["symbol"],
|
| )
|
|
|
| flow_window = kwargs.get("flow_window_minutes")
|
| if flow_window not in (None, "", []):
|
| try:
|
| record["flow_window_minutes"] = max(int(flow_window), 1)
|
| except (TypeError, ValueError):
|
| pass
|
| # When flow_window_minutes is absent, the executor resolves it
|
| # dynamically based on SPY intraday volatility (atr_ratio).
|
|
|
| flow_delta = kwargs.get("flow_min_delta_pct")
|
| if flow_delta in (None, "", []):
|
| record["flow_min_delta_pct"] = float(getattr(settings, "BUYER_SELLER_FLOW_MIN_DELTA_PCT", 0.005))
|
| else:
|
| try:
|
| delta_value = float(flow_delta)
|
| if delta_value > 1:
|
| delta_value = delta_value / 100.0
|
| record["flow_min_delta_pct"] = max(delta_value, 0.0)
|
| except (TypeError, ValueError):
|
| record["flow_min_delta_pct"] = float(getattr(settings, "BUYER_SELLER_FLOW_MIN_DELTA_PCT", 0.005))
|
|
|
| # --- Option gain/loss target fields ---
|
| entry_price = kwargs.get("entry_price")
|
| if entry_price is not None:
|
| try:
|
| record["entry_price"] = float(entry_price)
|
| except (TypeError, ValueError):
|
| pass
|
|
|
| gain_target_pct = kwargs.get("gain_target_pct")
|
| if gain_target_pct is not None:
|
| try:
|
| val = float(gain_target_pct)
|
| if val > 1:
|
| val = val / 100.0
|
| record["gain_target_pct"] = val
|
| except (TypeError, ValueError):
|
| pass
|
|
|
| stop_loss_pct = kwargs.get("stop_loss_pct")
|
| if stop_loss_pct is not None:
|
| try:
|
| val = float(stop_loss_pct)
|
| if val > 1:
|
| val = val / 100.0
|
| record["stop_loss_pct"] = val
|
| except (TypeError, ValueError):
|
| pass
|
|
|
| valid_until = kwargs.get("valid_until")
|
| if valid_until is not None:
|
| if isinstance(valid_until, str):
|
| try:
|
| valid_until = datetime.strptime(valid_until, "%Y-%m-%d")
|
| except ValueError:
|
| valid_until = datetime.fromisoformat(valid_until)
|
| if isinstance(valid_until, date) and not isinstance(valid_until, datetime):
|
| valid_until = datetime.combine(valid_until, datetime.max.time())
|
| record["valid_until"] = valid_until
|
|
|
| # Duplicate detection prior to insertion
|
| # Use tz-aware bounds matching the stored date (date_value is always tz-aware ET)
|
| lookup_start = datetime.combine(date_value.date(), datetime.min.time(), tzinfo=date_value.tzinfo) # tz-ok
|
| lookup_end = lookup_start + timedelta(days=1) # calendar-ok
|
| base_query = {
|
| "symbol": record["symbol"],
|
| "action": record["action"],
|
| "date": {"$gte": lookup_start, "$lt": lookup_end},
|
| "account_nick": record["account_nick"],
|
| }
|
| potential = rr.find(base_query, {"metadata": 1, "shares": 1, "dispatch": 1, "status": 1})
|
| for existing in potential:
|
| existing_status = str(existing.get("status") or "")
|
| if existing_status in ("cancelled", "canceled"):
|
| continue # don't match against cancelled plans
|
| existing_metadata = existing.get("metadata") or {}
|
| existing_leg = str(existing_metadata.get("leg") or "").strip().lower() or None
|
| existing_account = str(existing_metadata.get("account_id") or "").strip()
|
| existing_shares = int(existing.get("shares") or 0)
|
| # Scalp PDT plans have different dispatch tags (Plan A/B/D/E) for the
|
| # same symbol — don't dedup them. Only dedup if dispatch also matches.
|
| existing_dispatch = str(existing.get("dispatch") or "").strip()
|
| record_dispatch = str(record.get("dispatch") or "").strip()
|
| # Shares match: required for most plans (different shares = intentional chunks).
|
| # Exception: verdict buy_back / scalp_accumulate re-runs recalculate shares
|
| # each time (price changes). For these, same dispatch = same intent regardless
|
| # of shares. The dispatch tag already encodes symbol+account uniquely.
|
| shares_match = existing_shares == int(record.get("shares") or 0)
|
| # Recompute-each-run dispatches: every re-run recalculates shares/stop from the CURRENT
|
| # position or price, so SAME dispatch = SAME intent regardless of share count. Dedup by
|
| # dispatch and UPDATE the existing pending plan in place (operator 2026-06-23: eod-stop-
|
| # refresh + verdict-profit-protect were inserting 100s of duplicate plans/day, bloating
|
| # the executor queue and delaying real fills).
|
| _recompute_dispatch = record_dispatch.startswith((
|
| "verdict-buy_back-", "verdict-scalp-acc-", "verdict-add-",
|
| "eod-stop-refresh-", "verdict-profit-protect-", "profit-protect-stop-",
|
| ))
|
| if (
|
| existing_leg == normalized_leg
|
| and existing_account == normalized_account
|
| and (shares_match or _recompute_dispatch)
|
| and existing_dispatch == record_dispatch
|
| ):
|
| if _recompute_dispatch and not shares_match:
|
| # Refresh the existing pending plan with the latest shares/stop instead of a dup.
|
| _upd = {"shares": int(record.get("shares") or 0), "updated_at": datetime.now()}
|
| for _k in ("price", "limit_price", "price_limit", "price_operator",
|
| "level_operator", "level_limit", "notes"):
|
| if _k in record:
|
| _upd[_k] = record[_k]
|
| rr.update_one({"_id": existing.get("_id")}, {"$set": _upd})
|
| logger.info(
|
| "[add_trading_plan] Duplicate {}/{} {} dispatch={} shares={}vs{}; {} plan {}",
|
| record["symbol"], record["action"], lookup_start.date(), record_dispatch,
|
| existing_shares, int(record.get("shares") or 0),
|
| "UPDATED" if (_recompute_dispatch and not shares_match) else "kept",
|
| existing.get("_id"),
|
| )
|
| return existing.get("_id")
|
|
|
| # Assert data integrity before insert
|
| assert record["symbol"], "Symbol cannot be empty"
|
| assert record["action"] in ["buy", "sell", "hold", "cover", "short", "snipe_call", "snipe_put", "reap_call", "reap_put", "roll_call", "roll_put", "option_open"], f"Invalid action: {record['action']}"
|
| assert isinstance(record["date"], (datetime, date)), "Date must be datetime"
|
| assert record["shares"] >= 0, "Shares must be non-negative"
|
| assert record["action_start"] >= 0, "Action start must be non-negative"
|
| assert record["action_end"] > record["action_start"], "Action end must be after action start"
|
|
|
| metadata_keys = sorted((record.get("metadata") or {}).keys())
|
| logger.info(
|
| "[add_trading_plan] Inserting plan | symbol={} action={} shares={} amount={:.2f} price={:.2f} start={} end={} metadata_keys={}",
|
| record["symbol"],
|
| record["action"],
|
| record["shares"],
|
| float(record.get("amount", 0.0)),
|
| float(record.get("price", 0.0)),
|
| record.get("action_start"),
|
| record.get("action_end"),
|
| metadata_keys,
|
| )
|
|
|
| try:
|
| result = rr.insert_one(record)
|
|
|
| # Assert insertion succeeded
|
| assert result.inserted_id, "Failed to insert trading plan"
|
|
|
| # Verify the record was actually inserted
|
| inserted = rr.find_one({"_id": result.inserted_id})
|
| > assert inserted, f"Failed to verify inserted plan with ID {result.inserted_id}"
|
| ^^^^^^^^
|
| E AssertionError: Failed to verify inserted plan with ID test-id
|
|
|
| rtrader/database.py:5579: AssertionError
|
|
|
| During handling of the above exception, another exception occurred:
|
|
|
| monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x13bdb06b0>
|
|
|
| def test_add_trading_plan_accepts_option_intent_kwarg(
|
| monkeypatch: pytest.MonkeyPatch,
|
| ) -> None:
|
| """The create-time surface accepts an option_intent kwarg and persists
|
| it into metadata.option_intent. This is the contract the executor leg
|
| reads at trigger time."""
|
| from rtrader import database as database_module
|
|
|
| # Capture the record handed to Mongo — we don't want to write a real
|
| # doc to the DB in a unit test.
|
| captured: Dict[str, Any] = {}
|
|
|
| class _FakeCollection:
|
| def insert_one(self, record):
|
| captured.update(record)
|
| return types.SimpleNamespace(inserted_id="test-id")
|
|
|
| def find(self, *_a, **_kw):
|
| # Duplicate-detection lookup — return empty so no duplicate-match.
|
| return iter(())
|
|
|
| def find_one(self, *_a, **_kw):
|
| return None
|
|
|
| class _FakeMongo:
|
| def collection(self, name):
|
| assert name == "TradingPlan"
|
| return _FakeCollection()
|
|
|
| def __enter__(self):
|
| return self
|
|
|
| def __exit__(self, exc_type, exc_val, exc_tb):
|
| return False
|
|
|
| monkeypatch.setattr(database_module, "DBAccounting", lambda: _FakeMongo())
|
|
|
| from rtrader.option_intent import normalize_option_intent
|
|
|
| intent = normalize_option_intent({
|
| "underlying": "IWM",
|
| "side": "call",
|
| "contracts": 1,
|
| })
|
|
|
| from datetime import datetime
|
| from zoneinfo import ZoneInfo
|
| _now = datetime.now(ZoneInfo("America/New_York"))
|
|
|
| > database_module.add_trading_plan(
|
| symbol="OPTION",
|
| action="option_open",
|
| date=_now,
|
| shares=1,
|
| account_nick="cuixia",
|
| dispatch="manual-option-schedule",
|
| option_intent=intent,
|
| )
|
|
|
| tests/services/test_option_intent_executor.py:726:
|
| _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
| rtrader/utils/log.py:121: in wrapped
|
| result = func(*args, **kwargs)
|
| ^^^^^^^^^^^^^^^^^^^^^
|
| _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
|
|
| args = ()
|
| kwargs = {'account_nick': 'cuixia', 'action': 'option_open', 'date': datetime.datetime(2026, 8, 14, 23, 21, 12, 277850, tzinfo=zoneinfo.ZoneInfo(key='America/New_York')), 'dispatch': 'manual-option-schedule', ...}
|
| _sanitize_request_by = <function add_trading_plan.<locals>._sanitize_request_by at 0x13c874e00>
|
| _symbol = 'OPTION', _action = 'option_open'
|
| _VALID_ACTIONS = {'buy', 'cover', 'hold', 'option_open', 'reap_call', 'reap_put', ...}
|
| _shares = 1, _account = 'cuixia', _ZI = <class 'zoneinfo.ZoneInfo'>
|
| _ET = zoneinfo.ZoneInfo(key='America/New_York')
|
| date_value = datetime.datetime(2026, 8, 17, 0, 0, tzinfo=zoneinfo.ZoneInfo(key='America/New_York'))
|
| _sched = <module 'rtrader.scheduler' from '/Users/cao/robinhood-pm-93df26abc3d5/rtrader/scheduler.py'>
|
|
|
| @logger_wraps()
|
| def add_trading_plan(*args, **kwargs):
|
| def _sanitize_request_by(value: Any) -> Optional[str]:
|
| if value is None:
|
| return None
|
| try:
|
| text = str(value).strip()
|
| except Exception: # swallow-ok: defensive coercion of arbitrary input; None is the sentinel
|
| return None
|
| if not text:
|
| return None
|
| return text[:120]
|
|
|
| # ── Type guards — catch bad parameters early instead of corrupting MongoDB ──
|
| _symbol = kwargs.get("symbol")
|
| if not _symbol or not isinstance(_symbol, str):
|
| raise RuntimeError(f"symbol must be a non-empty string, got {type(_symbol)}: {_symbol!r}")
|
|
|
| _action = kwargs.get("action")
|
| if not _action or not isinstance(_action, str):
|
| raise RuntimeError(f"action must be a non-empty string, got {type(_action)}: {_action!r}")
|
| _VALID_ACTIONS = {"buy", "sell", "hold", "cover", "short", "snipe_call", "snipe_put", "reap_call", "reap_put", "roll_call", "roll_put", "option_open"}
|
| if _action.lower() not in _VALID_ACTIONS:
|
| raise RuntimeError(f"action must be one of {_VALID_ACTIONS}, got {_action!r}")
|
|
|
| _shares = kwargs.get("shares", 0)
|
| if not isinstance(_shares, (int, float)):
|
| raise RuntimeError(f"shares must be numeric, got {type(_shares)}: {_shares!r}")
|
|
|
| _account = kwargs.get("account_nick")
|
| if not _account or not isinstance(_account, str):
|
| raise RuntimeError(f"account_nick must be a non-empty string, got {type(_account)}: {_account!r}")
|
|
|
| # Parse and validate date — MUST be tz-aware datetime for MongoDB comparisons.
|
| # Naked date objects and naive datetimes cause tz mismatch crashes downstream.
|
| from zoneinfo import ZoneInfo as _ZI
|
| _ET = _ZI("America/New_York")
|
| date_value = kwargs.get("date")
|
| if date_value is None:
|
| raise RuntimeError("date is required for add_trading_plan")
|
| if isinstance(date_value, str):
|
| try:
|
| date_value = datetime.fromisoformat(date_value)
|
| except ValueError:
|
| date_value = datetime.strptime(date_value, "%Y-%m-%d")
|
| # String-parsed datetimes are naive — attach ET timezone
|
| if date_value.tzinfo is None:
|
| date_value = date_value.replace(tzinfo=_ET)
|
| elif isinstance(date_value, date) and not isinstance(date_value, datetime):
|
| # date object → convert to datetime with tzinfo=ET
|
| date_value = datetime.combine(date_value, datetime.min.time(), tzinfo=_ET) # tz-ok
|
| elif isinstance(date_value, datetime) and date_value.tzinfo is None:
|
| # Naive datetime → attach ET
|
| date_value = date_value.replace(tzinfo=_ET)
|
| if not isinstance(date_value, datetime):
|
| raise RuntimeError(f"date must be datetime/date/str, got {type(date_value)}: {date_value!r}")
|
|
|
| # Auto-advance plan date when today's session has CLOSED (post-close
|
| # creation can't fire today). Plans created BEFORE today's open should
|
| # stay dated today — `is_after_today_trading_hour()` distinguishes the
|
| # two; the old `not is_market_open()` conflated them and incorrectly
|
| # advanced before-open plans by one day.
|
| from rtrader import scheduler as _sched
|
| _now_et = datetime.now(_ET)
|
| _plan_date_et = date_value.astimezone(_ET).date()
|
| _today_et = _now_et.date()
|
| if _plan_date_et == _today_et and _sched.is_after_today_trading_hour(_now_et):
|
| # Use get_next_plan_session for holiday-aware advance.
|
| # Function takes no args — derives "now" internally via datetime.now(ET).
|
| _next_td = _sched.get_next_plan_session()
|
| date_value = datetime.combine(_next_td, datetime.min.time(), tzinfo=_ET) # tz-ok
|
| logger.warning(
|
| "[add_trading_plan] Auto-advanced plan date {} → {} "
|
| "(today's session closed, next plan session is {}; "
|
| "set strict_date=True to disable)",
|
| _today_et, _next_td, _next_td,
|
| )
|
|
|
| raw_metadata = kwargs.get("metadata")
|
|
|
| with DBAccounting() as mongo:
|
| rr = mongo.collection("TradingPlan")
|
| alpaca_mode = normalize_alpaca_mode(kwargs.get("alpaca_mode", "live"))
|
| # Sanitize action window values (ensure non-null, sensible bounds)
|
| raw_action_start = kwargs.get("action_start")
|
| try:
|
| action_start_value = int(raw_action_start)
|
| except (TypeError, ValueError):
|
| action_start_value = 1
|
| if action_start_value is None or action_start_value <= 0:
|
| action_start_value = 1
|
| if action_start_value > 390:
|
| action_start_value = 390
|
|
|
| raw_action_end = kwargs.get("action_end")
|
| try:
|
| action_end_value = int(raw_action_end) if raw_action_end is not None else 390
|
| except (TypeError, ValueError):
|
| action_end_value = 390
|
| if action_end_value is None or action_end_value < action_start_value:
|
| action_end_value = 390
|
|
|
| record = {
|
| "symbol": kwargs.get("symbol", "").upper(),
|
| "action": kwargs.get("action", "").lower(),
|
| "date": date_value,
|
| "shares": kwargs.get("shares", 0),
|
| "amount": kwargs.get("amount", 0),
|
| "price": kwargs.get("price", 0.0),
|
| "limit_price": kwargs.get("limit_price", None),
|
| "notes": kwargs.get("notes", ""),
|
| "status": kwargs.get("status", "review"),
|
| "action_start": action_start_value,
|
| "action_end": action_end_value,
|
| "created_at": datetime.now(),
|
| "updated_at": datetime.now(),
|
| "trigger": kwargs.get("trigger", ""),
|
| "price_operator": kwargs.get("price_operator", ""),
|
| "price_limit": kwargs.get("price_limit", 0.0),
|
| "level_operator": kwargs.get("level_operator", ""),
|
| "level_limit": kwargs.get("level_limit", 0.0),
|
| "log": kwargs.get("log", ""),
|
| "alpaca_mode": alpaca_mode,
|
| "account_nick": kwargs.get("account_nick") or "",
|
| "broker_backend": kwargs.get("broker_backend") or "",
|
| }
|
|
|
| # Dispatch tag — identifies which service/workflow created the plan.
|
| # Used for categorization (eod vs scalp vs squeeze), lock bypass,
|
| # and preventing duplicate plan creation.
|
| #
|
| # Dispatch format conventions:
|
| # eod-buy-{account}-{SYMBOL} — EOD top-pick buy (buy_trader_executor)
|
| # eod-stop-refresh-{account}-{SYMBOL} — daily stop-loss refresh (portfolio_risk_monitor)
|
| # verdict-{action}-{symbol}-{account} — LLM verdict sell/trim/exit/add (dispatch_llm_verdicts)
|
| # verdict-ah-add-{symbol}-{account} — after-hours institutional override ADD
|
| # verdict-profit-protect-{symbol}-{account} — profit protection stop-loss
|
| # scalp-pdt-exit-{job_id}-{type} — scalp PDT next-day exit (eod/stop/signal/open)
|
| # squeeze-short-vol-{symbol}-{account} — short squeeze entry (short_volume_cli)
|
| dispatch = kwargs.get("dispatch")
|
| if not dispatch:
|
| raise RuntimeError(
|
| f"dispatch is required for add_trading_plan (symbol={kwargs.get('symbol')}, "
|
| f"action={kwargs.get('action')}, account={kwargs.get('account_nick')})"
|
| )
|
| record["dispatch"] = str(dispatch)
|
|
|
| flow_bias = kwargs.get("flow_bias")
|
| if flow_bias:
|
| record["flow_bias"] = str(flow_bias)
|
|
|
| signal_bias = kwargs.get("signal_bias")
|
| if signal_bias:
|
| record["signal_bias"] = str(signal_bias)
|
|
|
| instrument_type = kwargs.get("instrument_type")
|
| if instrument_type:
|
| record["instrument_type"] = str(instrument_type).lower()
|
|
|
| mechanism_type = kwargs.get("mechanism_type")
|
| if mechanism_type:
|
| record["mechanism_type"] = str(mechanism_type)
|
|
|
| mechanism_mode = kwargs.get("mechanism_mode")
|
| if mechanism_mode:
|
| record["mechanism_mode"] = str(mechanism_mode)
|
|
|
| contract_symbol = kwargs.get("contract_symbol")
|
| if contract_symbol:
|
| record["contract_symbol"] = str(contract_symbol).upper()
|
|
|
| contract_mode = kwargs.get("contract_mode")
|
| if contract_mode:
|
| record["contract_mode"] = str(contract_mode)
|
|
|
| metadata = dict(raw_metadata) if isinstance(raw_metadata, dict) else raw_metadata
|
| sanitized_metadata = None
|
| if metadata is not None:
|
| if isinstance(metadata, dict):
|
| if "leg" in metadata and isinstance(metadata["leg"], str):
|
| metadata["leg"] = metadata["leg"].strip()
|
| if "account_id" in metadata and isinstance(metadata["account_id"], str):
|
| metadata["account_id"] = metadata["account_id"].strip()
|
| sanitized_metadata = _metadata_json_safe(metadata)
|
| if isinstance(sanitized_metadata, dict):
|
| record["metadata"] = sanitized_metadata
|
| elif sanitized_metadata not in (None, "", [], (), {}):
|
| record["metadata"] = sanitized_metadata
|
|
|
| # Option-intent plan (bug 93df26abc3d5): a plan that carries an OPTION
|
| # INTENT, not a contract symbol. The executor resolves the actual contract
|
| # at trigger time against the live quote. Allowed fields are
|
| # underlying / side / contracts / limit_price / expected_expiry_days; the
|
| # executor validates them at trigger — keeping the create-time surface
|
| # narrow lets us evolve the schema later without breaking existing plans.
|
| raw_option_intent = kwargs.get("option_intent")
|
| if isinstance(raw_option_intent, dict) and raw_option_intent:
|
| from rtrader.option_intent import normalize_option_intent
|
|
|
| try:
|
| normalized_intent = normalize_option_intent(raw_option_intent)
|
| except ValueError as exc:
|
| raise RuntimeError(f"option_intent invalid: {exc}") from exc
|
| metadata_dict = record.get("metadata")
|
| if not isinstance(metadata_dict, dict):
|
| metadata_dict = {}
|
| record["metadata"] = metadata_dict
|
| metadata_dict["option_intent"] = normalized_intent
|
|
|
| # Trade-attribute ledger stash (2026-07-02): the "why we entered" —
|
| # conviction score/climb/tier, TIDE regime, snapshot features, verdict
|
| # reason. Carried on the plan so the fill-reconciler can join it to the
|
| # actual fill by COID and write the TradeAttributeLedger entry record.
|
| entry_attributes = kwargs.get("entry_attributes")
|
| if isinstance(entry_attributes, dict) and entry_attributes:
|
| record["entry_attributes"] = _metadata_json_safe(entry_attributes)
|
|
|
| metadata_for_compare = record.get("metadata") or {}
|
| normalized_leg = str(metadata_for_compare.get("leg") or "").strip().lower()
|
| if not normalized_leg:
|
| normalized_leg = None
|
| normalized_account = str(metadata_for_compare.get("account_id") or "").strip()
|
|
|
| request_by_value = _sanitize_request_by(
|
| kwargs.get("request_by")
|
| or kwargs.get("requested_by")
|
| )
|
| if not request_by_value and isinstance(record.get("metadata"), dict):
|
| metadata_lookup = record["metadata"]
|
| request_by_value = _sanitize_request_by(
|
| metadata_lookup.get("request_by")
|
| or metadata_lookup.get("requested_by")
|
| or metadata_lookup.get("workflow")
|
| or metadata_lookup.get("source")
|
| )
|
| if not request_by_value:
|
| request_by_value = _sanitize_request_by(kwargs.get("trigger"))
|
| if not request_by_value:
|
| request_by_value = "system"
|
|
|
| record["request_by"] = request_by_value
|
| if isinstance(record.get("metadata"), dict):
|
| metadata_map = record["metadata"]
|
| metadata_map.setdefault("request_by", request_by_value)
|
| metadata_map.setdefault("requested_by", request_by_value)
|
|
|
| flow_bias = kwargs.get("flow_bias")
|
| record["flow_bias"] = str(flow_bias).lower() if flow_bias else ""
|
|
|
| signal_bias = kwargs.get("signal_bias")
|
| record["signal_bias"] = str(signal_bias).lower() if signal_bias else ""
|
|
|
| # TIDE bias — fires the plan as a market order only while the live avg-stock
|
| # breadth regime favors this direction (long/short). Analog of flow_bias on
|
| # market-wide breadth. See trading_plan_executor.is_tide_condition_met.
|
| tide_bias = kwargs.get("tide_bias")
|
| record["tide_bias"] = str(tide_bias).lower() if tide_bias else ""
|
|
|
| price_target = kwargs.get("price_target")
|
| record["price_target"] = str(price_target).lower() if price_target else ""
|
|
|
| # Enforce: seller flow sell plans MUST use market orders.
|
| # Price is falling under seller pressure — limit orders won't fill.
|
| if record["flow_bias"] == "seller" and record["action"] == "sell":
|
| if "metadata" not in record or not isinstance(record.get("metadata"), dict):
|
| record["metadata"] = {}
|
| if record["metadata"].get("order_type") != "market":
|
| record["metadata"]["order_type"] = "market"
|
| logger.info(
|
| "[add_trading_plan] Auto-set order_type=market for seller flow sell plan: {}",
|
| record["symbol"],
|
| )
|
|
|
| flow_window = kwargs.get("flow_window_minutes")
|
| if flow_window not in (None, "", []):
|
| try:
|
| record["flow_window_minutes"] = max(int(flow_window), 1)
|
| except (TypeError, ValueError):
|
| pass
|
| # When flow_window_minutes is absent, the executor resolves it
|
| # dynamically based on SPY intraday volatility (atr_ratio).
|
|
|
| flow_delta = kwargs.get("flow_min_delta_pct")
|
| if flow_delta in (None, "", []):
|
| record["flow_min_delta_pct"] = float(getattr(settings, "BUYER_SELLER_FLOW_MIN_DELTA_PCT", 0.005))
|
| else:
|
| try:
|
| delta_value = float(flow_delta)
|
| if delta_value > 1:
|
| delta_value = delta_value / 100.0
|
| record["flow_min_delta_pct"] = max(delta_value, 0.0)
|
| except (TypeError, ValueError):
|
| record["flow_min_delta_pct"] = float(getattr(settings, "BUYER_SELLER_FLOW_MIN_DELTA_PCT", 0.005))
|
|
|
| # --- Option gain/loss target fields ---
|
| entry_price = kwargs.get("entry_price")
|
| if entry_price is not None:
|
| try:
|
| record["entry_price"] = float(entry_price)
|
| except (TypeError, ValueError):
|
| pass
|
|
|
| gain_target_pct = kwargs.get("gain_target_pct")
|
| if gain_target_pct is not None:
|
| try:
|
| val = float(gain_target_pct)
|
| if val > 1:
|
| val = val / 100.0
|
| record["gain_target_pct"] = val
|
| except (TypeError, ValueError):
|
| pass
|
|
|
| stop_loss_pct = kwargs.get("stop_loss_pct")
|
| if stop_loss_pct is not None:
|
| try:
|
| val = float(stop_loss_pct)
|
| if val > 1:
|
| val = val / 100.0
|
| record["stop_loss_pct"] = val
|
| except (TypeError, ValueError):
|
| pass
|
|
|
| valid_until = kwargs.get("valid_until")
|
| if valid_until is not None:
|
| if isinstance(valid_until, str):
|
| try:
|
| valid_until = datetime.strptime(valid_until, "%Y-%m-%d")
|
| except ValueError:
|
| valid_until = datetime.fromisoformat(valid_until)
|
| if isinstance(valid_until, date) and not isinstance(valid_until, datetime):
|
| valid_until = datetime.combine(valid_until, datetime.max.time())
|
| record["valid_until"] = valid_until
|
|
|
| # Duplicate detection prior to insertion
|
| # Use tz-aware bounds matching the stored date (date_value is always tz-aware ET)
|
| lookup_start = datetime.combine(date_value.date(), datetime.min.time(), tzinfo=date_value.tzinfo) # tz-ok
|
| lookup_end = lookup_start + timedelta(days=1) # calendar-ok
|
| base_query = {
|
| "symbol": record["symbol"],
|
| "action": record["action"],
|
| "date": {"$gte": lookup_start, "$lt": lookup_end},
|
| "account_nick": record["account_nick"],
|
| }
|
| potential = rr.find(base_query, {"metadata": 1, "shares": 1, "dispatch": 1, "status": 1})
|
| for existing in potential:
|
| existing_status = str(existing.get("status") or "")
|
| if existing_status in ("cancelled", "canceled"):
|
| continue # don't match against cancelled plans
|
| existing_metadata = existing.get("metadata") or {}
|
| existing_leg = str(existing_metadata.get("leg") or "").strip().lower() or None
|
| existing_account = str(existing_metadata.get("account_id") or "").strip()
|
| existing_shares = int(existing.get("shares") or 0)
|
| # Scalp PDT plans have different dispatch tags (Plan A/B/D/E) for the
|
| # same symbol — don't dedup them. Only dedup if dispatch also matches.
|
| existing_dispatch = str(existing.get("dispatch") or "").strip()
|
| record_dispatch = str(record.get("dispatch") or "").strip()
|
| # Shares match: required for most plans (different shares = intentional chunks).
|
| # Exception: verdict buy_back / scalp_accumulate re-runs recalculate shares
|
| # each time (price changes). For these, same dispatch = same intent regardless
|
| # of shares. The dispatch tag already encodes symbol+account uniquely.
|
| shares_match = existing_shares == int(record.get("shares") or 0)
|
| # Recompute-each-run dispatches: every re-run recalculates shares/stop from the CURRENT
|
| # position or price, so SAME dispatch = SAME intent regardless of share count. Dedup by
|
| # dispatch and UPDATE the existing pending plan in place (operator 2026-06-23: eod-stop-
|
| # refresh + verdict-profit-protect were inserting 100s of duplicate plans/day, bloating
|
| # the executor queue and delaying real fills).
|
| _recompute_dispatch = record_dispatch.startswith((
|
| "verdict-buy_back-", "verdict-scalp-acc-", "verdict-add-",
|
| "eod-stop-refresh-", "verdict-profit-protect-", "profit-protect-stop-",
|
| ))
|
| if (
|
| existing_leg == normalized_leg
|
| and existing_account == normalized_account
|
| and (shares_match or _recompute_dispatch)
|
| and existing_dispatch == record_dispatch
|
| ):
|
| if _recompute_dispatch and not shares_match:
|
| # Refresh the existing pending plan with the latest shares/stop instead of a dup.
|
| _upd = {"shares": int(record.get("shares") or 0), "updated_at": datetime.now()}
|
| for _k in ("price", "limit_price", "price_limit", "price_operator",
|
| "level_operator", "level_limit", "notes"):
|
| if _k in record:
|
| _upd[_k] = record[_k]
|
| rr.update_one({"_id": existing.get("_id")}, {"$set": _upd})
|
| logger.info(
|
| "[add_trading_plan] Duplicate {}/{} {} dispatch={} shares={}vs{}; {} plan {}",
|
| record["symbol"], record["action"], lookup_start.date(), record_dispatch,
|
| existing_shares, int(record.get("shares") or 0),
|
| "UPDATED" if (_recompute_dispatch and not shares_match) else "kept",
|
| existing.get("_id"),
|
| )
|
| return existing.get("_id")
|
|
|
| # Assert data integrity before insert
|
| assert record["symbol"], "Symbol cannot be empty"
|
| assert record["action"] in ["buy", "sell", "hold", "cover", "short", "snipe_call", "snipe_put", "reap_call", "reap_put", "roll_call", "roll_put", "option_open"], f"Invalid action: {record['action']}"
|
| assert isinstance(record["date"], (datetime, date)), "Date must be datetime"
|
| assert record["shares"] >= 0, "Shares must be non-negative"
|
| assert record["action_start"] >= 0, "Action start must be non-negative"
|
| assert record["action_end"] > record["action_start"], "Action end must be after action start"
|
|
|
| metadata_keys = sorted((record.get("metadata") or {}).keys())
|
| logger.info(
|
| "[add_trading_plan] Inserting plan | symbol={} action={} shares={} amount={:.2f} price={:.2f} start={} end={} metadata_keys={}",
|
| record["symbol"],
|
| record["action"],
|
| record["shares"],
|
| float(record.get("amount", 0.0)),
|
| float(record.get("price", 0.0)),
|
| record.get("action_start"),
|
| record.get("action_end"),
|
| metadata_keys,
|
| )
|
|
|
| try:
|
| result = rr.insert_one(record)
|
|
|
| # Assert insertion succeeded
|
| assert result.inserted_id, "Failed to insert trading plan"
|
|
|
| # Verify the record was actually inserted
|
| inserted = rr.find_one({"_id": result.inserted_id})
|
| assert inserted, f"Failed to verify inserted plan with ID {result.inserted_id}"
|
|
|
| logger.info(f"[add_trading_plan] Successfully inserted plan with ID: {result.inserted_id}")
|
|
|
| # Publish plan create event for real-time updates
|
| # Send the complete plan data for the event
|
| # Format date as YYYY-MM-DD for consistency with frontend
|
| if isinstance(record["date"], datetime):
|
| date_str = record["date"].strftime("%Y-%m-%d")
|
| elif isinstance(record["date"], date):
|
| date_str = record["date"].strftime("%Y-%m-%d")
|
| else:
|
| date_str = str(record["date"])
|
|
|
| plan_data = {
|
| "_id": str(result.inserted_id),
|
| "symbol": record["symbol"],
|
| "action": record["action"],
|
| "date": date_str,
|
| "shares": record["shares"],
|
| "amount": record["amount"],
|
| "price": record["price"],
|
| "notes": record["notes"],
|
| "status": record["status"],
|
| "action_start": record["action_start"],
|
| "action_end": record["action_end"],
|
| "trigger": record.get("trigger", ""),
|
| "price_operator": record.get("price_operator", ""),
|
| "price_limit": record.get("price_limit", 0.0),
|
| "level_operator": record.get("level_operator", ""),
|
| "level_limit": record.get("level_limit", 0.0),
|
| "flow_bias": record.get("flow_bias", ""),
|
| "signal_bias": record.get("signal_bias", ""),
|
| "tide_bias": record.get("tide_bias", ""),
|
| "flow_window_minutes": record.get("flow_window_minutes"),
|
| "flow_min_delta_pct": record.get("flow_min_delta_pct"),
|
| "alpaca_mode": record.get("alpaca_mode", "live"),
|
| "request_by": record.get("request_by"),
|
| "requested_by": record.get("request_by"),
|
| }
|
| PlanEventPublisher.publish_create(str(result.inserted_id), plan_data)
|
|
|
| # Discord notification for plan creation
|
| _notify_trading_plan(
|
| "CREATED", record["symbol"], record["action"],
|
| record["shares"], account=record.get("account_nick", ""),
|
| status=record["status"], dispatch=record.get("dispatch", ""),
|
| notes=record.get("notes", ""), plan_date=date_str,
|
| )
|
|
|
| # Send notification via report.notify
|
| from rtrader.utils import report
|
| notification_msg = (
|
| f"Trading plan to {record['action']} {record['shares']} {record['symbol']} "
|
| f"scheduled for minutes {record['action_start']}-{record['action_end']} "
|
| f"(requested by {record.get('request_by')})!"
|
| )
|
| report.notify(notification_msg, "Trading Plan", type="success")
|
|
|
| return result.inserted_id
|
|
|
| except Exception as e:
|
| import traceback
|
| logger.error(f"[add_trading_plan] Database operation failed: {str(e)}")
|
| logger.error(f"[add_trading_plan] Stack trace:\n{traceback.format_exc()}")
|
| > raise AssertionError(f"Database operation failed: {str(e)}")
|
| E AssertionError: Database operation failed: Failed to verify inserted plan with ID test-id
|
|
|
| rtrader/database.py:5644: AssertionError
|