DhanHQ 3.1.0 → 3.2.1

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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +47 -1
  3. data/CHANGELOG.md +73 -0
  4. data/README.md +241 -3
  5. data/docs/AUTHENTICATION.md +3 -5
  6. data/docs/CONFIGURATION.md +3 -2
  7. data/docs/CONSTANTS_REFERENCE.md +5 -4
  8. data/docs/ENDPOINTS_AND_SANDBOX.md +1 -0
  9. data/docs/LIVE_ORDER_UPDATES.md +5 -10
  10. data/docs/RAILS_INTEGRATION.md +1 -1
  11. data/docs/RELEASE_GUIDE.md +13 -2
  12. data/docs/STANDALONE_RUBY_WEBSOCKET_INTEGRATION.md +2 -2
  13. data/docs/WEBSOCKET_INTEGRATION.md +13 -20
  14. data/docs/WEBSOCKET_PROTOCOL.md +7 -3
  15. data/lib/DhanHQ/agent/key_coercion.rb +36 -0
  16. data/lib/DhanHQ/agent/tool.rb +37 -0
  17. data/lib/DhanHQ/agent/tool_catalogue.rb +180 -0
  18. data/lib/DhanHQ/agent/tool_handlers.rb +116 -0
  19. data/lib/DhanHQ/agent/tool_registry.rb +17 -261
  20. data/lib/DhanHQ/agent/tool_schemas.rb +160 -0
  21. data/lib/DhanHQ/ai/prompt_helpers.rb +17 -7
  22. data/lib/DhanHQ/client.rb +105 -30
  23. data/lib/DhanHQ/concerns/order_audit.rb +35 -4
  24. data/lib/DhanHQ/configuration.rb +70 -1
  25. data/lib/DhanHQ/constants.rb +101 -0
  26. data/lib/DhanHQ/contracts/expired_options_data_contract.rb +1 -9
  27. data/lib/DhanHQ/contracts/global_stocks_estimator_contract.rb +28 -0
  28. data/lib/DhanHQ/contracts/global_stocks_modify_order_contract.rb +26 -0
  29. data/lib/DhanHQ/contracts/global_stocks_order_contract.rb +33 -0
  30. data/lib/DhanHQ/contracts/global_stocks_place_order_contract.rb +75 -0
  31. data/lib/DhanHQ/contracts/historical_data_contract.rb +1 -21
  32. data/lib/DhanHQ/contracts/multi_order_contract.rb +74 -0
  33. data/lib/DhanHQ/contracts/trade_history_contract.rb +1 -8
  34. data/lib/DhanHQ/dry_run/ledger.rb +71 -0
  35. data/lib/DhanHQ/dry_run/simulator.rb +140 -0
  36. data/lib/DhanHQ/helpers/attribute_helper.rb +23 -0
  37. data/lib/DhanHQ/models/alert_order.rb +6 -3
  38. data/lib/DhanHQ/models/global_stocks/funds.rb +56 -0
  39. data/lib/DhanHQ/models/global_stocks/holding.rb +101 -0
  40. data/lib/DhanHQ/models/global_stocks/margin.rb +54 -0
  41. data/lib/DhanHQ/models/global_stocks/market_status.rb +63 -0
  42. data/lib/DhanHQ/models/global_stocks/order.rb +189 -0
  43. data/lib/DhanHQ/models/global_stocks/order_estimate.rb +61 -0
  44. data/lib/DhanHQ/models/global_stocks/trade.rb +74 -0
  45. data/lib/DhanHQ/models/margin.rb +5 -1
  46. data/lib/DhanHQ/models/multi_order.rb +130 -0
  47. data/lib/DhanHQ/resources/global_stocks/funds.rb +25 -0
  48. data/lib/DhanHQ/resources/global_stocks/holdings.rb +22 -0
  49. data/lib/DhanHQ/resources/global_stocks/margin_calculator.rb +70 -0
  50. data/lib/DhanHQ/resources/global_stocks/market_status.rb +22 -0
  51. data/lib/DhanHQ/resources/global_stocks/orders.rb +112 -0
  52. data/lib/DhanHQ/resources/global_stocks/trades.rb +31 -0
  53. data/lib/DhanHQ/resources/multi_orders.rb +58 -0
  54. data/lib/DhanHQ/risk/pipeline.rb +0 -2
  55. data/lib/DhanHQ/version.rb +1 -1
  56. data/lib/DhanHQ/write_paths.rb +57 -0
  57. data/lib/DhanHQ/ws/client.rb +117 -2
  58. data/lib/DhanHQ/ws/connection.rb +40 -11
  59. data/lib/DhanHQ/ws/sub_state.rb +14 -0
  60. data/lib/dhan_hq.rb +4 -0
  61. data/lib/dhanhq/analysis/options_buying_advisor.rb +11 -10
  62. data/skills/dhanhq-ruby/SKILL.md +2 -1
  63. data/skills/dhanhq-ruby/examples/order_management.rb +7 -0
  64. data/skills/dhanhq-ruby/references/common-workflows.md +8 -8
  65. data/skills/dhanhq-ruby/references/instruments.md +7 -1
  66. data/skills/dhanhq-ruby/references/orders.md +2 -0
  67. metadata +30 -10
  68. data/.rspec +0 -3
  69. data/.rubocop.yml +0 -50
  70. data/.rubocop_todo.yml +0 -217
  71. data/Rakefile +0 -14
  72. data/TAGS +0 -10
  73. data/core +0 -0
  74. data/diagram.html +0 -184
  75. data/watchlist.csv +0 -3
@@ -2,35 +2,17 @@
2
2
 
3
3
  module DhanHQ
4
4
  module Agent
5
- # Machine-readable tool metadata shared by MCP and agent skills.
5
+ # Registry and dispatcher for the tools exposed to MCP clients and agent skills.
6
6
  #
7
- # Each tool has:
8
- # - name, description, scope, risk, schema (input), handler
9
- # - version: semantic version of the tool definition
10
- # - output_schema: JSON Schema for the tool's return value
11
- # - examples: array of example input/output pairs
12
- # rubocop:disable Metrics/ModuleLength
7
+ # Holds the catalogue (which tools exist, at what scope and risk) and enforces
8
+ # {DhanHQ::Agent::Policy} on every call. The tools themselves are described by
9
+ # {DhanHQ::Agent::ToolSchemas} and carried out by {DhanHQ::Agent::ToolHandlers};
10
+ # each tool is a {DhanHQ::Agent::Tool}.
13
11
  module ToolRegistry
14
- Tool = Struct.new(:name, :description, :scope, :risk, :schema, :handler,
15
- :version, :output_schema, :examples) do
16
- def to_h
17
- {
18
- name: name,
19
- description: description,
20
- scope: scope,
21
- risk: risk,
22
- input_schema: schema,
23
- output_schema: output_schema,
24
- version: version,
25
- examples: examples
26
- }.compact
27
- end
28
- end
29
-
30
12
  module_function
31
13
 
32
14
  def tools
33
- @tools ||= build_tools.freeze
15
+ @tools ||= ToolCatalogue.all.freeze
34
16
  end
35
17
 
36
18
  def find(name)
@@ -43,12 +25,17 @@ module DhanHQ
43
25
 
44
26
  def execute(name, arguments = {}, policy: Policy.from_env)
45
27
  tool = find(name)
46
- if tool.risk.to_s.end_with?("write") || tool.risk == "destructive_write"
47
- policy.require_write!(tool.scope)
48
- else
49
- policy.require!(tool.scope)
50
- end
51
- tool.handler.call(symbolize(arguments))
28
+ tool.write? ? policy.require_write!(tool.scope) : policy.require!(tool.scope)
29
+ tool.handler.call(KeyCoercion.symbolize(arguments))
30
+ end
31
+
32
+ # Coerces JSON-delivered argument keys to symbols.
33
+ # Part of this module's surface because {DhanHQ::Agent::OrderPreview} calls it.
34
+ #
35
+ # @param value [Hash, Array, Object]
36
+ # @return [Hash, Array, Object]
37
+ def symbolize(value)
38
+ KeyCoercion.symbolize(value)
52
39
  end
53
40
 
54
41
  # Returns capability manifest for the agent runtime.
@@ -63,237 +50,6 @@ module DhanHQ
63
50
  write_enabled: Policy.from_env.writes_enabled?
64
51
  }
65
52
  end
66
-
67
- def build_tools
68
- (primitive_tools + skill_tools).to_h { |tool_item| [tool_item.name, tool_item] }
69
- end
70
-
71
- def primitive_tools
72
- [
73
- tool("dhan_profile", "Fetch Dhan profile", "portfolio:read", "read_only",
74
- object_schema, profile_handler,
75
- version: "1.0.0",
76
- output_schema: { type: "object", properties: { client_id: { type: "string" } } }),
77
- tool("dhan_funds", "Fetch fund limits", "portfolio:read", "read_only",
78
- object_schema, funds_handler,
79
- version: "1.0.0",
80
- output_schema: { type: "object", properties: { available_balance: { type: "number" } } }),
81
- tool("dhan_holdings", "List holdings", "portfolio:read", "read_only",
82
- object_schema, holdings_handler,
83
- version: "1.0.0",
84
- output_schema: { type: "array", items: { type: "object" } }),
85
- tool("dhan_positions", "List positions", "portfolio:read", "read_only",
86
- object_schema, positions_handler,
87
- version: "1.0.0",
88
- output_schema: { type: "array", items: { type: "object" } }),
89
- tool("dhan_orders", "List orders", "orders:read", "read_only",
90
- object_schema, orders_handler,
91
- version: "1.0.0",
92
- output_schema: { type: "array", items: { type: "object" } }),
93
- tool("dhan_trades", "List trades", "orders:read", "read_only",
94
- object_schema, trades_handler,
95
- version: "1.0.0",
96
- output_schema: { type: "array", items: { type: "object" } }),
97
- tool("dhan_search_instruments", "Resolve symbols to security IDs", "market:read", "read_only",
98
- search_schema, search_handler,
99
- version: "1.0.0",
100
- output_schema: { type: "array", items: { type: "object" } },
101
- examples: [
102
- { input: { query: "RELIANCE" }, output: "[{security_id: '2885', symbol_name: 'RELIANCE'}]" }
103
- ]),
104
- tool("dhan_ltp", "Fetch last traded prices", "market:read", "read_only",
105
- feed_schema, ltp_handler,
106
- version: "1.0.0",
107
- output_schema: { type: "object", additionalProperties: { type: "number" } }),
108
- tool("dhan_quote", "Fetch market quotes", "market:read", "read_only",
109
- feed_schema, quote_handler,
110
- version: "1.0.0",
111
- output_schema: { type: "object", additionalProperties: { type: "object" } }),
112
- tool("dhan_order_preview", "Validate and summarize an order without placing it", "orders:read",
113
- "trade_adjacent_read", order_schema, preview_handler,
114
- version: "1.0.0",
115
- output_schema: {
116
- type: "object",
117
- properties: {
118
- valid: { type: "boolean" },
119
- errors: { type: "array" },
120
- summary: { type: "string" }
121
- }
122
- }),
123
- tool("dhan_place_order", "Place an order after external confirmation", "orders:write", "live_write",
124
- order_schema, place_order_handler,
125
- version: "1.0.0",
126
- output_schema: { type: "object", properties: { order_id: { type: "string" } } }),
127
- tool("dhan_cancel_order", "Cancel an order", "orders:cancel", "destructive_write",
128
- cancel_schema, cancel_order_handler,
129
- version: "1.0.0",
130
- output_schema: { type: "object", properties: { order_id: { type: "string" }, status: { type: "string" } } })
131
- ]
132
- end
133
-
134
- # Exposes each registered DhanHQ::Skills::Registry strategy as an MCP tool,
135
- # gated by the risk/scope the skill class declares (see DhanHQ::Skills::Base).
136
- def skill_tools
137
- DhanHQ::Skills::Registry.list.map do |skill|
138
- klass = DhanHQ::Skills::Registry.find(skill[:name])
139
- tool("dhan_skill_#{skill[:name]}", skill[:description], klass.scope, klass.risk,
140
- skill_input_schema(skill[:params]),
141
- ->(arguments) { DhanHQ::Skills::Registry.call(skill[:name], arguments) },
142
- version: "1.0.0")
143
- end
144
- end
145
-
146
- def skill_input_schema(params)
147
- properties = params.transform_values do |config|
148
- { type: skill_param_type(config[:type]) }.tap { |h| h[:description] = config[:description] if config[:description] }
149
- end
150
- required = params.select { |_, config| config[:required] }.keys.map(&:to_s)
151
- { type: "object", properties: properties, required: required, additionalProperties: false }
152
- end
153
-
154
- def skill_param_type(type)
155
- { string: "string", integer: "integer", number: "number", boolean: "boolean" }.fetch(type.to_sym, "string")
156
- end
157
-
158
- # rubocop:disable Metrics/ParameterLists
159
- def tool(name, description, scope, risk, schema, handler, version: "1.0.0", output_schema: nil, examples: nil)
160
- Tool.new(
161
- name: name, description: description, scope: scope, risk: risk,
162
- schema: schema, handler: handler, version: version,
163
- output_schema: output_schema, examples: examples
164
- )
165
- end
166
- # rubocop:enable Metrics/ParameterLists
167
-
168
- def object_schema
169
- { type: "object", properties: {}, additionalProperties: false }
170
- end
171
-
172
- def search_schema
173
- {
174
- type: "object",
175
- required: ["query"],
176
- properties: {
177
- query: { type: "string" },
178
- segments: { type: "array", items: { type: "string" } },
179
- limit: { type: "integer", minimum: 1, maximum: 100 },
180
- exact_match: { type: "boolean" }
181
- },
182
- additionalProperties: false
183
- }
184
- end
185
-
186
- def feed_schema
187
- {
188
- type: "object",
189
- required: ["instruments"],
190
- properties: {
191
- instruments: {
192
- type: "object",
193
- additionalProperties: { type: "array", items: { type: %w[integer string] } }
194
- }
195
- },
196
- additionalProperties: false
197
- }
198
- end
199
-
200
- def order_schema
201
- {
202
- type: "object",
203
- required: %w[transaction_type exchange_segment product_type order_type validity security_id quantity],
204
- properties: {
205
- transaction_type: enum(%w[BUY SELL]),
206
- exchange_segment: { type: "string" },
207
- product_type: { type: "string" },
208
- order_type: { type: "string" },
209
- validity: { type: "string" },
210
- security_id: { type: "string" },
211
- quantity: { type: "integer", minimum: 1 },
212
- price: { type: "number" },
213
- trigger_price: { type: "number" },
214
- correlation_id: { type: "string" }
215
- },
216
- additionalProperties: true
217
- }
218
- end
219
-
220
- def cancel_schema
221
- {
222
- type: "object",
223
- required: ["order_id"],
224
- properties: { order_id: { type: "string" } },
225
- additionalProperties: false
226
- }
227
- end
228
-
229
- def enum(values)
230
- { type: "string", enum: values }
231
- end
232
-
233
- def profile_handler = ->(_) { DhanHQ::Models::Profile.fetch }
234
-
235
- def funds_handler = ->(_) { DhanHQ::Models::Funds.fetch }
236
-
237
- def holdings_handler = ->(_) { DhanHQ::Models::Holding.all }
238
-
239
- def positions_handler = ->(_) { DhanHQ::Models::Position.all }
240
-
241
- def orders_handler = ->(_) { DhanHQ::Models::Order.all }
242
-
243
- def trades_handler = ->(_) { DhanHQ::Models::Trade.today }
244
-
245
- def search_handler
246
- lambda do |arguments|
247
- query = arguments.fetch(:query)
248
- options = arguments.except(:query)
249
- DhanHQ::Models::Instrument.search(query, **options)
250
- end
251
- end
252
-
253
- def ltp_handler = ->(arguments) { DhanHQ::Models::MarketFeed.ltp(arguments[:instruments]) }
254
-
255
- def quote_handler = ->(arguments) { DhanHQ::Models::MarketFeed.quote(arguments[:instruments]) }
256
-
257
- def preview_handler = ->(arguments) { OrderPreview.new(arguments).to_h }
258
-
259
- def place_order_handler
260
- lambda do |arguments|
261
- instrument = DhanHQ::Models::Instrument.find_by_security_id(arguments[:exchange_segment], arguments[:security_id])
262
- unless instrument
263
- raise DhanHQ::RiskViolation,
264
- "Cannot verify risk for unknown instrument: #{arguments[:exchange_segment]}:#{arguments[:security_id]}"
265
- end
266
-
267
- risk_type = instrument.instrument_type.to_s.start_with?("OPT") ? :options : :equity
268
- DhanHQ::Risk::Pipeline.run!(instrument: instrument, args: stringify(arguments), type: risk_type)
269
-
270
- DhanHQ::Models::Order.place(arguments)
271
- end
272
- end
273
-
274
- def cancel_order_handler
275
- lambda do |arguments|
276
- order = DhanHQ::Models::Order.find(arguments[:order_id])
277
- order&.cancel || false
278
- end
279
- end
280
-
281
- def symbolize(value)
282
- case value
283
- when Hash then value.each_with_object({}) { |(key, val), hash| hash[key.to_sym] = symbolize(val) }
284
- when Array then value.map { |val| symbolize(val) }
285
- else value
286
- end
287
- end
288
-
289
- def stringify(value)
290
- case value
291
- when Hash then value.each_with_object({}) { |(key, val), hash| hash[key.to_s] = stringify(val) }
292
- when Array then value.map { |val| stringify(val) }
293
- else value
294
- end
295
- end
296
53
  end
297
- # rubocop:enable Metrics/ModuleLength
298
54
  end
299
55
  end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Agent
5
+ # JSON Schema fragments describing the input each agent tool accepts.
6
+ #
7
+ # Pure data: no API calls, no policy, no state. Kept apart from
8
+ # {DhanHQ::Agent::ToolRegistry} so that adding an endpoint means editing a schema
9
+ # here and a handler in {DhanHQ::Agent::ToolHandlers}, rather than growing one
10
+ # module that does registration, schema description and dispatch at once.
11
+ module ToolSchemas
12
+ module_function
13
+
14
+ def object_schema
15
+ { type: "object", properties: {}, additionalProperties: false }
16
+ end
17
+
18
+ def search_schema
19
+ {
20
+ type: "object",
21
+ required: ["query"],
22
+ properties: {
23
+ query: { type: "string" },
24
+ segments: { type: "array", items: { type: "string" } },
25
+ limit: { type: "integer", minimum: 1, maximum: 100 },
26
+ exact_match: { type: "boolean" }
27
+ },
28
+ additionalProperties: false
29
+ }
30
+ end
31
+
32
+ def feed_schema
33
+ {
34
+ type: "object",
35
+ required: ["instruments"],
36
+ properties: {
37
+ instruments: {
38
+ type: "object",
39
+ additionalProperties: { type: "array", items: { type: %w[integer string] } }
40
+ }
41
+ },
42
+ additionalProperties: false
43
+ }
44
+ end
45
+
46
+ def order_schema
47
+ {
48
+ type: "object",
49
+ required: %w[transaction_type exchange_segment product_type order_type validity security_id quantity],
50
+ properties: {
51
+ transaction_type: enum(%w[BUY SELL]),
52
+ exchange_segment: { type: "string" },
53
+ product_type: { type: "string" },
54
+ order_type: { type: "string" },
55
+ validity: { type: "string" },
56
+ security_id: { type: "string" },
57
+ quantity: { type: "integer", minimum: 1 },
58
+ price: { type: "number" },
59
+ trigger_price: { type: "number" },
60
+ correlation_id: { type: "string" }
61
+ },
62
+ additionalProperties: true
63
+ }
64
+ end
65
+
66
+ def cancel_schema
67
+ {
68
+ type: "object",
69
+ required: ["order_id"],
70
+ properties: { order_id: { type: "string" } },
71
+ additionalProperties: false
72
+ }
73
+ end
74
+
75
+ # Global Stocks orders carry no exchange segment, product type or validity, and
76
+ # quantity is fractional. AMOUNT orders replace quantity with a dollar amount.
77
+ def global_order_schema
78
+ {
79
+ type: "object",
80
+ required: %w[transaction_type order_type security_id],
81
+ properties: {
82
+ transaction_type: enum(%w[BUY SELL]),
83
+ order_type: enum(DhanHQ::Constants::GlobalStocks::OrderType::ALL),
84
+ security_id: { type: "string" },
85
+ quantity: { type: "number", exclusiveMinimum: 0 },
86
+ price: { type: "number", minimum: 0 },
87
+ trigger_price: { type: "number" },
88
+ stop_loss_price: { type: "number" },
89
+ target_price: { type: "number" },
90
+ amount: { type: "number", description: "Dollar value for AMOUNT orders" },
91
+ correlation_id: { type: "string" }
92
+ },
93
+ additionalProperties: true
94
+ }
95
+ end
96
+
97
+ def global_estimate_schema
98
+ {
99
+ type: "object",
100
+ required: %w[security_id transaction_type price quantity],
101
+ properties: {
102
+ security_id: { type: "string" },
103
+ transaction_type: enum(%w[BUY SELL]),
104
+ price: { type: "number", minimum: 0 },
105
+ quantity: { type: "number", exclusiveMinimum: 0 }
106
+ },
107
+ additionalProperties: false
108
+ }
109
+ end
110
+
111
+ def multi_order_schema
112
+ {
113
+ type: "object",
114
+ required: ["orders"],
115
+ properties: {
116
+ orders: {
117
+ type: "array",
118
+ minItems: 1,
119
+ maxItems: DhanHQ::Contracts::MultiOrderContract::MAX_ORDERS,
120
+ items: {
121
+ type: "object",
122
+ required: %w[sequence transaction_type exchange_segment],
123
+ properties: {
124
+ sequence: { type: "string" },
125
+ transaction_type: enum(%w[BUY SELL]),
126
+ exchange_segment: { type: "string" },
127
+ product_type: { type: "string" },
128
+ order_type: { type: "string" },
129
+ validity: { type: "string" },
130
+ security_id: { type: "string" },
131
+ quantity: { type: "integer", minimum: 1 },
132
+ price: { type: "number" },
133
+ trigger_price: { type: "number" }
134
+ },
135
+ additionalProperties: true
136
+ }
137
+ }
138
+ },
139
+ additionalProperties: false
140
+ }
141
+ end
142
+
143
+ def enum(values)
144
+ { type: "string", enum: values }
145
+ end
146
+
147
+ def skill_input_schema(params)
148
+ properties = params.transform_values do |config|
149
+ { type: skill_param_type(config[:type]) }.tap { |h| h[:description] = config[:description] if config[:description] }
150
+ end
151
+ required = params.select { |_, config| config[:required] }.keys.map(&:to_s)
152
+ { type: "object", properties: properties, required: required, additionalProperties: false }
153
+ end
154
+
155
+ def skill_param_type(type)
156
+ { string: "string", integer: "integer", number: "number", boolean: "boolean" }.fetch(type.to_sym, "string")
157
+ end
158
+ end
159
+ end
160
+ end
@@ -41,14 +41,20 @@ module DhanHQ
41
41
  # @param funds [DhanHQ::Models::Funds] Funds data
42
42
  # @return [String] Portfolio summary
43
43
  def self.portfolio_summary(holdings:, positions:, funds:)
44
+ # Array() so a missing collection renders as an empty section instead of
45
+ # raising. `funds` was already guarded; these two were not, and a prompt
46
+ # helper failing hard on an empty portfolio is the wrong trade.
47
+ all_holdings = Array(holdings)
48
+ open_positions = Array(positions).select(&:open?)
49
+
44
50
  lines = ["=== Portfolio Summary ==="]
45
51
  lines << "Funds: #{funds.to_prompt}" if funds
46
52
  lines << ""
47
- lines << "Holdings (#{holdings.size}):"
48
- holdings.each { |h| lines << " #{h.to_prompt}" }
53
+ lines << "Holdings (#{all_holdings.size}):"
54
+ all_holdings.each { |holding| lines << " #{holding.to_prompt}" }
49
55
  lines << ""
50
- lines << "Open Positions (#{positions.count(&:open?)}):"
51
- positions.select(&:open?).each { |p| lines << " #{p.to_prompt}" }
56
+ lines << "Open Positions (#{open_positions.size}):"
57
+ open_positions.each { |position| lines << " #{position.to_prompt}" }
52
58
  lines.join("\n")
53
59
  end
54
60
 
@@ -95,13 +101,17 @@ module DhanHQ
95
101
  # @param risk_params [Hash] Risk parameters
96
102
  # @return [String] Risk report
97
103
  def self.risk_report(positions:, risk_params: {})
104
+ # P&L is summed across every position; only the count is restricted to open
105
+ # ones, since a closed position still contributed realised P&L today.
106
+ all_positions = Array(positions)
107
+
98
108
  lines = ["=== Risk Report ==="]
99
- total_unrealized = positions.sum { |p| p.unrealized_profit.to_f }
100
- total_realized = positions.sum { |p| p.realized_profit.to_f }
109
+ total_unrealized = all_positions.sum { |position| position.unrealized_profit.to_f }
110
+ total_realized = all_positions.sum { |position| position.realized_profit.to_f }
101
111
 
102
112
  lines << "Total Unrealized P&L: ₹#{total_unrealized.round(2)}"
103
113
  lines << "Total Realized P&L: ₹#{total_realized.round(2)}"
104
- lines << "Open Positions: #{positions.count(&:open?)}"
114
+ lines << "Open Positions: #{all_positions.count(&:open?)}"
105
115
 
106
116
  lines << "Max Drawdown: #{risk_params[:max_drawdown]}%" if risk_params[:max_drawdown]
107
117