DhanHQ 2.8.0 → 3.0.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +7 -0
  3. data/CHANGELOG.md +19 -0
  4. data/README.md +16 -5
  5. data/docs/RAILS_WEBSOCKET_INTEGRATION.md +1 -1
  6. data/docs/STANDALONE_RUBY_WEBSOCKET_INTEGRATION.md +1 -1
  7. data/docs/TECHNICAL_ANALYSIS.md +1 -1
  8. data/exe/DhanHQ +1 -1
  9. data/lib/DhanHQ/agent/order_preview.rb +50 -0
  10. data/lib/DhanHQ/agent/policy.rb +51 -0
  11. data/lib/DhanHQ/agent/tool_registry.rb +250 -0
  12. data/lib/DhanHQ/agent.rb +12 -0
  13. data/lib/DhanHQ/ai/context_builder.rb +145 -0
  14. data/lib/DhanHQ/ai/prompt_helpers.rb +114 -0
  15. data/lib/DhanHQ/ai.rb +27 -0
  16. data/lib/DhanHQ/auth.rb +0 -1
  17. data/lib/DhanHQ/client.rb +1 -3
  18. data/lib/DhanHQ/constants.rb +2 -0
  19. data/lib/DhanHQ/contracts/iceberg_order_contract.rb +83 -0
  20. data/lib/DhanHQ/contracts/twap_order_contract.rb +106 -0
  21. data/lib/DhanHQ/core/auth_api.rb +0 -1
  22. data/lib/DhanHQ/errors.rb +4 -0
  23. data/lib/DhanHQ/events/base.rb +203 -0
  24. data/lib/DhanHQ/events/bus.rb +158 -0
  25. data/lib/DhanHQ/events.rb +40 -0
  26. data/lib/DhanHQ/indicators.rb +283 -0
  27. data/lib/DhanHQ/market_data/market_snapshot.rb +97 -0
  28. data/lib/DhanHQ/market_data/ohlc_series.rb +169 -0
  29. data/lib/DhanHQ/market_data/option_snapshot.rb +223 -0
  30. data/lib/DhanHQ/market_data.rb +25 -0
  31. data/lib/DhanHQ/mcp/server.rb +72 -0
  32. data/lib/DhanHQ/mcp.rb +10 -0
  33. data/lib/DhanHQ/models/funds.rb +12 -0
  34. data/lib/DhanHQ/models/holding.rb +42 -0
  35. data/lib/DhanHQ/models/iceberg_order.rb +139 -0
  36. data/lib/DhanHQ/models/instrument.rb +36 -0
  37. data/lib/DhanHQ/models/order.rb +95 -0
  38. data/lib/DhanHQ/models/position.rb +66 -0
  39. data/lib/DhanHQ/models/search_result.rb +12 -0
  40. data/lib/DhanHQ/models/trade.rb +13 -0
  41. data/lib/DhanHQ/models/twap_order.rb +136 -0
  42. data/lib/DhanHQ/option_analytics/black_scholes.rb +194 -0
  43. data/lib/DhanHQ/option_analytics/max_pain.rb +119 -0
  44. data/lib/DhanHQ/option_analytics.rb +36 -0
  45. data/lib/DhanHQ/resources/iceberg_orders.rb +61 -0
  46. data/lib/DhanHQ/resources/twap_orders.rb +61 -0
  47. data/lib/DhanHQ/risk/checks/asm_gsm.rb +17 -0
  48. data/lib/DhanHQ/risk/checks/market_hours.rb +37 -0
  49. data/lib/DhanHQ/risk/checks/options.rb +46 -0
  50. data/lib/DhanHQ/risk/checks/order_type.rb +20 -0
  51. data/lib/DhanHQ/risk/checks/product_support.rb +34 -0
  52. data/lib/DhanHQ/risk/checks/quantity.rb +32 -0
  53. data/lib/DhanHQ/risk/checks/trading_permission.rb +16 -0
  54. data/lib/DhanHQ/risk/pipeline.rb +65 -0
  55. data/lib/DhanHQ/risk.rb +250 -0
  56. data/lib/DhanHQ/skills/base.rb +132 -0
  57. data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +87 -0
  58. data/lib/DhanHQ/skills/builtin/iron_condor.rb +93 -0
  59. data/lib/DhanHQ/skills/builtin/square_off_all.rb +45 -0
  60. data/lib/DhanHQ/skills/builtin/square_off_position.rb +48 -0
  61. data/lib/DhanHQ/skills/builtin/strangle.rb +93 -0
  62. data/lib/DhanHQ/skills/registry.rb +101 -0
  63. data/lib/DhanHQ/skills/workflow.rb +66 -0
  64. data/lib/DhanHQ/skills.rb +29 -0
  65. data/lib/DhanHQ/strategy/base.rb +189 -0
  66. data/lib/DhanHQ/strategy.rb +40 -0
  67. data/lib/DhanHQ/version.rb +1 -1
  68. data/lib/DhanHQ/ws/decoder.rb +57 -19
  69. data/lib/DhanHQ.rb +3 -0
  70. data/lib/dhan_hq/agent.rb +3 -0
  71. data/lib/dhan_hq/analysis.rb +9 -0
  72. data/lib/dhan_hq/mcp.rb +3 -0
  73. data/lib/dhan_hq/ta.rb +5 -0
  74. data/lib/dhan_hq.rb +27 -4
  75. data/lib/ta/technical_analysis.rb +3 -1
  76. data/skills/dhanhq-ruby/SKILL.md +74 -0
  77. data/skills/dhanhq-ruby/references/market_data.md +3 -0
  78. data/skills/dhanhq-ruby/references/orders.md +7 -0
  79. metadata +61 -20
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module AI
5
+ # Helper methods for generating prompts for AI trading assistants.
6
+ #
7
+ # Provides methods to create system prompts, user prompts,
8
+ # and context summaries for AI models.
9
+ module PromptHelpers
10
+ # Generate a system prompt for an AI trading assistant.
11
+ #
12
+ # @param capabilities [Array<String>] List of capabilities
13
+ # @return [String] System prompt
14
+ def self.system_prompt(capabilities: [])
15
+ <<~PROMPT.strip
16
+ You are an AI trading assistant for Indian stock markets (NSE, BSE, MCX).
17
+
18
+ Your capabilities:
19
+ - Fetch market data (LTP, OHLC, quotes)
20
+ - Place, modify, and cancel orders
21
+ - View portfolio holdings, positions, and orders
22
+ - Calculate option Greeks and implied volatility
23
+ - Analyze option chains and Max Pain
24
+ - Apply risk management rules
25
+
26
+ #{"Additional capabilities:\n#{capabilities.map { |c| "- #{c}" }.join("\n")}" unless capabilities.empty?}
27
+
28
+ Rules:
29
+ - Always confirm before placing live orders
30
+ - Use correlation_id for all agent-originated orders
31
+ - Never expose access tokens or secrets
32
+ - Prefer read-only operations unless explicitly asked to trade
33
+ - Validate instruments before trading using search
34
+ PROMPT
35
+ end
36
+
37
+ # Generate a portfolio summary prompt.
38
+ #
39
+ # @param holdings [Array<DhanHQ::Models::Holding>] Holdings data
40
+ # @param positions [Array<DhanHQ::Models::Position>] Positions data
41
+ # @param funds [DhanHQ::Models::Funds] Funds data
42
+ # @return [String] Portfolio summary
43
+ def self.portfolio_summary(holdings:, positions:, funds:)
44
+ lines = ["=== Portfolio Summary ==="]
45
+ lines << "Funds: #{funds.to_prompt}" if funds
46
+ lines << ""
47
+ lines << "Holdings (#{holdings.size}):"
48
+ holdings.each { |h| lines << " #{h.to_prompt}" }
49
+ lines << ""
50
+ lines << "Open Positions (#{positions.count(&:open?)}):"
51
+ positions.select(&:open?).each { |p| lines << " #{p.to_prompt}" }
52
+ lines.join("\n")
53
+ end
54
+
55
+ # Generate a market analysis prompt.
56
+ #
57
+ # @param snapshot [DhanHQ::MarketData::MarketSnapshot] Market snapshot
58
+ # @param series [DhanHQ::MarketData::OHLCSeries] OHLC series
59
+ # @return [String] Market analysis prompt
60
+ def self.market_analysis(snapshot:, series: nil)
61
+ lines = ["=== Market Analysis ==="]
62
+ lines << "Snapshot: #{snapshot.size} instruments"
63
+ lines << "Series: #{series&.size || 0} candles"
64
+
65
+ if series&.any?
66
+ lines << "Latest close: ₹#{series.last.close}"
67
+ lines << "Average close: ₹#{series.average_close&.round(2)}"
68
+ lines << "Price range: ₹#{series.price_range&.round(2)}"
69
+ end
70
+
71
+ lines.join("\n")
72
+ end
73
+
74
+ # Generate an order confirmation prompt.
75
+ #
76
+ # @param order_params [Hash] Order parameters
77
+ # @return [String] Order confirmation prompt
78
+ def self.order_confirmation(order_params)
79
+ <<~PROMPT.strip
80
+ === Order Confirmation Required ===
81
+ #{order_params[:transaction_type]} #{order_params[:quantity]}x #{order_params[:security_id]}
82
+ Exchange: #{order_params[:exchange_segment]}
83
+ Product: #{order_params[:product_type]}
84
+ Type: #{order_params[:order_type]}
85
+ #{order_params[:price] ? "Price: ₹#{order_params[:price]}" : "Market Price"}
86
+ #{"Trigger: ₹#{order_params[:trigger_price]}" if order_params[:trigger_price]}
87
+
88
+ Please confirm this order.
89
+ PROMPT
90
+ end
91
+
92
+ # Generate a risk report prompt.
93
+ #
94
+ # @param positions [Array<DhanHQ::Models::Position>] Current positions
95
+ # @param risk_params [Hash] Risk parameters
96
+ # @return [String] Risk report
97
+ def self.risk_report(positions:, risk_params: {})
98
+ 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 }
101
+
102
+ lines << "Total Unrealized P&L: ₹#{total_unrealized.round(2)}"
103
+ lines << "Total Realized P&L: ₹#{total_realized.round(2)}"
104
+ lines << "Open Positions: #{positions.count(&:open?)}"
105
+
106
+ lines << "Max Drawdown: #{risk_params[:max_drawdown]}%" if risk_params[:max_drawdown]
107
+
108
+ lines << "Daily Loss Limit: ₹#{risk_params[:daily_loss_limit]}" if risk_params[:daily_loss_limit]
109
+
110
+ lines.join("\n")
111
+ end
112
+ end
113
+ end
114
+ end
data/lib/DhanHQ/ai.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ai/context_builder"
4
+ require_relative "ai/prompt_helpers"
5
+
6
+ module DhanHQ
7
+ # AI integration layer for building AI-powered trading assistants.
8
+ #
9
+ # Provides workflow orchestration, prompt helpers, and context
10
+ # serialization for AI agents.
11
+ #
12
+ # @example Build context for AI
13
+ # context = DhanHQ::AI::ContextBuilder.build do |ctx|
14
+ # ctx.add_portfolio
15
+ # ctx.add_positions
16
+ # ctx.add_recent_orders(limit: 10)
17
+ # end
18
+ # puts context.to_prompt
19
+ #
20
+ # @example Generate system prompt
21
+ # prompt = DhanHQ::AI::PromptHelpers.system_prompt(
22
+ # capabilities: ["Option chain analysis", "Greeks calculation"]
23
+ # )
24
+ #
25
+ module AI
26
+ end
27
+ end
data/lib/DhanHQ/auth.rb CHANGED
@@ -91,7 +91,6 @@ module DhanHQ
91
91
  def self.build_connection(base_url)
92
92
  Faraday.new(url: base_url) do |c|
93
93
  c.request :url_encoded
94
- c.response :json, content_type: /\bjson$/
95
94
  c.adapter Faraday.default_adapter
96
95
  end
97
96
  end
data/lib/DhanHQ/client.rb CHANGED
@@ -212,9 +212,7 @@ module DhanHQ
212
212
  write_timeout = ENV.fetch("DHAN_WRITE_TIMEOUT", 30).to_i
213
213
 
214
214
  Faraday.new(url: url) do |conn|
215
- conn.request :json, parser_options: { symbolize_names: true }
216
- conn.response :json, content_type: /\bjson$/
217
- conn.response :logger if ENV["DHAN_DEBUG"] == "true"
215
+ conn.request :url_encoded
218
216
  conn.options.timeout = read_timeout
219
217
  conn.options.open_timeout = connect_timeout
220
218
  conn.options.write_timeout = write_timeout
@@ -475,6 +475,8 @@ module DhanHQ
475
475
  PAYLOAD_REQUIRES_DHAN_CLIENT_ID_PREFIXES = %w[
476
476
  /alerts/orders
477
477
  /v2/orders
478
+ /v2/orders/iceberg
479
+ /v2/orders/twap
478
480
  /v2/forever
479
481
  /v2/super/orders
480
482
  /v2/positions
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_contract"
4
+
5
+ module DhanHQ
6
+ module Contracts
7
+ # Validates request for POST /v2/orders/iceberg (create Iceberg order).
8
+ #
9
+ # Iceberg orders split a large order into multiple visible legs of a fixed
10
+ # disclosed quantity, reducing market impact.
11
+ class IcebergOrderCreateContract < BaseContract
12
+ params do
13
+ required(:dhan_client_id).filled(:string)
14
+ required(:transaction_type).filled(:string, included_in?: TRANSACTION_TYPES)
15
+ required(:exchange_segment).filled(:string, included_in?: EXCHANGE_SEGMENTS)
16
+ required(:product_type).filled(:string, included_in?: PRODUCT_TYPES)
17
+ required(:order_type).filled(:string, included_in?: ORDER_TYPES)
18
+ required(:validity).filled(:string, included_in?: VALIDITY_TYPES)
19
+ required(:security_id).filled(:string)
20
+ required(:quantity).filled(:integer, gt?: 0)
21
+ required(:price).filled(:float, gt?: 0)
22
+ required(:iceberg_qty).filled(:integer, gt?: 0)
23
+ required(:disclosed_quantity).filled(:integer, gteq?: 0)
24
+ optional(:correlation_id).maybe(:string, max_size?: 30, format?: /\A[a-zA-Z0-9 _-]*\z/)
25
+ optional(:trigger_price).maybe(:float, gteq?: 0)
26
+ optional(:after_market_order).maybe(:bool)
27
+ optional(:amo_time).maybe(:string, included_in?: AMO_TIMINGS)
28
+ optional(:drv_expiry_date).maybe(:string)
29
+ optional(:drv_option_type).maybe(:string, included_in?: %w[CALL PUT NA])
30
+ optional(:drv_strike_price).maybe(:float, gt?: 0)
31
+ end
32
+
33
+ rule(:iceberg_qty) do
34
+ key.failure("must not exceed total quantity") if value && values[:quantity] && value > values[:quantity]
35
+ end
36
+ end
37
+
38
+ # Validates request for PUT /v2/orders/iceberg/{order-id} (modify Iceberg order).
39
+ class IcebergOrderModifyContract < BaseContract
40
+ params do
41
+ required(:dhan_client_id).filled(:string)
42
+ required(:order_id).filled(:string)
43
+
44
+ optional(:transaction_type).maybe(:string, included_in?: TRANSACTION_TYPES)
45
+ optional(:exchange_segment).maybe(:string, included_in?: EXCHANGE_SEGMENTS)
46
+ optional(:product_type).maybe(:string, included_in?: PRODUCT_TYPES)
47
+ optional(:order_type).maybe(:string, included_in?: ORDER_TYPES)
48
+ optional(:validity).maybe(:string, included_in?: VALIDITY_TYPES)
49
+ optional(:security_id).maybe(:string, max_size?: 20)
50
+ optional(:quantity).maybe(:integer, gt?: 0)
51
+ optional(:price).maybe(:float, gt?: 0)
52
+ optional(:iceberg_qty).maybe(:integer, gt?: 0)
53
+ optional(:disclosed_quantity).maybe(:integer, gteq?: 0)
54
+ optional(:trigger_price).maybe(:float, gteq?: 0)
55
+ optional(:after_market_order).maybe(:bool)
56
+ optional(:amo_time).maybe(:string, included_in?: AMO_TIMINGS)
57
+ end
58
+
59
+ rule do
60
+ modifiable_fields = %i[
61
+ quantity price iceberg_qty disclosed_quantity
62
+ trigger_price validity order_type product_type
63
+ ]
64
+ changed = modifiable_fields.any? { |field| values.key?(field) && !values[field].nil? }
65
+ base.failure("at least one modifiable field must be provided") unless changed
66
+ end
67
+
68
+ rule(:order_type, :price) do
69
+ key(:price).failure("cannot modify price for MARKET orders") if values[:order_type] == DhanHQ::Constants::OrderType::MARKET && values[:price]
70
+ end
71
+
72
+ rule(:order_type, :trigger_price) do
73
+ if %w[STOP_LOSS STOP_LOSS_MARKET].include?(values[:order_type]) && (values[:trigger_price].nil? || values[:trigger_price].to_f <= 0)
74
+ key(:trigger_price).failure("must be present and greater than zero for STOP_LOSS orders")
75
+ end
76
+ end
77
+
78
+ rule(:iceberg_qty) do
79
+ key.failure("must not exceed total quantity") if value && values[:quantity] && value > values[:quantity]
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_contract"
4
+
5
+ module DhanHQ
6
+ module Contracts
7
+ # Validates request for POST /v2/orders/twap (create TWAP order).
8
+ #
9
+ # TWAP orders slice the total quantity across the trading window at a fixed
10
+ # interval to minimize market impact and achieve time-weighted execution.
11
+ class TwapOrderCreateContract < BaseContract
12
+ params do
13
+ required(:dhan_client_id).filled(:string)
14
+ required(:transaction_type).filled(:string, included_in?: TRANSACTION_TYPES)
15
+ required(:exchange_segment).filled(:string, included_in?: EXCHANGE_SEGMENTS)
16
+ required(:product_type).filled(:string, included_in?: PRODUCT_TYPES)
17
+ required(:order_type).filled(:string, included_in?: ORDER_TYPES)
18
+ required(:validity).filled(:string, included_in?: VALIDITY_TYPES)
19
+ required(:security_id).filled(:string)
20
+ required(:quantity).filled(:integer, gt?: 0)
21
+ required(:price).filled(:float, gt?: 0)
22
+ required(:slice_interval).filled(:integer, gt?: 0)
23
+ required(:start_time).filled(:string, format?: /\A([01]?\d|2[0-3]):([0-5]?\d):([0-5]?\d)\z/)
24
+ required(:end_time).filled(:string, format?: /\A([01]?\d|2[0-3]):([0-5]?\d):([0-5]?\d)\z/)
25
+ optional(:correlation_id).maybe(:string, max_size?: 30, format?: /\A[a-zA-Z0-9 _-]*\z/)
26
+ optional(:trigger_price).maybe(:float, gteq?: 0)
27
+ optional(:after_market_order).maybe(:bool)
28
+ optional(:amo_time).maybe(:string, included_in?: AMO_TIMINGS)
29
+ optional(:drv_expiry_date).maybe(:string)
30
+ optional(:drv_option_type).maybe(:string, included_in?: %w[CALL PUT NA])
31
+ optional(:drv_strike_price).maybe(:float, gt?: 0)
32
+ end
33
+
34
+ rule(:start_time, :end_time) do
35
+ next unless values[:start_time] && values[:end_time]
36
+
37
+ start_minutes = time_to_minutes(values[:start_time])
38
+ end_minutes = time_to_minutes(values[:end_time])
39
+ key.failure("must be after start_time") if end_minutes <= start_minutes
40
+ end
41
+
42
+ rule(:slice_interval) do
43
+ next unless value&.positive?
44
+
45
+ start_minutes = time_to_minutes(values[:start_time])
46
+ end_minutes = time_to_minutes(values[:end_time])
47
+ window_minutes = end_minutes - start_minutes
48
+ key.failure("slice interval must fit within the execution window") if value > (window_minutes * 60)
49
+ end
50
+
51
+ def time_to_minutes(time_str)
52
+ parts = time_str.split(":").map(&:to_i)
53
+ (parts[0] * 60) + parts[1]
54
+ end
55
+ end
56
+
57
+ # Validates request for PUT /v2/orders/twap/{order-id} (modify TWAP order).
58
+ class TwapOrderModifyContract < BaseContract
59
+ params do
60
+ required(:dhan_client_id).filled(:string)
61
+ required(:order_id).filled(:string)
62
+
63
+ optional(:transaction_type).maybe(:string, included_in?: TRANSACTION_TYPES)
64
+ optional(:exchange_segment).maybe(:string, included_in?: EXCHANGE_SEGMENTS)
65
+ optional(:product_type).maybe(:string, included_in?: PRODUCT_TYPES)
66
+ optional(:order_type).maybe(:string, included_in?: ORDER_TYPES)
67
+ optional(:validity).maybe(:string, included_in?: VALIDITY_TYPES)
68
+ optional(:security_id).maybe(:string, max_size?: 20)
69
+ optional(:quantity).maybe(:integer, gt?: 0)
70
+ optional(:price).maybe(:float, gt?: 0)
71
+ optional(:slice_interval).maybe(:integer, gt?: 0)
72
+ optional(:start_time).maybe(:string, format?: /\A([01]?\d|2[0-3]):([0-5]?\d):([0-5]?\d)\z/)
73
+ optional(:end_time).maybe(:string, format?: /\A([01]?\d|2[0-3]):([0-5]?\d):([0-5]?\d)\z/)
74
+ optional(:trigger_price).maybe(:float, gteq?: 0)
75
+ optional(:after_market_order).maybe(:bool)
76
+ optional(:amo_time).maybe(:string, included_in?: AMO_TIMINGS)
77
+ end
78
+
79
+ rule do
80
+ modifiable_fields = %i[
81
+ quantity price slice_interval start_time end_time
82
+ trigger_price validity order_type product_type
83
+ ]
84
+ changed = modifiable_fields.any? { |field| values.key?(field) && !values[field].nil? }
85
+ base.failure("at least one modifiable field must be provided") unless changed
86
+ end
87
+
88
+ rule(:order_type, :price) do
89
+ key(:price).failure("cannot modify price for MARKET orders") if values[:order_type] == DhanHQ::Constants::OrderType::MARKET && values[:price]
90
+ end
91
+
92
+ rule(:start_time, :end_time) do
93
+ next unless values[:start_time] && values[:end_time]
94
+
95
+ start_minutes = time_to_minutes(values[:start_time])
96
+ end_minutes = time_to_minutes(values[:end_time])
97
+ key.failure("must be after start_time") if end_minutes <= start_minutes
98
+ end
99
+
100
+ def time_to_minutes(time_str)
101
+ parts = time_str.split(":").map(&:to_i)
102
+ (parts[0] * 60) + parts[1]
103
+ end
104
+ end
105
+ end
106
+ end
@@ -13,7 +13,6 @@ module DhanHQ
13
13
  def connection
14
14
  @connection ||= Faraday.new(url: BASE_URL) do |faraday|
15
15
  faraday.request :url_encoded
16
- faraday.response :json, content_type: /\bjson$/
17
16
  faraday.adapter Faraday.default_adapter
18
17
  end
19
18
  end
data/lib/DhanHQ/errors.rb CHANGED
@@ -53,6 +53,10 @@ module DhanHQ
53
53
  # Raised when the API signals an issue with the requested data payload.
54
54
  class DataError < Error; end
55
55
 
56
+ # Risk management errors
57
+ # Raised when a pre-execution risk check fails (ASM/GSM, quantity, market hours, etc.)
58
+ class RiskViolation < Error; end
59
+
56
60
  # Server and network-related errors
57
61
  # DH-908, 800
58
62
  class InternalServerError < Error; end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ # Event types for the trading system.
5
+ #
6
+ # Provides typed event classes for order lifecycle, market data,
7
+ # position updates, and strategy signals.
8
+ #
9
+ # @example Subscribe to order events
10
+ # DhanHQ::Events.on(:order_filled) do |event|
11
+ # puts "Order filled: #{event.order_id}"
12
+ # end
13
+ #
14
+ module Events
15
+ # Base event class with common attributes.
16
+ class Base
17
+ attr_reader :timestamp, :data
18
+
19
+ def initialize(data = {})
20
+ @timestamp = Time.now
21
+ @data = data
22
+ end
23
+
24
+ def to_h
25
+ {
26
+ event_type: self.class.name.split("::").last.downcase.to_sym,
27
+ timestamp: timestamp,
28
+ data: data
29
+ }
30
+ end
31
+
32
+ def to_prompt
33
+ "#{self.class.name.split("::").last}: #{data.inspect}"
34
+ end
35
+ end
36
+
37
+ # Order placed event.
38
+ class OrderPlaced < Base
39
+ def order_id
40
+ data[:order_id]
41
+ end
42
+
43
+ def to_s
44
+ "OrderPlaced(#{order_id})"
45
+ end
46
+ end
47
+
48
+ # Order filled event.
49
+ class OrderFilled < Base
50
+ def order_id
51
+ data[:order_id]
52
+ end
53
+
54
+ def filled_quantity
55
+ data[:filled_quantity]
56
+ end
57
+
58
+ def filled_price
59
+ data[:filled_price]
60
+ end
61
+
62
+ def to_s
63
+ "OrderFilled(#{order_id}, #{filled_quantity}@#{filled_price})"
64
+ end
65
+ end
66
+
67
+ # Order cancelled event.
68
+ class OrderCancelled < Base
69
+ def order_id
70
+ data[:order_id]
71
+ end
72
+
73
+ def reason
74
+ data[:reason]
75
+ end
76
+
77
+ def to_s
78
+ "OrderCancelled(#{order_id}, reason=#{reason})"
79
+ end
80
+ end
81
+
82
+ # Order rejected event.
83
+ class OrderRejected < Base
84
+ def order_id
85
+ data[:order_id]
86
+ end
87
+
88
+ def error_code
89
+ data[:error_code]
90
+ end
91
+
92
+ def error_message
93
+ data[:error_message]
94
+ end
95
+
96
+ def to_s
97
+ "OrderRejected(#{order_id}, #{error_code}: #{error_message})"
98
+ end
99
+ end
100
+
101
+ # Stop loss hit event.
102
+ class SLHit < Base
103
+ def order_id
104
+ data[:order_id]
105
+ end
106
+
107
+ def trigger_price
108
+ data[:trigger_price]
109
+ end
110
+
111
+ def to_s
112
+ "SLHit(#{order_id}, trigger=#{trigger_price})"
113
+ end
114
+ end
115
+
116
+ # Take profit hit event.
117
+ class TPHit < Base
118
+ def order_id
119
+ data[:order_id]
120
+ end
121
+
122
+ def target_price
123
+ data[:target_price]
124
+ end
125
+
126
+ def to_s
127
+ "TPHit(#{order_id}, target=#{target_price})"
128
+ end
129
+ end
130
+
131
+ # Market data tick event.
132
+ class TickUpdated < Base
133
+ def security_id
134
+ data[:security_id]
135
+ end
136
+
137
+ def ltp
138
+ data[:ltp]
139
+ end
140
+
141
+ def volume
142
+ data[:volume]
143
+ end
144
+
145
+ def to_s
146
+ "TickUpdated(#{security_id}, ltp=#{ltp})"
147
+ end
148
+ end
149
+
150
+ # Position opened event.
151
+ class PositionOpened < Base
152
+ def security_id
153
+ data[:security_id]
154
+ end
155
+
156
+ def quantity
157
+ data[:quantity]
158
+ end
159
+
160
+ def side
161
+ data[:side]
162
+ end
163
+
164
+ def to_s
165
+ "PositionOpened(#{security_id}, #{side} #{quantity})"
166
+ end
167
+ end
168
+
169
+ # Position closed event.
170
+ class PositionClosed < Base
171
+ def security_id
172
+ data[:security_id]
173
+ end
174
+
175
+ def profit_loss
176
+ data[:profit_loss]
177
+ end
178
+
179
+ def to_s
180
+ "PositionClosed(#{security_id}, pnl=#{profit_loss})"
181
+ end
182
+ end
183
+
184
+ # Strategy signal event.
185
+ class StrategySignal < Base
186
+ def strategy_name
187
+ data[:strategy_name]
188
+ end
189
+
190
+ def signal_type
191
+ data[:signal_type]
192
+ end
193
+
194
+ def strength
195
+ data[:strength]
196
+ end
197
+
198
+ def to_s
199
+ "StrategySignal(#{strategy_name}, #{signal_type}, strength=#{strength})"
200
+ end
201
+ end
202
+ end
203
+ end