Skip to content

Commit ab1d687

Browse files
authored
Merge pull request #104 from JinBoatus1/main
post-trade analysis
2 parents 22d473b + 8a1a4b3 commit ab1d687

10 files changed

Lines changed: 564 additions & 38 deletions

File tree

dashboard/backend/api/routers/backtests.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -197,13 +197,15 @@ def run_backtest_background(
197197
strategy_prompt: Optional[str] = None,
198198
model: Optional[str] = None,
199199
pipeline: Optional[List[Dict[str, Any]]] = None,
200+
agent_id: Optional[str] = None,
200201
):
201202
"""Run backtest in background thread."""
202203
global backtest_status, backtest_session_id
203204

204205
strategy_prompt_path = None
205206
pipeline_path = None
206207
progress_file = None
208+
live_run_id = None
207209
try:
208210
import subprocess
209211
import sys
@@ -304,33 +306,63 @@ def run_backtest_background(
304306
print(f"✅ Backtest completed. Found {len(runs)} runs in database.", flush=True)
305307
if len(runs) > 0:
306308
print(f" Latest run IDs: {[r['run_id'] for r in runs[:3]]}", flush=True)
309+
_maybe_writeback_adapted_pipeline(agent_id, live_run_id)
307310
except Exception as e:
308311
backtest_status["error"] = str(e)
309312
print(f"❌ Backtest exception: {e}", flush=True)
310313
finally:
314+
import os
315+
311316
backtest_status["running"] = False
312317
backtest_status["started_at"] = None
313318
backtest_status["live_run_id"] = None
319+
backtest_status["progress_file"] = None
314320
if progress_file:
315321
try:
316-
import os
317-
os.remove(progress_file)
322+
Path(progress_file).unlink(missing_ok=True)
318323
except OSError:
319324
pass
320-
backtest_status["progress_file"] = None
321325
if strategy_prompt_path:
322326
try:
323-
import os
324327
os.remove(strategy_prompt_path)
325328
except OSError:
326329
pass
327330
if pipeline_path:
328331
try:
329-
import os
330332
os.remove(pipeline_path)
331333
except OSError:
332334
pass
333-
print(f"✋ Backtest background thread finished", flush=True)
335+
print("✋ Backtest background thread finished", flush=True)
336+
337+
338+
def _maybe_writeback_adapted_pipeline(agent_id: Optional[str], run_id: Optional[str]) -> None:
339+
"""Persist post-trade adapted pipeline back onto the agent row."""
340+
if not agent_id or not run_id:
341+
return
342+
run = db.get_run(run_id)
343+
if not run:
344+
return
345+
metadata = run.get("metadata")
346+
if isinstance(metadata, str):
347+
try:
348+
metadata = json.loads(metadata)
349+
except json.JSONDecodeError:
350+
metadata = None
351+
if not isinstance(metadata, dict):
352+
return
353+
adaptations = metadata.get("prompt_adaptations")
354+
final_pipeline = metadata.get("final_pipeline")
355+
if not adaptations or not isinstance(final_pipeline, list) or not final_pipeline:
356+
return
357+
try:
358+
agent_service.update_agent(agent_id, pipeline=final_pipeline)
359+
print(
360+
f"✅ Wrote adapted pipeline back to agent {agent_id} "
361+
f"({len(adaptations)} adaptation day(s))",
362+
flush=True,
363+
)
364+
except Exception as exc:
365+
print(f"⚠️ Could not write adapted pipeline to agent {agent_id}: {exc}", flush=True)
334366

335367
class BacktestRunRequest(BaseModel):
336368
"""Optional JSON body for POST /backtest/run.
@@ -535,7 +567,7 @@ async def run_backtest_endpoint(
535567
print(f"🧵 Starting background thread for backtest", flush=True)
536568
thread = threading.Thread(
537569
target=run_backtest_background,
538-
args=(start_date, end_date, session_id, strategy_prompt, model, pipeline),
570+
args=(start_date, end_date, session_id, strategy_prompt, model, pipeline, agent_id),
539571
daemon=True
540572
)
541573
thread.start()

dashboard/backend/app.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,5 +349,7 @@ async def serve_image(file_name: str):
349349
# Canonical startup is ``uvicorn dashboard.backend.app:app``. This direct
350350
# invocation is a deprecated compatibility path; reference the app by its
351351
# canonical import string so the reloader resolves the same module identity.
352+
import os
352353
import uvicorn
353-
uvicorn.run("dashboard.backend.app:app", host="0.0.0.0", port=8000, reload=True)
354+
port = int(os.environ.get("PORT", "8000"))
355+
uvicorn.run("dashboard.backend.app:app", host="0.0.0.0", port=port, reload=True)

dashboard/backend/domain/backtesting/engine.py

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@
4040
default_model_name,
4141
make_llm_client,
4242
)
43+
from dashboard.backend.infrastructure.llm.pipeline_runner import (
44+
is_last_bar_of_trading_day,
45+
recombine_pipeline,
46+
run_post_trade_analysis,
47+
split_pipeline,
48+
trading_day_key,
49+
)
4350

4451

4552
class HourlyBacktester:
@@ -66,6 +73,10 @@ def __init__(self, start_date: str, end_date: str, session_id: str = "legacy-dem
6673
self.strategy_prompt = (strategy_prompt or "").strip() or None
6774
# Optional sub-agent pipeline (when set, overrides strategy_prompt).
6875
self.pipeline = pipeline if pipeline else None
76+
self.initial_pipeline = (
77+
json.loads(json.dumps(self.pipeline)) if self.pipeline else None
78+
)
79+
self.prompt_adaptations: List[Dict] = []
6980
# Model id; defaults to the gateway-appropriate slug (CommonStack vs native).
7081
self.model = model or default_model_name()
7182
self.live_run_id = (live_run_id or "").strip() or None
@@ -178,10 +189,66 @@ def _llm_run_metadata(self) -> Optional[Dict]:
178189
LLM_MAX_OUTPUT_TOKENS is an env knob that changes a run's spend and
179190
response truncation; recording the EFFECTIVE value (post defensive
180191
parse) makes runs auditable after the env changes. Rule-based runs
181-
record nothing."""
182-
if not self.use_llm:
183-
return None
184-
return {"llm_max_output_tokens": llm_harness.DEFAULT_MAX_OUTPUT_TOKENS}
192+
record nothing unless post-trade adaptations were produced."""
193+
meta: Dict = {}
194+
if self.use_llm:
195+
meta["llm_max_output_tokens"] = llm_harness.DEFAULT_MAX_OUTPUT_TOKENS
196+
if self.prompt_adaptations:
197+
meta["prompt_adaptations"] = self.prompt_adaptations
198+
if self.initial_pipeline is not None:
199+
meta["initial_pipeline"] = self.initial_pipeline
200+
if self.pipeline is not None:
201+
meta["final_pipeline"] = self.pipeline
202+
return meta or None
203+
204+
def _current_equity(self, manager: PortfolioManager) -> float:
205+
if manager.equity_history:
206+
return float(manager.equity_history[-1].get("equity") or manager.cash)
207+
return float(manager.cash)
208+
209+
def _run_daily_post_trade(
210+
self,
211+
*,
212+
manager: PortfolioManager,
213+
day_episode: Dict,
214+
post_trade_steps: List[Dict],
215+
) -> None:
216+
"""Run once-per-day post-trade analysis and mutate ``self.pipeline``."""
217+
if not post_trade_steps or not self.use_llm or not self.llm_client:
218+
return
219+
if not day_episode.get("trading_day"):
220+
return
221+
222+
decision_steps, _ = split_pipeline(self.pipeline)
223+
start_eq = float(day_episode.get("day_start_equity") or 0)
224+
end_eq = self._current_equity(manager)
225+
day_return = ((end_eq - start_eq) / start_eq) if start_eq else 0.0
226+
trade_start = int(day_episode.get("trade_start_index") or 0)
227+
day_trades = manager.trades[trade_start:]
228+
229+
episode_context = {
230+
"trading_day": day_episode.get("trading_day"),
231+
"day_start_equity": start_eq,
232+
"day_end_equity": end_eq,
233+
"day_return": day_return,
234+
"trade_count": len(day_trades),
235+
"trades": day_trades,
236+
"latest_step_outputs": day_episode.get("latest_step_outputs") or [],
237+
}
238+
239+
patched, record, (in_tok, out_tok), calls = run_post_trade_analysis(
240+
self.llm_client,
241+
post_trade_steps=post_trade_steps,
242+
episode_context=episode_context,
243+
decision_pipeline=decision_steps,
244+
model=self.model,
245+
)
246+
manager.input_tokens += in_tok
247+
manager.output_tokens += out_tok
248+
manager.llm_calls += calls
249+
if record:
250+
self.prompt_adaptations.append(record)
251+
self.pipeline = recombine_pipeline(patched, post_trade_steps)
185252

186253
def run_agent_backtest(self) -> Tuple[str, List[Dict]]:
187254
"""Run backtest with agent making hourly decisions."""
@@ -192,6 +259,12 @@ def run_agent_backtest(self) -> Tuple[str, List[Dict]]:
192259
llm_model = "rule-based" # Default
193260

194261
manager = PortfolioManager(initial_capital=INITIAL_CAPITAL)
262+
_decision_steps, post_trade_steps = split_pipeline(self.pipeline)
263+
if post_trade_steps:
264+
print(
265+
f" Post-trade analysis: {len(post_trade_steps)} step(s), "
266+
"once per trading day\n"
267+
)
195268

196269
# Get all timestamps
197270
all_timestamps = set()
@@ -251,9 +324,25 @@ def run_agent_backtest(self) -> Tuple[str, List[Dict]]:
251324
price_cache[symbol][timestamp] = last_price
252325

253326
print(" ✅ Cache ready\n")
327+
328+
day_episode: Dict = {
329+
"trading_day": None,
330+
"day_start_equity": None,
331+
"trade_start_index": 0,
332+
"latest_step_outputs": [],
333+
}
254334

255335
# Hourly loop
256336
for i, timestamp in enumerate(all_timestamps):
337+
day_key = trading_day_key(timestamp)
338+
if day_episode["trading_day"] != day_key:
339+
day_episode = {
340+
"trading_day": day_key,
341+
"day_start_equity": self._current_equity(manager),
342+
"trade_start_index": len(manager.trades),
343+
"latest_step_outputs": [],
344+
}
345+
257346
# Get market data for this hour (real data when available)
258347
market_data = {}
259348
for symbol in DJIA_30:
@@ -281,6 +370,8 @@ def run_agent_backtest(self) -> Tuple[str, List[Dict]]:
281370
llm_calls_count += 1 # Track that LLM was used
282371
if llm_calls_count == 1: # Set on first call
283372
llm_model = self.model
373+
if manager.last_pipeline_step_outputs:
374+
day_episode["latest_step_outputs"] = manager.last_pipeline_step_outputs
284375
else:
285376
decision = manager.make_trading_decision(state)
286377

@@ -290,6 +381,13 @@ def run_agent_backtest(self) -> Tuple[str, List[Dict]]:
290381
# Update equity (uses forward-filled prices for smooth valuation)
291382
manager.update_equity(market_data, price_cache, timestamp)
292383
self._publish_live_progress(i + 1, total_steps, manager)
384+
385+
if post_trade_steps and is_last_bar_of_trading_day(all_timestamps, i):
386+
self._run_daily_post_trade(
387+
manager=manager,
388+
day_episode=day_episode,
389+
post_trade_steps=post_trade_steps,
390+
)
293391

294392
# Progress
295393
if (i + 1) % 100 == 0:
@@ -345,6 +443,8 @@ def run_agent_backtest(self) -> Tuple[str, List[Dict]]:
345443
print(f" • LLM Calls: {llm_calls_count}")
346444
print(f" • Tokens: {manager.input_tokens:,} in / {manager.output_tokens:,} out (est. cost ${est_cost:.4f})")
347445
print(f" • Trades: {len(manager.trades)}")
446+
if self.prompt_adaptations:
447+
print(f" • Post-trade adaptations: {len(self.prompt_adaptations)} day(s)")
348448
print(f" • Final: ${final_eq:,.0f}")
349449
print(f" • Return: {total_return*100:+.2f}%\n")
350450

dashboard/backend/domain/backtesting/portfolio_manager.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ def __init__(self, initial_capital: float = 100000):
7070
self.llm_decisions = 0 # steps the model actually drove (H6 coverage)
7171
self.input_tokens = 0
7272
self.output_tokens = 0
73-
73+
# Latest decision-pipeline step outputs (for daily post-trade analysis).
74+
self.last_pipeline_step_outputs = []
7475
def get_portfolio_state(self, market_data: Dict[str, pd.Series], price_cache: Dict = None, timestamp = None) -> Dict:
7576
"""Get current portfolio state with market indicators.
7677
@@ -287,15 +288,18 @@ def _trend_score(sig: Dict) -> float:
287288

288289
if pipeline:
289290
print(f" Sub-agent pipeline: {len(pipeline)} step(s)")
290-
decision, (input_delta, output_delta), pipeline_calls = run_pipeline_decision(
291-
llm_client,
292-
pipeline=pipeline,
293-
market_snapshot=market_snapshot,
294-
model=model,
291+
decision, (input_delta, output_delta), pipeline_calls, step_outputs = (
292+
run_pipeline_decision(
293+
llm_client,
294+
pipeline=pipeline,
295+
market_snapshot=market_snapshot,
296+
model=model,
297+
)
295298
)
296299
self.input_tokens += input_delta
297300
self.output_tokens += output_delta
298301
self.llm_calls += pipeline_calls
302+
self.last_pipeline_step_outputs = step_outputs or []
299303
if decision is None:
300304
print(" Falling back to rule-based logic")
301305
return self.make_trading_decision(portfolio_state)

0 commit comments

Comments
 (0)